docs: "public repo" does not mean "readable CI" — the Actions API always needs a token
Lint / docs-check (push) Successful in 6s
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 1m6s

The Gitea-token section claimed "Public-repo GET listings work unauthenticated,
so the token matters mainly for private repos or rate-limit headroom", while the
same paragraph recommends the token for "inspecting CI runs". Those two
statements are incompatible, and the optimistic one is wrong.

Measured 2026-08-15 on gitea.jordbo.se against joakimp/pi-devbox (private=false):

  GET /api/v1/repos/joakimp/pi-devbox                     -> 200
  GET /api/v1/repos/joakimp/mempalace-toolkit/tags        -> 200
  GET /api/v1/repos/joakimp/mempalace-toolkit/commits     -> 200
  GET /api/v1/repos/joakimp/pi-devbox/actions/runs        -> 401 "token is required"

So the unauthenticated claim holds for repo metadata, tags, and commit listings
(which is what the resolve-versions mempalace-toolkit note above relies on, and
that note stays correct), but NOT for any /actions/* endpoint. Narrowed the claim
rather than deleting it. Note the workflow comment in pi-devbox
.gitea/workflows/docker-publish.yml ("Gitea API requires auth even for
public-repo commit listing") is wrong in the opposite direction — harmless, since
it passes auth anyway, but it is not evidence for the strong claim.

This cost a real detour while triaging the v1.8.0 smoke failure: repo -> 200 was
read as "public, so CI is readable", then actions/runs 401'd.

Also recorded two things learned in the same session:

- The env GITEA_ACCESS_TOKEN was REVOKED, not stale. Proved it by comparing
  sha256 fingerprints (not values) against the host's compose .env: identical
  fingerprint, and 401 from the host too. Container env is baked at START, so a
  host-side renewal is invisible until recreate. Added /api/v1/version as the
  cheap "is this token alive at all" probe.

- With no usable token, Docker Hub tags localise a CI failure, because smoke
  gates build-variant. A fresh base-<hash> with no vX.Y.Z and latest still on the
  previous release == "base built, smoke failed, nothing shipped". That is how
  v1.8.0 was diagnosed before a token existed; the logs later confirmed it
  exactly, including the corollary that a failed (not skipped) smoke proves
  GITEA_BUILD_TOKEN was alive in CI.
This commit is contained in:
Joakim Persson
2026-08-15 12:21:32 +02:00
parent 13219d1d55
commit 69fc80aca0
+40 -5
View File
@@ -148,11 +148,46 @@ Gitea API interaction from inside the container — inspecting CI runs,
checking published tags, listing commits — e.g. checking published tags, listing commits — e.g.
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/opencode-devbox/actions/runs?limit=5"`. `curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/opencode-devbox/actions/runs?limit=5"`.
Prefer this over a short-lived PAT file when the env token is present (the Prefer this over a short-lived PAT file when the env token is present (the
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work `ci-release-watcher` skill auto-detects it). Never echo the token value
unauthenticated (see the `resolve-versions` mempalace-toolkit note above), so (including into logs).
the token matters mainly for private repos or rate-limit headroom; its
lifecycle is host-managed, so there is nothing to revoke after use. Never **Gotcha — "public repos need no token" does NOT extend to the Actions API.**
echo the token value (including into logs). Repo metadata, tags, and commit listings on a public repo are readable
unauthenticated (measured 2026-08-15 on `gitea.jordbo.se`: `GET /repos/{o}/{r}`,
`/tags`, `/commits?limit=1&sha=main` → all `200`; this is what the
`resolve-versions` mempalace-toolkit note above relies on). But **every
`/actions/*` endpoint returns `401 {"message":"token is required"}` even when
`private=false`** — including `/actions/runs`, `/actions/runs/<id>/jobs`, and
`/actions/jobs/<id>/logs`. So the token is *mandatory* for exactly the use case
this section opens with (inspecting CI runs), not merely "nice for private repos
or rate-limit headroom". Cost a wasted detour once: `repo → 200` was read as
"public, so CI is readable", which it is not.
**Gotcha — the env token can be silently REVOKED, and renewing the host `.env`
does not reach a running container.** The container's environment is baked at
**start**, so a host-side token renewal is invisible until the container is
recreated (or the value is re-read explicitly over SSH). Worse, a revoked token
looks identical to a valid one — the failure only shows up as `401` per request.
Verify before concluding anything about a repo's visibility:
`curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/version"` → `200` means the token
itself is alive. To tell staleness from revocation, compare fingerprints rather
than values: `printf '%s' "$GITEA_ACCESS_TOKEN" | sha256sum` against
`shasum -a 256` of the token in the host's compose `.env` — identical
fingerprints that both `401` mean the token is revoked upstream, not stale in
the container.
**Workaround — with no usable token, Docker Hub tags localise a CI failure.**
The job graph makes registry state a progress oracle, because `smoke` *gates*
`build-variant` and `build-variant` gates `promote-base-latest`. Read
`https://hub.docker.com/v2/repositories/<ns>/<img>/tags?page_size=100` (public,
no auth) and compare timestamps: a fresh `base-<hash>` with **no** `vX.Y.Z` tag
and `latest` still on the previous release pinpoints "base built, smoke failed,
nothing shipped" without reading a single log line. Diagnosed v1.8.0 this way
before a token was available; the log later confirmed it exactly. A second free
deduction from the same graph: since `smoke`'s `if:` requires
`needs.resolve-versions.result == 'success'`, a *failed* smoke proves
`GITEA_BUILD_TOKEN` was alive in CI — a dead build token would have **skipped**
smoke (via `require_sha`), not failed it.
**Gotcha — the jobs endpoint takes the internal `id`, NOT the `run_number` the **Gotcha — the jobs endpoint takes the internal `id`, NOT the `run_number` the
UI shows as `#239`.** The two diverge widely (`id=534` was `run_number=238`), UI shows as `#239`.** The two diverge widely (`id=534` was `run_number=238`),