--- key: ci.workflow-dispatch-ref-unrestricted title: '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)' status: active 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' 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' --- - **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. 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" (`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 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.