From d6553a7a05a91181d7c71cd83ee88060bfe0630b Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 1 Aug 2026 10:16:32 -0700 Subject: [PATCH] build-push: stop a whitespace-tainted REGISTRY_TOKEN failing as "denied" `docker login --password-stdin` strips exactly ONE trailing newline. `echo "$T"` adds one. So a secret pasted WITH a trailing newline -- which is what copying from a terminal, or an editor that terminates files with one, produces -- arrives at the registry as "\n" and gets back: Error response from daemon: Get "https://.../v2/": denied: with no further detail. That is indistinguishable from a revoked or wrong token. On 2026-08-01 it stopped every build and deploy in the estate and cost an afternoon of diagnosis on a credential that was in fact valid: the same token, tested by hand, returned 200 on /v2/ and was issued a pull,push-scoped registry token for jinemi/thermograph/backend. So: strip leading/trailing whitespace and CR/LF before the pipe, and never `echo` a credential into stdin. The token now travels through the ENVIRONMENT rather than being interpolated into the script text. `${{ }}` is substituted before bash parses the line, so a value containing a quote or a newline changes the shape of the command itself, not merely its arguments. Two diagnostics, deliberately asymmetric: * empty/unset -> HARD FAILURE naming where to set it. There is no case where proceeding helps. * wrong shape -> WARNING only (length, and whether it is outside [0-9a-f]), then attempt the login anyway. A hard assertion on token format would block every build the day Forgejo changes that format, which is a worse failure than the one being prevented. The warning is enough to turn the registry's opaque "denied:" into a diagnosis. Neither diagnostic prints the value; only its length and character class. Note the username is not a factor: tested against the live registry, all of admin_emi, emi and jinemi authenticate identically with a valid token and are each issued a push-scoped token. Forgejo's container registry authenticates on the token, not the username. --- .forgejo/workflows/build-push.yml | 38 ++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-push.yml b/.forgejo/workflows/build-push.yml index 31ffd9a..7a81ba5 100644 --- a/.forgejo/workflows/build-push.yml +++ b/.forgejo/workflows/build-push.yml @@ -110,12 +110,48 @@ jobs: - name: Log in to the Forgejo registry if: steps.image.outputs.changed == 'true' + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | # NOT secrets.GITHUB_TOKEN -- Forgejo's per-job auto-injected token can # never push to the container registry (a known Forgejo limitation, # independent of any permission setting). Needs a manually provisioned # PAT with write:package scope, stored as REGISTRY_TOKEN. - echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ steps.image.outputs.host }}" \ + # + # WHITESPACE IS THE FAILURE THIS GUARDS AGAINST, and it is a nasty one. + # `docker login --password-stdin` strips exactly ONE trailing newline. + # `echo "$T"` adds one. So a secret pasted WITH a trailing newline -- + # which is what happens if you copy from a terminal or an editor that + # ends files with one -- arrives as "\n" and the registry answers + # + # Error response from daemon: Get "https://.../v2/": denied: + # + # with no further detail. That is indistinguishable from a revoked or + # wrong token, and it cost an afternoon on 2026-08-01 chasing a + # credential that was in fact valid. Strip first, echo never. + # + # The token also goes through the ENVIRONMENT rather than being + # interpolated into the script text. `${{ }}` is substituted before bash + # parses the line, so a value containing a quote or newline would change + # the shape of the command itself rather than just its arguments. + tok=$(printf '%s' "$REGISTRY_TOKEN" | tr -d ' \t\r\n') + if [ -z "$tok" ]; then + echo "::error::REGISTRY_TOKEN is empty or unset. Set it at" \ + "Settings -> Actions -> Secrets on this repository." + exit 1 + fi + # Shape check is a WARNING, not a failure: a hard assertion on the token + # format would block every build the day Forgejo changes it. But saying + # so up front turns the registry's opaque "denied:" into a diagnosis. + # Length and character class only -- the value is never printed. + case "$tok" in + *[!0-9a-f]*) echo "::warning::REGISTRY_TOKEN contains characters outside" \ + "[0-9a-f]; a Forgejo PAT is 40 hex characters. If the" \ + "login below fails, re-paste the secret." ;; + esac + [ "${#tok}" -eq 40 ] || echo "::warning::REGISTRY_TOKEN is ${#tok} characters," \ + "expected 40. If the login below fails, re-paste the secret." + printf '%s' "$tok" | docker login "${{ steps.image.outputs.host }}" \ --username admin_emi --password-stdin - name: Build