From 7355873c39549722dc043e9c4f01022675b30ada Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 4 Sep 2026 23:26:07 +0200 Subject: [PATCH 01/31] fix(885): a head-authored run may hold no stored secret, so the PR route reads the registry and the status API anonymously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that run executes contributor-authored YAML and every `secrets.*` it names is materialised into it. Six jobs held `REGISTRY_PASSWORD` that way — `toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — two of them branch-protection required contexts. The read-only pull PAT the issue asked to cost first was REJECTED, and the measurement is the reason: this registry already issues an anonymous pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200), that token reads the pinned manifest and its config blob (200/200), and the combined-status GET answers 200 unauthenticated. A read-only PAT would grant exactly what anonymity grants while adding one more credential to the store head-supplied YAML reaches. So the stronger form was implemented instead: no PR-route job names a stored secret at all. - `.gitea/workflows/docker-build.yml`: the five `container: credentials:` blocks, the `ETV_REGISTRY_AUTH` step env and the three `ETV_STATUS_AUTH` step envs are gone. `build` keeps the PAT; it is gated `if: github.event_name != 'pull_request'`. - `scripts/ci-toolchain-image-resolves.sh`: reads `realm` out of the `Www-Authenticate` challenge, exchanges it once per run for an anonymous pull token, retries with the bearer. Every refusal direction is preserved — a 401/403 after the token leg, a token endpoint yielding no token, and one that cannot be reached all `fail` rather than degrading to could-not-tell — and the message now names the cause an operator can act on (the repo or package has stopped being public). - `scripts/ci-detect-already-validated.sh`: the status GET is anonymous. No credential override is kept: the URL names one instance, that instance is public, and an unusable `":"` would draw a 401 and turn a working read into a permanent skip=false. - `scripts/tests/test_workflow_persist_credentials.py`: the invariant, derived from the git index by "every job of a `pull_request`-triggered workflow that names a `secrets.*`" — never the six-name list, and never "every `container:` job", which names five of six because `toolchain-preflight` is container-free. Witnessed red against the unfixed workflow naming all six jobs; green after. Live tag protection applied and read back: `POST /repos/timothy/ersatztv/tag_protections` `{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}` -> id 1. A non-`v*` probe tag pushed and deleted proves tag pushes still work at all. The POSITIVE release-cut verification is DEFERRED to the operator's next real cut: pushing a `v*` tag publishes the `:prod` image, which is a release, not a verification step. What this does not close, stated so the records are not cited as a boundary: `REGISTRY_PASSWORD` stays in the Actions store for `build`, and head YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY`. Blast radius, not the route. New records `ci.pr-route-carries-no-stored-credential` and `release.tag-protection-v-star`; `ci.workflow-dispatch-ref-unrestricted`, `ci.actions-credential-scoping` and `release.main-direct-push-disabled` updated to match; catalog regenerated. Closes #885. Decisions-Edit: yes Proves: scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .gitea/workflows/docker-build.yml | 44 +-- docs/ci-cd.md | 80 +++-- docs/decisions/README.md | 8 +- .../records/ci/actions-credential-scoping.md | 14 +- .../pr-route-carries-no-stored-credential.md | 59 ++++ .../ci/workflow-dispatch-ref-unrestricted.md | 42 +-- .../release/main-direct-push-disabled.md | 14 +- .../records/release/tag-protection-v-star.md | 34 ++ scripts/ci-detect-already-validated.sh | 23 +- scripts/ci-toolchain-image-resolves.sh | 142 ++++++--- .../tests/test_ci_toolchain_image_resolves.py | 227 ++++++++++---- scripts/tests/test_pr_changed_files.py | 12 +- .../test_workflow_persist_credentials.py | 296 ++++++++++++++++++ 13 files changed, 793 insertions(+), 202 deletions(-) create mode 100644 docs/decisions/records/ci/pr-route-carries-no-stored-credential.md create mode 100644 docs/decisions/records/release/tag-protection-v-star.md diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index ff22cd1a6..bdfda707e 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -120,13 +120,20 @@ env: # is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on. # Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full # rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope". -# Every credentialed thing this file does uses the scoped REGISTRY_* PAT, never the injected token: -# its registry pushes, its five `container:` image pulls, its three commit-status GET steps -# (`ETV_STATUS_AUTH` in jobs `test`, `migrations` and `functional-e2e`, each a read-only GET via -# scripts/ci-detect-already-validated.sh) and its registry tag READ (`ETV_REGISTRY_AUTH` in job -# `toolchain-preflight`, via scripts/ci-toolchain-image-resolves.sh). The injected token therefore -# serves only its eight `actions/checkout` steps. Note this file needs no `packages:` unit for that -# same reason: the `container:` blocks carry explicit `credentials:`. +# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885, +# `ci.pr-route-carries-no-stored-credential`). Gitea resolves a `pull_request` run from the PR HEAD, +# so this file is attacker-authored on that route and every `secrets.*` it names is materialised +# into the run. The scoped REGISTRY_* PAT is therefore held by `build` alone, which is gated +# `if: github.event_name != 'pull_request'`. The jobs that used to hold it now work without it: +# the five `container:` pulls and `toolchain-preflight`'s registry tag READ go through the registry's +# anonymous bearer-token flow, and the three commit-status GETs +# (scripts/ci-detect-already-validated.sh in `test`, `migrations` and `functional-e2e`) read the +# combined-status API unauthenticated. Both depend on `timothy/ersatztv` and its `ersatztv-ci` +# package staying PUBLIC; making either private fails those jobs loudly at image pull, never +# silently. The invariant is held by +# scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret. +# The injected token serves only this file's eight `actions/checkout` steps. Note it needs no +# `packages:` unit: the container pulls are anonymous, not token-authenticated. # (Sites above are named by JOB, not by line number: this file is ~1150 lines, so any edit above a # citation silently invalidates it — a line-number citation here has gone stale within two lines # of being written.) @@ -150,8 +157,6 @@ jobs: with: persist-credentials: false - name: Resolve the pinned toolchain tag in the registry - env: - ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve scripts/ci-toolchain-image-resolves.sh @@ -165,9 +170,6 @@ jobs: runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} env: CI_EXECUTION_CLASS: toolchain CI_JOB_ROLE: guard @@ -195,8 +197,6 @@ jobs: scripts/ci-detect-docs-only.sh - name: Detect already-validated tree (#420) id: revalidate - env: - ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate scripts/ci-detect-already-validated.sh @@ -389,9 +389,6 @@ jobs: runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} # Independent gate (not a 'needs' of build yet) so the new MySql-service dependency # can't block image builds until it's proven reliable on the runner. Promote to a # required check / build dependency once green. (ersatztv#13) @@ -468,8 +465,6 @@ jobs: scripts/ci-detect-docs-only.sh - name: Detect already-validated tree (#420) id: revalidate - env: - ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: | "${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate scripts/ci-detect-already-validated.sh @@ -611,9 +606,6 @@ jobs: if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} env: CI_EXECUTION_CLASS: toolchain CI_JOB_ROLE: guard @@ -632,8 +624,6 @@ jobs: run: scripts/ci-detect-docs-only.sh - name: Detect already-validated tree (#420) id: revalidate - env: - ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} run: scripts/ci-detect-already-validated.sh - name: Cache NuGet packages @@ -1044,9 +1034,6 @@ jobs: runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} if: github.event_name == 'pull_request' env: CI_EXECUTION_CLASS: toolchain @@ -1149,9 +1136,6 @@ jobs: runs-on: ubuntu-latest container: image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0 - credentials: - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} if: github.event_name == 'pull_request' env: CI_EXECUTION_CLASS: toolchain diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 4e5e0a572..6f96d8af0 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -170,9 +170,13 @@ grants `releases: write`, which nothing here uses. The two read units are not sp `/issues/{n}/timeline` (the ersatztv#706 retarget fence) and, through `scripts/pr-changed-files.sh`, `/pulls/{n}` and `/pulls/{n}/files` (the ersatztv#698 exemption path). -No workflow needs a `packages:` unit. All five `container:` blocks in `docker-build.yml` carry -explicit `credentials:` using the scoped `REGISTRY_*` PAT, so no job pulls the toolchain image with -the injected token. +No workflow needs a `packages:` unit. Since ersatztv#885 the five `container:` blocks in +`docker-build.yml` carry no `credentials:` at all: they run on the `pull_request` route, so they may +hold no stored secret, and the runner pulls the toolchain image ANONYMOUSLY — this registry issues a +pull token for a public package with no credential (measured at the HTTP level 2026-09-04; see +`ci.pr-route-carries-no-stored-credential`, whose `mechanics:` also names the one thing that +measurement does NOT establish, act_runner's own pull). So no job pulls that image with the injected +token, and none pulls it with the `REGISTRY_*` PAT either. ### The binding was measured, not assumed (2026-08-27, Gitea 1.27.1) @@ -736,7 +740,11 @@ the image build. for `192.168.1.95:3000` — **BuildKit does not inherit the host daemon's `insecure-registries`**, so without this, cache/base-image/push over the HTTP registry fails (`http: server gave HTTP response to HTTPS client`). -3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`. +3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`. **`build` is the + ONLY job in this workflow that names them** (ersatztv#885, + `ci.pr-route-carries-no-stored-credential`): it is gated `if: github.event_name != 'pull_request'`, + and every job that IS on the PR route now reads the registry and the status API anonymously, + because a `pull_request` run executes head-supplied YAML. **`REGISTRY_PASSWORD` is a scoped PAT (`write:package` + `read:repository`), not an account password** — deliberately, so head-resolved PR code cannot use it to forge a commit status (`ci.actions-credential-scoping`, ersatztv#697). If a job ever fails with `token does not have at @@ -901,8 +909,11 @@ the docs-only detect), and every heavy step gains an added `&& steps.revalidate. squash, rebase, fast-forward, or a direct push have no `HEAD^2`, so they run); - `git rev-parse HEAD^{tree}` equals `HEAD^2^{tree}` — main did not advance since the PR's last run, a byte-identical tree; -- `HEAD^2` has a **green Gitea combined commit status**, queried via the API with - `ETV_STATUS_AUTH`. Trusting the aggregate `.state` is sound: a `skipped` context does **not** drag +- `HEAD^2` has a **green Gitea combined commit status**, queried via the API **anonymously** + (ersatztv#885: the three jobs that call `scripts/ci-detect-already-validated.sh` also run on the + `pull_request` route, so they hold no stored secret; `timothy/ersatztv` is public and answers the + combined-status GET without a credential — measured 2026-09-04). Trusting the aggregate `.state` + is sound: a `skipped` context does **not** drag the combined state below `success` (verified live against this instance — a real merge commit with four `skipped` PR-only contexts still reported `.state == success`), and the two required jobs never report `skipped` (they always run and report a real `success`/`failure`), so @@ -1432,8 +1443,11 @@ re-opens the hole this section exists to close. Practical consequences: **every** change to `main` goes through a PR, including a one-line docs fix; and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control — -they are fail-open and `--no-verify` bypasses them. Tag pushes are unaffected (separate mechanism; -`tag_protections` is empty), so the release cut in "Cutting a release" still works unchanged. +they are fail-open and `--no-verify` bypasses them. Tag pushes go through a separate mechanism and +are unaffected for the release operator: since ersatztv#885 `tag_protections` carries one rule, +`v*` whitelisted to `timothy` (`release.tag-protection-v-star`), so the release cut in "Cutting a +release" still works unchanged while the `renovate` bot can no longer push a `v*` tag — which would +otherwise have published a `:prod` image. **A fourth arm: guard-scope freshness (ersatztv#787).** `scripts/tests/test_ci_dropped_step_guard.py` decides which CI jobs must carry per-step execution markers by DERIVING that scope from @@ -1919,8 +1933,10 @@ as establishing that the gate cannot be forged (see the residual below, and ersa `docs/decisions/records/ci/gate-trigger-base-resolved.md`. **This closes the rewrite route through this workflow, not the class:** `docker-build.yml` is also head-resolved and must stay on `pull_request` because it builds the PR's code, so it got the read-only status identity instead — - its `ETV_STATUS_AUTH` is now a PAT scoped `write:package` + `read:repository`, which the status - endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). The inventory was never that one + the `ETV_STATUS_AUTH` it passed was a PAT scoped `write:package` + `read:repository`, which the + status endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). Since ersatztv#885 it + passes no credential there at all: the status read is anonymous, so the PR route materialises + nothing to refuse. The inventory was never that one workflow, though: Gitea injects a write-capable `GITEA_TOKEN` into every job and branch protection binds the *context*, not its issuer. Gitea >=1.26 with the Actions default set to **Restricted** (server-management#714) binds the injected token, but does not close the class either — not against @@ -2273,16 +2289,27 @@ documented API paths mention "environment", and the config file the running serv restricting dispatch would close nothing: the `pull_request:` row above is cheaper than any dispatch, because it runs **attacker-authored YAML**, which can name any secret in the repo store — not only the ones the committed workflows reference. That is what makes it cover `renovate.yml`'s -`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. Concretely today, six jobs in -`docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, -`migrations`, `functional-e2e`, `api-docs`, `format`), two of them required contexts — carry that as -*every job on the PR route that names `secrets.REGISTRY_PASSWORD`*, not as the list, and not as -"every `container:` job" either: `toolchain-preflight` is container-free and would fall out of that -predicate. "Push a branch, open a PR" therefore reaches the credential with **no act outside the -ordinary contribution flow**, where a dispatch costs one. (`dependency-scan.yml` references no -secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`. The live -residuals are that PR route and the `v*` tag push, tracked in #885 — along with `tag_protections`, -which is still empty. +`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. (`dependency-scan.yml` references no +secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`. + +Six jobs in `docker-build.yml` used to hold `REGISTRY_PASSWORD` on that route, two of them required +contexts. Since ersatztv#885 **no job on the `pull_request` route names any stored secret** +(`ci.pr-route-carries-no-stored-credential`): the toolchain image is pulled through the registry's +anonymous bearer-token flow and the commit-status API is read unauthenticated, leaving the PAT to +`build`, which the route cannot reach. The invariant is held by +`scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, +which derives the population from the git index by the predicate *every job of a +`pull_request`-triggered workflow that names a `secrets.*`* — never a list, and never "every +`container:` job" either, which names five of the six because `toolchain-preflight` is +container-free. The `v*` tag-push row is closed against the bot by `release.tag-protection-v-star`. + +What that does **not** close, and must not be read as closing: `REGISTRY_PASSWORD` is still in the +repo's Actions store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or +`SERVERMGMT_DEPLOY_KEY` in a job of its own. "Push a branch, open a PR" still reaches every secret in +the store with **no act outside the ordinary contribution flow**. Bounding the store needs +per-environment secret scoping, which Gitea 1.27.1 does not have. What is removed is the routine +materialisation of a write-capable credential into six PR-run environments — blast radius, not the +route. **A `v*` tag push does not fire `ci-image.yml`.** There is no `tags:` key, and a `branches:` filter is compared against a branch ref — so a release cut no longer republishes the toolchain image as a @@ -2413,13 +2440,14 @@ because they send you to different places: | HTTP 200 with a manifest body | green | resolves | | HTTP 404 | **red** | `IS GONE` — rebuild the tag (recovery above) | | HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) | -| 401 / 403 | **red** | the credentials were rejected — fix the secrets | +| 401 / 403 after a token was obtained | **red** | the registry `refused an ANONYMOUS read` — check that `timothy/ersatztv` and its `ersatztv-ci` package are still PUBLIC | +| the challenge names no realm, or the token endpoint answers with no token | **red** | `could NOT OBTAIN an anonymous pull token` — worded apart on purpose: this is about the registry's token endpoint, not the package's visibility. Not retried | | anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin | -| `ETV_REGISTRY_AUTH` unset, malformed, or either half empty | **red**, before any query | an absent secret interpolates to `":"`, which is not a credential | -The last two rows are the ones worth defending, because warning on them and exiting 0 is the natural -way to write this check and it is wrong: a missing `curl`, a moved registry and a DNS change all land -there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after. The +The two token-leg rows and the `could NOT VERIFY` row are the ones worth defending, because warning +on them and exiting 0 is the natural way to write this check and it is wrong: a missing `curl`, a +moved registry and a DNS change all land in the last of them, and a green-with-a-warning job is +indistinguishable from a healthy pin forever after. The unknown arm retries first (`ETV_CI_ATTEMPTS`, default 3, `ETV_CI_RETRY_SECONDS` apart) so an ordinary registry blip does not redden a PR — that pause is what makes failing on unknown affordable, and shortening it silently trades this guard for flake. @@ -2464,7 +2492,7 @@ check and takes no arguments. Run it from the CURRENT checkout, not the pin work `$repo` is kept above: ```bash -ETV_REGISTRY_AUTH=user:pass scripts/ci-toolchain-image-resolves.sh +scripts/ci-toolchain-image-resolves.sh ``` **What this rebuild does and does not restore.** It restores a *working* toolchain at that tag, built diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e24a8ef87..636e73fe5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -39,7 +39,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](records/blazor/rollback-tag.md) | | `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](records/blazor/ui-removed.md) | | `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](records/channel/origin-marker.md) | -| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`'s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo's checkouts now set it — 15 in #746 and `ci-image.yml`'s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885). | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) | +| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`'s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo's checkouts now set it — 15 in #746 and `ci-image.yml`'s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NO job on the `pull_request` route names a stored secret any more (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) | | `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](records/ci/batch-pushes-no-cancel-route.md) | | `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](records/ci/build-once-rejected.md) | | `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. THE COMBINED COMMIT-STATUS ENDPOINT CANNOT EXPRESS THIS: `GET /repos/{o}/{r}/commits/{sha}/status` has states `success`/`failure`/`pending`/`error` and NO `cancelled`, so it reports a cancelled job as `failure`. Anything polling that endpoint — which is what a CI monitor naturally polls, because it is the per-sha view the merge gate reads — must resolve the job-level `conclusion` via `actions/runs/{id}/jobs` before reporting a red. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) | @@ -61,6 +61,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | | `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) | +| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO job of a `pull_request`-triggered workflow may name a stored secret. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, and a job is in scope unless its `if:` is the one pinned exclusion string. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | | `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) | | `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark ` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always --gated `, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) | | `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) | @@ -74,7 +75,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and does not post its CLASSIFICATION if EITHER count moved. Since #849 it does not merely abstain either: when the head carries a row this run did not inherit — and that row is neither the repair sentinel nor an allow-listed reviewer's verdict — the arm REPLACES it with the unverified-write sentinel, because abstention is a handoff only when there is nothing to hand off (`ci.verdict-unverified-write-sentinel`). The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): every retarget fires `edited`, which is in this workflow's `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow's `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when the walk read EVERY page up to its 20-page cap and the LAST page came back empty — an empty page BEFORE the cap is SKIPPED rather than read as exhaustion, since Gitea pages this endpoint before it filters and a fully-filtered page is byte-identical to the end of the list (#870) — which NARROWS that defeat about 10x rather than closing it — the 50-row filtered block is unchanged, but the timeline it must sit in grows from ~100 rows to over 1000 — the page-20 terminator still being trusted for the same unprovable reason; an untrusted count (unreadable page, non-array body, non-numeric length, empty FIRST page, a cap reached on a non-empty page) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human's state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR's exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run's `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run's mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | | `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) | | `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) | -| `ci.workflow-dispatch-ref-unrestricted` | Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`'s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853's own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`'s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — both in #885, not dispatch. | 2026-08-30 | [link](records/ci/workflow-dispatch-ref-unrestricted.md) | +| `ci.workflow-dispatch-ref-unrestricted` | Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`'s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853's own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): no job on the `pull_request` route names any stored secret, the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "every job of a `pull_request`-triggered workflow that names a `secrets.*`", DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record's whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`'s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either. | 2026-08-30 | [link](records/ci/workflow-dispatch-ref-unrestricted.md) | | `ci.workflow-run-body-no-expressions` | A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step's `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard's own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing now also covers `docker-build.yml`'s `test` and `migrations` jobs, where a dropped step is fail-OPEN (the required check goes green having done no work) rather than fail-closed as it is here — see `ci.required-job-step-execution-markers`, which adds per-STEP markers there and extends this file's delimiter ban to those two jobs. It is still not a repo-wide property, but the remaining exceptions are narrower than this record originally said: `build` was brought into the ban too (its `Smoke + IPTV E2E` runs AFTER the image is pushed, so a drop there ships an unsmoked release candidate — its two payloads moved to `env:`, so the ban was free), leaving only `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step that gates nothing that ships. | 2026-08-06 | [link](records/ci/workflow-run-body-no-expressions.md) | | `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](records/concurrency/diff-scalar-fanout.md) | | `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](records/concurrency/etag-rotation-completion.md) | @@ -143,12 +144,13 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](records/release/done-when-merge-consent.md) | | `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11). | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) | | `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](records/release/live-e2e-required.md) | -| `release.main-direct-push-disabled` | Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works. | 2026-08-05 | [link](records/release/main-direct-push-disabled.md) | +| `release.main-direct-push-disabled` | Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), so the release cut still works for the operator while the `renovate` bot can no longer push a tag that publishes `:prod`. | 2026-08-05 | [link](records/release/main-direct-push-disabled.md) | | `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](records/release/merge-consent-autogrant.md) | | `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](records/release/migration-rehearsal-prodcopy.md) | | `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) | | `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](records/release/promotion-floating-prod.md) | | `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: @ ` comment references the PR's current head sha (short-sha prefix match against the verdict's OWN `@ ` field, marker at COLUMN 0 (no indent, so indented code blocks cannot self-approve), whole-word verdict token, fenced code blocks stripped with markdown fence-length semantics, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). The grammar lives in ONE tested place, `scripts/check-review-verdict.sh` — #629 found three false-opens that survived because it was implemented inline and untested while this record described stricter behaviour than the code had. | 2026-07-12 | [link](records/release/review-verdict-gate.md) | +| `release.tag-protection-v-star` | The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo's other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule's failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: the negative half is done and recorded below; the POSITIVE half — a real `v*` release-cut tag push by `timothy` still succeeding — is DEFERRED to the operator's next real cut, because pushing a `v*` tag from any working session publishes `:prod` and a release is the operator's act, not a verification step. Treat that as OPEN until a cut lands. There is no committed mirror of this rule: it lives in Gitea, and this record's `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses. | 2026-09-04 | [link](records/release/tag-protection-v-star.md) | | `release.verdict-status-check` | The H10 review verdict is written as a `review-verdict/h10` Gitea **commit status** on the exact reviewed sha by `scripts/post-review-verdict.sh`, and that context is a REQUIRED status check on `main`. Because a status belongs to one sha, a later commit cannot inherit it, so Gitea's own `merge_when_checks_succeed` refuses to merge a head no one reviewed. The PreToolUse hook additionally refuses to SCHEDULE an auto-merge unless that status is already green on head. A `pull_request_target` workflow auto-passes the two exempt classes (Renovate-authored, docs-only) unless the PR touches a protected path (`.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/`, `docker/ci/`). This extends — does not supersede — `release.review-verdict-gate` (#303 H10), whose comment convention remains the human-readable artifact and the hook's condition (c). | 2026-07-25 | [link](records/release/verdict-status-check.md) | | `release.verdict-vocabulary-shared` | The H10 verdict words live in exactly one place, `scripts/lib/review-verdict-vocabulary.sh`, as two arrays. `scripts/post-review-verdict.sh` (write: word to commit-status state) classifies through `etv_verdict_class`; `scripts/check-review-verdict.sh` (read: comment to classification) builds `POS_RE`/`NEG_RE` from `etv_verdict_alternation`. Neither script may restate a word, and neither may enumerate the vocabulary in a usage banner or an error string. Every word is validated to `[a-z][a-z-]*` before it reaches a regex. Validation is enforced by a DATA dependency, not a control-flow check: `etv_verdict_vocabulary_validate` sets `ETV_VERDICT_VOCABULARY_OK=1` on its final line, and the derived views refuse to hand out words without it. A vocabulary that is missing, unreadable, truncated, exits at top level, is declared as a scalar, or fails validation yields NO words on either side — exit 1 for the writer (nothing posted), exit 2 for the reader (callers fail closed). Do NOT reintroduce a text-comparison parity test alongside this. | 2026-08-26 | [link](records/release/verdict-vocabulary-shared.md) | | `release.verdict-writes-status-before-comment` | `scripts/post-review-verdict.sh` writes the sha-bound `review-verdict/h10` commit status FIRST and the human-readable `Review-verdict:` comment SECOND. Every refusal path still refuses (fail-closed, unchanged) and exits non-zero, and none of them may leave a verdict comment behind. An orphaned comment is therefore PREVENTED rather than tolerated. If the comment write fails after the status was written, that is an error too, but it degrades to an `ask` at the merge gate rather than to an apparent grant. | 2026-08-22 | [link](records/release/verdict-writes-status-before-comment.md) | diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index c528b1066..d04a187d5 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -5,9 +5,9 @@ status: active since: '2026-08-05' supersedes: none superseded-by: none -rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885).' +rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NO job on the `pull_request` route names a stored secret any more (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store.' signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714' -mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe) — note this means not probed, NOT unexercised, and the `container:` pull is in fact exercised by the shipped credential on every PR: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.' +mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe): the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run exercises which, and that this CHANGED on 2026-09-04 (#885): all three are now confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so none of them is exercised by a PR at all — the `container:` pull was the one that used to be, and it is now ANONYMOUS and carries no credential to infer about. All three are therefore first exercised on the post-merge push to `main`, AFTER the merge gate has passed, so a wrong inference reddens main, not the PR.' --- **What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and @@ -218,9 +218,13 @@ inherited, which is the residual the allow-list explicitly does not touch. **Whi was settled in #853** (`ci.workflow-dispatch-ref-unrestricted`): not the four unrestricted `workflow_dispatch` triggers but `docker-build.yml`'s head-resolved `pull_request:`, which runs attacker-authored YAML and therefore reaches EVERY secret in the store, not only the ones the -committed workflows name — six of its jobs already hold `REGISTRY_PASSWORD` on that route, two of them -required contexts. Residuals in #885. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag -may point at ANY commit — a prod image with no PR, review or status (tag protections are empty). +committed workflows name. Six of its jobs held `REGISTRY_PASSWORD` on that route, two of them required +contexts; #885 removed all six (`ci.pr-route-carries-no-stored-credential`), which narrows the blast +radius and leaves the ROUTE exactly as cheap as before — the store, not the committed references, is +what head YAML reaches. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag may point +at ANY commit — a prod image with no PR, review or status. Since #885 that push is whitelisted to +`timothy` (`release.tag-protection-v-star`), which closes it against the bot and not against the +admin. **And none of it was necessary: direct pushes to `main` were server-side permitted, so the gate was bypassable with no forgery at all (#743).** That route is now closed — `main` carries `enable_push: false` (`release.main-direct-push-disabled`), which removes `main` as a destination for diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md new file mode 100644 index 000000000..f951e7493 --- /dev/null +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -0,0 +1,59 @@ +--- +key: ci.pr-route-carries-no-stored-credential +title: '2026-09-04 — No job on the `pull_request` route names a stored secret; the registry and the status API are read ANONYMOUSLY (#885)' +status: active +since: '2026-09-04' +supersedes: none +superseded-by: none +rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO job of a `pull_request`-triggered workflow may name a stored secret. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, and a job is in scope unless its `if:` is the one pinned exclusion string. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message.' +signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' +mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. NOT MEASURED FROM THIS SESSION, and it is the one thing this change cannot establish locally: whether act_runner''s daemon performs the credential-free `container:` pull. The HTTP-level anonymous read is proven; the daemon''s behaviour is first observable on a real PR run, where a failure is loud (both required contexts die at image pull in 1-2s) rather than silent.' +--- + +**What was wrong.** `docker-build.yml` triggers on `pull_request:`, and Gitea resolves that run from +the PR head. The YAML that executes is therefore written by whoever opened the PR, and six of its +jobs asked for `secrets.REGISTRY_PASSWORD` — a PAT scoped `write:package` + `read:repository` +(`ci.actions-credential-scoping`) that writes `ersatztv:prod`, the floating tag prod's stack follows. +Two of those jobs are required contexts, so the credential was materialised on every ordinary +contribution, into environments a contributor controls the commands of. + +**Why "drop the credential" and not "scope it down".** The obvious remedy — mint a +`read:package`/`read:repository` PAT for the PR route, the scoping precedent +`ci.actions-credential-scoping` sets — was costed first and does not survive its own measurement. +This registry serves a public package, and the measurements in `mechanics:` show an unauthenticated +client already obtaining a pull token and reading the manifest and blob with it. A read-only PAT +would buy no capability that anonymity does not, and would pay for that nothing by putting one more +credential in the store that head-supplied YAML can name. Taking the credential out entirely is +strictly stronger and strictly cheaper. + +**What the anonymous flow costs in code.** One extra round trip, and a rule the old script did not +follow: a registry 401 is a *challenge*, not a refusal. `scripts/ci-toolchain-image-resolves.sh` now +reads `realm` out of `Www-Authenticate` rather than assuming a token endpoint, exchanges it for a +pull token once per run, and retries the read with the bearer. Every refusal direction it had is +kept — a 401 or 403 *after* the token leg, a token endpoint that answers with no token, and one that +cannot be reached, all `fail` rather than degrade to could-not-tell (`unknown-must-fail-not-warn`). +Only the message changed, and deliberately: it now names the cause an operator can act on, which is +that the repo or the package has stopped being public. + +**Why the guard derives its population.** The issue's first statement of this invariant was "every +`container:` job on the PR route", and that was wrong on the day it was written: +`toolchain-preflight` is `runs-on: small` with no `container:` and took the credential through a step +`env:`, so the predicate named five of six. A population derived by the wrong predicate is not better +than a hand-written list — it is a list with a false claim of completeness attached +(`testing.guard-derives-population-from-source`, `an-issues-file-list-is-not-the-population`). The +shipped predicate reads the whole job subtree for any `secrets.*`, so the entry point the credential +takes does not matter, and a text-versus-walk cross-check reports any reference the walk cannot +reach. + +**The `if:` classifier is a PIN, not a parser.** Exactly one string — +`github.event_name != 'pull_request'` — takes a job off the route. Anything else, including +expressions a human can see are equivalent, leaves the job IN the population. Parsing +`github.event_name` expressions was tried in `test_image_build_delegates_the_spa_suite` and withdrawn +after repeated defects from that one mechanism; a pin can only ever be too demanding, which is the +safe direction, and widening it is then a deliberate edit rather than an accident. + +**The residual, stated so nobody cites this record as a boundary.** Head-supplied YAML can still name +every secret in the store — the committed workflows' references were never the limit +(`ci.gate-trigger-base-resolved`, `ci.workflow-dispatch-ref-unrestricted`). What this removes is +routine exposure: log leakage, a compromised action or container-job dependency, and a credential in +`argv` visible to `ps` on the shared `small` host. Blast radius, not the route. diff --git a/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md b/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md index 06d4cfa24..75e0237ba 100644 --- a/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md +++ b/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md @@ -6,8 +6,8 @@ since: '2026-08-30' supersedes: none superseded-by: none stale-after: '2027-02-28' -rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — both in #885, not dispatch.' -signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections empty, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845' +rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): no job on the `pull_request` route names any stored secret, the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "every job of a `pull_request`-triggered workflow that names a `secrets.*`", DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record''s whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either.' +signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections, v* tag whitelist, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845' mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"' sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 matching "environment"); `CreateActionWorkflowDispatch` schema; live `tag_protections` = `[]`; live `branch_protections` = one `main` rule; `/api/v1/admin/users` = exactly `timothy` (admin) + `renovate` (non-admin), with `permission: write` from `/repos/timothy/ersatztv/collaborators/renovate/permission`; host `app.ini` `[actions]`; differential dispatch-authorization probe with anonymous and authenticated-non-writer controls' --- @@ -47,16 +47,19 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat into a run log. **That is the step that makes this cover all four dispatches**, `renovate.yml` included, even though `renovate.yml` has no `pull_request:` trigger of its own; without it the argument would reach only the registry pair. - Concretely on the registry credential today, SIX jobs hold `REGISTRY_PASSWORD` and run on the PR - route — `toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of - which `test` and `migrations` are both branch-protection required contexts - (`.gitea/required-status-contexts.json`). **Carry the invariant, not the list:** *every job on the PR - route that names `secrets.REGISTRY_PASSWORD`*, because a remediation scoped to today's six names - silently misses the seventh. Not "every `container:` job" — `toolchain-preflight` is deliberately - container-free (`runs-on: small`, credential via `ETV_REGISTRY_AUTH`), so that predicate names five of - six and is stale before it ships. - So "push a branch, open a PR" reaches the credential with **no act outside the ordinary contribution - flow**, where a dispatch costs one — that is this repo's sense of "deliberate" + On the registry credential, SIX jobs held `REGISTRY_PASSWORD` on the PR route — + `toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of which + `test` and `migrations` are both branch-protection required contexts + (`.gitea/required-status-contexts.json`). That was closed in #885 + (`ci.pr-route-carries-no-stored-credential`), by the invariant and not by the list: *every job of a + `pull_request`-triggered workflow that names a `secrets.*`*, derived from the git index, because a + remediation scoped to those six names silently misses the seventh. Not "every `container:` job" + either — `toolchain-preflight` is deliberately container-free (`runs-on: small`, credential via + `ETV_REGISTRY_AUTH`), so that predicate names five of six and is stale before it ships. + **The route itself is untouched by that fix**, which is why this record still stands: head-supplied + YAML can name any secret in the store, so "push a branch, open a PR" still reaches all of them with + **no act outside the ordinary contribution flow**, where a dispatch costs one — that is this + repo's sense of "deliberate" (`ci.toolchain-image-publish-is-a-dispatch`), not a raw step count, which would read 2 vs 2. Any control that restricted dispatch and left this open would close the more visible route and report progress that did not happen. Per-`if:` ref gates on the publish steps do not change this: they live @@ -76,11 +79,10 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat with `"not found"` while `renovate` on this repo reaches ref resolution with `"ref ... doesn't exist"` — the same response a known writer gets — so `renovate` passes the dispatch authorization gate. -- **One cheap control exists and was deliberately NOT taken here: `tag_protections` is empty.** Gitea - 1.27.1 does support tag protection (`name_pattern` plus a username/team whitelist), and a `v*` rule - whitelisting the release operator would close `docker-build.yml`'s tag-push row against the bot at - no operational cost, since only the operator cuts releases. It is not applied in this change because - it is live branch-protection-class configuration whose failure mode is a broken release cut, which - deserves its own change and its own verification that a legitimate tag push still succeeds — not a - rider on a decision record. It carries no weight against the admin actor either way. Tracked with - the `pull_request:` residual in #885. +- **One cheap control was identified here and applied in #885: the `v*` tag rule.** Gitea 1.27.1 + supports tag protection (`name_pattern` plus a username/team whitelist), and `tag_protections` was + empty when this record was written (2026-08-30), leaving `docker-build.yml`'s tag-push row open + against the bot. It was deliberately not applied as a rider on a decision record — its failure mode + is a broken release cut, so it earned its own change and its own verification. It now exists as + `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), and it carries no weight against + the admin actor, which was true when it was proposed and is true now. diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index e8d6d1338..0764fb4cb 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -5,7 +5,7 @@ status: active since: '2026-08-05' supersedes: none superseded-by: none -rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.' +rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), so the release cut still works for the operator while the `renovate` bot can no longer push a tag that publishes `:prod`.' signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714' mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' --- @@ -109,11 +109,13 @@ mirror (not applicable: `fork: false`, `mirror: false`). Merge remains the one i **Why the release cut does not deadlock.** #743 flagged that the tag path had to keep working, and #719 documents H11 blocking a tag-only push on every release cut. Branch protection is scoped to -`refs/heads/main`; tags are governed by an entirely separate mechanism, and `tag_protections` on this -repo is empty, so tag pushes are unrestricted by anything except ordinary write permission. Demonstrated -rather than assumed: from one worktree, the branch push to `main` was refused and a tag push succeeded. -Do not conflate the two mechanisms — disabling branch push says nothing about tags, and a future -tag-protection rule would not inherit from this one. +`refs/heads/main`; tags are governed by an entirely separate mechanism. `tag_protections` was empty +when this record was written (measured 2026-08-24), so tag pushes were unrestricted by anything except +ordinary write permission. Demonstrated rather than assumed: from one worktree, the branch push to +`main` was refused and a tag push succeeded. Do not conflate the two mechanisms — disabling branch +push says nothing about tags, and the tag-protection rule #885 later added +(`release.tag-protection-v-star`, `v*` whitelisted to `timothy`) does not inherit from this one in +either direction. **The `docker-build.yml` `persist-credentials` question (#743's fourth box), since resolved in #746.** Head-resolved jobs kept a write-capable credential in `.git/config` because their `actions/checkout` diff --git a/docs/decisions/records/release/tag-protection-v-star.md b/docs/decisions/records/release/tag-protection-v-star.md new file mode 100644 index 000000000..3beddb285 --- /dev/null +++ b/docs/decisions/records/release/tag-protection-v-star.md @@ -0,0 +1,34 @@ +--- +key: release.tag-protection-v-star +title: '2026-09-04 — `v*` tag pushes are whitelisted to the release operator, closing the tag-push route against the bot (#885)' +status: active +since: '2026-09-04' +supersedes: none +superseded-by: none +rule: 'The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo''s other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule''s failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: the negative half is done and recorded below; the POSITIVE half — a real `v*` release-cut tag push by `timothy` still succeeding — is DEFERRED to the operator''s next real cut, because pushing a `v*` tag from any working session publishes `:prod` and a release is the operator''s act, not a verification step. Treat that as OPEN until a cut lands. There is no committed mirror of this rule: it lives in Gitea, and this record''s `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses.' +signals: 'tag_protections empty, v* tag push publishes prod, release cut blocked, renovate can push a tag, tag protection whitelist, release operator whitelist, prod image published by tag, deferred positive verification · paths: `.gitea/workflows/docker-build.yml` · issues: #885, #853, #743, #698, #742' +mechanics: 'Applied 2026-09-04 with `POST /api/v1/repos/timothy/ersatztv/tag_protections` body `{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}`, which returned `{"id": 1, "name_pattern": "v*", "whitelist_usernames": ["timothy"], "whitelist_teams": [], "created_at": "2026-09-04T23:18:50+02:00"}`. Read back the same day with `GET .../tag_protections` -> the one rule, fields as echoed. It is reversible with `DELETE .../tag_protections/1`. NOTE THE ROUTE NAME: `/repos/{owner}/{repo}/tags/protection` is NOT it — measured 2026-09-04, that path resolves `protection` as a TAG NAME and answers 404 `{"message": "tag doesn''t exist: protection"}`. Before this the endpoint returned `[]` (measured #743 2026-08-24 and again 2026-09-04). NEGATIVE VERIFICATION available from this session and performed: a NON-`v*` tag (`probe-885-tagpush`) pushed by `timothy` succeeded and was deleted, confirmed gone by `git ls-remote --tags` — so tag pushes still work at all and the rule is not mis-scoped to `*`. NOT VERIFIED, and it cannot be from a working session: that a real `v*` release-cut push still succeeds, and that a push by `renovate` is refused (only the `timothy` credential is available here). The first is the operator''s next cut; the second would require the bot''s credential.' +--- + +**What this closes.** `release.main-direct-push-disabled` made every change to `main` go through a PR +with a sha-bound review verdict, and said in the same breath that "tag pushes are unaffected +(separate mechanism)". That sentence was accurate and was also the gap: `docker-build.yml` publishes +`:prod` on a `v*` tag, so one tag push reaches the image prod's stack follows, with no PR, no review +verdict and no merge gate anywhere in the path. `tag_protections` was empty, so nothing stood in it. + +**What it does not close, said plainly.** Against `timothy` it is decoration: the whitelist contains +that account, and an owner can delete the rule with one API call anyway. No self-administered control +is a boundary against the actor who administers it. The rule is worth having because the actor set +has a *second* member — `renovate` holds repository write, opens PRs on its own, and is bounded on +the merge path by `review-verdict/h10` and the exemption rule (#698, #742, #845) while being +completely unbounded on the tag path. This makes the two paths agree. + +**Why it was split out of #853 rather than bundled.** Its failure mode is not a leaked credential, it +is a release cut that will not push — a failure that surfaces at the worst moment, when someone is +trying to ship. That earns its own change and its own verification, which is why the positive half is +recorded above as deferred rather than quietly assumed. A rule whose only failure mode is untested is +a rule that gets deleted in a hurry by whoever hits it first. + +**How to unblock a release cut that this rule breaks.** `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`, +push the tag, re-`POST` the rule. Prefer adding the pushing account to `whitelist_usernames` with a +`PATCH` if the cut is moving to a different operator, so the protection is never left off. diff --git a/scripts/ci-detect-already-validated.sh b/scripts/ci-detect-already-validated.sh index 470353bfe..91cf11a2a 100755 --- a/scripts/ci-detect-already-validated.sh +++ b/scripts/ci-detect-already-validated.sh @@ -63,16 +63,21 @@ if [ "$merge_tree" != "$pr_tree" ]; then fi # Merge tree matches the PR head tree exactly. Confirm that PR head was actually validated green -# before trusting it -- query the Gitea combined commit status API. Auth is required (private -# instance); a missing/failing/non-success response always falls through to skip=false. -if [ -z "${ETV_STATUS_AUTH:-}" ]; then - echo "ETV_STATUS_AUTH not set; cannot verify PR head status; running full validation (safe default)" - emit false - exit 0 -fi - +# before trusting it -- query the Gitea combined commit status API. +# +# THE READ IS ANONYMOUS (ersatztv#885, `ci.pr-route-carries-no-stored-credential`). The three jobs +# that call this run on the `pull_request` route as well as on the main push, and Gitea resolves a +# `pull_request` run from the PR HEAD -- so those jobs may hold no stored secret, and the +# `ETV_STATUS_AUTH` they used to carry is gone. `timothy/ersatztv` is public and answers this GET +# unauthenticated (measured 2026-09-04: HTTP 200 carrying the combined state). No credential +# override is kept in its place: the URL below names ONE instance, that instance is public, so an +# override would be a code path with no caller -- and an unusable one (":", the shape an absent +# secret interpolates to) would draw a 401 and turn a working read into a permanent skip=false. +# +# Every failure direction here is safe: a missing, failing or non-success response falls through to +# skip=false, which re-runs validation. Nothing about this step can cause a skip that was not earned. status_url="http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/commits/${pr_head}/status" -status_json="$(curl -sf -u "$ETV_STATUS_AUTH" "$status_url" || true)" +status_json="$(curl -sf "$status_url" || true)" if [ -z "$status_json" ]; then echo "status API request for PR head ${pr_head} failed; running full validation (safe default)" emit false diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index 3b3c3e761..d4ab3e80f 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -29,13 +29,24 @@ # RETRIED (they are usually transient) and then FAIL. The message stays distinct from the deleted # case: "could not verify" and "IS GONE" send an operator to different places. # -# Env (all optional except the credential; the defaults are the live values): +# IT READS THE REGISTRY ANONYMOUSLY, AND THAT IS LOAD-BEARING RATHER THAN INCIDENTAL. This job runs +# on the `pull_request` route, where the workflow YAML is supplied by the PR head, so it may hold no +# stored secret (ersatztv#885, `ci.pr-route-carries-no-stored-credential`). A naive unauthenticated +# GET of a manifest is a 401 for every tag, present or deleted — which is why this used to demand a +# credential — but that 401 is a Bearer CHALLENGE, and this registry issues an anonymous pull token +# for a public package against it. Measured 2026-09-04: the token endpoint answers 200 with no +# credential, that token reads the pinned manifest and its config blob (200), and a tag that does +# not exist answers 404 rather than 401 — so the deleted-tag diagnosis this whole script exists for +# survives the change. What does NOT survive it is `timothy/ersatztv` or its `ersatztv-ci` package +# being made private: the token leg then refuses, and this fails loudly with a message that names +# that cause rather than reporting could-not-tell. +# +# Env (all optional; the defaults are the live values): # ETV_CI_REGISTRY registry host:port (default 192.168.1.95:3000) # ETV_CI_IMAGE_REPO package path inside the registry (default timothy/ersatztv-ci) # ETV_CI_WORKFLOW workflow file to read the pin from (default .gitea/workflows/docker-build.yml) # ETV_CI_ATTEMPTS tries per pin before an unknown becomes a failure (default 3) # ETV_CI_RETRY_SECONDS pause between those tries (default 5) -# ETV_REGISTRY_AUTH user:pass — REQUIRED; the registry rejects anonymous reads with 401 set -euo pipefail registry="${ETV_CI_REGISTRY:-192.168.1.95:3000}" @@ -52,58 +63,103 @@ fail() { printf '::error::ci-toolchain-image-resolves: %s\n' "$*" >&2; exit 1; } pins=$(grep -oE 'ersatztv-ci:[0-9a-f]+' "$workflow" | cut -d: -f2 | sort -u || true) [ -n "$pins" ] || fail "no ersatztv-ci pin found in $workflow — if the grep pattern stopped matching, fix it here and in pr-checks.yml::ci-image-pin together" -# No credentials is NOT a pass. An unauthenticated read of this registry is a 401 for every tag, -# present or deleted, so a run without them would report "cannot tell" for a live pin and for a -# deleted one alike — the shape where a guard reports green having checked nothing. -# -# The EMPTY-halves check is the one that matters in CI and is easy to miss: an absent secret does -# not arrive here as an unset variable. `ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ ... }}` -# interpolates a missing secret to the empty string, so the job passes the non-empty string ":". -# Testing only the unset case would leave the production shape uncovered. -auth="${ETV_REGISTRY_AUTH:-}" -[ -n "$auth" ] || fail "ETV_REGISTRY_AUTH (user:pass) is unset, so the registry cannot be queried — this check refuses to report a pass it did not establish" -case "$auth" in - *:*) ;; - *) fail "ETV_REGISTRY_AUTH must be user:pass, got a value with no ':' — the registry cannot be queried and this check refuses to report a pass it did not establish" ;; -esac -[ -n "${auth%%:*}" ] && [ -n "${auth#*:}" ] \ - || fail "ETV_REGISTRY_AUTH has an empty half (user or password) — this is what an ABSENT REGISTRY_USER/REGISTRY_PASSWORD secret interpolates to, not a credential. Fix the secrets rather than reading an unauthenticated 401 as could-not-tell." - accept='application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json' attempts="${ETV_CI_ATTEMPTS:-3}" retry_seconds="${ETV_CI_RETRY_SECONDS:-5}" rc=0 -# One GET, returning " ". The body is fetched rather than a HEAD sent, because +# The anonymous pull token, acquired at most once and reused for every pin. Empty until the +# registry challenges; `token_leg_done` separates "not tried yet" from "tried and got nothing", so a +# registry that is genuinely refusing anonymous reads is not re-asked once per pin. +token="" +token_leg_done=0 +headers=$(mktemp) +trap 'rm -f "$headers"' EXIT + +# One directive out of a `Www-Authenticate: Bearer realm="…",service="…"` challenge. The realm is +# read from the challenge rather than assumed, so a registry that moves its token endpoint is +# followed instead of guessed at; `service` is optional (this registry issues a token without it, +# measured 2026-09-04) and is passed through when the challenge names one. +challenge_field() { + printf '%s' "$1" | sed -n "s/.*[ ,]$2=\"\([^\"]*\)\".*/\1/p" | head -1 +} + +# The token leg. Returns non-zero on every shape that leaves us without a bearer — no challenge, no +# realm in it, a token endpoint that will not answer, or an answer carrying no token. Each of those +# is "could not establish anonymous access", which the caller turns into a REFUSAL rather than a +# could-not-tell: an empty token would otherwise fall through to a second 401 and read as an +# ordinary auth failure with no cause named. +acquire_token() { + local challenge realm service url body + token_leg_done=1 + challenge=$(tr -d '\r' < "$headers" | sed -n 's/^[Ww][Ww][Ww]-[Aa]uthenticate:[[:space:]]*//p' | head -1) + [ -n "$challenge" ] || return 1 + realm=$(challenge_field "$challenge" realm) + [ -n "$realm" ] || return 1 + service=$(challenge_field "$challenge" service) + url="$realm?scope=repository:$image_repo:pull" + # Spelled as a full `if` rather than `[ … ] && …`: as a bare statement the latter returns the + # test's exit status, which is 1 whenever `service` is absent — a legal challenge shape. + if [ -n "$service" ]; then url="$url&service=$service"; fi + body=$(curl -s "$url") || return 1 + token=$(printf '%s' "$body" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) + [ -n "$token" ] || return 1 + return 0 +} + +# One GET, recording the HTTP code and whether the body is a manifest. The body is fetched rather +# than a HEAD sent, because # HTTP 200 alone does not mean "the manifest is there": a proxy, a captive login page or an error # document all answer 200 with a body that is not a manifest, and a check that reads only the status # line reports those as "resolves". A manifest always carries `schemaVersion`, so the body is matched # for it — with a shell `case`, so nothing depends on jq being installed and no pipeline can invert # the result on a large body. -probe() { - local url="$1" resp code body - # `-u` puts the credential in argv, visible to `ps` for the length of the call — and this job has - # no `container:`, so that is the shared host. Kept because it is the shape every other curl caller - # in scripts/ already uses (`ci-detect-already-validated.sh`, `pr-changed-files.sh`, - # `select-queue.sh`, `issue-qualification-audit.sh`): fixing one site would leave the class intact - # and the codebase inconsistent. The class is tracked in ersatztv#821. - resp=$(curl -s -w '\n%{http_code}' -u "$auth" -H "Accept: $accept" "$url") || resp="" - [ -n "$resp" ] || { printf '000 no\n'; return 0; } +# +# It ASSIGNS `code`/`is_manifest` rather than printing them, and so does `probe` — because the token +# and the "already tried the token leg" flag must survive from one pin to the next, and a +# `$(probe …)` command substitution runs in a SUBSHELL whose assignments are discarded. Reading the +# answer through a subshell would silently re-run the whole two-leg exchange for every pin. +request() { + local url="$1" resp body + if [ -n "$token" ]; then + resp=$(curl -s -w '\n%{http_code}' -D "$headers" -H "Authorization: Bearer $token" -H "Accept: $accept" "$url") || resp="" + else + resp=$(curl -s -w '\n%{http_code}' -D "$headers" -H "Accept: $accept" "$url") || resp="" + fi + if [ -z "$resp" ]; then + code="000" + is_manifest="no" + return 0 + fi code=${resp##*$'\n'} body=${resp%$'\n'*} case "$body" in - *'"schemaVersion"'*) printf '%s yes\n' "$code" ;; - *) printf '%s no\n' "$code" ;; + *'"schemaVersion"'*) is_manifest="yes" ;; + *) is_manifest="no" ;; esac } +# The two legs of an anonymous registry read: the plain GET, and — only if it is challenged and we +# have not already tried — the token exchange followed by ONE retry carrying the bearer. A 401 that +# survives the retry is left as a 401 and refused by the caller; it is never rounded off to +# could-not-tell, because the cause (this package is no longer readable without a credential) sends +# an operator somewhere quite different from a flaky registry. +probe() { + request "$1" + if [ "$code" = "401" ] && [ "$token_leg_done" -eq 0 ]; then + if acquire_token; then + request "$1" + fi + fi +} + +code="" +is_manifest="no" for pin in $pins; do url="http://$registry/v2/$image_repo/manifests/$pin" attempt=1 while : ; do - read -r code is_manifest < -H Accept `: prints a scripted body, -# a newline and the HTTP code, and logs the call. It VALIDATES `-u` rather than ignoring it — a stub -# that answers 200 whether or not the script authenticates would stay green if the real `-u` were -# deleted, which is the fidelity gap that lets a test double certify a script the live registry -# would reject on every request. +# Stands in for the two curl shapes the script issues: the registry GET +# (`-s -w '\n%{http_code}' -D [-H Authorization] -H Accept `) and the token GET +# (`-s ?scope=…`). It prints a scripted body, a newline and the HTTP code, dumps response +# headers where `-D` asks for them, and logs every call with the Authorization header it carried. +# +# It ENFORCES the protocol rather than ignoring it: an unauthenticated registry read is 401 + a +# challenge, exactly as the live registry answers (measured 2026-09-04), so a script that stopped +# performing the token leg would fail every test in this file rather than sailing through on a stub +# that answers 200 regardless. CURL_SHIM = r"""#!/usr/bin/env python3 import os, pathlib, sys state = pathlib.Path(os.environ["STUB_DIR"]) args = sys.argv[1:] url = [a for a in args if a.startswith("http")][-1] -tag = url.rsplit("/", 1)[-1] -auth = args[args.index("-u") + 1] if "-u" in args else "" -with (state / "calls").open("a") as fh: - fh.write(f"{url} auth={auth}\n") -# The live registry answers 401 to an anonymous read of ANY tag, present or deleted. -user, _, password = auth.partition(":") -if not user or not password: + +def flag(name): + return args[args.index(name) + 1] if name in args and args.index(name) + 1 < len(args) else None + + +def header(name): + prefix = name.lower() + ":" + for i, a in enumerate(args): + if a == "-H" and i + 1 < len(args) and args[i + 1].lower().startswith(prefix): + return args[i + 1].split(":", 1)[1].strip() + return "" + + +authorization = header("Authorization") +dump = flag("-D") +codes = dict(pair.split("=", 1) for pair in (state / "codes").read_text().split() if pair) + +with (state / "calls").open("a") as fh: + fh.write("%s auth=%s\n" % (url, authorization)) + +# --- the token endpoint -------------------------------------------------------------------- +if "/token" in url: + behaviour = codes.get("TOKEN", "issue") + if behaviour == "unreachable": + sys.exit(7) + if behaviour == "empty": + print('{"expires_in": 300}\n200', end="") + else: + print('{"token": "anon-token"}\n200', end="") + sys.exit(0) + +# --- the registry -------------------------------------------------------------------------- +tag = url.rsplit("/", 1)[-1] +code = codes.get(tag, codes.get("*", "200")) + +if code == "TRANSPORT": + sys.exit(7) + +if authorization != "Bearer anon-token": + # 401 for a live tag and a deleted one alike -- the challenge is the ONLY thing that tells a + # client where a token can be had. + if dump: + realm = codes.get("REALM", "http://registry.test/v2/token") + pathlib.Path(dump).write_text( + "HTTP/1.1 401 Unauthorized\r\n" + 'Www-Authenticate: Bearer realm="%s",service="container_registry",scope="*"\r\n' % realm + ) + print("{}\n401", end="") + sys.exit(0) + +if dump: + pathlib.Path(dump).write_text("HTTP/1.1 %s\r\n" % code) + +if code == "401-AFTER-TOKEN": print("{}\n401", end="") sys.exit(0) -codes = dict(pair.split("=", 1) for pair in (state / "codes").read_text().split() if pair) -code = codes.get(tag, codes.get("*", "200")) -if code == "TRANSPORT": - # Only the EXIT STATUS is observable: the script's `|| resp=""` discards whatever curl printed, - # so what this reproduces is the non-zero exit, not the `\n000` real curl also emits. - sys.exit(7) body = '{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json"}' if code == "200-NOT-A-MANIFEST": code, body = "200", "Sign in" -print(f"{body}\n{code}", end="") +print("%s\n%s" % (body, code), end="") """ WORKFLOW_TEMPLATE = """jobs: @@ -84,7 +136,7 @@ def preflight(tmp_path): env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}" env["STUB_DIR"] = str(state) env["ETV_CI_WORKFLOW"] = str(workflow) - env["ETV_REGISTRY_AUTH"] = "stub-user:stub-pass" + env.pop("ETV_REGISTRY_AUTH", None) # The retry PAUSE is what makes failing on an unknown affordable in CI and unaffordable in a # test suite; the retry COUNT is behaviour, so it is kept and only the wait is removed. env["ETV_CI_ATTEMPTS"] = "2" @@ -107,6 +159,22 @@ def preflight(tmp_path): log = state / "calls" return log.read_text().splitlines() if log.exists() else [] + def token_calls(self): + return [c for c in self.calls() if "/token" in c] + + def manifest_calls(self): + return [c for c in self.calls() if "/manifests/" in c] + + def authenticated_manifest_calls(self): + """The manifest reads that actually carried the bearer. + + The ATTEMPT count is this, not `manifest_calls`: the run opens with exactly one + unauthenticated read, which is the challenge that starts the token leg, and every read + after the token is acquired carries it. Counting the raw calls would report one more + attempt than the retry loop made. + """ + return [c for c in self.manifest_calls() if c.endswith("auth=Bearer anon-token")] + def run(self, script: Path | None = None): return subprocess.run( ["bash", str(script or SCRIPT)], @@ -124,7 +192,7 @@ def test_a_pin_that_resolves_passes(preflight): result = preflight.run() assert result.returncode == 0, result.stderr assert "32747a0 resolves" in result.stdout - assert preflight.calls(), "the registry was never queried, so nothing was established" + assert preflight.authenticated_manifest_calls(), "the registry was never read with a token" def test_MUTATION_a_deleted_tag_is_reported_as_a_failure(preflight): @@ -170,14 +238,14 @@ def test_an_unknown_is_RETRIED_before_it_fails(preflight): preflight.env["ETV_CI_ATTEMPTS"] = "3" preflight.set_codes({"32747a0": "503"}) assert preflight.run().returncode != 0 - assert len(preflight.calls()) == 3, f"expected 3 attempts, got {preflight.calls()}" + assert len(preflight.authenticated_manifest_calls()) == 3, f"expected 3 attempts, got {preflight.calls()}" def test_an_ANSWER_is_not_retried(preflight): """404 and 200 are answers; retrying them would only slow the job down.""" preflight.set_codes({"32747a0": "404"}) assert preflight.run().returncode != 0 - assert len(preflight.calls()) == 1, f"a 404 must not be retried, got {preflight.calls()}" + assert len(preflight.authenticated_manifest_calls()) == 1, f"a 404 must not be retried, got {preflight.calls()}" def test_HTTP_200_with_a_body_that_is_not_a_manifest_is_not_a_pass(preflight): @@ -189,59 +257,90 @@ def test_HTTP_200_with_a_body_that_is_not_a_manifest_is_not_a_pass(preflight): assert "IS GONE" not in result.stderr -@pytest.mark.parametrize("code", ["401", "403"]) -def test_rejected_credentials_refuse_rather_than_pass(preflight, code): +@pytest.mark.parametrize("code", ["401-AFTER-TOKEN", "403"]) +def test_a_401_or_403_AFTER_the_token_leg_REFUSES_rather_than_passing(preflight, code): """The failure mode that would otherwise make this job green forever. - An anonymous read of this registry is 401 for a live tag and a deleted one alike, so treating - an auth failure as "could not tell, carry on" would turn a broken secret into a permanent, - silent pass. + Reached only once the token leg has run, so it means one specific thing: this registry will not + serve an anonymous pull of this package — normally because the repo or the package has been made + PRIVATE. Treating that as "could not tell, carry on" would turn it into a permanent silent pass, + and it is not a preflight-only problem: since ersatztv#885 every `container:` job pulls the same + image with no credential, so they fail at image pull too. """ preflight.set_codes({"32747a0": code}) result = preflight.run() assert result.returncode != 0 - assert "rejected these credentials" in result.stderr + assert "refused an ANONYMOUS read" in result.stderr + assert "PUBLIC" in result.stderr, "the message must name the cause an operator can act on" + assert preflight.token_calls(), "the token leg must have been attempted before refusing" -@pytest.mark.parametrize( - ("value", "shape"), - [ - (None, "unset"), - (":", "both secrets absent — WHAT THE WORKFLOW ACTUALLY PASSES"), - ("user:", "password secret absent"), - (":pass", "user secret absent"), - ("no-colon", "malformed"), - ], -) -def test_unusable_credentials_refuse_BEFORE_querying_anything(preflight, value, shape): - """The empty-halves cases are the ones that happen, and testing only `unset` misses them. +def test_the_TOKEN_LEG_actually_runs_and_the_bearer_REACHES_the_registry(preflight): + """Anti-vacuity for every test above, and the shape of the whole anonymous read in one place. - `ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}` interpolates - a missing secret to the empty string, so a job with no secrets configured passes the non-empty - string ":" — which is a perfectly good non-empty value and a useless credential. The registry - answers 401 to it for a live tag and a deleted one alike. + The stub answers 401 to an unauthenticated read exactly as the live registry does, so a script + that stopped exchanging the challenge for a token would redden this entire file rather than + passing on a stub that serves anyone. The call sequence is pinned rather than counted: challenge, + token, re-read WITH the bearer. """ - if value is None: - del preflight.env["ETV_REGISTRY_AUTH"] - else: - preflight.env["ETV_REGISTRY_AUTH"] = value - result = preflight.run() - assert result.returncode != 0, f"{shape}: reported success on a credential it cannot use" - assert "ETV_REGISTRY_AUTH" in result.stderr - assert preflight.calls() == [], "it must not query the registry it cannot authenticate to" - - -def test_the_credential_actually_REACHES_the_registry(preflight): - """Anti-vacuity for every test above: the stub 401s an unauthenticated read, as the live - registry does, so a script that stopped passing `-u` would redden the whole file rather than - sailing through on a stub that answers 200 regardless.""" preflight.set_codes({"*": "200"}) assert preflight.run().returncode == 0 assert preflight.calls() == [ - "http://192.168.1.95:3000/v2/timothy/ersatztv-ci/manifests/32747a0 auth=stub-user:stub-pass" + "http://192.168.1.95:3000/v2/timothy/ersatztv-ci/manifests/32747a0 auth=", + "http://registry.test/v2/token?scope=repository:timothy/ersatztv-ci:pull&service=container_registry auth=", + "http://192.168.1.95:3000/v2/timothy/ersatztv-ci/manifests/32747a0 auth=Bearer anon-token", ] +def test_the_REALM_is_read_from_the_CHALLENGE_rather_than_assumed(preflight): + """A hardcoded token endpoint would work here and break the day the registry moves it. + + The realm is whatever the `Www-Authenticate` header names, so this points the challenge somewhere + else entirely and requires the token request to follow it. + """ + preflight.set_codes({"*": "200", "REALM": "http://elsewhere.test/auth/v2/token"}) + assert preflight.run().returncode == 0 + assert preflight.token_calls() == [ + "http://elsewhere.test/auth/v2/token?scope=repository:timothy/ersatztv-ci:pull&service=container_registry auth=" + ] + + +@pytest.mark.parametrize("behaviour", ["empty", "unreachable"]) +def test_a_token_endpoint_that_yields_NO_TOKEN_refuses(preflight, behaviour): + """No bearer is not "carry on unauthenticated" — it is could-not-establish, and it must refuse. + + Both shapes leave the script without a token: an answer carrying no `token` field, and an + endpoint that will not answer at all. Falling through to a second unauthenticated read would + surface as an ordinary 401 with no cause named, which is the diagnosis this arm exists to give. + """ + preflight.set_codes({"*": "200", "TOKEN": behaviour}) + result = preflight.run() + assert result.returncode != 0, "a run that never obtained a token must not report success" + assert "could NOT OBTAIN an anonymous pull token" in result.stderr, ( + "a failed token leg must be worded apart from a refusal that survived a GOOD token: one " + "sends an operator to the registry's token endpoint, the other to the package's visibility. " + f"stderr={result.stderr}" + ) + assert "still PUBLIC" not in result.stderr, "nothing here establishes the package went private" + assert preflight.authenticated_manifest_calls() == [], "no read can have carried a token" + + +def test_the_TOKEN_LEG_is_performed_ONCE_across_every_pin(preflight): + """The token is state that must survive the pin loop. + + `probe` assigns it rather than printing it precisely because a `$(…)` command substitution runs + in a subshell whose assignments are discarded — a version that read the answer through one would + re-run the challenge and the exchange for every pin, and pass every other test in this file. + """ + preflight.set_workflow_text( + WORKFLOW_TEMPLATE.format(pin="32747a0") + " image: 192.168.1.95:3000/timothy/ersatztv-ci:15d2439\n" + ) + preflight.set_codes({"*": "200"}) + assert preflight.run().returncode == 0 + assert len(preflight.token_calls()) == 1, f"the token leg ran {len(preflight.token_calls())} times" + assert len(preflight.authenticated_manifest_calls()) == 2 + + def test_a_workflow_with_no_pin_at_all_is_a_failure(preflight): """If the grep stops matching, the honest report is 'I found nothing', not 'all clear'.""" preflight.set_workflow_text("jobs:\n test:\n runs-on: ubuntu-latest\n") @@ -259,7 +358,7 @@ def test_every_distinct_pin_is_checked_and_one_gone_fails_the_job(preflight): result = preflight.run() assert result.returncode != 0 assert "15d2439" in result.stderr - assert len(preflight.calls()) == 2, f"both pins must be queried, got {preflight.calls()}" + assert len(preflight.authenticated_manifest_calls()) == 2, f"both pins must be queried, got {preflight.calls()}" def test_the_grep_line_cannot_match_ITSELF(preflight): @@ -296,7 +395,9 @@ def test_the_PRODUCTION_retry_defaults_are_the_ones_that_run(preflight): elapsed = time.monotonic() - started assert result.returncode != 0 - assert len(preflight.calls()) == 3, f"the default attempt count is not 3 — got {len(preflight.calls())} call(s)" + assert len(preflight.authenticated_manifest_calls()) == 3, ( + f"the default attempt count is not 3 — got {len(preflight.authenticated_manifest_calls())} call(s)" + ) assert elapsed >= 8, ( f"two pauses at the default 5s should clear the 8s floor; took {elapsed:.1f}s, so the pause " "has been shortened out from under the 'a blip does not redden a PR' argument" diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 1df25c4ca..aed20de49 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -851,11 +851,13 @@ def test_no_OTHER_workflow_writes_the_review_verdict_status(): The trigger test above hardens ONE file, and by construction cannot see the more general problem: the gate is forgeable by *any* head-resolved workflow holding credentials that can POST a commit status, not only by the gate's own definition. `docker-build.yml` is exactly that — it - triggers on `pull_request` (correctly; it builds the PR's code) and carries `ETV_STATUS_AUTH`. - A probe once confirmed those credentials could WRITE statuses, not merely read them - (ersatztv#697); a follow-up probe confirmed `REGISTRY_PASSWORD` is now scoped off - `write:repository` and the same POST returns 403 (see `ci.actions-credential-scoping`). That - closes the registry-credential route, not the general problem — `GITEA_TOKEN`, `RENOVATE_TOKEN`, + triggers on `pull_request` (correctly; it builds the PR's code) and used to carry + `ETV_STATUS_AUTH`. A probe once confirmed those credentials could WRITE statuses, not merely read + them (ersatztv#697); a follow-up probe confirmed `REGISTRY_PASSWORD` is now scoped off + `write:repository` and the same POST returns 403 (see `ci.actions-credential-scoping`); and since + ersatztv#885 no job on that route names a stored secret at all — the status read is anonymous + (`ci.pr-route-carries-no-stored-credential`). That closes the registry-credential route, not the + general problem — `GITEA_TOKEN`, `RENOVATE_TOKEN`, and a collaborator's own token can all still POST a status, and none of that is closed by this test. diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 0f7e38fdb..5bf7eeecb 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -299,3 +299,299 @@ def test_no_workflow_lives_under_dot_GITHUB() -> None: f"{WORKFLOW_DIR} only — decide whether Gitea runs these and widen the population or delete " "them, but do not leave them unchecked." ) + + +# ================================================================================================= +# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885) +# +# The second credential invariant over the same derived population, and the same shape of defect one +# step out: `persist-credentials` is about a credential a step LEAVES BEHIND, this is about a +# credential the workflow ASKS FOR. Gitea resolves a `pull_request` run from the PR HEAD, so on that +# route the YAML is authored by the contributor, and every `secrets.*` it names is materialised into +# the run environment. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way, two of them +# branch-protection required contexts. +# +# WHY THE PREDICATE IS "names a stored secret", NOT "is a `container:` job". The issue's own first +# statement of the invariant was the latter, and it was wrong: `toolchain-preflight` is deliberately +# container-free and took the credential through a step `env:` instead, so that predicate named five +# of six and would have gone stale the day it shipped. A population derived by the WRONG predicate is +# not better than a hand-written list — it is a list with a false claim of completeness attached. +# +# WHY `pull_request_target` IS NOT IN THIS POPULATION. That trigger is BASE-resolved +# (`ci.gate-trigger-base-resolved`): the YAML that runs is `main`'s, not the head's, which is exactly +# why `review-verdict.yml` uses it to hold a write-capable token. The exposure here is head-authored +# YAML, so the trigger that is not head-authored is out — and a workflow that adds `pull_request` +# alongside it enters the population on that key alone. +# +# WHAT THIS DOES NOT CLOSE, so no reader mistakes it for a boundary: `REGISTRY_PASSWORD` is still in +# the repo's Actions store for `build`, and head-supplied YAML can still NAME it, or `RENOVATE_TOKEN`, +# or `SERVERMGMT_DEPLOY_KEY`. What is removed is the ROUTINE materialisation of a write-capable +# credential into six PR-run environments. Bounding the store itself needs per-environment secret +# scoping, which Gitea 1.27.1 does not have (probed in ersatztv#853). +# ================================================================================================= + +# The repo's Actions secret store, read 2026-09-04: GH_COM_TOKEN, REGISTRY_PASSWORD, REGISTRY_USER, +# RENOVATE_TOKEN, SERVERMGMT_DEPLOY_KEY. `GITEA_TOKEN` is deliberately NOT one of them — it is the +# per-run token Gitea injects, bounded by the workflow's own `permissions:` block, and a head-authored +# run receives it whether or not any job names it. Allow-listing it is therefore a statement about a +# MECHANISM (injected, scoped, unavoidable) and not an exemption for a site, which is why it is a +# closed one-member set rather than a list that can grow: a second entry would be an exemption, and +# an exemption outlives its reason silently. +INJECTED_SECRETS = frozenset({"GITEA_TOKEN"}) + +PULL_REQUEST = "pull_request" + +_SECRET_REF = re.compile(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)") + +# The ONLY job-level `if:` in this repo that takes a job OFF the `pull_request` route. This is a PIN, +# not an expression parser, and the direction is the point: an `if:` that is not in this set leaves +# the job IN the population, so an unrecognised guard reddens rather than exempting. Parsing +# `github.event_name` expressions was tried elsewhere and withdrawn after repeated defects from that +# one mechanism (`test_image_build_delegates_the_spa_suite`); a pin has no such failure mode, because +# the only way to get it wrong is to be too demanding. +PR_EXCLUDING_IFS = frozenset({"github.event_name != 'pull_request'"}) + + +def _unwrap_expression(value: str) -> str: + """`${{ x }}` -> `x`; anything else unchanged, whitespace-trimmed.""" + stripped = value.strip() + if stripped.startswith("${{") and stripped.endswith("}}"): + stripped = stripped[3:-2] + return stripped.strip() + + +def workflow_triggers(doc: object) -> set[str]: + """The trigger names under `on:`, in all three spellings it can take. + + `on` is read back from `yaml.safe_load` as the BOOLEAN `True`, not the string `"on"` — YAML 1.1 + resolves a bare `on` to a boolean, and PyYAML implements 1.1. A `doc.get("on")` here returns + None for every workflow in this repo, which would empty the population and make every assertion + below pass having measured nothing. Both keys are read so the function survives a loader that + resolves it either way. + """ + if not isinstance(doc, dict): + return set() + on = doc.get(True) + if on is None: + on = doc.get("on") + if isinstance(on, str): + return {on} + if isinstance(on, list): + return {str(item) for item in on} + if isinstance(on, dict): + return {str(key) for key in on} + return set() + + +def runs_on_pull_request(doc: object) -> bool: + return PULL_REQUEST in workflow_triggers(doc) + + +def pull_request_jobs(doc: object) -> list[tuple[str, dict]]: + """Every job of a `pull_request`-triggered workflow that the trigger can actually reach.""" + jobs = doc.get("jobs") if isinstance(doc, dict) else None + if not isinstance(jobs, dict): + return [] + reachable: list[tuple[str, dict]] = [] + for job_id, job in jobs.items(): + if not isinstance(job, dict): + continue + condition = job.get("if") + if isinstance(condition, str) and _unwrap_expression(condition) in PR_EXCLUDING_IFS: + continue + reachable.append((str(job_id), job)) + return reachable + + +def secret_names(node: object) -> set[str]: + """Every `secrets.NAME` reachable anywhere in a subtree — keys and values, at any depth. + + Deliberately structure-blind: the credential entered through `container.credentials`, a step + `env:`, and a job `env:` in this repo already, and naming those three places would be the + hand-written-population mistake in a different coat. + """ + found: set[str] = set() + stack: list[object] = [node] + while stack: + item = stack.pop() + if isinstance(item, dict): + for key, value in item.items(): + stack.append(key) + stack.append(value) + elif isinstance(item, list): + stack.extend(item) + elif isinstance(item, str): + found.update(_SECRET_REF.findall(item)) + return found + + +def stored_secret_faults(rel: str, doc: object) -> list[str]: + """Human-readable faults for one workflow — accumulated, not failed fast.""" + faults: list[str] = [] + if not runs_on_pull_request(doc): + return faults + for job_id, job in pull_request_jobs(doc): + named = sorted(secret_names(job) - INJECTED_SECRETS) + if named: + faults.append( + f"{rel}: job `{job_id}` names stored secret(s) on the pull_request route: " + f"{', '.join(named)}. Gitea resolves a `pull_request` run from the PR HEAD, so this " + f"YAML is contributor-authored and every secret it names is handed to that run. " + f"Take the credential out of the job (the toolchain image pulls anonymously and the " + f"commit-status API answers unauthenticated), or gate the job off the route with " + f"`if: github.event_name != 'pull_request'` the way `build` is. There is " + f"deliberately no exemption list: `ci.pr-route-carries-no-stored-credential`." + ) + return faults + + +def test_no_PULL_REQUEST_route_job_names_a_STORED_secret() -> None: + faults: list[str] = [] + for path in workflow_files(): + rel = path.relative_to(Path(__file__).resolve().parents[2]).as_posix() + faults.extend(stored_secret_faults(rel, yaml.safe_load(path.read_text()))) + assert not faults, "\n".join(faults) + + +def test_the_job_walk_finds_every_secret_reference_the_TEXT_does() -> None: + """The `jobs.` walk and a plain text scan must agree, per file. + + Without it, a reference the walk cannot reach — a workflow-level `env:`, a `defaults:` block, a + shape act_runner grows later — is invisible to the assertion above while it still reports clean. + The walk deliberately covers ALL jobs here, not just the reachable ones, because `build`'s + `REGISTRY_PASSWORD` is real text in a file the scan reads. + + COMMENT-ONLY LINES ARE STRIPPED first, so that a comment DISCUSSING a secret name no job uses + does not read as a reference the walk missed — `review-verdict.yml:104` names + `secrets.GITEA_TOKEN` in prose. Say plainly what that buys TODAY, which is nothing: both halves + are SETS, and measured 2026-09-04 every name any comment mentions is also named by a job, so the + comparison agrees with the strip and without it. It is kept for the case that has not arrived + yet, not because it is currently load-bearing. + + Two blind spots the strip has, stated rather than left to be discovered: + * it is LINE-level, so a TRAILING comment (`foo: bar # secrets.X`) survives into the text + half. The text set is then larger than the walk's, which REDDENS — the safe direction; + * conversely a `#`-prefixed line inside a `run: |` block is a shell comment to a reader and + part of the YAML scalar to the walk, so the strip removes it from the text half only and + the walk's set becomes the larger one. That reddens too, for a benign cause; reword the + comment rather than widening the strip, which would start hiding real references. + """ + disagreements: list[str] = [] + for path in workflow_files(): + text = path.read_text() + doc = yaml.safe_load(text) + jobs = doc.get("jobs") if isinstance(doc, dict) else None + walked = secret_names(jobs) if isinstance(jobs, dict) else set() + scanned = set( + _SECRET_REF.findall("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))) + ) + if walked != scanned: + disagreements.append( + f"{path.name}: the jobs walk reached {sorted(walked)} but the text scan found " + f"{sorted(scanned)}. The walk is what the stored-secret assertion runs over, so the " + f"difference is secret references this guard cannot see — widen `secret_names`' " + f"entry point, or move the reference inside a job." + ) + assert not disagreements, "\n".join(disagreements) + + +def test_the_PULL_REQUEST_populations_are_NOT_empty() -> None: + """Anti-vacuity in three directions, because each one alone can empty the assertion silently.""" + docs = [(p, yaml.safe_load(p.read_text())) for p in workflow_files()] + pr_workflows = [(p, d) for p, d in docs if runs_on_pull_request(d)] + assert pr_workflows, ( + "no tracked workflow was read as triggering on `pull_request`. Either the repo really has " + "none, or `workflow_triggers` has stopped resolving the `on:` key — note YAML 1.1 gives it " + "back as the boolean True. Every assertion above would pass having measured nothing." + ) + reachable = sum(len(pull_request_jobs(d)) for _, d in pr_workflows) + assert reachable > 0, ( + f"{len(pr_workflows)} workflow(s) trigger on `pull_request` and not one reachable job was " + "found — `pull_request_jobs` is excluding everything, so the guard measures an empty set." + ) + named = set() + for _, doc in pr_workflows: + jobs = doc.get("jobs") if isinstance(doc, dict) else None + if isinstance(jobs, dict): + named |= secret_names(jobs) + assert "REGISTRY_PASSWORD" in named, ( + "`REGISTRY_PASSWORD` is no longer named anywhere in the pull_request-triggered workflows. " + "It is supposed to survive in `build`, which is gated off the route — if the secret was " + "renamed, this guard has quietly become a check on a string nothing uses, so update the " + "name here in the same commit." + ) + + +def test_a_job_whose_IF_is_UNRECOGNISED_stays_on_the_pull_request_route() -> None: + """The fail-closed direction of the `if:` pin, driven rather than assumed. + + `PR_EXCLUDING_IFS` is one string. Every other condition — including ones that a human can see + exclude the route — must leave the job in the population, so that widening the pin is a + deliberate edit and never an accident of expression parsing. + """ + for condition in ( + "github.event_name == 'push'", + "${{ github.event_name != 'pull_request' && true }}", + "github.ref == 'refs/heads/main'", + "always()", + ): + job = {"if": condition, "env": {"X": "${{ secrets.REGISTRY_PASSWORD }}"}} + doc = {True: ["pull_request"], "jobs": {"j": job}} + assert [job_id for job_id, _ in pull_request_jobs(doc)] == ["j"], condition + assert len(stored_secret_faults("synthetic.yml", doc)) == 1, condition + + gated = {"if": "${{ github.event_name != 'pull_request' }}", "env": {"X": "${{ secrets.REGISTRY_PASSWORD }}"}} + excluded = {True: ["pull_request"], "jobs": {"j": gated}} + assert pull_request_jobs(excluded) == [] + assert stored_secret_faults("synthetic.yml", excluded) == [] + + +def test_the_stored_secret_collector_reports_a_pull_request_job_naming_ONE() -> None: + """Negative control on the collector, over every shape the credential actually entered through.""" + for job in ( + {"container": {"image": "x", "credentials": {"username": "${{ secrets.REGISTRY_USER }}"}}}, + {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}}, + {"steps": [{"run": "x", "env": {"A": "${{ secrets.RENOVATE_TOKEN }}"}}]}, + {"steps": [{"run": "echo ${{ secrets.SERVERMGMT_DEPLOY_KEY }}"}]}, + ): + doc = {True: {"pull_request": None}, "jobs": {"j": job}} + faults = stored_secret_faults("synthetic.yml", doc) + assert len(faults) == 1, job + assert "names stored secret(s) on the pull_request route" in faults[0] + + # The injected token is not a stored secret and must not be reported. + injected = {True: {"pull_request": None}, "jobs": {"j": {"env": {"A": "${{ secrets.GITEA_TOKEN }}"}}}} + assert stored_secret_faults("synthetic.yml", injected) == [] + + # A workflow that never triggers on `pull_request` is out of the population entirely, and + # `pull_request_target` does NOT put it back in: that trigger is base-resolved, so the YAML that + # runs is `main`'s rather than the head's. + for trigger in ({"push": None}, {"pull_request_target": None}, {"workflow_dispatch": None}): + off_route = {True: trigger, "jobs": {"j": {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}}}} + assert stored_secret_faults("synthetic.yml", off_route) == [], trigger + + +def test_MUTATION_the_BUILD_job_losing_its_route_EXCLUSION_is_DETECTED() -> None: + """Drives the SHIPPED workflow, one clause changed — not a synthetic document. + + `build` is the one job that still holds `REGISTRY_PASSWORD`, and the only thing keeping it off + the head-authored route is its `if: github.event_name != 'pull_request'`. Deleting that clause is + the whole defect this guard exists for, so the guard is required to name it, by job id and by + secret. A synthetic fixture cannot show that: it would prove the collector works on a document + this repo does not ship. + """ + root = Path(__file__).resolve().parents[2] + doc = yaml.safe_load((root / ".gitea/workflows/docker-build.yml").read_text()) + assert stored_secret_faults("docker-build.yml", doc) == [], "the unmutated tree must be clean" + + build = doc["jobs"]["build"] + assert _unwrap_expression(build["if"]) in PR_EXCLUDING_IFS, ( + "`build`'s `if:` is no longer the pinned pull_request exclusion, so this mutation no longer " + f"changes anything — it reads {build.get('if')!r}. Re-point the mutation before trusting it." + ) + assert "REGISTRY_PASSWORD" in secret_names(build), "`build` no longer names the secret this mutation is about" + + del build["if"] + faults = stored_secret_faults("docker-build.yml", doc) + assert len(faults) == 1, f"expected exactly one fault, got {faults}" + assert "job `build`" in faults[0] and "REGISTRY_PASSWORD" in faults[0], faults[0] -- 2.47.3 From b71bf4d7b227dc008d21369de402e80321184535 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:28:15 +0200 Subject: [PATCH 02/31] fix(885): a name SET cannot see a second copy, so the PR-route guard judges the workflow scope and the cross-check COUNTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped guard walked `jobs.` only and leaned on a text-versus-walk cross-check to catch anything the walk could not reach. That cross-check compared per-file NAME SETS, and the two halves cancelled on the one file the invariant is about: measured 2026-09-05 at 59003d5a3, hoisting env: ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} into `.gitea/workflows/docker-build.yml`'s root `env:` — which materialises into EVERY job on the head-authored PR route — left `pytest scripts/tests/test_workflow_persist_credentials.py -q` at `14 passed`, rc=0. The same hoist in `pr-checks.yml` reddened, because no job there already names those secrets. The guard could only ever see a name NO job used; a second copy of a reference `build` legitimately keeps naming changed no set. That is `dont-keep-a-copy-of-a-set` / `proof-sharing-with-subject-proves-nothing`: the proof shared its accumulator with its subject and cancelled. Two changes, because the cross-check was being asked to do the assertion's job: * the workflow scope (everything outside `jobs:`) is now judged in its own right by the same structure-blind collector — it is a second entry site on equal footing with the job subtree, not an edge case, since no job-level `if:` can take a root `env:`/`defaults:` off the route; * the cross-check walks the whole document and compares occurrence COUNTS. A duplicate at an unreachable location now reddens: probed 2026-09-05, a trailing `# ${{ secrets.REGISTRY_PASSWORD }}` on a root `env:` line reports `walk [('REGISTRY_PASSWORD', 1)] vs text [('REGISTRY_PASSWORD', 2)]` where the set version agreed. Under counting the comment strip becomes load-bearing rather than the no-op the old docstring admitted it was. Driven by a mutation on the SHIPPED `docker-build.yml`, the way the `build`-loses- its-`if:` mutation already is, plus a direct assertion on the two collectors that a duplicated reference changes the count and not the names. Witnessed red with the hoist in the tree (3 failed) and green without it (17 passed). The decision record's own claims were false in the same way and are corrected: `rule:` said "NO job ... may name a stored secret" (a root `env:` is not a job) and the prose said "a text-versus-walk cross-check reports any reference the walk cannot reach". Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/decisions/README.md | 2 +- .../pr-route-carries-no-stored-credential.md | 29 ++- .../test_workflow_persist_credentials.py | 190 ++++++++++++++++-- 3 files changed, 193 insertions(+), 28 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 636e73fe5..7e9e37ca4 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -61,7 +61,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | | `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) | -| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO job of a `pull_request`-triggered workflow may name a stored secret. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, and a job is in scope unless its `if:` is the one pinned exclusion string. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | +| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | | `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) | | `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark ` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always --gated `, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) | | `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) | diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index f951e7493..c5f591600 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -1,12 +1,12 @@ --- key: ci.pr-route-carries-no-stored-credential -title: '2026-09-04 — No job on the `pull_request` route names a stored secret; the registry and the status API are read ANONYMOUSLY (#885)' +title: '2026-09-04 — Nothing on the `pull_request` route names a stored secret — no job AND no workflow scope; the registry and the status API are read ANONYMOUSLY (#885)' status: active since: '2026-09-04' supersedes: none superseded-by: none -rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO job of a `pull_request`-triggered workflow may name a stored secret. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, and a job is in scope unless its `if:` is the one pinned exclusion string. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message.' -signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' +rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message.' +signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. NOT MEASURED FROM THIS SESSION, and it is the one thing this change cannot establish locally: whether act_runner''s daemon performs the credential-free `container:` pull. The HTTP-level anonymous read is proven; the daemon''s behaviour is first observable on a real PR run, where a failure is loud (both required contexts die at image pull in 1-2s) rather than silent.' --- @@ -42,8 +42,21 @@ that the repo or the package has stopped being public. than a hand-written list — it is a list with a false claim of completeness attached (`testing.guard-derives-population-from-source`, `an-issues-file-list-is-not-the-population`). The shipped predicate reads the whole job subtree for any `secrets.*`, so the entry point the credential -takes does not matter, and a text-versus-walk cross-check reports any reference the walk cannot -reach. +takes does not matter. + +**The second site is the WORKFLOW SCOPE, and a name-set cross-check could not see it.** A root +`env:` (or `defaults:`) is materialised into every job, so on this trigger it is on the head-authored +route by definition and no job-level `if:` reaches it — it is a site on equal footing with the job +subtree, not an edge case. The first cut of the guard walked `jobs.` only and leaned on a +text-versus-walk cross-check to catch what the walk could not reach; that cross-check compared per-file +NAME SETS, and measured 2026-09-05 hoisting +`ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}` into +`docker-build.yml`'s root `env:` left it green (`14 passed`), because `build` legitimately keeps naming +both names and the two halves cancelled. A set answers "does this name appear somewhere", which is the +one fact a second copy of the same reference cannot change — the `dont-keep-a-copy-of-a-set` / +`proof-sharing-with-subject-proves-nothing` shape. So the collector now judges the workflow scope in +its own right, and the cross-check walks the whole document and compares occurrence COUNTS. Both are +driven by mutations on the shipped `docker-build.yml` rather than on a synthetic document. **The `if:` classifier is a PIN, not a parser.** Exactly one string — `github.event_name != 'pull_request'` — takes a job off the route. Anything else, including @@ -52,6 +65,12 @@ expressions a human can see are equivalent, leaves the job IN the population. Pa after repeated defects from that one mechanism; a pin can only ever be too demanding, which is the safe direction, and widening it is then a deliberate edit rather than an accident. +**A known-stale claim elsewhere.** `docs/remote-state-inventory.md`'s row for +`scripts/ci-toolchain-image-resolves.sh` still lists "an unusable credential" among the shapes that +fail the job. There is no credential in that script any more, so the clause describes a check that no +longer exists; `test_remote_state_inventory.py` asserts file-set equality only and cannot see it. +Tracked as #909 — do not read that row's failure list as current. + **The residual, stated so nobody cites this record as a boundary.** Head-supplied YAML can still name every secret in the store — the committed workflows' references were never the limit (`ci.gate-trigger-base-resolved`, `ci.workflow-dispatch-ref-unrestricted`). What this removes is diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 5bf7eeecb..5b0316e5d 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -27,6 +27,7 @@ habit. `test_no_workflow_hides_in_a_SUBDIRECTORY` covers nesting below `.gitea/w from __future__ import annotations import re +from collections import Counter from pathlib import Path import yaml @@ -425,11 +426,61 @@ def secret_names(node: object) -> set[str]: return found +def secret_name_counts(node: object) -> Counter: + """`secret_names`, counting OCCURRENCES rather than collapsing them to a set of names. + + Same traversal, different accumulator, so the cross-check below measures the same walk the + assertion runs on. Kept beside `secret_names` rather than replacing it: the fault collector + genuinely wants names (it reports which secret a job holds, once), and only the cross-check + needs locations. + """ + found: Counter = Counter() + stack: list[object] = [node] + while stack: + item = stack.pop() + if isinstance(item, dict): + for key, value in item.items(): + stack.append(key) + stack.append(value) + elif isinstance(item, list): + stack.extend(item) + elif isinstance(item, str): + found.update(_SECRET_REF.findall(item)) + return found + + +def outside_jobs(doc: object) -> dict: + """Everything in a workflow document EXCEPT `jobs:` — the workflow scope. + + A root `env:` is materialised into every job, and `defaults:` likewise; neither is reachable by + a walk that starts at `jobs.`, and no job-level `if:` can take a workflow-scope reference off + the route. So the scope is a SECOND site the credential can enter through, on equal footing with + the job subtree, and it is walked by the same structure-blind collector rather than by naming + `env:` and `defaults:` — naming them would reproduce the hand-written-population mistake the + job-level predicate already avoids (`testing.guard-derives-population-from-source`). + """ + if not isinstance(doc, dict): + return {} + return {key: value for key, value in doc.items() if key != "jobs"} + + def stored_secret_faults(rel: str, doc: object) -> list[str]: """Human-readable faults for one workflow — accumulated, not failed fast.""" faults: list[str] = [] if not runs_on_pull_request(doc): return faults + workflow_scope = sorted(secret_names(outside_jobs(doc)) - INJECTED_SECRETS) + if workflow_scope: + faults.append( + f"{rel}: the WORKFLOW SCOPE (outside `jobs:`) names stored secret(s) on the " + f"pull_request route: {', '.join(workflow_scope)}. A root `env:` or `defaults:` is " + f"materialised into EVERY job, so no job-level " + f"`if: github.event_name != 'pull_request'` can take it off the head-authored route. " + f"Move the reference into a job that is gated off the route the way `build` is, or " + f"take the credential out entirely (the toolchain image pulls anonymously and the " + f"commit-status API answers unauthenticated). There is deliberately no exemption " + f"list: `ci.pr-route-carries-no-stored-credential`." + ) for job_id, job in pull_request_jobs(doc): named = sorted(secret_names(job) - INJECTED_SECRETS) if named: @@ -453,46 +504,83 @@ def test_no_PULL_REQUEST_route_job_names_a_STORED_secret() -> None: assert not faults, "\n".join(faults) -def test_the_job_walk_finds_every_secret_reference_the_TEXT_does() -> None: - """The `jobs.` walk and a plain text scan must agree, per file. +def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: + """The document walk and a plain text scan must agree per file, COUNTED not merely named. - Without it, a reference the walk cannot reach — a workflow-level `env:`, a `defaults:` block, a - shape act_runner grows later — is invisible to the assertion above while it still reports clean. - The walk deliberately covers ALL jobs here, not just the reachable ones, because `build`'s - `REGISTRY_PASSWORD` is real text in a file the scan reads. + The two halves of the assertion above — the workflow scope and each reachable job — are both + reached from the document root, so the cross-check is over the whole document rather than over + `jobs.`. A reference the walk cannot reach at all (a shape act_runner grows later, a key the + loader drops) is then a disagreement, and the guard says so instead of reporting clean. - COMMENT-ONLY LINES ARE STRIPPED first, so that a comment DISCUSSING a secret name no job uses - does not read as a reference the walk missed — `review-verdict.yml:104` names - `secrets.GITEA_TOKEN` in prose. Say plainly what that buys TODAY, which is nothing: both halves - are SETS, and measured 2026-09-04 every name any comment mentions is also named by a job, so the - comparison agrees with the strip and without it. It is kept for the case that has not arrived - yet, not because it is currently load-bearing. + IT COUNTS OCCURRENCES, IT DOES NOT COMPARE NAME SETS, and that is the load-bearing part rather + than a refinement. Measured 2026-09-05 on the shipped tree at 59003d5a3: hoisting + `ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}` into + `docker-build.yml`'s root `env:` left a set-comparison cross-check GREEN (`14 passed`), because + `build` legitimately keeps naming both names — the two halves cancelled and the check could only + ever see a name NO job already used. That is the `dont-keep-a-copy-of-a-set` / + `proof-sharing-with-subject-proves-nothing` shape: a set says a name appears SOMEWHERE, which is + exactly the fact a second copy of the same reference does not change. A count changes. + + COMMENT-ONLY LINES ARE STRIPPED first, so that a comment DISCUSSING a secret name does not read + as a reference the walk missed — `review-verdict.yml` names `secrets.GITEA_TOKEN` in prose, and + under counting that strip IS load-bearing (a set comparison forgave it; a count does not). Two blind spots the strip has, stated rather than left to be discovered: * it is LINE-level, so a TRAILING comment (`foo: bar # secrets.X`) survives into the text - half. The text set is then larger than the walk's, which REDDENS — the safe direction; + half. The text count is then larger than the walk's, which REDDENS — the safe direction; * conversely a `#`-prefixed line inside a `run: |` block is a shell comment to a reader and part of the YAML scalar to the walk, so the strip removes it from the text half only and - the walk's set becomes the larger one. That reddens too, for a benign cause; reword the + the walk's count becomes the larger one. That reddens too, for a benign cause; reword the comment rather than widening the strip, which would start hiding real references. + + A YAML anchor/alias would also redden benignly (the walk visits the aliased node once per + reference, the text carries `*alias`); measured 2026-09-05 no tracked workflow uses one. """ disagreements: list[str] = [] + total = Counter() for path in workflow_files(): text = path.read_text() - doc = yaml.safe_load(text) - jobs = doc.get("jobs") if isinstance(doc, dict) else None - walked = secret_names(jobs) if isinstance(jobs, dict) else set() - scanned = set( + walked = secret_name_counts(yaml.safe_load(text)) + total += walked + scanned = Counter( _SECRET_REF.findall("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))) ) if walked != scanned: disagreements.append( - f"{path.name}: the jobs walk reached {sorted(walked)} but the text scan found " - f"{sorted(scanned)}. The walk is what the stored-secret assertion runs over, so the " - f"difference is secret references this guard cannot see — widen `secret_names`' " - f"entry point, or move the reference inside a job." + f"{path.name}: the document walk reached {sorted(walked.items())} but the text scan " + f"found {sorted(scanned.items())}. The walk is what the stored-secret assertion runs " + f"over, so the difference is secret references this guard cannot see — widen " + f"`secret_names`' entry point, or take the reference out of the file." ) assert not disagreements, "\n".join(disagreements) + assert sum(total.values()) > 0, ( + "no workflow reached by this check names a single `secrets.*` reference. Two empty Counters " + "compare equal, so the loop above would agree having measured nothing — either every " + "reference really is gone (say so here and in `ci.pr-route-carries-no-stored-credential`) " + "or `workflow_files()`/`_SECRET_REF` has stopped matching." + ) + + +def test_the_cross_check_COUNTS_locations_rather_than_collecting_NAMES() -> None: + """The property that distinguishes this check from the one it replaced, asserted directly. + + A duplicated reference — the same secret named at a second location — changes no NAME and + changes the COUNT. That difference is the entire reason a workflow-level hoist of a name `build` + already uses was invisible before, so it is pinned on the collectors themselves rather than left + to be inferred from the mutation tests. + """ + once = {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}} + twice = {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}", "B": "${{ secrets.REGISTRY_PASSWORD }}"}} + + assert secret_names(once) == secret_names(twice) == {"REGISTRY_PASSWORD"} + assert secret_name_counts(once) == Counter({"REGISTRY_PASSWORD": 1}) + assert secret_name_counts(twice) == Counter({"REGISTRY_PASSWORD": 2}) + assert secret_name_counts(once) != secret_name_counts(twice) + + # Two references in ONE scalar count twice, which is the shape a hoisted + # `user:${{ secrets.X }}:${{ secrets.Y }}` line takes. + pair = {"env": {"A": "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}"}} + assert secret_name_counts(pair) == Counter({"REGISTRY_USER": 1, "REGISTRY_PASSWORD": 1}) def test_the_PULL_REQUEST_populations_are_NOT_empty() -> None: @@ -571,6 +659,64 @@ def test_the_stored_secret_collector_reports_a_pull_request_job_naming_ONE() -> assert stored_secret_faults("synthetic.yml", off_route) == [], trigger +def test_the_collector_reports_a_WORKFLOW_SCOPE_reference_no_job_if_can_reach() -> None: + """The workflow scope is judged even when every job is gated OFF the route. + + This is the case a job-only walk gets exactly backwards: the document looks maximally safe (its + one job carries the pinned exclusion) while the root `env:` is materialised into that job's + environment on the head-authored route anyway. The fault must therefore not depend on any job + being in the population. + """ + for scope in ( + {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}}, + {"defaults": {"run": {"shell": "bash -c 'echo ${{ secrets.RENOVATE_TOKEN }}'"}}}, + ): + doc = { + True: {"pull_request": None}, + **scope, + "jobs": {"j": {"if": "github.event_name != 'pull_request'", "steps": [{"run": "true"}]}}, + } + faults = stored_secret_faults("synthetic.yml", doc) + assert len(faults) == 1, (scope, faults) + assert "WORKFLOW SCOPE" in faults[0], faults[0] + assert pull_request_jobs(doc) == [], "the job is supposed to be OFF the route in this fixture" + + # The injected token is not a stored secret at workflow scope either. + injected = {True: {"pull_request": None}, "env": {"A": "${{ secrets.GITEA_TOKEN }}"}, "jobs": {}} + assert stored_secret_faults("synthetic.yml", injected) == [] + + # And the scope is only judged on the route: a push-only workflow may hold one. + off_route = {True: {"push": None}, "env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}, "jobs": {}} + assert stored_secret_faults("synthetic.yml", off_route) == [] + + +def test_MUTATION_a_WORKFLOW_LEVEL_env_in_the_SHIPPED_workflow_is_DETECTED() -> None: + """Drives the SHIPPED `docker-build.yml`, hoisting the credential to workflow scope. + + This is the mutation that a name-set cross-check could not see (measured 2026-09-05 at + 59003d5a3: `14 passed`, rc=0) — `build` already names both halves, so a second copy at root + changed no NAME. It is exercised on the real file rather than a synthetic document for the same + reason `test_MUTATION_the_BUILD_job_losing_its_route_EXCLUSION_is_DETECTED` is: `docker-build.yml` + is the file the invariant is about, and a fixture would only prove the collector works on a + document this repo does not ship. + """ + root = Path(__file__).resolve().parents[2] + doc = yaml.safe_load((root / ".gitea/workflows/docker-build.yml").read_text()) + assert stored_secret_faults("docker-build.yml", doc) == [], "the unmutated tree must be clean" + assert runs_on_pull_request(doc), "`docker-build.yml` no longer triggers on pull_request" + assert "REGISTRY_PASSWORD" in secret_names(doc["jobs"]["build"]), ( + "`build` no longer names `REGISTRY_PASSWORD`, so this mutation no longer reproduces the " + "cancelling name set it was written for — re-point it before trusting it." + ) + assert secret_names(outside_jobs(doc)) == set(), "the shipped workflow scope must name no secret" + + doc["env"]["ETV_REGISTRY_AUTH"] = "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}" + faults = stored_secret_faults("docker-build.yml", doc) + assert len(faults) == 1, f"expected exactly one fault, got {faults}" + assert "WORKFLOW SCOPE" in faults[0], faults[0] + assert "REGISTRY_PASSWORD" in faults[0] and "REGISTRY_USER" in faults[0], faults[0] + + def test_MUTATION_the_BUILD_job_losing_its_route_EXCLUSION_is_DETECTED() -> None: """Drives the SHIPPED workflow, one clause changed — not a synthetic document. -- 2.47.3 From bbd919bfe396bf9c10d06abd89f817fb83f8c425 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:28:37 +0200 Subject: [PATCH 03/31] fix(885): a refusal carrying NO challenge never ran the token leg, so it stops claiming one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `probe` enters `acquire_token` on a `401` only. A registry answering `403` on the first read — or a `401` with no `Www-Authenticate` — therefore leaves `token_leg_done=0` and `token=""`, the guard at the `401|403` arm is false, and the run fell through to the message that says the read was refused "even after a Bearer token was obtained". Probed 2026-09-05 with a curl shim answering `403` and dumping only `HTTP/1.1 403 Forbidden`: that message is printed, EXIT=1, and no token was ever requested. The fail direction was safe; the diagnosis was not. It sends an operator to package visibility on evidence that does not exist (`dont-narrate-mechanisms-you-didnt-measure`) — in a script whose whole design is that its refusal messages are worded apart on purpose. The arm now branches on what actually ran, `token` first so the never-asked case cannot borrow either other mechanism: * `token` non-empty -> refused after a GOOD bearer (an answer about the PACKAGE) * token leg attempted -> challenged but produced no token (about the TOKEN ENDPOINT) * neither -> refused with no challenge at all (about ACCESS) The pre-existing `403` test could not reach this: `CURL_SHIM` answered `401` + a challenge to every unauthenticated read regardless of the configured code, so the `403` parameter was only ever observable AFTER the token leg. The shim grew a challenge-less behaviour (`CHALLENGE=none`, `REFUSAL=403|401`) rather than the assertion being written against the old one, and both codes are driven because they take different paths — the challenge-less `401` still enters and abandons the token leg. Witnessed red on the predecessor script (2 failed) and green on the fix. `docs/ci-cd.md`'s "Cutting a release" runbook — the section an operator reads at cut time — gains the `v*` tag protection, the account it whitelists, the fact that its positive half is unverified, and the `DELETE .../tag_protections/1` unblock. The tag-protection note already in this file sits inside the `main`-direct-push discussion, which is not where a release cut is driven from, and `release.tag-protection-v-star` names its own failure mode as a cut that will not push. `ci.pr-route-carries-no-stored-credential` records that `docs/remote-state-inventory.md`'s row for the preflight still lists "an unusable credential" among the shapes that fail the job, which this issue deleted. That file is held by a concurrent change, so the one-clause edit is tracked as #909 rather than made here. Refs #885, #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/ci-cd.md | 12 ++++ scripts/ci-toolchain-image-resolves.sh | 32 +++++++---- .../tests/test_ci_toolchain_image_resolves.py | 57 ++++++++++++++++++- 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 6f96d8af0..8510bde96 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -101,6 +101,18 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada 1. Confirm `main` CI is green; run the full local gate plus `dotnet list package --vulnerable --include-transitive`; then push a `vYY.N.P` tag on that exact `main` commit. + + > **`v*` tags are protected — push as `timothy`.** Since ersatztv#885 the repo carries one + > tag-protection rule (`name_pattern: v*`, `whitelist_usernames: ["timothy"]`, + > `release.tag-protection-v-star`), because a `v*` push builds and publishes `:prod`. A push from + > any other account is refused. **The positive half of that rule is UNVERIFIED**: it was applied + > 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because + > pushing a `v*` tag outside a real cut would publish `:prod`. This cut is its first real + > exercise. If the tag push is refused, unblock with + > `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`, push, then re-`POST` the rule + > (prefer a `PATCH` adding the account if the cut has moved to a different operator, so the + > protection is never left off) — and record the outcome on `release.tag-protection-v-star`, + > which is waiting for it. 2. Wait for tag CI to build `:prod` + the immutable `:` + `:` images. Run `scripts/security-scan.sh` on jazz against **the immutable `:` image**, not a moving tag, and triage every ZAP/semgrep finding. diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index d4ab3e80f..95abe988a 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -191,22 +191,34 @@ for pin in $pins; do # `fail` rather than `rc=1`: unlike a 404, this says nothing about the pin, and it will say # the same thing about every remaining one. Abandoning the loop keeps the log to one cause. # - # Reached only after the token leg has been ATTEMPTED, and the two ways of getting here send - # an operator to different places, so they get different messages — the same reason `404` and - # `could NOT VERIFY` are worded apart. A challenge that yielded no usable token is an - # infrastructure answer about the TOKEN ENDPOINT; a refusal that survived a good token is an - # answer about this PACKAGE, and since ersatztv#885 it is not a preflight-only problem: every - # `container:` job pulls the same image with no credential, so they fail at image pull too, - # including both required contexts. + # THREE ways to get here, and they send an operator to three different places, so they are + # worded apart — the same reason `404` and `could NOT VERIFY` are. Each message states only + # what actually ran, because a message naming a step that did not happen is evidence for a + # diagnosis nobody performed: + # + # * a refusal that survived a GOOD token is an answer about this PACKAGE, and since + # ersatztv#885 it is not a preflight-only problem: every `container:` job pulls the same + # image with no credential, so they fail at image pull too, including both required + # contexts; + # * a challenge that yielded no usable token is an infrastructure answer about the TOKEN + # ENDPOINT; + # * a refusal carrying NO Bearer challenge at all never reached the token leg — `probe` + # enters it on a 401 only, so a first-read 403 (or a 401 with no `Www-Authenticate`, which + # the token leg then abandons) leaves `token` empty having asked for nothing. This is an + # answer about ACCESS to the registry, and the branch order below is `token` first + # precisely so this case cannot borrow either of the other two mechanisms. # # A failed token leg is NOT retried, deliberately: `token_leg_done` is set before the attempt # so a registry genuinely refusing anonymous reads is asked once rather than once per pin. The # cost is that a transient token-endpoint outage fails the job on its first try; the message # below says so rather than blaming the package. - if [ "$token_leg_done" -eq 1 ] && [ -z "$token" ]; then - fail "could NOT OBTAIN an anonymous pull token for $registry/$image_repo:$pin — the registry challenged (HTTP $code) but the token leg produced none: either the challenge named no realm, or the token endpoint did not answer with a token. The pin was NOT checked. Look at the registry's token endpoint, not at the pin." + if [ -n "$token" ]; then + fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin even after a Bearer token was obtained, so the pin could not be checked. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that timothy/ersatztv and its ersatztv-ci package are still PUBLIC — do not read this as a pass." fi - fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin even after a Bearer token was obtained, so the pin could not be checked. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that timothy/ersatztv and its ersatztv-ci package are still PUBLIC — do not read this as a pass." + if [ "$token_leg_done" -eq 1 ]; then + fail "could NOT OBTAIN an anonymous pull token for $registry/$image_repo:$pin — the registry answered HTTP $code and the token leg produced none: either there was no Www-Authenticate challenge, or it named no realm, or the token endpoint did not answer with a token. The pin was NOT checked. Look at the registry's token endpoint, not at the pin." + fi + fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin WITHOUT issuing a Bearer challenge, so no token was ever requested and the pin could not be checked. A challenge is what tells a client where a token can be had; an outright refusal is an answer about ACCESS to the registry, not about the pin. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that timothy/ersatztv and its ersatztv-ci package are still PUBLIC, and that nothing (a proxy, an ACL) is answering for the registry — do not read this as a pass." ;; *) # NOT gone, and NOT a pass either. Deliberately worded apart from the 404 message: this sends diff --git a/scripts/tests/test_ci_toolchain_image_resolves.py b/scripts/tests/test_ci_toolchain_image_resolves.py index 99334b0a6..9a117eaa6 100644 --- a/scripts/tests/test_ci_toolchain_image_resolves.py +++ b/scripts/tests/test_ci_toolchain_image_resolves.py @@ -85,6 +85,17 @@ code = codes.get(tag, codes.get("*", "200")) if code == "TRANSPORT": sys.exit(7) +if authorization != "Bearer anon-token" and codes.get("CHALLENGE") == "none": + # A registry that REFUSES an unauthenticated read outright instead of challenging: no + # Www-Authenticate header at all, so a client has nowhere to ask for a token. `REFUSAL` picks + # the code, because a 403 and a challenge-less 401 take DIFFERENT paths through the script -- + # `probe` enters the token leg on 401 only. + refusal = codes.get("REFUSAL", "403") + if dump: + pathlib.Path(dump).write_text("HTTP/1.1 %s Refused\r\n" % refusal) + print("{}\n%s" % refusal, end="") + sys.exit(0) + if authorization != "Bearer anon-token": # 401 for a live tag and a deleted one alike -- the challenge is the ONLY thing that tells a # client where a token can be had. @@ -271,8 +282,52 @@ def test_a_401_or_403_AFTER_the_token_leg_REFUSES_rather_than_passing(preflight, result = preflight.run() assert result.returncode != 0 assert "refused an ANONYMOUS read" in result.stderr + assert "even after a Bearer token was obtained" in result.stderr, ( + "this arm is the one where a token really was obtained, so it is the only one allowed to " + f"say so. stderr={result.stderr}" + ) assert "PUBLIC" in result.stderr, "the message must name the cause an operator can act on" - assert preflight.token_calls(), "the token leg must have been attempted before refusing" + assert preflight.authenticated_manifest_calls(), ( + "a message claiming the read survived a Bearer token must be reached with a read that " + "CARRIED one — `token_calls` only shows the token was asked for" + ) + + +@pytest.mark.parametrize("refusal", ["403", "401"]) +def test_a_refusal_with_NO_CHALLENGE_never_claims_a_token_was_obtained(preflight, refusal): + """The message must not name a mechanism the run did not perform. + + `probe` enters the token leg on a `401` only, so a registry answering `403` on the first read — + or a `401` carrying no `Www-Authenticate` — leaves the script with no token having asked for + nothing. Measured 2026-09-05 on the predecessor at 59003d5a3, the `403` shape reported "even + after a Bearer token was obtained", which sends an operator to package visibility on evidence + that does not exist (`dont-narrate-mechanisms-you-didnt-measure`). The two codes are BOTH driven + because they take different paths: the challenge-less `401` still enters and abandons the token + leg, the `403` never enters it. + + The shim answering 401 + a challenge to every unauthenticated read is why the pre-existing + `403` case could not reach this — it could only ever be observed AFTER the token leg — so the + shim grew a challenge-less behaviour rather than the assertion being written against the old one. + """ + preflight.set_codes({"*": "200", "CHALLENGE": "none", "REFUSAL": refusal}) + result = preflight.run() + assert result.returncode != 0, "an unreadable registry is not a pass" + assert "even after a Bearer token was obtained" not in result.stderr, ( + f"no token was obtained on this path. stderr={result.stderr}" + ) + assert preflight.authenticated_manifest_calls() == [], "no read can have carried a token here" + if refusal == "403": + assert preflight.token_calls() == [], "a 403 first read must not even ask for a token" + assert "WITHOUT issuing a Bearer challenge" in result.stderr, result.stderr + assert "PUBLIC" in result.stderr, "the message must still name a cause an operator can act on" + else: + # A challenge-less 401 DOES enter the token leg (and abandons it for want of a realm), so it + # is the token-endpoint diagnosis rather than the never-asked one. + assert preflight.token_calls() == [], "there was no realm to request a token from" + assert "could NOT OBTAIN an anonymous pull token" in result.stderr, result.stderr + assert "no Www-Authenticate challenge" in result.stderr, ( + f"the token-leg message must admit the challenge was missing. stderr={result.stderr}" + ) def test_the_TOKEN_LEG_actually_runs_and_the_bearer_REACHES_the_registry(preflight): -- 2.47.3 From 2f661be684033fe20ef832e1d9e3ae3add35feb0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:41:38 +0200 Subject: [PATCH 04/31] fix(885): the outcome table gains the third refusal arm, and the collector stops keeping a second copy of its own walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ons from the fixes in this branch, both of the class the branch is about. `docs/ci-cd.md`'s preflight outcome table listed two token-leg rows and now needs three: a `401`/`403` carrying no `Www-Authenticate` at all never reaches the token leg, and the table is what an operator reads to decide where a red preflight sends them. The paragraph after it named "the two token-leg rows" and now says why the three are worded apart at all — a message naming a step the run skipped is evidence for a diagnosis nobody performed. `secret_name_counts` was added beside `secret_names` as a second traversal with a different accumulator. That is a copy of a mechanism, free to drift from the one the assertion runs on — the guard reproducing, inside itself, the defect it was just widened to catch. There is now ONE walk: the counting one, with `secret_names` derived from it, which is the lossless direction. Re-witnessed after the refactor — the workflow-scope hoist into the shipped `docker-build.yml` still reports 3 failed, the clean tree 17 passed. Refs #885 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/ci-cd.md | 8 ++++- .../test_workflow_persist_credentials.py | 36 +++++++------------ 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8510bde96..3e7fd56b9 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2454,9 +2454,15 @@ because they send you to different places: | HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) | | 401 / 403 after a token was obtained | **red** | the registry `refused an ANONYMOUS read` — check that `timothy/ersatztv` and its `ersatztv-ci` package are still PUBLIC | | the challenge names no realm, or the token endpoint answers with no token | **red** | `could NOT OBTAIN an anonymous pull token` — worded apart on purpose: this is about the registry's token endpoint, not the package's visibility. Not retried | +| 401 / 403 carrying NO `Www-Authenticate` challenge at all | **red** | `refused an ANONYMOUS read ... WITHOUT issuing a Bearer challenge` — no token was ever requested, because `probe` enters the token leg on a `401` only. An answer about ACCESS to the registry (visibility, or something answering for it) | | anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin | -The two token-leg rows and the `could NOT VERIFY` row are the ones worth defending, because warning +**The three refusal rows say only what actually ran**, and that is the point of wording them apart +rather than a nicety: a message naming a step the run skipped is evidence for a diagnosis nobody +performed. So the arm branches on `token` first — a bearer was obtained — then on whether the token +leg was attempted at all, and only then reports the never-asked case. + +Those three rows and the `could NOT VERIFY` row are the ones worth defending, because warning on them and exiting 0 is the natural way to write this check and it is wrong: a missing `curl`, a moved registry and a DNS change all land in the last of them, and a green-with-a-warning job is indistinguishable from a healthy pin forever after. The diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 5b0316e5d..64354cda0 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -404,35 +404,17 @@ def pull_request_jobs(doc: object) -> list[tuple[str, dict]]: return reachable -def secret_names(node: object) -> set[str]: - """Every `secrets.NAME` reachable anywhere in a subtree — keys and values, at any depth. +def secret_name_counts(node: object) -> Counter: + """Every `secrets.NAME` reachable anywhere in a subtree — keys and values, at any depth — COUNTED. Deliberately structure-blind: the credential entered through `container.credentials`, a step `env:`, and a job `env:` in this repo already, and naming those three places would be the hand-written-population mistake in a different coat. - """ - found: set[str] = set() - stack: list[object] = [node] - while stack: - item = stack.pop() - if isinstance(item, dict): - for key, value in item.items(): - stack.append(key) - stack.append(value) - elif isinstance(item, list): - stack.extend(item) - elif isinstance(item, str): - found.update(_SECRET_REF.findall(item)) - return found - -def secret_name_counts(node: object) -> Counter: - """`secret_names`, counting OCCURRENCES rather than collapsing them to a set of names. - - Same traversal, different accumulator, so the cross-check below measures the same walk the - assertion runs on. Kept beside `secret_names` rather than replacing it: the fault collector - genuinely wants names (it reports which secret a job holds, once), and only the cross-check - needs locations. + Occurrences, not a set of names, because the two consumers want different views of ONE walk and + only this direction is lossless: `secret_names` is derived from it below. A second traversal + with a different accumulator would be a copy of a mechanism, free to drift from the one the + assertion runs on — the shape this guard exists to catch, in the guard itself. """ found: Counter = Counter() stack: list[object] = [node] @@ -449,6 +431,12 @@ def secret_name_counts(node: object) -> Counter: return found +def secret_names(node: object) -> set[str]: + """The names `secret_name_counts` reached. The fault collector wants names — it reports which + secret a job holds, once — and only the cross-check needs locations.""" + return set(secret_name_counts(node)) + + def outside_jobs(doc: object) -> dict: """Everything in a workflow document EXCEPT `jobs:` — the workflow scope. -- 2.47.3 From fba895852cc6f237ffabdc08746e9772193f443a Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:43:49 +0200 Subject: [PATCH 05/31] fix(885): the two named records state the invariant this branch actually ships, not the job-only half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ci.workflow-dispatch-ref-unrestricted` and `ci.actions-credential-scoping` are the two records the issue requires be updated to match, and both restated the invariant as "every job of a `pull_request`-triggered workflow that names a `secrets.*`". That was the shipped predicate when they were written and is now narrower than what the guard holds: the workflow scope outside `jobs:` is judged too, because a root `env:` or `defaults:` is materialised into every job and no job-level `if:` reaches it. A record that understates its own guard is the failure this repo grades worst — it reads as a checked description and stops the next reader looking. `ci.pr-route-carries-no-stored-credential` also names the two inventory rows that this issue made incomplete and did not edit, because both files are held by concurrent changes: `docs/remote-state-inventory.md` still lists "an unusable credential" among the shapes that fail the preflight, and `docs/guard-inventory.md` still describes `test_workflow_persist_credentials.py` as the `actions/checkout` guard alone. Neither goes red — both suites assert set equality over FILES and both files were already listed — so the carry is tracked as #909 rather than left to be discovered. Refs #885, #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/decisions/README.md | 4 ++-- .../decisions/records/ci/actions-credential-scoping.md | 2 +- .../ci/pr-route-carries-no-stored-credential.md | 10 ++++++---- .../records/ci/workflow-dispatch-ref-unrestricted.md | 10 ++++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 7e9e37ca4..bcb7a3f65 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -39,7 +39,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](records/blazor/rollback-tag.md) | | `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](records/blazor/ui-removed.md) | | `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](records/channel/origin-marker.md) | -| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`'s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo's checkouts now set it — 15 in #746 and `ci-image.yml`'s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NO job on the `pull_request` route names a stored secret any more (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) | +| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`'s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo's checkouts now set it — 15 in #746 and `ci-image.yml`'s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NOTHING on the `pull_request` route names a stored secret any more — no job and no workflow scope (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) | | `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](records/ci/batch-pushes-no-cancel-route.md) | | `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](records/ci/build-once-rejected.md) | | `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. THE COMBINED COMMIT-STATUS ENDPOINT CANNOT EXPRESS THIS: `GET /repos/{o}/{r}/commits/{sha}/status` has states `success`/`failure`/`pending`/`error` and NO `cancelled`, so it reports a cancelled job as `failure`. Anything polling that endpoint — which is what a CI monitor naturally polls, because it is the per-sha view the merge gate reads — must resolve the job-level `conclusion` via `actions/runs/{id}/jobs` before reporting a red. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) | @@ -75,7 +75,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and does not post its CLASSIFICATION if EITHER count moved. Since #849 it does not merely abstain either: when the head carries a row this run did not inherit — and that row is neither the repair sentinel nor an allow-listed reviewer's verdict — the arm REPLACES it with the unverified-write sentinel, because abstention is a handoff only when there is nothing to hand off (`ci.verdict-unverified-write-sentinel`). The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): every retarget fires `edited`, which is in this workflow's `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow's `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when the walk read EVERY page up to its 20-page cap and the LAST page came back empty — an empty page BEFORE the cap is SKIPPED rather than read as exhaustion, since Gitea pages this endpoint before it filters and a fully-filtered page is byte-identical to the end of the list (#870) — which NARROWS that defeat about 10x rather than closing it — the 50-row filtered block is unchanged, but the timeline it must sit in grows from ~100 rows to over 1000 — the page-20 terminator still being trusted for the same unprovable reason; an untrusted count (unreadable page, non-array body, non-numeric length, empty FIRST page, a cap reached on a non-empty page) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human's state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR's exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run's `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run's mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | | `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) | | `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) | -| `ci.workflow-dispatch-ref-unrestricted` | Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`'s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853's own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): no job on the `pull_request` route names any stored secret, the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "every job of a `pull_request`-triggered workflow that names a `secrets.*`", DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record's whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`'s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either. | 2026-08-30 | [link](records/ci/workflow-dispatch-ref-unrestricted.md) | +| `ci.workflow-dispatch-ref-unrestricted` | Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`'s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853's own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): nothing on the `pull_request` route names any stored secret — no job and no workflow scope — the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "no part of a `pull_request`-triggered workflow may name a `secrets.*`" — every job the trigger reaches AND the workflow scope outside `jobs:`, since a root `env:`/`defaults:` is materialised into every job and no job-level `if:` can take it off the route — DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record's whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`'s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either. | 2026-08-30 | [link](records/ci/workflow-dispatch-ref-unrestricted.md) | | `ci.workflow-run-body-no-expressions` | A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step's `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard's own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing now also covers `docker-build.yml`'s `test` and `migrations` jobs, where a dropped step is fail-OPEN (the required check goes green having done no work) rather than fail-closed as it is here — see `ci.required-job-step-execution-markers`, which adds per-STEP markers there and extends this file's delimiter ban to those two jobs. It is still not a repo-wide property, but the remaining exceptions are narrower than this record originally said: `build` was brought into the ban too (its `Smoke + IPTV E2E` runs AFTER the image is pushed, so a drop there ships an unsmoked release candidate — its two payloads moved to `env:`, so the ban was free), leaving only `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step that gates nothing that ships. | 2026-08-06 | [link](records/ci/workflow-run-body-no-expressions.md) | | `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](records/concurrency/diff-scalar-fanout.md) | | `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](records/concurrency/etag-rotation-completion.md) | diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index d04a187d5..665c594f5 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -5,7 +5,7 @@ status: active since: '2026-08-05' supersedes: none superseded-by: none -rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NO job on the `pull_request` route names a stored secret any more (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store.' +rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NOTHING on the `pull_request` route names a stored secret any more — no job and no workflow scope (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store.' signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714' mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe): the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run exercises which, and that this CHANGED on 2026-09-04 (#885): all three are now confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so none of them is exercised by a PR at all — the `container:` pull was the one that used to be, and it is now ANONYMOUS and carries no credential to infer about. All three are therefore first exercised on the post-merge push to `main`, AFTER the merge gate has passed, so a wrong inference reddens main, not the PR.' --- diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index c5f591600..8b318d057 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -65,11 +65,13 @@ expressions a human can see are equivalent, leaves the job IN the population. Pa after repeated defects from that one mechanism; a pin can only ever be too demanding, which is the safe direction, and widening it is then a deliberate edit rather than an accident. -**A known-stale claim elsewhere.** `docs/remote-state-inventory.md`'s row for +**Two inventory rows understate this, tracked as #909.** `docs/remote-state-inventory.md`'s row for `scripts/ci-toolchain-image-resolves.sh` still lists "an unusable credential" among the shapes that -fail the job. There is no credential in that script any more, so the clause describes a check that no -longer exists; `test_remote_state_inventory.py` asserts file-set equality only and cannot see it. -Tracked as #909 — do not read that row's failure list as current. +fail the job; there is no credential in that script any more. And `docs/guard-inventory.md` describes +`scripts/tests/test_workflow_persist_credentials.py` as the `actions/checkout` guard only, while it +now also holds this invariant with its own derived population and mutation proofs. Neither goes red: +both suites assert set equality over FILES, and both files were already listed. Do not read either +row as a complete description of what its subject checks. **The residual, stated so nobody cites this record as a boundary.** Head-supplied YAML can still name every secret in the store — the committed workflows' references were never the limit diff --git a/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md b/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md index 75e0237ba..3263250e7 100644 --- a/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md +++ b/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md @@ -6,7 +6,7 @@ since: '2026-08-30' supersedes: none superseded-by: none stale-after: '2027-02-28' -rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): no job on the `pull_request` route names any stored secret, the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "every job of a `pull_request`-triggered workflow that names a `secrets.*`", DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record''s whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either.' +rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): nothing on the `pull_request` route names any stored secret — no job and no workflow scope — the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "no part of a `pull_request`-triggered workflow may name a `secrets.*`" — every job the trigger reaches AND the workflow scope outside `jobs:`, since a root `env:`/`defaults:` is materialised into every job and no job-level `if:` can take it off the route — DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record''s whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either.' signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections, v* tag whitelist, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845' mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"' sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 matching "environment"); `CreateActionWorkflowDispatch` schema; live `tag_protections` = `[]`; live `branch_protections` = one `main` rule; `/api/v1/admin/users` = exactly `timothy` (admin) + `renovate` (non-admin), with `permission: write` from `/repos/timothy/ersatztv/collaborators/renovate/permission`; host `app.ini` `[actions]`; differential dispatch-authorization probe with anonymous and authenticated-non-writer controls' @@ -51,9 +51,11 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat `toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of which `test` and `migrations` are both branch-protection required contexts (`.gitea/required-status-contexts.json`). That was closed in #885 - (`ci.pr-route-carries-no-stored-credential`), by the invariant and not by the list: *every job of a - `pull_request`-triggered workflow that names a `secrets.*`*, derived from the git index, because a - remediation scoped to those six names silently misses the seventh. Not "every `container:` job" + (`ci.pr-route-carries-no-stored-credential`), by the invariant and not by the list: *no part of a + `pull_request`-triggered workflow may name a `secrets.*`* — neither a job the trigger reaches nor + the workflow scope outside `jobs:`, whose `env:`/`defaults:` are materialised into every job — + derived from the git index, because a remediation scoped to those six names silently misses the + seventh. Not "every `container:` job" either — `toolchain-preflight` is deliberately container-free (`runs-on: small`, credential via `ETV_REGISTRY_AUTH`), so that predicate names five of six and is stale before it ships. **The route itself is untouched by that fix**, which is why this record still stands: head-supplied -- 2.47.3 From 985a6e150511a2eabafdbb685d97b9d396dfe3b1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:45:36 +0200 Subject: [PATCH 06/31] =?UTF-8?q?fix(885):=20a=20challenge-less=20401=20la?= =?UTF-8?q?nds=20in=20the=20TOKEN-LEG=20arm,=20not=20the=20never-asked=20o?= =?UTF-8?q?ne=20=E2=80=94=20the=20table=20and=20the=20comment=20said=20oth?= =?UTF-8?q?erwise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row added for the third refusal shape was written as "401 / 403 carrying NO `Www-Authenticate` challenge at all", and the script comment beside it made the same binding. Both are wrong for the 401 half: `probe` enters the token leg on a 401, so a challenge-less 401 DOES call `acquire_token`, which sets `token_leg_done=1` and abandons for want of a realm — it reports `could NOT OBTAIN an anonymous pull token`, the row above. Only a FIRST-READ 403 reaches the never-asked arm. The parametrised test already drives both codes and asserts exactly that split; the prose beside them did not match it. The three rows now bind one shape each: a refusal surviving a bearer the run really obtained, a 401 whose token leg yielded none (no challenge header, no realm, or no token in the answer), and a first-read 403 that asked for nothing. Prose between arms regenerates mis-bindings — which is why the arms are stated as one self-binding row apiece rather than as a category sentence covering two. Refs #885 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/ci-cd.md | 6 +++--- scripts/ci-toolchain-image-resolves.sh | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 3e7fd56b9..e73683619 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2452,9 +2452,9 @@ because they send you to different places: | HTTP 200 with a manifest body | green | resolves | | HTTP 404 | **red** | `IS GONE` — rebuild the tag (recovery above) | | HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) | -| 401 / 403 after a token was obtained | **red** | the registry `refused an ANONYMOUS read` — check that `timothy/ersatztv` and its `ersatztv-ci` package are still PUBLIC | -| the challenge names no realm, or the token endpoint answers with no token | **red** | `could NOT OBTAIN an anonymous pull token` — worded apart on purpose: this is about the registry's token endpoint, not the package's visibility. Not retried | -| 401 / 403 carrying NO `Www-Authenticate` challenge at all | **red** | `refused an ANONYMOUS read ... WITHOUT issuing a Bearer challenge` — no token was ever requested, because `probe` enters the token leg on a `401` only. An answer about ACCESS to the registry (visibility, or something answering for it) | +| 401 / 403 that survives a bearer the run actually obtained | **red** | the registry `refused an ANONYMOUS read ... even after a Bearer token was obtained` — about the PACKAGE: check that `timothy/ersatztv` and its `ersatztv-ci` package are still PUBLIC | +| 401 whose token leg yielded no bearer — no `Www-Authenticate` at all, a challenge naming no realm, or a token endpoint answering with no token | **red** | `could NOT OBTAIN an anonymous pull token` — about the registry's TOKEN ENDPOINT, not the package's visibility. Not retried | +| 403 on the FIRST read, before any token leg — `probe` enters it on a `401` only, so nothing was ever asked for | **red** | `refused an ANONYMOUS read ... WITHOUT issuing a Bearer challenge` — about ACCESS to the registry: visibility, or something answering for it | | anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin | **The three refusal rows say only what actually ran**, and that is the point of wording them apart diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index 95abe988a..edf8265de 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -202,10 +202,10 @@ for pin in $pins; do # contexts; # * a challenge that yielded no usable token is an infrastructure answer about the TOKEN # ENDPOINT; - # * a refusal carrying NO Bearer challenge at all never reached the token leg — `probe` - # enters it on a 401 only, so a first-read 403 (or a 401 with no `Www-Authenticate`, which - # the token leg then abandons) leaves `token` empty having asked for nothing. This is an - # answer about ACCESS to the registry, and the branch order below is `token` first + # * a FIRST-READ 403 never reached the token leg at all — `probe` enters it on a 401 only — + # so nothing was ever asked for. This is an answer about ACCESS to the registry. Note the + # boundary: a 401 carrying no `Www-Authenticate` DOES enter the token leg and abandon it, + # so it lands in the row above, not this one. The branch order below is `token` first # precisely so this case cannot borrow either of the other two mechanisms. # # A failed token leg is NOT retried, deliberately: `token_leg_done` is set before the attempt -- 2.47.3 From ccc604aef720201c45e1a643a37cb6cbcba92ab1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:47:16 +0200 Subject: [PATCH 07/31] fix(885): the never-asked message reports what the RUN did, not what the registry sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "WITHOUT issuing a Bearer challenge" is a claim about the registry's response that this script never checks. `probe` enters the token leg on a `401` only, so a `403` carrying a perfectly good `Www-Authenticate` would be refused with that sentence having never looked at the header — the same defect one branch over, in the message written to fix it. It now says NO TOKEN WAS EVER REQUESTED, which is a fact about the run: the token leg was not entered, and this answer was never followed as a challenge. The assertion and the outcome-table row move with it, and the comment says why the weaker claim is the honest one. Refs #885 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/ci-cd.md | 2 +- scripts/ci-toolchain-image-resolves.sh | 13 ++++++++----- scripts/tests/test_ci_toolchain_image_resolves.py | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index e73683619..ad710297a 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2454,7 +2454,7 @@ because they send you to different places: | HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) | | 401 / 403 that survives a bearer the run actually obtained | **red** | the registry `refused an ANONYMOUS read ... even after a Bearer token was obtained` — about the PACKAGE: check that `timothy/ersatztv` and its `ersatztv-ci` package are still PUBLIC | | 401 whose token leg yielded no bearer — no `Www-Authenticate` at all, a challenge naming no realm, or a token endpoint answering with no token | **red** | `could NOT OBTAIN an anonymous pull token` — about the registry's TOKEN ENDPOINT, not the package's visibility. Not retried | -| 403 on the FIRST read, before any token leg — `probe` enters it on a `401` only, so nothing was ever asked for | **red** | `refused an ANONYMOUS read ... WITHOUT issuing a Bearer challenge` — about ACCESS to the registry: visibility, or something answering for it | +| 403 on the FIRST read, before any token leg — `probe` enters it on a `401` only | **red** | `refused an ANONYMOUS read ... and NO TOKEN WAS EVER REQUESTED` — it reports what the RUN did (never followed this answer as a challenge), not what the registry sent. About ACCESS to the registry: visibility, or something answering for it | | anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin | **The three refusal rows say only what actually ran**, and that is the point of wording them apart diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index edf8265de..41ea0dab1 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -203,10 +203,13 @@ for pin in $pins; do # * a challenge that yielded no usable token is an infrastructure answer about the TOKEN # ENDPOINT; # * a FIRST-READ 403 never reached the token leg at all — `probe` enters it on a 401 only — - # so nothing was ever asked for. This is an answer about ACCESS to the registry. Note the - # boundary: a 401 carrying no `Www-Authenticate` DOES enter the token leg and abandon it, - # so it lands in the row above, not this one. The branch order below is `token` first - # precisely so this case cannot borrow either of the other two mechanisms. + # so nothing was ever asked for. This is an answer about ACCESS to the registry. Its + # message says NO TOKEN WAS EVER REQUESTED rather than "the registry issued no challenge": + # a 403 MAY carry a `Www-Authenticate` and this script would still not follow it, so only + # the first is something the run observed. Note the boundary too: a 401 carrying no + # challenge DOES enter the token leg and abandon it, so it lands in the arm above, not + # this one. The branch order below is `token` first precisely so this case cannot borrow + # either of the other two mechanisms. # # A failed token leg is NOT retried, deliberately: `token_leg_done` is set before the attempt # so a registry genuinely refusing anonymous reads is asked once rather than once per pin. The @@ -218,7 +221,7 @@ for pin in $pins; do if [ "$token_leg_done" -eq 1 ]; then fail "could NOT OBTAIN an anonymous pull token for $registry/$image_repo:$pin — the registry answered HTTP $code and the token leg produced none: either there was no Www-Authenticate challenge, or it named no realm, or the token endpoint did not answer with a token. The pin was NOT checked. Look at the registry's token endpoint, not at the pin." fi - fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin WITHOUT issuing a Bearer challenge, so no token was ever requested and the pin could not be checked. A challenge is what tells a client where a token can be had; an outright refusal is an answer about ACCESS to the registry, not about the pin. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that timothy/ersatztv and its ersatztv-ci package are still PUBLIC, and that nothing (a proxy, an ACL) is answering for the registry — do not read this as a pass." + fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin and NO TOKEN WAS EVER REQUESTED: the token leg is entered on a 401 only, so this answer was never followed as a Bearer challenge and the pin could not be checked. An outright refusal is an answer about ACCESS to the registry, not about the pin. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that timothy/ersatztv and its ersatztv-ci package are still PUBLIC, and that nothing (a proxy, an ACL) is answering for the registry — do not read this as a pass." ;; *) # NOT gone, and NOT a pass either. Deliberately worded apart from the 404 message: this sends diff --git a/scripts/tests/test_ci_toolchain_image_resolves.py b/scripts/tests/test_ci_toolchain_image_resolves.py index 9a117eaa6..b974186c0 100644 --- a/scripts/tests/test_ci_toolchain_image_resolves.py +++ b/scripts/tests/test_ci_toolchain_image_resolves.py @@ -318,7 +318,7 @@ def test_a_refusal_with_NO_CHALLENGE_never_claims_a_token_was_obtained(preflight assert preflight.authenticated_manifest_calls() == [], "no read can have carried a token here" if refusal == "403": assert preflight.token_calls() == [], "a 403 first read must not even ask for a token" - assert "WITHOUT issuing a Bearer challenge" in result.stderr, result.stderr + assert "NO TOKEN WAS EVER REQUESTED" in result.stderr, result.stderr assert "PUBLIC" in result.stderr, "the message must still name a cause an operator can act on" else: # A challenge-less 401 DOES enter the token leg (and abandons it for want of a realm), so it -- 2.47.3 From 25ec811c73074e32a336aa5a2e2765c5b7da31fe Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:50:30 +0200 Subject: [PATCH 08/31] =?UTF-8?q?fix(885):=20the=20cross-check's=20own=20C?= =?UTF-8?q?OUNTING=20could=20be=20reverted=20with=20the=20suite=20green=20?= =?UTF-8?q?=E2=80=94=20extract=20it=20and=20drive=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_the_cross_check_COUNTS_locations_rather_than_collecting_NAMES` asserted the property on the two COLLECTORS, not on the check that uses them. The comparison itself was inlined in a loop over the real workflows, which agree under either mechanism — so changing `walked != scanned` back to a name-set comparison, restoring the exact blind spot this branch exists to close, left all 17 tests passing. A guard whose distinguishing mechanism has no mutation proof is the shape `testing.guard-ships-with-mutation-proof` names. The per-file half is now `walk_versus_text_faults(name, text)`, driven on a text/walk pair whose NAME SETS AGREE: a second `${{ secrets.REGISTRY_PASSWORD }}` in a trailing comment, which the line-level strip leaves in the text half and the YAML walk cannot reach. Counting reports it; the set comparison the branch replaced reports nothing, and the test asserts BOTH halves of that so the contrast is the assertion rather than a comment. Measured: with `walked == scanned` mutated to `set(walked) == set(scanned)`, 1 failed / 16 passed; restored, 17 passed. Refs #885 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../test_workflow_persist_credentials.py | 54 ++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 64354cda0..cfad85b07 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -492,6 +492,28 @@ def test_no_PULL_REQUEST_route_job_names_a_STORED_secret() -> None: assert not faults, "\n".join(faults) +def walk_versus_text_faults(name: str, text: str) -> list[str]: + """One file's half of the cross-check below, extracted so a negative control can DRIVE it. + + Inlined in the loop it could be reverted to a name-set comparison — restoring the blind spot this + whole change is about — with every test in this file still green, because nothing but the loop + over the real workflows would ever call it and those agree either way + (`testing.guard-ships-with-mutation-proof`). + """ + walked = secret_name_counts(yaml.safe_load(text)) + scanned = Counter( + _SECRET_REF.findall("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))) + ) + if walked == scanned: + return [] + return [ + f"{name}: the document walk reached {sorted(walked.items())} but the text scan found " + f"{sorted(scanned.items())}. The walk is what the stored-secret assertion runs over, so the " + f"difference is secret references this guard cannot see — widen `secret_names`' entry " + f"point, or take the reference out of the file." + ] + + def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: """The document walk and a plain text scan must agree per file, COUNTED not merely named. @@ -528,18 +550,8 @@ def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: total = Counter() for path in workflow_files(): text = path.read_text() - walked = secret_name_counts(yaml.safe_load(text)) - total += walked - scanned = Counter( - _SECRET_REF.findall("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))) - ) - if walked != scanned: - disagreements.append( - f"{path.name}: the document walk reached {sorted(walked.items())} but the text scan " - f"found {sorted(scanned.items())}. The walk is what the stored-secret assertion runs " - f"over, so the difference is secret references this guard cannot see — widen " - f"`secret_names`' entry point, or take the reference out of the file." - ) + total += secret_name_counts(yaml.safe_load(text)) + disagreements.extend(walk_versus_text_faults(path.name, text)) assert not disagreements, "\n".join(disagreements) assert sum(total.values()) > 0, ( "no workflow reached by this check names a single `secrets.*` reference. Two empty Counters " @@ -565,6 +577,24 @@ def test_the_cross_check_COUNTS_locations_rather_than_collecting_NAMES() -> None assert secret_name_counts(twice) == Counter({"REGISTRY_PASSWORD": 2}) assert secret_name_counts(once) != secret_name_counts(twice) + # And the CROSS-CHECK ITSELF, on a text/walk pair whose NAME SETS AGREE. Reverting the + # comparison to name sets would restore the blind spot this change is about, so the difference + # is driven rather than left to the collectors' unit assertions above. + clean = 'jobs:\n j:\n env:\n A: "${{ secrets.REGISTRY_PASSWORD }}"\n' + assert walk_versus_text_faults("synthetic.yml", clean) == [] + + # The same name a SECOND time, at a location the YAML walk cannot reach — a trailing comment, + # which the line-level strip leaves in the text half. + duplicated = ( + 'jobs:\n j:\n env:\n A: "${{ secrets.REGISTRY_PASSWORD }}" # ${{ secrets.REGISTRY_PASSWORD }}\n' + ) + faults = walk_versus_text_faults("synthetic.yml", duplicated) + assert len(faults) == 1, faults + assert "REGISTRY_PASSWORD" in faults[0] + + # A name-set comparison — the mechanism this replaced — reports NOTHING on that same input. + assert secret_names(yaml.safe_load(duplicated)) == set(_SECRET_REF.findall(duplicated)) == {"REGISTRY_PASSWORD"} + # Two references in ONE scalar count twice, which is the shape a hoisted # `user:${{ secrets.X }}:${{ secrets.Y }}` line takes. pair = {"env": {"A": "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}"}} -- 2.47.3 From 433e8f81c8ff043a1c7c8ac2fc8c4ae2eaa9e4f1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 00:52:37 +0200 Subject: [PATCH 09/31] fix(885): cite the predecessor by description, not by a sha the rebase orphaned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three docstrings dated their measurement to `59003d5a3`, this branch's head before it was rebased onto `main` after ersatztv#907 landed. That commit is unreachable from the branch and will never be in `main`, so `git show` on it fails for every later reader — a citation that cannot be followed is worse than none, because it reads as checkable. Each now names what it measured against ("the predecessor of this commit", and for the guard, "as it walked `jobs.` only and compared per-file NAME SETS"), which is what the reader actually needs and what survives any rebase. Refs #885 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- scripts/tests/test_ci_toolchain_image_resolves.py | 2 +- scripts/tests/test_workflow_persist_credentials.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/tests/test_ci_toolchain_image_resolves.py b/scripts/tests/test_ci_toolchain_image_resolves.py index b974186c0..181eab4ff 100644 --- a/scripts/tests/test_ci_toolchain_image_resolves.py +++ b/scripts/tests/test_ci_toolchain_image_resolves.py @@ -299,7 +299,7 @@ def test_a_refusal_with_NO_CHALLENGE_never_claims_a_token_was_obtained(preflight `probe` enters the token leg on a `401` only, so a registry answering `403` on the first read — or a `401` carrying no `Www-Authenticate` — leaves the script with no token having asked for - nothing. Measured 2026-09-05 on the predecessor at 59003d5a3, the `403` shape reported "even + nothing. Measured 2026-09-05 on the predecessor of this commit, the `403` shape reported "even after a Bearer token was obtained", which sends an operator to package visibility on evidence that does not exist (`dont-narrate-mechanisms-you-didnt-measure`). The two codes are BOTH driven because they take different paths: the challenge-less `401` still enters and abandons the token diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index cfad85b07..11c09b7a8 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -523,7 +523,8 @@ def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: loader drops) is then a disagreement, and the guard says so instead of reporting clean. IT COUNTS OCCURRENCES, IT DOES NOT COMPARE NAME SETS, and that is the load-bearing part rather - than a refinement. Measured 2026-09-05 on the shipped tree at 59003d5a3: hoisting + than a refinement. Measured 2026-09-05 against the predecessor of this commit — the guard as it + walked `jobs.` only and compared per-file NAME SETS — hoisting `ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}` into `docker-build.yml`'s root `env:` left a set-comparison cross-check GREEN (`14 passed`), because `build` legitimately keeps naming both names — the two halves cancelled and the check could only @@ -711,9 +712,9 @@ def test_the_collector_reports_a_WORKFLOW_SCOPE_reference_no_job_if_can_reach() def test_MUTATION_a_WORKFLOW_LEVEL_env_in_the_SHIPPED_workflow_is_DETECTED() -> None: """Drives the SHIPPED `docker-build.yml`, hoisting the credential to workflow scope. - This is the mutation that a name-set cross-check could not see (measured 2026-09-05 at - 59003d5a3: `14 passed`, rc=0) — `build` already names both halves, so a second copy at root - changed no NAME. It is exercised on the real file rather than a synthetic document for the same + This is the mutation that a name-set cross-check could not see (measured 2026-09-05 against the + predecessor of this commit: `14 passed`, rc=0) — `build` already names both halves, so a second + copy at root changed no NAME. It is exercised on the real file rather than a synthetic document for the same reason `test_MUTATION_the_BUILD_job_losing_its_route_EXCLUSION_is_DETECTED` is: `docker-build.yml` is the file the invariant is about, and a fixture would only prove the collector works on a document this repo does not ship. -- 2.47.3 From 383171dfa4e6c7e6ec9da2a409230563712dc023 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 02:01:17 +0200 Subject: [PATCH 10/31] fix(885): a detector that knows ONE spelling is one an added job writes around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `secrets['REGISTRY_PASSWORD']` is the same reference to the expression evaluator as `secrets.REGISTRY_PASSWORD`, and the guard shipped here could see only the dot form. Measured 2026-09-05 against the predecessor of this commit, a `pull_request`-route job whose `env:` read `"${{ secrets['REGISTRY_PASSWORD'] }}"` produced `stored_secret_faults(...) == []` AND `walk_versus_text_faults(...) == []` — the text cross-check cannot report the gap, because both of its halves resolve references through the one pattern, so a spelling it does not know is a shared blind spot they agree at zero on rather than a disagreement they name (`proof-sharing-with-subject-proves-nothing`). The file enumerated four other blind spots it has — composite actions, reusable workflows, nested directories, both directions of the comment strip — and not this one, which is what made the omission read as coverage. `secret_refs` is now the single entry point for both halves, and it matches the dot form, both index forms and a case-varied context, then counts the RESIDUE: any `secrets` token inside a `${{ }}` span that yielded no literal name is reported under the sentinel `secrets.*`. Counting the residue rather than pattern-matching `toJSON(secrets)` and a computed index is what makes it exhaustive over the grammar — a spelling nobody has written yet still faults, in the fail-closed direction. The bare word is read as the context only inside an expression, because in prose it is ordinary English; this file and four workflows discuss "secrets" in comments. `test_the_collector_sees_every_SPELLING_of_a_secret_reference` drives the six spellings through the collector and the cross-check and asserts each is invisible to the real predecessor, so reverting the widening reddens it. Whether act_runner resolves each spelling against this instance was not probed from here (that needs a live run); the direction makes that acceptable — a spelling the runner does not support costs a spurious demand on a job nobody has written, the omission cost a live write-capable credential on the head-authored route. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/decisions/README.md | 2 +- .../pr-route-carries-no-stored-credential.md | 17 ++- .../test_workflow_persist_credentials.py | 133 ++++++++++++++++-- 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index bcb7a3f65..40425d827 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -61,7 +61,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | | `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) | -| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | +| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | | `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) | | `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark ` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always --gated `, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) | | `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) | diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index 8b318d057..77e52114f 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -5,8 +5,8 @@ status: active since: '2026-09-04' supersedes: none superseded-by: none -rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message.' -signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' +rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message.' +signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, indexed secrets reference, whole-context secrets reference, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. NOT MEASURED FROM THIS SESSION, and it is the one thing this change cannot establish locally: whether act_runner''s daemon performs the credential-free `container:` pull. The HTTP-level anonymous read is proven; the daemon''s behaviour is first observable on a real PR run, where a failure is loud (both required contexts die at image pull in 1-2s) rather than silent.' --- @@ -58,6 +58,19 @@ one fact a second copy of the same reference cannot change — the `dont-keep-a- its own right, and the cross-check walks the whole document and compares occurrence COUNTS. Both are driven by mutations on the shipped `docker-build.yml` rather than on a synthetic document. +**One detector feeds both halves, so a SPELLING gap is silent rather than reported.** The walk and +the text cross-check both resolve references through `secret_refs`, which is what makes the check +cheap — and it means the cross-check can only ever report a spelling that function already +recognises. Measured 2026-09-05, the dot-only pattern reported `[]` from *both* halves on a PR-route +job holding `${{ secrets['REGISTRY_PASSWORD'] }}`, the same reference to the evaluator. The pattern +therefore covers the dot form, both index forms and a case-varied context, and then counts the +RESIDUE: any `secrets` token inside a `${{ }}` span that yielded no literal name is reported as +`secrets.*`. Counting the residue rather than matching `toJSON(secrets)` and a computed index by name +is what makes it exhaustive over the grammar — a spelling nobody has written yet still faults. The +cost is over-matching in one direction (a spelling this instance's runner may not resolve costs a +spurious demand on a job nobody has written), and the word `secrets` is only read as the context +INSIDE an expression, because in prose it is ordinary English. + **The `if:` classifier is a PIN, not a parser.** Exactly one string — `github.event_name != 'pull_request'` — takes a job off the route. Anything else, including expressions a human can see are equivalent, leaves the job IN the population. Parsing diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 11c09b7a8..2509a7efc 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -342,7 +342,62 @@ INJECTED_SECRETS = frozenset({"GITEA_TOKEN"}) PULL_REQUEST = "pull_request" -_SECRET_REF = re.compile(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)") +# The name a WHOLE-CONTEXT reference is reported under. It cannot collide with a real secret: a +# secret name is `[A-Za-z_][A-Za-z0-9_]*`, so no stored name can contain `.` or `*`. +WHOLE_SECRETS_CONTEXT = "secrets.*" + +# Every spelling of a `secrets` reference the expression grammar admits — NOT just `secrets.NAME`. +# `secrets['NAME']` is the same reference to the evaluator (GitHub's expression syntax, which Gitea's +# runner implements, defines `[]` as the index operator alongside `.`), and context names are matched +# case-insensitively, so `Secrets.NAME` is one too. Recognising only the dot spelling made this a +# detector an added job could step around by writing a different one — and the cross-check below +# cannot report that, because BOTH of its halves read through this same function: a spelling neither +# half knows is a SHARED blind spot they agree at zero on, not a disagreement +# (`proof-sharing-with-subject-proves-nothing`, ersatztv#819). Measured 2026-09-05 against the +# predecessor of this commit, an `env:` of `"${{ secrets['REGISTRY_PASSWORD'] }}"` on a synthetic +# `pull_request` job produced `stored_secret_faults(...) == []` AND `walk_versus_text_faults(...) == []`. +# +# The name is taken VERBATIM, not case-folded onto the store's uppercase spelling. Whether the +# evaluator resolves `secrets.gitea_token` to the stored `GITEA_TOKEN` was not probed from here, and +# the un-folded reading is the demanding one: an unrecognised spelling misses the one-member +# `INJECTED_SECRETS` allow-list and is reported, rather than being forgiven on an unmeasured claim. +_SECRET_REF = re.compile( + r"(? list[str]: + """Every `secrets` reference in one string — occurrences, not names. + + The single entry point for both the document walk and the text cross-check, so that widening one + spelling widens both. A list rather than a set for the reason `secret_name_counts` is a Counter: + only the lossless direction can be narrowed afterwards. + """ + found = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(text)] + for expression in _EXPRESSION.findall(text): + unresolved = len(_SECRETS_TOKEN.findall(expression)) - len(_SECRET_REF.findall(expression)) + found.extend([WHOLE_SECRETS_CONTEXT] * unresolved) + return found + # The ONLY job-level `if:` in this repo that takes a job OFF the `pull_request` route. This is a PIN, # not an expression parser, and the direction is the point: an `if:` that is not in this set leaves @@ -405,7 +460,7 @@ def pull_request_jobs(doc: object) -> list[tuple[str, dict]]: def secret_name_counts(node: object) -> Counter: - """Every `secrets.NAME` reachable anywhere in a subtree — keys and values, at any depth — COUNTED. + """Every secret reference reachable anywhere in a subtree — keys and values, at any depth — COUNTED. Deliberately structure-blind: the credential entered through `container.credentials`, a step `env:`, and a job `env:` in this repo already, and naming those three places would be the @@ -427,7 +482,7 @@ def secret_name_counts(node: object) -> Counter: elif isinstance(item, list): stack.extend(item) elif isinstance(item, str): - found.update(_SECRET_REF.findall(item)) + found.update(secret_refs(item)) return found @@ -501,9 +556,7 @@ def walk_versus_text_faults(name: str, text: str) -> list[str]: (`testing.guard-ships-with-mutation-proof`). """ walked = secret_name_counts(yaml.safe_load(text)) - scanned = Counter( - _SECRET_REF.findall("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))) - ) + scanned = Counter(secret_refs("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")))) if walked == scanned: return [] return [ @@ -546,6 +599,15 @@ def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: A YAML anchor/alias would also redden benignly (the walk visits the aliased node once per reference, the text carries `*alias`); measured 2026-09-05 no tracked workflow uses one. + + WHAT THIS CROSS-CHECK STRUCTURALLY CANNOT REPORT, since it is the reason `secret_refs` has to be + widened rather than leaned on: both halves read through that one function, so a spelling IT does + not recognise is invisible to both and they agree at zero. The dot/index/whole-context spellings + are covered there; what remains uncovered is a name the expression COMPUTES + (`secrets[format('{0}_TOKEN', x)]`), which no static reader resolves. That spelling still faults, + but as `secrets.*` — the whole-context sentinel — because the index contains no literal, which is + the fail-closed direction and is asserted in + `test_the_collector_sees_every_SPELLING_of_a_secret_reference`. """ disagreements: list[str] = [] total = Counter() @@ -558,7 +620,7 @@ def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: "no workflow reached by this check names a single `secrets.*` reference. Two empty Counters " "compare equal, so the loop above would agree having measured nothing — either every " "reference really is gone (say so here and in `ci.pr-route-carries-no-stored-credential`) " - "or `workflow_files()`/`_SECRET_REF` has stopped matching." + "or `workflow_files()`/`secret_refs` has stopped matching." ) @@ -594,7 +656,7 @@ def test_the_cross_check_COUNTS_locations_rather_than_collecting_NAMES() -> None assert "REGISTRY_PASSWORD" in faults[0] # A name-set comparison — the mechanism this replaced — reports NOTHING on that same input. - assert secret_names(yaml.safe_load(duplicated)) == set(_SECRET_REF.findall(duplicated)) == {"REGISTRY_PASSWORD"} + assert secret_names(yaml.safe_load(duplicated)) == set(secret_refs(duplicated)) == {"REGISTRY_PASSWORD"} # Two references in ONE scalar count twice, which is the shape a hoisted # `user:${{ secrets.X }}:${{ secrets.Y }}` line takes. @@ -678,6 +740,61 @@ def test_the_stored_secret_collector_reports_a_pull_request_job_naming_ONE() -> assert stored_secret_faults("synthetic.yml", off_route) == [], trigger +def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None: + """The spellings that are the SAME reference to the evaluator and were invisible to the detector. + + Driven against the real predecessor (`dot_only` below is `_SECRET_REF` exactly as this branch + shipped it before this commit), because that is the mutation this test exists to catch: revert + the widening and every case here goes back to `faults == []`. It cannot be caught by the + text-versus-walk cross-check — both halves read through `secret_refs`, so a spelling it does not + know is a shared blind spot they agree at zero on rather than a disagreement + (`proof-sharing-with-subject-proves-nothing`). Measured 2026-09-05, the predecessor reported + `stored_secret_faults(...) == []` and `walk_versus_text_faults(...) == []` on the index spelling. + + Whether act_runner resolves each of these against this instance was NOT probed from here — that + needs a live run. The direction is what makes that acceptable: a spelling the runner happens not + to support costs a spurious demand on a job nobody has written; the omission cost a live + write-capable credential on the head-authored route. + """ + dot_only = re.compile(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)") + + for expression, reported in ( + ("${{ secrets['REGISTRY_PASSWORD'] }}", "REGISTRY_PASSWORD"), + ('${{ secrets["REGISTRY_PASSWORD"] }}', "REGISTRY_PASSWORD"), + ("${{ secrets [ 'REGISTRY_PASSWORD' ] }}", "REGISTRY_PASSWORD"), + ("${{ Secrets.REGISTRY_PASSWORD }}", "REGISTRY_PASSWORD"), + # Names NO secret and hands over ALL of them; and an index the expression COMPUTES, which no + # static reader resolves. Both are reported under the whole-context sentinel. + ("${{ toJSON(secrets) }}", WHOLE_SECRETS_CONTEXT), + ("${{ secrets[format('{0}_PASSWORD', 'REGISTRY')] }}", WHOLE_SECRETS_CONTEXT), + ): + assert dot_only.findall(expression) == [], ( + f"{expression!r} is supposed to be a spelling the dot-only predecessor could not see — " + "if it can, this row proves nothing about the widening." + ) + assert secret_refs(expression) == [reported], expression + doc = {True: {"pull_request": None}, "jobs": {"j": {"env": {"A": expression}}}} + faults = stored_secret_faults("synthetic.yml", doc) + assert len(faults) == 1, (expression, faults) + assert reported in faults[0], (expression, faults) + + # The workflow scope is judged through the same function, so it widens with it. + scoped = {True: {"pull_request": None}, "env": {"A": "${{ secrets['REGISTRY_PASSWORD'] }}"}, "jobs": {}} + assert len(stored_secret_faults("synthetic.yml", scoped)) == 1 + + # The cross-check now COUNTS these spellings on both sides rather than agreeing at zero. + indexed = "jobs:\n j:\n env:\n A: \"${{ secrets['REGISTRY_PASSWORD'] }}\"\n" + assert secret_name_counts(yaml.safe_load(indexed)) == Counter({"REGISTRY_PASSWORD": 1}) + assert walk_versus_text_faults("synthetic.yml", indexed) == [] + + # The over-match direction, which is the cost of matching a bare `secrets` at all: the word is + # only a context reference INSIDE an expression. Ordinary prose and a longer identifier are not. + assert secret_refs("echo 'no secrets are used here'") == [] + assert secret_refs("${{ env.mysecrets.REGISTRY_PASSWORD }}") == [] + prose = {True: {"pull_request": None}, "jobs": {"j": {"steps": [{"run": "echo 'no secrets here'"}]}}} + assert stored_secret_faults("synthetic.yml", prose) == [] + + def test_the_collector_reports_a_WORKFLOW_SCOPE_reference_no_job_if_can_reach() -> None: """The workflow scope is judged even when every job is gated OFF the route. -- 2.47.3 From 8885fdd9d2df0361d1dc3ef0c580b7e2e01a3e11 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 05:02:42 +0200 Subject: [PATCH 11/31] fix(885): the swept symbol's last live-voice site, and a caveat that is now a measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ETV_STATUS_AUTH` is gone from `test`, `migrations` and `functional-e2e`, and three same-subject sites were reworded to match. `review-verdict.yml`'s comment was the fourth and still named the symbol as a live thing; it now names the credential by what it is, and says the PR route materialises none to refuse. `docs/remote-state-inventory.md`'s row for `ci-toolchain-image-resolves.sh` listed "an unusable credential" among the shapes that fail the job — that script holds no credential any more. The row names the three refused-anonymous-read shapes the shipped script actually has instead, and re-confirms the `UNSAFE-KNOWN` grade against the anonymous script: the tag it reads is mutable either way. That is #909's first half; its other half, `docs/guard-inventory.md`, stays with the session holding that file. `ci.pr-route-carries-no-stored-credential`'s `mechanics:` carried one self-declared unmeasured claim — whether act_runner's daemon performs the credential-free `container:` pull. Measured 2026-09-05 on the runner host 192.168.1.99, which runs both act_runner containers and creates every job container on its own docker socket: a `docker pull` of the pinned tag with a scratch docker config holding only `{}` exits 0. The two things that run did not exercise — an anonymous layer download, and act_runner's own pull call path — replace the open unknown rather than being dropped, and `docs/ci-cd.md` cites both measurements. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .gitea/workflows/review-verdict.yml | 15 +++++++++++---- docs/ci-cd.md | 8 +++++--- .../ci/pr-route-carries-no-stored-credential.md | 15 ++++++++------- docs/remote-state-inventory.md | 2 +- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 24d85e137..9e0dc0f45 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -1446,10 +1446,17 @@ jobs: # credential, and reachable from any workflow because secrets are a per-repo store. # # What this STILL does not claim: the test asks "was this POSTed by an account we accept - # verdicts from", NOT "by someone who actually reviewed it". `ETV_STATUS_AUTH`'s registry - # credential can no longer POST a status at all (#697, fixed by scoping it off - # `write:repository` — see `ci.actions-credential-scoping`), and `RENOVATE_TOKEN` is now - # re-derived rather than inherited; but an ALLOW-LISTED collaborator's own token can still + # verdicts from", NOT "by someone who actually reviewed it". The registry credential + # `docker-build.yml` used to pass as `ETV_STATUS_AUTH` can no longer POST a status at all + # (#697, fixed by scoping it off `write:repository` — see `ci.actions-credential-scoping`), + # and since #885 that route materialises no STORED credential to refuse: nothing on the + # `pull_request` route names a stored secret and the status read is anonymous + # (`ci.pr-route-carries-no-stored-credential`). The INJECTED `GITEA_TOKEN` is still handed + # to every job there and is a separate control: what bounds it is `docker-build.yml`'s own + # workflow-level `permissions:` (`code: read`), not the removal of the stored secrets. + # `RENOVATE_TOKEN` is now re-derived rather than inherited. + # + # But an ALLOW-LISTED collaborator's own token can still # POST a success with an attacker-chosen `Review-verdict:` description, which this guard then # preserves. That is provenance, not authentication — branch protection binds the context # NAME, not its issuer, and Gitea offers no way to restrict who may post a context. diff --git a/docs/ci-cd.md b/docs/ci-cd.md index ad710297a..7d0485de9 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -185,9 +185,11 @@ grants `releases: write`, which nothing here uses. The two read units are not sp No workflow needs a `packages:` unit. Since ersatztv#885 the five `container:` blocks in `docker-build.yml` carry no `credentials:` at all: they run on the `pull_request` route, so they may hold no stored secret, and the runner pulls the toolchain image ANONYMOUSLY — this registry issues a -pull token for a public package with no credential (measured at the HTTP level 2026-09-04; see -`ci.pr-route-carries-no-stored-credential`, whose `mechanics:` also names the one thing that -measurement does NOT establish, act_runner's own pull). So no job pulls that image with the injected +pull token for a public package with no credential (measured at the HTTP level 2026-09-04, and at the +daemon level 2026-09-05 — a `docker pull` of the pinned tag with an empty docker config succeeds on +the runner host, which is the same daemon that creates the job containers; see +`ci.pr-route-carries-no-stored-credential`, whose `mechanics:` names both measurements and the two +things neither exercises). So no job pulls that image with the injected token, and none pulls it with the `REGISTRY_*` PAT either. ### The binding was measured, not assumed (2026-08-27, Gitea 1.27.1) diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index 77e52114f..b6b2c809e 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message.' signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, indexed secrets reference, whole-context secrets reference, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' -mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. NOT MEASURED FROM THIS SESSION, and it is the one thing this change cannot establish locally: whether act_runner''s daemon performs the credential-free `container:` pull. The HTTP-level anonymous read is proven; the daemon''s behaviour is first observable on a real PR run, where a failure is loud (both required contexts die at image pull in 1-2s) rather than silent.' +mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. TWO THINGS THAT RUN DID NOT EXERCISE, because those layers were already on that host: an anonymous LAYER download, and act_runner''s own pull call path rather than the CLI''s. Both are first observable on a real PR run, where a failure is loud (both required contexts die at image pull in 1-2s) rather than silent.' --- **What was wrong.** `docker-build.yml` triggers on `pull_request:`, and Gitea resolves that run from @@ -78,13 +78,14 @@ expressions a human can see are equivalent, leaves the job IN the population. Pa after repeated defects from that one mechanism; a pin can only ever be too demanding, which is the safe direction, and widening it is then a deliberate edit rather than an accident. -**Two inventory rows understate this, tracked as #909.** `docs/remote-state-inventory.md`'s row for -`scripts/ci-toolchain-image-resolves.sh` still lists "an unusable credential" among the shapes that -fail the job; there is no credential in that script any more. And `docs/guard-inventory.md` describes +**One inventory row still understates this, tracked as #909.** `docs/guard-inventory.md` describes `scripts/tests/test_workflow_persist_credentials.py` as the `actions/checkout` guard only, while it -now also holds this invariant with its own derived population and mutation proofs. Neither goes red: -both suites assert set equality over FILES, and both files were already listed. Do not read either -row as a complete description of what its subject checks. +now also holds this invariant with its own derived population and mutation proofs. Nothing goes red +on that: `test_guard_inventory.py` asserts set equality over FILES, and the file was already listed, +so a row whose PROSE decays is invisible to it. Do not read it as a complete description of what its +subject checks. #909's other half — `docs/remote-state-inventory.md`'s row for +`scripts/ci-toolchain-image-resolves.sh`, which listed "an unusable credential" among the shapes that +fail the job — is corrected in this change. **The residual, stated so nobody cites this record as a boundary.** Head-supplied YAML can still name every secret in the store — the committed workflows' references were never the limit diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index ab81e72c6..2f519d27d 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -133,7 +133,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | `scripts/ci-peak-anon.sh` | `N/A` | Reads no live remote state — samples the runner's local cgroup `memory.stat`/`memory.peak`. | | `scripts/ci-prove-ban-detects.sh` | `N/A` | Reads no live remote state — mutates a local workflow copy and runs pytest against the local checkout. | | `scripts/ci-step-ran.sh` | `N/A` | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. | -| `scripts/ci-toolchain-image-resolves.sh` — registry manifest read for the pinned toolchain tag | `UNSAFE-KNOWN` | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the `container:` jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded `UNSAFE-KNOWN` rather than `N/A` deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unusable credential, an unverifiable answer (after retries) and an HTTP 200 whose body is not a manifest all FAIL the job. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing `curl`, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. | +| `scripts/ci-toolchain-image-resolves.sh` — registry manifest read for the pinned toolchain tag | `UNSAFE-KNOWN` | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the `container:` jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded `UNSAFE-KNOWN` rather than `N/A` deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unverifiable answer (after retries), an HTTP 200 whose body is not a manifest, and all three shapes of refused anonymous read — a `401`/`403` surviving a bearer the run actually obtained, a `401` whose token leg produced no bearer, and a first-read `403` that never entered the token leg at all — every one of them FAILS the job. The script names no credential (ersatztv#885, `ci.pr-route-carries-no-stored-credential`): it runs on the `pull_request` route, where the YAML is head-supplied, so it reads this public package through the registry's anonymous Bearer-token flow instead. That changes nothing about the grade — the tag it reads is mutable either way. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing `curl`, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. | | `scripts/set-provider.sh` | `N/A` | Reads no live remote state — sets local `dotnet user-secrets` values. | | `scripts/__init__.py` | `N/A` | Empty package marker — executes nothing. | | `scripts/scripted-schedules/entrypoint.py` — `ScriptedScheduleApi.get_context(build_id)`, then `define_content` / `reset_playout` / `build_playout` against the same live server | `UNSAFE-KNOWN` | A genuine read-then-act over live ErsatzTV state. The context is fetched, handed to user-supplied script functions that mutate the playout, and re-fetched after a reset with nothing pinning either read — a concurrent build or edit between them is invisible. Accepted because it runs inside a single scripted-schedule build the server itself serialises per playout, and because the API exposes no version or ETag on the context to compare against; the honest bound is that the blast radius is one playout's content, reversible by rebuilding. | -- 2.47.3 From 23cef5c9d1285e33d0a34b1deabf63a08b1ef83e Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 06:15:12 +0200 Subject: [PATCH 12/31] fix(885): two dependencies with opposite failure directions, and a token leg that got zero retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header comment and the record's `rule:` said the registry reads and the commit-status reads "both depend on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private fails those jobs loudly at image pull, never silently". Wrong in both clauses for the repo half, and this branch has already been sent back twice for exactly this shape of mechanism claim. Measured 2026-09-05: the `ersatztv-ci` package is linked to no repository (every version reports `"repository": null`), so the repo's visibility does not gate the anonymous pull token at all; and the only thing it does gate — the combined-status GET — fails in the opposite direction, because `ci-detect-already-validated.sh` answers a failed `curl -sf` with `emit false; exit 0`. That job stays GREEN and the #420 cross-run skip silently stops firing. So the two dependencies are now stated apart, each with its own failure direction, in `docker-build.yml`, in the preflight's header, in the record and in the `ci-cd.md` outcome table; the preflight's own 401/403 messages stop sending an operator to the repo's visibility when it is the package's. `token_leg_done` was set once per RUN, before the attempt, so a token endpoint that could not be reached failed the preflight with no retry while an identical blip on the manifest read got three. The stated reason — "a registry genuinely refusing anonymous reads is asked once rather than once per pin" — is a per-pin argument that never covered the per-attempt axis. It is now sorted by what the endpoint SAID rather than by which leg it happened on: an answer (no token in the body, a challenge naming no realm, no challenge at all) settles the question and is asked once per run; an endpoint that could not be reached, or that answered 5xx, settled nothing and is retried on the same `ETV_CI_ATTEMPTS` budget as the manifest read, because a red here denies a merge (the consent hook reads the COMBINED status, #598) and the two legs of one read must not have opposite flake tolerances. The token-leg message now reports the attempts it actually made. Driven against the SHIPPED predecessor rather than a hand-written mutant: the three new behavioural assertions are red on it (1 token call where 3 are required, and a blip shorter than the budget failing the run), while the two that pin the property the retry must not cost pass on both. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .gitea/workflows/docker-build.yml | 14 ++- docs/ci-cd.md | 16 ++-- docs/decisions/README.md | 2 +- .../pr-route-carries-no-stored-credential.md | 15 +++- scripts/ci-toolchain-image-resolves.sh | 90 ++++++++++++++----- .../tests/test_ci_toolchain_image_resolves.py | 71 +++++++++++++++ 6 files changed, 175 insertions(+), 33 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index bdfda707e..5289361ba 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -128,9 +128,17 @@ env: # the five `container:` pulls and `toolchain-preflight`'s registry tag READ go through the registry's # anonymous bearer-token flow, and the three commit-status GETs # (scripts/ci-detect-already-validated.sh in `test`, `migrations` and `functional-e2e`) read the -# combined-status API unauthenticated. Both depend on `timothy/ersatztv` and its `ersatztv-ci` -# package staying PUBLIC; making either private fails those jobs loudly at image pull, never -# silently. The invariant is held by +# combined-status API unauthenticated. Those are TWO dependencies, on two different objects, with +# opposite failure directions — do not collapse them into one "keep it public or CI breaks loudly". +# The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it +# reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull +# token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and +# losing it IS loud: every `container:` job dies at image pull in 1-2s, both required contexts among +# them, and `toolchain-preflight` names the cause in its own 401/403 message. (2) The three +# commit-status GETs need `timothy/ersatztv` itself to stay publicly readable, and losing that is +# SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls through to `skip=false`, and the +# jobs stay GREEN — only the ersatztv#420 cross-run skip quietly stops firing, which costs a +# redundant re-validation and never a skip that was not earned. The invariant is held by # scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret. # The injected token serves only this file's eight `actions/checkout` steps. Note it needs no # `packages:` unit: the container pulls are anonymous, not token-authenticated. diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 7d0485de9..80c062f1a 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2454,23 +2454,27 @@ because they send you to different places: | HTTP 200 with a manifest body | green | resolves | | HTTP 404 | **red** | `IS GONE` — rebuild the tag (recovery above) | | HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) | -| 401 / 403 that survives a bearer the run actually obtained | **red** | the registry `refused an ANONYMOUS read ... even after a Bearer token was obtained` — about the PACKAGE: check that `timothy/ersatztv` and its `ersatztv-ci` package are still PUBLIC | -| 401 whose token leg yielded no bearer — no `Www-Authenticate` at all, a challenge naming no realm, or a token endpoint answering with no token | **red** | `could NOT OBTAIN an anonymous pull token` — about the registry's TOKEN ENDPOINT, not the package's visibility. Not retried | +| 401 / 403 that survives a bearer the run actually obtained | **red** | the registry `refused an ANONYMOUS read ... even after a Bearer token was obtained` — about the PACKAGE: check that the `ersatztv-ci` package is still PUBLIC. It is linked to no repository (measured 2026-09-05: every version reports `"repository": null`), so this is the package's own visibility and NOT `timothy/ersatztv`'s | +| 401 whose token leg ANSWERED and yielded no bearer — no `Www-Authenticate` at all, a challenge naming no realm, or a token endpoint answering with no token | **red**, asked once | `could NOT OBTAIN an anonymous pull token ... after 1 token-leg attempt(s)` — about the registry's TOKEN ENDPOINT, not the package's visibility. The endpoint said something, so asking again cannot change it | +| 401 whose token endpoint could not be REACHED, or answered 5xx | **red** after `ETV_CI_ATTEMPTS` token-leg tries | the same message, naming the attempts it actually made. An endpoint that said nothing is the same transport blip a flaky manifest read gets to survive — the two legs of one read must not have opposite flake tolerances when a red here denies a merge | | 403 on the FIRST read, before any token leg — `probe` enters it on a `401` only | **red** | `refused an ANONYMOUS read ... and NO TOKEN WAS EVER REQUESTED` — it reports what the RUN did (never followed this answer as a challenge), not what the registry sent. About ACCESS to the registry: visibility, or something answering for it | | anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin | -**The three refusal rows say only what actually ran**, and that is the point of wording them apart +**The refusal rows say only what actually ran**, and that is the point of wording them apart rather than a nicety: a message naming a step the run skipped is evidence for a diagnosis nobody performed. So the arm branches on `token` first — a bearer was obtained — then on whether the token -leg was attempted at all, and only then reports the never-asked case. +leg was attempted at all, and only then reports the never-asked case; and the token-leg message +carries its own attempt count rather than leaving the reader to infer one from the retry policy. -Those three rows and the `could NOT VERIFY` row are the ones worth defending, because warning +Those refusal rows and the `could NOT VERIFY` row are the ones worth defending, because warning on them and exiting 0 is the natural way to write this check and it is wrong: a missing `curl`, a moved registry and a DNS change all land in the last of them, and a green-with-a-warning job is indistinguishable from a healthy pin forever after. The unknown arm retries first (`ETV_CI_ATTEMPTS`, default 3, `ETV_CI_RETRY_SECONDS` apart) so an ordinary registry blip does not redden a PR — that pause is what makes failing on unknown affordable, and -shortening it silently trades this guard for flake. +shortening it silently trades this guard for flake. **An unreachable token endpoint is in that +class, not in the refusal class**, and is retried on the same budget: the split is by what the +endpoint SAID, never by which leg of the read it happened on. **It is not a `needs:` of anything, but it is not consequence-free either.** The merge-consent hook reads the PR's **combined** status and denies on a non-`success` combined state (a `skipped` context diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 40425d827..ae17ab01b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -61,7 +61,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | | `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) | -| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | +| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo's visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull in 1-2s, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | | `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) | | `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark ` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always --gated `, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) | | `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) | diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index b6b2c809e..7f55d26c3 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -5,7 +5,7 @@ status: active since: '2026-09-04' supersedes: none superseded-by: none -rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity DEPENDS on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private breaks every container job at image pull, loudly, and the preflight names that cause in its own 401/403 message.' +rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull in 1-2s, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, indexed secrets reference, whole-context secrets reference, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. TWO THINGS THAT RUN DID NOT EXERCISE, because those layers were already on that host: an anonymous LAYER download, and act_runner''s own pull call path rather than the CLI''s. Both are first observable on a real PR run, where a failure is loud (both required contexts die at image pull in 1-2s) rather than silent.' --- @@ -33,7 +33,18 @@ pull token once per run, and retries the read with the bearer. Every refusal dir kept — a 401 or 403 *after* the token leg, a token endpoint that answers with no token, and one that cannot be reached, all `fail` rather than degrade to could-not-tell (`unknown-must-fail-not-warn`). Only the message changed, and deliberately: it now names the cause an operator can act on, which is -that the repo or the package has stopped being public. +that the package has stopped being anonymously pullable. + +**The token leg is retried on the same budget as the manifest read, and split by what the endpoint +SAID.** A leg that answered — no token in the body, a challenge naming no realm, no challenge at all +— has settled the question, and is asked once per run. A leg whose endpoint could not be reached, or +that answered 5xx, settled nothing and is the same transport blip the manifest read absorbs, so it +gets the same `ETV_CI_ATTEMPTS` tries. Sorting by LEG instead of by answer is the defect that was +here first: measured 2026-09-05, an unreachable token endpoint failed the job after one token call +while an identical blip on the manifest read got three, because the flag that stops a genuine refusal +being re-asked was set once per run and the failed leg left a `401` standing in the retry loop's +break list. A red here denies a merge (the consent hook reads the COMBINED status, ersatztv#598), so +the two legs of one read cannot be allowed opposite flake tolerances. **Why the guard derives its population.** The issue's first statement of this invariant was "every `container:` job on the PR route", and that was wrong on the day it was written: diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index 41ea0dab1..f645dc2ff 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -37,9 +37,15 @@ # for a public package against it. Measured 2026-09-04: the token endpoint answers 200 with no # credential, that token reads the pinned manifest and its config blob (200), and a tag that does # not exist answers 404 rather than 401 — so the deleted-tag diagnosis this whole script exists for -# survives the change. What does NOT survive it is `timothy/ersatztv` or its `ersatztv-ci` package -# being made private: the token leg then refuses, and this fails loudly with a message that names -# that cause rather than reporting could-not-tell. +# survives the change. What does NOT survive it is the `ersatztv-ci` PACKAGE ceasing to be +# anonymously pullable: the token leg then refuses, and this fails loudly with a message that names +# that cause rather than reporting could-not-tell. That is the package and not this repo — the +# package is linked to no repository (measured 2026-09-05: every version of it reports +# `"repository": null`), so `timothy/ersatztv` going private would not take the pull token away. +# What that WOULD take away is the unauthenticated commit-status GET in +# scripts/ci-detect-already-validated.sh, which is a different dependency with the opposite failure +# direction: it falls through to `skip=false` and stays green. Do not cite this script's loudness +# for that one. # # Env (all optional; the defaults are the live values): # ETV_CI_REGISTRY registry host:port (default 192.168.1.95:3000) @@ -68,11 +74,25 @@ attempts="${ETV_CI_ATTEMPTS:-3}" retry_seconds="${ETV_CI_RETRY_SECONDS:-5}" rc=0 -# The anonymous pull token, acquired at most once and reused for every pin. Empty until the -# registry challenges; `token_leg_done` separates "not tried yet" from "tried and got nothing", so a -# registry that is genuinely refusing anonymous reads is not re-asked once per pin. +# The anonymous pull token, acquired once and reused for every pin. Empty until the registry +# challenges. `token_leg_done` separates "not tried yet" from "tried", which is what lets the +# refusal arms below report what the run actually DID rather than what it might have done. +# +# `token_leg_retryable` separates the two ways the leg can come back empty, and they are not the +# same event. A token endpoint that ANSWERED and named no token — or a challenge naming no realm, or +# no challenge at all — has told us something, and asking it again cannot change the answer: that is +# the registry genuinely refusing anonymous reads, and it is asked once per RUN. A token endpoint +# that could not be REACHED, or that answered 5xx, told us nothing; it is the same transport blip the +# manifest read absorbs, so it is retried on the same `ETV_CI_ATTEMPTS` budget. Without that split +# the two legs of one read had opposite flake tolerances — measured 2026-09-05 on the predecessor of +# this commit with `ETV_CI_ATTEMPTS=3`, an unreachable token endpoint failed after 1 token call and +# 1 manifest call while a flaky manifest read got 3 retries — and this job's red denies a merge (the +# consent hook reads the COMBINED status, ersatztv#598), so a one-second token-endpoint outage +# blocked a merge until someone re-ran it. token="" token_leg_done=0 +token_leg_retryable=0 +token_leg_attempts=0 headers=$(mktemp) trap 'rm -f "$headers"' EXIT @@ -88,10 +108,13 @@ challenge_field() { # realm in it, a token endpoint that will not answer, or an answer carrying no token. Each of those # is "could not establish anonymous access", which the caller turns into a REFUSAL rather than a # could-not-tell: an empty token would otherwise fall through to a second 401 and read as an -# ordinary auth failure with no cause named. +# ordinary auth failure with no cause named. It also SORTS them, into `token_leg_retryable`: only +# the shapes where the endpoint said nothing at all are worth asking again. acquire_token() { - local challenge realm service url body + local challenge realm service url resp body http token_leg_done=1 + token_leg_retryable=0 + token_leg_attempts=$((token_leg_attempts + 1)) challenge=$(tr -d '\r' < "$headers" | sed -n 's/^[Ww][Ww][Ww]-[Aa]uthenticate:[[:space:]]*//p' | head -1) [ -n "$challenge" ] || return 1 realm=$(challenge_field "$challenge" realm) @@ -101,7 +124,20 @@ acquire_token() { # Spelled as a full `if` rather than `[ … ] && …`: as a bare statement the latter returns the # test's exit status, which is 1 whenever `service` is absent — a legal challenge shape. if [ -n "$service" ]; then url="$url&service=$service"; fi - body=$(curl -s "$url") || return 1 + # The status code is read for the same reason the manifest read reads it: it is the only thing + # that separates "this endpoint REFUSED" from "this endpoint was not there", and only the second + # is worth a second ask. A `curl` that fails outright (DNS, connect, reset) leaves no response at + # all and lands in the same class. + resp=$(curl -s -w '\n%{http_code}' "$url") || resp="" + if [ -z "$resp" ]; then + token_leg_retryable=1 + return 1 + fi + http=${resp##*$'\n'} + body=${resp%$'\n'*} + case "$http" in + 000|5??) token_leg_retryable=1; return 1 ;; + esac token=$(printf '%s' "$body" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) [ -n "$token" ] || return 1 return 0 @@ -139,14 +175,17 @@ request() { esac } -# The two legs of an anonymous registry read: the plain GET, and — only if it is challenged and we -# have not already tried — the token exchange followed by ONE retry carrying the bearer. A 401 that -# survives the retry is left as a 401 and refused by the caller; it is never rounded off to +# The two legs of an anonymous registry read: the plain GET, and — only if it is challenged and the +# leg is not already settled — the token exchange followed by ONE retry carrying the bearer. A 401 +# that survives the retry is left as a 401 and refused by the caller; it is never rounded off to # could-not-tell, because the cause (this package is no longer readable without a credential) sends # an operator somewhere quite different from a flaky registry. +# +# "Settled" is `token_leg_done` AND NOT `token_leg_retryable`: a leg abandoned because its endpoint +# could not be reached settled nothing, so the next attempt asks again. probe() { request "$1" - if [ "$code" = "401" ] && [ "$token_leg_done" -eq 0 ]; then + if [ "$code" = "401" ] && { [ "$token_leg_done" -eq 0 ] || [ "$token_leg_retryable" -eq 1 ]; }; then if acquire_token; then request "$1" fi @@ -161,7 +200,13 @@ for pin in $pins; do while : ; do probe "$url" case "$code" in - 200|404|401|403) break ;; + 200|404|403) break ;; + 401) + # A 401 is an answer — unless the only reason it still stands is a token leg whose endpoint + # could not be reached, in which case it is an unknown wearing the first read's status code + # and belongs in the retry class below with every other unknown. + if [ "$token_leg_retryable" -eq 0 ]; then break; fi + ;; esac # Only the unknown answers are retried: 200/404 are answers, and an auth failure will not cure # itself. A transient registry is the common case for the rest, and absorbing it here is what @@ -211,17 +256,20 @@ for pin in $pins; do # this one. The branch order below is `token` first precisely so this case cannot borrow # either of the other two mechanisms. # - # A failed token leg is NOT retried, deliberately: `token_leg_done` is set before the attempt - # so a registry genuinely refusing anonymous reads is asked once rather than once per pin. The - # cost is that a transient token-endpoint outage fails the job on its first try; the message - # below says so rather than blaming the package. + # A token leg that ANSWERED is not retried, deliberately: `token_leg_done` is set before the + # attempt and `token_leg_retryable` stays 0, so a registry genuinely refusing anonymous reads + # is asked once per RUN rather than once per pin or once per attempt. A token endpoint that + # could not be REACHED settled nothing and IS retried, on the same `ETV_CI_ATTEMPTS` budget the + # manifest read uses — the two legs of one read must not have opposite flake tolerances when a + # red here denies a merge. The message below reports how many token-leg attempts actually ran, + # so the two cases are distinguishable in the log rather than only in this comment. if [ -n "$token" ]; then - fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin even after a Bearer token was obtained, so the pin could not be checked. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that timothy/ersatztv and its ersatztv-ci package are still PUBLIC — do not read this as a pass." + fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin even after a Bearer token was obtained, so the pin could not be checked. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that the ersatztv-ci package is still PUBLIC (it is linked to no repository, so this is the package's own visibility, not the repo's) — do not read this as a pass." fi if [ "$token_leg_done" -eq 1 ]; then - fail "could NOT OBTAIN an anonymous pull token for $registry/$image_repo:$pin — the registry answered HTTP $code and the token leg produced none: either there was no Www-Authenticate challenge, or it named no realm, or the token endpoint did not answer with a token. The pin was NOT checked. Look at the registry's token endpoint, not at the pin." + fail "could NOT OBTAIN an anonymous pull token for $registry/$image_repo:$pin after $token_leg_attempts token-leg attempt(s) — the registry answered HTTP $code and the token leg produced none: either there was no Www-Authenticate challenge, or it named no realm, or the token endpoint did not answer with a token. The pin was NOT checked. Look at the registry's token endpoint, not at the pin." fi - fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin and NO TOKEN WAS EVER REQUESTED: the token leg is entered on a 401 only, so this answer was never followed as a Bearer challenge and the pin could not be checked. An outright refusal is an answer about ACCESS to the registry, not about the pin. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that timothy/ersatztv and its ersatztv-ci package are still PUBLIC, and that nothing (a proxy, an ACL) is answering for the registry — do not read this as a pass." + fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin and NO TOKEN WAS EVER REQUESTED: the token leg is entered on a 401 only, so this answer was never followed as a Bearer challenge and the pin could not be checked. An outright refusal is an answer about ACCESS to the registry, not about the pin. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that the ersatztv-ci package is still PUBLIC (it is linked to no repository, so this is the package's own visibility, not the repo's), and that nothing (a proxy, an ACL) is answering for the registry — do not read this as a pass." ;; *) # NOT gone, and NOT a pass either. Deliberately worded apart from the 404 message: this sends diff --git a/scripts/tests/test_ci_toolchain_image_resolves.py b/scripts/tests/test_ci_toolchain_image_resolves.py index 181eab4ff..1823c0e3b 100644 --- a/scripts/tests/test_ci_toolchain_image_resolves.py +++ b/scripts/tests/test_ci_toolchain_image_resolves.py @@ -70,8 +70,18 @@ with (state / "calls").open("a") as fh: # --- the token endpoint -------------------------------------------------------------------- if "/token" in url: behaviour = codes.get("TOKEN", "issue") + if behaviour == "blip": + # Unreachable on the FIRST ask and healthy afterwards -- a token-endpoint outage shorter + # than the retry budget, which is the shape a retry exists to absorb. + marker = state / "token-blip" + behaviour = "issue" if marker.exists() else "unreachable" + marker.write_text("1") if behaviour == "unreachable": sys.exit(7) + if behaviour == "server-error": + # It ANSWERS, but with nothing a client can use -- the other half of "told us nothing". + print("502 Bad Gateway\n502", end="") + sys.exit(0) if behaviour == "empty": print('{"expires_in": 300}\n200', end="") else: @@ -380,6 +390,67 @@ def test_a_token_endpoint_that_yields_NO_TOKEN_refuses(preflight, behaviour): assert preflight.authenticated_manifest_calls() == [], "no read can have carried a token" +@pytest.mark.parametrize("behaviour", ["unreachable", "server-error"]) +def test_a_token_endpoint_that_SAID_NOTHING_is_retried_like_any_other_unknown(preflight, behaviour): + """The two legs of one read must not have opposite flake tolerances. + + A token endpoint that cannot be reached (or answers 5xx) has told the run nothing — it is the + same transport blip a flaky manifest read gets three tries to survive. Measured 2026-09-05 on + the predecessor of this commit, it got ZERO: `token_leg_done` was set once per RUN, the failed + leg left the first read's `401` standing, and `401` was in the retry loop's break list, so the + job reddened after one token call. A red here denies a merge (the consent hook reads the + COMBINED status, ersatztv#598), so that was a one-second outage blocking a merge until someone + re-ran the job. + """ + preflight.env["ETV_CI_ATTEMPTS"] = "3" + preflight.set_codes({"*": "200", "TOKEN": behaviour}) + result = preflight.run() + assert result.returncode != 0, "a run that never obtained a token must not report success" + assert len(preflight.token_calls()) == 3, ( + f"expected the token leg to be retried on the attempt budget, got {preflight.calls()}" + ) + assert "could NOT OBTAIN an anonymous pull token" in result.stderr, result.stderr + assert "after 3 token-leg attempt(s)" in result.stderr, ( + f"the message must report what the run actually did, not what it might have. stderr={result.stderr}" + ) + + +@pytest.mark.parametrize("codes", [{"TOKEN": "empty"}, {"CHALLENGE": "none", "REFUSAL": "401"}]) +def test_a_token_endpoint_that_ANSWERED_is_asked_ONCE(preflight, codes): + """The other direction, and the property the retry above must not cost. + + An endpoint that answered and named no token — or a `401` carrying no challenge to follow — has + said something, and asking again cannot change it. That is a registry genuinely refusing + anonymous reads, and it is asked once per RUN. + """ + preflight.env["ETV_CI_ATTEMPTS"] = "3" + preflight.set_codes({"*": "200", **codes}) + result = preflight.run() + assert result.returncode != 0 + assert len(preflight.token_calls()) <= 1, ( + f"a settled token leg must not be re-asked, got {preflight.calls()}" + ) + assert len(preflight.manifest_calls()) == 1, ( + f"nor may the manifest read be retried behind it, got {preflight.calls()}" + ) + assert "could NOT OBTAIN an anonymous pull token" in result.stderr, result.stderr + + +def test_a_token_endpoint_BLIP_shorter_than_the_budget_RECOVERS(preflight): + """Retrying is only worth anything if the run can still succeed. + + The endpoint is unreachable on the first ask and healthy on the second, so the whole point is + the exit code: the pin resolves, with a bearer that reached the registry. + """ + preflight.env["ETV_CI_ATTEMPTS"] = "3" + preflight.set_codes({"*": "200", "TOKEN": "blip"}) + result = preflight.run() + assert result.returncode == 0, f"stdout={result.stdout}\nstderr={result.stderr}" + assert "32747a0 resolves" in result.stdout + assert len(preflight.token_calls()) == 2, f"expected one failed ask and one good one, got {preflight.calls()}" + assert preflight.authenticated_manifest_calls(), "the recovered token never reached the registry" + + def test_the_TOKEN_LEG_is_performed_ONCE_across_every_pin(preflight): """The token is state that must survive the pin loop. -- 2.47.3 From ae5a32290f7d4f9c32f4edbbe5d9e21cd7e043f2 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 06:29:54 +0200 Subject: [PATCH 13/31] style(885): ruff format the assertion the previous commit wrapped by hand `ruff format --check` is part of the local gate for touched Python and the predecessor of this file passed it; one new assertion was hand-wrapped across lines that fit on one. Refs #885 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- scripts/tests/test_ci_toolchain_image_resolves.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/tests/test_ci_toolchain_image_resolves.py b/scripts/tests/test_ci_toolchain_image_resolves.py index 1823c0e3b..c36c79a47 100644 --- a/scripts/tests/test_ci_toolchain_image_resolves.py +++ b/scripts/tests/test_ci_toolchain_image_resolves.py @@ -427,9 +427,7 @@ def test_a_token_endpoint_that_ANSWERED_is_asked_ONCE(preflight, codes): preflight.set_codes({"*": "200", **codes}) result = preflight.run() assert result.returncode != 0 - assert len(preflight.token_calls()) <= 1, ( - f"a settled token leg must not be re-asked, got {preflight.calls()}" - ) + assert len(preflight.token_calls()) <= 1, f"a settled token leg must not be re-asked, got {preflight.calls()}" assert len(preflight.manifest_calls()) == 1, ( f"nor may the manifest read be retried behind it, got {preflight.calls()}" ) -- 2.47.3 From dd836c1f46da488862bf3033e134dd8e06702b7a Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 07:30:00 +0200 Subject: [PATCH 14/31] fix(885): the swept symbol's last live-voice site in the dispatch record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same-subject sweep that past-tensed `ETV_STATUS_AUTH` and this record's own `rule:` field left the record BODY saying `toolchain-preflight` takes the registry credential "via `ETV_REGISTRY_AUTH`" in the present tense — a symbol this branch removes from every workflow, so the body contradicted the `rule:` field of the same record. Container-free and `runs-on: small` are still true today and stay in the present tense; only the credential clause moves to the past, matching the `rule:` field's "took the credential through `ETV_REGISTRY_AUTH`". Body-only, so the generated catalog is unchanged (regenerated to confirm). Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../records/ci/workflow-dispatch-ref-unrestricted.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md b/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md index 3263250e7..6f03997c8 100644 --- a/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md +++ b/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md @@ -55,9 +55,9 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat `pull_request`-triggered workflow may name a `secrets.*`* — neither a job the trigger reaches nor the workflow scope outside `jobs:`, whose `env:`/`defaults:` are materialised into every job — derived from the git index, because a remediation scoped to those six names silently misses the - seventh. Not "every `container:` job" - either — `toolchain-preflight` is deliberately container-free (`runs-on: small`, credential via - `ETV_REGISTRY_AUTH`), so that predicate names five of six and is stale before it ships. + seventh. Not "every `container:` job" either — `toolchain-preflight` is deliberately + container-free (`runs-on: small`) and took the credential through `ETV_REGISTRY_AUTH`, so that + predicate names five of six and is stale before it ships. **The route itself is untouched by that fix**, which is why this record still stands: head-supplied YAML can name any secret in the store, so "push a branch, open a PR" still reaches all of them with **no act outside the ordinary contribution flow**, where a dispatch costs one — that is this -- 2.47.3 From d62335bc8d6e95cd800325b77e7c9a66c67c0991 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 08:53:43 +0200 Subject: [PATCH 15/31] =?UTF-8?q?fix(885):=20an=20English=20full=20stop=20?= =?UTF-8?q?is=20not=20a=20secret=20name=20=E2=80=94=20the=20detector=20rea?= =?UTF-8?q?ds=20`secrets`=20only=20inside=20an=20expression=20span?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_SECRET_REF` ran over the whole string while the comment above it claimed every pattern was confined to `${{ }}` spans. Executed against the shipped module, `secret_refs("# We deliberately pass no secrets. Then the pull is anonymous.")` returned `['Then']`, and fed through the real collector that is one fault reading "job `j` names stored secret(s) on the pull_request route: Then" — a fabricated name, on a PR-route job, for its own comment. The same comment separately reddened the text-versus-walk cross-check, because the line-level strip removes a `#` line from the text half only. The existing negative control passed for a reason that does not generalise: no `.` follows the word in `"no secrets are used here"`. A sentence ENDING in "secrets." is the likeliest thing to be written into a PR-route `run:` block on this branch's own subject, so the trap was self-inflicted. `secret_refs` now resolves names per `${{ }}` span, so every spelling is scoped the way the residue counter already was. The added rows drive the real predecessor — `_SECRET_REF` applied to the whole string — and assert it read a name where the scoped reader reads none, so reverting the scoping reddens them. The `INJECTED_SECRETS` comment stops calling the injected `GITEA_TOKEN` "bounded by the workflow's own `permissions:`": on this route the head supplies that file and can delete the block. Allow-listing it is a claim about the store it is not in, not about a bound. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../test_workflow_persist_credentials.py | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 2509a7efc..2ff80f83b 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -333,9 +333,11 @@ def test_no_workflow_lives_under_dot_GITHUB() -> None: # The repo's Actions secret store, read 2026-09-04: GH_COM_TOKEN, REGISTRY_PASSWORD, REGISTRY_USER, # RENOVATE_TOKEN, SERVERMGMT_DEPLOY_KEY. `GITEA_TOKEN` is deliberately NOT one of them — it is the -# per-run token Gitea injects, bounded by the workflow's own `permissions:` block, and a head-authored -# run receives it whether or not any job names it. Allow-listing it is therefore a statement about a -# MECHANISM (injected, scoped, unavoidable) and not an exemption for a site, which is why it is a +# per-run token Gitea injects, and a head-authored run receives it whether or not any job names it. +# Its `permissions:` block narrows it for the COMMITTED file only — on this route the head supplies +# that file and can delete the block — so allow-listing it is NOT a claim that it is bounded. It is a +# statement about a MECHANISM (injected, unavoidable, out of the store this guard is about) and not +# an exemption for a site, which is why it is a # closed one-member set rather than a list that can grow: a second entry would be an exemption, and # an exemption outlives its reason silently. INJECTED_SECRETS = frozenset({"GITEA_TOKEN"}) @@ -378,9 +380,13 @@ _SECRET_REF = re.compile( # grammar: any spelling of the context that does not yield a literal name is reported, including one # nobody has written yet. # -# Both patterns are confined to `${{ }}` spans, because a bare `secrets` is otherwise ordinary -# English — this file and four workflows discuss "secrets" in prose, and matching that would fault -# every job carrying a comment. Inside an expression the word is the context and nothing else. +# EVERY pattern here reads only inside `${{ }}` spans, `_SECRET_REF` included, because outside one +# the word is ordinary English and `secrets.` is a sentence boundary. That scoping is enforced in +# `secret_refs` below rather than in the patterns, which cannot express "within the enclosing span". +# Applied to the whole string instead, `_SECRET_REF` reads the English `# We pass no secrets. Then +# the pull is anonymous.` as a reference to a secret named `Then` — a fabricated name, faulting a +# PR-route job for a comment, on this branch's own subject. A reference the runner actually resolves +# is always inside an expression, so the scoping costs no real spelling. _EXPRESSION = re.compile(r"\$\{\{(.*?)\}\}", re.S) _SECRETS_TOKEN = re.compile(r"(? list[str]: spelling widens both. A list rather than a set for the reason `secret_name_counts` is a Counter: only the lossless direction can be narrowed afterwards. """ - found = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(text)] + found: list[str] = [] for expression in _EXPRESSION.findall(text): - unresolved = len(_SECRETS_TOKEN.findall(expression)) - len(_SECRET_REF.findall(expression)) - found.extend([WHOLE_SECRETS_CONTEXT] * unresolved) + resolved = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(expression)] + found.extend(resolved) + # The residue: `secrets` tokens in this span that resolved to no literal name. + found.extend([WHOLE_SECRETS_CONTEXT] * (len(_SECRETS_TOKEN.findall(expression)) - len(resolved))) return found @@ -794,6 +802,29 @@ def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None: prose = {True: {"pull_request": None}, "jobs": {"j": {"steps": [{"run": "echo 'no secrets here'"}]}}} assert stored_secret_faults("synthetic.yml", prose) == [] + # The prose row above passes for a reason that does not generalise — no `.` follows the word. An + # English sentence ENDING in "secrets." is the shape that reached the unscoped predecessor, and + # it is the likeliest sentence to be written into a PR-route `run:` block on this branch's own + # subject. Driven against that predecessor: `unscoped` is `_SECRET_REF` applied to the whole + # string, which is how `secret_refs` read before the span scoping. + for sentence in ( + "# We deliberately pass no secrets. Then the pull is anonymous.", + "echo 'this job holds no secrets. Anonymous pull only'", + ): + unscoped = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(sentence)] + assert unscoped, ( + f"{sentence!r} is supposed to be a sentence the unscoped predecessor read as a secret " + "name — if it is not, this row proves nothing about the scoping." + ) + assert secret_refs(sentence) == [], sentence + commented = {True: {"pull_request": None}, "jobs": {"j": {"steps": [{"run": f"{sentence}\ntrue"}]}}} + assert stored_secret_faults("synthetic.yml", commented) == [], sentence + # The same comment reddened the text-versus-walk cross-check separately, and for a different + # mechanism: the strip is LINE-level, so a fabricated name inside a `run:` scalar was removed + # from the text half only and the two halves disagreed on a name no workflow ever held. + as_text = f"jobs:\n j:\n steps:\n - run: |\n {sentence}\n true\n" + assert walk_versus_text_faults("synthetic.yml", as_text) == [], sentence + def test_the_collector_reports_a_WORKFLOW_SCOPE_reference_no_job_if_can_reach() -> None: """The workflow scope is judged even when every job is gated OFF the route. -- 2.47.3 From a4eea6460b995af5f761e812347c2a3f0d960338 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 08:54:06 +0200 Subject: [PATCH 16/31] fix(885): a legal challenge in another case read as a missing realm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header field names (RFC 9110 §5.1) and auth-param names (RFC 7235 §2.1) are both case-insensitive, so `WWW-AUTHENTICATE: Bearer REALM="…"` is the same challenge this registry sends in mixed case today. The preflight matched the header name in a fixed case for all but four letters and the directive name in lowercase only, so that spelling fell into the "named no realm" arm: the job fails — the safe direction — but names a cause that is not the real one and points an operator at a token endpoint that is healthy. The header line is now selected by an `awk` comparison on the lowercased field name, which leaves the value's case alone (a realm URL is case-sensitive), and the directive name is matched through a character class generated from the key. The new test drives the whole anonymous read end to end against an all-caps challenge rather than testing the parser, so the token leg and the authenticated re-read both have to survive the spelling. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- scripts/ci-toolchain-image-resolves.sh | 17 +++++++++-- .../tests/test_ci_toolchain_image_resolves.py | 29 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index f645dc2ff..a812a0fbd 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -100,8 +100,20 @@ trap 'rm -f "$headers"' EXIT # read from the challenge rather than assumed, so a registry that moves its token endpoint is # followed instead of guessed at; `service` is optional (this registry issues a token without it, # measured 2026-09-04) and is passed through when the challenge names one. +# +# The directive NAME is matched in any case, through a character class generated from the key, and +# the header name likewise (below): RFC 7235 §2.1 makes auth-param names case-insensitive and RFC +# 9110 §5.1 makes field names case-insensitive, so a registry answering `WWW-Authenticate: Bearer +# Realm="…"` is spelling this legally. Matching one spelling would send that challenge down the +# "named no realm" arm and `fail` the job with the wrong diagnosis — loud, but pointing an operator +# at a token endpoint that is fine. The VALUE keeps its case: a realm URL is case-sensitive. challenge_field() { - printf '%s' "$1" | sed -n "s/.*[ ,]$2=\"\([^\"]*\)\".*/\1/p" | head -1 + local key="$2" pattern="" index char + for ((index = 0; index < ${#key}; index++)); do + char=${key:index:1} + pattern="$pattern[$char$(printf '%s' "$char" | tr '[:lower:]' '[:upper:]')]" + done + printf '%s' "$1" | sed -n "s/.*[ ,]$pattern=\"\([^\"]*\)\".*/\1/p" | head -1 } # The token leg. Returns non-zero on every shape that leaves us without a bearer — no challenge, no @@ -115,7 +127,8 @@ acquire_token() { token_leg_done=1 token_leg_retryable=0 token_leg_attempts=$((token_leg_attempts + 1)) - challenge=$(tr -d '\r' < "$headers" | sed -n 's/^[Ww][Ww][Ww]-[Aa]uthenticate:[[:space:]]*//p' | head -1) + challenge=$(tr -d '\r' < "$headers" | + awk -F: 'tolower($1) == "www-authenticate" { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }') [ -n "$challenge" ] || return 1 realm=$(challenge_field "$challenge" realm) [ -n "$realm" ] || return 1 diff --git a/scripts/tests/test_ci_toolchain_image_resolves.py b/scripts/tests/test_ci_toolchain_image_resolves.py index c36c79a47..87269de7b 100644 --- a/scripts/tests/test_ci_toolchain_image_resolves.py +++ b/scripts/tests/test_ci_toolchain_image_resolves.py @@ -111,10 +111,12 @@ if authorization != "Bearer anon-token": # client where a token can be had. if dump: realm = codes.get("REALM", "http://registry.test/v2/token") - pathlib.Path(dump).write_text( - "HTTP/1.1 401 Unauthorized\r\n" - 'Www-Authenticate: Bearer realm="%s",service="container_registry",scope="*"\r\n' % realm - ) + # A legal challenge in a case no part of the script spells: field names and auth-param + # names are both case-insensitive, so this is the same header, not a malformed one. + line = 'Www-Authenticate: Bearer realm="%s",service="container_registry",scope="*"\r\n' + if codes.get("CHALLENGE") == "upper": + line = 'WWW-AUTHENTICATE: Bearer REALM="%s",SERVICE="container_registry",SCOPE="*"\r\n' + pathlib.Path(dump).write_text("HTTP/1.1 401 Unauthorized\r\n" + line % realm) print("{}\n401", end="") sys.exit(0) @@ -370,6 +372,25 @@ def test_the_REALM_is_read_from_the_CHALLENGE_rather_than_assumed(preflight): ] +def test_the_CHALLENGE_is_read_in_ANY_case(preflight): + """`WWW-AUTHENTICATE: Bearer REALM="…"` is the same challenge, and must take the same path. + + Field names (RFC 9110 §5.1) and auth-param names (RFC 7235 §2.1) are both case-insensitive, so + a registry may answer in this spelling without being broken. The predecessor read the header + name in a fixed case for all but four letters and the directive name in lowercase only, which + sent a legal challenge down the "named no realm" arm: a `fail` naming a cause that is not the + real one, pointing an operator at a healthy token endpoint. Driven end to end rather than on the + parser, so the whole anonymous read has to survive the spelling. + """ + preflight.set_codes({"*": "200", "CHALLENGE": "upper"}) + result = preflight.run() + assert result.returncode == 0, result.stderr + assert preflight.token_calls() == [ + "http://registry.test/v2/token?scope=repository:timothy/ersatztv-ci:pull&service=container_registry auth=" + ], preflight.calls() + assert preflight.authenticated_manifest_calls(), preflight.calls() + + @pytest.mark.parametrize("behaviour", ["empty", "unreachable"]) def test_a_token_endpoint_that_yields_NO_TOKEN_refuses(preflight, behaviour): """No bearer is not "carry on unauthenticated" — it is could-not-establish, and it must refuse. -- 2.47.3 From af9c2349a73aee6f651a0148fa7f7a2b96d6b67f Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 08:54:44 +0200 Subject: [PATCH 17/31] fix(885): three claims this branch added, dated to what was actually measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `review-verdict.yml`'s residual list said the injected `GITEA_TOKEN` on the `pull_request` route is BOUNDED by `docker-build.yml`'s workflow-level `permissions: code: read`. That block lives in the head-supplied file on exactly that route: a PR author deletes it, and with the owner-level Actions default at `permissive` that alone yields a write-capable token. It is NARROWED for the committed file, and it stays in the residual set the paragraph exists to enumerate — which is what `release.verdict-status-check` and `test_pr_changed_files.py` already say. The same reword lands in `ci.pr-route-carries-no-stored-credential`, where the allow-list reason is now the store the token is not in rather than a bound. The "dies at image pull in 1-2s" figure was never measured on this branch — the 1-2s in `ci-toolchain-image-resolves.sh`'s header is an observation from the #772 incident, not a property of this change. The loud/silent asymmetry is what carries the argument, so the claim is now that a container job dies at image pull before it runs a step, which is true by construction. `ci.actions-credential-scoping`'s reworded `mechanics:` said "all three are now confined to the `build` job". `build` declares no `container:` at all; the buildcache write and the base-image pull are what it confines, and the `container:` pull is credential-free everywhere. `docs/ci-cd.md` asserted the `renovate` bot can no longer push a `v*` tag while `release.tag-protection-v-star` records that as NOT VERIFIED. The rule is read back live and real; what is unmeasured is Gitea honouring it against an account only the operator can test. Both docs now say expected, unverified. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .gitea/workflows/docker-build.yml | 4 ++-- .gitea/workflows/review-verdict.yml | 7 +++++-- docs/ci-cd.md | 11 +++++++---- docs/decisions/README.md | 2 +- .../records/ci/actions-credential-scoping.md | 2 +- .../ci/pr-route-carries-no-stored-credential.md | 11 +++++++---- 6 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 5289361ba..bcdcd55ae 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -133,8 +133,8 @@ env: # The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it # reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull # token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and -# losing it IS loud: every `container:` job dies at image pull in 1-2s, both required contexts among -# them, and `toolchain-preflight` names the cause in its own 401/403 message. (2) The three +# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both required +# contexts among them, and `toolchain-preflight` names the cause in its own 401/403 message. (2) The three # commit-status GETs need `timothy/ersatztv` itself to stay publicly readable, and losing that is # SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls through to `skip=false`, and the # jobs stay GREEN — only the ersatztv#420 cross-run skip quietly stops firing, which costs a diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 9e0dc0f45..d59073fe3 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -1452,8 +1452,11 @@ jobs: # and since #885 that route materialises no STORED credential to refuse: nothing on the # `pull_request` route names a stored secret and the status read is anonymous # (`ci.pr-route-carries-no-stored-credential`). The INJECTED `GITEA_TOKEN` is still handed - # to every job there and is a separate control: what bounds it is `docker-build.yml`'s own - # workflow-level `permissions:` (`code: read`), not the removal of the stored secrets. + # to every job there and STAYS IN THIS RESIDUAL SET: `docker-build.yml`'s workflow-level + # `permissions:` (`code: read`) NARROWS it for the COMMITTED file only, and on the + # `pull_request` route the head supplies that file and can delete the block — with the + # owner-level Actions default at `permissive` (`ci.actions-credential-scoping`) that alone + # yields a write-capable token. Removing the stored secrets does not touch it. # `RENOVATE_TOKEN` is now re-derived rather than inherited. # # But an ALLOW-LISTED collaborator's own token can still diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 80c062f1a..77148ec70 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -105,7 +105,8 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada > **`v*` tags are protected — push as `timothy`.** Since ersatztv#885 the repo carries one > tag-protection rule (`name_pattern: v*`, `whitelist_usernames: ["timothy"]`, > `release.tag-protection-v-star`), because a `v*` push builds and publishes `:prod`. A push from - > any other account is refused. **The positive half of that rule is UNVERIFIED**: it was applied + > any other account is expected to be refused — configured, not exercised, since only the + > `timothy` credential is available here. **The positive half of that rule is UNVERIFIED too**: it was applied > 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because > pushing a `v*` tag outside a real cut would publish `:prod`. This cut is its first real > exercise. If the tag push is refused, unblock with @@ -1459,9 +1460,11 @@ Practical consequences: **every** change to `main` goes through a PR, including and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control — they are fail-open and `--no-verify` bypasses them. Tag pushes go through a separate mechanism and are unaffected for the release operator: since ersatztv#885 `tag_protections` carries one rule, -`v*` whitelisted to `timothy` (`release.tag-protection-v-star`), so the release cut in "Cutting a -release" still works unchanged while the `renovate` bot can no longer push a `v*` tag — which would -otherwise have published a `:prod` image. +`v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is meant to leave the release +cut in "Cutting a release" working unchanged while refusing a `v*` tag from the `renovate` bot — a push +that would otherwise have published a `:prod` image. Both halves of that are EXPECTED, UNVERIFIED: only +the `timothy` credential exists here, so neither a real release cut nor a refused bot push has been +exercised. `release.tag-protection-v-star` carries the same caveat and is waiting for the next cut. **A fourth arm: guard-scope freshness (ersatztv#787).** `scripts/tests/test_ci_dropped_step_guard.py` decides which CI jobs must carry per-step execution markers by DERIVING that scope from diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ae17ab01b..0fe112982 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -61,7 +61,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | | `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) | -| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow's own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo's visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull in 1-2s, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | +| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow's own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo's visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | | `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) | | `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark ` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always --gated `, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) | | `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) | diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md index 665c594f5..62ac0d135 100644 --- a/docs/decisions/records/ci/actions-credential-scoping.md +++ b/docs/decisions/records/ci/actions-credential-scoping.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NOTHING on the `pull_request` route names a stored secret any more — no job and no workflow scope (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store.' signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714' -mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe): the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run exercises which, and that this CHANGED on 2026-09-04 (#885): all three are now confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so none of them is exercised by a PR at all — the `container:` pull was the one that used to be, and it is now ANONYMOUS and carries no credential to infer about. All three are therefore first exercised on the post-merge push to `main`, AFTER the merge gate has passed, so a wrong inference reddens main, not the PR.' +mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe): the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run exercises which, and that this CHANGED on 2026-09-04 (#885). The buildcache write and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`. The `container:` pull is not in `build` at all — `build` declares no `container:` — and it is the one a PR used to exercise with this credential; the five `container:` blocks now declare no `credentials:` and pull ANONYMOUSLY, so there is no credential left there to infer about (`ci.pr-route-carries-no-stored-credential`). No PR run therefore exercises this PAT at all, and the two credentialed uses are first exercised on the post-merge push to `main`, AFTER the merge gate has passed, so a wrong inference reddens main, not the PR.' --- **What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index 7f55d26c3..092c0bc5e 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -5,9 +5,9 @@ status: active since: '2026-09-04' supersedes: none superseded-by: none -rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and bounded by the workflow''s own `permissions:`, not stored: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull in 1-2s, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' +rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow''s own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, indexed secrets reference, whole-context secrets reference, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' -mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. TWO THINGS THAT RUN DID NOT EXERCISE, because those layers were already on that host: an anonymous LAYER download, and act_runner''s own pull call path rather than the CLI''s. Both are first observable on a real PR run, where a failure is loud (both required contexts die at image pull in 1-2s) rather than silent.' +mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. TWO THINGS THAT RUN DID NOT EXERCISE, because those layers were already on that host: an anonymous LAYER download, and act_runner''s own pull call path rather than the CLI''s. Both are first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' --- **What was wrong.** `docker-build.yml` triggers on `pull_request:`, and Gitea resolves that run from @@ -79,8 +79,11 @@ RESIDUE: any `secrets` token inside a `${{ }}` span that yielded no literal name `secrets.*`. Counting the residue rather than matching `toJSON(secrets)` and a computed index by name is what makes it exhaustive over the grammar — a spelling nobody has written yet still faults. The cost is over-matching in one direction (a spelling this instance's runner may not resolve costs a -spurious demand on a job nobody has written), and the word `secrets` is only read as the context -INSIDE an expression, because in prose it is ordinary English. +spurious demand on a job nobody has written), and the word `secrets` is read as the context only +INSIDE a `${{ }}` span — every pattern, not just the residue counter. Outside one it is ordinary +English, and `secrets.` is a full stop: applied to the whole string the pattern read the comment +`# We pass no secrets. Then the pull is anonymous.` as a reference to a secret named `Then`, which +faulted a PR-route job for its own prose, on this branch's own subject. **The `if:` classifier is a PIN, not a parser.** Exactly one string — `github.event_name != 'pull_request'` — takes a job off the route. Anything else, including -- 2.47.3 From a84fa7665b353c8afa821d5359e52f15c125b871 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 08:56:28 +0200 Subject: [PATCH 18/31] fix(885): brace the accumulator so `[` after it is not read as an array subscript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pattern="$pattern[$char…]"` is SC1087 — shellcheck reads `$pattern[` as an array expansion and errors out. It concatenates correctly here because `pattern` is a plain string, so this is a lint stop rather than a runtime defect; braced, the character class is unambiguous to reader and linter alike. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- scripts/ci-toolchain-image-resolves.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index a812a0fbd..b652779a7 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -111,7 +111,7 @@ challenge_field() { local key="$2" pattern="" index char for ((index = 0; index < ${#key}; index++)); do char=${key:index:1} - pattern="$pattern[$char$(printf '%s' "$char" | tr '[:lower:]' '[:upper:]')]" + pattern="${pattern}[${char}$(printf '%s' "$char" | tr '[:lower:]' '[:upper:]')]" done printf '%s' "$1" | sed -n "s/.*[ ,]$pattern=\"\([^\"]*\)\".*/\1/p" | head -1 } -- 2.47.3 From d137d70bf2aa43ddc71fbed4df1a3b3e50721691 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 08:58:05 +0200 Subject: [PATCH 19/31] style(885): rewrap the header comment the previous commit ran past 100 columns Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .gitea/workflows/docker-build.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index bcdcd55ae..587d4c706 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -133,8 +133,9 @@ env: # The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it # reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull # token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and -# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both required -# contexts among them, and `toolchain-preflight` names the cause in its own 401/403 message. (2) The three +# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both +# required contexts among them, and `toolchain-preflight` names the cause in its own 401/403 +# message. (2) The three # commit-status GETs need `timothy/ersatztv` itself to stay publicly readable, and losing that is # SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls through to `skip=false`, and the # jobs stay GREEN — only the ersatztv#420 cross-run skip quietly stops firing, which costs a -- 2.47.3 From 6713b82042a2b877cc99c77e42b3a9f00171775e Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 08:58:56 +0200 Subject: [PATCH 20/31] style(885): reflow the paragraph rather than leaving a stub line mid-sentence Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .gitea/workflows/docker-build.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 587d4c706..f6c370233 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -135,11 +135,11 @@ env: # token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and # losing it IS loud: every `container:` job dies at image pull, before it runs a step, both # required contexts among them, and `toolchain-preflight` names the cause in its own 401/403 -# message. (2) The three -# commit-status GETs need `timothy/ersatztv` itself to stay publicly readable, and losing that is -# SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls through to `skip=false`, and the -# jobs stay GREEN — only the ersatztv#420 cross-run skip quietly stops firing, which costs a -# redundant re-validation and never a skip that was not earned. The invariant is held by +# message. (2) The three commit-status GETs need `timothy/ersatztv` itself to stay publicly +# readable, and losing that is SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls +# through to `skip=false`, and the jobs stay GREEN — only the ersatztv#420 cross-run skip quietly +# stops firing, which costs a redundant re-validation and never a skip that was not earned. The +# invariant is held by # scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret. # The injected token serves only this file's eight `actions/checkout` steps. Note it needs no # `packages:` unit: the container pulls are anonymous, not token-authenticated. -- 2.47.3 From e656583247f3c9cef0eec2eea0165fd3db81bc9e Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 09:01:08 +0200 Subject: [PATCH 21/31] style(885): the tag-protection paragraph reads as unverified in its opening clause too "unaffected for the release operator" asserted the outcome the rest of the paragraph then marks unverified. Both places now say what is intended and what is measured, and the blockquote is rewrapped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/ci-cd.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 77148ec70..8355de83b 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -106,8 +106,8 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada > tag-protection rule (`name_pattern: v*`, `whitelist_usernames: ["timothy"]`, > `release.tag-protection-v-star`), because a `v*` push builds and publishes `:prod`. A push from > any other account is expected to be refused — configured, not exercised, since only the - > `timothy` credential is available here. **The positive half of that rule is UNVERIFIED too**: it was applied - > 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because + > `timothy` credential is available here. **The positive half is UNVERIFIED too**: the rule was + > applied 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because > pushing a `v*` tag outside a real cut would publish `:prod`. This cut is its first real > exercise. If the tag push is refused, unblock with > `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`, push, then re-`POST` the rule @@ -1459,12 +1459,13 @@ re-opens the hole this section exists to close. Practical consequences: **every** change to `main` goes through a PR, including a one-line docs fix; and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control — they are fail-open and `--no-verify` bypasses them. Tag pushes go through a separate mechanism and -are unaffected for the release operator: since ersatztv#885 `tag_protections` carries one rule, -`v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is meant to leave the release -cut in "Cutting a release" working unchanged while refusing a `v*` tag from the `renovate` bot — a push -that would otherwise have published a `:prod` image. Both halves of that are EXPECTED, UNVERIFIED: only -the `timothy` credential exists here, so neither a real release cut nor a refused bot push has been -exercised. `release.tag-protection-v-star` carries the same caveat and is waiting for the next cut. +are meant to stay unaffected for the release operator: since ersatztv#885 `tag_protections` carries +one rule, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave +the release cut in "Cutting a release" working unchanged while refusing a `v*` tag from the `renovate` +bot — a push that would otherwise have published a `:prod` image. Both halves of that are EXPECTED, +UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot +push has been exercised. `release.tag-protection-v-star` carries the same caveat and waits on the +next cut. **A fourth arm: guard-scope freshness (ersatztv#787).** `scripts/tests/test_ci_dropped_step_guard.py` decides which CI jobs must carry per-step execution markers by DERIVING that scope from -- 2.47.3 From fa06da21f33db49281d7100a55f9f45c1b5d36bf Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 11:00:29 +0200 Subject: [PATCH 22/31] fix(885): the guard states both invariants, and the second one is executed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `docs/guard-inventory.md` rows and the module docstring described `test_workflow_persist_credentials.py` as the `actions/checkout` guard only. The deferral to #909 rested on `docs/guard-inventory.md` being held by the session working #881; that issue is closed and its PR is the commit this branch is rebased onto, so the file is free and the edit belongs here under docs-update-is-part-of-done. `MUTATIONS` keys at most one declared clause mutation per guard FILE (`test_the_manifest_covers_exactly_the_MUTATION_rows` asserts `len(MUTATIONS) == len(declared)`), and the grading row's proof-ref column is compared against it, so the route invariant cannot take a second `MUTATIONS` row. It takes a `CLAIMS` entry instead — the population #881 widened this file to carry — bound to the inventory sentence that states it: deleting `build`'s `if: github.event_name != 'pull_request'` from the shipped `docker-build.yml` is applied to a sandbox copy every run and the named proof is required to redden with the collector's own wording. That grows the `CLAIMS` population from three entries to four, which invalidates the cost span `testing.mutation-claims-are-executed` measured over three. Re-taking it here produced 54.3s/149.5s, 81.6s/78.7s and 114.3s/84.2s across three A/B pairs with other builds on the host — two inverted, so the load dominates the signal. The record now says the span is a lower bound and that a re-measurement is owed on a quiet machine, rather than carrying a scaled or invented number. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../pr-route-carries-no-stored-credential.md | 18 +++++++------ .../testing/mutation-claims-are-executed.md | 2 +- docs/guard-inventory.md | 4 +-- scripts/tests/mutation_manifest.py | 26 +++++++++++++++++++ .../test_workflow_persist_credentials.py | 7 ++++- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index 092c0bc5e..dfb87f93a 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -92,14 +92,16 @@ expressions a human can see are equivalent, leaves the job IN the population. Pa after repeated defects from that one mechanism; a pin can only ever be too demanding, which is the safe direction, and widening it is then a deliberate edit rather than an accident. -**One inventory row still understates this, tracked as #909.** `docs/guard-inventory.md` describes -`scripts/tests/test_workflow_persist_credentials.py` as the `actions/checkout` guard only, while it -now also holds this invariant with its own derived population and mutation proofs. Nothing goes red -on that: `test_guard_inventory.py` asserts set equality over FILES, and the file was already listed, -so a row whose PROSE decays is invisible to it. Do not read it as a complete description of what its -subject checks. #909's other half — `docs/remote-state-inventory.md`'s row for -`scripts/ci-toolchain-image-resolves.sh`, which listed "an unusable credential" among the shapes that -fail the job — is corrected in this change. +**The guard now holds TWO invariants, and only one grade can name a declared clause.** +`scripts/tests/test_workflow_persist_credentials.py` holds the `actions/checkout` invariant (#835) +and this one, on the same index-derived population. Both of its `docs/guard-inventory.md` rows say +so; nothing MAKES them say so, because `test_guard_inventory.py` asserts set equality over FILES and +the file was already listed, so a row whose PROSE decays stays green. The manifest keys at most one +`MUTATIONS` entry per guard FILE, so the declared clause there stands for the checkout half and the +route half is executed through a `CLAIMS` entry instead, bound to the inventory sentence that states +it (`testing.mutation-claims-are-executed`). That is the same obligation reached through the +population #881 widened, not a weaker one: the clause is declared by hand, applied to a sandbox copy +and its result read on every run, exactly as an inventory row's is. **The residual, stated so nobody cites this record as a boundary.** Head-supplied YAML can still name every secret in the store — the committed workflows' references were never the limit diff --git a/docs/decisions/records/testing/mutation-claims-are-executed.md b/docs/decisions/records/testing/mutation-claims-are-executed.md index b9964afff..4c724ae20 100644 --- a/docs/decisions/records/testing/mutation-claims-are-executed.md +++ b/docs/decisions/records/testing/mutation-claims-are-executed.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'A `MUTATION` row in `docs/guard-inventory.md` is not a statement that someone once witnessed a red. It carries a DECLARED clause mutation in `scripts/tests/mutation_manifest.py`, and `scripts/tests/test_mutation_harness.py` applies that mutation to an isolated copy of the repository on every run and requires the row''s OWN named test to go red. The manifest and the MUTATION rows are compared for SET EQUALITY in both directions, so a row cannot claim the grade without a mutation and a mutation cannot outlive the grade it justifies. EXIT STATUS IS NOT THE VERDICT: each entry also declares the DIAGNOSTIC its red must carry, matched against pytest''s exception output alone, because pytest reports a crashing test exactly as it reports a detecting one and a red for an unrelated reason is evidence about nothing. WHERE THE GUARD IS ITSELF A TEST, `target` may differ from `guard` and the exact-once check applies to the declared TARGET. Two shapes are admissible and the choice is not free. Where the guard''s assertion IS the check — a completeness comparison against a Markdown inventory — the mutation goes into the guarded ARTIFACT, per `testing.guard-ships-with-mutation-proof`''s checker-guard exception, because mutating such a checker''s own POPULATION demonstrates a false POSITIVE while proving nothing about the detection the row claims. Where the guard is a test module wrapping a separately mutable DETECTOR or helper, the clause may be in that detector, since disarming it is a real clause disarm and the module''s own assertion is what notices. THE MUTATION IS DECLARED, NEVER INFERRED: a harness that guessed which clause of a 90-line hook is the guard would manufacture the confident-but-empty coverage this exists to prevent, which is why `testing.guard-ships-with-mutation-proof` rejected a generic runner. Where a proof test already names its clause in source, the manifest reuses THAT string, so a retarget in either place is caught by the other. COARSENESS IS RECORDED, NOT HIDDEN: each entry is graded `CLAUSE` or `DETECTOR`, and a `DETECTOR` entry — one whose detector accumulates faults from independent arms, so disarming any single arm leaves its proof test green — must CARRY the finer mutation that survived, which is re-run every time and required to keep surviving. Guards that are not graded `MUTATION` each carry a STATED reason in that same manifest, keyed on the guard and compared for SET EQUALITY against the inventory''s `GUARD` rows in both directions — so a new guard cannot arrive without someone writing what a proof would need, and a reason cannot outlive the row it is about. Keying the reason on the row''s GRADE instead is tautological (a new guard inherits one and nobody looks at it) and a pinned COUNT moves only on net change; both were tried and are rejected. The sandbox is a real git repository built from `git ls-files` with working-tree content, never a filesystem walk. THE POPULATION IS EVERY DECLARED MUTATION-OUTCOME CLAIM, NOT ONLY THE INVENTORY''S ROWS (ersatztv#881): a sentence asserting that a specific mutation reddens, or does not redden, a named test is the same kind of claim wherever it is written — a code comment, a test docstring, a decision record — and is either a `CLAIMS` entry in `scripts/tests/mutation_manifest.py` that executes every run, or it is not written. A claim entry is keyed on the PROSE rather than on a guard: it carries the tracked `site` and the verbatim `quote`, both checked every run, so a reworded sentence reports as a RETARGET instead of drifting away from the entry that justifies it. THE QUOTE SPANS THE MUTATION AND THE OUTCOME IT ASSERTS: the outcome half is the checkable part, so a quote stopping at the mutation binds the uncontroversial words and leaves the assertion, and the test it names, outside the binding entirely. It declares `RED` or `GREEN`, and GREEN is the half no `MUTATION` row can express — a claim that a mutation is NOT noticed is read by THREE separate clauses, because each alone certifies a run that proves nothing: the run exited 0, something actually PASSED, and the named proof was shown to REACH the mutated clause. THE TWO DIRECTIONS ARE NOT SYMMETRIC, WHICH IS WHY THE THIRD CLAUSE EXISTS ON ONLY ONE OF THEM. A RED claim checks its own relevance for free — a proof that never executes the mutated file stays green and is refused in the same words as a clause that has stopped being load-bearing — while a GREEN claim gets precisely the green it asserts from a proof that reads a different file entirely, so exit status and a passing test TOGETHER still certify a run that never executed the clause. A GREEN entry therefore also declares a SECOND mutation of the SAME clause, required to REDDEN the same proof and carrying its own declared diagnostic, executed through the same red-reading path the inventory rows use; only once that passes is the green evidence about this clause at all. It is worth what the `DETECTOR` survivor was worth: re-run, and required to KEEP surviving. THE REFERENT IS DECLARED, NEVER INFERRED, exactly as the clause is: a checker hunting for sentences that merely SOUND like mutation claims would be a string predicate over prose, which this repo refuses to make load-bearing, so what is enforced is a claim that named its own referent, and the direction that cannot be mechanised — prose that declares nothing — is stated rather than papered over. TWO NEIGHBOURING PROPOSALS ARE SETTLED HERE. A QUOTATION of another file is a claim about that file: adopted, and the `quote` field IS the check, wherever the referent is declared; a general quotation-checker is rejected for the same reason inference is, since a quotation whose referent is unnamed has nothing to resolve. ANCHORING PROSE TO A STATE THE COMMIT ITSELF MOVES (''the last edit to that path'', ''the only X that''): rejected as a detector and kept as a phrasing rule. The rejection is measured, and the scan is PINNED IN THE BODY rather than summarised as a figure here, for the same reason the population scan is — a count quoted without the predicate that produced it is the defect this record is about. What it shows is that the shape is indistinguishable BY PATTERN from ordinary rationale (''X is the only thing that can Y''), which this rule carves out, so a detector would fire mostly on prose it must not flag, and an advisory check that does that stops being read. Anchor to a date or a sha under `docs.no-session-narrative`''s dated-measurement clause; inside this rule''s own population the anchor is the manifest entry, which is re-executed and therefore cannot go stale.' signals: 'mutation harness · declared clause not inferred · MUTATION row is executed · set equality manifest vs inventory · CLAUSE vs DETECTOR granularity · surviving finer mutation is re-run · pytest exit code 1 is the only red that counts · every other exit status is rejected · positive control before any mutation · sandbox from git ls-files · paths: `scripts/tests/test_mutation_harness.py`, `scripts/tests/mutation_manifest.py`, `scripts/tests/mutation_harness_lib.py`, `docs/guard-inventory.md` · prose claim bound to its declared site and verbatim quote · GREEN direction executes a claim that a mutation is NOT noticed · a GREEN entry declares a reach mutation proving its proof touches the clause · quotation of another file is a claim about that file · state anchors rejected as a detector, kept as a phrasing rule · issues: #790, #775, #774, #778, #806, #812, #881' -mechanics: 'The harness builds ONE sandbox per session — tracked files only, from `git ls-files -s`, in an environment with every `GIT_*` variable stripped so an exported `GIT_DIR`/`GIT_COMMON_DIR`/`GIT_CONFIG_*` cannot point its `init`/`add`/`commit` at the real repository — and resets it between mutations with `git reset --hard` TO A BASELINE COMMIT RECORDED OUTSIDE THE REPOSITORY, plus `git clean -qffdx`. Both halves matter: bare `git reset --hard` resets to whatever HEAD is, so a proof test that COMMITS inside the sandbox moves HEAD onto a commit carrying the mutant and every later reset restores it faithfully; and a baseline held as a REF inside the sandbox is one more thing a proof can move. `core.hooksPath`, `commit.gpgsign` and `core.worktree` are pinned on every git invocation for the same reason. The `-ff` removes a nested repository a proof may have left. It runs the full set of named proof tests UNMUTATED first, inside the fixture rather than as a separate test so ordering is a dependency and not a convention. Only pytest exit code 1 counts as red; every other status is rejected. That is what closes the two ways a proof ref goes stale, and they are measured rather than assumed: with an explicit `file.py::function` node id a missing file and a missing function both exit 4, while 5 needs a collection that succeeded and selected nothing. The sandbox skips the `ErsatzTV-macOS` gitlink and recreates the `.claude/skills/jellyfin` symlink as a symlink. `CLAIMS` reuses the same sandbox, the same reset and the same clause application (`_mutate_and_run`, split out so the two directions cannot come to disagree about what a red is worth), and its proof refs join the positive control for the reason the row proofs do, which holds in BOTH directions: over a proof that was ALREADY red, a RED claim is satisfied by redness its own mutation did not cause, and a GREEN claim is REFUSED for a reason that has nothing to do with its mutation — reported as a wrong sentence when what is wrong is the proof. What that control cannot do is see ONE ref that collects nothing — its assertions are over the aggregate of every ref, so the other refs supply the word `passed` — nor ONE ref that collects plenty and touches nothing the claim mutates, which is the larger of the two, since such a proof is green before and after and a GREEN claim would read as verified. Both discriminations are per-claim, in the GREEN direction''s own vacuity and relevance gates. THE RELEVANCE GATE RUNS LAST BECAUSE IT IS THE EXPENSIVE ONE: it is the only one of the three that costs a second full run of the proof, under the reach mutation with a sandbox reset between, so an entry the status or the vacuity gate has already refused never pays for it. What keeps the three gates worth three is that each carries its own disarm proof, rather than the position it sits in. The sandbox is reset between a claim''s two proof runs, for the reason it is reset between mutations. Each claim''s `site` is tested for membership of the git INDEX, not `is_file`, so a claim pointing at an untracked file fails here instead of passing on one machine. Cost, re-measured 2026-09-05 WITH the relevance gate, on an M-series Mac with `-p no:randomly`, whole file, three baseline/branch pairs run back to back: the `CLAIMS` half adds 31.7-43.6%, which is the span of the three pairs and not a rounding of it — 43.6% (46.8s -> 67.2s), 34.1% (50.1s -> 67.2s) and 31.7% (48.9s -> 64.4s), under varying concurrent load. The figure this replaces, 12.7-16.6% measured 2026-09-04, was taken before the GREEN direction had a relevance gate and is retired rather than adjusted: the gate runs a GREEN entry''s proof a SECOND time under the reach mutation, with a sandbox reset between, and its three disarm proofs each drive further inner pytest runs. A measurement is a property of the population that produced it, so growing the population invalidates it — re-measure, never scale. Quote the PERCENTAGE rather than the seconds - the absolute figures moved 30% with machine load in one session, and the earlier ~14s here covered fewer entries. The ~4min `script-tests` suite it was measured against is PRE-#870 and no longer holds: that change made `count_pr_mutations` read to its 20-page cap, and `test_pr_changed_files.py` went from 202s to 474s locally (286 -> 294 tests, same machine, `-p no:randomly`, 528383cf3 vs the #870 branch), so the job is roughly double. Re-measure on a runner before quoting a job figure.' +mechanics: 'The harness builds ONE sandbox per session — tracked files only, from `git ls-files -s`, in an environment with every `GIT_*` variable stripped so an exported `GIT_DIR`/`GIT_COMMON_DIR`/`GIT_CONFIG_*` cannot point its `init`/`add`/`commit` at the real repository — and resets it between mutations with `git reset --hard` TO A BASELINE COMMIT RECORDED OUTSIDE THE REPOSITORY, plus `git clean -qffdx`. Both halves matter: bare `git reset --hard` resets to whatever HEAD is, so a proof test that COMMITS inside the sandbox moves HEAD onto a commit carrying the mutant and every later reset restores it faithfully; and a baseline held as a REF inside the sandbox is one more thing a proof can move. `core.hooksPath`, `commit.gpgsign` and `core.worktree` are pinned on every git invocation for the same reason. The `-ff` removes a nested repository a proof may have left. It runs the full set of named proof tests UNMUTATED first, inside the fixture rather than as a separate test so ordering is a dependency and not a convention. Only pytest exit code 1 counts as red; every other status is rejected. That is what closes the two ways a proof ref goes stale, and they are measured rather than assumed: with an explicit `file.py::function` node id a missing file and a missing function both exit 4, while 5 needs a collection that succeeded and selected nothing. The sandbox skips the `ErsatzTV-macOS` gitlink and recreates the `.claude/skills/jellyfin` symlink as a symlink. `CLAIMS` reuses the same sandbox, the same reset and the same clause application (`_mutate_and_run`, split out so the two directions cannot come to disagree about what a red is worth), and its proof refs join the positive control for the reason the row proofs do, which holds in BOTH directions: over a proof that was ALREADY red, a RED claim is satisfied by redness its own mutation did not cause, and a GREEN claim is REFUSED for a reason that has nothing to do with its mutation — reported as a wrong sentence when what is wrong is the proof. What that control cannot do is see ONE ref that collects nothing — its assertions are over the aggregate of every ref, so the other refs supply the word `passed` — nor ONE ref that collects plenty and touches nothing the claim mutates, which is the larger of the two, since such a proof is green before and after and a GREEN claim would read as verified. Both discriminations are per-claim, in the GREEN direction''s own vacuity and relevance gates. THE RELEVANCE GATE RUNS LAST BECAUSE IT IS THE EXPENSIVE ONE: it is the only one of the three that costs a second full run of the proof, under the reach mutation with a sandbox reset between, so an entry the status or the vacuity gate has already refused never pays for it. What keeps the three gates worth three is that each carries its own disarm proof, rather than the position it sits in. The sandbox is reset between a claim''s two proof runs, for the reason it is reset between mutations. Each claim''s `site` is tested for membership of the git INDEX, not `is_file`, so a claim pointing at an untracked file fails here instead of passing on one machine. Cost, re-measured 2026-09-05 WITH the relevance gate, on an M-series Mac with `-p no:randomly`, whole file, three baseline/branch pairs run back to back: the `CLAIMS` half adds 31.7-43.6%, which is the span of the three pairs and not a rounding of it — 43.6% (46.8s -> 67.2s), 34.1% (50.1s -> 67.2s) and 31.7% (48.9s -> 64.4s), under varying concurrent load. The figure this replaces, 12.7-16.6% measured 2026-09-04, was taken before the GREEN direction had a relevance gate and is retired rather than adjusted: the gate runs a GREEN entry''s proof a SECOND time under the reach mutation, with a sandbox reset between, and its three disarm proofs each drive further inner pytest runs. A measurement is a property of the population that produced it, so growing the population invalidates it — re-measure, never scale. That span belongs to the THREE entries #881 shipped; #885 added a fourth (the PR-route stored-secret invariant, whose guard file already holds the one `MUTATIONS` entry its key allows), so the span is a LOWER BOUND until it is re-taken. Re-taking it was attempted 2026-09-05 on this machine and REFUSED rather than skipped: three A/B pairs — whole file, `-p no:randomly`, arm A deselecting the per-claim execution and arm B the shipped file — came out 54.3s/149.5s, 81.6s/78.7s and 114.3s/84.2s while other builds shared the host, and TWO of the three are inverted, which is a load reading and not a cost reading. Take it on a quiet machine; a number off a contended one would be worse than the dated lower bound it replaced. Quote the PERCENTAGE rather than the seconds - the absolute figures moved 30% with machine load in one session, and the earlier ~14s here covered fewer entries. The ~4min `script-tests` suite it was measured against is PRE-#870 and no longer holds: that change made `count_pr_mutations` read to its 20-page cap, and `test_pr_changed_files.py` went from 202s to 474s locally (286 -> 294 tests, same machine, `-p no:randomly`, 528383cf3 vs the #870 branch), so the job is roughly double. Re-measure on a runner before quoting a job figure.' --- **What #775 left open, in its own words: "nothing checks that a row claiming `MUTATION` is telling diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index 4e1818700..e622de0b9 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -172,7 +172,7 @@ The audit #806 asked for, recorded whichever way it came out, because "we looked | `test_ci_image_pin_population.py` | `.gitea/workflows/*.yml` + `*.yaml` | **yes** — "docker-build is the ONLY workflow pinning the toolchain image" | converted, and `*.yaml` added: Gitea accepts both spellings, so a `.yaml` workflow was structurally invisible while the test read as covering all of them | | `test_workflow_job_guards.py` | `.gitea/workflows/*.y*ml` | **yes** — every tracked workflow's jobs must each declare a `CI_JOB_ROLE`, and the guard-declaring ones must match `## Workflow-job guards` both ways | index-derived from the start (ersatztv#786); registered in `test_guard_populations_derive_from_git.py` so the shared proof covers it | | `test_pr_changed_files.py` | `.gitea/workflows/*.y*ml` | **yes** — "no OTHER workflow writes the review-verdict status", and since ersatztv#748 "every tracked workflow declares a `permissions:` block", with NO exemption list (the one `ci-image.yml` briefly needed was deleted when #744 landed, per #835) | converted. Not on #806's list: an untracked `.yaml` dropped in `.gitea/workflows/` reddened two guards while absent from the index — the issue's list of files to assess was a starting point, not the population | -| `test_workflow_persist_credentials.py` | `.gitea/workflows/*.yml` + `*.yaml` | **yes** — every `actions/checkout` in every workflow must drop the persisted credential | derived from the index from the start (#835). Shipped with NO exemption list, which is why it waited for #744: the one non-compliant checkout would otherwise have bought a permanent entry that keeps passing after its reason expires | +| `test_workflow_persist_credentials.py` | `.gitea/workflows/*.yml` + `*.yaml` | **yes, over TWO invariants on one population** — every `actions/checkout` in every workflow must drop the persisted credential (#835), and since #885 no job the `pull_request` trigger reaches, and no workflow scope outside `jobs:`, may name a stored secret (`ci.pr-route-carries-no-stored-credential`) | derived from the index from the start (#835). Shipped with NO exemption list, which is why it waited for #744: the one non-compliant checkout would otherwise have bought a permanent entry that keeps passing after its reason expires. The second invariant reuses that population and adds two derivations of its own — which jobs the trigger reaches (an `if:` PIN, not a parser) and where a reference to the secret store can be written — and it carries the same no-exemption rule, `secrets.GITEA_TOKEN` excepted as INJECTED rather than stored. THE GRADE BELOW COVERS ONE HALF: the manifest keys at most one declared clause mutation per GUARD file, so that entry stands for the checkout invariant. The route invariant is bound instead by a `CLAIMS` entry on this sentence — deleting `build`'s `if: github.event_name != 'pull_request'` from the shipped `docker-build.yml` reddens `test_no_PULL_REQUEST_route_job_names_a_STORED_secret` — which the harness executes every run for the same reason a `MUTATION` row is executed. A row that understates its guard is invisible to `test_guard_inventory.py`, which compares FILE sets, so this prose is the only thing that carries the second invariant | | `test_image_build_delegates_the_spa_suite.py` | DERIVED: tracked Dockerfiles and workflows from the git index; the stages within them that carry the SPA source. PINNED (hand-written, and the distinction is the whole design): the commands each such stage runs, and the gating `Test SPA` step's `run:` body and `if:` | **yes on the derived populations, by pin rather than by predicate** — every SPA-carrying stage must have a pin and match it; every image-publishing job whose Dockerfile has such a stage must transitively `needs:` the gating job | ersatztv#887. **THE PARSER WAS WITHDRAWN, and that is the finding worth carrying.** Three versions of this guard asked "does this command RUN the suite, and can it FAIL?" of arbitrary shell text, and that predicate was wrong NINE times across three cold-review rounds, always the same mechanism: heredoc bodies skipped as data when BuildKit EXECUTES `RUN < `run: >` folds two commands into one; (3) Gitea's treatment of a SKIPPED `needs:` job is not modelled — the guard forbids the job-level `if:` that would create one rather than reasoning about it; (4) the pins are hand-written and a legitimate edit reddens this guard, which is the intended review trigger and the stated cost | | `web/src/api/pageSizeCallSites.guard.test.ts` | `import.meta.glob` over `web/src/**/*.{ts,tsx,mts,cts}`, INTERSECTED with the git index | **yes** — an unregistered discovered site fails | converted under #819. The population is the glob INTERSECTED with the git index, and the walk/index divergence is asserted in BOTH directions WITHIN a shared scope: a key the index does not carry is dropped, and a tracked, on-disk, in-scope path that never reached the scanner FAILS rather than shrinking the population silently (that direction catches a dotfile, which the glob cannot match, and a disk/index spelling divergence, which `core.ignorecase` and NFD/NFC normalisation make permanent). The SCOPE itself cancels out of that equality — both sides call one `isInScopeSourcePath` — so narrowing it moves both sets together and no assertion comparing the two sides can see it. That is closed separately, by re-deriving the predicate from its two components over the whole tracked index (`the shared scope predicate is EXACTLY its two documented components`). Two mechanisms carry that. FIRST, the scope predicate is proved by a CLOSED-FORM restatement — it may share no helper, at any depth, with the predicate it checks, since anything shared sits on both sides of the comparison and cancels. FOUR earlier attempts were each measured going green while removing real files: a table of example paths (four of `src/`'s eight directories, missed a 23-file narrowing), a decomposition delegating its filename half to `isScannableSourceFileName` (11 files), one still sharing a `basename` helper (15 files, and blind to a planted call site), and one still sharing the tracked-file array every comparison was derived from (8 files, also blind). SECOND — because that last one showed a restatement cannot police the population it reads — the plugin runs a separate `git ls-files --others` query and the guard requires every in-scope walked path to appear in `tracked ∪ others`; narrowing `tracked` cannot suppress that, since it adds nothing to `others`. SIX residuals, NOT a claim of closure, each with its direction MEASURED by planting a real `pageSize` call site rather than reasoned about. FAIL-NOISY (they redden a checkout; they cannot hide a call site): (2) a scope term matching no tracked path today survives until the day it first matches one; (4) the WIRING in `listSourceFiles`, indistinguishable on a clean checkout where the walk set and the index set agree on every in-scope key (they are not literally equal — the index also carries `.css`, which the glob never yields), so no assertion COMPARING THE TWO POPULATIONS can tell which is passed; (5) `ABSENT_FROM_DISK` emptied. BLIND — each hid a planted `pageSize: 100` with the whole suite green: (1) a COORDINATED edit of the scope predicate and BOTH closed-form restatements, which is three sites and not two, since a second restatement guards `expectedTrackedSources` (measured: the two-site edit reddens, the three-site edit does not) — tolerated because a three-site policy edit is review-visible in a way a one-line slip is not; and (3) a misdescription by the plugin's THREE outputs — not only a mispartition of `tracked`/`others`, which preserves the union any consumer compares, but a FATTENED `absentFromDisk`, which subtracts real files from the hole-detection direction. The fattening is now caught wherever it overlaps a file the walk found (a walked path is on disk by construction, so it can never be legitimately absent); restricted to paths the walk cannot see anyway — a dotfile, a case-divergent name — it stays invisible from the consuming side and is answerable only by testing the derivation directly, and the real-git tests that do so catch an UNCONDITIONAL misdescription, not one keyed on a path pattern. Neither blind residual is tolerated for being harmless. A sixth is specific to WATCH mode and is stale in BOTH directions, so it belongs to neither group: the virtual module has no backing file, so the index is read once per dev-server lifetime while the glob refreshes. A file CREATED mid-session reddens the cross-check misleadingly (noisy); a file already UNTRACKED when the watcher started keeps that classification when staged mid-session, so its call sites are never scanned — MEASURED green across both phases while `npx vitest run` on the same tree reports `UNREGISTERED`, i.e. blind, and a window that did not exist before #819 because the population was then the walk. A watch-mode green is therefore not authoritative for this guard; restarting the watcher clears the noisy case and opens the blind one, so the remedy is `npm test -- --run`. Invalidating the module from `configureServer` was implemented, measured and REJECTED: it fixes the created-mid-session red and additionally blinds the create-then-stage sequence, while the already-untracked-then-staged sequence is blind either way, since `git add` fires no watcher event in either design. The index is read by `web/vite-plugins/trackedSourceFiles.ts` in Vite's own Node context and handed to the app project as the virtual module `virtual:etv-tracked-source-files`. That is what reaches the index without admitting `@types/node` to `tsconfig.app.json` — the obstacle that deferred this, since wiring those types in was tried and reverted (it leaked Node's `setTimeout` into the app project and broke three unrelated tests). The plugin throws rather than falling back to the unfiltered walk when git fails, reports zero files, or cannot learn the Vite root. Residual, stated: a file tracked but DELETED in the working tree is subtracted deliberately, since an unstaged deletion is a normal developer state and a guard red on one gets ignored (#806); it is distinguished from the hole cases above by an on-disk existence check, not conflated with them | | `web/src/api/completeAnnotations.guard.test.ts` | TWO derived populations: the `Complete<…>` annotations across `web/src/**/*.{ts,tsx,mts,cts}` INTERSECTED with the git index, and the droppable SCHEMAS parsed out of the generated `src/api/generated/v1.d.ts` | **partial, and the split is stated** — set equality holds for the SCHEMA population (a new optional member fails until dispositioned) and every tracked in-scope path must be supplied by the glob; there is NO closed-form restatement of the scope predicate, so a coordinated edit to it is caught by five named path pins AND a 0.95 population-ratio floor, not by a completeness proof | ersatztv#820. Turns `test_optional_request_members.py`'s `COVERED` disposition — worded "the builder is annotated `Complete`", a claim about ANOTHER LANGUAGE'S source that nothing checked — into a check, and makes `docs/spa-conventions.md` §4b's prohibitions executable as asserted ABSENCES. It found one live defect: `playouts.ts` declared two request types as hand-written mirrors SHADOWING generated schemas of the same name, so their `Complete<>` was checking a local copy rather than the contract. **NARROWED after four BLOCKED review rounds, and the narrowing is the point.** It originally also derived the write WRAPPERS and required the annotation on the wrapper parameter. Every blocker across those rounds came out of that one mechanism — the obligation attached to the wrong population; reachability mistaken for protection (`Complete` is shallow, so a wrapper annotation never reached a nested schema); body discovery keyed on a parameter NAME, then on parameter-versus-local; and finally an `export function` -> `export const` refactor that removed real protection while both the AST scan and its supposedly independent regex cross-check stayed blind TOGETHER, because both keyed on the same token. Five defects from one mechanism is the `process.enumerate-workaround-behaviors-before-deleting` signal to remove it rather than patch a sixth time, and ~250 lines went with it. WHAT IS NOT COVERED, listed because the earlier version of this row stated residuals in the direction that was actually covered: (1) the obligation is per-SCHEMA, not per-SITE and not per-WRAPPER — deleting the `Complete<>` from an API wrapper stays green as long as some production file still names that schema, so the wrapper half of §4b rests on review; (2) it is a TOKEN-PRESENCE check, so a dead `export type X = Complete` that nothing uses discharges the obligation as well as a live builder does — it catches deletion, which is the failure actually observed in #807, not substitution; (3) the PHANTOM direction needs a fresh literal in a contextually typed position and is not checked at all (sites-in-code, #777); (4) the test-file exclusion covers `*.test.*`, `*.spec.*` and the ONE setup file `vite.config.ts` names, whose path is pinned so a rename reddens — a SECOND `setupFiles` entry is loaded by vitest every run and WOULD discharge the obligation; that needs a three-site coordinated edit and is tolerated on the terms the sibling guard states for its own; (5) a MUST-NOT-ANNOTATE violation inside a `*.guard.test.ts` is not seen, since that file class is excluded to keep `completeRequest.guard.test.ts`'s synthetic `Complete<{…}>` fixtures out of the resolver. The disposition VALUES are cross-checked against the authoritative Python table by `scripts/tests/test_complete_annotation_dispositions.py` — without it, flipping one row from `ANNOTATED` to `CREATE` silently retired the requirement, which cold review demonstrated. Nine mutations were witnessed by hand across development, NOT re-executed per suite, so this guard claims no standing `MUTATION` grade — the same footing as `pageSizeCallSites.guard.test.ts`. (Its Python cross-check `test_complete_annotation_dispositions.py` DOES carry a declared harness-executed mutation and is graded accordingly.) A SIXTH residual, shared with its sibling: every population here derives from `trackedSources.tracked`, so a narrowing inside `web/vite-plugins/trackedSourceFiles.ts` cancels out of every comparison — this guard never reads the plugin's separate `others` query, and relies on `pageSizeCallSites.guard.test.ts` and `trackedSourceFiles.test.ts` policing the plugin. A coordinated scope edit is caught by five named path pins AND a 0.95 population-ratio floor whose denominator is computed by a different expression, not by a completeness proof | @@ -270,7 +270,7 @@ recorded as unexamined rather than as cleared. | `scripts/tests/test_prove_fix.py` | the `script-tests` job | PROOF | NONE | — | | `scripts/tests/test_review_verdict_vocabulary.py` | the `script-tests` job, when the H10 verdict vocabulary stops being shared — a word added to the one declaration that fails to reach the write side or the read side | GUARD | MUTATION | `test_review_verdict_vocabulary.py::test_a_word_added_to_the_shared_source_reaches_BOTH_sides` | | `scripts/tests/test_remote_state_inventory.py` | the `script-tests` job, on an executable that talks to a remote service with no row in `docs/remote-state-inventory.md` | GUARD | MUTATION | `test_remote_state_inventory.py::test_every_in_scope_file_has_a_row_and_every_row_names_a_real_file` | -| `scripts/tests/test_workflow_persist_credentials.py` | the `script-tests` job, when an `actions/checkout` step no longer sets `persist-credentials: false` | GUARD | MUTATION | `test_workflow_persist_credentials.py::test_every_actions_checkout_DROPS_the_persisted_credential` | +| `scripts/tests/test_workflow_persist_credentials.py` | the `script-tests` job, when an `actions/checkout` step no longer sets `persist-credentials: false`, or when a job on the `pull_request` route — or the workflow scope outside `jobs:` — names a stored secret. The declared clause mutation opposite covers the checkout half; the route half is executed every run through the `CLAIMS` entry named in the population table above | GUARD | MUTATION | `test_workflow_persist_credentials.py::test_every_actions_checkout_DROPS_the_persisted_credential` | | `scripts/tests/test_workflow_job_guards.py` | the `script-tests` job, on a workflow JOB that is a guard with no row in the workflow-job table below, or a job with no `CI_JOB_ROLE` at all | GUARD | MUTATION | `test_workflow_job_guards.py::test_the_inventory_covers_exactly_the_guard_JOBS_that_exist` | | `scripts/tests/test_worktree_ownership_guard.py` | the `script-tests` job | PROOF | NONE | — | diff --git a/scripts/tests/mutation_manifest.py b/scripts/tests/mutation_manifest.py index c6e31fa79..cc5478603 100644 --- a/scripts/tests/mutation_manifest.py +++ b/scripts/tests/mutation_manifest.py @@ -679,4 +679,30 @@ CLAIMS: tuple[Claim, ...] = ( "The mutation drops `record_wing_faults()` from the call and keeps `yaml_faults`, which is " "the deletion the sentence describes rather than a caricature of it.", ), + Claim( + site="docs/guard-inventory.md", + quote=( + "deleting `build`'s `if: github.event_name != 'pull_request'` from the shipped " + "`docker-build.yml` reddens `test_no_PULL_REQUEST_route_job_names_a_STORED_secret`" + ), + target=".gitea/workflows/docker-build.yml", + clause=" if: github.event_name != 'pull_request'", + replacement=" # if: clause removed by the mutation harness", + proof="test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret", + outcome=RED, + expect="job `build` names stored secret(s) on the pull_request route", + why="`MUTATIONS` is keyed one entry per GUARD FILE, and " + "`scripts/tests/test_workflow_persist_credentials.py` holds TWO invariants since #885 — the " + "`actions/checkout` one its declared entry stands for, and the PR-route stored-secret one. " + "The second would otherwise have no executed mutation at all, so the inventory row states it " + "in prose and the prose is bound here, which is the population #881 widened this file to " + "cover. The clause is the ONE thing keeping `build` — the last job naming `REGISTRY_PASSWORD` " + "— off the head-authored route, so the mutation is the defect the invariant exists for and " + "not a caricature: with it deleted the collector must name that job and that secret. The " + "mutation is driven into the SHIPPED workflow rather than a fixture for the same reason the " + "module's own two mutation tests are: a synthetic document would only prove the collector " + "works on YAML this repo does not run. The declared diagnostic is the fault's own wording " + "rather than the assertion's, because a red anywhere else in that test — a parse error, an " + "empty population — prints a different message and is evidence about nothing.", + ), ) diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 2ff80f83b..53302351c 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -1,4 +1,9 @@ -"""Every `actions/checkout` drops the persisted credential (ersatztv#835). +"""TWO credential invariants over one derived workflow population. + +Every `actions/checkout` drops the persisted credential (ersatztv#835) — below — and no job +the `pull_request` trigger reaches, nor the workflow scope outside `jobs:`, names a stored +secret (ersatztv#885). The second is documented at its own banner further down, where the +population it adds to this one is derived; what follows here is the first. WHAT THIS IS PROTECTING. `actions/checkout` writes an `Authorization` header into `.git/config` unless `persist-credentials: false` is set, and this instance's Actions default token permission is -- 2.47.3 From fe05526ef4ffc342dab80bb6dc36837ab813b827 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 11:00:42 +0200 Subject: [PATCH 23/31] fix(885): the tag rule this branch added is dated where it is asserted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release.main-direct-push-disabled` was edited on this branch to note the new `v*` tag protection, and picked up two of the defects the round was hunting elsewhere. Its `rule:` said the `renovate` bot "can no longer push a tag that publishes `:prod`" as settled fact, while `release.tag-protection-v-star` records that exact claim as NOT VERIFIED and `docs/ci-cd.md` was already corrected to EXPECTED, UNVERIFIED. Only the `timothy` credential exists in a working session, so neither a real release cut nor a refused bot push has been exercised; all three now agree on confidence. Its `mechanics:` still read "`GET .../tag_protections` returns `[]`" in the present tense — the one fact this branch changed, and the one site an otherwise complete sweep left behind. Read back live today the endpoint returns one rule, `v*` whitelisted to `timothy`. The clause is now past tense and bound to its probe date, with the current state named. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/decisions/README.md | 2 +- docs/decisions/records/release/main-direct-push-disabled.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0fe112982..7f7fcfcb9 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -144,7 +144,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](records/release/done-when-merge-consent.md) | | `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11). | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) | | `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](records/release/live-e2e-required.md) | -| `release.main-direct-push-disabled` | Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), so the release cut still works for the operator while the `renovate` bot can no longer push a tag that publishes `:prod`. | 2026-08-05 | [link](records/release/main-direct-push-disabled.md) | +| `release.main-direct-push-disabled` | Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave the release cut working for the operator while refusing a `v*` push by the `renovate` bot that would publish a `:prod` image. Both halves are EXPECTED, UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot push has been exercised, and that record carries the same caveat. | 2026-08-05 | [link](records/release/main-direct-push-disabled.md) | | `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](records/release/merge-consent-autogrant.md) | | `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](records/release/migration-rehearsal-prodcopy.md) | | `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) | diff --git a/docs/decisions/records/release/main-direct-push-disabled.md b/docs/decisions/records/release/main-direct-push-disabled.md index 0764fb4cb..0e73da2c6 100644 --- a/docs/decisions/records/release/main-direct-push-disabled.md +++ b/docs/decisions/records/release/main-direct-push-disabled.md @@ -5,9 +5,9 @@ status: active since: '2026-08-05' supersedes: none superseded-by: none -rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), so the release cut still works for the operator while the `renovate` bot can no longer push a tag that publishes `:prod`.' +rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave the release cut working for the operator while refusing a `v*` push by the `renovate` bot that would publish a `:prod` image. Both halves are EXPECTED, UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot push has been exercised, and that record carries the same caveat.' signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714' -mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' +mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returned `[]` at that probe; since #885 it carries one `v*` rule whitelisted to `timothy` (`release.tag-protection-v-star`). Repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.' --- **Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct -- 2.47.3 From c325cd6ee4923310073c126d8d1cf7dc711f61ea Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 12:20:40 +0200 Subject: [PATCH 24/31] fix(885): a handover written in the YAML grammar names no secret, so the guard reads the KEY too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector was exhaustive over the `${{ }}` expression grammar and blind to `jobs..secrets: inherit` on a `uses:` job, which passes the caller's whole store to the called workflow while naming nothing. `secret_refs` reads only inside expression spans — correctly, since outside one `secrets.` is a full stop — and `inherit` is a plain scalar, so such a job was put in the derived population by `pull_request_jobs`, walked, and reported CLEAN. Measured against the predecessor: stored_secret_faults('synthetic.yml', {True: {'pull_request': None}, 'jobs': {'reused': {'uses': './.gitea/workflows/reusable.yml', 'secrets': 'inherit'}}}) -> [] ... the same job with secrets: {TOK: '${{ secrets.RENOVATE_TOKEN }}'} -> 1 fault so the miss was specific to the VALUE SHAPE, not the key. That is the failure the done-condition names — a new job joining the population unprotected without reddening anything — in a guard whose stated selling point is exhaustiveness over the grammar and no exemption list. `opaque_secret_handovers` now faults a `secrets:` key whose value is not a mapping of names, under the existing `secrets.*` whole-context sentinel, and both fault sites read through one `held_secret_names` so the workflow scope and the job subtree cannot drift on which references are forgiven. The test is on the value shape and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any non-mapping value hands over a set the guard cannot enumerate, a spelling act_runner grows later included. Both halves of the predecessor measurement are re-derived every run rather than left as prose: the new test asserts `secret_names(job) - INJECTED_SECRETS` — the collector verbatim as it read before this clause — empty on the same fixtures it asserts the fault on, and asserts the job is in the population. Reverting `held_secret_names` to that expression reddens that test and only that test (measured: 1 failed, 18 passed). The clause reads the DOCUMENT only and the text-versus-walk cross-check cannot cover it — there is no expression for its half to match, which is a stronger reason than the shared-blind-spot one the cross-check already discloses. Said at the definition, in the cross-check's "STRUCTURALLY CANNOT REPORT" paragraph, and in the record, rather than left to be discovered; it does not redden the cross-check either, since the clause feeds the fault collector and not `secret_name_counts`. Whether Gitea 1.27.1 / act_runner resolves `workflow_call` + `secrets: inherit` on this instance was NOT probed — that affects reachability today, not the guard's silence, and the direction is the one the spelling rows already take. No tracked workflow uses a job-level `uses:`, so nothing reddens. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/decisions/README.md | 2 +- .../pr-route-carries-no-stored-credential.md | 18 +- .../test_workflow_persist_credentials.py | 155 +++++++++++++++++- 3 files changed, 170 insertions(+), 5 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 7f7fcfcb9..c10629775 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -61,7 +61,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | | `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) | -| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow's own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo's visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | +| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow's own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo's visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | | `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) | | `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark ` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always --gated `, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) | | `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) | diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index dfb87f93a..1c964cbce 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -5,8 +5,8 @@ status: active since: '2026-09-04' supersedes: none superseded-by: none -rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow''s own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' -signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, indexed secrets reference, whole-context secrets reference, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' +rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow''s own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' +signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, indexed secrets reference, whole-context secrets reference, secrets inherit on a reusable-workflow job, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. TWO THINGS THAT RUN DID NOT EXERCISE, because those layers were already on that host: an anonymous LAYER download, and act_runner''s own pull call path rather than the CLI''s. Both are first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' --- @@ -85,6 +85,20 @@ English, and `secrets.` is a full stop: applied to the whole string the pattern `# We pass no secrets. Then the pull is anonymous.` as a reference to a secret named `Then`, which faulted a PR-route job for its own prose, on this branch's own subject. +**Exhaustive over the EXPRESSION grammar is not exhaustive over the document.** Scoping every +pattern to `${{ }}` spans is right — outside one the word is English — and it leaves one handover +the collector cannot see by construction: `jobs..secrets: inherit` on a `uses:` job, which passes +the caller's whole store to the called workflow and contains no expression to read. It is +`toJSON(secrets)` one level up, written in the YAML grammar, and it is the shape that most nearly +defeats this guard's premise — a job written that way is *in* the derived population, is walked, and +is reported clean. Measured 2026-09-05 against the predecessor of that clause, exactly that: `[]` +from the fault collector and `[]` from the cross-check, while the mapping spelling of the same key +faulted by name. So a `secrets:` key whose value is not a mapping now faults under the `secrets.*` +sentinel, judged on the VALUE SHAPE rather than on the word `inherit`, for the reason the residue +counter does not match `toJSON` by name. Unlike the spelling gaps this one is invisible to the text +cross-check for a reason no widening of `secret_refs` fixes — there is no text for its half to match +— so the clause reads the document only, and says so where it is defined. + **The `if:` classifier is a PIN, not a parser.** Exactly one string — `github.event_name != 'pull_request'` — takes a job off the route. Anything else, including expressions a human can see are equivalent, leaves the job IN the population. Parsing diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 53302351c..55b6b84c8 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -505,6 +505,75 @@ def secret_names(node: object) -> set[str]: return set(secret_name_counts(node)) +# The second way a document hands stored secrets to a run: a `secrets:` KEY, which names them in the +# YAML grammar rather than inside an expression. `jobs..secrets:` on a `uses:` (reusable-workflow) +# job passes the caller's store to the called workflow, and its value takes one of two shapes — a +# MAPPING of name -> value, whose values are `${{ }}` expressions the collector above already reads, +# or the bare scalar `inherit`, which hands over the WHOLE store while naming nothing at all. +# +# `inherit` is the `toJSON(secrets)` shape one level up, and it defeats the collector for the same +# reason the span scoping is correct: `secret_refs` reads only inside `${{ }}`, and `inherit` is not +# an expression. Measured 2026-09-05 against the predecessor of this commit, a `pull_request` job of +# `{'uses': './.gitea/workflows/reusable.yml', 'secrets': 'inherit'}` was put in the population by +# `pull_request_jobs`, walked, and reported CLEAN (`stored_secret_faults(...) == []`), while the +# mapping spelling produced one correctly-named fault — so the miss was in the VALUE SHAPE, not the +# key. That is precisely a new job silently joining the population unprotected. Both halves of that +# measurement are RE-DERIVED every run rather than left as prose — `secret_names(job) - +# INJECTED_SECRETS` is the predecessor collector verbatim, and +# `test_a_SECRETS_HANDOVER_naming_nothing_is_reported_under_the_WHOLE_CONTEXT_sentinel` asserts it +# empty on the same fixture it asserts the fault on. +# +# The test is on the value shape and not on the word `inherit`, for the reason the residue counter is +# not a match on `toJSON`: any value that is not a mapping of names hands over something this guard +# cannot enumerate, including a spelling act_runner grows later. Whether this instance's runner +# resolves `workflow_call` + `secrets: inherit` at all was NOT probed from here; the direction makes +# that acceptable, as it does for the expression spellings — an unsupported shape costs a spurious +# demand on a job nobody has written, and the omission cost the whole store. +# +# This clause is DOCUMENT-ONLY, stated here rather than left to be discovered: `secrets:` is a plain +# YAML key, so the text-versus-walk cross-check has nothing to match on and does not cover it. It +# also does not REDDEN that cross-check, because the clause feeds the fault collector and not +# `secret_name_counts`, which both halves read through. +# +# The key is matched case-INSENSITIVELY, the same direction `_SECRET_REF` takes: the grammar spells it +# lowercase, and reading `Secrets:` as one too can only ever be too demanding. +SECRETS_KEY = "secrets" + + +def opaque_secret_handovers(node: object) -> int: + """`secrets:` keys in a subtree whose value is not a mapping of names — occurrences, not sites. + + Structure-blind for the reason `secret_name_counts` is: walking for the key anywhere rather than + at `jobs..secrets` avoids pinning the one location today's grammar documents + (`testing.guard-derives-population-from-source`). + """ + found = 0 + stack: list[object] = [node] + while stack: + item = stack.pop() + if isinstance(item, dict): + for key, value in item.items(): + if isinstance(key, str) and key.lower() == SECRETS_KEY and not isinstance(value, dict): + found += 1 + stack.append(value) + elif isinstance(item, list): + stack.extend(item) + return found + + +def held_secret_names(node: object) -> set[str]: + """Every STORED secret a subtree hands to the run: the names it references, plus the whole-context + sentinel when it hands over a set this guard cannot enumerate. + + The one place `INJECTED_SECRETS` is subtracted, so the two fault sites below cannot drift apart on + which references are forgiven. + """ + held = secret_names(node) - INJECTED_SECRETS + if opaque_secret_handovers(node): + held.add(WHOLE_SECRETS_CONTEXT) + return held + + def outside_jobs(doc: object) -> dict: """Everything in a workflow document EXCEPT `jobs:` — the workflow scope. @@ -525,7 +594,7 @@ def stored_secret_faults(rel: str, doc: object) -> list[str]: faults: list[str] = [] if not runs_on_pull_request(doc): return faults - workflow_scope = sorted(secret_names(outside_jobs(doc)) - INJECTED_SECRETS) + workflow_scope = sorted(held_secret_names(outside_jobs(doc))) if workflow_scope: faults.append( f"{rel}: the WORKFLOW SCOPE (outside `jobs:`) names stored secret(s) on the " @@ -538,7 +607,7 @@ def stored_secret_faults(rel: str, doc: object) -> list[str]: f"list: `ci.pr-route-carries-no-stored-credential`." ) for job_id, job in pull_request_jobs(doc): - named = sorted(secret_names(job) - INJECTED_SECRETS) + named = sorted(held_secret_names(job)) if named: faults.append( f"{rel}: job `{job_id}` names stored secret(s) on the pull_request route: " @@ -621,6 +690,12 @@ def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: but as `secrets.*` — the whole-context sentinel — because the index contains no literal, which is the fail-closed direction and is asserted in `test_the_collector_sees_every_SPELLING_of_a_secret_reference`. + + It equally cannot report a handover written in the YAML grammar rather than the expression + grammar — `jobs..secrets: inherit` on a `uses:` job — and for a stronger reason than a shared + blind spot: there is no expression for the text half to match at all. That shape is judged by + `opaque_secret_handovers`, which reads the DOCUMENT only, and is asserted in + `test_a_SECRETS_HANDOVER_naming_nothing_is_reported_under_the_WHOLE_CONTEXT_sentinel`. """ disagreements: list[str] = [] total = Counter() @@ -831,6 +906,82 @@ def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None: assert walk_versus_text_faults("synthetic.yml", as_text) == [], sentence +def test_a_SECRETS_HANDOVER_naming_nothing_is_reported_under_the_WHOLE_CONTEXT_sentinel() -> None: + """`secrets: inherit` hands the WHOLE store to a `uses:` job while naming no secret at all. + + The `toJSON(secrets)` shape one level up — in the YAML grammar rather than the expression + grammar — and so the one shape the span scoping above cannot see, since `inherit` is a plain + scalar and not an expression. Driven against the REAL predecessor rather than a hand-written + mutant: `secret_names(job) - INJECTED_SECRETS` is the collector exactly as it read before this + clause, and it is asserted empty on every row here while the job IS in the population — walked, + and reported clean. That is a job joining the population unprotected without reddening anything. + + Whether act_runner on this instance resolves `workflow_call` + `secrets: inherit` was NOT probed + from here; it affects reachability today, not the guard's silence, and the direction is the same + one the spelling rows take — an unsupported shape costs a spurious demand on a job nobody has + written. + """ + for value in ("inherit", "INHERIT", None, ["REGISTRY_PASSWORD"]): + job = {"uses": "./.gitea/workflows/reusable.yml", "secrets": value} + doc = {True: {"pull_request": None}, "jobs": {"reused": job}} + + assert [job_id for job_id, _ in pull_request_jobs(doc)] == ["reused"], value + assert secret_names(job) - INJECTED_SECRETS == set(), ( + f"a `secrets:` value of {value!r} is supposed to name nothing the reference collector " + "can see — if it does, this row proves nothing about the handover clause." + ) + + faults = stored_secret_faults("synthetic.yml", doc) + assert len(faults) == 1, (value, faults) + assert WHOLE_SECRETS_CONTEXT in faults[0], (value, faults) + + # The KEY is read case-insensitively, the direction `_SECRET_REF` takes — asserted rather than + # only claimed in the comment beside it. + cased = {True: {"pull_request": None}, "jobs": {"reused": {"uses": "./x.yml", "Secrets": "inherit"}}} + assert len(stored_secret_faults("synthetic.yml", cased)) == 1 + + # The MAPPING spelling is not what this clause reports: every value in it is an expression the + # reference collector already reads, so it faults by NAME and not under the sentinel. + named = { + True: {"pull_request": None}, + "jobs": {"reused": {"uses": "./x.yml", "secrets": {"TOK": "${{ secrets.RENOVATE_TOKEN }}"}}}, + } + faults = stored_secret_faults("synthetic.yml", named) + assert len(faults) == 1, faults + assert "RENOVATE_TOKEN" in faults[0] and WHOLE_SECRETS_CONTEXT not in faults[0], faults + + # A `workflow_call` DECLARATION is a mapping of names, not a handover — the negative control that + # keeps the clause from faulting every reusable workflow that declares its own inputs. + declaring = { + True: {"pull_request": None, "workflow_call": {"secrets": {"TOK": {"required": True}}}}, + "jobs": {"j": {"steps": [{"run": "true"}]}}, + } + assert stored_secret_faults("synthetic.yml", declaring) == [] + + # The workflow scope is judged through the same helper, so the clause widens with it. + scoped = {True: {"pull_request": None}, "secrets": "inherit", "jobs": {}} + scope_faults = stored_secret_faults("synthetic.yml", scoped) + assert len(scope_faults) == 1 and "WORKFLOW SCOPE" in scope_faults[0], scope_faults + + # And it is judged on the route only, and only for a job the trigger reaches. + off_route = {True: {"push": None}, "jobs": {"reused": {"uses": "./x.yml", "secrets": "inherit"}}} + assert stored_secret_faults("synthetic.yml", off_route) == [] + gated = { + True: {"pull_request": None}, + "jobs": { + "reused": {"if": "github.event_name != 'pull_request'", "uses": "./x.yml", "secrets": "inherit"}, + }, + } + assert stored_secret_faults("synthetic.yml", gated) == [] + + # The clause is DOCUMENT-ONLY: `secrets:` is a plain YAML key, so the text half of the + # cross-check has nothing to match and must stay SILENT rather than report a disagreement over a + # reference neither half can name. + as_text = "jobs:\n reused:\n uses: ./x.yml\n secrets: inherit\n" + assert secret_name_counts(yaml.safe_load(as_text)) == Counter() + assert walk_versus_text_faults("synthetic.yml", as_text) == [] + + def test_the_collector_reports_a_WORKFLOW_SCOPE_reference_no_job_if_can_reach() -> None: """The workflow scope is judged even when every job is gated OFF the route. -- 2.47.3 From d4c00f75679f4b2d38fadac97f4285cb336feb0d Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 12:21:54 +0200 Subject: [PATCH 25/31] docs(885): the handover clause states its own unprobed reachability, where the rule is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code banner and the test docstring say that whether act_runner on this instance resolves `workflow_call` + `secrets: inherit` was not probed, and why that is acceptable — it governs reachability today, not the guard's silence. The record stated the clause without that bound, so a reader who meets the rule through the catalog rather than through the file met a confidence claim the source deliberately does not make. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../records/ci/pr-route-carries-no-stored-credential.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index 1c964cbce..6c87123e8 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -97,7 +97,10 @@ faulted by name. So a `secrets:` key whose value is not a mapping now faults und sentinel, judged on the VALUE SHAPE rather than on the word `inherit`, for the reason the residue counter does not match `toJSON` by name. Unlike the spelling gaps this one is invisible to the text cross-check for a reason no widening of `secret_refs` fixes — there is no text for its half to match -— so the clause reads the document only, and says so where it is defined. +— so the clause reads the document only, and says so where it is defined. Whether act_runner on this +instance resolves `workflow_call` + `secrets: inherit` at all was NOT probed (2026-09-05); that +governs reachability today, not the guard's silence, and the direction is the one every spelling row +already takes — an unsupported shape costs a spurious demand on a job nobody has written. **The `if:` classifier is a PIN, not a parser.** Exactly one string — `github.event_name != 'pull_request'` — takes a job off the route. Anything else, including -- 2.47.3 From f075d1417c85f7e5da0917d753625edcef482174 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 13:40:15 +0200 Subject: [PATCH 26/31] fix(885): an `if:` is an expression unwrapped, so the scoped detector reads that key whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `${{ }}` span scoping added in a6225e4ee was correct about prose and wrong about one real spelling: `if:` is the only key the expression grammar lets omit the delimiters in, so `if: secrets.REGISTRY_PASSWORD != ''` named a stored secret in a document holding no `${{` at all, and the collector reported it clean. Measured on the previous head e35e1b772: `secret_refs("secrets.REGISTRY_PASSWORD != ''")` -> `[]`, and the same string as a job-level or step-level `if:` on a synthetic `pull_request` job -> `stored_secret_faults(...) == []`. That an unwrapped condition is evaluated is not inferred — `docker-build.yml`'s own `build` job carries `if: github.event_name != 'pull_request'` bare, and `PR_EXCLUDING_IFS` pins that exact string. `condition_refs` reads an `if:` value as one span with the delimiters neutralised to a SPACE (deleting them collapses `${{ secrets.A }}${{ secrets.B }}` into the single identifier `secrets.Asecrets`, losing a reference), and `secret_name_counts` routes the value there instead of onto the stack, so a wrapped condition still counts once. Everywhere else the scoping stands and the English `# We pass no secrets. Then …` still costs nothing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../test_workflow_persist_credentials.py | 122 +++++++++++++++++- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 55b6b84c8..430f9db7c 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -390,11 +390,43 @@ _SECRET_REF = re.compile( # `secret_refs` below rather than in the patterns, which cannot express "within the enclosing span". # Applied to the whole string instead, `_SECRET_REF` reads the English `# We pass no secrets. Then # the pull is anonymous.` as a reference to a secret named `Then` — a fabricated name, faulting a -# PR-route job for a comment, on this branch's own subject. A reference the runner actually resolves -# is always inside an expression, so the scoping costs no real spelling. +# PR-route job for a comment, on this branch's own subject. +# +# THE SCOPING COSTS EXACTLY ONE REAL SPELLING, AND IT IS NOT NOTHING: an `if:` value is an expression +# WHETHER OR NOT it is wrapped, so `if: secrets.REGISTRY_PASSWORD != ''` names a stored secret in a +# document that contains no `${{` at all. That an unwrapped condition is evaluated rather than read +# as text is demonstrated by this repo's own `docker-build.yml` and not inferred — `build` carries +# `if: github.event_name != 'pull_request'` bare, and `PR_EXCLUDING_IFS` below pins that exact +# unwrapped string. `if:` is the only key whose value the grammar lets omit the delimiters, so the +# scoping is repaired AT THAT KEY rather than abandoned: `condition_refs` reads an `if:` value as one +# span, and `secret_name_counts` routes the value there instead of through `secret_refs`. Everywhere +# else the scoping stands, and the English sentence above still costs nothing. _EXPRESSION = re.compile(r"\$\{\{(.*?)\}\}", re.S) _SECRETS_TOKEN = re.compile(r"(? list[str]: + """Every `secrets` reference inside ONE expression span — the resolved names, then the residue. + + Shared by both entry points, so that widening a spelling widens the wrapped and the unwrapped + reading together: a second copy of this resolution would be free to drift from the one the + assertion runs on, which is the shape this guard exists to catch. + """ + resolved = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(expression)] + # The residue: `secrets` tokens in this span that resolved to no literal name. + residue = len(_SECRETS_TOKEN.findall(expression)) - len(resolved) + return resolved + [WHOLE_SECRETS_CONTEXT] * residue + def secret_refs(text: str) -> list[str]: """Every `secrets` reference in one string — occurrences, not names. @@ -405,13 +437,22 @@ def secret_refs(text: str) -> list[str]: """ found: list[str] = [] for expression in _EXPRESSION.findall(text): - resolved = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(expression)] - found.extend(resolved) - # The residue: `secrets` tokens in this span that resolved to no literal name. - found.extend([WHOLE_SECRETS_CONTEXT] * (len(_SECRETS_TOKEN.findall(expression)) - len(resolved))) + found.extend(_refs_in_expression(expression)) return found +def condition_refs(condition: str) -> list[str]: + """Every `secrets` reference in an `if:` value, which is an expression with or without `${{ }}`. + + The WHOLE value is read as one span, with the delimiters neutralised rather than honoured, so + that a condition mixing the two forms — `${{ true }} && secrets.X != ''` — is covered by the same + read as the bare one, and a fully wrapped condition still counts each reference exactly once. + This is strictly more demanding than `secret_refs` on the same string and never less: an `if:` is + never prose, so the over-match the span scoping exists to avoid cannot arise here. + """ + return _refs_in_expression(_EXPRESSION_DELIMITER.sub(" ", condition)) + + # The ONLY job-level `if:` in this repo that takes a job OFF the `pull_request` route. This is a PIN, # not an expression parser, and the direction is the point: an `if:` that is not in this set leaves # the job IN the population, so an unrecognised guard reddens rather than exempting. Parsing @@ -483,6 +524,12 @@ def secret_name_counts(node: object) -> Counter: only this direction is lossless: `secret_names` is derived from it below. A second traversal with a different accumulator would be a copy of a mechanism, free to drift from the one the assertion runs on — the shape this guard exists to catch, in the guard itself. + + One key is read differently, and it is a KEY rather than a place in the document: the value of an + `if:` is an expression with or without `${{ }}`, so it goes through `condition_refs`. Read as an + ordinary string it would be scoped to its `${{ }}` spans and a bare `if: secrets.X != ''` would + be invisible, on the route where the head writes the file. The value is routed there INSTEAD of + onto the stack, so a wrapped condition is counted once rather than twice. """ found: Counter = Counter() stack: list[object] = [node] @@ -491,7 +538,10 @@ def secret_name_counts(node: object) -> Counter: if isinstance(item, dict): for key, value in item.items(): stack.append(key) - stack.append(value) + if isinstance(key, str) and key.lower() == IF_KEY and isinstance(value, str): + found.update(condition_refs(value)) + else: + stack.append(value) elif isinstance(item, list): stack.extend(item) elif isinstance(item, str): @@ -682,6 +732,13 @@ def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None: A YAML anchor/alias would also redden benignly (the walk visits the aliased node once per reference, the text carries `*alias`); measured 2026-09-05 no tracked workflow uses one. + A THIRD asymmetry, which is not the strip's: an `if:` value is read by `condition_refs`, so an + UNWRAPPED `if: secrets.X != ''` is counted by the walk and not by the text half, which has no key + to read it by and is scoped to `${{ }}` spans. The walk's count is then the larger one, which + reddens — the safe direction, and on a document that is already faulting the stored-secret + assertion for the same reference. Measured 2026-09-05, no tracked workflow names a secret in an + `if:` at all, so nothing in the tree reaches it. + WHAT THIS CROSS-CHECK STRUCTURALLY CANNOT REPORT, since it is the reason `secret_refs` has to be widened rather than leaned on: both halves read through that one function, so a spelling IT does not recognise is invisible to both and they agree at zero. The dot/index/whole-context spellings @@ -905,6 +962,57 @@ def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None: as_text = f"jobs:\n j:\n steps:\n - run: |\n {sentence}\n true\n" assert walk_versus_text_faults("synthetic.yml", as_text) == [], sentence + # AND THE SPELLING THAT SCOPING COSTS: an `if:` value is an expression whether or not it is + # wrapped, so a condition naming a stored secret contains no `${{` and the span scoping cannot + # see it. `docker-build.yml`'s own `build` job carries an unwrapped `if:`, so this is the shape + # the repo already writes and not a hypothetical. Driven against the REAL predecessor: reading + # the value as an ordinary string — which is how `secret_name_counts` routed it before + # `condition_refs` — is `secret_refs`, and it is asserted empty on every row. + condition = "secrets.REGISTRY_PASSWORD != ''" + assert secret_refs(condition) == [], ( + "an unwrapped condition is supposed to be invisible to the span-scoped reader — if it is " + "not, these rows prove nothing about `condition_refs`." + ) + assert condition_refs(condition) == ["REGISTRY_PASSWORD"] + + job_level = {True: {"pull_request": None}, "jobs": {"j": {"if": condition, "steps": [{"run": "true"}]}}} + step_level = { + True: {"pull_request": None}, + "jobs": {"j": {"steps": [{"if": "secrets.RENOVATE_TOKEN != ''", "run": "true"}]}}, + } + for doc, name in ((job_level, "REGISTRY_PASSWORD"), (step_level, "RENOVATE_TOKEN")): + faults = stored_secret_faults("synthetic.yml", doc) + assert len(faults) == 1, (name, faults) + assert name in faults[0], (name, faults) + + # A WRAPPED condition counts once, not twice — the delimiters are neutralised rather than read as + # a second span, so the cross-check still agrees with the text half on the shape workflows write. + wrapped = "jobs:\n j:\n if: ${{ secrets.REGISTRY_PASSWORD != '' }}\n steps:\n - run: true\n" + assert secret_name_counts(yaml.safe_load(wrapped)) == Counter({"REGISTRY_PASSWORD": 1}) + assert walk_versus_text_faults("synthetic.yml", wrapped) == [] + + # A condition MIXING the two forms is read whole, so the unwrapped half is not lost behind the + # wrapped one. + assert condition_refs("${{ true }} && secrets.RENOVATE_TOKEN != ''") == ["RENOVATE_TOKEN"] + # And two wrapped references in one condition stay two: the delimiters become a SPACE, so the + # names cannot collapse into one identifier. + assert condition_refs("${{ secrets.REGISTRY_USER }}${{ secrets.REGISTRY_PASSWORD }}") == [ + "REGISTRY_USER", + "REGISTRY_PASSWORD", + ] + + # The clause is on the `if:` KEY, so the English sentences above are untouched by it: they are + # `run:` scalars, where the word is prose and the scoping still costs nothing. + still_clean = { + True: {"pull_request": None}, + "jobs": {"j": {"if": "github.event_name == 'push'", "steps": [{"run": "# We pass no secrets. Then true"}]}}, + } + assert stored_secret_faults("synthetic.yml", still_clean) == [] + + # The pinned exclusion is itself an unwrapped condition and must stay clean — it names no secret, + # and reading conditions must not start faulting every gated job. + assert condition_refs("github.event_name != 'pull_request'") == [] + def test_a_SECRETS_HANDOVER_naming_nothing_is_reported_under_the_WHOLE_CONTEXT_sentinel() -> None: """`secrets: inherit` hands the WHOLE store to a `uses:` job while naming no secret at all. -- 2.47.3 From 47a5582443e5411a9d0003e0edd1a3bc363359e0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 13:43:50 +0200 Subject: [PATCH 27/31] docs(885): the record names the spelling the scoping costs, and the layer leg is measured Two corrections to `ci.pr-route-carries-no-stored-credential`, both about claims that read as checked and were not. The `rule:` said the detector reads every spelling "only inside a `${{ }}` span", and the body enumerated `secrets: inherit` as the ONE shape left uncovered. An unwrapped `if:` is a second, and it is a shape this repo writes: both now name it, and say the value of an `if:` is read whole. `mechanics:` listed an anonymous LAYER download among two things the daemon probe did not exercise. Measured 2026-09-05 from a workstation holding no registry credential: the anonymous pull token reads the pinned manifest's first layer `sha256:179c68a720750ab4d354f6b55c0a9f551d4fd7bde93606dd0be79ba16493a39e` -> HTTP 200, 32991280 bytes, and the same GET with no token -> 401. act_runner's own pull call path is the one leg still unexercised. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/decisions/README.md | 2 +- .../pr-route-carries-no-stored-credential.md | 29 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c10629775..52cee1ab0 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -61,7 +61,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | | `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) | -| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow's own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo's visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | +| `ci.pr-route-carries-no-stored-credential` | Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != 'pull_request'`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside an EXPRESSION, because outside one `secrets.` is a full stop and not a context — which means a `${{ }}` span anywhere, AND the WHOLE VALUE of an `if:`, the one key the grammar lets omit the delimiters in (`docker-build.yml`'s own `build` job carries `if: github.event_name != 'pull_request'` unwrapped, and this guard pins that exact bare string): the index forms `secrets['NAME']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets['REGISTRY_PASSWORD'] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Measured 2026-09-05 against the span-scoped predecessor, an unwrapped `if: secrets.REGISTRY_PASSWORD != ''` on a PR-route job produced NO fault either, job-level or step-level — the scoping is right about prose and wrong about that one key, so an `if:` value is read WHOLE, with the delimiters neutralised so a wrapped condition still counts once. A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow's own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo's visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned. | 2026-09-04 | [link](records/ci/pr-route-carries-no-stored-credential.md) | | `ci.python-lint-ruff-config-committed` | The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. | 2026-08-21 | [link](records/ci/python-lint-ruff-config-committed.md) | | `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark ` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always --gated `, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) | | `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) | diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index 6c87123e8..fddd6998e 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -5,9 +5,9 @@ status: active since: '2026-09-04' supersedes: none superseded-by: none -rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside a `${{ }}` span, because outside an expression `secrets.` is a full stop and not a context: the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow''s own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' -signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, indexed secrets reference, whole-context secrets reference, secrets inherit on a reusable-workflow job, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' -mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. TWO THINGS THAT RUN DID NOT EXERCISE, because those layers were already on that host: an anonymous LAYER download, and act_runner''s own pull call path rather than the CLI''s. Both are first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' +rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside an EXPRESSION, because outside one `secrets.` is a full stop and not a context — which means a `${{ }}` span anywhere, AND the WHOLE VALUE of an `if:`, the one key the grammar lets omit the delimiters in (`docker-build.yml`''s own `build` job carries `if: github.event_name != ''pull_request''` unwrapped, and this guard pins that exact bare string): the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Measured 2026-09-05 against the span-scoped predecessor, an unwrapped `if: secrets.REGISTRY_PASSWORD != ''''` on a PR-route job produced NO fault either, job-level or step-level — the scoping is right about prose and wrong about that one key, so an `if:` value is read WHOLE, with the delimiters neutralised so a wrapped condition still counts once. A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow''s own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' +signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, an unwrapped if condition is an expression, indexed secrets reference, whole-context secrets reference, secrets inherit on a reusable-workflow job, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' +mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. That run exercised neither an anonymous LAYER download (those layers were already on that host) nor act_runner''s own pull call path rather than the CLI''s. The layer leg has since been measured directly, 2026-09-05 from a workstation holding no registry credential: the anonymous pull token reads the pinned manifest''s first layer `sha256:179c68a720750ab4d…` -> 200, 32991280 bytes, while the same GET with NO token -> 401 — so the blob path is anonymous THROUGH the token challenge and not merely open. act_runner''s call path is the one leg still unexercised, and it is first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' --- **What was wrong.** `docker-build.yml` triggers on `pull_request:`, and Gitea resolves that run from @@ -80,15 +80,28 @@ RESIDUE: any `secrets` token inside a `${{ }}` span that yielded no literal name is what makes it exhaustive over the grammar — a spelling nobody has written yet still faults. The cost is over-matching in one direction (a spelling this instance's runner may not resolve costs a spurious demand on a job nobody has written), and the word `secrets` is read as the context only -INSIDE a `${{ }}` span — every pattern, not just the residue counter. Outside one it is ordinary +INSIDE an expression — every pattern, not just the residue counter. Outside one it is ordinary English, and `secrets.` is a full stop: applied to the whole string the pattern read the comment `# We pass no secrets. Then the pull is anonymous.` as a reference to a secret named `Then`, which faulted a PR-route job for its own prose, on this branch's own subject. -**Exhaustive over the EXPRESSION grammar is not exhaustive over the document.** Scoping every -pattern to `${{ }}` spans is right — outside one the word is English — and it leaves one handover -the collector cannot see by construction: `jobs..secrets: inherit` on a `uses:` job, which passes -the caller's whole store to the called workflow and contains no expression to read. It is +**Scoping to `${{ }}` spans cost exactly one real spelling, and it is an `if:`.** A condition is an +expression WHETHER OR NOT it is wrapped — `if:` is the only key the grammar lets omit the delimiters +in — so `if: secrets.REGISTRY_PASSWORD != ''` names a stored secret in a document that contains no +`${{` at all, and the span-scoped collector reported it clean, job-level and step-level alike +(measured 2026-09-05 against that predecessor). That an unwrapped condition is genuinely evaluated +is not inferred: `build`'s own route exclusion is written that way, and this guard pins that bare +string. So the scoping is repaired AT THAT KEY rather than abandoned — the value of an `if:` is read +as ONE span, with `${{` and `}}` replaced by a SPACE rather than deleted, since deleting them +collapses `${{ secrets.A }}${{ secrets.B }}` into the single identifier `secrets.Asecrets` and loses +a reference. Everywhere else the scoping stands and the English sentence above still costs nothing. +The cost is named here rather than left implicit in the code, because "the scoping costs no real +spelling" is the reading a narrower note invites, and it is false in exactly this one place. + +**Exhaustive over the EXPRESSION grammar is still not exhaustive over the document.** Reading every +expression, wrapped or bare, leaves one handover the collector cannot see by construction: +`jobs..secrets: inherit` on a `uses:` job, which passes the caller's whole store to the called +workflow and contains no expression to read. It is `toJSON(secrets)` one level up, written in the YAML grammar, and it is the shape that most nearly defeats this guard's premise — a job written that way is *in* the derived population, is walked, and is reported clean. Measured 2026-09-05 against the predecessor of that clause, exactly that: `[]` -- 2.47.3 From 786d1c3c4b2e734e3e631865bb8e2ee6c2af2c5d Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 13:44:25 +0200 Subject: [PATCH 28/31] docs(885): an unmeasured failure TIME, replaced by the structural fact it stood for `scripts/ci-toolchain-image-resolves.sh` and `docs/ci-cd.md` both stated that the #772 container jobs "died after 1-2s", and the header used the same number to argue the preflight needs no `needs:` gate. Nobody measured it, and it cannot be measured from a working session without reproducing a deleted-tag incident. What the number stood for is structural and IS known: a container job that cannot pull its image fails AT the pull, before it runs a step, so it wastes no work waiting to be told and the argument against serialising the five jobs survives intact. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- docs/ci-cd.md | 2 +- scripts/ci-toolchain-image-resolves.sh | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8355de83b..9e587d066 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2412,7 +2412,7 @@ sha tag once 15 newer versions of the package exist. `ci-image.yml` publishes a and on every push to `main` touching `docker/ci/**` (plus every branch dispatch), while the pin only moves when a human bumps it — so a pin ages toward eviction on its own. That is what happened between 2026-08-11 and 2026-08-13 (ersatztv#772): the tag vanished, and every `container:` job — **both required contexts -included** — died after 1–2s with +included** — died at image pull, before running a step, with ``` Error response from daemon: failed to resolve reference ".../ersatztv-ci:": not found diff --git a/scripts/ci-toolchain-image-resolves.sh b/scripts/ci-toolchain-image-resolves.sh index b652779a7..8ba67c922 100755 --- a/scripts/ci-toolchain-image-resolves.sh +++ b/scripts/ci-toolchain-image-resolves.sh @@ -3,7 +3,8 @@ # # WHY THIS EXISTS. `docker-build.yml` pins its five `container:` jobs to an immutable # `ersatztv-ci:`. Between 2026-08-11 and 2026-08-13 that tag was deleted from the Gitea -# registry and every one of those jobs — including BOTH required contexts — died after 1-2s with +# registry and every one of those jobs — including BOTH required contexts — died at image pull, +# before running a step, with # # Error response from daemon: failed to resolve reference "…/ersatztv-ci:": not found # @@ -18,9 +19,10 @@ # failure into one actionable message, which is all a consumer of someone else's registry can do. # # WHY IT DOES NOT GATE THE CONTAINER JOBS with `needs:`. Serialising five jobs behind a checkout + -# one curl would tax every green run to speed up the rare red one, and the container jobs already -# fail fast (1-2s) when the pull fails. This runs in PARALLEL: the diagnosis is present the moment -# anyone looks, and the happy path pays nothing. +# one curl would tax every green run to speed up the rare red one, and a container job that cannot +# pull its image fails at the pull, before it runs a step, so it wastes no work waiting to be told. +# This runs in PARALLEL: the diagnosis is present the moment anyone looks, and the happy path pays +# nothing. # # UNKNOWN IS NOT A PASS. Warning and exiting 0 on every answer that is not 200 or # 404 makes "curl is missing from this runner", "the -- 2.47.3 From d793342f507565ff3d491ed6376199ec3eb07682 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 13:59:29 +0200 Subject: [PATCH 29/31] docs(885): a condition NAMES a secret without exporting it, and is faulted for naming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `if:` clause reads the one site whose reference the evaluator resolves without materialising anything into the job environment, so a reader can reasonably ask why it faults. Both the function and the record now say: the predicate is "names a stored secret", never "exports one" — on the head-authored route the contributor picks the comparison, which makes a condition an oracle over the value, and a predicate about exposure would have to model what each site does with its reference and give up the structure-blindness that saw `toolchain-preflight`'s step `env:` when a `container:`-shaped predicate did not. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../records/ci/pr-route-carries-no-stored-credential.md | 8 +++++++- scripts/tests/test_workflow_persist_credentials.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index fddd6998e..badb58d2e 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -96,7 +96,13 @@ as ONE span, with `${{` and `}}` replaced by a SPACE rather than deleted, since collapses `${{ secrets.A }}${{ secrets.B }}` into the single identifier `secrets.Asecrets` and loses a reference. Everywhere else the scoping stands and the English sentence above still costs nothing. The cost is named here rather than left implicit in the code, because "the scoping costs no real -spelling" is the reading a narrower note invites, and it is false in exactly this one place. +spelling" is the reading a narrower note invites, and it is false in exactly this one place. A +condition is also the one site where a named secret is not materialised into the environment — the +evaluator resolves it and the job sees a boolean — and it faults anyway, because the predicate is +"names a stored secret" and never "exports one": on this route the contributor picks the comparison, +which makes the condition an oracle over the value, and a predicate about EXPOSURE would have to +model what each site does with the reference and give up the structure-blindness that saw +`toolchain-preflight`'s step `env:`. **Exhaustive over the EXPRESSION grammar is still not exhaustive over the document.** Reading every expression, wrapped or bare, leaves one handover the collector cannot see by construction: diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 430f9db7c..2cedcd63b 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -449,6 +449,15 @@ def condition_refs(condition: str) -> list[str]: read as the bare one, and a fully wrapped condition still counts each reference exactly once. This is strictly more demanding than `secret_refs` on the same string and never less: an `if:` is never prose, so the over-match the span scoping exists to avoid cannot arise here. + + WHAT AN `if:` REFERENCE COSTS DIFFERS FROM EVERY OTHER SITE THIS GUARD READS, and it is faulted + anyway. A secret in an `env:` or a `run:` is materialised into the job environment; a secret in a + condition is resolved by the evaluator and the job sees only the boolean. The predicate here is + "names a stored secret", never "exports one" — on the head-authored route a condition comparing a + secret to a literal the contributor chooses is an oracle over its value, and a predicate that + asked about EXPOSURE would have to model what each site does with the reference, which is the + structure-blindness this collector deliberately does not give up (it is what saw + `toolchain-preflight`'s step `env:` when a `container:`-shaped predicate did not). """ return _refs_in_expression(_EXPRESSION_DELIMITER.sub(" ", condition)) -- 2.47.3 From 45cada07e4607f9258dd4c37ec265846205ba3e2 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 14:03:25 +0200 Subject: [PATCH 30/31] test(885): the predecessor read is asserted on BOTH condition rows, not one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block claimed the ordinary-string read — `secret_refs`, how `secret_name_counts` routed an `if:` value before `condition_refs` — "is asserted empty on every row", while asserting it on the job-level condition only. The step-level row's own string went unchecked, so a predecessor that happened to see it would have left the row proving nothing. Both rows now run the same three assertions from one loop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../test_workflow_persist_credentials.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 2cedcd63b..49258324f 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -974,22 +974,23 @@ def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None: # AND THE SPELLING THAT SCOPING COSTS: an `if:` value is an expression whether or not it is # wrapped, so a condition naming a stored secret contains no `${{` and the span scoping cannot # see it. `docker-build.yml`'s own `build` job carries an unwrapped `if:`, so this is the shape - # the repo already writes and not a hypothetical. Driven against the REAL predecessor: reading - # the value as an ordinary string — which is how `secret_name_counts` routed it before - # `condition_refs` — is `secret_refs`, and it is asserted empty on every row. - condition = "secrets.REGISTRY_PASSWORD != ''" - assert secret_refs(condition) == [], ( - "an unwrapped condition is supposed to be invisible to the span-scoped reader — if it is " - "not, these rows prove nothing about `condition_refs`." - ) - assert condition_refs(condition) == ["REGISTRY_PASSWORD"] + # the repo already writes and not a hypothetical. Driven against the REAL predecessor on BOTH + # rows: `secret_refs` is how `secret_name_counts` read an `if:` value before `condition_refs`, + # and it is asserted empty on each condition here before the fault is demanded. + for name, condition, at_step in ( + ("REGISTRY_PASSWORD", "secrets.REGISTRY_PASSWORD != ''", False), + ("RENOVATE_TOKEN", "secrets.RENOVATE_TOKEN != ''", True), + ): + assert secret_refs(condition) == [], ( + f"{condition!r} is supposed to be invisible to the span-scoped reader — if it is not, " + "this row proves nothing about `condition_refs`." + ) + assert condition_refs(condition) == [name], condition - job_level = {True: {"pull_request": None}, "jobs": {"j": {"if": condition, "steps": [{"run": "true"}]}}} - step_level = { - True: {"pull_request": None}, - "jobs": {"j": {"steps": [{"if": "secrets.RENOVATE_TOKEN != ''", "run": "true"}]}}, - } - for doc, name in ((job_level, "REGISTRY_PASSWORD"), (step_level, "RENOVATE_TOKEN")): + job = ( + {"steps": [{"if": condition, "run": "true"}]} if at_step else {"if": condition, "steps": [{"run": "true"}]} + ) + doc = {True: {"pull_request": None}, "jobs": {"j": job}} faults = stored_secret_faults("synthetic.yml", doc) assert len(faults) == 1, (name, faults) assert name in faults[0], (name, faults) -- 2.47.3 From 61ed6a7955f28f4704e75ca3d69a0f9b57a27711 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 14:59:23 +0200 Subject: [PATCH 31/31] test(885): exercise REGISTRY_PASSWORD at both if: levels, re-confirm the layer measurement Round-nine's bare-if fix table paired each secret name with only one level (job for REGISTRY_PASSWORD, step for RENOVATE_TOKEN), so the exact REGISTRY_PASSWORD-at-step-level and RENOVATE_TOKEN-at-job-level cases the finding named were never driven. All four combinations now run. The decision record's anonymous-layer-download closure read as reporting a past run without saying who ran it. Re-measured directly this session (2026-09-05, no stored credential): anonymous token -> pinned manifest's first layer -> 200/32991280 bytes, same GET with no token -> 401. Record updated to say the leg was re-confirmed, not merely "measured...since". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../records/ci/pr-route-carries-no-stored-credential.md | 2 +- scripts/tests/test_workflow_persist_credentials.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index badb58d2e..068fb1bb1 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside an EXPRESSION, because outside one `secrets.` is a full stop and not a context — which means a `${{ }}` span anywhere, AND the WHOLE VALUE of an `if:`, the one key the grammar lets omit the delimiters in (`docker-build.yml`''s own `build` job carries `if: github.event_name != ''pull_request''` unwrapped, and this guard pins that exact bare string): the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Measured 2026-09-05 against the span-scoped predecessor, an unwrapped `if: secrets.REGISTRY_PASSWORD != ''''` on a PR-route job produced NO fault either, job-level or step-level — the scoping is right about prose and wrong about that one key, so an `if:` value is read WHOLE, with the delimiters neutralised so a wrapped condition still counts once. A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow''s own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, an unwrapped if condition is an expression, indexed secrets reference, whole-context secrets reference, secrets inherit on a reusable-workflow job, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' -mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. That run exercised neither an anonymous LAYER download (those layers were already on that host) nor act_runner''s own pull call path rather than the CLI''s. The layer leg has since been measured directly, 2026-09-05 from a workstation holding no registry credential: the anonymous pull token reads the pinned manifest''s first layer `sha256:179c68a720750ab4d…` -> 200, 32991280 bytes, while the same GET with NO token -> 401 — so the blob path is anonymous THROUGH the token challenge and not merely open. act_runner''s call path is the one leg still unexercised, and it is first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' +mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. That run exercised neither an anonymous LAYER download (those layers were already on that host) nor act_runner''s own pull call path rather than the CLI''s. The layer leg has since been measured directly and re-confirmed 2026-09-05 from a workstation holding no registry credential: `GET /v2/token?service=container_registry&scope=repository:timothy/ersatztv-ci:pull` -> 200 + token, that bearer against the pinned manifest''s first layer `sha256:179c68a720750ab4d…` -> 200, 32991280 bytes, while the same GET with NO token -> 401 — so the blob path is anonymous THROUGH the token challenge and not merely open. act_runner''s call path is the one leg still unexercised, and it is first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' --- **What was wrong.** `docker-build.yml` triggers on `pull_request:`, and Gitea resolves that run from diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 49258324f..68f5b30e1 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -979,6 +979,8 @@ def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None: # and it is asserted empty on each condition here before the fault is demanded. for name, condition, at_step in ( ("REGISTRY_PASSWORD", "secrets.REGISTRY_PASSWORD != ''", False), + ("REGISTRY_PASSWORD", "secrets.REGISTRY_PASSWORD != ''", True), + ("RENOVATE_TOKEN", "secrets.RENOVATE_TOKEN != ''", False), ("RENOVATE_TOKEN", "secrets.RENOVATE_TOKEN != ''", True), ): assert secret_refs(condition) == [], ( -- 2.47.3