From ae13c2264eec61196cc783d8d79da952533accfc Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Sat, 15 Aug 2026 14:27:18 +0200 Subject: [PATCH] ci: survive a revoked GITEA_BUILD_TOKEN on public commit reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to a2f0a4a, which documented the hazard; this removes it. resolve-versions read three PUBLIC Gitea repos with `curl -sf -H "$AUTH_HEADER"`. Gitea rejects an invalid token rather than ignoring it, so the token turned a read that works anonymously into a hard failure: no Authorization header 200 empty token (secret unset) 200 <- absent secret was always safe garbage/revoked token 401 <- stale secret broke the release A revoked GITEA_BUILD_TOKEN therefore failed resolve-versions via require_sha, presenting as connectivity or an API fault, on data any anonymous client could fetch. Hit exactly that failure mode today with an expired PAT. New gitea_sha() helper tries authed, and on 401/403 retries anonymously with a loud stderr warning naming the token as the cause. Deliberate choices: - non-200 after the retry emits nothing and returns 0, so require_sha still raises the explicit abort — the helper never invents a fallback ref, which is the property the surrounding code exists to guarantee - warnings go to stderr, NOT as ::warning:: annotations: the function's stdout IS the SHA, so an annotation there would be captured into the ref - the header is still sent first, so a private repo keeps working Verified by extracting the function from the YAML step body (so the test ran the committed text, not a copy) and calling it against live Gitea: valid token -> 0e1369e6b496 (pi-toolkit) REVOKED token -> warns, retries anon, f60cf9c73205 (mempalace-toolkit) unset secret -> 98eb07bce60a (pi-extensions) nonexistent repo -> empty + HTTP 404 warning, so require_sha aborts Behaviour unchanged on the happy path: all three SHAs are byte-identical to the ones the v1.8.1 release run resolved with the old curl code. `bash -n` clean on the extracted step body; YAML re-parsed. --- .gitea/workflows/docker-publish.yml | 46 +++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index d064ec7..281b383 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -177,6 +177,40 @@ jobs: fi } + # Read a commit SHA from Gitea, surviving a bad build token. + # + # These repos are public (see the note at the call sites), so auth is + # a convenience, not a requirement — but Gitea REJECTS an invalid + # token (401) rather than ignoring it, so a revoked or malformed + # GITEA_BUILD_TOKEN could fail an entire release on reads that work + # fine anonymously. An ABSENT secret was always safe (Gitea ignores an + # empty `token ` value and serves the request, 200); a STALE one was + # not. So: try authed, and on 401/403 retry anonymously. + # + # A non-200 after that emits nothing and returns 0 deliberately, so + # require_sha raises the loud explicit abort rather than this helper + # inventing a fallback ref. + # + # Messages go to STDERR, not as ::warning:: annotations: this + # function's stdout IS the SHA, so anything written there would be + # captured into the ref by the command substitution. + gitea_sha() { # $1=repo + local repo="$1" url resp code + url="https://gitea.jordbo.se/api/v1/repos/joakimp/${repo}/commits?limit=1&sha=main" + resp=$(curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$url" || printf '\n000') + code=${resp##*$'\n'} + if [ "$code" = "401" ] || [ "$code" = "403" ]; then + printf 'WARNING: Gitea rejected the build token for %s (HTTP %s); retrying anonymously. The read should succeed (public repo), but GITEA_BUILD_TOKEN is stale or malformed and should be rotated.\n' "$repo" "$code" >&2 + resp=$(curl -s -w '\n%{http_code}' "$url" || printf '\n000') + code=${resp##*$'\n'} + fi + if [ "$code" != "200" ]; then + printf 'WARNING: Gitea commit lookup for %s returned HTTP %s\n' "$repo" "$code" >&2 + return 0 + fi + printf '%s' "${resp%$'\n'*}" | jq -r '.[0].sha // empty' 2>/dev/null || true + } + # ── pi version: from the PIN, not from npm `latest` ─────────── # Until v1.7.0 this followed npm `latest`, which meant every release # silently adopted whatever pi had shipped that morning — unaudited — @@ -256,13 +290,9 @@ jobs: # require_sha failure that reads like an API or network fault. If this # step ever fails on a repo you can browse anonymously, suspect the # token before you suspect Gitea. - TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \ - "https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \ - | jq -r '.[0].sha // empty' 2>/dev/null || true) + TOOLKIT_REF=$(gitea_sha pi-toolkit) require_sha PI_TOOLKIT_REF "$TOOLKIT_REF" - EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \ - "https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \ - | jq -r '.[0].sha // empty' 2>/dev/null || true) + EXTENSIONS_REF=$(gitea_sha pi-extensions) require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF" echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT" @@ -272,9 +302,7 @@ jobs: # into the base-decide hash (see that job) to force a base rebuild # when the toolkit moves — otherwise a toolkit-only fix silently # fails to land unless Dockerfile.base itself changes. - MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \ - "https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \ - | jq -r '.[0].sha // empty' 2>/dev/null || true) + MEMPALACE_TOOLKIT_REF=$(gitea_sha mempalace-toolkit) require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF" echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"