fix(748): declare permissions: on all six workflows, and prove the declaration binds #856
Closed
timothy
wants to merge 12 commits from
fix/748-workflow-permissions-v2 into main
pull from: fix/748-workflow-permissions-v2
merge into: :main
:main
:renovate/meziantou.analyzer-3.x
:renovate/nuget-sixlabors.imagesharp-vulnerability
:913-scripted-playout-401
:929-vuln-pkcs-imagesharp-v4
:renovate/dotnet-monorepo
:release/v26.15.0-notes
:renovate/lucene.net
:renovate/cliwrap-3.x
:issue-806-guard-populations
:scratch/767b-poisoned
:scratch/767b-control
:release/v26.14.0-notes
:release/v26.14.0
:renovate/sqlitepclraw.bundle_e_sqlite3-3.x
:docs/510-skill-logo-bug-policy
:fix/510-watermark-resolution-policy
:fix/629-verdict-classifier-falseopens
:fix/609-decisions-edit-token-scope
:issue-135-clear-to-none
:release/v26.12.0-notes
:fix/409b-lastscan-api-parity
:fix/401-updatechannel-mirror-422
:fix/327-playlist-rename-validation
:fix/410-scancancel-log-level
:fix/409-447-librariesscreen-neverscanned
:fix/338-zap-exit-code
:fix/367-plex-budget-message
:fix/310-debom-legacy-cs
:ci/604-lane-rebalance
:feat/388-design-mirror
:feat/247-test-ownership
:feat/247-primary-action
:feat/357-player-owned-playback
:feat/357-jellyfin-plugin-poc
:fix/289-mcp-hardening
:issue58-mcp
:feat/244-channels-extract
:ci/auto-bump-prod-compose
:feat/multi-rerun-collections-api
:feat/collections-api
:feat/quick-wins
:feat/185-docs-part2
:feat/140-collections-screen
:feat/146-channel-edit
:feat/147-classic-ui-link
:issue22-renovate-dashboard
:feat/91-cutover
:feat/63-composite-create
:feat/65-library-browse
:feat/85-epg
:feat/86-schedule-editor
:feat/109-dashboard-data
:feat/99-session-tracking
:fix/dockerfile-node-tag
:feat/59-spa-foundation
:docs/59-ui-redesign-brief
:feat/102-json-guide
:feat/111-schedule-durations
:feat/104-artwork-upload
:feat/103-media-sources-api
:feat/playouts-read-api
:feat/108-health-api
:feat/105-picker-list-endpoints
:issue-97-channel-state-api
:issue42-jellyfin-musicvideos
:issue46-rest-api-error-contract
:dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
:qsv-improvements
:hdr-vulkan-cuda-test
No Reviewers
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#856
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Declares
permissions:on all six workflows so the owner-level Actions default can move to Restricted (server-management#714), and — the part that was actually blocking — proves the declaration binds, whichci.actions-credential-scopinghad carried as UNPROBED since 2026-08-05.Why this was parked for three weeks
The change itself was written on 2026-08-06 (
7280a91d, subjectNOT VERIFIED, do not merge) and blocked on #751, which closed 2026-08-10. The unblocking was noticed on 2026-08-14 by a server-management session, which correctly left a cross-reference rather than picking up work in this repo — and no ersatztv session claimed it since. Not abandoned; orphaned by a repo boundary.It stayed parked for a real reason:
review-verdict.ymlis base-resolved, so CI-green on its own PR proves nothing, and a wrong unit blocks every merge with no force-merge escape since #743.The probe
Matched scratch-base pair on Gitea 1.27.1, identical except for the gate job's single permission unit, the block carried at job level on
set-verdict-status— the shipped shape:code: writePosted review-verdict/h10-PROBE=success;creator: nullcode: readcurlexit 22; no status writtenThe negative control is the load-bearing half: a green positive alone is equally consistent with
permissions:being a no-op beside a permissive default. Both reached the classify step's start marker, so neither was the #751 dropped-step shape. Probe branches and PRs (#851, #852) deleted.creator: nullalso confirms approach A preserves the provenance signal that approach B would destroy.What it does NOT establish
Both controls ran under a permissive owner default. Nothing here says what a declared
code: writereceives under Restricted — GitHub letspermissions:only narrow, and if Gitea copies that, Restricted caps the gate at read and the gate becomes unwritable. The flip stays gated on re-running the probe under Restricted; server-management#714 stays open for that, and the issue's Deferred section says so.Corrections to the issue body
docker-build.ymldoes not write a status with the injected token — noGITEA_TOKENreference at all; itsETV_STATUS_AUTHis the scoped REGISTRY_* PAT doing read-only GETs. Onlyreview-verdict.ymlneeds write. Nopackages:unit is needed anywhere: all fivecontainer:blocks carry explicitcredentials:.Guard
Two mutation-proved tests in
scripts/tests/test_pr_changed_files.py: the gate job's units by equality (an extra unit is a finding too), and every tracked workflow declaring a block. Seven clause mutations red, baseline green, plus an anti-vacuity assert after measuring that a moved.gitea/workflows/made the second test pass having examined nothing.Verification
scripts/tests/1015 passed / 2 skipped;decisions_validateOK; catalog regenerated; ruff clean. Independent cold-context review in an isolated worktree, 5 rounds, final verdict MERGEABLE — it re-ran the mutation battery itself rather than accepting the claims.fixes #748
So the owner-level Actions default can move to Restricted (server-management#714) without this repo's merge gate losing the ability to write `review-verdict/h10`. review-verdict.yml (job-level) code: write, issues: read, pull-requests: read the other five (top-level) code: read `code: write` because Gitea has no `statuses` scope — status-write IS Code-write (`reqRepoWriter(unit.TypeCode)`), per `ci.actions-credential-scoping`. The two read units are NOT spare: the gate job also GETs `/issues/{n}/timeline` (the #706 retarget fence) and, via `scripts/pr-changed-files.sh`, `/pulls/{n}` and `/pulls/{n}/files` (the #698 exemption path). Enumerated against every authenticated call the job makes; the set covers it exactly, with nothing over. Corrects #748's body: `docker-build.yml` does NOT write a status with the injected token. It has no `GITEA_TOKEN` reference at all — its `ETV_STATUS_AUTH` is the scoped REGISTRY_* PAT doing a read-only GET. Only review-verdict.yml needs write. No `packages:` unit is needed anywhere: all five `container:` blocks carry explicit `credentials:` using the REGISTRY_* PAT, so no job pulls the toolchain image with the injected token. refs #748permissions:binds, measured by matched probe ca342db70bAdds `docs/ci-cd.md` -> "Workflow token scope" (the section the inline comments on all six workflows point at) and corrects `ci.actions-credential-scoping`, which had carried "whether `permissions:` is honored here is still UNPROBED" and an explicit "do NOT add a `permissions:` key" since 2026-08-05. Settled by a matched scratch-base probe pair on Gitea 1.27.1, differing only in the gate job's single permission unit: code: write -> job success, `Posted review-verdict/h10-PROBE=success`, status creator: null code: read -> job failure at the POST, curl exit 22 (HTTP >=400 under -f), NO status written Both reached the classify step's start marker and both logged the retarget fence as trusted, so the read units were exercised and neither run was the #751 dropped-step shape. Both ran while the owner default was still `permissive`, which is what establishes the declaration binds irrespective of that default. The negative control is the load-bearing half: a green positive alone is equally consistent with `permissions:` being a no-op sitting beside a permissive default. Also corrects the two places that framed the persisted checkout credential's write-capability as decided by the instance default; since this change it is decided by `ci-image.yml`'s own `code: read` declaration. Decisions-Edit: yes Refs: #748rule:field, not the body 01e51830edRound 4. Round 3 corrected the BODY of `ci.gate-trigger-base-resolved` and left its `rule:` frontmatter, which `build_decisions_catalog.py` mirrors verbatim into `docs/decisions/README.md`. Three compounding consequences, all now fixed: * it contradicted the body of its own record twelve lines below it; * the catalog is where CLAUDE.md says a decision lookup STARTS, so the stale version was the one a future editor read FIRST and the corrected body sat behind it; * as stated it was false for this repo today, not merely dated — "any ref-resolved workflow can still forge `review-verdict/h10`". This is the same fixed-the-site-not-the-class shape recurring one level up. My round-3 sweep grepped body prose and missed it because the `rule:` field is a single long YAML line and my pattern was anchored on wording I had already corrected. Re-swept by subject with a wider pattern across `docs/` and `CLAUDE.md`; the remaining hits are past-tense, already corrected, or about a different subject (the secret store; "credentials that CAN post a status", which stays true). The rule now says what #748 does and does not close: current instances closed, the SHAPE open, and the new guard is what covers a newly added workflow. GRADING, not just correcting. The body asserted "none of them can POST a status" at full strength while `ci-cd.md` and `ci.actions-credential-scoping` both grade the same claim as resting on the TOP-LEVEL declaration form, which is inferred rather than probed. The corpus graded it in two places and asserted it ungraded in the third — the record about forgery routes. Both the rule and the body now carry the qualifier. Also: `docker-build.yml`'s round-3 meta-note had been inserted between "uses the REGISTRY_* PAT" and "needs no `packages:` unit for that reason", stealing the antecedent of "that reason". Moved to the end of the block. And `ci-cd.md` understated `ci-image.yml`'s trigger filter (`docker/ci/**` AND its own file, not just its own file). Decisions-Edit: yes Refs: #748Review-verdict: MERGEABLE @
548e0585-round cold-context adversarial review in an isolated worktree; reviewer re-ran the 7-mutation battery and both vacuity probes itself. Unit set enumerated against every authenticated call reachable from set-verdict-status (incl. through pr-changed-files.sh) and measured end-to-end by a matched scratch-base probe with a negative control.
`ci-image-pin` (a BLOCKING job) went red on the PR head, exactly as `ci.actions-credential-scoping` predicted: its `expected` is `git log -1 -- docker/ci .gitea/workflows/ci-image.yml`, so a PR that EDITS `ci-image.yml` re-points `expected` at its own commit and the existing pin reads as stale. I had read that warning and bundled the file anyway. Two further reasons not to pay the toll instead: * clearing it means letting ci-image.yml publish a toolchain image from this branch, then bumping all five pins — and a rebase recharges it. That is why `land-toolchain-image-change-separately` exists. * `fix/744-ci-image-branch-filter` is ACTIVE in another session right now (queued workflow_dispatch run 2340), and #744 exists precisely to change this file alone. Editing it here would collide. Verified my push did NOT publish an image: no ci-image run from this branch. So `ci-image.yml` is reverted to `origin/main` and the guard carries a ONE-NAME literal exemption with the reason and the tracking issue — the same exemption #746 already carries for that file's `persist-credentials`, and ersatztv#835 already tracks deleting BOTH once #744 lands. Mutation-checked that the exemption does not blunt the guard: dropping `renovate.yml`'s block still reds. THE CLAIM THAT HAD TO CHANGE, not just the counts. I had written in `ci.gate-trigger-base-resolved` that "no ref-resolved workflow that exists TODAY can post a status". With ci-image.yml excluded that is FALSE, and falsest in the worst place: it declares nothing, so it keeps the owner default, AND its unfiltered `push:` is path-scoped to itself, so any branch push runs its head-resolved YAML with a status-capable token. Both the record's `rule:` and its body now say ci-image.yml is the exception and is the worst-placed of the six, with #744 named as the issue that closes the route — scoping would not have closed it anyway, since scoping bounds what a job may DO and only a filter bounds whether it RUNS. Swept every dependent count and claim rather than the ones I remembered: six->five declaring, five->four holding `code: read`, and the reverted assertion that ci-image.yml's persisted checkout credential is read-only by declaration — it is not; it is still write-capable on the owner default, in `docs/ci-cd.md`, `ci.actions-credential-scoping` (body AND `rule:`), `release.verdict-status-check` and `release.main-direct-push-disabled`. New "carve-out" subsection in docs/ci-cd.md states it as a scheduling constraint, explicitly not a safety judgement. Decisions-Edit: yes Refs: #748 #744 #835Superseded by a rebase onto
45b17e58e. #857 (#744/#835) merged while this was in review and removed both halves of theci-image.ymltoll, soci-image.ymlnow declarespermissions:too and the one-name guard exemption this branch carried was deleted rather than documented — the form #835 asked for. Reopening as a fresh PR fromfix/748-v3; this branch is preserved asbackup/748-pre-rebase.Pull request closed