--- key: ci.toolchain-image-publish-is-a-dispatch title: '2026-08-27 — `ci-image.yml` publishes on `push` to main only; from a branch it is a `workflow_dispatch` (#744)' status: active since: '2026-08-27' supersedes: none superseded-by: none rule: '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.' signals: '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' mechanics: '`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-scoping` explicitly leaves open, and this record closes one route through it. `ci-image.yml`'s `push` trigger was path-scoped but unfiltered by branch, and Gitea resolves a `push` workflow's definition from the pushed branch — so any branch push touching `docker/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 writes `ersatztv:prod` and the `ersatztv-ci:` that five `container:` 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_dispatch` is 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 turns `ci-image-pin` red, 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 the `docker/ci` commit as HEAD, dispatch `ci-image.yml` on that branch (it tags `git 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 on `fix/744-ci-image-branch-filter` at `43b1e45` succeeded (run 2340), the job bound to that branch and sha, and it published `ersatztv-ci:43b1e45`; `:latest` still 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-pin` derives `expected` from the last commit touching the image sources; while `.gitea/workflows/ci-image.yml` was one of them, editing that file re-pointed `expected` at 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 because `ci-image.yml` is 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 a `docker/ci` touch 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 a `docker/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, because `expected` remains the last `docker/ci` commit 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.yml` has `branches: [main]` and would pass a keyword test, yet it also has `tags: ['v*']` and `pull_request:`, both head-resolved and both holding the same registry credential — and a tag push is explicitly outside `release.main-direct-push-disabled`, so `git push origin :refs/tags/v99.9.9` runs that commit's workflow and can publish `ersatztv:prod` with no dispatch at all. On top of that, four workflows carry an unrestricted `workflow_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-unrestricted` carries the probe and the reasoning: Gitea 1.27.1 has no ref restriction for `workflow_dispatch` and no protected-environment concept, and restricting dispatch would change nothing, because `docker-build.yml`'s head-resolved `pull_request:` route runs attacker-authored YAML, which reaches every secret in the store — so it covers `renovate.yml`'s `RENOVATE_TOKEN` too, without dispatching `renovate.yml` at all. **Only the dispatch third is settled**: the `v*` tag push and the `pull_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 without `persist-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.