Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 21s
Probed every reachable surface rather than stopping at the endpoint #853 already knew 404s: the dispatch API takes `ref` as a required free-form string with no allow-list; protected environments do not exist at 1.27.1 (0 of 308 documented paths mention "environment", secrets are org/repo/user-scoped only); the loaded `app.ini` sets two `[actions]` keys; and the CLI's sole Actions subcommand is `generate-runner-token`. So option 3 is unavailable. Accepted on a different ground than the issue proposed. "Anyone with repository write can already do worse" is unfalsifiable and hides the cheaper route. The operative reason is that dispatch is not the cheapest path: `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches every secret in the store — six of its jobs hold `REGISTRY_PASSWORD` on that route and two are branch-protection required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one. Corrections to #853's own table, verified against the tree: `dependency-scan.yml` references no secrets at all; the "four workflows" count is right. Deliberately not applied: a `v*` tag protection (`tag_protections` is empty and 1.27.1 supports it) — protection-class config whose failure mode is a broken release cut, so it needs its own change and verification. Tracked with the `pull_request:` residual in #885. The web UI was not swept, and the record says so explicitly rather than claiming exhaustiveness — a Gitea Actions control can exist with no API surface at all. fixes #853 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
9.5 KiB
9.5 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| ci.toolchain-image-publish-is-a-dispatch | 2026-08-27 — `ci-image.yml` publishes on `push` to main only; from a branch it is a `workflow_dispatch` (#744) | active | 2026-08-27 | none | none | A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow's definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`'s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this. | unfiltered push trigger, branches filter on push, attacker YAML on a runner, feature branch publishes an image, self-referencing workflow trigger, ci-image-pin expected goes stale, publish-then-pin two-step, toolchain image recovery after a rebase, workflow_dispatch on a branch · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/docker-build.yml`, `docker/ci/Dockerfile`, `docs/ci-cd.md`, `docs/decisions/records/ci/actions-credential-scoping.md`, `scripts/e2e-ui.sh` · issues: #744, #853, #885, #697, #746, #835, #445, #390, #594, #597 | `docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"; branch-dispatch probe 2026-08-27 (run 2340, published `ersatztv-ci:43b1e45`) |
- Credential scoping bounds what a job may DO; it never bounded whether the job RUNS. That is the
seam
ci.actions-credential-scopingexplicitly leaves open, and this record closes one route through it.ci-image.yml'spushtrigger was path-scoped but unfiltered by branch, and Gitea resolves apushworkflow's definition from the pushed branch — so any branch push touchingdocker/ci/**or the workflow file ran that branch's own YAML, with no PR, no review and no status check in the loop, on a docker-capable runner holding the credential that writesersatztv:prodand theersatztv-ci:<sha>that fivecontainer:jobs execute.branches: [main]closes the drive-by route, and only that: the filter is loaded from the pushed ref like every other line in the file, so a branch that deletes it re-enables the route in one edit. What goes away is publication as a side effect of an ordinary push of a legitimate change — a real and frequently-trodden path, and not a boundary against someone who intends to run their own YAML. Do not cite this record as that boundary.workflow_dispatchis self-supplied from the selected branch in the same way, and is now the deliberate publish path. - The capability removed was real, so it is replaced rather than argued away. An earlier attempt
(#697) justified the filter with "publishing from a feature branch was not load-bearing", which is
false: a PR changing
docker/ci/**without moving the pin turnsci-image-pinred, and the merge-consent hook reads the COMBINED commit status, so it will not auto-grant — the job is not itself a branch-protection required context, but the effect is the same. The only way to move the pin was to publish from the branch first. The replacement is the same two-step with a dispatch in place of the push — push thedocker/cicommit as HEAD, dispatchci-image.ymlon that branch (it tagsgit rev-parse --short HEAD, so the two must coincide), then commit the pin bump on top. A rebase rewrites the sha, so all three are redone; that was already true. Probed rather than reasoned: dispatching onfix/744-ci-image-branch-filterat43b1e45succeeded (run 2340), the job bound to that branch and sha, and it publishedersatztv-ci:43b1e45;:lateststill resolved to its pre-dispatch digest, a check whose stated limit lives with the probe (2026-08-27,docs/ci-cd.md). - Dropping the self-path is a DECIDED TRADEOFF, and calling it forced would be wrong.
ci-image-pinderivesexpectedfrom the last commit touching the image sources; while.gitea/workflows/ci-image.ymlwas one of them, editing that file re-pointedexpectedat the editing commit. Retaining it stays workable because the branch dispatch survives — push the ci-image.yml commit as HEAD, dispatch, pin it — so the necessity argument does not hold. What does hold is the price: that route charges a full ~2GB publish plus a five-pin bump for every edit to the file, a comment included, and a rebase charges it again. That price is what reverted #697's attempt and what made landing this change alone the cheaper option. Dropping it prices the opposite risk instead (the bullet below). The second was chosen becauseci-image.ymlis edited far more often for triggers, comments and runner placement than for build recipe, so the first cost is paid constantly and the second only on a change that can be paired with adocker/citouch deliberately. - What that costs, recorded because it is invisible otherwise: a change to how the image is built
that lives only in
ci-image.yml— build-args, Dockerfile path, platforms — neither republishes nor invalidates the pin, so CI keeps running an image built by the previous recipe and nothing goes red. The remedy is adocker/ci/**edit in the same change — a Dockerfile comment bump suffices — and it is the only one: publishing after the merge and then pinning is not an alternative, becauseexpectedremains the lastdocker/cicommit and rejects a pin naming the workflow-only commit, while leaving the pin alone keeps consuming the old image. The weekly cron does not cover it either — that moves:latest, never a pin. A corollary worth its own line:docker/ci/**has no cheap-edit path, so even a comment fix there costs a publish-and-repin and should ride the next genuine toolchain bump (#854). - This closes ONE route into ONE file, not the class — and the residual is NOT all dispatch.
Sort by the ref class a trigger admits, not by which keyword it carries, or the enumeration
mis-sorts:
docker-build.ymlhasbranches: [main]and would pass a keyword test, yet it also hastags: ['v*']andpull_request:, both head-resolved and both holding the same registry credential — and a tag push is explicitly outsiderelease.main-direct-push-disabled, sogit push origin <any-commit>:refs/tags/v99.9.9runs that commit's workflow and can publishersatztv:prodwith no dispatch at all. On top of that, four workflows carry an unrestrictedworkflow_dispatch. What #744 changed is that none of this is any longer a side effect of an ordinary branch push. Read it as protection against an accidental or drive-by push, not as a boundary against a malicious or compromised writer. That remaining class was worked in #853 and accepted rather than fixed —ci.workflow-dispatch-ref-unrestrictedcarries the probe and the reasoning: Gitea 1.27.1 has no ref restriction forworkflow_dispatchand no protected-environment concept, and restricting dispatch would change nothing, becausedocker-build.yml's head-resolvedpull_request:route runs attacker-authored YAML, which reaches every secret in the store — so it coversrenovate.yml'sRENOVATE_TOKENtoo, without dispatchingrenovate.ymlat all. Only the dispatch third is settled: thev*tag push and thepull_request:route itself stay open in #885. - The persist-credentials exemption was downstream of this, and closed with it.
ci-image.yml's checkout was the one left withoutpersist-credentials: false(#746), for exactly the two mechanical reasons above. Both gone, the flag went on in the same change, which is what let #835 ship its guard with no exemption list — an exemption whose only justification is a temporary condition elsewhere keeps passing after that condition expires, leaving the guard permanently blind to the file it was written for.