From 8d522e63cc6b82989af4b682d754734808a751ba Mon Sep 17 00:00:00 2001 From: Chris Shuttlesworth Date: Tue, 4 Aug 2026 09:23:37 -0400 Subject: [PATCH] feat: optional ghcr-token for user-owned packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real release failed with "ghcr.io/cshuttle/topology:latest does not exist" — for an image whose digest reads fine from a workstation. The registry returns the same not-found for an unauthorised read, and cshuttle is a personal account: a package bootstrapped by a manual push is USER-owned, so the repo-scoped GITHUB_TOKEN 403s on it. Topology's own build workflow has used a classic PAT for exactly this reason since July; the release path had no way to. Adds an optional ghcr-token secret, used for the crane login when supplied. Also rewrites the error, which confidently blamed absence for what is almost always auth and would have sent the next person hunting for a missing tag. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-image.yml | 36 ++++++++++++++++++++++++++--- README.md | 9 ++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index 25fe7f7..c72845b 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -33,6 +33,10 @@ # summary: ${{ inputs.summary }} # images: ghcr.io/cshuttle/topology # runner: arc-topology +# secrets: +# # Only if the repo pushes with a classic PAT — see the secret's +# # description. Omit and GITHUB_TOKEN is used. +# ghcr-token: ${{ secrets.GHCR_WRITE_TOKEN }} # # NOT covered: repos whose artifact is a compiled binary rather than an image. # There is exactly one of those (chrome-exporter) and its build is nothing like @@ -111,6 +115,17 @@ on: type: string default: v0.21.7 required: false + secrets: + ghcr-token: + description: >- + Classic PAT with write:packages, for a package the repo-scoped + GITHUB_TOKEN cannot touch. `cshuttle` is a personal account, so a + package bootstrapped by a manual push is USER-owned: GITHUB_TOKEN 403s + on it — including on reads — unless that package grants this repo + access under Package settings -> Manage Actions access. Repos that + push with a classic PAT must pass the same one here, or the promote + step reports the image as missing when it is really unreadable. + required: false outputs: tag: description: The git tag created. @@ -182,7 +197,18 @@ jobs: /tmp/crane version - name: log in to ghcr - run: echo "${{ github.token }}" | /tmp/crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin + env: + GHCR_TOKEN: ${{ secrets.ghcr-token }} + GHCR_USER: ${{ github.repository_owner }} + run: | + set -eu + if [ -n "${GHCR_TOKEN:-}" ]; then + printf '%s' "$GHCR_TOKEN" | /tmp/crane auth login ghcr.io -u "$GHCR_USER" --password-stdin + echo "authenticated to ghcr as $GHCR_USER with the supplied token" + else + printf '%s' "${{ github.token }}" | /tmp/crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin + echo "authenticated to ghcr with GITHUB_TOKEN" + fi # The heart of it: work out which commit the image actually came from, # prove it belongs to this branch, then move the version tag onto that @@ -211,8 +237,12 @@ jobs: for img in $IMAGES; do echo "── $img" - if ! digest="$(/tmp/crane digest "$img:$SOURCE_TAG" 2>/dev/null)"; then - echo "::error::$img:$SOURCE_TAG does not exist — nothing to promote." + if ! digest="$(/tmp/crane digest "$img:$SOURCE_TAG" 2>&1)"; then + echo "::error::cannot read $img:$SOURCE_TAG — crane said: $digest" + echo "::error::If the tag plainly exists, this is almost certainly AUTH, not absence:" + echo "::error::a user-owned ghcr package returns the same 'not found' for an unauthorised" + echo "::error::read. Pass the repo's classic PAT as the ghcr-token secret, or grant this" + echo "::error::repo access under the package's Manage Actions access settings." exit 1 fi echo " $SOURCE_TAG = $digest" diff --git a/README.md b/README.md index 6b40a5e..821cce8 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,9 @@ jobs: summary: ${{ inputs.summary }} images: ghcr.io/cshuttle/topology runner: arc- + secrets: + # Only for a package GITHUB_TOKEN cannot read — see below. Omit otherwise. + ghcr-token: ${{ secrets.GHCR_WRITE_TOKEN }} ``` - **Promotes, never rebuilds.** A rebuild on the tag produces a second digest @@ -223,6 +226,12 @@ jobs: the image tag: `/` is not legal in a docker tag. - Notes are always GitHub-generated; `summary` is pre-pended when supplied, and the promoted digests are listed under it. +- **`ghcr-token` when the package is user-owned.** On a personal account a + package bootstrapped by a manual push is owned by the user, not the repo, and + `GITHUB_TOKEN` gets 403 on it — including on reads, which the registry reports + as a plain "not found". A repo that pushes with a classic PAT must pass the + same PAT here. The alternative is granting the repo access under the package's + *Manage Actions access* settings, after which the secret can be dropped. ## Git hooks