Files
ersatztv/docs/decisions/records/ci/workflow-dispatch-ref-unrestricted.md
T
timothyandClaude Fable 5.1 fba895852c fix(885): the two named records state the invariant this branch actually ships, not the job-only half
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 15:15:42 +02:00

13 KiB

key, title, status, since, supersedes, superseded-by, stale-after, rule, signals, mechanics, sources
key title status since supersedes superseded-by stale-after rule signals mechanics sources
ci.workflow-dispatch-ref-unrestricted 2026-08-30 — `workflow_dispatch` cannot be ref-restricted at Gitea 1.27.1, and restricting it would close nothing while `docker-build.yml` keeps `pull_request:` (#853) active 2026-08-30 none none 2027-02-28 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. 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 `docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push" 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
  • The two capability questions the issue named were probed to a negative, and the negatives are the durable part of this record. There is no ref restriction for workflow_dispatch at 1.27.1: the endpoint takes ref as a required free-form string and no repo, org or admin endpoint scopes which refs may be selected. There is no protected-environment concept to gate a secret behind — not a missing endpoint but a missing feature, evidenced by zero of 308 documented paths mentioning "environment" and by secrets existing only at org, repo and user scope. THREE surfaces were swept, because "the obvious call 404s" is not evidence of absence: the REST API, the config file the running server names in its own --config, and the gitea CLI. The web UI was not swept, and that is a real limit on this evidence rather than a formality — a Gitea Actions control can exist with NO API surface at all, which ci.actions-credential-scoping records for the owner-level default: it 404s on the API and was nonetheless set, verified and reverted. That record does NOT name the surface used — a non-API one is an INFERENCE by elimination here, since app.ini sets only two [actions] keys and the CLI exposes only generate-runner-token. It is left open because the decision does not rest on it — what carries the conclusion is the secret-scope model (org/repo/user only, no per-ref gate) plus the fact that restricting dispatch would close nothing regardless. Sweep the UI before citing this record as proof that no such control CAN exist. Check the CLI's exit code, not its match count — run as root the binary refuses to start and exits 1, so grepping its empty help output "finds nothing" for entirely the wrong reason; run as the service user it does list an actions command, whose sole subcommand is generate-runner-token. Re-probe on the VERSION rather than the date: stale-after is a calendar field, but what invalidates all of this is an upgrade past 1.27.1, which can land months earlier.
  • Do not close this class with "anyone with repository write can already do worse." That argument was the issue's own leading option and it is the one to reject, for two reasons. It is unfalsifiable as stated, so it licenses accepting anything; and it skips the question that actually decides the matter, which is whether dispatch is the cheapest route to the credential. It is not.
  • pull_request: is the cheaper route, and it is the finding that settles the decision. docker-build.yml triggers on pull_request:, which Gitea resolves from the PR head, so the run executes attacker-authored YAML — and that, not the specific jobs, is the whole argument: such YAML can name any secret in the repo store, not only the ones the committed workflows reference. ci.gate-trigger-base-resolved states it directly — "any PR-added workflow can reference RENOVATE_TOKEN, a write:repository bot PAT in the same store". That step is INFERRED from the repo-scoped secret model plus that record, not measured here: measuring it prints a live credential 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. 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: 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 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 in the same head-supplied file and an attacker edits them out.
  • The write set is two accounts, and the asymmetry between them is worth stating once. They are timothy (owner, site admin) and renovate (collaborator, permission: write, is_admin: false). Against the admin, no control administered by that same admin is a boundary — that is a property of the trust model, not a defect. Against the bot it is different, and the difference is easy to misread: considerable machinery bounds what Renovate can get MERGED (review-verdict/h10 as a required context, and the exemption that auto-passes a Renovate PR only when every changed path is a dependency manifest AND none is under any of the six protected directories .claude/, .codex/, .gitea/, .husky/, scripts/, docker/ci/ — #698, #742, #845; ci.exemption-provenance has the authoritative form). None of that machinery bounds what Renovate can RUN. A reader of ci.exemption-provenance could reasonably infer the bot is tightly constrained; on the run path it is not. Measured rather than inferred: on a matched probe against the same endpoint and payload, an authenticated non-writer short-circuits at authorization 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 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.