Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 5 7fcb5e9b28 fix(767): gate the release path on the delimiter ban with a prerequisite job
The ban that keeps `build`'s `Smoke + IPTV E2E` from being silently dropped was enforced
only by a pytest in `script-tests` — `on: pull_request`, and not a required context. Nothing
re-checked it on a `v*` tag push, which is exactly when the candidate image is published and
`DeployStack jazz-media` promotes it. A delimiter that reached `main` would drop `Smoke` on
the tag build, publish an unsmoked candidate, and report green.

A `scan` job now runs the PyYAML-based ban test, and `build` lists it in `needs:`. That edge
is the whole property: a red `scan` skips `build` outright, so the image is never built.

TWO DESIGNS WERE TRIED AND THE FIRST ONE'S FAILURES ARE RECORDED, because both are easy to
re-invent. The first cut put a bespoke stdlib scanner in `build` itself, as an unconditional
step before `Build and push`. Two independent cold reviews rejected it:

  * A guard STEP cannot protect the job it lives in. `build` is what publishes, so a dropped
    guard step there fails OPEN — and "the guard's own body has no opener, so it cannot be
    dropped" is circular when the only thing enforcing that property is the same PR-only test
    being backstopped. A `needs:` edge is not circular.
  * The hand-written YAML parser had ~10 false NEGATIVES in one review round (flow mappings,
    a quoted `"run":` key, aliases, multiline quoted scalars) — strictly WEAKER than the check
    it backstopped, in the only direction that matters for a security gate. Deleted rather
    than patched: running the existing test needs no second definition of "what is a `run:`
    body", so there is no drift surface at all.

No third marker bucket was needed. The deferral assumed the answer had to be markers on
`build`, modelling `Smoke`'s publish-ref `if:`. The delimiter class is a STATIC property of
the workflow text, so a job that reads the text catches it without modelling any `if:`.

The `scan` job's own steps carry #756 markers and a trailing assert, so a drop inside it is
caught too — moving the terminal assumption rather than removing it: to fail open you must
now drop the pytest step AND the assert step.

Every way of disarming the gate was mutation-tested to a red: removing the `needs:` edge,
adding a job-level `if:`, marking a step `continue-on-error`, injecting a delimiter into a
scan body, dropping the ban test from the pytest invocation, removing a marker, and deleting
the assert step. The guard's real command line is also driven against the steps' real marker
lines with each key dropped in turn.

Refs: #767
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:54:08 +02:00
timothyandtimothy 884ac8a7e9 fix(756): extend the dropped-step guard to docker-build.yml's required jobs, where a drop is fail-OPEN (#768)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Failing after 6m8s
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) Successful in 4m42s
A `run:` body the runner declines to interpolate is dropped, and the job still
concludes `success` (#751). #751 fixed that in review-verdict.yml, where the
failure is fail-CLOSED. This closes the two places where it is fail-OPEN:
`Build & test (.NET)` and `EF migration integrity (SQLite + MySql)` are the
other two required contexts on `main`, so a dropped step there sends a required
check green having done no work.

Per-STEP markers, not per-job as proposed: a marker on the first step only
proves the job began, while the drop that costs something is `Test`, `Build` or
a migration replay. The trailing guard carries no `if:` — with a dozen steps,
`always()` would announce a false "these steps never executed" on every ordinary
red build; the default `success()` is correct because guard-skipped implies
job-red. Plus a ban on the raw `${{` opener in `test`, `migrations` and `build`,
which makes the class unreachable rather than merely caught. `build` is included
because its Smoke step runs AFTER the image is pushed.

Measured live on the build lane in both directions: probe #765 (drop caught,
sole failure in the job) and #766 (a failing continue-on-error step does not
skip the guard). 510 tests, 30 mutations killed across two harnesses, five cold
review rounds across two model families.

Residual tracked as #767: the `build` ban is review-time only, not fail-closed
on the release path.

fixes #756

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-10 23:35:08 +00:00
timothy 9a5d34e888 fix(751): a stray expression delimiter in a COMMENT killed the verdict gate (#764)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 14s
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) Successful in 4m31s
The classify step in review-verdict.yml stopped executing on 2026-08-03 and the job
reported success anyway, so the branch-protection-required review-verdict/h10 was posted
by nothing but a human hand for three days and both exemption classes silently died.

Three independent defects, each alone sufficient:

* A ${{ }} sequence in a SHELL COMMENT. The runner scans the whole run: scalar for the
  expression opener and rewrites the entire body into one format(...) call; `pr number`
  does not parse, so it drops the step and concludes the job green. The prose documenting
  a fix disabled the fix.
* The retarget fence never trusted its count: a page past the end of the timeline is JSON
  `null`, not `[]`, so rt_ok was never yes for ANY PR and every exemption success was
  withheld. Fixing the first alone would not have restored the exemptions.
* The same nil-slice shape on /commits/{sha}/status, which made read_existing_verdict
  exit 1 and post nothing.

A nil Go slice serialises to `null`, so every list-shaped field on this API is suspect and
only a per-endpoint measurement settles it — timeline returns bare null, the combined
status returns {"statuses": null}, comments and pulls/{n}/files return [], and
/statuses/{sha} returns []. Four endpoints, three shapes.

The silent green is the actual defect, so a start-marker guard now fails the job when the
classifier did not execute, and two static guards reject the delimiter at review time.
CLAUDE.md and AGENTS.md became PROTECTED paths: they define the H10 rule and were
docs-only-exemptible, reachable again precisely because this restores the exemptions.

Verified by a live scratch-base probe pair with a negative control, 460 tests, and 29
mutations across six rounds. Five cold review rounds, alternating model families; none
found a path to a green review-verdict/h10 on an unreviewed head, and every one found a
defect beside the fix — including that round 2's guard was dead code against a page limit
of 100 on a server that caps at 50.

Deferred: #756 (docker-build's required jobs, where a dropped step is fail-OPEN) and #763
(paging both /statuses/{sha} reads).

fixes #751
2026-08-10 19:33:33 +00:00
timothyandClaude Opus 5 20b117dabf fix(751): round-5 findings — a rationale that was itself vacuous, and a third regex round
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Review verdict / Set review-verdict status (pull_request_target) Successful in 6s
PR Gates / Script tests (pytest) (pull_request) Successful in 2m14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m34s
review-verdict/h10 Review-verdict: MERGEABLE @ 20b117d (base: main)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m27s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Fifth cold review: MERGEABLE, no Blocker, no High. Four Low findings, none behavioural.
Fixing all four rather than accepting them, because two are the exact class this issue
exists to retire: text that reads as a checked reason and is not.

A VACUOUS RATIONALE, on the branch about vacuous rationales. The comment on the history
stub's `page` guard said it sits ahead of the read-counting modes "so the page-2 probe
cannot shift 'raced row appears on read N'", by analogy with the combined endpoint.
Measured: moving that guard AFTER the counter modes reddens NOTHING, because no history
mode that counts reads ever issues a page-2 request — `raced=1` on page 1 short-circuits
the probe. The real reason is the other half: page 2 must terminate for modes that
describe page 1 only, and dropping just that `print("[]")` reddens
`test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair`. Comment now says which half is
load-bearing and which was wrong. (The COMBINED endpoint's guard genuinely is
counter-related — moving it reddens three mid-run-race tests.)

THE FALSE REPAIR IS STICKY, and the previous commit undersold it as "a stall a reviewer
can clear". It writes `$REPAIR_DESC`, which the classification refuses to grant an
exemption over and re-writes as a fixed point on every later run — so a spurious repair
removes that head's exemption PERMANENTLY, not for one run, and only a human verdict
clears it. Still the right direction against a forged green over a rejection, but it is a
per-sha loss of the exemption, and that is the argument for real paging (#763) rather
than living with this. Said in the comment now.

CORRECTING THE PREVIOUS COMMIT MESSAGE, which over-generalised: "uncertainty resolves to
a stall … never to leaving green" is true of the page-2 probe and NOT of the enclosing
path. An unreadable page 1, or a non-numeric high-water mark, still leaves the exemption
`success` standing unverified. The workflow's own comments state that correctly; the
message did not.

THIRD ROUND ON ONE REGEX, which is the documented budget for a string-matching predicate.
Assertion C started as `\w+\s*\(\)\s*\{`, gained `function\s+\w+` when review found
`function mk {` slipped it, and STILL missed the union form `function mk() {` — the
natural next spelling once the previous one is caught. Now
`^\s*(function\s+)?\w+\s*(\(\s*\))?\s*\{`, verified against all seven spellings.

THE COMPLETENESS COUNT, restored properly. Relaxing `len(bodies) >= 3` to `assert bodies`
fixed a false red but threw away the only check that the walk reached ALL run-bearing
steps: `max(len) > 5000` proves it reached the classifier and nothing about the short
ones, so a helper that silently stopped yielding them would pass an unscanned delimiter.
Now counted against the job's own step list, read directly rather than through the helper
under test — which catches a helper reading the wrong key or dropping steps, while still
tolerating a step being legitimately added or removed.

Verification: 460 green. Three mutations, each as intended — the union spelling `function
mk() {` (red, previously passed), a walk that drops the short steps (red, the property the
count guard restores), and a legitimate step deletion (PASSES, confirming the false red it
replaced stays fixed). Twenty-nine mutations across six rounds.

Refs: #751
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:08:10 +02:00
timothyandClaude Opus 5 e133c11fde fix(751): rebase onto #760, close the fail-OPEN twin, and retire four claims that had rotted
Fourth cold review round: no Blocker, no new path to a green `review-verdict/h10` on an
unreviewed head, and it independently re-measured 14 claims in the diff. It also caught
that this branch was about to revert someone else's work, and found the one remaining
place where the nil-slice/clamp lesson had not been applied.

REBASED ONTO 9881d1ff8 (#760), which landed while this was in review. The tell was the
one CLAUDE.md documents: `git diff origin/main HEAD` showed deletions I never made —
`docs/decisions/records/mcp/tool-schema-openapi-parity.md` and edits to `docs/mcp.md`.
Pushing would have reverted them. The generated catalog was regenerated rather than
trusted to the rebase, and verified to carry BOTH records.

THE FAIL-OPEN TWIN, one function further on than the last round reached.
`/statuses/{sha}?limit=100` is read twice — for the high-water mark and for the
post-write race check — and neither pages, while `limit` clamps to 50. So "no raced row
on page 1" does not establish "no race": a human `BLOCKED` landing in the write window
can sit on a page the job never reads, leaving a forged green over a rejection. This is
the ONE path in the design whose failure direction is toward SUCCESS.

Measured rather than argued: a probe head reached 33 rows after ~5 runs against a cap of
50, and the ordering is only coarsely newest-first (`33,32,31,30,28,29,27,…`), so a few
CI reruns reach it and the row's position cannot be relied on — which this workflow's own
comment already disclaimed. That comment ALSO claimed order-independence flatly; false
once the page clamps, so it now says what actually holds and what saves us.

The mitigation is conservative rather than complete: if page 1 shows no race, page 2 is
read, and any rows there — or an unreadable page 2 — count as "assume raced" and repair
to `pending`. Uncertainty resolves to a stall a reviewer can clear, never to leaving
green. Real paging of both reads, including the high-water mark, is #763.

A THIRD empty shape turned up while modelling it: `/statuses/{sha}` past the end returns
`[]`, where `/commits/{sha}/status` returns `{"statuses": null}` and the timeline returns
bare `null`. Three endpoints, three shapes, one server. The code tolerates both here
because guessing per endpoint has now been wrong twice.

MY OWN COVERAGE GAP, found by mutation rather than by reading: inverting the
unreadable-history-page-2 branch reddened NOTHING. Now tested both ways. Same class as
the two untested refuse branches the review flagged, which are also covered now.

FOUR CLAIMS RETIRED, all of the shape this issue is about — text that reads as checked
and is not:

* "18 tests fail" for the corrected-double mutation is 21 now, because rounds 3-4 added
  three fence-dependent tests. Broke a number while documenting broken numbers. Both
  citations now give the range and lead with the invariant.
* "measured: 4, 2, 9, 5, 10" first-page timeline events are 7, 5, 9, 6, 10 today.
  Timelines grow; the figures are gone and the invariant stated instead — a PR is created
  by a push, and a push is an event, so page 1 is never empty.
* The strict test's docstring said "RAW TEXT" while the test reads parsed `run:` scalars,
  with a dead `raw =` assignment left behind (a new ruff F841).
* `len(bodies) >= 3` had zero slack: deleting the optional jq-preflight step reddened it
  with a message asserting the classifier had not been examined, which was untrue. The
  length assertion already carries the property, so the count only needs to be non-empty.

Also: dead `_workflow_expression_fields_text` removed; assertion C's regex now catches
`function foo {` as well as `foo() {`; the page-2 refusal says a human verdict clears it.

Verification: 460 green. Five more mutations as intended — deleting the history page-2
check (red), accepting an unreadable one (red, after the coverage gap was closed),
accepting a garbage page 2 in read_existing_verdict (red), hiding the marker write behind
`function mk {` (red), and the earlier twenty-one still hold. Live re-probe on this body
follows.

Refs: #751
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:42:58 +02:00
timothyandClaude Opus 5 951dae26a9 fix(751): the truncation guard I added was DEAD CODE — the page cap is 50, not 100
Third review round, cut short by a transport hang after ~11h, but it had already found
the thing that mattered: the guard added last round could never fire.

`read_existing_verdict` asks for `limit=100` and refused when the page came back with
100 rows. This instance caps `limit` at the server-wide `MAX_RESPONSE_ITEMS`, MEASURED
AT 50 — `/issues?limit=100` returns 50 items. A response can therefore never carry 100
rows, so the comparison was unreachable and the hole it was written for was still open.

The sting is that the repo already knew. `scripts/pr-changed-files.sh`, two test files
and `ci.script-tests-job` all document that Gitea caps `limit` at `MAX_RESPONSE_ITEMS`
(50 in the PR #619 measurement). The review found it by grepping this codebase, not
upstream. Writing a guard against a constant the repo had already measured as wrong is
the same failure as the unfaithful test double two rounds ago: a number believed rather
than checked.

So this is now the THIRD guard for one hole, and the first two were both no-ops:

  1. `.statuses | length` vs `.total_count` — `total_count` is the count for the PAGE
     RETURNED, not the commit (`?limit=1` on a 6-context head gives
     `len=1, total_count=1`). Equal by construction.
  2. "refuse when the page is full at 100" — dead code, as above.
  3. Ask the server. Completeness is needed ONLY to justify "no verdict exists on this
     head", so when the row is absent from page 1 the job reads PAGE 2, and refuses if
     it carries anything. Cap-independent: no reconfiguration re-breaks it, and nothing
     is hardcoded that a measurement could contradict.

Measured to make sure page 2 is real rather than assumed: `?limit=3&page=2` on
3aed43c6 returns three further rows, and `page=9` returns the same `statuses: null`
terminator the timeline uses.

The probe is skipped when the row IS on page 1, because there is nothing to learn — the
combined endpoint returns the latest status per CONTEXT, so a context cannot recur on a
later page.

TEST-DOUBLE FIDELITY, again the fiddly part. The stub now honours `page`, and that guard
had to go BEFORE the read-counting modes: `appears-on-read:N` counts how many times the
job has LOOKED at the status, and the completeness probe is part of the same look, not a
further one. Letting it increment those counters shifted "the verdict appears on read N"
by one and broke three mid-run-race tests — a false red that would have been easy to
"fix" by adjusting the expected counts, which would have quietly destroyed what those
three tests measure.

Verification: 455 green. Four more mutations, each as intended — deleting the probe
(red), accepting a non-empty page 2 (red), refusing even on an EMPTY page 2 (red, the
deadlock control), and reverting the twin `statuses: null` gate (48 red). Twenty-one
mutations across four rounds.

Refs: #751
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:22:50 +02:00
timothyandClaude Opus 5 46ec532745 fix(751): re-review round — a truncation hole, and the tests that closed findings needed closing
Cross-family re-review of the previous fix commit. It did NOT pass, and it was right
not to: the round that fixed the reviewers' findings introduced two of its own, both in
the tests written to close them. That is this file's recurring shape, and it is the
reason the fix commit gets re-reviewed rather than the initial diff only.

TRUNCATION (High). `read_existing_verdict` asks for 100 statuses and never checked
whether the page was full. If a head ever carried more contexts than that, an existing
`review-verdict/h10` could fall off page 1, the job would conclude no verdict exists,
and it could post an exemption `success` over a human `failure` — the worst thing this
gate can do. Six contexts exist today, so this guards a future shape, not a live bug.

BUT THE PROPOSED GUARD WAS A NO-OP, and measuring is what showed it. The review asked
for `.statuses | length` compared against `.total_count`. On this instance `total_count`
is the count for the PAGE RETURNED, not for the commit: on 3aed43c6 (6 contexts),
`?limit=1` gives `len=1, total_count=1` and `?limit=3` gives `len=3, total_count=3`.
The two are equal by construction, so that check would have read as a completeness
proof while proving nothing — and it would have been the second guard in this file to
look like a check and not be one. What IS observable is a page at the requested limit,
which means "maybe more", so that is now treated as unreadable: post nothing, leave the
required check absent. The stub mirrors the per-page `total_count` deliberately, so the
new test cannot pass for the wrong reason either.

THE TESTS THAT CLOSED THE LAST ROUND'S FINDINGS:

* The behavioural guard test — added to answer "a bare `exit 1` substring is satisfiable
  by dead code" — extracted the two marker lines BY TEXT and ran them alone. That
  passes even if the write is moved into a function nobody calls: the extractor finds
  the text, runs it at top level, the marker appears, and the test reports the guard
  proven while production writes no marker. It now executes the classify body's real
  PREFIX down to and including the write, which reproduces the production control flow
  instead of a reconstruction of it. Mutation: move the write into an uncalled function
  -> RED (it previously passed).
* The anti-vacuity check — added to replace an over-broad assertion — hand-counted
  `run:` keys with a regex that only matched an indented `run:` starting `|` or `>`. It
  false-redded legal spellings (`- run: |`, a single-line `run: echo ok`) and could
  count a `run: |` sitting inside a heredoc. Hand-parsing YAML to validate a YAML parse
  is the wrong shape: it adds a second, worse parser whose every disagreement is a
  false alarm, and a red here blocks all merges. Now asserted on CONTENT — the walk
  reached >=3 bodies and one over 5000 chars.

FALSE RED, THIRD INSTANCE IN THIS FILE. The repo-wide expression test scanned raw file
text, so a delimiter in an inert top-level YAML comment redded the repo even though the
runner never evaluates it. It now scans PARSED scalars: PyYAML drops YAML comments,
while a `run:` body is itself a scalar and keeps its SHELL comments — which is exactly
the distinction that matters, since inside a `run:` scalar a comment is not inert.
Verified in both directions: an inert top-level comment passes, the same payload in a
run-body comment still reds.

Also: the `total_count` zero check now requires the JSON TYPE to be number — `jq -r`
renders `0` and `"0"` identically, so a text compare accepted a corrupted
`"total_count": "0"` as "no statuses".

Verification: 455 green. Six further mutations, each landing as intended — the uncalled
function (red), an inert YAML comment (PASSES, no false red), the same payload in a
run-body comment (red), accepting a full status page (red), comparing total_count as
text (red), and breaking the YAML walk's job key (red). Seventeen mutations across the
three rounds.

Re-probe of both live controls follows on this body; the previous probe evidence was
taken before this commit and no longer describes what would merge.

Refs: #751
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:22:50 +02:00
timothyandClaude Opus 5 edd8d3d9c9 fix(751): review round — the twin null-shape, a governance self-exemption, and four over-claims
Two independent cold reviews (a cross-family GPT-5.6 pass and an isolated Opus pass).
Neither found a path to a green `review-verdict/h10` on an unreviewed head. Both found
real defects BESIDE the fix, which is the failure mode this file keeps producing.

THE TWIN, and the reason not to trust "I fixed the two I could see". `GET
/commits/{sha}/status` returns `statuses: null` — not `[]` — for a head with no
statuses yet: `{"state":"pending","total_count":0,"statuses":null}`, measured on PR
#739's head. `read_existing_verdict` gated on `.statuses | type == "array"` and took
its `exit 1` path, posting NOTHING. Fail-closed, but the user-visible outcome is the
one this issue is about: an exempt PR with no status and, since #743, no bypass. Its
double printed `{"statuses": []}` at all three no-verdict sites, so that branch was
unreachable in the suite — the same unfaithful-double story as the timeline, one
function over. `null` is accepted only when `total_count` is 0, so a body that merely
lost its array is still refused and an existing verdict is still protected. Swept
`scripts/pr-changed-files.sh` too: `pulls/{n}/files` returns `[]`, unaffected. The
generalisable rule is that a nil Go slice serialises to `null`, so every list-shaped
field on this API is suspect and only a per-endpoint measurement settles it.

A GOVERNANCE SELF-EXEMPTION, reachable again precisely because this change works.
`DOCS_ONLY` matched `CLAUDE.md` and `AGENTS.md` — the documents that DEFINE the
completion protocol, the merge-consent convention and the H10 rule. Driving the real
classify body with a lone `CLAUDE.md` change produced `review-verdict/h10=success`.
Protecting `.claude/` while the file specifying what it enforces stayed exemptible is
the same self-exemption the header rules out, one directory over. Both added to
PROTECTED; `README.md` deliberately not (ordinary prose, no enforcement).

FOUR OVER-CLAIMS, corrected rather than defended:

* The repo-wide expression test does NOT catch "any payload that cannot evaluate".
  It checks the HEAD TOKEN of each dotted path. `${{ github.ref == }}` and
  `${{ …head.sha + }}` pass; so does a renamed output, since tokens after the first
  are skipped by design. Claim corrected in the docstring, `docs/ci-cd.md` and the
  record. The test is kept permissive on purpose: a red here blocks every merge.
* The strict test's anti-vacuity half banned expressions ANYWHERE outside
  `with:`/`env:`, so the standard `if: ${{ always() }}` spelling and even a delimiter
  in an inert top-level comment went red — a guard more dangerous than its target.
  Replaced with the honest property: the YAML walk saw every `run:` body it declares.
* The `if:` assertion demanded the bare `always()` exactly; now normalised, since the
  wrapped form is identical to the runner.
* `exit 1` was matched anywhere in the guard body, so an unreachable
  `if false; then exit 1; fi` satisfied it while the real branch said `exit 0`. Now
  required INSIDE the missing-marker branch — and the new behavioural test settles it
  properly by EXECUTING the guard body both ways.
* The record asserted a repo-wide obligation to guard consequential steps. It is not
  repo-wide: `docker-build.yml`'s `test`/`migrations` are also required contexts and a
  dropped step there is fail-OPEN (green having done no work), strictly worse than
  here. Scoped to this file and tracked as #756 rather than asserted as done.

Also: comments in both files still said it was unestablished whether a later step runs
after a drop — runs 1863/1866 established it, so they now record the measurement; a
cited test name that never existed; `kind` leaked to global scope; a mangled comment
wrap; and an already-false "one event on page 1".

Hardening of my own: `null` now counts as exhaustion only from page 2 ON. Every real
PR's first page carries events (4, 2, 9, 5, 10 across #752/#753/#749/#739/#717), so a
terminator on page 1 means no page was ever read, and certifying "no retarget" from a
response we cannot explain is the one thing the fence exists to refuse. Narrows rather
than closes it: a wrong `null` on page 3 still reads as exhaustion.

Verification: 137 in this file / 452 total green; ELEVEN mutations each red —
reintroducing the defect, deleting the guard, deleting the marker write, removing
`if: always()`, `exit 1`→`exit 0`, a delimiter in the guard body, the fence gate (20
red), the TWIN gate (70 red), dropping the governance paths, accepting a null first
page, and diverging the marker path between the two steps.

Refs: #751
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:22:50 +02:00
timothyandClaude Opus 5 40b3747434 fix(751): the fence never trusted its count — a page past the end is null, not []
The scratch-base probe found a SECOND, independent reason `review-verdict/h10` was
never posted automatically. Fixing the dropped step alone would NOT have restored the
exemptions.

`count_retargets` pages `/issues/{n}/timeline` and trusts its count only on a
validated empty page, gated on `type == "array"`. But a page past the end of that
endpoint is the JSON value `null` — measured at Gitea 1.27.1 on PR #752, four bytes —
so the real terminator read as UNREADABLE. The walk never reached a validated empty
page, `rt_ok` was never `yes` for ANY pull request, and the fence therefore withheld
EVERY exemption `success`. Renovate and docs-only PRs got no status at all: the same
user-visible outcome as the dropped step, by a completely unrelated route.

The instance is not consistent between endpoints — `/issues/{n}/comments` returns `[]`
when empty — so both shapes terminate the walk now, and the regression test is
parameterised over both. The type is read as a VALUE (`case` over `jq -r 'type'`)
rather than through `jq -e`, whose exit-status semantics already bit this workflow at
jq 1.6 (#647).

TWO REASONS THIS LOOKED DELIBERATE RATHER THAN BROKEN, both worth generalising:

* It had never run. This fence shipped in 8f6d4f443 — the same commit whose prose
  comment stopped the classify step executing at all. Merging a guard and first
  executing it are different events, and only the second tells you anything.
* The test double asserted the wrong shape while claiming to be measured. Its comment
  read "Real shapes, measured on this instance and deliberately mirrored" and it
  printed `[]` past the end, so the `array`-only gate was never exercised by the suite
  either. Correcting the double and restoring the old gate turns 18 TESTS RED — every
  one of them had been green for the wrong reason. A fidelity claim in a double is an
  assertion and it decays like any other.

The new test asserts the POSTED STATUS, not the log: on the real probe run the log
said `Decision: state=success` and the job still posted nothing, so the decision and
the write are separate events and only the write is what a merge reads.

Also here, both found while editing this code:

* `ci.verdict-write-retarget-fence` stated this as a narrow residual ("a timeline over
  the 20-page cap can never be exempted") when the behaviour was universal. Corrected
  in place rather than left as a checked-looking claim that talks the next reader out
  of verifying.
* The workflow cited `ci.paged-endpoint-completeness`, a key that has never existed as
  a record anywhere. Repointed at the record that actually owns this walk.

Marker hardening from the probe: `RUNNER_TEMP` is `/tmp` on this runner, not a private
per-job directory, so the start marker is now keyed on the run id and attempt. The
lane starts a container per job today, which makes a fixed name fresh in practice, but
that is a property of the lane and a stale marker would make the guard PASS on a run
whose step was dropped — the exact silent pass it exists to remove.

Verification: 446 passed; M7 (revert only the type gate, keep the corrected double) →
18 red. Probe evidence in the issue.

Refs: #751
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:22:50 +02:00
timothyandClaude Opus 5 2bdb6c44e4 fix(751): a stray expression delimiter in a COMMENT killed the verdict gate
`review-verdict.yml`'s classify step stopped executing on 2026-08-03 and the job
reported `success` anyway, so `review-verdict/h10` — the branch-protection-required
status — was posted by nothing but a human hand for three days, and both exemption
classes (Renovate-manifest, docs-only) silently stopped working.

The cause is one token in prose. The #706 note explaining why a concurrency group
does not work here quoted a `concurrency:` snippet containing a PR-number expression
as an ILLUSTRATION, inside a shell comment. A shell comment is not inert there: the
runner scans the whole `run:` scalar for the expression opener before bash sees it,
and one occurrence makes it rewrite the ENTIRE body into a single `format(...)` call.
That rewrite is all-or-nothing, so a payload that does not parse — `pr number` does
not — fails the interpolation of the whole scalar, and the runner then DROPS THE STEP
AND CONCLUDES THE JOB GREEN. The prose documenting a fix disabled the fix.

`git blame`/`git log -S` put the line in 8f6d4f443 (2026-08-03), which dates the
outage precisely rather than "present in run 1832, not bisected further back".

Three changes, deliberately different in kind:

* The prose no longer writes the delimiters. It names the expression instead.
* The classifier writes a start marker and a new `if: always()` step FAILS THE JOB
  when it is missing. This is the half that generalises: the delimiter was one bug in
  one comment, but a dropped step concluding `success` is what made it cost three
  days behind a green tick. It asserts execution STARTED, never completed — the
  classifier has several legitimate `exit 0` abstention paths.
* Two static guards in scripts/tests/test_pr_changed_files.py: no expression
  delimiter in ANY `run:` body of the gate file (absolute, because a dropped step
  here is a dead merge gate and its bodies are ~700 lines of prose), and repo-wide,
  every expression payload must name a real context or function (permissive, because
  other workflows interpolate into `run:` legitimately). The second catches the class
  — a payload that cannot evaluate, wherever it appears.

Note every pre-existing workflow-shape test reads `_code_lines()`, which strips
comments. That is correct for what it was for, but it encodes the assumption this bug
falsifies: inside a `run:` scalar a comment CAN change behaviour. The new strict test
reads the raw scalar for that reason and must never adopt `_code_lines`.

Blast radius, audited: PR #739 (docs-only) merged 2026-08-05 with ZERO commit
statuses on its head, and got in only because admin force-merge was still enabled.
#743 disabled that on 2026-08-06, so the workaround that absorbed this bug is gone —
the next docs-only or Renovate-manifest PR would be permanently stuck. The two
Renovate PRs in the window escaped by timing, merging minutes before the bad commit.
Non-exempt PRs were unaffected throughout: humans posted their verdicts by hand.

Verification: all six mutations red, restored tree green — reintroducing the exact
defect (strict + general tests), deleting the guard step, deleting only the marker
write, weakening `if: always()`, turning the guard's `exit 1` into `exit 0`, and
putting a delimiter in the guard's own body. 129 passed on the fixed tree.

Still to establish, and NOT claimed here: that the runner executes a LATER step after
dropping an earlier one. The #751 evidence cannot say — the classifier was the job's
last step, so there was never a subsequent step to observe. A scratch-base probe with
a negative control answers it next; if the runner drops the rest of the steps too,
this guard is inert and the body has to move into `scripts/`.

Refs #751

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:22:50 +02:00
timothyandtimothy 9881d1ff81 fix(754,757): declare graphicsElementIds + padToNearestMinute, and pin every MCP tool to its OpenAPI contract (#760)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m51s
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) Successful in 4m37s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-06 20:42:37 +00:00
timothy 3aed43c6de fix(743): make review-verdict/h10 unskippable — disable direct push to main + admin force-merge; fix(719) tag-only push (#749)
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 & test (.NET) (push) Successful in 35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 49s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m20s
2026-08-05 22:58:16 +00:00
timothyandClaude Opus 5 f822e4737c docs(743): label the second attested claim, close the survivor list, state the rule in CLAUDE.md
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 34s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 40s
Review verdict / Set review-verdict status (pull_request_target) Successful in 8s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 24s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m52s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
review-verdict/h10 Review-verdict: MERGEABLE @ f822e47 (base: main)
Round 3 returned MERGEABLE with three LOW documentation findings. Batched
before posting the verdict, since a new sha voids both the CI run and the
verdict.

- `ci-cd.md` labelled the unprobed half of the `enable_push` bullet but stated
  the `block_admin_merge_override` counterfactual flatly one bullet below —
  the same measured-vs-attested flattening round 2 fixed, one site over. Now
  labelled, with why it was not probed (verifying it means merging an
  unreviewed PR).
- `release.verdict-status-check` said "what survives is the forgery list
  above". That record's job is enumerating survivors, so an unqualified "what
  survives is X" reads as exhaustive — and it omitted the admin residual, which
  is a SKIP route rather than a forgery one. Added.
- `CLAUDE.md` never learned the rule. It is the always-read surface, and it
  still framed a direct `git push origin main` as a live path while describing
  a docs-only *push* exemption for a push the server now refuses. My corpus
  sweep covered `docs/` and missed the file that carries the docs-update rule.

Note on what remains unverified rather than closed: neither direction of
`block_admin_merge_override` was measured, and whether Gitea treats an ABSENT
required context as blocking (versus satisfied) is asserted by our docs but
not proven — the combined status on this PR reads `success` with
`review-verdict/h10` absent. Both belong to #747's re-verification sweep.

Verification: 441/441 script tests; decisions-validate OK.

refs #743

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:35:41 +02:00
timothyandClaude Opus 5 6af65ba5c5 fix(743): re-tense the third stale site, and pin the two surviving mutants
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 35s
PR Gates / Docs update reminder (pull_request) Successful in 42s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
PR Gates / decisions lifecycle (pull_request) Successful in 1m25s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m58s
Review verdict / Set review-verdict status (pull_request_target) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m52s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Round 2 of review. One blocking finding, and it is the same defect class as
round 1's: a present-tense claim that this PR falsified.

`release.verdict-status-check` — the record ABOUT the h10 status check — still
said "direct pushes to `main` are server-side permitted, so the gate can be
skipped without forging anything". A reader resolving that key from the catalog
would conclude the control does not exist. Round 1 corrected `ci-cd.md` and
`ci.actions-credential-scoping` and I stopped at the two sites I had edited,
instead of sweeping the corpus by SUBJECT. Swept properly this time
(`server-side permitted`, `bypassable`, `without forging`, `push whitelist`,
`enable_push`): this was the only remaining stale site.

Test gaps the reviewer found by mutation testing, now closed. Both mutants
SURVIVED the suite as shipped — the round-1 fixes were correct but unpinned:

- dropping `|| [ -n "${_h11_local_ref:-}" ]` → an unterminated final line is
  dropped. Two directions, and the dangerous one is not the obvious one: a
  dropped *branch* line leaves only tag refs and grants the exemption to a push
  containing a branch. Both pinned.
- dropping `[ -t 0 ] ||` → the hook hangs forever on an interactive run. Pinned
  with a real pty and an explicit timeout, so a regression fails cleanly rather
  than hanging a CI job. Verified the mutant is killed by exactly that test
  (and that it dies via the timeout, 32s).

Also from review, non-blocking:

- `ci-cd.md:951` cited `enable_push: false` alone as what closed #743 — the
  precise thing the new record says never to do, since the force-merge route
  also skipped the gate with no forgery. Now cites both fields.
- `ci-cd.md` flattened measured and source-attested into one 403: only the
  contents API was probed; the web editor/upload/apply-patch paths share the
  predicate but were not. Separated.
- `format-as-you-touch-rebase` still said "the documented sequence" and
  "always" for the release-cut behind-ness. `docs/ci-cd.md` documents the tag
  step, not the release-notes-PR flow, and the frequency is attested by one
  observed cut. Attributed to #719 instead.
- Documented the operator recovery path. `block_admin_merge_override: true`
  removes the `force_merge` escape that used to unstick a wrongly-red required
  context — that escape WAS the bypass, so it is gone by design, and the
  recovery (fix the status; last resort PATCH the field, merge, set it back)
  needed to be written down rather than left implicit in a residual.

Verification: 441/441 script tests; decisions-validate OK; PyYAML parses all
193 records; both mutants confirmed killed and the hook restored byte-identical.

fixes #719

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:10:45 +02:00
timothyandClaude Opus 5 6d80343320 fix(743): close the admin force-merge bypass; the push half alone was not enough
Independent review found the record repeated on the merge path exactly the
mistake it had just diagnosed on the push path.

The push argument was: a whitelist naming `timothy` closes nothing, because
`timothy` is the identity every credential already holds. The merge path had
the identical shape and went unchecked — `block_admin_merge_override` defaults
to `false`, so `CanBypassBranchProtection` returns true for a repo admin and
`POST /pulls/{n}/merge` with `force_merge: true` merges straight past a missing
or red `review-verdict/h10`. One API call, no forgery, no PATCH — cheaper than
the push route this change had just removed.

So `enable_push: false` alone did NOT make the gate load-bearing, which is
what the record's headline sentence claimed. `main` now carries both fields;
they are one control and neither is citable alone.

An admin-shaped control that exempts the only admin exempts everybody.

Other review findings addressed:

- H11's owning record (`release.format-as-you-touch-rebase`) now documents the
  #719 tag-only carve-out. It is a narrowing of an existing convention, so it
  amends that record rather than adding a new one — including the two details
  that are easy to regress (the .husky/pre-push forwarding, without which the
  exemption is dead code the unit tests still pass over; and the at-least-one-
  ref guard against vacuous exemption).
- The record now states which write surfaces were enumerated and how each was
  established — contents-API refusal is MEASURED here (403 `user cannot commit
  to repo`), apply-patch/revert/cherry-pick are source-attested only. The
  admin force-merge bypass is likewise marked source-attested, not probed:
  probing it means merging an unreviewed PR.
- prepush-rebase-check.sh: process a final ref line with no trailing newline
  (previously dropped, which silently reinstated the #719 block), and skip the
  stdin read on a TTY so an interactive run does not hang.
- Corrected a citation the review caught: docs/ci-cd.md documents the tag step,
  not a release-notes-PR flow. Cite #719 for the observed flow instead.

Also fixed a frontmatter break this round introduced: a `: ` inside the
unquoted `rule:` scalar. PyYAML rejected it while the dependency-free reader
accepted it, so only `scripts/tests` caught it.

Verification: 438/438 script tests pass; decisions-validate OK; PyYAML parses
all three touched records.

refs #743 #719

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:52:28 +02:00
timothyandClaude Opus 5 b91707b707 fix(719): exempt tag-only pushes from the H11 branch-freshness check
H11 (.claude/hooks/prepush-rebase-check.sh) refuses to push a branch that is
behind origin/main. It fired on tag-only pushes too, breaking every release
cut: docs/ci-cd.md's "Cutting a release" flow lands a release-notes commit
via PR and then tags that merge commit, so the local branch is always one
commit behind origin/main at tag time. A tag push cannot revert anyone's
merged work, which is the failure H11 exists to prevent, so skip the
freshness check when every ref being pushed is under refs/tags/.

.husky/pre-push previously consumed pre-push's stdin ref lines and forwarded
them only to prepush-donewhen.sh; prepush-rebase-check.sh got none. Forward
the captured $_prepush_refs to it too, or the new logic is dead.

Guard against the vacuous-truth case explicitly required by #719: "all
pushed refs are tags" is trivially true over zero ref lines (manual run,
forgotten forwarding), which would silently disable H11 for every push.
Require at least one parsed ref line before granting the exemption.

Adds scripts/tests/test_prepush_rebase_check_tag_exemption.py using real
local git repos (bare origin + a work tree pushed one commit behind it) to
exercise git fetch/merge-base/rev-list against a genuinely-moved origin:
tag-only allowed, branch-only still blocked, mixed branch+tag still
blocked, and zero ref lines still blocked (the vacuous-truth guard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:33:10 +02:00
timothyandClaude Opus 5 691a7acc77 fix(743): disable direct pushes to main so review-verdict/h10 is on the only path to main
Gitea evaluates `status_check_contexts` when it MERGES a PR. A direct
`git push origin HEAD:main` never consults them, so the whole h10 gate was
skippable with no forgery — strictly cheaper than every route enumerated in
#697. `main` now carries `enable_push: false`.

Measured on this instance (Gitea 1.27.1) against a throwaway `probe-743-*`
rule rather than against `main`:

  enable_push: false                      -> push by timothy (site admin)
                                             REFUSED, pre-receive hook declined
  enable_push_whitelist + ["timothy"]     -> identical push SUCCEEDED

That second line is why this is a DISABLE and not a whitelist: #743 offered the
two as interchangeable, but the only write accounts here are `timothy` (site
admin) and `renovate`, and every credential in the threat model — agent
sessions, PATs, the injected GITEA_TOKEN — acts as `timothy`. A whitelist
naming `timothy` would have ticked the box and closed nothing.

Then demonstrated on `main` itself, per the issue's Done-when: a direct push
was refused, and a tag-only push from the same worktree succeeded (tags are
governed by `tag_protections`, which is empty). The release cut is unaffected.

What this closes: the write-only credential routes — the injected GITEA_TOKEN,
RENOVATE_TOKEN, any non-admin collaborator PAT. What it does NOT close: an
admin credential can PATCH the protection off, push, and restore it. Recorded
as an accepted residual rather than implied to be covered.

Also corrects two claims the probe contradicted, and one that the mid-session
Gitea upgrade (1.25.4 -> 1.27.1) invalidated:

- ci-cd.md and ci.actions-credential-scoping both said "a push whitelist would
  close more of this class than the 1.26 upgrade". The whitelist form closes
  nothing here; corrected in place.
- ci.actions-credential-scoping's rule said "do NOT add a `permissions:` key
  while this instance is below Gitea 1.26.0". That precondition no longer
  holds at 1.27.1, so the directive now misleads. Corrected — while noting the
  consequence is still UNVERIFIED: `/api/v1/settings/actions` 404s at 1.27.1,
  so whether `permissions:` binds here was not probed. The upgrade alone is
  not evidence the constraint works.
- That record's 1.25.4 measurements are now dated, not current. Flagged as
  such rather than silently re-pinned to a version they were never taken on.

#743's fourth box (docker-build.yml `persist-credentials: false`) is decided
in the record and deliberately not done here: two of its checkout steps run
`git fetch ... || true` feeding the changed-file skip logic, so a credential
regression would be silent rather than loud. Drop the `|| true` masking first.

fixes #743

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:31:45 +02:00
timothy 08e95f9ec1 fix(697): scope CI's registry credential so head-resolved workflows cannot forge review-verdict/h10 (#745)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 47s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
Closes the credential half of #697. `REGISTRY_PASSWORD` was the admin account's
basic auth, handed to head-resolved PR code by docker-build.yml; it is now a PAT
scoped `write:package` + `read:repository`.

Verified on Gitea 1.25.4: registry push SUCCEEDED, status GET 200, status POST
REFUSED 403 (required=[write:repository]).

Does NOT close the class. Surviving routes, all recorded: RENOVATE_TOKEN (#742),
the injected GITEA_TOKEN (server-management#714), a collaborator's own token, the
`v*` tag push, and — making all of them unnecessary — direct pushes to `main`,
which are server-side permitted (#743). ci-image.yml's trigger filter was
attempted, reverted, and split out as #744.

Three cold adversarial review rounds: BLOCKED, BLOCKED, BLOCKED, then MERGEABLE.

fixes #697
2026-08-05 19:56:52 +00:00
timothyandClaude Opus 5 b91939e5c4 fix(697): correct the overclaims three adversarial review rounds found
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 22s
PR Gates / Docs update reminder (pull_request) Successful in 26s
PR Gates / decisions lifecycle (pull_request) Successful in 41s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 56s
review-verdict/h10 Review-verdict: MERGEABLE @ b91939e (base: main)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 59s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m56s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m3s
Review verdict / Set review-verdict status (pull_request_target) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round 1 BLOCKED (1 Blocker, 4 High, 3 Medium, 2 Low); round 2 BLOCKED on the fix
(1 Blocker, 2 High, 4 Medium, 2 Low); round 3 BLOCKED on one Medium. Every
finding re-verified against the live instance before acting.

ROUND 2 — the blocker was self-inflicted and the local gate could not see it.
Adding `branches: [main]` to ci-image.yml re-points `ci-image-pin`'s `expected`
at the editing commit, staling all five `container:` pins and failing that
BLOCKING job — for a change altering zero bytes of the toolchain image.
Reproduced: expected=ed9dd6254 vs pins=32747a0. Reverted here (the commit was
amended, so no commit on the branch touches that path) and filed as #744.

That edit had also FALSIFIED its own justification: branch publishing IS
load-bearing — docs/ci-cd.md documents the rebase-recovery flow as "let
ci-image.yml publish :<short sha>, then bump the pin", which is how you satisfy
ci-image-pin from inside a PR. Reverting also keeps three trigger descriptions
true (ci-cd.md:1043, the recovery flow, pr-checks.yml's escape-hatch comment).

Also fixed:
  - gate-trigger-base-resolved.md was the file round 1's fix did not touch, and
    still said "no workflow route retains human provenance" — false, since a
    PR-added workflow can reference RENOVATE_TOKEN. Its `rule:` also kept the
    race framing, and `rule:` is what the catalog and MemPalace mirror.
  - `mechanics:` claimed "independent review confirmed no CI consumption breaks".
    It confirmed no such thing. Round 3 then caught the REPLACEMENT sentence
    making the same class of error: only the `container:` pull is exercised by a
    PR, because `build` carries `if: github.event_name != 'pull_request'` and
    cache-to/cache-from live only there. Those and the base-image pull first run
    on the post-merge push to main — a wrong inference reddens main, not the PR.
  - A fourth surviving route was unnamed: docker-build.yml publishes :prod from a
    `v*` tag push and a tag may point at any commit (tag protections are empty).
    "three surviving routes" became "at least these" — a count reads as complete.
  - Unmarked inferences, a "three later sections" that undercounted four, a
    dangling "the two items below", and a #744 rationale that stated the pin toll
    without its documented remedy.

Local gate: 432 script tests pass; `decisions_validate.py --base origin/main
--head HEAD` and `build_decisions_catalog.py --check` both exit 0; ci-image-pin
recomputed by hand and matching the pinned commit. The record is 62 prose lines
against a 60-line ceiling that is a `::warning::` by design (#520) — the blocking
constraint is the 2-25% minority band, currently 10.8%.

Refs #697, #742, #743, #744.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:23:43 +02:00
timothyandClaude Opus 5 e298bb291e fix(697): scope CI's registry credential so head-resolved workflows cannot forge review-verdict/h10
`REGISTRY_USER`/`REGISTRY_PASSWORD` were the ADMIN account's basic auth, and
`docker-build.yml` triggers on `pull_request` — head-resolved — so a PR's own
code was handed instance-admin credentials. Basic auth carries no scope, so the
same secret that pushes an image administers every repo on the instance and can
POST `review-verdict/h10`, the required context that makes merge-consent derived
rather than assertable. Refs #697.

Fixed at the credential, not the triggers: patching triggers enumerates
instances of "a ref-resolved workflow obtains status-capable credentials", and
adding a new workflow file is itself a route. `REGISTRY_PASSWORD` is now a PAT
scoped `write:package` + `read:repository`.

Verified on Gitea 1.25.4, not inferred:
  - registry push of a probe tag SUCCEEDED (cleaned up, confirmed 404)
  - GET /commits/{sha}/status SUCCEEDED (what ci-detect-already-validated.sh does)
  - POST /statuses/{sha} REFUSED, HTTP 403:
    required=[write:repository], token scope=write:package,read:repository

Scope of what this closes, stated without overclaim. It closes the instance-wide
admin escalation and that credential's durable forgery route — durable because a
status POSTed with a USER credential carries a real `creator` and is inherited as
a human verdict, while an Actions job's carries `creator: null` and is re-derived.
It does NOT close the class. Three things survive it:

  - `RENOVATE_TOKEN` is a `write:repository` PAT of a real bot account in the
    SAME secret store, so it also posts with non-null `creator`. It cannot be
    scoped down (Renovate needs repo write), and secrets are a per-repo store
    that any PR-added workflow can reference. Closing this needs the provenance
    check tightened to an allow-list of approved reviewers.
  - Every job still receives a write-capable `GITEA_TOKEN`. `permissions:` YAML
    is a no-op before Gitea 1.26.0 and no `app.ini` lever exists at any version;
    only >=1.26 with the Actions default set to Restricted binds it.
    Tracked in server-management#714.
  - Branch protection binds the context NAME, not its issuer, so any write-scoped
    personal token forges the status with genuine human provenance. Unfixable
    in-repo. `h10` is a process guard, not a security boundary against push access.

Auditing the secret STORE rather than the workflow set also surfaced
`SERVERMGMT_DEPLOY_KEY`, still present though the `bump-prod-compose` job that
used it was removed in 1b5efd7b9 — an SSH deploy key to another repo, obtainable
by any PR-added workflow, with no remaining benefit.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:34:34 +02:00
timothy d7647b6104 fix(685): AddItemsDialog resolves by search instead of windowing the whole media-library type (#741)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m26s
2026-08-05 18:01:59 +00:00
timothy 7be42654fe fix(685): suppress both empty-states on error; name the addable-kind derivation
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 18s
Review verdict / Set review-verdict status (pull_request_target) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m21s
review-verdict/h10 Review-verdict: MERGEABLE @ 7be4265 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 2m7s
PR Gates / Script tests (pytest) (pull_request) Successful in 2m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-4 verification returned MERGEABLE with no new defects and no
BLOCKER/HIGH/MEDIUM. These are its remaining LOW and nits.

- "No results — try a search above." asserts a search that COMPLETED and found
  nothing, so it was false beside a failed request. Both empty-state messages
  are now suppressed on error and the role="alert" banner is the whole message,
  as that test's comment already claimed. Pinned positively and negatively so a
  refactor cannot satisfy the assertion by rendering nothing at all.

- The (typeof ADDABLE_TYPE_LIST)[number] derivation is now the named
  AddableKind, spelled once instead of twice: a third ingress into the searched
  kinds is most likely to be written by copying one of the existing two, and the
  "one list, all ingresses" property should be visible at a glance rather than
  reassembled.

- The §3b lesson cited two measured test counts, which go stale against the very
  suite they describe — a count taken before the helper had unit tests no longer
  holds now that it does. Scoped the observation to the sha it was measured on
  and replaced the counts with the invariant they were evidence for: every gate
  needs at least one test that reddens when that gate ALONE is removed.

- Documented why the error banner stays conditionally mounted while the hint's
  live region does not: role="alert" is the one live-region role screen readers
  reliably announce on insertion, so the two are correct for opposite reasons.
  The review flagged the divergence as unexplained, not as wrong.

refs #740
2026-08-05 19:40:07 +02:00
timothy d7725c274c fix(685): close the second ingress; stop the error path claiming a short query
Verification round returned MERGEABLE with all three blocking findings resolved
by measurement. These are its four remaining items.

- DEFAULT_SEARCH_KINDS was the SECOND ingress into the searched kinds and was
  not derived, so the previous commit's "enforced by the type system" claim held
  for one of two paths. A non-addable kind there typechecked clean and would
  have overstated the hint with every one of its rows dropped — the exact defect
  the derivation exists to prevent. Now derived; verified by mutation that
  adding 'Collection' to it is a compile error.

- The error path fell into the min-query guidance branch, so a valid 2-character
  query that got a 500 told the user to type at least 2 characters. That branch
  conflated "nothing searched yet" with "the last search failed". Newly
  introduced by the previous commit's error-path reset; now gated on !error and
  pinned by a test.

- The aria-live region was mounted conditionally, creating the region and its
  text in one commit — which most screen readers do not announce. It is now
  mounted unconditionally with the condition inside.

- The §3b lesson mis-stated where the duplicate gate lived: it was inside
  runSearch, the genuine single sink, NOT at one of the callers — so the rule as
  written ("put the gate in the single sink, not at each caller") described the
  revision that was rejected. Reworded to the actual lesson: the gate's home is
  the shared helper, and "it's the single sink" is not evidence it is the only
  guard. That misreading is why #685 got this wrong twice.

Declined again, with reasons: the NaN pageSize edge (faithful to the sibling
helper), the clamp test's unpinned lower bound (same), and the registry's
disclosed same-identity substitution gap.

refs #740
2026-08-05 19:20:26 +02:00
timothy b6bf94f129 fix(685): test the helper's bound; delete the masking duplicate gate
Independent review round 2 returned BLOCKED on two findings, both correct.

- The helper's bound was dead code to the suite. searchLibraryBrowseItems had
  zero tests, so deleting its clamp OR its gate left the whole suite green —
  while the registry note claimed a caller "cannot skip the bound". That is the
  previous round's finding relocated, not removed. It now has the three tests
  its sibling searchLibraryPickerOptions already had (clamp, gate, compile),
  plus one pinning the full-row return that is its reason to exist.

- The screen kept a second copy of the min-query check, and the two masked each
  other: the 1-character boundary test passed with EITHER gate alone, so it
  pinned nothing. The screen's copy is deleted; the helper is the sole gate.
  Measured before/after: with the duplicate present, removing the helper's gate
  left that test green; with it gone, the same removal reddens it.

- §3b contradicted itself two lines apart — the parent still said "there is no
  truncation, so there is no truncation hint" above a sub-bullet mandating one.
  Reworded so a hint is permitted, required only where bulk selection makes the
  count actionable. Same correction to the 'search-bounded' definition.

- A failed search left results/totalMatches stale, rendering a confident
  "Showing 75 of 60000 matches" beside the error banner. The catch clears them.

- Results now carry a `Results for "<query>"` heading and the guidance is keyed
  to the settled query, not the live input, so rows are never shown without
  saying which search produced them. `selected` persists across queries (correct
  for a multi-select picker); the Add button's count keeps it discoverable.

- The hint sums pre-filter totalCount against post-filter rows, which is only
  correct because every filterable kind is addable. MediaKindFilter is now
  derived from ADDABLE_TYPE_LIST, making that a compile error rather than prose.

- aria-live on the hint; the #740 doc caveat no longer overstates the typeahead
  rule as a mandate this screen violates.

Declined: the NaN pageSize edge (copied faithfully from the sibling helper) and
the registry's same-identity substitution gap (already disclosed in that file).

refs #740
2026-08-05 19:08:13 +02:00
timothy 4be3f247d8 fix(685): move the picker bound into the helper; surface the per-kind cap
Independent review round 2. Verdict was MERGEABLE with no blockers; this takes
the two recommended fixes plus the structural one it listed as a follow-up.

- The bound was caller discipline, not code: getLibraryBrowseItems does not
  clamp pageSize, so the bound was only the constant this one call site chose
  to pass, and §3b is explicit that a bound a caller can exceed is not a bound.
  New searchLibraryBrowseItems in libraryBrowse.ts owns the min-query gate, the
  pageSize clamp and the titleContainsQuery compile, returning full
  LibraryBrowseItem rows plus totalCount (searchLibraryPickerOptions' {id,name}
  shape loses the mediaType that toAddItemsRequest needs). runSearch keeps one
  early return, for the spinner only, and no longer re-implements the gate.

- The min-query guidance was keyed to the LIVE input, so backspacing below the
  gate after a search wiped the rendered rows and their checkmarks while
  `selected` and the Add button still counted them. Keyed to results.length too.

- "Nothing left to hint at" was false: each kind is still capped at
  LIBRARY_PICKER_RESULTS and totalCount was never read. This is a bulk
  multi-select add, so the cap is surfaced — per-kind totalCounts are summed and
  rendered as "Showing N of M matches" once it exceeds the rendered rows. The
  registry note and the §3b bullet are corrected to stop claiming otherwise.

- Gate boundary tested at 1 character (§3b: inclusive endpoints, or a > for >=
  slip passes the whole suite).

- The guard test's deviation loop iterates an empty list now, so it gains one
  bidirectional assertion that is non-vacuous: the set carrying an `issue` field
  must equal the set classified 'deviation'.

- The §3b bullet no longer reads as a conformance certificate: AddItemsDialog
  still lacks the seqRef and useIsMountedRef guards §3b mandates. That defect is
  PRE-EXISTING, not introduced here, and is tracked in #740.

refs #740
2026-08-05 18:46:43 +02:00
timothy 28ce8c4dfe fix(685): gate AddItemsDialog on a real query instead of windowing the whole type
AddItemsDialog.runSearch was reachable with an empty query two ways — a blank
form submit, and a kind-chip click, which called it immediately — and
getLibraryBrowseItems omits a falsy `query`, so each path degraded into an
unfiltered browse of the whole media-library type (first 50 rows, per kind)
presented as the answer with nothing surfacing the truncation. All ten
ADDABLE_TYPE_LIST entries are spa-conventions §3b Class B media-library types.

The dialog is multi-select, so §3b's SearchPicker (single-select) does not fit;
it takes §3b's constraints instead:

- no request below LIBRARY_PICKER_MIN_QUERY, enforced in runSearch — the single
  sink both entry paths route through, not duplicated per caller
- typed text compiled with titleContainsQuery rather than forwarded raw (a
  second latent §3b violation here: the search index's default field does not
  match bare title words)
- each kind bounded to LIBRARY_PICKER_RESULTS
- merged.slice(0, 50) removed — it silently dropped up to 100 of 150 fetched
  rows even for a real query

Tests assert zero requests below the gate on both paths, exactly one bounded
request per kind above it (20k-row fixture), the compiled+escaped query, and
that no fetched row is dropped. Each was verified to fail with its mechanism
removed.

The pageSize registry entry moves from `deviation` to `search-bounded`. That
leaves zero deviation entries, so the anti-vacuity assertion guarding that list
is deleted deliberately, per its own instruction.

Done-when box 4 (collection-family truncation hint) has no subject: this screen
offers no collection-family type.

fixes #685
2026-08-05 18:46:43 +02:00
timothyandClaude Opus 5 e46e2cfe68 docs(skill): Dispatcharr EPG refresh ssh'd to the wrong host
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 17m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 19m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 9m55s
The refresh_epg_data command targeted 192.168.1.99, but Dispatcharr moved to
jazz (192.168.1.29) in #634 — it would fail with 'No such container'.

refs server-management#692

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:35:38 +02:00
33 changed files with 4635 additions and 145 deletions
+32
View File
@@ -12,6 +12,38 @@ set -uo pipefail
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# Tag-only push exemption (ersatztv#719): the release cut tags a commit on main while the local
# branch sits 1 commit behind origin/main, so H11 blocked EVERY release -- and its "rebase first"
# advice did not even apply, since no branch was being pushed. A tag push cannot revert anyone's
# merged work, which is the failure mode H11 exists to prevent, so skip the freshness check when
# EVERY ref being pushed is under refs/tags/. (See #719 for the observed flow.)
#
# Read pushed refs from stdin: git feeds pre-push hooks one line per ref, "<local ref> <local sha>
# <remote ref> <remote sha>" (.husky/pre-push forwards the lines it already captured). Ignore blank
# lines. VACUOUS-TRUTH GUARD: "all refs are tags" is trivially true when there are zero ref lines
# (hook run manually, stdin not forwarded, etc.) -- that would silently disable H11 for every push.
# Require at least one parsed ref line before granting the exemption; with zero lines, fall through
# to the existing branch-freshness check below (current behavior preserved).
#
# `[ -t 0 ] ||` so an interactive run does not hang waiting on a terminal: this script had no stdin
# reader before #719, and its own docs call "run by hand" a supported case. A TTY yields no ref
# lines, which is exactly the zero-line fall-through.
_h11_refs_seen=0
_h11_all_tags=1
[ -t 0 ] || while IFS=' ' read -r _h11_local_ref _h11_local_sha _h11_remote_ref _h11_remote_sha \
|| [ -n "${_h11_local_ref:-}" ]; do # `|| [ -n ... ]` also processes a final line with no trailing newline
[ -z "${_h11_local_ref:-}" ] && continue
_h11_refs_seen=1
case "${_h11_remote_ref:-}" in
refs/tags/*) ;;
*) _h11_all_tags=0 ;;
esac
_h11_local_ref=''
done
if [ "$_h11_refs_seen" = "1" ] && [ "$_h11_all_tags" = "1" ]; then
exit 0
fi
# Best-effort fetch of the latest main; offline / no network -> don't block.
git fetch origin main --quiet 2>/dev/null || exit 0
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
+1 -1
View File
@@ -363,7 +363,7 @@ docker start ersatztv
```
- **Dispatcharr caches ErsatzTV's XMLTV.** Repointing its DB rows is not enough — it keeps serving a stale EPG full of dead `ersatztv:8409` artwork URLs (breaks Kodi artwork). Force a refresh (EPG source 9):
```bash
ssh timothy@192.168.1.99 'docker exec dispatcharr python manage.py shell -c \
ssh timothy@192.168.1.29 'docker exec dispatcharr python manage.py shell -c \
"from apps.epg.tasks import refresh_epg_data; refresh_epg_data(9)"'
```
- **`/api/health` returns 401** (needs an API key). The Telegraf probe has no `response_string_match`, so ErsatzTV reads as **unhealthy in Grafana** — a false alarm, and **pre-existing**, not caused by the move. The container healthcheck uses the unauthenticated internal `/health` and is unaffected.
+215 -21
View File
@@ -122,14 +122,23 @@ jobs:
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
# EVERY consequential `run:` step in this job marks itself as its FIRST act (ersatztv#756),
# and the trailing `Assert every expected step executed` guard fails the job when one is
# missing. This is a REQUIRED context on `main`, and a step the runner drops takes the job
# GREEN having done no work — see scripts/ci-step-ran.sh for why that is fail-OPEN here while
# the same drop in review-verdict.yml is fail-CLOSED.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
@@ -141,7 +150,9 @@ jobs:
- name: Restore
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
dotnet restore
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
# the SPA's package downloads are project deps, so they stay cached per lockfile.
@@ -156,36 +167,50 @@ jobs:
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm ci
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark npm-ci
npm ci
- name: Check generated SPA API client
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run check:api
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark check-api
npm run check:api
- name: Lint SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run lint
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark lint
npm run lint
- name: Typecheck SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run typecheck
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark typecheck
npm run typecheck
- name: Test SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm test -- --run
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-test
npm test -- --run
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run build
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-build
npm run build
- name: Strip Scanner project ref (matches Docker build)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark strip-scanner
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
@@ -199,13 +224,16 @@ jobs:
- name: Build
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build --configuration Release --no-restore
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
dotnet build --configuration Release --no-restore
- name: Test
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark dotnet-test
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal \
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
# per test project (via --collect above); ReportGenerator merges them into a human-readable
@@ -258,6 +286,43 @@ jobs:
continue-on-error: true
run: scripts/ci-peak-anon.sh report
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
# version of the same bug that #751 fixed in review-verdict.yml.
#
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
# exactly one real step, so there is no ordinary red for it to talk over. Here there are
# twelve, and a genuine failure in an early one (a lint error, a failing test) SKIPS every
# later step — an `always()` guard would then announce "these steps never executed: typecheck
# web-test build dotnet-test" on top of every normal red build. That is not a dropped step, it
# is the runner doing what it is told, and a guard that cries wolf on every red build is a
# guard that gets deleted.
#
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
# and therefore reaches here.
#
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
# are held to naming a real context by
# test_every_workflow_expression_names_a_REAL_context_or_function.
- name: Assert every expected step executed (ersatztv#756)
env:
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
run: >-
scripts/ci-step-ran.sh assert
--always detect revalidate
--gated restore npm-ci check-api lint typecheck web-test web-build strip-scanner build dotnet-test
migrations:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
@@ -328,14 +393,21 @@ jobs:
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
# Same per-step marker contract as the `test` job above (ersatztv#756) — this is the other
# REQUIRED context, so a dropped migration-replay step would report EF integrity green having
# replayed nothing.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
@@ -347,11 +419,15 @@ jobs:
- name: Restore
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
dotnet restore
- name: Build
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build --configuration Release --no-restore
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
dotnet build --configuration Release --no-restore
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
# — no per-run `dotnet tool install`. Bump its version there (ersatztv#390).
@@ -361,6 +437,7 @@ jobs:
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark sqlite
echo "::group::SQLite model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
@@ -384,6 +461,7 @@ jobs:
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
run: |
set -euo pipefail
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark mysql
echo "::group::MySql model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
@@ -418,6 +496,42 @@ jobs:
# how the original defects escaped. The fixture itself is retained and is opt-in via
# ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627.
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
# version of the same bug that #751 fixed in review-verdict.yml.
#
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
# exactly one real step, so there is no ordinary red for it to talk over. Here a genuine
# failure in an early step (a failing `dotnet build`, a MySql replay error) SKIPS every later
# step — an `always()` guard would then announce "these steps never executed: sqlite mysql" on
# top of every normal red build. That is not a dropped step, it is the runner doing what it is
# told, and a guard that cries wolf on every red build is a guard that gets deleted.
#
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
# and therefore reaches here.
#
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
# are held to naming a real context by
# test_every_workflow_expression_names_a_REAL_context_or_function.
- name: Assert every expected step executed (ersatztv#756)
env:
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
run: >-
scripts/ci-step-ran.sh assert
--always detect revalidate
--gated restore build sqlite mysql
functional-e2e:
name: Functional E2E (curl + UI contracts)
runs-on: ubuntu-latest
@@ -529,6 +643,61 @@ jobs:
# server. Its exit status is Playwright's.
scripts/e2e-ui.sh
# THE DELIMITER BAN, RE-CHECKED ON THE RELEASE PATH ITSELF (ersatztv#767).
#
# The ban that keeps `build`'s `Smoke + IPTV E2E` from being silently dropped was enforced only by
# `test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body` in the
# `script-tests` job of pr-checks.yml — `on: pull_request`, and NOT a required context. So the ban
# was REVIEW-TIME only: nothing re-checked it on a `v*` tag push, which is precisely when the
# candidate image is published and `DeployStack jazz-media` promotes it.
#
# WHY A JOB AND NOT A STEP INSIDE `build`. A step cannot protect the thing it shares a job with:
# `build` is what publishes, so a guard step there fails OPEN if the runner drops it, and "my body
# has no opener so I cannot be dropped" is circular when the only thing enforcing that property is
# the same PR-only test being backstopped. As a `needs:` of `build`, a red here means `build` never
# runs at all — the image is not built, let alone pushed. Fail-closed by dependency, not by
# assertion.
#
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. The first cut of #767 hand-parsed the
# workflow YAML in stdlib Python, to avoid provisioning PyYAML on `build`'s bare runner. Two
# independent reviews found ~10 false NEGATIVES in that parser within one round (flow mappings
# `{run: …}`, a quoted `"run":` key, aliases, multiline quoted scalars) — i.e. it was strictly
# WEAKER than the check it was meant to backstop, in the one direction that matters for a security
# gate. Running the existing PyYAML-based test needs no second implementation of "what is a `run:`
# body" and therefore has no drift surface. `small` is git-only, so Python is provisioned here the
# same way `script-tests` does it.
#
# This job's OWN steps carry #756 markers and a trailing assert, so a drop inside THIS job is
# caught too. That terminates the regress at the same axiom the sibling guards already rest on —
# to fail open you must now drop the pytest step AND the assert step, rather than either one.
scan:
name: Delimiter ban (release path)
runs-on: small
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install test dependencies
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark deps
python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# The ban test plus the structural tests that hold this job's own shape. NOT the whole
# scripts/tests suite: that is `script-tests`'s job, it needs jq/git preflights, and an
# unrelated pytest regression must not be able to block a release.
- name: Run the delimiter-ban tests
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark ban
PYTHONPATH=. python3 -m pytest scripts/tests/test_ci_dropped_step_guard.py scripts/tests/test_ci_release_path_scan_job.py -q
# No `if:` — see the sibling guards in `test`/`migrations` for why the default `success()` is
# the wanted condition. Both keys are `--always`: every step in this job is unconditional.
- name: Assert every expected step executed (ersatztv#756)
run: >-
scripts/ci-step-ran.sh assert
--always deps ban
build:
name: Build & push image (amd64)
# Moved back off `small` (server-management#639). This is the one HEAVY job that
@@ -545,7 +714,10 @@ jobs:
# was queueing behind has drained. Real builds (main/tags) get the full
# ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127).
runs-on: ubuntu-latest
needs: [test, migrations]
# `scan` (ersatztv#767) re-checks the delimiter ban on the release path. As a `needs:` its red
# SKIPS this job outright, so a delimiter in `Smoke + IPTV E2E` can no longer reach the point
# where an image is published and never booted.
needs: [test, migrations, scan]
if: github.event_name != 'pull_request'
steps:
- name: Checkout
@@ -616,11 +788,33 @@ jobs:
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
# THE TWO VALUES COME IN THROUGH `env:`, NOT INLINE (ersatztv#756). This step runs AFTER
# `Build and push`, so on a `v*` tag the image is already in the registry as the release
# candidate — and it is this smoke run that decides whether the candidate was ever booted at
# all. A stray expression delimiter anywhere in this body (a comment is not inert — #751) would
# DROP the step and conclude the job `success`: a candidate published, never smoke-tested, and
# `DeployStack jazz-media` promotes exactly that image. `env:` is interpolated PER VALUE, so a
# bad payload there fails that value instead of taking the whole body with it, and with the
# body delimiter-free the class is unreachable here — held by
# test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body.
#
# The ban IS re-checked on the release path now (ersatztv#767): the `scan` job above runs the
# PyYAML-based ban test and is a `needs:` of this job, so a delimiter here means `build` never
# runs and no image is published. Do not re-add the note that once stood here saying the ban is
# "review-time only, tracked as #767" — that was true before the `scan` job existed.
#
# This step still carries no per-step markers, and that is a genuine (smaller) residual rather
# than a dismissal: markers would additionally catch a drop caused by something OTHER than a
# delimiter. Adding them needs a bucket modelling this step's publish-ref `if:`, which the
# guard's always/gated buckets do not express. The delimiter class itself is covered.
- name: Smoke + IPTV E2E (assert key endpoints)
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
env:
SMOKE_SHORT_SHA: ${{ steps.meta.outputs.short }}
SMOKE_RUN_ID: ${{ github.run_id }}
run: |
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
NAME="etv-smoke-${{ github.run_id }}"
IMG="${IMAGE}:${SMOKE_SHORT_SHA}"
NAME="etv-smoke-${SMOKE_RUN_ID}"
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
echo "Pulling ${IMG}"
docker pull "$IMG"
+286 -21
View File
@@ -182,6 +182,47 @@ jobs:
run: |
set -euo pipefail
# NEVER WRITE AN EXPRESSION DELIMITER ANYWHERE IN THIS BODY, INCLUDING IN A COMMENT
# (ersatztv#751). A shell comment is not inert here. The runner scans this whole `run:`
# scalar for the expression opener before bash ever sees it, and one occurrence makes it
# rewrite the ENTIRE body into a single `format(...)` call so the result can be spliced
# back in. That rewrite is all-or-nothing: if the payload does not parse as an expression
# the interpolation of the whole scalar fails, and — the part that made this expensive —
# the runner DROPS THE STEP AND REPORTS THE JOB GREEN.
#
# That is exactly how this gate spent 2026-08-03 to 2026-08-06 dead: the #706 note above,
# explaining why a concurrency group does not work, quoted a `concurrency:` snippet
# containing a PR-number expression as ILLUSTRATION. `pr number` is not a valid
# expression, so this step never ran, `review-verdict/h10` was never posted by anything
# but a human hand, and both exemption classes silently stopped working while every run
# reported success. The prose documenting a fix disabled the fix.
#
# Two things now stop a recurrence, and they are deliberately different in kind: the
# start-marker below turns a dropped step into a RED job instead of a green one, and
# `test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body` in
# scripts/tests/test_pr_changed_files.py rejects the delimiter at review time. Note that
# the older workflow-shape tests all scan comment-STRIPPED text (`_code_lines`) precisely
# so prose cannot redden them — which makes every one of them structurally blind to this
# class. The new test reads the raw scalar for that reason. To describe an expression in
# prose here, name it (`a github.event.pull_request.number expression`); do not quote the
# delimiters.
#
# THE START MARKER (ersatztv#751). Written before anything else can fail, and read by the
# `Assert the classifier actually executed` step below. Its absence means this step did
# not begin — the silent-green failure mode — and that is now a job failure. It records
# only that execution STARTED: every `exit 0` abstention path below is a legitimate
# outcome, so completion is deliberately not what is asserted.
# KEYED ON THE RUN, not a fixed name. Measured on this instance, `RUNNER_TEMP` is `/tmp` —
# not a private per-job directory — so a fixed name lives at a path other jobs can also
# write. The `small` lane starts a container per job today, which makes the file fresh in
# practice, but that is a property of the lane rather than a guarantee, and a STALE marker
# would satisfy the guard below on a run whose step was dropped: a silent PASS, the exact
# failure mode this guard exists to remove. Including the run id and attempt means a marker
# from any other run cannot answer for this one. If the runner does not export them the path
# degrades to a fixed name, which is no worse than having no key at all.
RAN_MARKER="${RUNNER_TEMP:-${GITHUB_WORKSPACE:-/tmp}}/h10-classifier-started-${GITHUB_RUN_ID:-norunid}-${GITHUB_RUN_ATTEMPT:-1}"
: > "$RAN_MARKER"
CONTEXT="review-verdict/h10"
# The description this job writes when it repairs its own raced exemption (#706 race 2).
# It is a SENTINEL, not just a message: `read_existing_verdict` recognises it, and the
@@ -242,7 +283,19 @@ jobs:
# containing executable hooks"). A derived rule has to be evaluated against the PR's own
# file list, which is the very thing being classified — more moving parts inside a security
# predicate, to remove a maintenance burden that is one line per new tooling directory.
PROTECTED='^(\.claude/|\.codex/|\.gitea/|\.husky/|scripts/|docker/ci/)'
# `CLAUDE.md` and `AGENTS.md` are listed even though they are `.md` files, because they are
# not prose about the project — they are the documents that DEFINE the completion protocol,
# the merge-consent convention and the H10 rule itself. `.claude/` being protected while the
# file that specifies what `.claude/` enforces was docs-only-exempt is the same
# self-exemption the header rules out, one directory over. Found by cold review
# (ersatztv#751): driving the real classify body with a lone `CLAUDE.md` change produced
# `review-verdict/h10=success`, "Exempt: docs-only change (no code, no protected path)".
#
# It is fixed HERE rather than deferred because this change is what makes it reachable
# again: no exemption `success` was writable at all while the classify step was dropped, so
# restoring the exemptions restores this hole with them. `README.md` is deliberately NOT
# listed — it is ordinary prose and carries no enforcement.
PROTECTED='^(\.claude/|\.codex/|\.gitea/|\.husky/|scripts/|docker/ci/|CLAUDE\.md$|AGENTS\.md$)'
# Docs-only: prose and decision records. Deliberately narrower than the hook's pattern,
# which also lets .claude/.gitea/.husky through — that carve-out is safe there only
# because it falls through to a HUMAN PROMPT, whereas here it would post a green status
@@ -328,11 +381,85 @@ jobs:
read_existing_verdict() {
local json row
json=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || json=""
if [ -z "${json//[[:space:]]/}" ] || ! printf '%s' "$json" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
echo "::error::Could not read existing commit statuses for ${SHA:0:7}. Refusing to post anything rather than risk overwriting an existing verdict."
# `.statuses` IS `null`, NOT `[]`, ON A HEAD WITH NO STATUSES YET — the same nil-slice
# serialization as the timeline terminator, found by cold review of the fix for that one
# (ersatztv#751). Measured on this instance: PR #739's head 5fa672e2 returns
# `{"state":"pending","total_count":0,"statuses":null}`. An `array`-only gate read that as
# unreadable and took the `exit 1` below, so the job posted NOTHING — fail-closed, but the
# user-visible outcome is exactly the one this issue is about: an exempt PR left with no
# status and, since ersatztv#743, no bypass. Fixing only the timeline site would have left
# the identical bug one function away.
#
# `total_count` is required to agree, so `null` is accepted only as the empty case and not
# as a body that merely lost its array. Anything else is still "cannot tell" and still
# refuses to post, which is what protects an existing verdict from a transient API error.
# WHETHER PAGE 1 IS THE WHOLE LIST CANNOT BE DECIDED FROM PAGE 1 (ersatztv#751). Two
# plausible guards were tried and both are no-ops HERE, which is why this ended up as an
# extra round-trip instead of an arithmetic test:
#
# * `.statuses | length` vs `.total_count` — `total_count` is the count for the PAGE
# RETURNED, not for the commit. Measured at 1.27.1 on 3aed43c6 (6 contexts):
# `?limit=1` gives `len=1, total_count=1`, `?limit=3` gives `len=3, total_count=3`.
# Equal by construction, so the check proves nothing.
# * "refuse when the page came back FULL at the requested limit of 100" — this instance
# caps `limit` at the server-wide `MAX_RESPONSE_ITEMS`, measured at 50
# (`/issues?limit=100` returns 50). A response can therefore never contain 100 rows and
# the comparison was DEAD CODE. The repo already knew this and said so in
# `scripts/pr-changed-files.sh`, two test files and `ci.script-tests-job`; the guard was
# written anyway. Hardcoding the cap instead would re-break the day it is reconfigured.
#
# So ask the server, and only when it matters. Completeness is needed ONLY to justify the
# conclusion "no verdict exists on this head" — if the row IS on page 1 there is nothing
# further to learn, since this endpoint returns the latest status per CONTEXT and the same
# context cannot recur on a later page. When the row is absent, page 2 is read: any rows
# there mean the list is longer than one page and the verdict could be sitting beyond it,
# so this refuses rather than concluding absence. Cap-independent by construction.
st_kind=$(printf '%s' "$json" | jq -r '.statuses | type' 2>/dev/null) || st_kind=""
# NUMBER, not `jq -r` text: `jq -r` renders the JSON number 0 and the JSON string "0"
# identically, so a schema-corrupted `"total_count": "0"` would satisfy a string compare
# (cold review reproduced this). Requiring the type as well pins the accept path to a real
# numeric zero.
st_total=$(printf '%s' "$json" | jq -r 'if (.total_count | type) == "number" then (.total_count | tostring) else "x" end' 2>/dev/null) || st_total="x"
st_ok=no
case "$st_kind" in
array) st_ok=yes ;;
null) if [ "$st_total" = "0" ]; then st_ok=yes; fi ;;
esac
if [ -z "${json//[[:space:]]/}" ] || [ "$st_ok" != yes ]; then
echo "::error::Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}'). Refusing to post anything rather than risk overwriting an existing verdict."
exit 1
fi
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[.statuses[] | select(.context == $c)] | first // {}')
# `// []` so the null case cannot hard-error here under `set -e` once it is accepted above.
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[(.statuses // [])[] | select(.context == $c)] | first // {}')
# THE COMPLETENESS PROBE, run only when page 1 shows no verdict — see the note above. An
# unreadable or unexpected page 2 is treated as "cannot tell" and refuses, the same
# direction as every other unreadable case here: concluding "no verdict exists" is what
# licenses posting an exemption over one, so it is the conclusion that must be earned.
if [ "$(printf '%s' "$row" | jq -r '.context // ""')" = "" ]; then
more=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100&page=2") || more=""
if [ -z "${more//[[:space:]]/}" ]; then
echo "::error::Could not read page 2 of the commit statuses for ${SHA:0:7}, so 'no verdict exists' cannot be established. Refusing to post anything."
exit 1
fi
more_kind=$(printf '%s' "$more" | jq -r '.statuses | type' 2>/dev/null) || more_kind=""
more_len=$(printf '%s' "$more" | jq -r '(.statuses // []) | length' 2>/dev/null) || more_len=""
case "$more_kind" in
null) ;;
array)
case "$more_len" in
''|*[!0-9]*)
echo "::error::Page 2 of the commit statuses for ${SHA:0:7} had a non-numeric length; refusing to conclude that no verdict exists."
exit 1 ;;
0) ;;
*)
echo "::error::${CONTEXT} was not on page 1 of the statuses for ${SHA:0:7}, but page 2 carries ${more_len} more row(s) — the list is longer than one page and an existing verdict may be beyond it. Refusing to post anything rather than overwrite a verdict this job cannot see. A human verdict clears this: scripts/post-review-verdict.sh ${PR} MERGEABLE."
exit 1 ;;
esac ;;
*)
echo "::error::Page 2 of the commit statuses for ${SHA:0:7} was '${more_kind:-unparseable}'; refusing to conclude that no verdict exists."
exit 1 ;;
esac
fi
ex_state=$(printf '%s' "$row" | jq -r '.status // ""')
ex_creator=$(printf '%s' "$row" | jq -r '.creator.login // ""')
ex_desc=$(printf '%s' "$row" | jq -r '.description // ""')
@@ -410,8 +537,8 @@ jobs:
# run 7521 (`synchronize`) had started.
#
# WHY NOT A CONCURRENCY GROUP, which is the obvious answer and what #706 proposed. It does
# not work here, also measured: with `concurrency: {group: …-${{ pr number }},
# cancel-in-progress: false}` active on an identical probe, runs 7528 and 7529 still ran
# not work here, also measured: with a `concurrency:` key grouping by PR number and
# `cancel-in-progress: false` active on an identical probe, runs 7528 and 7529 still ran
# CONCURRENTLY and 7528 ended 36s after 7529 began. Gitea 1.25.4 does auto-cancel superseded
# `push` runs on a branch — a negative control with no `concurrency:` key at all showed that —
# but that behaviour does NOT extend to `pull_request_target`. `cancel-in-progress: true` is
@@ -438,19 +565,63 @@ jobs:
# coming — a real stall. The retarget count moves only for the mutation that actually
# invalidates a classification, and that mutation always brings its own re-run.
#
# Completeness is a guard, not an assumption (`ci.paged-endpoint-completeness`): the count is
# Completeness is a guard, not an assumption (`ci.verdict-write-retarget-fence`; this
# cited `ci.paged-endpoint-completeness` until 2026-08-06, a key that has never existed as
# a record — resolve decisions through the catalog, never through a key or path quoted in a
# comment). The count is
# trusted ONLY when paging reached a validated EMPTY page. A short page, a non-array body, a
# non-numeric length or the page cap all leave `rt_ok=no`, and an untrusted count is treated
# below as "cannot tell" rather than as zero.
count_retargets() {
rt_count=0
rt_ok=no
local page=1 raw n m total=0
local page=1 raw n m kind total=0
while [ "$page" -le 20 ]; do
raw=$(gh "$BASE_URL/repos/$REPO/issues/$PR/timeline?limit=50&page=${page}") || return 0
if [ -z "${raw//[[:space:]]/}" ] || ! printf '%s' "$raw" | jq -e 'type == "array"' >/dev/null 2>&1; then
return 0
fi
if [ -z "${raw//[[:space:]]/}" ]; then return 0; fi
# A PAGE PAST THE END IS JSON `null` HERE, NOT `[]` (ersatztv#751). Measured on this
# instance at Gitea 1.27.1 (page 1 non-empty, page 2 exhausted): page 2 of PR #752's timeline is the
# four bytes `null`, while `/issues/{n}/comments` with no comments returns `[]`. The
# instance is NOT consistent between endpoints, so the terminator has to be read from
# what this endpoint actually does rather than from the shape a list endpoint "should"
# return.
#
# This mattered far more than it looks. An `array`-only type gate treated `null` as
# unreadable, so the walk NEVER reached a validated empty page, `rt_ok` was never `yes`
# for ANY pull request, and the fence below therefore withheld every exemption
# `success` — permanently. Renovate and docs-only PRs got no status at all rather than
# an auto-pass, which is the same user-visible outcome as ersatztv#751 arriving by a
# completely different route. It was invisible because it shipped in the SAME commit
# (8f6d4f443) that stopped this step from executing at all, so the fence had never once
# run in production, and because the test double asserted the wrong shape — it printed
# `[]` for a page past the end while claiming to mirror measured reality.
#
# Read the type as a VALUE rather than through `jq -e`. `jq -e` reports the truthiness
# of the last output, so distinguishing "the body is null" from "the predicate is
# false" through it means relying on the same exit-status subtlety that already bit this
# workflow once at jq 1.6 (ersatztv#647). A `case` over `jq -r 'type'` cannot be read
# two ways. Anything that is neither `null` nor `array` is still "cannot tell".
kind=$(printf '%s' "$raw" | jq -r 'type' 2>/dev/null) || kind=""
# `null` counts as exhaustion only from page 2 ON. THE INVARIANT, not a figure that
# rots: a real PR's timeline always carries at least one event on page 1 (it is created
# by a push, which is itself an event), so a `null` FIRST page is anomalous rather than
# empty. Spot-checked across #752/#753/#749/#739/#717, all non-empty; the counts
# themselves are deliberately not recorded here because timelines grow and an earlier
# version of this comment cited five numbers of which three were stale within days.
# Trusting a zero count from an anomalous first page would
# mean trusting that no retarget happened on the strength of a response we cannot
# explain. Requiring one real page keeps the property the walk is for: something was
# actually read. A PR that somehow has an empty first page falls through to `rt_ok=no`,
# which withholds the exemption and asks for a human verdict — the safe direction.
# This narrows rather than closes the general concern: a wrong `null` on page 3 is still
# read as exhaustion, and no bounded number of round-trips can rule that out.
case "$kind" in
null)
if [ "$page" -gt 1 ]; then rt_ok=yes; rt_count=$total; fi
return 0 ;;
array) ;;
*) return 0 ;;
esac
n=$(printf '%s' "$raw" | jq -r 'length')
case "$n" in ''|*[!0-9]*) return 0 ;; esac
if [ "$n" -eq 0 ]; then rt_ok=yes; rt_count=$total; return 0; fi
@@ -471,10 +642,12 @@ jobs:
# --- Whose verdict is it? (ersatztv#698 route 3) -------------------------------------
# This short-circuit used to exit on ANY existing `success`, which made an exemption this job
# wrote indistinguishable from a verdict a human wrote. That is what let a forged exemption
# survive: obtained once — via the route-1 retarget race, a sibling workflow holding
# status-write credentials (#697), a direct API call, or inheritance across PRs by sha (#663)
# — it was thereafter accepted unchanged on every run, because the guard exited before it
# looked at the PR, the base, the author or the files.
# survive: obtained once — via the route-1 retarget race, a collaborator's own token, the
# `GITEA_TOKEN` injected into every job, `RENOVATE_TOKEN`, a direct API call, or inheritance
# across PRs by sha (#663) — it was thereafter accepted unchanged on every run, because the
# guard exited before it looked at the PR, the base, the author or the files. (`#697`'s
# registry credential can no longer post a status at all — see `ci.actions-credential-scoping`
# — but that closes only one route; the others above still work.)
#
# The guard still exists for its original reason: re-posting `pending` over a real human
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
@@ -491,9 +664,11 @@ jobs:
# machine-written") an unrecognised shape would be trusted — the fail-open this issue is about.
#
# What this does NOT claim: the test asks "was this POSTed by a user credential", NOT "by a
# reviewer". `ETV_STATUS_AUTH` is basic auth, so head-controlled code can POST a success with a
# non-null creator AND an attacker-chosen `Review-verdict:` description, which this guard then
# preserves. That is #697 — provenance, not authentication.
# reviewer". `ETV_STATUS_AUTH`'s registry credential can no longer POST a status at all (#697,
# fixed by scoping it off `write:repository` — see `ci.actions-credential-scoping`), but any
# OTHER user credential — a collaborator's own token, `RENOVATE_TOKEN` — can still POST a
# success with a non-null creator AND an attacker-chosen `Review-verdict:` description, which
# this guard then preserves. That is provenance, not authentication.
read_existing_verdict
if [ "$ex_human" = yes ] && { [ "$ex_state" = "success" ] || [ "$ex_state" = "failure" ]; }; then
echo "${CONTEXT} is already '${ex_state}' on ${SHA:0:7}, written by '${ex_creator}' as a human verdict — leaving it alone."
@@ -771,9 +946,21 @@ jobs:
# live head), `/statuses/{sha}` a BARE ARRAY (24 rows on the same head) — hence the different
# `type == "array"` guard here.
#
# No claim is made about the order rows come back in, because the check does not depend on
# it: it selects by id against the high-water mark rather than inspecting the top of the
# list. A verdict older than the mark is invisible to it no matter where it sits.
# ORDER IS NOT RELIED ON *WITHIN* A PAGE — the check selects by id against the high-water
# mark rather than inspecting the top of the list, so a verdict older than the mark is
# invisible to it no matter where it sits. But this read is ONE PAGE, and `limit=100` clamps
# to the server-wide `MAX_RESPONSE_ITEMS`, measured at 50 on this instance (ersatztv#751).
# An earlier version of this comment claimed order-independence flatly; that is false the
# moment a head carries more rows than the clamp, because a raced row can then sit on a page
# this never reads. Measured: a probe head reached 33 rows after ~5 runs, and the ordering is
# only coarsely newest-first (ids came back `33,32,31,30,28,29,27,…`), so >50 is ordinarily
# reachable on a PR with a few CI reruns.
#
# That matters more here than anywhere else in this job, because this is the ONE path whose
# failure direction is toward SUCCESS: missing a raced human `failure` leaves a forged green
# standing over a rejection. So the page-2 probe below treats "there are rows I did not read"
# as "assume raced" — the conservative direction — rather than as "no race found".
# Full paging of this endpoint, including for the high-water mark above, is ersatztv#763.
#
# The repair is `pending`, NEVER a copy of the human's state. Re-posting their `failure`
# would attribute a human verdict to this job — the exact provenance confusion the
@@ -818,6 +1005,39 @@ jobs:
and (((.description // "") | startswith("Review-verdict:"))))
or ((.creator == null) and ((.description // "") == $rd))
)] | length')
# "NO RACE FOUND ON PAGE 1" IS NOT "NO RACE" (ersatztv#751). If page 1 was not the whole
# list, a raced row can be beyond it, so an unread page is treated as a race rather than
# as absence. Only checked when page 1 looked clean — a race already found needs no
# further evidence. Unreadable page 2 also counts as raced: this is the fail-toward-
# SUCCESS path, so uncertainty must resolve to `pending`, never to leaving green.
#
# BE HONEST ABOUT THE COST: a repair here is STICKY, not a one-run stall. It writes
# `$REPAIR_DESC`, and the classification above refuses to grant an exemption over that
# sentinel and re-writes it as a fixed point on every later run — so a SPURIOUS repair
# (a head with more status rows than the page cap and no actual race) removes that head's
# exemption permanently, and only a human `post-review-verdict.sh` clears it. That is
# still the right direction, because the alternative is a forged green over a human
# rejection. But it is a per-sha loss of the exemption, not an inconvenience, which is
# the argument for replacing this with real paging (#763) rather than living with it.
if [ "$raced" = "0" ]; then
more_hist=$(gh "$BASE_URL/repos/$REPO/statuses/$SHA?limit=100&page=2") || more_hist="UNREADABLE"
if [ "$more_hist" = "UNREADABLE" ]; then
echo "::warning::Could not read page 2 of the status history for ${SHA:0:7}; treating this as a possible raced verdict and repairing to pending rather than leaving an exemption green on an unverified head."
raced=1
else
mh_kind=$(printf '%s' "$more_hist" | jq -r 'type' 2>/dev/null) || mh_kind=""
mh_len=$(printf '%s' "$more_hist" | jq -r 'if type == "array" then length else 0 end' 2>/dev/null) || mh_len=""
case "${mh_kind}:${mh_len}" in
null:*|array:0) ;;
array:*)
echo "::warning::The status history for ${SHA:0:7} runs past page 1 (${mh_len} more row(s)), so a raced human verdict could be on a page this job did not read. Repairing to pending rather than leaving the exemption green."
raced=1 ;;
*)
echo "::warning::Page 2 of the status history for ${SHA:0:7} was '${mh_kind:-unparseable}'; treating as a possible raced verdict and repairing to pending."
raced=1 ;;
esac
fi
fi
case "$raced" in
''|*[!0-9]*)
echo "::warning::Post-write verification for ${SHA:0:7} returned '${raced}' instead of a count; not acting on it."
@@ -851,3 +1071,48 @@ jobs:
if [ "$state" = "pending" ]; then
echo "::notice::This PR needs an H10 review verdict for head ${SHA:0:7} before it can merge. After reviewing, run: scripts/post-review-verdict.sh ${PR} MERGEABLE"
fi
# THE SILENT-GREEN HALF OF ersatztv#751, WHICH IS THE ACTUAL DEFECT. The stray expression
# delimiter was one bug in one comment; this step exists because of how the runner REPORTED it.
# A step it declines to interpolate is dropped and the job concludes `success`, so the gate was
# dead for three days behind a green tick. Nothing surfaced it: the workflow's own status
# context said success, and `review-verdict/h10` was merely ABSENT — which reads as "not
# reviewed yet" on a normal PR and is indistinguishable from the correct pending state.
#
# This turns that class of failure — dropped for ANY reason, not only an interpolation failure
# — into a red job. It asserts execution STARTED, never that it completed: the classifier has
# several legitimate `exit 0` abstention paths (a human verdict landed mid-run, a mid-run
# retarget, a sentinel appearing) and treating those as failures would redden ordinary PRs.
#
# `if: always()` so it also runs when the classifier failed on purpose — its fail-closed paths
# exit non-zero, the job is already red, and this step then finds the marker and stays quiet
# rather than piling a misleading second error on top.
#
# ITS OWN BODY MUST STAY EXPRESSION-FREE. A guard that can be dropped by the very mechanism it
# guards against is worse than none, because its absence is also silent. No delimiters here, no
# illustrative snippets, and `always()` is written bare as `if:` requires — the static test in
# scripts/tests/test_pr_changed_files.py holds this to it.
#
# THAT A LATER STEP STILL RUNS AFTER AN EARLIER ONE IS DROPPED IS MEASURED, not assumed — it is
# the premise this guard stands on, and the #751 report could not settle it because the
# classifier was the job's last step, leaving nothing subsequent to observe. Established on this
# instance by a scratch-base probe with a negative control (Gitea 1.27.1, 2026-08-06): run 1863
# dropped the classifier on a reintroduced bad payload, logged `evaluating expression 'always()'
# -> true`, ran THIS step, and the job concluded `failure`. Run 1866 is the positive control —
# the classifier ran, posted its exemption, and this step found the marker at
# `/tmp/h10-classifier-started-1866-1`, confirming the run-keyed path resolves.
#
# Had the runner dropped the remaining steps too, this guard could not work and the body would
# have had to move into `scripts/`, where a one-line `run:` makes the class unreachable. It does
# not, so it stays here. Re-measure if the runner is upgraded: this is the one assumption whose
# failure is silent again.
- name: Assert the classifier actually executed
if: always()
run: |
set -euo pipefail
marker="${RUNNER_TEMP:-${GITHUB_WORKSPACE:-/tmp}}/h10-classifier-started-${GITHUB_RUN_ID:-norunid}-${GITHUB_RUN_ATTEMPT:-1}"
if [ ! -f "$marker" ]; then
echo "::error::The classify step did not execute AT ALL — no start marker at ${marker}. The runner dropped it (an interpolation failure over the run: body does this and still reports the job green; see ersatztv#751) or it was skipped. review-verdict/h10 has NOT been posted for this head, so exempt PRs (Renovate, docs-only) are silently unmergeable. Failing the job so this is visible instead of green."
exit 1
fi
echo "The classify step executed (start marker present at ${marker})."
+3 -2
View File
@@ -12,8 +12,9 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
./.claude/hooks/prepush-rebase-check.sh || exit 1
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1. Exempts a
# tag-only push (ersatztv#719) — forward the ref lines captured above so it can tell.
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-rebase-check.sh || exit 1
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
+4 -2
View File
@@ -85,9 +85,11 @@ Every task that closes a Gitea issue MUST complete ALL of these before it is con
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent.
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
**`main` is PR-only — there is no direct-push path any more (ersatztv#743, `release.main-direct-push-disabled`).** Branch protection carries `enable_push: false` **and** `block_admin_merge_override: true`: a direct `git push origin HEAD:main` is refused server-side at pre-receive for every account including a site admin, the contents API is refused too, and an admin cannot `force_merge` past a missing or red required context. This is what makes `review-verdict/h10` load-bearing rather than conventional — Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery. Practically: **every** change to `main` goes through a PR, including a one-line docs fix. Tag pushes are unaffected (separate mechanism), so the release cut is unchanged.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs are exempt from the *review-verdict* gate; the direct-push exemption is moot now that direct pushes are refused outright.
**The 7 mandatory completion steps and the `## Closing record` comment template** live in the
`closing-an-issue` skill (`.claude/skills/closing-an-issue/SKILL.md`) — invoke it (or `/done`)
@@ -21,4 +21,16 @@
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
</ItemGroup>
<!--
The generated OpenAPI document is the wire contract the MCP catalog wraps. Copying it into the
test output lets ToolCatalogTests assert that every write tool declares exactly the request-body
fields its endpoint accepts, so a new DTO property cannot drift out of a tool schema unnoticed
(issue #754). Regenerated by scripts/update-openapi.sh.
-->
<ItemGroup>
<Content Include="..\ErsatzTV\wwwroot\openapi\v1.json"
Link="openapi\v1.json"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+260 -3
View File
@@ -77,9 +77,22 @@ public class ToolCatalogTests
// Enums must NOT be forced required (they have server-side defaults).
createRequired.ShouldNotContain("streamingMode");
// Update carries the same body fields plus the route id.
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("showInEpg", out _).ShouldBeTrue();
// Update carries the create body fields plus the route id...
JsonElement updateProps = update.InputSchema.RootElement.GetProperty("properties");
updateProps.TryGetProperty("id", out _).ShouldBeTrue();
updateProps.TryGetProperty("showInEpg", out _).ShouldBeTrue();
// ...plus graphicsElementIds, which is on UpdateChannelRequest only. PUT is a full replace, so
// while the tool could not express this field an agent following the tool's own "send the full
// desired state" instruction silently detached every graphics element (issue #754).
updateProps.TryGetProperty("graphicsElementIds", out JsonElement graphicsElementIds).ShouldBeTrue();
graphicsElementIds.GetProperty("type").GetString().ShouldBe("array");
graphicsElementIds.GetProperty("items").GetProperty("type").GetString().ShouldBe("integer");
// Create must NOT send it: CreateChannelRequest has no such property, and the tool schema is
// additionalProperties:false. This is why it is declared on the update tool rather than in the
// shared ChannelFields().
createProps.TryGetProperty("graphicsElementIds", out _).ShouldBeFalse();
}
[Test]
@@ -256,4 +269,248 @@ public class ToolCatalogTests
tool.QueryParameters.ShouldNotBeNull();
tool.QueryParameters!.ShouldContain("deep");
}
// #754: ToolCatalog declared 27 of UpdateChannelRequest's 28 properties. The missing one was
// graphicsElementIds, and because PUT /api/v1/channels/{id} is a FULL REPLACE the omission was not
// merely "one field you cannot set" — an agent that GET-edit-PUT the channel, exactly as the tool's
// description tells it to, detached every graphics element (including the On Now/Next overlay) with
// a 200 and no error. The same shape was live on ersatztv_update_schedule, which omitted
// padToNearestMinute and silently cleared a configured pad.
//
// Neither is fixable by counting fields once: the defect is that nothing tied the tool schema to the
// contract it wraps. So this test asserts the tie for EVERY write tool against the generated OpenAPI
// document (the actual wire contract, linked into the test output by the csproj). A new property on
// any request DTO now fails here until the catalog declares it.
[Test]
public void Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields()
{
using JsonDocument spec = LoadOpenApiDocument();
JsonElement paths = spec.RootElement.GetProperty("paths");
ToolDefinition[] writeTools = ToolCatalog.All
.Where(t => t.HttpMethod == HttpMethod.Post
|| t.HttpMethod == HttpMethod.Put
|| t.HttpMethod == HttpMethod.Patch)
.ToArray();
// Pin the covered set rather than trusting the filter. A tool that stopped being a write verb,
// or a new write tool, must show up as a change here — a bare loop over a filtered set passes
// just as happily when the set silently shrinks to nothing.
string[] expectedWriteTools =
[
"ersatztv_add_collection_items",
"ersatztv_create_channel",
"ersatztv_create_collection",
"ersatztv_create_playout",
"ersatztv_create_schedule",
"ersatztv_create_smart_collection",
"ersatztv_enable_jellyfin_library_sync",
"ersatztv_refresh_jellyfin_libraries",
"ersatztv_reset_channel_playout",
"ersatztv_scan_jellyfin_collections",
"ersatztv_scan_library",
"ersatztv_update_channel",
"ersatztv_update_collection",
"ersatztv_update_collection_custom_order",
"ersatztv_update_playout",
"ersatztv_update_schedule",
"ersatztv_update_smart_collection"
];
writeTools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)
.ShouldBe(expectedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
foreach (ToolDefinition tool in writeTools)
{
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
string verb = tool.HttpMethod.Method.ToLowerInvariant();
pathItem.TryGetProperty(verb, out JsonElement operation)
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
Dictionary<string, string> declared = DeclaredBodyArguments(tool);
Dictionary<string, string> accepted = RequestBodyProperties(spec, operation, tool.Name);
// Compare name AND type. Names alone would let a field drift to the wrong JSON type: the
// tool would advertise "string" for an int?, the agent would send "30", and the API would
// 400 — green test, broken tool.
declared.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal)
.ShouldBe(
accepted.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal),
customMessage:
$"{tool.Name} declares body fields that do not match {verb.ToUpperInvariant()} {tool.PathTemplate}. "
+ "A field the endpoint accepts but the tool omits is silently dropped on a full-replace "
+ "write (#754); a field the tool sends but the endpoint does not accept is rejected; "
+ "a field declared with the wrong type is rejected at the API.");
}
}
// #757, the sibling of the body guard above. Query parameters drift the same way and are WORSE for
// reads: ToolArgumentValidator rejects undeclared arguments, so a parameter the tool omits is not
// merely undocumented, it is unreachable — the caller cannot pass it at all. That is how #616's
// paging omission hard-capped two tools at the first page. This covers EVERY tool, not just the
// write verbs, because the drift that existed when this was written was entirely on reads.
[Test]
public void Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters()
{
using JsonDocument spec = LoadOpenApiDocument();
JsonElement paths = spec.RootElement.GetProperty("paths");
// Every tool is covered, so an emptiness guard is enough here — there is no filter to escape.
ToolCatalog.All.Count.ShouldBeGreaterThan(30);
// Accumulate rather than throwing on the first mismatch, so one run reports the WHOLE drift set.
// Failing fast here would hand back one tool at a time and invite fixing them one at a time,
// which is how the #754 twin stayed hidden in the first place.
List<string> drift = [];
foreach (ToolDefinition tool in ToolCatalog.All)
{
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
string verb = tool.HttpMethod.Method.ToLowerInvariant();
pathItem.TryGetProperty(verb, out JsonElement operation)
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
IReadOnlySet<string> declared = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
HashSet<string> accepted = QueryParameterNames(operation);
string[] missing = accepted.Except(declared, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
string[] phantom = declared.Except(accepted, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
if (missing.Length > 0 || phantom.Length > 0)
{
drift.Add(
$"{tool.Name} ({verb.ToUpperInvariant()} {tool.PathTemplate}): "
+ $"unreachable={string.Join(",", missing)} phantom={string.Join(",", phantom)}");
}
}
// A parameter the endpoint accepts but the tool omits is UNREACHABLE, not merely undocumented:
// ToolArgumentValidator rejects undeclared arguments, so the caller cannot pass it at all
// (#616 hard-capped two paged tools exactly this way). A phantom is the reverse — the tool
// advertises something the endpoint ignores.
drift.ShouldBeEmpty();
}
private static HashSet<string> QueryParameterNames(JsonElement operation)
{
if (!operation.TryGetProperty("parameters", out JsonElement parameters))
{
return [];
}
return parameters.EnumerateArray()
.Where(p => p.TryGetProperty("in", out JsonElement location)
&& string.Equals(location.GetString(), "query", StringComparison.Ordinal))
.Select(p => p.GetProperty("name").GetString())
.OfType<string>()
.ToHashSet(StringComparer.Ordinal);
}
// The body is every declared argument that is not routed elsewhere — mirroring exactly how
// ErsatzTvApiClient builds the request, so this test cannot disagree with the code it guards.
// DELETE is not compared: ErsatzTvApiClient sets hasBody for POST/PUT/PATCH only, so a body
// argument on a DELETE tool would be silently dropped. No DELETE tool has one today.
private static Dictionary<string, string> DeclaredBodyArguments(ToolDefinition tool)
{
var pathParameters = Regex.Matches(tool.PathTemplate, @"\{([^}]+)\}")
.Select(m => m.Groups[1].Value)
.ToHashSet(StringComparer.Ordinal);
IReadOnlySet<string> queryParameters = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
if (!tool.InputSchema.RootElement.TryGetProperty("properties", out JsonElement properties))
{
return [];
}
return properties.EnumerateObject()
.Where(p => !pathParameters.Contains(p.Name)
&& !queryParameters.Contains(p.Name)
&& !string.Equals(p.Name, "ifMatch", StringComparison.Ordinal))
.ToDictionary(p => p.Name, p => DeclaredType(p.Value), StringComparer.Ordinal);
}
// The tool schema's own shape: a plain "type", plus the array element type where there is one.
private static string DeclaredType(JsonElement property)
{
string type = property.GetProperty("type").GetString().ShouldNotBeNull();
return type == "array" && property.TryGetProperty("items", out JsonElement items)
? $"array<{items.GetProperty("type").GetString()}>"
: type;
}
private static Dictionary<string, string> RequestBodyProperties(JsonDocument spec, JsonElement operation, string toolName)
{
// No request body at all (queue/scan POSTs) — the tool must send none either.
if (!operation.TryGetProperty("requestBody", out JsonElement requestBody))
{
return [];
}
JsonElement schema = requestBody
.GetProperty("content")
.GetProperty("application/json")
.GetProperty("schema");
// Every request body in this document is a plain $ref to a component schema. Anything else
// (allOf/inline/oneOf) is a contract shape this guard has not been taught to read, so fail
// loudly rather than comparing against an empty set and reporting a false pass.
schema.TryGetProperty("$ref", out JsonElement reference)
.ShouldBeTrue($"{toolName}: request body schema is not a $ref; teach this test the new shape");
JsonElement schemas = spec.RootElement.GetProperty("components").GetProperty("schemas");
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
return schemas
.GetProperty(componentName)
.GetProperty("properties")
.EnumerateObject()
.ToDictionary(p => p.Name, p => SpecType(schemas, p.Value, toolName, p.Name), StringComparer.Ordinal);
}
// Normalize the generator's shapes onto the catalog's vocabulary. Two forms appear in this
// document: a nullable type as ["null", T] (the catalog has no nullable notion — optionality is
// carried by `required`), and a $ref to a component, which for the enum fields is a string enum
// and for `logo` is an object.
private static string SpecType(JsonElement schemas, JsonElement property, string toolName, string fieldName)
{
if (property.TryGetProperty("$ref", out JsonElement reference))
{
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
return SpecType(schemas, schemas.GetProperty(componentName), toolName, fieldName);
}
JsonElement type = property.GetProperty("type");
string[] types = type.ValueKind == JsonValueKind.Array
? type.EnumerateArray().Select(t => t.GetString()).OfType<string>().Where(t => t != "null").ToArray()
: [type.GetString().ShouldNotBeNull()];
// More than one non-null type is a shape this guard has not been taught to read; fail rather
// than picking one and reporting a comparison that means nothing.
types.Length.ShouldBe(1, $"{toolName}.{fieldName}: unexpected OpenAPI type union [{string.Join(", ", types)}]");
// The element schema is resolved through the same normalization: an array's items can itself be
// a $ref to a component (ReplaceRemoteLibraryPreferencesRequest.libraries), which the catalog
// declares as an object array.
return types[0] == "array" && property.TryGetProperty("items", out JsonElement items)
? $"array<{SpecType(schemas, items, toolName, fieldName)}>"
: types[0];
}
private static JsonDocument LoadOpenApiDocument()
{
string path = Path.Combine(AppContext.BaseDirectory, "openapi", "v1.json");
// A missing spec would make every assertion above vacuous, so it is an explicit failure.
File.Exists(path).ShouldBeTrue(
$"OpenAPI document not found at {path}; the test project links it from ErsatzTV/wwwroot/openapi/v1.json");
return JsonDocument.Parse(File.ReadAllText(path));
}
}
+43 -6
View File
@@ -29,14 +29,32 @@ public static class ToolCatalog
Get("ersatztv_list_schedules", "List schedules.", "/api/v1/schedules"),
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
Get("ersatztv_get_schedule_items", "Get a schedule's items. Emits the schedule ETag.", "/api/v1/schedules/{id}/items", IdPath("Schedule id.")),
Get("ersatztv_list_playouts", "List playouts (paged).", "/api/v1/playouts", [], Page()),
Get(
"ersatztv_list_playouts",
"List playouts (paged), optionally filtered by channel name.",
"/api/v1/playouts",
[],
[
Str(
"query",
"Optional case-insensitive substring match on the CHANNEL name (not the playout or schedule name); omit for all playouts.",
arg: In.Query),
.. Page()
]),
Get("ersatztv_get_playout", "Get a playout by id.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
Get(
"ersatztv_get_playout_items",
"Get upcoming items (and unscheduled gaps) for a playout (paged).",
"/api/v1/playouts/{id}/items",
[IdPath("Playout id.")],
Page()),
[
Bool(
"showFiller",
"Include items whose filler kind is not None (pre/mid/post-roll, tail, fallback, guide-mode, deco); "
+ "default false returns only non-filler items.",
arg: In.Query),
.. Page()
]),
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/v1/ffmpeg/profiles"),
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/v1/ffmpeg/profiles/{id}", IdPath("FFmpeg profile id.")),
Get(
@@ -132,7 +150,8 @@ public static class ToolCatalog
[Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
Put(
"ersatztv_update_schedule",
"Update a program schedule's settings.",
"Update a program schedule. Send the full desired state: every field is applied, so omitting "
+ "padToNearestMinute CLEARS a configured pad (GET the schedule first to copy current values).",
"/api/v1/schedules/{id}",
[IdPath("Schedule id."), Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
Delete("ersatztv_delete_schedule", "Delete a program schedule.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
@@ -159,9 +178,22 @@ public static class ToolCatalog
ChannelFields()),
Put(
"ersatztv_update_channel",
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values).",
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values). "
+ "graphicsElementIds is part of that state: omitting it DETACHES every graphics element (e.g. the On Now/Next overlay), "
+ "so copy it from ersatztv_get_channel unless you mean to clear it.",
"/api/v1/channels/{id}",
[IdPath("Channel id."), .. ChannelFields()]),
[
IdPath("Channel id."),
.. ChannelFields(),
// Update-only: UpdateChannelRequest carries GraphicsElementIds, CreateChannelRequest does
// not, so this cannot move into the shared ChannelFields() without making create send an
// unknown property. PUT is a full replace, so omitting it detaches every attached element
// with no error — issue #754.
IntArray(
"graphicsElementIds",
"Ids of the graphics elements attached to the channel. Full replace: omit or send [] to detach all.")
]),
Post(
"ersatztv_reset_channel_playout",
"Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
@@ -297,7 +329,12 @@ public static class ToolCatalog
Bool("treatCollectionsAsShows", "Treat collections as shows."),
Bool("shuffleScheduleItems", "Shuffle schedule items."),
Bool("randomStartPoint", "Use a random start point."),
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values).")
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values)."),
// Both Create- and UpdateScheduleRequest carry this, so it belongs in the shared helper. The
// update PUT is a full replace that writes the value unconditionally, so omitting it used to
// clear a configured pad silently — the same #754 shape as channel graphicsElementIds.
Int("padToNearestMinute", "Pad each item to the nearest N minutes; omit or send null for no padding.")
];
// ---- Tool factories ----
+380 -8
View File
@@ -413,6 +413,14 @@ the image build.
`insecure-registries`**, so without this, cache/base-image/push over the HTTP
registry fails (`http: server gave HTTP response to HTTPS client`).
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`.
**`REGISTRY_PASSWORD` is a scoped PAT (`write:package` + `read:repository`), not an account
password** — deliberately, so head-resolved PR code cannot use it to forge a commit status
(`ci.actions-credential-scoping`, ersatztv#697). If a job ever fails with `token does not have at
least one of required scope(s)`, the fix is to narrow what the job does, **never** to widen the
token to `write:repository` or to put the admin password back. Note what the scope still reaches:
`write:package` covers `ersatztv:prod` (the tag prod's stack follows) and `ersatztv-ci:<sha>` (the
toolchain image five `container:` jobs execute), so this is the deployment supply chain, not an
inert endpoint — see `ci.actions-credential-scoping`.
4. `docker/build-push-action@v6`: amd64-only, `docker/Dockerfile`, `INFO_VERSION`
build-arg, registry layer cache (`type=registry,ref=…:buildcache`,
`cache-to … ignore-error=true`).
@@ -597,6 +605,167 @@ CI-validated, so the tree-match check correctly declines. So the skip is a genui
win (clean, up-to-date, un-rebased merges in quiet periods) — correct-but-conservative by
construction, not a general dedup. It never fires unsafely; when in doubt it runs the full matrix.
### Dropped-step guard on the required jobs (ersatztv#756)
`test` and `migrations` write the only two `docker-build.yml` contexts branch protection requires on
`main`. A step the runner declines to interpolate is **dropped, and the job still concludes
`success`** (ersatztv#751), so in these two jobs that failure is **fail-OPEN**: a required check
reports green having done no work. In `review-verdict.yml` the same drop is fail-closed — the status
is simply absent and the merge is blocked — which is why #751 fixed the safe direction first.
Two independent mechanisms hold it, and neither is redundant:
- **A static ban on expression delimiters** in any `run:` body of `test`, `migrations` **and
`build`**. The drop mechanism *requires* an opener in the scalar, so this makes the class
unreachable rather than merely detected — and it is the raw `${{` opener that is banned, not a
well-formed pair, because an unclosed one triggers the same rewrite. When a step genuinely needs a
value, pass it through the step's `env:` block, which is interpolated **per value**, so a bad
payload there cannot take the body with it.
**Why `build` is in the ban although it is not a required context.** Its one delimiter-bearing body
was `Smoke + IPTV E2E`, which runs *after* `Build and push` — so on a `v*` tag the image is already
in the registry as the release candidate and that step is what decides whether the candidate was
ever booted. A drop there publishes an unsmoked candidate, reports green, and `DeployStack
jazz-media` promotes exactly that image. Its two payloads moved into the step's `env:`, so the ban
cost nothing.
**The ban is re-checked on the release path itself (ersatztv#767).** It used to be enforced only by
the `script-tests` job, which lives in `pr-checks.yml` (`on: pull_request`) and is **not** a
required context — a *review-time* check on the PR that would introduce a delimiter, not a gate on
the release. `pr-checks.yml` does not run on a `v*` tag push at all, so a delimiter that ever
reached `main` would still drop `Smoke` on the tag build and go green; `main` being PR-only (#743)
meant such a change had to pass through a PR where `script-tests` reddens, but a red on a
non-required check does not block the merge server-side.
There is now a **`scan` job** (`Delimiter ban (release path)`) that runs the PyYAML-based ban test,
and **`build` lists it in `needs:`**. That single edge is the fail-closed property: a red `scan`
means `build` is skipped outright, so the image is never built, let alone pushed.
**Why a job and not a step inside `build`.** A step cannot protect the job it lives in. `build` is
what publishes, so a guard step there fails **open** if the runner drops it — and the defence
("the guard's own body has no opener, so it cannot be dropped") is circular when the only thing
enforcing that property is the same PR-only test being backstopped. This was the first design and
two independent reviews rejected it for exactly that.
**Why it runs the real pytest and not a bespoke scanner.** The same first cut hand-parsed the
workflow YAML in stdlib Python, to avoid provisioning PyYAML on `build`'s bare runner. Review found
~10 **false negatives** in that parser in one round — flow mappings (`{run: …}`), a quoted
`"run":` key, aliases, multiline quoted scalars — making it strictly *weaker* than the check it
backstopped, in the only direction that matters for a security gate. Running the existing test
needs no second definition of "what is a `run:` body", so it has no drift surface at all. `scan`
runs on `small` and provisions Python the same way `script-tests` does.
The wiring is held by `scripts/tests/test_ci_release_path_scan_job.py` — `build` depends on it, it
carries **no job-level `if:`** (one that excluded the tag push would restore the hole; one that
skipped the job would skip `build` too), no step is `continue-on-error`, it actually invokes the
ban test, and every one of its own `run:` bodies is delimiter-free. Its steps also carry #756
markers and a trailing assert, so a drop *inside this job* is caught as well.
What this does **not** claim: that no step can ever fail to run for a reason other than the
interpolation drop. It moves the terminal assumption — to fail open you must now drop the pytest
step **and** the assert step, rather than either one alone.
Measuring a guard on this path does **not** require cutting a release, and an earlier draft here
claiming it would was simply wrong: `build` runs on every push to `main`
(`if: github.event_name != 'pull_request'`), and a `workflow_dispatch` on any other ref runs the
job while `Build and push` publishes nothing (its `push:` is gated on `main`/`v*`). That is how
#767 was verified — see the decision record for the run ids.
`functional-e2e` is delimiter-free too but is deliberately **not** banned: it is
advisory by declaration, and the rule is "ban where a drop is consequential", not "ban wherever it
is currently free". `api-docs` and `format` keep one `github.base_ref` each in a detect step and
gate nothing that ships.
- **Runtime per-step markers**, for a step that fails to run for any *other* reason. Every `run:`
step that is not `continue-on-error: true` calls
`"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its **first act**, and the job's last
step calls `ci-step-ran.sh assert --always … --gated …`, which fails the job when an expected key
was never recorded.
**Per step, not per job.** A marker written by the first step only proves the job *began*, which was
never in doubt. The drop that costs something is `Test`, `Build` or a migration replay — all well
past step one — so a job-level marker would have been a guard that cannot see the case it exists for.
**The guard carries no `if:`, and that is deliberate.** The #751 guard uses `if: always()` because its
job has one real step. These have a dozen, and a genuine failure in an early step legitimately skips
every later one — an `always()` guard would then announce a false *"these steps never executed:
typecheck web-test build dotnet-test"* on top of every ordinary red build, and a guard that cries wolf
gets deleted. (`migrations` is smaller — six marked steps — but the same argument applies, and its
guard comment is worded for its own keys rather than copied from `test`'s.) The default `if:` is `success()`, which is the wanted condition, and the invariant that
makes relying on it safe rather than lucky is: **the guard is skipped only when an earlier step
failed, and that already fails the job**. So *guard skipped ⇒ job red*, and every path to a green job
runs the guard. A dropped step is invisible precisely *because* it concludes `success` — which keeps
the job green and therefore reaches the guard.
That invariant has one path where it could plausibly be false and where being wrong would be silent:
a step marked `continue-on-error: true` that FAILS. If that flipped `success()`, the guard would be
skipped on a job that still concluded green — the guard rendered a no-op by exactly the failure mode
it exists to catch, with no signal. The `test` job has three `continue-on-error` steps and two of them sit immediately before the guard,
so this is a live path, not a theoretical one. **Measured** (scratch PR #766, run 1913 job 8075): the last
advisory step was made to `exit 1`, the log carries `❌ Failure - Main Report peak container memory`,
and the guard **still ran**, reported `All 12 expected step(s) executed`, and the job concluded
`success`. A failing `continue-on-error` step does not flip `success()` on this runner, so the
invariant holds where it mattered most. That run is also the `test` job's full twelve-key positive
control on the build lane.
**Adding a step to either job?** Mark it, and add its key to that job's guard list in the right
bucket (`--always` for the two detect steps, `--gated` for anything carrying the docs-only /
already-validated `if:`). `scripts/tests/test_ci_dropped_step_guard.py` derives the expected set from
the workflow, so an unmarked step or a bucket mismatch is a red — it does not rely on anyone
remembering. One caveat, since this section is careful about it elsewhere: that red is `script-tests`,
the same non-required, PR-only check discussed above. For the delimiter ban on `test`/`migrations`
that hardly matters, because the runtime guard is the fail-closed backstop — but a **newly added,
unmarked** step is caught by the static test *alone*, since the runtime guard cannot expect a key
nobody declared.
**The marker path is keyed on job + run id + attempt** — and be precise about why, because the
obvious justification is a #751 measurement that does *not* transfer. #751 found `RUNNER_TEMP` to be
`/tmp` and called it "not a private per-job directory", but that was taken on `review-verdict.yml`,
which runs *without* a `container:`. These two jobs run **inside** the CI toolchain image, so their
`/tmp` is the job container's own and starts empty. The fresh container is therefore what actually
rules out a stale marker here; the keying is defence in depth against a lane change nobody would
think to re-check this against. `GITHUB_JOB` and `GITHUB_RUN_ID` are *measured* present and the
script refuses without them rather than falling back to a name other runs share.
`GITHUB_RUN_ATTEMPT` is required too — but **how** that was established is the part worth keeping,
because the first two attempts at it were both worthless. Grepping a job log for the variable *name*
proves nothing: logs do not dump the environment. Inferring it from the *absence* of the script's
"not set" warning proves nothing either, because that warning goes to **stderr**, and whether step
stderr reaches a job log here was itself never established — the control offered for that turned out
to be an `::error::` line this script writes to *stdout*. So the script was made to **report its
resolved identity on stdout**, where capture is not in question, and the answer was simply read off
this change's own run: `Marker identity: job=test run=1916 attempt=1 (from the runner)`, and the same
for `migrations`. Both required jobs, on the lane that matters.
That measurement is what promoted it from warn-and-default to required, and it is why the residual
this paragraph used to describe — a rerun inheriting attempt 1's markers — no longer exists. The
identity line stays, as the standing evidence a future reader checks first if the keying is ever
doubted again.
**The premise was re-measured on the build lane.** The whole thing rests on the runner still executing
a later step after dropping an earlier one. #751 established that on the `small` lane; these jobs run
in a `container:` on `ubuntu-latest`, so it was measured there rather than assumed — scratch PR #765
(Gitea 1.27.1, 2026-08-10) reintroduced the exact #751 defect in the `test` job's `revalidate` step.
Recorded outcome (job `test`, run 1910, 20:03:49→20:13:31Z — a full 9m42s heavy run, so `Build` and
`Test` really executed):
- `Unable to interpolate expression 'format('# PROBE ONLY … {0}\n…', pr number)'` at 20:04:06 — the
step was **dropped**, exactly as #751 describes, and it reported conclusion `success`.
- **Every other marked step still ran** — eleven markers were recorded, ten of them AFTER the drop
(`restore npm-ci check-api lint typecheck web-test web-build strip-scanner build dotnet-test`),
`detect` being the eleventh and earlier. The premise holds on this lane.
- The guard ran at 20:13:29, reported `These steps of job 'test' never executed: revalidate`, and was
the **only** ❌ in the entire job log — every other step succeeded. Without it this run would have
concluded `success` having never executed that step, which is precisely the fail-open being closed.
- Incidental but kept: the dropped step's output arrived as `ETV_REVALIDATE_SKIP:` **empty**, not
`false` — the case the guard must read as "widen what is required", never as a skip.
**The positive control is the same run's `migrations` job**, which the probe did not touch: it marked
all six steps, the guard reported `All 6 expected step(s) executed: detect revalidate restore build
sqlite mysql`, and the job concluded **success**. So one run demonstrates both directions on the build
lane — a drop caught and reddened, and a clean job passing. The twelve-step `test` positive control is
this change's own CI run.
Full rationale: `docs/decisions/records/ci/required-job-step-execution-markers.md`.
### `docs-reminder` job (non-blocking, PR-only — in `pr-checks.yml`)
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
@@ -765,6 +934,40 @@ from `Build ErsatzTV Image / …` to `PR Gates / …`) does not affect merges. T
unreviewed commit from merging** (ersatztv#622). It is not produced by a job's success/failure; it
is a commit status that `scripts/post-review-verdict.sh` POSTs onto one specific sha.
**`main` is PR-only AND admin-override-proof, and it takes both to make the check load-bearing**
(ersatztv#743, `release.main-direct-push-disabled`). Gitea evaluates `status_check_contexts` when it
**merges a PR** — a direct `git push origin HEAD:main` never consults them. So until 2026-08-05 the
entire gate was skippable with no forgery at all, which was cheaper than every route enumerated in
#697. `main` now carries **two** fields, and citing either alone is a mistake:
- `enable_push: false` — a direct push is refused server-side at pre-receive (`Not allowed to push to
protected branch main`), for every account including a site admin. The contents API is refused too
— measured, HTTP 403 `user cannot commit to repo`. The web editor, upload, apply-patch, revert and
cherry-pick paths share that same `CanUserPush` predicate and are therefore expected to refuse as
well, but were not probed (source-attested only).
- `block_admin_merge_override: true` — without it (the default is `false`), a repo admin could
`POST /pulls/{n}/merge` with `force_merge: true` and merge straight past a missing or red
`review-verdict/h10`. Disabling push alone just moves the bypass from the push path to the merge
path, since `timothy` is admin and is the identity every session already uses. **Source-attested,
not probed** (Gitea 1.27 `CanBypassBranchProtection`): verifying it by experiment means merging an
unreviewed PR, so the field was set rather than measured. Setting it is safe under either
semantics; re-confirming the bypass itself rides with ersatztv#747.
**Operator recovery when a required context gets stuck.** `block_admin_merge_override: true` removes
the "Merge (admin)" / `force_merge: true` escape that used to unstick a PR whose required context was
absent or wrongly red — a recurring situation here (a killed run overwriting a newer green, an
advisory red counted into the combined status, a gate workflow that cannot post). That escape is gone
*by design*: it was also the bypass. The supported recovery is to fix the status
(re-run the job, or re-post the verdict with `scripts/post-review-verdict.sh`); the last resort is to
`PATCH .../branch_protections/main` setting `block_admin_merge_override: false`, merge, and set it
straight back. Do the last one deliberately and say so in the PR — it is the one action that
re-opens the hole this section exists to close.
Practical consequences: **every** change to `main` goes through a PR, including a one-line docs fix;
and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control —
they are fail-open and `--no-verify` bypasses them. Tag pushes are unaffected (separate mechanism;
`tag_protections` is empty), so the release cut in "Cutting a release" still works unchanged.
**The hole it closes.** `pretooluse-merge-consent.sh` proves its three consent conditions at the
moment the merge tool is called. Pass `merge_when_checks_succeed=true` and Gitea performs the merge
*later*, against whatever head is green then — while the Done-when and review-verdict checks were
@@ -834,7 +1037,18 @@ as a human verdict — a non-null `.creator.login` **and** a `Review-verdict:` d
**re-derived** rather than inherited. (Measured: a status POSTed with a user credential carries a
creator; one POSTed by an Actions job carries `"creator": null`.) Without this, an exemption obtained
once was accepted unchanged on every later run. This is a *provenance* check, not an authentication
one — someone who can POST statuses directly can still impersonate a verdict, which is ersatztv#697.
one — someone who can POST statuses directly can still impersonate a verdict (ersatztv#697). That
provenance asymmetry is *why* the credential scoping in `ci.actions-credential-scoping` mattered: a
forgery through a **user** credential inherits as a human verdict, while one through a job's
`GITEA_TOKEN` carries `creator: null` and is re-derived, so it must win a race. CI's registry secret
was a user credential — the admin account — and no longer carries status-write. **`RENOVATE_TOKEN`
still is one** (`write:repository`, a real bot account), and secrets are a per-repo store any
PR-added workflow can reference, so that route is narrowed rather than closed; tightening this check
from "non-null creator" to an allow-list of approved reviewers is what would close it
(ersatztv#742). A collaborator's own personal token still can, and no repo-side change closes that.
Note also that re-derivation is **not** a race the attacker can lose: it fires only on the trigger's
`types`, and posting a status is not one of them, so a POST timed after the last PR event stands
until the next one.
Deciding either exemption requires the PR's **complete** changed-file list, which the workflow does
not compute itself: it calls `scripts/pr-changed-files.sh`, the single shared implementation also
@@ -901,12 +1115,24 @@ as establishing that the gate cannot be forged (see the residual below, and ersa
here **only** because this job never checks out or executes head-supplied code. Verified on this
instance with four scratch PRs rather than inferred from GitHub; full rationale in
`docs/decisions/records/ci/gate-trigger-base-resolved.md`. **This closes the rewrite route through
this workflow, not the class:** `docker-build.yml` is also head-resolved and its `ETV_STATUS_AUTH`
credentials can write statuses, so it can still forge `review-verdict/h10` — it must stay on
`pull_request` because it builds the PR's code, so it needs a read-only status identity instead
(ersatztv#697) — and the inventory is every workflow, not that one, because Gitea injects a
write-capable `GITEA_TOKEN` into every job and branch protection binds the *context*, not its
issuer. The exemption path has separate defects of its own (ersatztv#698). One operational
this workflow, not the class:** `docker-build.yml` is also head-resolved and must stay on
`pull_request` because it builds the PR's code, so it got the read-only status identity instead —
its `ETV_STATUS_AUTH` is now a PAT scoped `write:package` + `read:repository`, which the status
endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). The inventory was never that one
workflow, though: Gitea injects a write-capable `GITEA_TOKEN` into every job and branch protection
binds the *context*, not its issuer. Gitea >=1.26 with the Actions default set to **Restricted**
(server-management#714) binds the injected token, but does not close the class either — not against
a personal token, and not against `RENOVATE_TOKEN` (ersatztv#742). **And none of it was necessary:
direct pushes to `main` were server-side permitted, so the gate could be skipped without any forgery
(ersatztv#743). That is now CLOSED — `main` carries `enable_push: false` **and**
`block_admin_merge_override: true`, so it is reachable only through the PR merge path, the one path
on which Gitea evaluates `status_check_contexts`, and an admin cannot `force_merge` past them
(`release.main-direct-push-disabled` — neither field is citable alone).** Note the fix is *disabling* push, not whitelisting it: a
push whitelist naming `timothy` was measured to still admit the push, and `timothy` is the identity
every session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form would have
closed nothing. The block binds a site admin at pre-receive but not a credential that can first
PATCH branch protection off — an accepted residual, recorded in that decision. The
exemption path has separate defects of its own (ersatztv#698). One operational
consequence of the trigger change: a PR whose base is not `main` now gets **no**
`review-verdict/h10` at all. That is fail-closed. `edited` **is** now among the trigger's `types`
(ersatztv#698), so a PR retargeted onto `main` reclassifies instead of staying statusless until its
@@ -937,7 +1163,153 @@ trust the editing PR's own checks. Verify the way ersatztv#672 did:
branches.
The same shape is what makes a `branches:`/`types:` change verifiable at all, since neither can be
observed from the editing PR.
observed from the editing PR. Note step 2 requires the scratch **base**'s own `branches:` filter to
name that base — the definition comes from the base, so a base the filter does not admit produces no
run at all.
⚠️ **Never write an expression delimiter inside a `run:` body here — a comment is NOT inert**
(ersatztv#751, `ci.workflow-run-body-no-expressions`). A `run:` body is not shell when the runner
reads it. The runner scans the whole scalar for the expression opener and, on finding one, rewrites
the **entire** body into a single `format(...)` call so the result can be spliced back in. That
rewrite is all-or-nothing: a payload that does not evaluate fails the interpolation of the whole
scalar, and **the runner then drops the step and concludes the job `success`**.
That is not hypothetical. From 8f6d4f443 (2026-08-03) to 2026-08-06 the classify step **never ran**.
The #706 note above, explaining why a concurrency group does not work here, quoted a `concurrency:`
snippet containing a PR-number expression *as an illustration*, in a shell comment. `pr number` is not
a valid expression. So `review-verdict/h10` was posted by nothing but a human hand for three days,
both exemption classes silently stopped working, and every run reported success. The prose documenting
a fix disabled the fix.
**The silent green is the real defect.** An absent required status reads as "not reviewed yet", which
is indistinguishable from the correct pending state — so an ordinary PR looked ordinary while the gate
was dead, and the cost landed only where no human was in the loop. PR #739 (docs-only) merged
2026-08-05 with **zero** commit statuses on its head, and got in only because admin force-merge was
still enabled; ersatztv#743 removed that escape the next day, so a docs-only or Renovate-manifest PR
arriving after that would simply have been stuck with no bypass. The two Renovate PRs in the window
escaped by timing, merging minutes before the bad commit.
Three things now hold the line, and they are deliberately different in kind:
- **The prose names expressions instead of quoting them** — write "a
`github.event.pull_request.number` expression", not the delimiters. Pass values in through the
step's `env:` block, which is interpolated per value, so a bad payload there cannot take the body
with it.
- **A start-marker guard turns a dropped step RED.** The classifier writes a marker as its first act
and an `if: always()` step fails the job when it is missing. It asserts execution *started*, never
that it completed — the classifier has several legitimate `exit 0` abstention paths. The guard's own
body must stay expression-free, or the mechanism it guards against can delete the guard too, and
that absence would be silent as well.
- **Two static guards**, in `scripts/tests/test_pr_changed_files.py`: no delimiter in *any* `run:`
body of this file (absolute — a dropped step here is a dead merge gate, and its bodies are ~700
lines of prose), and repo-wide, every expression payload's **head token** must name a context or
function the runner can resolve (permissive, because the other workflows interpolate into `run:`
legitimately — 5 occurrences today, in `ci-image.yml`, `docker-build.yml`'s `api-docs`/`format`
and `pr-checks.yml`'s two git-diff gates; #756 removed `build`'s two and banned that job as
well, so the ban now covers `test`, `migrations` and `build`). Be precise about the second
one's reach: it catches the
historical defect (`pr number`) and a nonexistent context, but **not** a syntactically invalid
payload whose tokens are all known (`${{ github.ref == }}` passes), nor a renamed output
(`steps.metadata.outputs.shortsha` passes — every token after the first is preceded by `.` and is
skipped), nor an unclosed opener. Catching those needs an expression parser. An earlier draft of
this section claimed it caught "a payload that cannot evaluate, wherever it sits"; that was false,
and the corrected claim is the one to rely on.
Worth knowing why nothing caught this for three days: every *other* workflow-shape test in that file
reads `_code_lines()`, which strips comments. That is correct for what it was for, but it encodes the
assumption this bug falsifies. The strict test reads the raw scalar, and must never adopt
`_code_lines`.
⚠️ **A page past the end of `/issues/{n}/timeline` is JSON `null`, not `[]`** — and this instance is
not consistent between endpoints (`/issues/{n}/comments` returns `[]` when empty). The retarget
fence's `count_retargets` gated on `type == "array"`, so it read the real terminator as *unreadable*:
the walk never reached a validated empty page, `rt_ok` was never `yes` for **any** PR, and the fence
therefore withheld **every** exemption `success`. Renovate and docs-only PRs got no status at all —
the same user-visible outcome as the dropped step above, by a completely unrelated route. So fixing
the interpolation alone would not have restored the exemptions.
Two things kept it invisible, and both are worth generalising:
- It shipped in the **same commit** (8f6d4f443) that stopped the step executing, so the fence had
never once run in production. A guard's first real execution is not the same event as its merge.
- The **test double asserted the wrong shape while claiming measured fidelity.** Its comment read
"Real shapes, measured on this instance and deliberately mirrored" and it printed `[]` for a page
past the end. Every fence test was green against a response the server never produces, so the
`array`-only gate was never exercised by the suite either. With the double corrected and the old
gate restored, **most of the fence suite fails** — 18 tests when first measured at `c710db4a1`, 21
once three more fence-dependent tests existed. The invariant is the point, not the count: they had
all been passing for the wrong reason. (Given as a range on purpose — an earlier draft cited a bare
"18", which was stale two commits later, inside a section about stale claims.) When a double claims
fidelity, that claim is a test assertion and needs re-measuring like any other.
The type is now read as a value (`case` over `jq -r 'type'`) rather than through `jq -e`, whose
exit-status semantics already bit this workflow once at jq 1.6, and both `null` and `[]` terminate the
walk. The regression test is parameterised over both shapes because both are live on this server.
`null` is accepted as exhaustion only from **page 2 on** — every real PR's first page carries events
(spot-checked non-empty across #752/#753/#749/#739/#717; the counts are deliberately not recorded here
because timelines grow and an earlier draft's five figures were stale within days), so a `null` first
page is anomalous rather
than empty, and the walk should not certify "no retarget happened" from a response it cannot explain.
**The same nil-slice shape bites `/commits/{sha}/status`** — a third instance, found by cold review of
the fix for the second. A head with no statuses yet returns
`{"state":"pending","total_count":0,"statuses":null}` (measured on PR #739's head). `read_existing_verdict`
gated on `.statuses | type == "array"`, so it hit its `exit 1` and posted nothing at all — fail-closed,
same user-visible outcome. `null` is now accepted there only when `total_count` is 0, so a body that
merely lost its array is still refused and an existing verdict is still protected from a transient
error. `scripts/pr-changed-files.sh` was swept and is unaffected (`pulls/{n}/files` returns `[]`).
**The generalisable rule: a nil Go slice serialises to `null`, so every list-shaped field on this API
is suspect and only a per-endpoint measurement settles it.**
**Establishing that "no verdict exists" needs a second page, and both arithmetic guards for it are
no-ops here.** `read_existing_verdict` concluding absence is what licenses posting an exemption over a
verdict the job cannot see, so that conclusion has to be earned. Two obvious checks were tried and both
proved empty:
- **`.statuses | length` vs `.total_count`** — `total_count` is the count for the **page returned**, not
for the commit. Measured at 1.27.1 on `3aed43c6` (6 contexts): `?limit=1` returns
`len=1, total_count=1`, `?limit=3` returns `len=3, total_count=3`. Equal by construction, so the check
reads as a completeness proof while proving nothing.
- **"refuse when the page comes back full at the requested `limit=100`"** — this instance caps `limit`
at the server-wide `MAX_RESPONSE_ITEMS`, **measured at 50** (`/issues?limit=100` returns 50). A
response can therefore never carry 100 rows, and the comparison was **dead code**. The repo already
documented that cap in `scripts/pr-changed-files.sh`, two test files and `ci.script-tests-job`; the
guard was written against 100 anyway, and a cold review caught it. Hardcoding 50 instead would
re-break the day the setting changes.
So the job **asks the server, and only when it matters**: if the `review-verdict/h10` row is on page 1
there is nothing further to learn (this endpoint returns the latest status per *context*, and a context
cannot recur on a later page). When the row is absent it reads **page 2** — any rows there mean the list
runs longer than one page and a verdict could be beyond it, so it refuses instead of concluding absence.
Cap-independent by construction. Paging is real here: measured `?limit=3&page=2` returning three further
rows, and `page=9` returning the same `statuses: null` terminator.
The `total_count` zero-check also requires the JSON **type** to be a number: `jq -r` renders `0` and
`"0"` identically, so a text compare would accept a schema-corrupted `"total_count": "0"` as "no
statuses".
**The repo-wide expression guard scans PARSED scalars, not raw file text.** A delimiter in an ordinary
top-level YAML comment is inert — the runner never evaluates it — so redding on it is a false positive,
and this file has now produced that false red twice. PyYAML drops those comments. A `run:` body is
itself a scalar and keeps its *shell* comments, which is the point: inside a `run:` scalar a comment is
not inert. Verified both directions by mutation — an inert top-level comment passes; the same payload
in a run-body comment still reds.
**`CLAUDE.md` and `AGENTS.md` are now PROTECTED paths.** `DOCS_ONLY` matched them, so the documents
that *define* the completion protocol, the merge-consent convention and the H10 rule were themselves
docs-only-exemptible while `.claude/` was protected — the same self-exemption the gate rules out, one
directory over. Driving the real classify body with a lone `CLAUDE.md` change produced
`review-verdict/h10=success`. It is fixed here rather than deferred because restoring the exemptions is
what makes it reachable: no exemption `success` was writable at all while the classify step was
dropped. `README.md` is deliberately not listed — ordinary prose, no enforcement. For the same reason,
#706's known residual returns with the working fence: while `rt_ok` was never `yes`, route 1 was closed
by accident.
**That gap is now closed** — `docker-build.yml`'s `test` and `migrations` jobs are also required
contexts, and there a dropped step is **fail-OPEN**: the required check goes green having done no work,
which is strictly worse than an absent status (compare #684). ersatztv#756 gave those two jobs
per-**step** execution markers and extended the delimiter ban to them; see
"Dropped-step guard on the required jobs" above.
It lives in its **own workflow file** on purpose: `pr-checks.yml` sets `cancel-in-progress: true`,
and a cancelled run there would leave an exempt PR with no status and no further push to
+8 -3
View File
@@ -36,6 +36,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](records/blazor/rollback-tag.md) |
| `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](records/blazor/ui-removed.md) |
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](records/channel/origin-marker.md) |
| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance's default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) |
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](records/ci/batch-pushes-no-cancel-route.md) |
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](records/ci/build-once-rejected.md) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) |
@@ -43,10 +44,10 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](records/ci/decisions-lifecycle-flake.md) |
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](records/ci/docs-only-detect-shallow-safe.md) |
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](records/ci/docs-only-skip-steps.md) |
| `ci.exemption-provenance` | The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR's live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR's immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR's — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow's `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked — the job refuses to write at all if the PR's timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Path predicates are evaluated by COUNTING with `grep -c`, never `\| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`. | 2026-07-29 | [link](records/ci/exemption-provenance.md) |
| `ci.exemption-provenance` | The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR's live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR's immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR's — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow's `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked — the job refuses to write at all if the PR's timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `\| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`. | 2026-07-29 | [link](records/ci/exemption-provenance.md) |
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](records/ci/format-gate-folder-mode.md) |
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 2026-07-16 | [link](records/ci/functional-e2e-harness.md) |
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. Tracked in #697; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (a user credential's forgery carries a real `creator` and is inherited as a human verdict; an Actions job's carries `creator: null` and is re-derived — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The injected token stays write-capable until Gitea >=1.26 with a Restricted default (server-management#714), and a collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](records/ci/gitea-milestone-filter-noop.md) |
| `ci.grep-q-pipefail-inversion` | In any script running under `set -o pipefail`, a security or classification predicate of the form `producer \| grep -q…` is FORBIDDEN: `grep -q` exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so `pipefail` reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (`grep -q… <<< "$data"`) is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an `if`/`!` that failure flips the predicate the same way. COUNT instead — `n=$(printf '%s\n' "$data" \| grep -cE "$re")` — because `grep -c` drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an `if`/`elif` condition: inside `$( )` an `exit` leaves only the subshell and `set -e` does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate `.gitea/workflows/review-verdict.yml` and the advisory hook `.claude/hooks/pretooluse-merge-consent.sh`. | 2026-07-29 | [link](records/ci/grep-q-pipefail-inversion.md) |
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](records/ci/infra-shaped-red-under-load.md) |
@@ -55,6 +56,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) |
| `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) |
| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) |
| `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always <keys> --gated <keys>`, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) |
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) |
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](records/ci/runner-placement.md) |
| `ci.script-tests-job` | The `scripts/tests/` pytest suite runs on every PR as a dedicated `script-tests` job in `pr-checks.yml` (`runs-on: small`, `setup-python` + `pip install pytest`, `PYTHONPATH=. python3 -m pytest scripts/tests -q`), unconditionally rather than behind a `scripts/**` path filter, and **never as a step inside `decisions-guard`** — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it. | 2026-07-26 | [link](records/ci/script-tests-job.md) |
@@ -64,6 +66,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts `change_target_branch` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if the count moved. The COUNT is the key because the branch NAME is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption — while the event count is monotonic and cannot alias. Abstaining is a handoff, not a stall, and that is the property the design rests on: every retarget fires `edited`, which is in this workflow's `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops and the last run writes the final answer. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow's `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when paging reached a validated EMPTY page; an untrusted count (unreadable page, non-array body, non-numeric length, page cap hit) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` cannot turn an unreviewed head green while withholding it would strand ordinary PRs for no safety gain. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human's state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR's exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run's `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run's mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) |
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) |
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) |
| `ci.workflow-run-body-no-expressions` | A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step's `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard's own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing now also covers `docker-build.yml`'s `test` and `migrations` jobs, where a dropped step is fail-OPEN (the required check goes green having done no work) rather than fail-closed as it is here — see `ci.required-job-step-execution-markers`, which adds per-STEP markers there and extends this file's delimiter ban to those two jobs. It is still not a repo-wide property, but the remaining exceptions are narrower than this record originally said: `build` was brought into the ban too (its `Smoke + IPTV E2E` runs AFTER the image is pushed, so a drop there ships an unsmoked release candidate — its two payloads moved to `env:`, so the ban was free), leaving only `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step that gates nothing that ships. | 2026-08-06 | [link](records/ci/workflow-run-body-no-expressions.md) |
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](records/concurrency/diff-scalar-fanout.md) |
| `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](records/concurrency/etag-rotation-completion.md) |
| `concurrency.force-write-non-ifmatch` | Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500). | 2026-07-12 | [link](records/concurrency/force-write-non-ifmatch.md) |
@@ -95,6 +98,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](records/iptv/logo-drives-bug-preset.md) |
| `locking.entitylocker-atomic-flags` | `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing. | 2026-07-11 | [link](records/locking/entitylocker-atomic-flags.md) |
| `mcp.server-foundation` | `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. | 2026-07-20 | [link](records/mcp/server-foundation.md) |
| `mcp.tool-schema-openapi-parity` | Every POST/PUT/PATCH tool in `ToolCatalog` declares exactly the request-body properties its endpoint accepts, each with a matching type, and EVERY tool (read and write) declares exactly its endpoint's query parameters, both asserted against the generated `ErsatzTV/wwwroot/openapi/v1.json` (linked into `ErsatzTV.Mcp.Tests`) by `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields` and `Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`. A field the endpoint accepts but the tool omits is a DEFECT, not a deferral: on the full-replace tools (channel update, schedule update, custom-order) the omission is silently applied as a clear. The write tools are NOT uniformly full-replace — add-collection-items is additive, and several leave an omitted field unchanged — so each tool description states its own semantics. An omitted query parameter is UNREACHABLE, not merely undocumented, because `ToolArgumentValidator` rejects undeclared arguments. | 2026-08-06 | [link](records/mcp/tool-schema-openapi-parity.md) |
| `media.lastscan-null-boundary` | A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. | 2026-07-18 | [link](records/media/lastscan-null-boundary.md) |
| `media.remote-stream-probe` | `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. | 2026-07-19 | [link](records/media/remote-stream-probe.md) |
| `media.remote-stream-probe-externaljson` | External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows. | 2026-07-20 | [link](records/media/remote-stream-probe-externaljson.md) |
@@ -120,8 +124,9 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `process.subagent-drop-resume` | Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | [link](records/process/subagent-drop-resume.md) |
| `release.api-contract-ci-gate` | A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship regenerated OpenAPI artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`) in the same diff, enforced by a blocking `api-docs` CI job that regenerates-and-diffs against a fresh build. | 2026-07-12 | [link](records/release/api-contract-ci-gate.md) |
| `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](records/release/done-when-merge-consent.md) |
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push``prepush-rebase-check.sh`. | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) |
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push``prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11). | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) |
| `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](records/release/live-e2e-required.md) |
| `release.main-direct-push-disabled` | Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works. | 2026-08-05 | [link](records/release/main-direct-push-disabled.md) |
| `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](records/release/merge-consent-autogrant.md) |
| `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](records/release/migration-rehearsal-prodcopy.md) |
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) |
@@ -0,0 +1,89 @@
---
key: ci.actions-credential-scoping
title: '2026-08-05 — CI''s registry credential is a scoped PAT, not the admin password, because Gitea cannot separate status-write from repo-write (#697)'
status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance''s default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation.'
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #420, server-management#714'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
---
**What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and
`docker-build.yml` triggers on `pull_request` — head-resolved — so a PR's own code got instance-admin
credentials. Basic auth carries no scope: the secret pushing an image administers every repo on the
instance.
**Why the credential and not only the triggers.** Patching triggers enumerates *instances* of "a
ref-resolved workflow obtains status-capable credentials", and adding a new workflow file is itself a
route, so that enumeration never completes. But it is not either/or: `ci-image.yml`'s unfiltered
`push:` is path-scoped to itself, so any branch push runs attacker YAML on a docker-capable runner
with no PR. Scoping bounds what a job may DO; only a filter bounds whether it RUNS. That filter is
**#744**, not this record: editing `ci-image.yml` re-points `ci-image-pin`'s `expected` at the editing
commit and staleness-fails a **blocking** job. That is a toll, not a wall — the documented two-step
(publish `:<short sha>`, then bump all five pins) clears it — but a rebase rewrites the sha and charges
it again, so it lands alone (`land-toolchain-image-change-separately`).
**What the scoped token still reaches — not "just a registry credential".** `write:package` over owner
`timothy` writes `ersatztv:prod` (the floating tag prod's `jazz-media` stack follows) and
`ersatztv-ci:<sha>` (the toolchain image *executing* five `container:` jobs). A sha-named tag is not an
immutable artifact (no container tag immutability in Gitea 1.25 — INFERRED), so overwriting the pinned
tag is code execution inside CI, chaining back into the routes below. This is the deployment supply
chain for prod and CI itself.
**Admin ownership is a real residual.** The PAT is minted under `timothy`, a site admin. The 403 proves
the scope gate binds the *status* endpoint ahead of any admin bypass; it does NOT establish that for
*package* endpoints, where Gitea resolves permission by owner and an admin passes object-level checks,
so the token's package reach is plausibly wider than this repo. A non-admin bot account would close
this, but is not free: packages live in a user namespace only its owner and admins can write. Both
halves INFERRED, neither probed.
**Provenance, corrected.** `review-verdict.yml` leaves an existing `h10` alone only when it is
positively identifiable as human — non-null `.creator.login` plus a `Review-verdict:` description
(`release.verdict-status-check`). A user credential posts with a real creator and is INHERITED; an
Actions job posts `creator: null` and is re-derived. **That asymmetry is not protection.** Re-derivation
fires only on `opened|reopened|synchronize|ready_for_review|edited`, and posting a status is none of
them, so a POST timed after the last event stands until the attacker merges. The gain here is that PR
code can no longer escalate to instance admin — NOT that the durable forgery route is closed.
**The boundary is everything reachable from a job, not the secret store.** The store is a useful lower
bound — auditing it rather than the workflow set is what found `RENOVATE_TOKEN` and
`SERVERMGMT_DEPLOY_KEY` below, since any PR-added workflow can reference any secret. But
`GITEA_TOKEN` is injected and never in the store; nor is the credential
`actions/checkout` persists into `.git/config` (`docker-build.yml` omits `persist-credentials: false`);
and jobs reach the runner's docker daemon.
**Measured vs inferred.** Measured here: the `v1.25.4` scope enum (`access_token_scope.go`) has no
`status` entry; the `reqRepoWriter` gate (`routers/api/v1/api.go`); the probes in `mechanics`. Read from
docs, NOT verified (2026-08-05): `permissions:` landed in 1.26.0 (Gitea PR #36173); no `app.ini` lever
at any version; Gitea rejects GitHub's `statuses`/`checks` scopes.
**Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1.**
The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is
therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403
probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their
date and are the best evidence available, but do not cite them as current behaviour without
re-probing. Re-verification of the 1.25.4-pinned claims across the CI docs is tracked separately.
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
`write:repository` bot PAT in the same store, posting with a real creator, and cannot be scoped down
because Renovate needs repo write (#742). The injected `GITEA_TOKEN` is write-capable in every job;
only Gitea >=1.26 with the Actions default set to **Restricted** binds it (server-management#714) —
the version half of that condition is now satisfied (1.27.1) but the *default* half is unverified, so
treat this route as still open until probed. A
collaborator's own token always can. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag
may point at ANY commit — a prod image with no PR, review or status (tag protections are empty).
**And none of it was necessary: direct pushes to `main` were server-side permitted, so the gate was
bypassable with no forgery at all (#743).** That route is now closed — `main` carries
`enable_push: false` (`release.main-direct-push-disabled`), which removes `main` as a destination for
every write-only credential in this list, including the injected `GITEA_TOKEN` and `RENOVATE_TOKEN`.
It does not remove them as *forgery* routes on the PR path, and it does not bind an admin credential,
which can PATCH the protection off first. Correction to this record's earlier wording: a push
*whitelist* would NOT have closed more of the class than the upgrade — measured 2026-08-05, a
whitelist naming `timothy` still admitted the push, and every credential here acts as `timothy`.
Treat this list as "at least these", never exhaustive. `SERVERMGMT_DEPLOY_KEY` remains in the
store though its `bump-prod-compose` job went in `1b5efd7b9`, and its key on `timothy/server-management`
is `read_only: false` — write access to the repo holding prod's GitOps stack definitions. Left in place
by explicit decision 2026-08-05; recorded so it is accepted, not forgotten. Severity throughout: push
access required, so a compromised contributor or subverted automated session, never an anonymous one.
@@ -5,7 +5,7 @@ status: active
since: '2026-07-29'
supersedes: none
superseded-by: none
rule: 'The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR''s live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR''s immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`.'
rule: 'The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR''s live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR''s immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`.'
signals: 'forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py` · issues: #698, #697, #672, #663, #649, #632'
mechanics: '`scripts/pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>` (5 args; a 4-arg call exits 2); workflow env `BASE_REF: ${{ github.event.pull_request.base.ref }}`; `BOT_MANIFESTS` anchored allow-list; short-circuit requires `.creator.login` non-null AND description matching `^Review-verdict:`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
---
@@ -79,9 +79,10 @@ test still fails: the guard degrades toward re-deriving, never toward trusting.
**What this does NOT close.** Anyone who can POST statuses directly can write both a creator and a
`Review-verdict:` description and impersonate a verdict; branch protection binds the *context*, not its
issuer. A provenance check, not an authentication one — that is `#697`, left open because its durable
fix is credential scoping, partly server-management territory. Severity as `#672`: requires push
access, so the threat model is a compromised contributor.
issuer. A provenance check, not an authentication one — that was `#697`'s registry credential, fixed by
scoping it off `write:repository` (`ci.actions-credential-scoping`); `GITEA_TOKEN`, `RENOVATE_TOKEN`,
and a collaborator's own token still can. Severity as `#672`: requires push access, so the threat model
is a compromised contributor.
**Verification honesty.** Route 1 was reproduced live; the "and now it fails" half cannot be shown from
a PR, because `pull_request_target` resolves this definition from `main` — the self-test gap
@@ -5,7 +5,7 @@ status: active
since: '2026-07-28'
supersedes: none
superseded-by: none
rule: 'The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR''s own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch''s gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree''s scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator''s own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. Tracked in #697; the exemption path has its own separate defects in #698.'
rule: 'The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR''s own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch''s gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree''s scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator''s own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI''s registry secret was the ADMIN account''s basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential''s route (a user credential''s forgery carries a real `creator` and is inherited as a human verdict; an Actions job''s carries `creator: null` and is re-derived — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger''s `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The injected token stays write-capable until Gitea >=1.26 with a Restricted default (server-management#714), and a collaborator''s own token remains unfixable; the exemption path has its own separate defects in #698.'
signals: 'workflow definition resolved from head, PR rewrites the gate that judges it, self-approve a required status check, pull_request_target vs pull_request, gate trigger branches filter, attacker-supplied base branch, how to test a change to review-verdict.yml, workflow not exercised by its own PR, gate edit goes live only on merge, required_approvals 0 does not bind an author, forged commit status inherited by sha · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #672, #663, #649, #622'
mechanics: '`on: pull_request_target: {branches: [main], types: [opened, reopened, synchronize, ready_for_review, edited]}` (`edited` added by `ci.exemption-provenance` so a retarget reclassifies); asserted by `test_the_workflow_trigger_is_pull_request_TARGET_scoped_to_main` in `scripts/tests/test_pr_changed_files.py`; the job''s own context is renamed to `... (pull_request_target)` and must stay OUT of branch protection''s required list'
---
@@ -54,7 +54,11 @@ inventory is not a short list: Gitea injects `GITEA_TOKEN` into **every** job, d
read/**write**, so head-resolved, `push`-triggered and `workflow_dispatch` workflows alike are routes
(1.24+ loads a dispatched definition from the selected branch). A collaborator's own API token is a
route with no workflow at all — branch protection binds the *context*, not its issuer. Full inventory
in `#697`; the exemption path's own defects are `#698`. No in-repository test can establish
in `#697`, whose credential half is resolved in `ci.actions-credential-scoping` — the registry secret
no longer carries status-write. That does NOT leave the workflow routes provenance-free: any
PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store,
whose status carries a real creator and IS inherited (`#742`). The exemption path's
own defects are `#698`. No in-repository test can establish
status-authority isolation: the sibling guard added here catches only plain-text naming of the
context.
@@ -0,0 +1,116 @@
---
key: ci.required-job-step-execution-markers
title: '2026-08-10 — every consequential `run:` step in docker-build.yml''s two REQUIRED jobs records that it executed, and a trailing guard fails the job when the set is incomplete (#756)'
status: active
since: '2026-08-10'
supersedes: none
superseded-by: none
rule: 'A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`''s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its FIRST act, and the job''s LAST step calls `ci-step-ran.sh assert --always <keys> --gated <keys>`, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step''s `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked.'
signals: 'required check green but no work done, step never ran but job green, Build & test green in seconds, EF migration integrity green without replaying, missing Run Main step marker, Unable to interpolate expression format(, dropped step docker-build, ci-step-ran.sh, marker file, expression delimiter in a required job · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-step-ran.sh`, `scripts/tests/test_ci_dropped_step_guard.py`, `scripts/tests/test_ci_release_path_scan_job.py` · issues: #756, #751, #684, #767'
mechanics: '`scripts/ci-step-ran.sh` owns the marker path so it exists ONCE and the write and the read cannot diverge. It is keyed on `GITHUB_JOB`/`GITHUB_RUN_ID` — REQUIRED, refusing rather than falling back to a reusable name — plus `GITHUB_RUN_ATTEMPT`. All three REFUSE rather than falling back to a reusable name. The third was warn-and-default until its presence was measured: grepping a log for the variable NAME proves nothing, and inferring it from the absence of a stderr warning proves nothing either (stderr capture was itself unestablished), so `assert` was made to print `Marker identity: job=… run=… attempt=… (from the runner)` on STDOUT and the answer was read off run 1916 for both required jobs. That line is retained as standing evidence. Do NOT justify the keying with #751''s "RUNNER_TEMP is /tmp, not a private per-job dir": that was measured on a job with no `container:` and does not transfer — these jobs get a fresh container, which is the primary protection, and the keying is defence in depth. Held by `scripts/tests/test_ci_dropped_step_guard.py`: static (marker set derived from the workflow equals the guard''s expectations, bucket matches each step''s `if:`, guard is last / has no `if:` / is not advisory / has no delimiter) and behavioural (the guard''s real command line executed against markers written by the steps'' real marker lines, dropping each key in turn). The release-path `scan` job (#767) runs the existing PyYAML-based ban test rather than a second implementation, so there is no drift surface; `scripts/tests/test_ci_release_path_scan_job.py` holds the WIRING instead — that `build` needs it, that it carries no job-level `if:` (one excluding the tag push restores the hole, one skipping the job skips `build` too), that no step is advisory, that it actually invokes the ban test, and that its own run bodies are delimiter-free. Its steps carry markers and a trailing assert of their own, verified by the same drop-each-key-in-turn behavioural pattern.'
---
**Why per step, when #756 proposed per job.** A job-start marker answers "did this job begin", which
was never in doubt. The fail-open it is supposed to close is a required context reporting success
while the work inside it did not happen, and the steps that carry that work are `Test`, `Build` and
the two migration replays — all of them well past step one. A guard positioned where it cannot see
the case it was built for is the "guard that never executed" failure one level up, and this repo has
now shipped that twice in the same file (#751's retarget fence, and #751's own guard).
**Why a script rather than an inline body, when #751 chose inline.** Two reasons and the second is
the load-bearing one. The path literal exists once, so the write and the read cannot drift — #751
carries it twice and spends real test effort proving the copies agree, because a divergence reddens
every run and then gets deleted as broken. And a one-line `run: scripts/ci-step-ran.sh …` cannot
contain an expression delimiter, so the mechanism being guarded against cannot drop the guard. #751's
own record names that as the stronger construction and settled for inline only because its
measurement showed it was not required there.
**Why a script is acceptable here and would NOT be in `review-verdict.yml`.** That workflow checks
out the PR's BASE precisely so a PR cannot supply the code that judges it. `docker-build.yml` is
head-resolved by design — a PR already supplies every test this job runs — so calling a script from
the head adds no authority a PR did not already have. This is a correctness gate against silent
no-ops, not a security gate against a hostile PR; that job belongs to `review-verdict/h10`. Do not
carry this reasoning back into the gate workflow.
**The premise was re-measured on the BUILD lane, not inherited.** The whole guard rests on the runner
still executing a LATER step after dropping an earlier one. #751 established that on the `small`
lane; these two jobs run in a `container:` on `ubuntu-latest`, which is a different lane, so assuming
it transfers would be the same shape of mistake the guard exists to catch. Measured by scratch PR
#765 (Gitea 1.27.1, 2026-08-10), which reintroduced the exact #751 defect — an invalid expression
payload inside a shell comment — in the `test` job's `revalidate` step. The step was dropped, the
other eleven markers were still recorded — ten of them AFTER the drop, `detect` being the earlier
eleventh — and the guard was the ONLY failing step
in the job — so without it that run would have concluded `success` having skipped a step. The SAME
run supplies the positive control on the same lane: its untouched `migrations` job marked all six
steps, reported `All 6 expected step(s) executed`, and concluded `success`.
A second probe (PR #766, run 1913) settled the one path on which the `if:`-less guard could have been
a silent no-op: a FAILING `continue-on-error` step. Had that flipped `success()`, the guard would be
skipped on a still-green job. It does not — the advisory step failed, the guard ran anyway, reported
`All 12 expected step(s) executed`, and the job stayed `success`. Full log extracts in
docs/ci-cd.md.
**The two halves are deliberately different in kind, and neither is redundant.** The delimiter ban is
static and absolute, and it makes the defect class UNREACHABLE in these jobs rather than merely
detected — it is the cheaper and more general half, and it is enforceable today only because both
jobs were already delimiter-free (measured 2026-08-10: `test` 0, `migrations` 0), and `build` was
brought in by moving its two payloads to `env:` — leaving `api-docs` and `format` with one
`github.base_ref` each, in detect steps that gate nothing that ships. The runtime markers catch a step
that fails to run for any OTHER reason, including reasons not yet met. Keeping only the static half
would be trusting that this is the only way a step can vanish, which is exactly the assumption #751
falsified about shell comments.
**What this does not claim.** The guard proves a step STARTED, never that it did its work correctly
— that is what the step's own exit status is for. It does not cover `uses:` steps, which are not
`run:` bodies and cannot be dropped this way.
An earlier draft dismissed the non-required jobs as "a smaller cost (no required context lies)", and
cold review showed that was false for the one that matters. `build`'s only delimiter-bearing body was
`Smoke + IPTV E2E`, which runs AFTER `Build and push`: on a `v*` tag the candidate image is already
published, and that step is the only thing that boots it. A drop there ships an unsmoked release
candidate under a green tick, and prod promotion pulls exactly that image. It was also the cheap case
— both payloads were plain values, so moving them into `env:` cost nothing and let `build` join the
ban. The claim not to repeat is the draft's dichotomy ("give up interpolation or move into
`scripts/`"); the `env:` escape hatch this record prescribes was the answer all along. What genuinely
remains uncovered is `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step
that gates nothing that ships, and `functional-e2e`, which is advisory by declaration.
**The `build` ban is now fail-closed on the release path (ersatztv#767 — this was the open
residual).** It used to be enforced only by `script-tests`, which is `on: pull_request` and is not a
required context, so nothing re-checked it when a release was actually cut: a delimiter that reached
`main` would still drop `Smoke` on the tag build and report green. A `scan` job now runs the
PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and
no image is published.
**Two designs were tried, and the first one's failures are the reusable part.** The first put a
bespoke stdlib scanner in `build` itself as an unconditional step before `Build and push`. Two
independent reviews rejected it on two counts, both easy to re-invent:
- **A guard step cannot protect the job it lives in.** `build` publishes, so a guard step there is
fail-OPEN if the runner drops it. The defence offered — "the guard's own body has no opener, so it
cannot be dropped" — is circular, because the only thing enforcing that property was the same
PR-only, non-required test being backstopped. A `needs:` edge is not circular: a red job skips its
dependents by construction.
- **A hand-written parser was strictly weaker than the check it backstopped.** It hand-parsed YAML to
avoid provisioning PyYAML on `build`'s bare runner, and review found ~10 false NEGATIVES in one
round (flow mappings, a quoted `"run":` key, aliases, multiline quoted scalars). For a security
gate only false negatives matter, so this was worse than useless — it looked like enforcement. Do
not re-attempt a bespoke scanner to save provisioning a dependency; run the real test.
**Why this needs no third marker bucket.** The deferral assumed the answer had to be markers on
`build`, requiring a bucket that models `Smoke`'s publish-ref `if:`. It does not: the delimiter class
is a *static* property of the workflow text, so a job that reads the text catches it without
modelling any `if:`. The marker buckets are unchanged. Per-step markers on `build` remain a genuine
smaller residual — they would catch a drop caused by something other than a delimiter.
**What this does not claim.** That no step can ever fail to run for another reason. The `scan` job's
own steps carry markers and a trailing assert, which moves the terminal assumption rather than
removing it: to fail open you must now drop the pytest step AND the assert step, not either alone.
**Measured, not assumed.** See the closing record on ersatztv#767 for the run ids of the poisoned and
control dispatches. The arrangement: a `workflow_dispatch` on a scratch branch whose `Smoke` body
carries a deliberate delimiter must redden `scan` and leave `build` skipped, and the same dispatch
without the poison must pass. Note that "no image was published" is NOT part of the evidence — on a
scratch ref `Build and push` has `push: false` regardless, so that conjunct could not have come out
the other way; the discriminating observation is `scan` red and `build` skipped. Do NOT repeat the
cost estimate an earlier draft gave ("would require pushing a real `v*` tag").
@@ -7,7 +7,7 @@ supersedes: none
superseded-by: none
rule: 'The `review-verdict/h10` job counts `change_target_branch` events on the PR''s issue timeline at run start and again immediately before its POST, and writes NOTHING if the count moved. The COUNT is the key because the branch NAME is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption — while the event count is monotonic and cannot alias. Abstaining is a handoff, not a stall, and that is the property the design rests on: every retarget fires `edited`, which is in this workflow''s `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops and the last run writes the final answer. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow''s `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when paging reached a validated EMPTY page; an untrusted count (unreadable page, non-array body, non-numeric length, page cap hit) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` cannot turn an unreviewed head green while withholding it would strand ordinary PRs for no safety gain. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human''s state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR''s exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run''s `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run''s mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead.'
signals: 'stale review-verdict run overwrites a fresher one, retarget ABA against the docs-only classifier, concurrency group does not serialize pull_request_target, gitea auto-cancel push vs pull_request_target, forged exemption restored after reclassification, human BLOCKED silently turned green, post-write status verification, change_target_branch timeline count, why does my PR post no verdict status after a retarget · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #706, #698, #672, #663, #622'
mechanics: '`count_retargets()` pages `GET /repos/{repo}/issues/{pr}/timeline?limit=50&page=N` (cap 20) setting `rt_count`/`rt_ok`, trusted only on a validated empty page; `retargets_before`/`retargets_before_ok` captured before enumeration, re-counted immediately before the POST; `max_id_before` from `GET /repos/{repo}/statuses/{sha}` (a BARE ARRAY, unlike the combined `/commits/{sha}/status` object); repair POST is `pending`; tests `test_a_RETARGET_DURING_the_run_posts_NOTHING`, `test_a_PR_retargeted_BEFORE_the_run_but_QUIET_during_it_is_STILL_exempt`, `test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION`, `test_an_UNTRUSTED_retarget_count_STILL_LETS_PENDING_THROUGH`, `test_a_human_verdict_landing_AFTER_the_POST_is_repaired_to_pending`, `test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair`'
mechanics: '`count_retargets()` pages `GET /repos/{repo}/issues/{pr}/timeline?limit=50&page=N` (cap 20) setting `rt_count`/`rt_ok`, trusted only on a validated empty page, which is a page of EITHER `null` (what this endpoint really returns past the end) or `[]` — an `array`-only type gate read the real terminator as unreadable and withheld every exemption (#751); `retargets_before`/`retargets_before_ok` captured before enumeration, re-counted immediately before the POST; `max_id_before` from `GET /repos/{repo}/statuses/{sha}` (a BARE ARRAY, unlike the combined `/commits/{sha}/status` object); repair POST is `pending`; tests `test_a_RETARGET_DURING_the_run_posts_NOTHING`, `test_a_PR_retargeted_BEFORE_the_run_but_QUIET_during_it_is_STILL_exempt`, `test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION`, `test_an_UNTRUSTED_retarget_count_STILL_LETS_PENDING_THROUGH`, `test_a_human_verdict_landing_AFTER_the_POST_is_repaired_to_pending`, `test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair`'
---
`ci.exemption-provenance` closed three routes into the exemption path and left one residual it named:
@@ -70,3 +70,31 @@ marker, so the exemption returned two events later instead of one. Only a re-pos
4. **A timeline over the 20-page cap can never be exempted**`rt_ok` stays `no` on every run, so only
a human verdict clears it and comment-flooding becomes a fail-closed denial of exemption.
Negligible at 1000 events; the log says so rather than promising a later run will fix it.
**CORRECTION, 2026-08-06 (ersatztv#751).** Residual 4 above described as a narrow edge case what was
in fact the universal behaviour: `rt_ok` stayed `no` on **every** pull request, not only over-cap ones,
so the fence withheld **every** exemption `success` from the day it shipped. A page past the end of
this endpoint is the JSON value `null`, not `[]` (measured at Gitea 1.27.1 on PR #752; the same
instance returns `[]` for an empty `/issues/{n}/comments`, so it is not consistent between endpoints).
`count_retargets` gated on `type == "array"` and therefore read the real terminator as unreadable,
never reaching the validated empty page it required. Renovate and docs-only PRs got no status at all.
Two reasons it read as deliberate rather than broken, both worth carrying forward:
- **It never ran.** This fence shipped in 8f6d4f443 — the same commit whose prose comment stopped the
classify step from executing at all (`ci.workflow-run-body-no-expressions`). Merging a guard and
first executing it are different events, and only the second tells you anything.
- **The double asserted the wrong shape while claiming to be measured.** The stub's comment read "Real
shapes, measured on this instance and deliberately mirrored" and it printed `[]` past the end. So the
`array`-only gate was never exercised by the suite either. Correcting the double and restoring the
old gate reddens most of the fence suite — 18 tests when first measured, 21 once three more
fence-dependent tests existed. The invariant, not the number, is that every one of them had been
green for the wrong reason. A fidelity claim in a test double is an assertion, and it decays like any
other.
The type is now read as a value (`case` over `jq -r 'type'`) rather than through `jq -e`, whose
exit-status semantics already bit this workflow at jq 1.6 (`ci.jq-version-contract`), and both `null`
and `[]` terminate the walk. `test_the_fence_TRUSTS_the_count_and_POSTS_when_the_timeline_terminates`
is parameterised over both shapes and asserts the POSTED STATUS rather than the log line — on the real
probe run the log said `Decision: state=success` and the job still posted nothing, so the decision and
the write are separate events and only the write is what a merge reads.
@@ -0,0 +1,105 @@
---
key: ci.workflow-run-body-no-expressions
title: '2026-08-06 — an expression delimiter anywhere in a `run:` body, INCLUDING in a comment, silently drops the step and reports the job green (#751)'
status: active
since: '2026-08-06'
supersedes: none
superseded-by: none
rule: 'A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step''s `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard''s own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing now also covers `docker-build.yml`''s `test` and `migrations` jobs, where a dropped step is fail-OPEN (the required check goes green having done no work) rather than fail-closed as it is here — see `ci.required-job-step-execution-markers`, which adds per-STEP markers there and extends this file''s delimiter ban to those two jobs. It is still not a repo-wide property, but the remaining exceptions are narrower than this record originally said: `build` was brought into the ban too (its `Smoke + IPTV E2E` runs AFTER the image is pushed, so a drop there ships an unsmoked release candidate — its two payloads moved to `env:`, so the ban was free), leaving only `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step that gates nothing that ships.'
signals: 'Unable to interpolate expression format(, step never ran but job green, missing Run Main step marker, review-verdict/h10 absent after a green run, docs-only PR unmergeable, Renovate PR unmergeable, exemption stopped working, expression in a shell comment, workflow comment changed behaviour · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #751, #706, #748'
mechanics: '`RAN_MARKER` written at the top of the classify step and asserted by the `Assert the classifier actually executed` step (`if: always()`, expression-free body, `exit 1` on a missing marker); static guards `test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body` (raw scalar, absolute, gate file only) and `test_every_workflow_expression_names_a_REAL_context_or_function` (repo-wide, allow-list of contexts/functions) plus `test_a_dropped_classify_step_FAILS_the_job_instead_of_going_green` (pins marker path agreement and guard ordering across ONE yaml parse)'
---
**How it happened, which is the part that generalises.** The #706 note explaining why a concurrency
group does not work in `review-verdict.yml` quoted a `concurrency:` snippet containing a PR-number
expression *as an illustration*, inside a shell comment. `pr number` is not a valid expression. From
8f6d4f443 (2026-08-03) to 2026-08-06 the classify step therefore never ran, `review-verdict/h10` was
posted by nothing but a human hand, and both exemption classes silently stopped working — while every
run reported success. The prose documenting a fix disabled the fix.
**Why nothing caught it.** Every pre-existing workflow-shape test in
`scripts/tests/test_pr_changed_files.py` reads `_code_lines()`, which strips comment lines. That is
correct for what it was for — its own docstring notes that prose legitimately discusses
`pulls/N/files`, and a raw scan would redden the repo over a piece of writing — but it encodes the
assumption this bug falsifies: that a comment in a workflow cannot change behaviour. Inside a `run:`
scalar it can. The strict test added here reads the RAW scalar for exactly that reason and must never
adopt `_code_lines`.
**The silent green is the defect; the delimiter was only the trigger.** An absent required status
reads as "not reviewed yet" on an ordinary PR, which is indistinguishable from the correct pending
state — so a normal PR looked normal while the gate was dead. The visible cost landed on the two
classes with no human in the loop: PR #739 (docs-only) merged 2026-08-05 with ZERO commit statuses on
its head, and got in only because admin force-merge was still enabled. #743 removed that escape the
next day, so by the time this was found the workaround that had been absorbing the bug was gone and
the next docs-only or Renovate-manifest PR would have been permanently stuck. The two Renovate PRs in
the window escaped by timing alone, merging minutes before the bad commit landed.
**Scope of the strict rule, and why it is not repo-wide.** As of 2026-08-06, `docker-build.yml`,
`ci-image.yml` and `pr-checks.yml` interpolated into `run:` bodies legitimately (7 occurrences then;
#756 removed `build`'s two, leaving 5 today — see below). A repo-wide ban would be
false and would be deleted the first time it got in someone's way. `review-verdict.yml` earns the
absolute rule on two counts: it writes the branch-protection-required status, and its `run:` bodies
are ~700 lines of dense prose — the only place the delimiter has ever appeared by accident.
The first of those two counts turned out to apply elsewhere as well, and #756 acted on it: the
absolute ban now also covers `docker-build.yml`'s `test` and `migrations` jobs, which write the other
two required contexts and were delimiter-free already, so the rule cost nothing to impose there. The
remaining 2 occurrences inside `docker-build.yml``api-docs` and `format`, one `github.base_ref`
each — sit in jobs that gate nothing that ships (5 repo-wide, counting `ci-image.yml` and the two
`pr-checks.yml` gates). `build` is banned too, and NOT because it is required (it is not): its
`Smoke + IPTV E2E` step runs after the image is pushed, so a drop there publishes a release candidate
that was never booted. Read this paragraph as scoping the rule to steps whose non-execution is
CONSEQUENTIAL — required contexts and the release path — rather than to this one file.
**The probe found a SECOND, independent reason the gate posted nothing**, and it is why fixing the
interpolation alone would not have restored the exemptions: a page past the end of
`/issues/{n}/timeline` is JSON `null`, not `[]`, so the retarget fence never trusted its count for ANY
PR and withheld every exemption `success`. Corrected in `ci.verdict-write-retarget-fence`, whose stated
residual had described that universal behaviour as a narrow over-cap edge case. Both defects shipped in
the same commit, which is the general lesson: a guard that has never executed has told you nothing, and
merging it is not executing it.
**A THIRD instance of the same server behaviour was found by cold review of this fix**, and it is
the reason to distrust "I fixed the two I could see". `GET /commits/{sha}/status` also returns
`statuses: null` — not `[]` — for a head with no statuses yet (measured on PR #739's head 5fa672e2:
`{"state":"pending","total_count":0,"statuses":null}`). `read_existing_verdict` gated on
`.statuses | type == "array"` and took its `exit 1` path, posting nothing: fail-closed, but the same
user-visible outcome again. Its double printed `{"statuses": []}` at all three no-verdict sites, so
that branch was unreachable in the suite; correcting the double and restoring the old gate turns 40+
tests red. `scripts/pr-changed-files.sh` was swept too and is unaffected — `pulls/{n}/files` returns
`[]`. The generalisable rule is that a nil Go slice serialises to `null`, so EVERY list-shaped field
on this API is suspect, and a per-endpoint measurement is the only way to know.
**Restoring the exemptions restores a hole that had been dead**, and this is worth saying rather than
presenting the change as pure repair. `DOCS_ONLY` matched `CLAUDE.md` and `AGENTS.md`, the documents
that define the completion protocol and the H10 rule itself — so those were auto-exemptible while
`.claude/` was protected, which is the same self-exemption the workflow header rules out, one
directory over. Reachable only because exemptions work again, hence fixed here (both added to
`PROTECTED`; see `ci.exemption-provenance`). For the same reason, #706's known residual — the
sub-round-trip ABA window, "narrowed and observable, not closed" — comes back with the working fence:
while `rt_ok` was never `yes`, route 1 was closed by accident.
**Three guards were proposed or written for the same hole and the first two were no-ops** — the hole
being that concluding "no verdict exists" is what licenses posting over one. `total_count` is per-PAGE
here (`?limit=1` on a 6-context head gives `len=1, total_count=1`), so length-vs-total is equal by
construction; and "refuse on a full page at `limit=100`" was DEAD CODE, because the instance caps
`limit` at `MAX_RESPONSE_ITEMS`, measured at 50 — a cap this repo already documented in three places
before the guard was written against 100. The working version asks the server: read page 2 when the row
is absent from page 1, and refuse if it carries anything. Cap-independent, so no reconfiguration
re-breaks it. Second, `jq -r` renders the number `0` and the string `"0"` identically, so the zero
check requires the JSON type as well. Neither was a live failure — both are the difference between a
guard that holds because the input happens to be well-formed and one that holds because it checks.
**The tests written to close a review finding then needed closing themselves**, which is the honest
shape of work on this file. The behavioural guard test first extracted the two marker lines by text and
ran them alone — which passes even if the write is moved into a function nobody calls. It now executes
the classify body's real PREFIX down to the write, reproducing the production control flow instead of a
reconstruction of it. The anti-vacuity check first hand-counted `run:` keys with a regex, which
false-redded legal spellings (`- run: |`, a single-line `run: echo ok`) and could count a `run: |`
inside a heredoc; hand-parsing YAML to validate a YAML parse is the wrong shape, so it now asserts on
content — the walk reached at least three bodies and one over 5000 characters.
**Verified by mutation, not by a green suite.** All six mutations produce a red and the restored tree
is green: reintroducing the exact defect (caught by both the strict and the general test), deleting
the guard step, deleting only the marker write, weakening `if: always()`, turning the guard's
`exit 1` into `exit 0`, and putting a delimiter in the guard's own body.
@@ -0,0 +1,104 @@
---
key: mcp.tool-schema-openapi-parity
title: '2026-08-06 — every MCP tool declares exactly its endpoint''s OpenAPI request-body fields and query parameters, asserted in CI (#754, #757)'
status: active
since: '2026-08-06'
supersedes: none
superseded-by: none
rule: 'Every POST/PUT/PATCH tool in `ToolCatalog` declares exactly the request-body properties its endpoint accepts, each with a matching type, and EVERY tool (read and write) declares exactly its endpoint''s query parameters, both asserted against the generated `ErsatzTV/wwwroot/openapi/v1.json` (linked into `ErsatzTV.Mcp.Tests`) by `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields` and `Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`. A field the endpoint accepts but the tool omits is a DEFECT, not a deferral: on the full-replace tools (channel update, schedule update, custom-order) the omission is silently applied as a clear. The write tools are NOT uniformly full-replace — add-collection-items is additive, and several leave an omitted field unchanged — so each tool description states its own semantics. An omitted query parameter is UNREACHABLE, not merely undocumented, because `ToolArgumentValidator` rejects undeclared arguments.'
signals: 'MCP tool schema drift, full-replace write, silently dropped field, graphicsElementIds, padToNearestMinute, additionalProperties false · paths: `ErsatzTV.Mcp/ToolCatalog.cs`, `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `docs/mcp.md` · issues: #754, #757, #58, #616'
mechanics: '`ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`; `ErsatzTV.Mcp.Tests.csproj` links `openapi/v1.json`'
---
`ToolCatalog.ChannelFields()` declared 27 of `UpdateChannelRequest`'s 28 properties. The missing one
was `graphicsElementIds`, which attaches channel-level graphics elements including the built-in On
Now/Next overlay (`graphics.channel-level-attachment`).
The cost was not "one field you cannot set". `PUT /api/v1/channels/{id}` is a **full replace**, and
the tool's own description instructs the caller to *"send the full desired state"* — which the schema
could not express. An agent that faithfully GET-edit-PUT a channel detached every attached graphics
element, with a `200` and no error. Nothing surfaced until the overlay stopped rendering at the next
transition, hours later. That is the `optional-parameter-on-shared-primitive-is-opt-out` shape: the
omission is invisible at the call site and only observable as missing pixels.
Fixing the one field would have left the mechanism intact, and the mechanism had already produced a
second instance: `ScheduleFlags()` omitted `padToNearestMinute`, which both `CreateScheduleRequest`
and `UpdateScheduleRequest` carry and `UpdateProgramScheduleHandler` writes unconditionally — so
`ersatztv_update_schedule` silently cleared a configured pad the same way. Nothing tied a tool's
declared arguments to the contract it wraps, so the next added DTO property would have drifted too.
So the guard is the decision, and it is asserted against the **generated OpenAPI document** rather
than the DTO types: `v1.json` is the actual wire contract, it is already regenerated by
`scripts/update-openapi.sh` as part of the API checklist, and asserting against it keeps
`ErsatzTV.Mcp.Tests` free of a project reference to the whole ASP.NET host. The test derives each
tool's body set exactly as `ErsatzTvApiClient` does — declared arguments minus path parameters, minus
query parameters, minus the reserved `ifMatch` header — so the guard cannot disagree with the routing
it guards.
Three anti-vacuity properties are deliberate, per the repo's standing "a test that filters on the
property it asserts cannot see what is missing" rule:
- The **covered write-tool set is pinned by name**, not merely filtered. A tool that stops being a
write verb, or a new one that is added, changes this list rather than silently leaving the loop.
- A **missing or unrecognised spec is a failure**, never an empty comparison: an absent `v1.json`
fails with the path it looked in, and a request body that is not a plain `$ref` (an `allOf`,
`oneOf`, or inline schema), or a property whose type is a union this guard has not been taught,
fails asking to be taught the shape instead of comparing against `{}`.
- **Names are compared with types**, not alone. A name-only guard is the same defect one level down:
the tool would advertise `string` for an `int?`, the agent would send `"30"`, and the API would
reject it — green test, broken tool. The generator's `["null", T]` nullable form and its `$ref`
(enum → `string`, model → `object`) are normalized onto the catalog's vocabulary, arrays down to
their element type.
All were verified by mutation rather than assumed: dropping `graphicsElementIds`, dropping
`padToNearestMinute`, retyping either field, drifting an array's element type, and removing the
copied spec each turn the suite red, and each failure names the field or path at fault.
**Query parameters are guarded the same way, across every tool (#757).** A second test compares each
tool's routed `QueryParameters` against the spec's `parameters[in=query]` for its path and verb, reads
included — the drift that existed when this was written was entirely on reads. An omitted parameter
there is worse than an undeclared body field: `additionalProperties:false` means the caller cannot
pass it *at all*, so the capability is unreachable rather than merely undocumented (`ersatztv_list_playouts`
had lost its channel-name `query` filter and `ersatztv_get_playout_items` its `showFiller`; #616 was
the same shape with paging). That test **accumulates** its mismatches and asserts once, so a run
reports the whole drift set — failing on the first would invite fixing one tool at a time, which is
how the twin in this very issue stayed hidden.
It also **composes with** the older `Every_Query_Parameter_Should_Be_A_Declared_Property`, and the pair
is the clearest illustration in this repo of why "a test that filters on the property it asserts cannot
see what is missing" is a rule. That older test filters `Where(t => t.QueryParameters is { Count: > 0 })`
— so a tool that lost its query parameters entirely escaped it, which is exactly how `list_playouts` and
`get_playout_items` hid. The new test has no filter and reports them as *unreachable*; the old one then
checks that a routed parameter is also a declared argument. Neither subsumes the other, and the inner
duplicate of the old check was deliberately removed from the new test rather than kept as a second copy.
**Scope, stated so it is not mistaken for more.** Request bodies are compared for POST/PUT/PATCH only.
DELETE is uncovered because `ErsatzTvApiClient` builds a body for POST/PUT/PATCH only, so a body
argument on a DELETE tool would be silently dropped; no tool has one today. Header arguments (`ifMatch`)
and per-parameter *descriptions* are not compared either — `api.paging-zero-based` is pinned by its own
test.
The type comparison is **lossy by design, at the catalog's ceiling**: the catalog's vocabulary is
`{string, integer, number, boolean, object, array<T>}`, so every object component collapses to `object`
and every enum to `string`. Swapping one model or enum for another is therefore invisible here
(verified by repointing `logo` at a structurally unrelated model — the suite stays green), as is
`format` (`int32` vs `int64`). That is the right ceiling rather than a gap to close: comparing deeper
than the catalog can express would assert a distinction no tool schema carries, and an opaque object
like `logo` is copied through from a GET verbatim, so nested drift cannot cause the silent-clear this
record exists to prevent. `integer` vs `number` IS distinguished. The `>1` non-null type-union
assertion is a fail-loud guard for a shape this generator does not currently emit, so it is deliberate
but **unexercised**.
The guard is also a **two-job conjunction**, not self-contained: it compares against a checked-in
`v1.json`, so it is only as fresh as the regeneration. What keeps it honest is the `api-docs` CI job,
whose `^ErsatzTV/Controllers/Api/` path filter covers the directory every request DTO lives in — a
new DTO property cannot leave `v1.json` stale without that job going red. That holds for a DTO's OWN
properties and no further: a NESTED model such as `ArtworkContentTypeModel` lives in
`ErsatzTV.Application/Artworks/`, outside that filter, so changing it can leave `v1.json` stale without
the job firing. Pre-existing, and harmless to this guard only because nested shape is not compared.
`graphicsElementIds` is declared on the **update tool only**, not in the shared `ChannelFields()`:
`CreateChannelRequest` has no such property, and the tool schemas are `additionalProperties:false`,
so sharing it would make every create call send an unknown property. `padToNearestMinute` is on both
schedule requests, so it does belong in the shared `ScheduleFlags()`. The parity test is what makes
that per-field placement checkable rather than a matter of care.
@@ -5,8 +5,8 @@ status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`.
signals: 'format-as-you-touch, rebase not merge, BOM backlog · paths: `.husky/pre-push`, `.claude/hooks/prepush-rebase-check.sh` · issues: #311 (H11), #309, #310, #269, #312'
rule: 'A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR''s changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11).'
signals: 'format-as-you-touch, rebase not merge, BOM backlog, tag-only push exemption, H11 blocks release cut, refs/tags pre-push, vacuous-truth guard · paths: `.husky/pre-push`, `.claude/hooks/prepush-rebase-check.sh`, `scripts/tests/test_prepush_rebase_check_tag_exemption.py` · issues: #311 (H11), #719, #309, #310, #269, #312'
mechanics: '`docs/contributing.md` §7; `.claude/hooks/prepush-rebase-check.sh`; `npm run check:api`'
---
@@ -36,5 +36,22 @@ git hook has no "ask"); deliberate escape `ETV_SKIP_REBASE_CHECK=1`. This supers
guidance to "merge main into your PR branch." (After a rebase that conflicts in *generated* artifacts —
v1.json/v1.d.ts/endpoint-index — regenerate, don't hand-resolve; `npm run check:api` guards.)
**2a. The tag-only carve-out (#719).** H11 fired on the release cut: tagging a commit on `main` from
a branch that is behind `origin/main` tripped the freshness check, and the rebase advice it printed
did not even apply — no branch was being pushed. Observed while cutting `v26.13.0` (#719); note
`docs/ci-cd.md` → "Cutting a release" documents the tag step itself, not the release-notes-PR flow
that leaves the branch behind, so the frequency is attested by #719 rather than by that doc. The hook now reads git's pre-push ref lines (`<local ref> <local sha>
<remote ref> <remote sha>`) and exits 0 when every parsed line's *remote* ref is under `refs/tags/`.
Two details are load-bearing and easy to regress:
- `.husky/pre-push` consumes stdin into `$_prepush_refs` before any guard runs, so it must **forward**
those lines (`printf '%s\n' "$_prepush_refs" | …`). Without that the check receives EOF and the
exemption is dead code that silently never fires. The unit tests drive the hook directly and would
still pass, so this wiring is not covered by them.
- The exemption requires **at least one** parsed ref line. "All refs are tags" is vacuously true for
zero lines, which would disable H11 for every push; with no lines the hook falls through to the
normal freshness check. `scripts/tests/test_prepush_rebase_check_tag_exemption.py` pins both the
negative control (branch push from a behind branch still blocked), the mixed branch+tag case, and
the two zero-line cases.
Rationale, as with the whole hook program: make the process rule a derivation/hook, not prose to
remember (#303 methodology review). Tracked: #311; sibling #312 (H12 issue-qualification audit).
@@ -0,0 +1,79 @@
---
key: release.main-direct-push-disabled
title: '2026-08-05 — `main` refuses direct pushes (`enable_push: false`), because a push whitelist would have been a no-op here (#743)'
status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.'
signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714'
mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. NOT measured, source-attested only (Gitea 1.27 `CanBypassBranchProtection`, `services/pull/check.go`, `routers/private/hook_pre_receive.go`): that `block_admin_merge_override: false` would have let an admin `force_merge` past the required contexts — the field was set to true rather than probed, since probing it means merging an unreviewed PR. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout.'
---
**Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct
push entirely)" as if the two were interchangeable. They are not, and which one is right depends on a
fact about *this* instance: the only accounts with repository write are `timothy` (a site admin) and
`renovate`. Every credential in the threat model — an agent session, a collaborator PAT, the
`GITEA_TOKEN` Gitea injects into every Actions job — authenticates as one of those two, and
overwhelmingly as `timothy`. A whitelist admitting `timothy` therefore admits precisely the identity
the control is supposed to constrain. It would have ticked the issue's box while changing nothing.
This was measured, not reasoned: the same push was refused under `enable_push: false` and accepted
under a whitelist naming `timothy`.
**Disabling push alone was NOT enough, and the reason is the same argument twice.** The first draft of
this record disabled direct push and concluded that `review-verdict/h10` was therefore load-bearing.
An independent review caught that this repeated on the merge path exactly the mistake it had just
diagnosed on the push path. The push argument was: a whitelist naming `timothy` fails because
`timothy` is the identity every credential already holds. The merge path had the identical shape —
`block_admin_merge_override` defaulted to `false`, so `CanBypassBranchProtection` returned true for a
repo admin and `POST /pulls/{n}/merge` with `force_merge: true` merged straight past a missing or red
`h10`. One API call, cheaper than the push route it replaced. **An admin-shaped control that exempts
the only admin exempts everybody.** Both fields are now set; treat them as one control, and never
cite `enable_push: false` alone as the reason the gate holds.
**What this actually closes, and what it does not.** It closes the *write-only* credential routes,
which is most of #743's own "who can do it" list: the injected `GITEA_TOKEN` (repo write, not admin),
`RENOVATE_TOKEN`, and any non-admin collaborator PAT. Those can no longer reach `main` at all, by any
path that skips the gate.
It does **not** close the admin route. `timothy` is a site admin, so a credential holding that
identity can `PATCH` either field off, act, and restore it — the exact sequence used to *prove* the
push semantics above. Closing that requires agent sessions to run as a scoped non-admin credential,
which is a different change with its own cost (packages live in a user namespace; see the "Admin
ownership is a real residual" section of `ci.actions-credential-scoping`). Recorded as an accepted
residual rather than fixed here, so it is not mistaken for covered. The severity bound from #697 and
#743 is unchanged throughout: push access is required, so this is a compromised contributor or a
subverted automated session, never an anonymous attacker.
**Which write surfaces were enumerated.** `git push` (measured, refused), the contents API and by
extension the web editor / upload path (measured on a probe branch, refused HTTP 403 — they share the
`CanUserPush` predicate, which has no admin special-case and no `unprotected_file_patterns` carve-out
since that field is empty), apply-patch / revert / cherry-pick (source-attested, same predicate),
force push (`enable_force_push: false`), default-branch deletion (separately refused), and fork-sync /
mirror (not applicable: `fork: false`, `mirror: false`). Merge remains the one intended path.
**Why the release cut does not deadlock.** #743 flagged that the tag path had to keep working, and
#719 documents H11 blocking a tag-only push on every release cut. Branch protection is scoped to
`refs/heads/main`; tags are governed by an entirely separate mechanism, and `tag_protections` on this
repo is empty, so tag pushes are unrestricted by anything except ordinary write permission. Demonstrated
rather than assumed: from one worktree, the branch push to `main` was refused and a tag push succeeded.
Do not conflate the two mechanisms — disabling branch push says nothing about tags, and a future
tag-protection rule would not inherit from this one.
**The `docker-build.yml` `persist-credentials` question (#743's fourth box), decided and deferred.**
Its six `actions/checkout` steps omit `persist-credentials: false`, so a head-resolved job keeps a
write-capable credential in `.git/config`. It *should* be set — but not blind, and not in this PR,
because two steps run `git fetch --no-tags --depth=100 origin "$base_ref" || true` and feed the result
into the changed-file skip logic. That `|| true` means a credential regression does not fail the job;
it silently yields an empty changed-file set, and the skip logic then reads "nothing changed". The repo
is public, so anonymous fetch is *expected* to cover it — expected is not measured, and the failure
mode is silent, which is the shape that has burned this repo before. The correct order is: drop the
`|| true` masking so a fetch failure is loud, then set `persist-credentials: false` and confirm both
jobs still compute a non-empty changed set on a PR that genuinely changes files.
**Why this is not redundant with the Husky pre-push hooks.** `.husky/pre-push` guards (H6 done-when,
H11 rebase, H13 clean worktree) are client-side and deliberately fail-open — a git hook cannot prompt.
They are not installed in CI, not present in a fresh clone until `husky` runs, and `--no-verify`
bypasses them, which the worktree workflow uses routinely. They are good friction against mistakes and
were never a control against a credential. This record is the server-side half; the hooks remain useful
and unchanged.
@@ -182,8 +182,24 @@ described as one:
**Only this file's instance is closed, not the class.** Any head-resolved workflow holding
credentials that can POST a commit status can still forge `review-verdict/h10`;
`docker-build.yml` demonstrably can, and must stay head-resolved because it builds the PR's own
code. Tracked in #697. So the "careless change rather than a hostile one" posture below still
`docker-build.yml` demonstrably could, and must stay head-resolved because it builds the PR's own
code — so #697 scoped its credential instead (`ci.actions-credential-scoping`), leaving AT LEAST
these: the injected `GITEA_TOKEN` (posts with `creator: null`), `RENOVATE_TOKEN` (a
`write:repository` bot PAT in the same secret store, so it posts with a real creator and IS
inherited, #742), a collaborator's own token, and the `v*` tag push — which matters less for
forging this status than for what else it does: `docker-build.yml` publishes `:prod` from a tagged
ref, and a tag may point at any commit, so it ships a prod image with no PR, review or status.
None of which used to be even required — direct pushes to `main` were server-side permitted, so
the gate could be skipped without forging anything (#743). **That route is now closed**
(`release.main-direct-push-disabled`): `main` carries `enable_push: false` *and*
`block_admin_merge_override: true`, so every change reaches `main` through the PR merge path,
which is the only path on which these required contexts are evaluated. What survives is the
forgery list above — those routes post a status rather than skip it, so they are still real —
**plus one skip route that is not forgery at all**: a credential that can `PATCH` branch
protection can turn either field off, act, and restore it. `timothy` is a site admin, so every
session holds that capability; it is an accepted residual, recorded in
`release.main-direct-push-disabled` and `ci.actions-credential-scoping`, not a closed route. So
the "careless change rather than a hostile one" posture below still
describes the repo accurately — it is simply no longer *this* workflow that is the weakest link.
An untrusted-contributor repo would still need the classification moved somewhere no PR can
reach (server-side policy), not merely a base-pinned definition.
+41 -3
View File
@@ -186,12 +186,50 @@ Re-adding an already-present item is an **idempotent no-op** (no duplicate rows,
referenced id does not exist the whole batch is rejected (`422`). So the flow is: search → add ids →
re-run to confirm idempotence.
### Full-replace writes drop what you omit (`mcp.tool-schema-openapi-parity`)
**Check each tool's own description — the write tools are not uniform, and one is not uniform with
itself.** Three are full replaces, where a field you leave out is not "left unchanged" but written as
empty: `ersatztv_update_channel`, `ersatztv_update_schedule`, `ersatztv_update_collection_custom_order`.
`ersatztv_update_playout` is **mixed, and this is the easy one to get wrong**: `scheduleFile` is
leave-unchanged, but `dailyRebuildTime` is always applied — `UpdatePlayoutHandler` sets it to `null`
unconditionally before re-applying a supplied value, so calling this tool to set `scheduleFile` while
omitting `dailyRebuildTime` **silently clears the daily reset**. Send both, or neither.
The rest are additive or leave-unchanged and say so: `ersatztv_add_collection_items` is an idempotent
add (it does **not** replace membership), `ersatztv_update_collection` leaves an omitted
`useCustomPlaybackOrder` alone, and `ersatztv_enable_jellyfin_library_sync` leaves an absent row
untouched.
For the full-replace ones, the GET → edit one field → PUT flow is only safe if the tool can express
the whole state, and `ersatztv_update_channel` could not — it omitted `graphicsElementIds`, so that flow
silently detached every graphics element (including the On Now/Next overlay) with a `200` and no
error, visible only as missing pixels at the next transition. `ersatztv_update_schedule` cleared
`padToNearestMinute` the same way (ersatztv#754).
Both are fixed, and the class is now guarded by two tests in `ToolCatalogTests`, comparing against the
generated `ErsatzTV/wwwroot/openapi/v1.json`:
- Every POST/PUT/PATCH tool declares **exactly** the request-body fields its endpoint accepts, each
with a matching type. A new property on a request DTO fails until the catalog declares it.
- Every tool — read **and** write — declares **exactly** its endpoint's query parameters. An omitted
one is not merely undocumented but *unreachable*, since `ToolArgumentValidator` rejects undeclared
arguments; that is how #616 hard-capped two paged tools at the first page, and how
`ersatztv_list_playouts` (`query`) and `ersatztv_get_playout_items` (`showFiller`) lost their
filters until ersatztv#757.
When adding a write tool, regenerate the spec (`./scripts/update-openapi.sh`) and add the tool to the
pinned list in the body test.
## Deferred
Channel create/update (`ersatztv_create_channel` / `ersatztv_update_channel`) wrap a 28-field DTO with
nine enum fields. Only `name`/`number`/`ffmpegProfileId` are required; the rest have server-side
Channel create/update (`ersatztv_create_channel` / `ersatztv_update_channel`) wrap a large DTO with
nine enum fields — 27 body fields on create, and 28 on update, which additionally carries
`graphicsElementIds`. Only `name`/`number`/`ffmpegProfileId` are required; the rest have server-side
defaults, and the enum fields take the enum **name** (the API validates them). Discover an existing
channel's shape and current enum values with `ersatztv_get_channel` before creating/updating.
channel's shape and current enum values with `ersatztv_get_channel` before creating/updating — and
copy its `graphicsElementIds` through unless you mean to detach them.
Deliberately **not** exposed in this cautious first write pass:
+36 -4
View File
@@ -178,10 +178,42 @@ the #644 follow-up got Class A right and Class B only half right):
The helper owns both bounds: at most ONE `getLibraryBrowseItems` request per settled query, at most
`LIBRARY_PICKER_RESULTS` (25) rows, and no request at all below `LIBRARY_PICKER_MIN_QUERY` (2)
characters. Selecting a media-library type must issue **zero** requests. There is no truncation, so
there is no truncation hint — the old `Showing the first 100 of 5000 — use search to narrow.` copy
is gone from these pickers along with the window it described. Prove the bound with a
**request-count assertion against a large (20k-row) fixture**, not by inspection.
characters. Selecting a media-library type must issue **zero** requests. The per-kind
`LIBRARY_PICKER_RESULTS` cap is the only truncation this class has — there is no whole-type window
left to hint at, so the old `Showing the first 100 of 5000 — use search to narrow.` copy is gone
from these pickers along with the window it described. Surfacing the per-kind cap is *permitted*
wherever it is reachable, and *required* only where bulk selection makes the count actionable — see
the `AddItemsDialog` sub-bullet below, which sums the cap across kinds and renders a `Showing N of
M matches` hint for exactly that reason. Prove the bound with a **request-count assertion against a
large (20k-row) fixture**, not by inspection.
- **`SearchPicker` is the single-select SHAPE, not the rule itself.** A MULTI-select picker
(`CollectionsScreen`'s `AddItemsDialog` — checkbox rows, many items added at once, fanned out
over several kinds) cannot render `SearchPicker` and must not be forced to. It satisfies this
section by taking the same *constraints* the helper enforces for single-select — the gate on
`LIBRARY_PICKER_MIN_QUERY`, `titleContainsQuery` the typed text, `LIBRARY_PICKER_RESULTS` per
kind — via `searchLibraryBrowseItems` (`web/src/api/libraryBrowse.ts`), a sibling of
`searchLibraryPickerOptions` that returns full `LibraryBrowseItem` rows plus `totalCount`
instead of `{id, name}`, so the bound lives in the helper rather than the caller (#685 review
finding 2). There is no post-fetch `slice`, but the per-kind cap can still truncate the real
match count — this is a bulk multi-select add, where "add the 40 matching episodes" is a
first-class use, so `AddItemsDialog` sums each kind's `totalCount` and renders a `Showing N of
M matches` hint once it exceeds the rendered rows (finding 4 — an earlier revision of this
bullet called the truncation nothing left to hint at). **The gate's home is the shared HELPER,
not the screen — however single-sink the screen's own function looks.** #685 got this wrong
twice in a row, and the second time is the instructive one: the check sat inside `runSearch`,
which genuinely IS the one sink both entry paths route through, so it read as correct. It was
still a duplicate of the helper's gate, and the two masked each other: as of `4be3f247d`
which had no unit tests on the helper — deleting EITHER copy left the whole suite green, so the
min-query boundary test pinned nothing. Removing the screen's copy is what made the helper's
gate load-bearing. **The invariant, not the count: every gate must have at least one test that
reddens when that gate ALONE is removed.** A guard you cannot redden is not a guard, and "it's
the single sink" is not evidence that it is the only one. **Outstanding on this screen**: `AddItemsDialog` still lacks the monotonic `seqRef`
stale-response guard and `useIsMountedRef()` — the same class of guard "Debounced typeaheads"
below mandates there, applied to a debounced-while-typing fetch; `AddItemsDialog` is an explicit
Search-button submission, not a typeahead, so that mandate doesn't reach it directly, but the
same race (a superseded search settling after a newer one) can still occur here — tracked in
**ersatztv#740**, not yet fixed here.
- **Compile typed text; never forward raw Lucene.** Send `titleContainsQuery(text)`
`title:*<escaped>*`. The index's default field does not match bare title words (`Alpha` finds
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env bash
# Per-step execution markers for the two REQUIRED docker-build.yml jobs (ersatztv#756).
#
# WHY THIS EXISTS. A `run:` body the runner declines to interpolate is DROPPED, and the job still
# concludes `success` (ersatztv#751, `ci.workflow-run-body-no-expressions`). In
# `review-verdict.yml` that is fail-CLOSED — the required `review-verdict/h10` is simply absent and
# the merge is blocked. In `docker-build.yml` it is fail-OPEN: `Build & test (.NET)` and
# `EF migration integrity (SQLite + MySql)` are the other two required contexts on `main`, so a
# dropped step there sends a required check GREEN having done no work. #751 guarded the safe
# direction because that is where the live bug was, not because these were checked.
#
# WHY PER STEP, NOT PER JOB, which is what #756 proposed. A marker written by the job's FIRST step
# only proves the job started. The dangerous drop is not step 1 — it is `Test`, or the migration
# replay: the job runs everything around them, reports green, and nothing ran that anyone cared
# about. A guard that cannot see the fail-open case it was built for is the "guard that never
# executed" failure one level up. So every consequential step marks itself and a trailing guard
# asserts the whole expected SET.
#
# THAT GUARD CARRIES NO `if:` — unlike the #751 one, which uses `if: always()` because its job has a
# single real step. These jobs have a dozen, and a genuine early failure legitimately skips every
# later step, so `always()` would print a false "these steps never executed" on top of every ordinary
# red build. The default `success()` is the wanted condition: the guard is skipped only when an
# earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every green
# path runs the guard.
#
# WHY A SCRIPT AND NOT AN INLINE BODY, unlike the #751 guard. Two reasons, and the second is the
# load-bearing one:
#
# * The path literal exists ONCE. The #751 guard carries it twice (write + assert) and its tests
# spend real effort proving the two copies agree, because a divergence reddens every run and
# then gets deleted as broken. Here they cannot diverge.
# * A one-line `run: scripts/ci-step-ran.sh …` CANNOT CONTAIN AN EXPRESSION DELIMITER, so the
# mechanism this guards against cannot drop the guard itself. #751's own record names this as
# the stronger construction ("the body would have had to move into scripts/, where a one-line
# run: makes the class unreachable") and settled for inline only because the measurement showed
# it was not required there.
#
# WHY A SCRIPT IS ACCEPTABLE HERE THOUGH IT WOULD NOT BE IN review-verdict.yml. That workflow
# checks out the PR's BASE precisely so a PR cannot supply the code that judges it. `docker-build.yml`
# is head-resolved by design — a PR already supplies every test this job runs — so calling a script
# from the head adds no authority a PR did not already have. This is a CORRECTNESS gate against
# silent no-ops, not a security gate against a hostile PR; that job belongs to `review-verdict/h10`.
# Do not copy this reasoning back into the gate workflow.
#
# THE MARKER FILE IS KEYED ON THE RUN, and BE PRECISE ABOUT WHY — the obvious justification is a
# #751 measurement that does NOT transfer to these jobs, and saying so is the point. #751 measured
# `RUNNER_TEMP` to be `/tmp` and called it "not a private per-job directory"; that was taken on
# `review-verdict.yml`, which runs WITHOUT a `container:`. `test` and `migrations` run INSIDE the CI
# toolchain image, so their `/tmp` is the job container's own and starts empty. That follows from
# `container:`, NOT from a measurement: the build-lane probe confirmed only that `RUNNER_TEMP` is
# `/tmp` here (the marker landed at `/tmp/etv-ci-steps-ran-test-1910-1`) — it says nothing about the
# directory being private or empty, and an earlier draft of this comment cited it as though it did.
# The fresh container is what actually rules out a stale marker here; the keying is defence in depth.
#
# It is kept because container-per-job is a property of how the lane is configured today, not a
# guarantee, and a STALE marker is the one failure that makes this guard PASS on a run whose step was
# dropped — a silent success, i.e. the exact thing being removed. Cheap insurance against a lane
# change nobody would think to re-check this against.
set -euo pipefail
usage() {
cat >&2 <<'EOF'
usage:
ci-step-ran.sh mark <key>
Record that this step began executing. Call it as the step's FIRST act, before
anything in the body can fail.
ci-step-ran.sh assert --always <key>... [--gated <key>...]
Fail unless every expected key was marked. --always keys are always required.
--gated keys are required only when the job's skip gates did NOT fire, read from
ETV_DOCS_ONLY / ETV_REVALIDATE_SKIP so this mirrors the steps' own `if:`.
EOF
exit 2
}
# NO SILENT FALLBACK FOR THE RUN IDENTITY — found by cold review. The first version defaulted to
# `nojob`/`norunid`/`1`, and those are REUSABLE: with `GITHUB_RUN_ID` unset, every run on the host
# would share ONE marker file, so a leftover from any earlier run would satisfy the guard on a run
# whose step was dropped. A silent PASS — the exact failure the keying exists to remove, reintroduced
# by the code meant to implement it.
#
# THE TWO HALVES ARE TREATED DIFFERENTLY, ON EVIDENCE, because the blast radii differ and this is a
# REQUIRED check — a wrong refusal deadlocks every merge, so strictness is not free:
#
# * `GITHUB_JOB` and `GITHUB_RUN_ID` are MEASURED present on this runner (#756's build-lane probe
# wrote `/tmp/etv-ci-steps-ran-test-1910-1`; `test` is the job id and 1910 is the real API run
# id). Absence would mean the runner changed under us, so refusing is safe AND correct.
# * `GITHUB_RUN_ATTEMPT` is measured present TOO, as of ersatztv#756's own PR run — but note how,
# because the first two attempts to settle it were both bad. Grepping a job log for the variable
# NAME proves nothing (logs do not dump the environment). Inferring it from the ABSENCE of this
# script's "not set" warning proves nothing either, because that warning goes to stderr and
# whether step stderr reaches a job log here was itself never established. So the script was made
# to REPORT its resolved identity on stdout, where capture is not in question, and the answer was
# then simply read off run 1916: `Marker identity: job=test run=1916 attempt=1 (from the runner)`
# and the same for `migrations`. Both required jobs, on the lane that matters.
#
# That measurement is what promoted it from warn-and-default to REQUIRED, which is why the residual
# this comment used to describe — a rerun inheriting attempt 1's markers — no longer exists. If a
# future runner stops exporting any of the three, every job reddens with a message naming the
# variable; that is loud, instantly diagnosable, and the correct direction for a required check.
marker_path() {
local missing=""
[ -n "${GITHUB_JOB:-}" ] || missing="$missing GITHUB_JOB"
[ -n "${GITHUB_RUN_ID:-}" ] || missing="$missing GITHUB_RUN_ID"
[ -n "${GITHUB_RUN_ATTEMPT:-}" ] || missing="$missing GITHUB_RUN_ATTEMPT"
if [ -n "$missing" ]; then
# NOTHING IS PRINTED TO STDOUT HERE, and that is load-bearing rather than style: this
# function's stdout IS its return value (it is always called inside `$( )`), so a notice
# printed here is captured INTO the path. An earlier revision did exactly that and both
# sub-commands then failed on a nonexistent directory. Caught by
# test_a_degraded_run_IDENTITY_*, which is why that test asserts on the exit status and on
# the absence of any marker file rather than only on the message.
echo "::error::ci-step-ran.sh cannot identify this run —${missing} not set. The marker path would fall back to a name other runs also use, and a stale marker would make the dropped-step guard PASS on a run whose step never executed (ersatztv#756). Refusing rather than degrading to a reusable name." >&2
exit 3
fi
printf '%s/etv-ci-steps-ran-%s-%s-%s' \
"${RUNNER_TEMP:-${GITHUB_WORKSPACE:-/tmp}}" \
"$GITHUB_JOB" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"
}
cmd_mark() {
[ "$#" -eq 1 ] && [ -n "$1" ] || usage
# Appended, never truncated: every step in the job shares one file, and a `>` here would erase
# its predecessors and make the guard red on every run.
#
# A failure to write is NOT swallowed. The step is running under `bash -e`, so a non-zero here
# fails the step and reddens the job — which is the same direction the guard would take a moment
# later, but with a message pointing at the real cause instead of at a missing marker.
local target
# NOT `>> "$(marker_path)"`: the refusal above `exit`s a SUBSHELL there, and bash discards a
# command substitution's exit status when it is only part of a redirection — the write would go
# to an empty path and the error would read as a redirection failure rather than the real cause.
target="$(marker_path)" || exit $?
printf '%s\n' "$1" >> "$target"
}
cmd_assert() {
local -a always=() gated=()
local bucket=""
while [ "$#" -gt 0 ]; do
case "$1" in
--always) bucket=always ;;
--gated) bucket=gated ;;
-*) usage ;;
*)
case "$bucket" in
always) always+=("$1") ;;
gated) gated+=("$1") ;;
*) usage ;;
esac ;;
esac
shift
done
# ANTI-VACUITY, at runtime rather than only in the test suite. An `assert` called with no
# expectations passes unconditionally and reports "every expected step executed" — a guard that
# proves nothing while looking like it proved everything. Refuse instead.
if [ "${#always[@]}" -eq 0 ] && [ "${#gated[@]}" -eq 0 ]; then
echo "::error::ci-step-ran.sh assert was called with no expected keys, so it would pass unconditionally. This is a workflow bug, not a build failure." >&2
exit 2
fi
# The skip gates, mirroring the `if:` every gated step carries:
# steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
# Anything other than the exact string `true` means the step was expected to run — including the
# EMPTY string, which is what these read as when the detect step itself was dropped. That
# direction is deliberate: a dropped detect step must widen what is required, never narrow it.
local skipped=no
if [ "${ETV_DOCS_ONLY:-}" = "true" ] || [ "${ETV_REVALIDATE_SKIP:-}" = "true" ]; then
skipped=yes
fi
local marker attempt_used
# `|| exit $?` because `set -e` does NOT fire on a failing command substitution in an assignment;
# without it a degraded identity would leave `marker` empty and every key would read as missing —
# fail-closed by luck, with a misleading message.
marker="$(marker_path)" || exit $?
# Read the attempt back OFF THE RESOLVED PATH rather than from the environment. It reports what
# the path was actually keyed on, so a future change to how the path is built cannot silently
# disagree with the line that documents it.
attempt_used="${marker##*-}"
# `${arr[@]+"${arr[@]}"}` rather than a bare `"${arr[@]}"`: under `set -u` bash 3.2 (the system
# bash on the Macs this suite also runs on) treats expanding an EMPTY array as an unbound
# variable and aborts. The CI image ships bash 5, where it is fine — which is exactly the kind of
# difference that makes a guard pass locally and die on the runner, or the reverse.
local -a expected=(${always[@]+"${always[@]}"})
if [ "$skipped" = no ]; then
expected+=(${gated[@]+"${gated[@]}"})
else
echo "Skip gate fired (docs_only='${ETV_DOCS_ONLY:-}', already_validated='${ETV_REVALIDATE_SKIP:-}') — the gated steps were not expected to run."
fi
# RE-CHECKED AFTER GATING, not only on argv — found by cold review, which reproduced it:
# `ETV_DOCS_ONLY=true … assert --always --gated foo` printed "All 0 expected step(s) executed"
# and exited 0. The argv check above cannot see that, because the set is emptied by the gate, not
# by the caller. Unreachable with today's argv (both jobs pass `--always detect revalidate`), but
# it directly contradicted the comment above it, and a guard that reports proving everything
# while proving nothing is the failure this whole file exists to remove.
if [ "${#expected[@]}" -eq 0 ]; then
echo "::error::ci-step-ran.sh assert ended up with NO expected keys after the skip gate, so it would pass unconditionally. This is a workflow bug, not a build failure." >&2
exit 2
fi
local -a missing=()
local key
for key in "${expected[@]}"; do
# `grep -qxF` over a FILE, never a pipeline: `grep -q` exits at its first match and would
# SIGPIPE a producer, which under `set -o pipefail` inverts the result for large inputs
# (ersatztv#698). Reading the file directly has no producer to kill. `-x` so a key cannot be
# satisfied by another key that contains it, `-F` so a key is never read as a pattern.
if ! grep -qxF "$key" "$marker" 2>/dev/null; then
missing+=("$key")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::These steps of job '${GITHUB_JOB:-?}' never executed: ${missing[*]}. The runner DROPPED them (an interpolation failure over a run: body does this and still reports the job GREEN — ersatztv#751/#756) or their \`if:\` no longer matches the guard's expectations. This job is a REQUIRED check, so a green here would mean a required context passed having done no work. Failing the job so it is visible."
if [ -f "$marker" ]; then
echo "Marker file ${marker} recorded:"
sed 's/^/ /' "$marker"
else
echo "There is no marker file at ${marker} at all — not one step of this job executed."
fi
exit 1
fi
# The resolved identity, on stdout, every run. This is what turns "is GITHUB_RUN_ATTEMPT
# exported here?" from an inference into something a reader just looks up — and it is why the
# variable is still WARN-and-default rather than REFUSE: `GITHUB_JOB` and `GITHUB_RUN_ID` have
# positive evidence (the probe's marker filename), this one does not yet, and refusing on an
# unestablished variable would redden a REQUIRED check. Promote it once a run has printed
# `attempt=<n> (from the runner)`.
# Kept after the promotion, though all three components are now required and the line can no
# longer report anything but the runner's own values. It is the standing evidence: this is the
# line that settled whether GITHUB_RUN_ATTEMPT is exported, and it is what a future reader checks
# first if the keying is ever doubted again.
echo "Marker identity: job=${GITHUB_JOB} run=${GITHUB_RUN_ID} attempt=${attempt_used} (from the runner)"
echo "All ${#expected[@]} expected step(s) executed: ${expected[*]}"
}
[ "$#" -ge 1 ] || usage
sub="$1"
shift
case "$sub" in
mark) cmd_mark "$@" ;;
assert) cmd_assert "$@" ;;
*) usage ;;
esac
+723
View File
@@ -0,0 +1,723 @@
"""The dropped-step guard on docker-build.yml's two REQUIRED jobs (ersatztv#756).
WHAT THIS IS PROTECTING. A `run:` body the runner declines to interpolate is DROPPED, and the job
still concludes `success` (ersatztv#751, `ci.workflow-run-body-no-expressions`). #751 fixed that in
`review-verdict.yml`, where the consequence is fail-CLOSED `review-verdict/h10` is absent and the
merge is blocked. It left the two places where the same drop is fail-OPEN: `Build & test (.NET)` and
`EF migration integrity (SQLite + MySql)` are the other two required contexts on `main`, so a dropped
step there sends a required check green having done no work.
THE TESTS COME IN THREE KINDS AND NONE SUBSTITUTES FOR ANOTHER, which is the lesson #751 paid for:
* STATIC the marker set and the guard's expectations agree, and the guard is positioned so it
can actually run. Cheap, and the only kind that catches a NEW step added without a marker.
* BEHAVIOURAL the guard's real command line is EXECUTED against markers written by the steps'
real marker lines, both extracted from the parsed workflow. A structural test cannot prove an
exit code, and `exit 1` in a body is satisfiable by dead code.
* A LIVE PROBE that the runner still executes a LATER step after dropping an earlier one, on the
BUILD lane rather than the `small` lane #751 measured. That is the premise the whole guard rests
on and no test here can establish it; it is recorded in docs/ci-cd.md and on the issue.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "docker-build.yml"
SCRIPT = REPO_ROOT / "scripts" / "ci-step-ran.sh"
# The jobs whose contexts branch protection REQUIRES on `main`. Read live on 2026-08-10:
# Build ErsatzTV Image / Build & test (.NET) (pull_request)
# Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request)
# review-verdict/h10
# The third is guarded by test_pr_changed_files.py; these two are this file's subject. `build`,
# `api-docs` and `format` are deliberately NOT here — they are not required, and all three
# legitimately interpolate into a `run:` body, so extending the absolute rule to them would be false.
# Per-step markers apply to the two REQUIRED contexts, where a dropped step is fail-OPEN.
MARKED_JOBS = ("test", "migrations")
# The delimiter ban is WIDER than the marker set, and the extra job is not an afterthought.
# `build`'s "Smoke + IPTV E2E" step runs AFTER `Build and push`, so on a `v*` tag the image is
# already in the registry as the release candidate and this step is what decides whether it was ever
# booted. A drop there publishes an unsmoked candidate and goes green, and `DeployStack jazz-media`
# promotes exactly that image — not a "smaller cost than a required context", which is what an
# earlier draft of the decision record claimed. Its two payloads moved into the step's `env:`, which
# is the free half of the escape hatch, so the ban costs nothing there.
#
# `functional-e2e` is deliberately NOT here even though it is delimiter-free today: it is advisory by
# declaration (not a required check, not a `needs:` of `build`), so the rule stays "ban where a drop
# is consequential" rather than "ban wherever it happens to be free right now".
# `api-docs` and `format` keep one delimiter each, both `github.base_ref` in a detect step, and gate
# nothing that ships.
DELIMITER_BAN_JOBS = ("test", "migrations", "build")
# THE RAW OPENER, not a closed `${{ … }}` pair — found by cold review. The runner's rewrite is
# triggered by the OPENER; a closed-pair regex therefore misses `# ${{` with no closer, which would
# sail through an "absolute" ban and still drop the step. Nothing in these jobs may contain the
# opener at all, so matching it directly is both simpler and strictly stronger. `_EXPR` is kept for
# reporting the payload of a well-formed one in the failure message.
_OPENER = re.compile(r"\$\{\{")
_EXPR = re.compile(r"\$\{\{(.*?)\}\}", re.S)
_MARK = re.compile(r'ci-step-ran\.sh"?\s+mark\s+(\S+)')
# ONE parse, shared. `yaml.safe_load` per call returns a fresh object graph, so an identity test
# across two helpers (`steps[-1] is guard`) would compare structurally-equal but distinct dicts and
# fail — or, worse in the other direction, an `is not` filter would exclude nothing and a step would
# match as its own guard. That is not hypothetical: test_pr_changed_files.py records exactly this
# going wrong in the #751 guard test, where the assertions then ran against the wrong step.
_DOC = yaml.safe_load(WORKFLOW.read_text())
def _doc():
return _DOC
def _steps(job: str):
return _doc()["jobs"][job]["steps"]
def _run_steps(job: str):
return [s for s in _steps(job) if s.get("run")]
def _guard(job: str):
"""The trailing assert step. Located by CONTENT, never by index.
Locating it as `steps[-1]` here and then asserting it is last elsewhere would be circular the
position test would hold by construction. This finds the step that invokes the assert
sub-command, and `test_the_guard_is_the_LAST_step` independently checks where it sits.
"""
hits = [s for s in _run_steps(job) if "ci-step-ran.sh assert" in s["run"]]
assert len(hits) == 1, f"job '{job}' has {len(hits)} assert steps, expected exactly 1"
return hits[0]
def _marked(job: str):
"""[(step, key)] for every step that records its own execution, in declaration order."""
out = []
for s in _run_steps(job):
m = _MARK.search(s["run"])
if m:
out.append((s, m.group(1)))
return out
def _guard_buckets(job: str):
"""(always_keys, gated_keys) as the guard's own argv spells them."""
argv = _guard(job)["run"].split()
assert "--always" in argv and "--gated" in argv, argv
a, g = argv.index("--always"), argv.index("--gated")
return argv[a + 1:g], argv[g + 1:]
# Mirrors the `if:` every gated step in these jobs carries. Compared as a normalised string rather
# than by parsing the expression: what matters is that a step's gating and the guard's bucketing are
# the SAME condition, and any rewrite of one that is not mirrored in the other should be loud.
SKIP_GATE = ("steps.detect.outputs.docs_only!='true'&&steps.revalidate.outputs.skip!='true'")
def _is_gated(step) -> bool:
return re.sub(r"\s+", "", str(step.get("if", ""))) == SKIP_GATE
# ------------------------------------------------------------------------------------------------
# STATIC
# ------------------------------------------------------------------------------------------------
@pytest.mark.parametrize("job", DELIMITER_BAN_JOBS)
def test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body(job):
"""The absolute rule from `review-verdict.yml`, extended to the two required build jobs.
This is the cheaper and more general half of #756: the drop mechanism REQUIRES an opener in the
scalar, so a job with none is immune by construction and the runtime markers are a backstop
rather than the only line of defence.
The scope is the three jobs in `DELIMITER_BAN_JOBS` see the comment there for why `build` is in
and `functional-e2e` is not. Do NOT restate this docstring as "scoped to the required pair":
round 2 moved `build`'s two payloads into `env:` and brought it into the ban, and this docstring
sits directly above the decorator that parametrises over the wider set.
The escape hatch when a value really is needed is the step's `env:` block, which is interpolated
PER VALUE, so a payload that does not evaluate cannot take the body with it.
The `run:` SCALAR AS PARSED, comments and all. A shell comment inside a `run:` body is NOT inert
that is the whole #751 defect — so this must never filter comments out. Ordinary YAML comments
outside a `run:` body ARE inert and are not read here.
"""
offenders = []
for s in _run_steps(job):
for m in _OPENER.finditer(s["run"]):
closed = _EXPR.match(s["run"], m.start())
payload = closed.group(1).strip() if closed else "<unclosed opener>"
offenders.append(f"{s.get('name', '?')}: {payload!r}")
assert not offenders, (
f"job '{job}' of docker-build.yml has an expression delimiter inside a run: body — "
f"{offenders}. A dropped step in this job is CONSEQUENTIAL — `test`/`migrations` write "
"REQUIRED status contexts, and `build` publishes the release candidate before its smoke step "
"runs. Even in a comment a delimiter is unsafe: the runner rewrites the WHOLE body into a "
"format(...) call, and if the payload does not parse it DROPS THE STEP and reports the job "
"green — so the check passes having done no work (ersatztv#751/#756). Pass the value in "
"through the step's `env:` "
"block instead; to describe an expression in prose, name it rather than quoting the "
"delimiters."
)
# ANTI-VACUITY. A walk that reached no bodies, or only the trivial ones, would make the
# assertion above green while proving nothing. Counted against the job's own step list read
# here, so a helper that silently stopped yielding steps is caught rather than rewarded.
declared = sum(1 for s in _steps(job) if isinstance(s, dict) and s.get("run"))
assert len(_run_steps(job)) == declared >= 3, (
f"the walk reached {len(_run_steps(job))} run: bodies but job '{job}' declares {declared}"
)
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_every_consequential_run_step_marks_itself_as_its_FIRST_act(job):
"""The completeness half — and the only test that catches a NEWLY ADDED step with no marker.
A guard that checks a fixed list can go quietly incomplete: someone adds a `Test SPA (part 2)`
step, it is never marked, the guard never expects it, and a drop of exactly that step is
invisible again. So the expectation is DERIVED from the workflow rather than written down twice.
EXEMPT: steps carrying `continue-on-error: true`. Those are advisory by construction (the
peak-anon sampler, the coverage summary) the workflow already declares that their failure must
not redden the job, so their non-execution cannot be a fail-open either. Making them mandatory
would be asserting the opposite of what `continue-on-error` means.
FIRST ACT, not merely present. A marker written at the END of a body records completion, not
execution and this repo has legitimate early-exit paths. More importantly a marker further down
can be skipped by an early `exit 0` while the step did nothing, which is the fail-open again one
line lower. `set -euo pipefail` is allowed to precede it: it cannot fail, and it is what makes
the rest of the body honest.
"""
missing, late = [], []
for s in _run_steps(job):
if s.get("continue-on-error") is True or "ci-step-ran.sh assert" in s["run"]:
continue
m = _MARK.search(s["run"])
if not m:
missing.append(s.get("name", "?"))
continue
# By LINE, not by byte offset. The marker sits mid-line (the command is quoted and
# prefixed with $GITHUB_WORKSPACE), so slicing at `m.start()` counts the marker's OWN line
# prefix as a preceding command and reddens every correctly-written step.
lines = s["run"].splitlines()
at = next(i for i, ln in enumerate(lines) if _MARK.search(ln))
preceding = [
ln.strip() for ln in lines[:at]
if ln.strip() and not ln.strip().startswith("#")
]
if [ln for ln in preceding if not ln.startswith("set -")]:
late.append((s.get("name", "?"), preceding))
assert not missing, (
f"these run: steps of the REQUIRED job '{job}' do not record that they executed: {missing}. "
"A step the runner drops concludes success, so without a marker its non-execution takes the "
"whole required context green having done no work (ersatztv#756). Add "
'`\"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh\" mark <key>` as the step\'s first line and '
"the key to the guard step's --always/--gated list."
)
assert not late, (
f"these steps of '{job}' mark themselves only after other commands have run: {late}. The "
"marker must be the first act, or a body that exits early records nothing while the guard "
"still expects it — or worse, records success for work that did not happen."
)
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_the_guard_expects_EXACTLY_the_set_of_marked_keys_in_the_right_bucket(job):
"""Set equality in BOTH directions, plus the bucket, because each failure is silent differently.
A key marked but not expected the guard never notices that step being dropped: a fail-open
that looks fully guarded. A key expected but not marked the guard reddens on every single run,
which is fail-closed but reads as "this guard is broken" and is how a correct guard gets deleted.
The BUCKET has to match the step's own `if:`. A gated step listed under `--always` reddens every
docs-only and already-validated run the two paths whose entire purpose is to report green in
seconds. An always-run step listed under `--gated` stops being checked the moment either skip
gate fires, which is a fail-open on precisely the runs where least else is happening.
"""
marked = _marked(job)
keys = [k for _, k in marked]
assert len(keys) == len(set(keys)), (
f"job '{job}' reuses a marker key: {[k for k in keys if keys.count(k) > 1]}. Two steps "
"sharing a key means either one satisfies the guard for both, so dropping one is invisible."
)
always, gated = _guard_buckets(job)
assert sorted(always + gated) == sorted(keys), (
f"job '{job}': the guard expects {sorted(always + gated)} but the steps mark "
f"{sorted(keys)}. Keys marked-but-unexpected are unguarded drops; keys "
"expected-but-unmarked redden every run."
)
# AN UNRECOGNISED `if:` IS REJECTED, never silently bucketed — found by both reviewers. The
# protocol only knows two conditions: absent (always runs) and exactly the skip gate. A marked
# step carrying a third condition (`if: github.event_name == 'push'`, or the `always() && <gate>`
# spelling the peak-anon steps already use) would fall through to "always", the suite would go
# green, and the guard would then demand a step the runner legitimately skipped — reddening a
# REQUIRED context and deadlocking `main`. There is already a near-miss in this file: `Report
# peak container memory` carries that third spelling and escapes only because it is
# `continue-on-error: true` and therefore exempt from marking.
for step, key in marked:
cond = re.sub(r"\s+", "", str(step.get("if", "")))
assert cond in ("", SKIP_GATE), (
f"job '{job}': marked step {step.get('name')!r} has an `if:` the guard protocol does not "
f"model ({step.get('if')!r}). Only 'absent' and the exact skip gate are understood; "
"anything else would be bucketed as --always and would fail the job on a run where the "
"step is legitimately skipped. Extend the protocol deliberately, or leave the step "
"unmarked."
)
want = "gated" if _is_gated(step) else "always"
got = "gated" if key in gated else "always"
assert want == got, (
f"job '{job}': step {step.get('name')!r} is {want} (if: {step.get('if')!r}) but the "
f"guard lists its key {key!r} under --{got}."
)
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_the_guard_is_the_LAST_step_carries_no_if_and_is_not_advisory(job):
"""Position and condition, which together are what make the guard reachable and quiet.
LAST, because a guard placed before a marked step reads a marker not yet written and fails on
every run.
NO `if:` a deliberate departure from the #751 guard's `if: always()`, and the thing most likely
to be "corrected" back. That job has one real step, so `always()` costs nothing. These jobs have
a dozen, and a genuine failure in an early one SKIPS every later step: an `always()` guard would
then report "these steps never executed: typecheck web-test build dotnet-test" on top of every
ordinary red build. That is the runner obeying its own gating, not a dropped step, and a guard
that cries wolf on every red build gets deleted.
The default `if:` is `success()`, and the invariant that makes relying on it safe rather than
lucky: this step is skipped only when an earlier step FAILED, and that failure already fails the
job. So `guard skipped => job red`, and every path to a green job runs the guard. A dropped step
is invisible precisely because it concludes `success` which keeps the job green and therefore
reaches here.
NOT `continue-on-error`, which would let it observe the failure and go green anyway the whole
defect, one attribute over.
"""
steps = _steps(job)
guard = _guard(job)
assert steps[-1] is guard, (
f"the dropped-step guard is not the last step of '{job}' — it is at index "
f"{steps.index(guard)} of {len(steps)}, so any marked step after it would be unguarded and "
"the guard would read a marker that has not been written yet."
)
assert "if" not in guard, (
f"the '{job}' guard carries `if: {guard.get('if')!r}`. It must have none: the default "
"`success()` is what keeps it silent on ordinary red builds, and `always()` would make it "
"announce a false 'these steps never executed' on every failing run. See the comment above "
"the step for why this is a deliberate departure from the #751 guard."
)
assert guard.get("continue-on-error") is not True, (
f"the '{job}' guard is continue-on-error, so it detects the dropped step and lets the job go "
"green regardless — which is the defect it exists to remove."
)
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_the_guards_OWN_body_cannot_be_dropped_by_the_mechanism_it_guards_against(job):
"""A guard the guarded mechanism can silently delete is worse than no guard.
Its absence is silent too: the job simply goes green with nothing checked, which is
indistinguishable from a clean run. #751 states the rule; here it is stronger than there,
because the body is a single command with no delimiter possible rather than 20 lines of prose
that must be kept clean by hand.
The gate VALUES arrive through `env:`, which the runner interpolates per value a bad payload
there fails that value, not the body. Both are additionally held to naming a real context by
test_every_workflow_expression_names_a_REAL_context_or_function in test_pr_changed_files.py.
"""
guard = _guard(job)
assert not _OPENER.search(guard["run"]), (
f"the '{job}' guard's own run body contains an expression delimiter, so the runner can drop "
"the guard the same way it drops the steps the guard is watching — and that absence is "
"silent as well."
)
assert guard["run"].strip().startswith("scripts/ci-step-ran.sh assert"), (
f"the '{job}' guard is no longer a bare invocation: {guard['run']!r}. Keeping it to one "
"command is what makes a delimiter impossible rather than merely absent."
)
# THE VALUES, not just the names — found by cold review. Asserting the keys alone accepts
# `ETV_DOCS_ONLY: ${{ steps.detect.outputs.doc_only }}` (note the typo), which names a real
# context so the repo-wide expression check passes it too. The guard would then read an EMPTY
# value on a docs-only run, demand the gated steps that were correctly skipped, and redden a
# REQUIRED context on every docs-only PR.
# THE TWO MAPPINGS MUST BE PRESENT AND CORRECT — but this deliberately does NOT demand that the
# `env:` block contain ONLY them. An earlier version compared the whole dict, which false-redded
# on adding an unrelated variable (an `LC_ALL`, say) and on the equally-valid `${{x}}` spacing;
# a red here blocks every merge through the combined status, so brittleness is a real cost and
# not a free strictness win. Whitespace inside the delimiters is normalised for the same reason.
env = {k: re.sub(r"\s+", "", str(v)) for k, v in (guard.get("env") or {}).items()}
for name, want in (("ETV_DOCS_ONLY", "${{steps.detect.outputs.docs_only}}"),
("ETV_REVALIDATE_SKIP", "${{steps.revalidate.outputs.skip}}")):
assert env.get(name) == want, (
f"the '{job}' guard's env: has {name}={guard.get('env', {}).get(name)!r}, expected the "
f"output the gated steps' own `if:` reads ({want}). A typo here is SILENT rather than "
"loud: it still names a real context, so the repo-wide expression check passes it, the "
"value arrives empty, and the guard then demands steps that were legitimately skipped — "
"reddening a REQUIRED context on every docs-only run."
)
# ------------------------------------------------------------------------------------------------
# BEHAVIOURAL — the guard's real command line, against markers written by the steps' real lines
# ------------------------------------------------------------------------------------------------
def _mark_line(step) -> str:
"""The step's OWN marker line, verbatim from the workflow.
Extracted rather than rebuilt in Python ON PURPOSE. A test that composed the command itself
would keep passing after the workflow and the script drifted apart on the path, the quoting or
the sub-command and that divergence is exactly the failure that makes the guard fail on every
run and then get deleted as broken. Running the real line proves the two agree by construction.
"""
line = next(ln for ln in step["run"].splitlines() if _MARK.search(ln))
return line.strip()
# THE GATE VALUES DEFAULT TO `"false"`, WHICH IS WHAT THE RUNNER ACTUALLY SENDS — and getting this
# wrong made the whole suite blind. Found by cold review, which demonstrated it: every behavioural
# test used to leave these UNSET, so the guard was never once driven at its production values. Change
# the gate in `ci-step-ran.sh` from `= "true"` to `-n` — a one-token regression — and all 30 tests
# stayed GREEN while the guard, run with the real environment, reported
# `Skip gate fired (docs_only='false') … All 2 expected step(s) executed` and exited 0. `Build`,
# `Test` and both migration replays would have been unguarded on every ordinary run, with the guard
# announcing that it had proved everything.
#
# THE COMPLETE VALUE SET, and where each comes from — worth spelling out, because the obvious reading
# of the evidence is wrong. Both producers document `true|false` and write exactly that
# (`scripts/ci-detect-docs-only.sh` -> `docs_only=`, `scripts/ci-detect-already-validated.sh` ->
# `skip=`), so an ordinary run sends `false` and a skipping run sends `true`.
#
# The live log of the probe this change cites (run 1910, job 8064) shows `ETV_DOCS_ONLY: false` and
# `ETV_REVALIDATE_SKIP:` EMPTY — but do NOT read that as revalidate's normal output. `revalidate` was
# the step the probe deliberately dropped, so it wrote no output at all. The empty string is
# therefore not an odd third state: it is the SIGNATURE OF THE VERY FAILURE THIS GUARD EXISTS TO
# CATCH, which is exactly why the gate must treat anything that is not `true` as "widen what is
# required". `None` (unset) is the same case reached a different way.
#
# A test double is an assertion about what the real system sends, and the earlier version of this one
# was wrong about the only field the guard branches on.
GATE_VALUES_IN_THE_WILD = ("false", "", None)
def _env(tmp_path, **extra):
env = {
"PATH": os.environ["PATH"],
"GITHUB_WORKSPACE": str(REPO_ROOT),
"RUNNER_TEMP": str(tmp_path),
"GITHUB_JOB": "test",
"GITHUB_RUN_ID": "424242",
"GITHUB_RUN_ATTEMPT": "7",
"ETV_DOCS_ONLY": "false",
"ETV_REVALIDATE_SKIP": "false",
}
env.update(extra)
return {k: v for k, v in env.items() if v is not None}
def _run(script: str, env):
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env,
capture_output=True, text=True)
@pytest.mark.parametrize("gate", GATE_VALUES_IN_THE_WILD,
ids=["gate-false", "gate-empty", "gate-unset"])
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_the_guard_PASSES_when_every_step_marked_itself(job, gate, tmp_path):
"""The positive control. Without it, a guard that always failed would satisfy every case below.
`GITHUB_JOB` is set to the job under test, so this also covers the marker file being keyed per
job: if it were not, the two jobs would share a file and one job's markers would answer for the
other's dropped steps.
"""
marks = [_mark_line(s) for s, _ in _marked(job)]
guard = _guard(job)["run"]
# Parametrised over every NOT-SKIPPING spelling the runner emits — `false` on an ordinary run,
# empty when the producing step was dropped, absent if the output is never set. All three must
# require the gated steps; a gate that treats any of them as a skip is fail-open on that path.
env = _env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY=gate, ETV_REVALIDATE_SKIP=gate)
r = _run("\n".join(["set -e", *marks, guard]), env)
assert r.returncode == 0, (
f"the '{job}' guard rejected a run in which every step marked itself — the steps and the "
f"guard disagree, so this would fail on every run.\n{r.stdout}\n{r.stderr}"
)
assert "All" in r.stdout and "executed" in r.stdout, r.stdout
# The other half of the identity contract: with GITHUB_RUN_ATTEMPT set (`_env` sends 7) the line
# must report the REAL value and say so. A mis-derivation (`${marker#*-}` rather than `##`) or an
# inverted provenance test would otherwise ship silently, and the operator reading this line to
# settle the promotion question would read it wrong.
assert f"Marker identity: job={job} run=424242 attempt=7 (from the runner)" in r.stdout, (
f"the guard misreported its marker identity: {r.stdout!r}")
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_dropping_ANY_single_step_FAILS_the_guard(job, tmp_path):
"""Every marked step, one at a time — not a sample.
An arbitrary sample gives false negatives here: the interesting drop is `Test` or the migration
replay, and a test that only omitted the first step would prove the guard catches the one case
that was never fail-open anyway. Dropping each key in turn is the only version that establishes
the property the issue asks for.
"""
marked = _marked(job)
guard = _guard(job)["run"]
for dropped_step, dropped_key in marked:
d = tmp_path / dropped_key
d.mkdir()
marks = [_mark_line(s) for s, k in marked if k != dropped_key]
r = _run("\n".join(["set -e", *marks, guard]), _env(d, GITHUB_JOB=job))
assert r.returncode != 0, (
f"job '{job}': the guard went GREEN with {dropped_step.get('name')!r} "
f"(key {dropped_key!r}) never having executed. That is a REQUIRED context reporting "
f"success having skipped that work — the exact fail-open of ersatztv#756.\n{r.stdout}"
)
assert dropped_key in (r.stdout + r.stderr), (
f"the guard failed but did not name the missing step {dropped_key!r}: {r.stdout}"
)
@pytest.mark.parametrize("job", MARKED_JOBS)
@pytest.mark.parametrize("gate", ["ETV_DOCS_ONLY", "ETV_REVALIDATE_SKIP"])
def test_a_fired_skip_gate_does_not_require_the_gated_steps(gate, job, tmp_path):
"""The docs-only and already-validated paths must still report green in seconds.
They are the reason these jobs are never `if:`-skipped at the JOB level (a skipped required
context is a state this repo deliberately does not rely on ersatztv#416/#418), so a guard that
reddened them would make every docs-only PR unmergeable. Which is #751's user-visible symptom
arriving from the opposite direction, and worth a test rather than a comment.
"""
marks = [_mark_line(s) for s, k in _marked(job) if k in _guard_buckets(job)[0]]
guard = _guard(job)["run"]
r = _run("\n".join(["set -e", *marks, guard]), _env(tmp_path, GITHUB_JOB=job, **{gate: "true"}))
assert r.returncode == 0, (
f"with {gate}=true the guard still demanded the gated steps, so every docs-only / "
f"already-validated run of a REQUIRED job would be red.\n{r.stdout}\n{r.stderr}"
)
assert "Skip gate fired" in r.stdout, r.stdout
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_a_fired_skip_gate_STILL_requires_the_ALWAYS_steps(job, tmp_path):
"""The negative control for the test above — otherwise `ETV_DOCS_ONLY=true` would be a blanket
off-switch and the previous test would be passing for the wrong reason.
This is the case that matters most on a docs-only run: the detect steps are the only things that
execute, so if their drop were unguarded the skip path would be entirely unchecked.
"""
guard = _guard(job)["run"]
r = _run("\n".join(["set -e", guard]), _env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY="true"))
assert r.returncode != 0, (
"with ETV_DOCS_ONLY=true and NO steps marked at all, the guard passed — the skip gate is "
"acting as a blanket off-switch rather than as a narrowing of what is expected."
)
assert "detect" in (r.stdout + r.stderr), r.stdout
@pytest.mark.parametrize("job", MARKED_JOBS)
def test_an_EMPTY_gate_value_requires_the_gated_steps(job, tmp_path):
"""A dropped `detect` step leaves its outputs EMPTY, not 'false'.
Reading empty as "skipped" would mean the one drop that disables the detect step also disables
the guard for everything downstream the guard switching itself off in response to the very
failure it exists to catch. The direction has to be: anything that is not exactly `true` widens
what is required.
"""
guard = _guard(job)["run"]
r = _run("\n".join(["set -e", guard]),
_env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY="", ETV_REVALIDATE_SKIP=""))
assert r.returncode != 0
assert _guard_buckets(job)[1][-1] in (r.stdout + r.stderr), (
f"empty gate values were read as a skip, so the gated steps went unchecked: {r.stdout}"
)
def test_a_STALE_marker_from_another_run_cannot_satisfy_the_guard(tmp_path):
"""A marker from another run, attempt or job must never answer for this one.
Do NOT restate this as "RUNNER_TEMP is /tmp, not a private per-job directory". That is a #751
measurement taken on a job with no `container:`, and it does not transfer: these two jobs run
inside the CI toolchain image, so their `/tmp` is the container's own. The fresh container is
what actually rules out staleness here; the keying is defence in depth against a lane change
nobody would think to re-check this against, and that is why it is still worth testing.
"""
marks = [_mark_line(s) for s, _ in _marked("test")]
guard = _guard("test")["run"]
# Run 1 marks everything.
first = _env(tmp_path, GITHUB_JOB="test", GITHUB_RUN_ID="111", GITHUB_RUN_ATTEMPT="1")
assert _run("\n".join(["set -e", *marks]), first).returncode == 0
# Run 2 shares RUNNER_TEMP but marks nothing. It must NOT inherit run 1's markers.
second = _env(tmp_path, GITHUB_JOB="test", GITHUB_RUN_ID="222", GITHUB_RUN_ATTEMPT="1")
r = _run(guard, second)
assert r.returncode != 0, (
"a marker file left by a DIFFERENT run satisfied the guard, so a run whose steps were all "
f"dropped would pass silently.\n{r.stdout}"
)
# ...and a RETRY of run 1 must not inherit run 1's either.
retry = _env(tmp_path, GITHUB_JOB="test", GITHUB_RUN_ID="111", GITHUB_RUN_ATTEMPT="2")
assert _run(guard, retry).returncode != 0, (
"a re-run inherited the first attempt's markers, so a step dropped only on the retry passes"
)
# ...nor may the OTHER job in the same run inherit them.
sibling = _env(tmp_path, GITHUB_JOB="migrations", GITHUB_RUN_ID="111", GITHUB_RUN_ATTEMPT="1")
assert _run(_guard("migrations")["run"], sibling).returncode != 0, (
"the two required jobs share one marker file, so one job's markers answer for the other's "
"dropped steps"
)
def test_assert_with_no_expected_keys_REFUSES_instead_of_passing(tmp_path):
"""The script's own anti-vacuity check, exercised rather than trusted.
`assert` with an empty expectation list would print "All 0 expected step(s) executed" and exit 0
a guard that proves nothing while reporting that it proved everything. That is how a guard
ends up shipped and dead, which this repo has now done twice (#751's fence, #751's own guard).
"""
r = _run(f"{SCRIPT} assert", _env(tmp_path))
assert r.returncode == 2, f"expected a usage refusal, got {r.returncode}: {r.stdout} {r.stderr}"
assert "no expected keys" in (r.stdout + r.stderr)
def test_mark_APPENDS_so_one_step_does_not_erase_its_predecessors(tmp_path):
"""`>` instead of `>>` in the script would leave only the last step's key.
The guard would then redden on every run fail-closed, but it would look like the guard is
broken rather than like a real drop, and that is the state in which a correct guard gets removed.
"""
env = _env(tmp_path)
assert _run(f"{SCRIPT} mark alpha && {SCRIPT} mark beta", env).returncode == 0
r = _run(f"{SCRIPT} assert --always alpha beta", env)
assert r.returncode == 0, f"the second mark erased the first: {r.stdout} {r.stderr}"
def test_a_key_is_matched_WHOLE_not_as_a_substring(tmp_path):
"""`build` must not be satisfied by `web-build`, and `test` not by `web-test`.
Both pairs are live key names in the `test` job, so a substring match would mean dropping the
real `Build` or `Test` step the two most consequential steps in the whole workflow is
invisible because an SPA step of a similar name ran.
"""
env = _env(tmp_path)
assert _run(f"{SCRIPT} mark web-build && {SCRIPT} mark web-test", env).returncode == 0
r = _run(f"{SCRIPT} assert --always build", env)
assert r.returncode != 0, (
"the key 'build' was satisfied by a marker for 'web-build' — a dropped `dotnet build` would "
"pass unnoticed"
)
def test_a_degraded_run_IDENTITY_refuses_rather_than_sharing_a_marker_path(tmp_path):
"""`GITHUB_RUN_ID` absent must REFUSE, not fall back to a name every run shares.
The first version of `marker_path` defaulted to `nojob`/`norunid`/`1`. Those are reusable, so a
leftover marker from any earlier run on the host would satisfy the guard on a run whose step was
dropped a silent PASS, which is the precise failure the run-keying exists to remove,
reintroduced by the code implementing it. Found by cold review.
Asserted on BOTH sub-commands: a refusal that only `assert` honoured would let `mark` write to a
shared path and leave the two disagreeing about where the file is.
"""
env = _env(tmp_path)
for var in ("GITHUB_RUN_ID", "GITHUB_JOB", "GITHUB_RUN_ATTEMPT"):
degraded = {k: v for k, v in env.items() if k != var}
for argv in (f"{SCRIPT} mark alpha", f"{SCRIPT} assert --always alpha"):
r = _run(argv, degraded)
assert r.returncode != 0, (
f"with {var} unset, `{argv.split()[-2]}` continued and used a fallback path that "
f"other runs also use — a stale marker there passes the guard on a dropped run.\n"
f"{r.stdout}{r.stderr}"
)
assert "cannot identify this run" in (r.stdout + r.stderr), (
f"refused, but without naming the cause: {r.stdout!r} {r.stderr!r}")
assert not list(tmp_path.iterdir()), (
"a degraded-identity `mark` still created a marker file somewhere under RUNNER_TEMP")
def test_the_marker_identity_is_REPORTED_on_stdout_every_run(tmp_path):
"""The line that settled `GITHUB_RUN_ATTEMPT`, kept as standing evidence.
Worth recording HOW that was settled, because the first two attempts were both bad. Grepping a
job log for the variable NAME proves nothing (logs do not dump the environment). Inferring it
from the ABSENCE of a "not set" warning proves nothing either, because that warning goes to
stderr and whether step stderr reaches a job log here was itself never established the control
offered for that was an `::error::` this script writes to STDOUT. So the script was made to
REPORT its resolved identity on stdout, where capture is not in question, and the answer was read
off ersatztv#756's own PR run: `Marker identity: job=test run=1916 attempt=1 (from the runner)`,
and the same for `migrations`. That is what promoted the variable from warn-and-default to
required.
Asserted because cold review demonstrated three mutations of this reporting deleting the echo,
mis-deriving the attempt, inverting the provenance all surviving a 50-green suite. It is a
documented contract (the record's `mechanics:`), and a future reader is told to trust it.
"""
marks = [_mark_line(s) for s, _ in _marked("test")]
r = _run("\n".join(["set -e", *marks, _guard("test")["run"]]),
_env(tmp_path, GITHUB_RUN_ID="1916", GITHUB_RUN_ATTEMPT="4"))
assert r.returncode == 0, r.stdout + r.stderr
assert "Marker identity: job=test run=1916 attempt=4 (from the runner)" in r.stdout, (
"the guard did not report the identity its marker path was actually keyed on, so a reader "
f"cannot audit the keying from a run log: {r.stdout!r}")
def test_a_skip_gate_that_empties_the_expected_set_REFUSES(tmp_path):
"""The anti-vacuity check has to run AFTER the gate, not only on argv. Cold review reproduced
this exactly:
ETV_DOCS_ONLY=true assert --always --gated foo
-> "All 0 expected step(s) executed", exit 0
The argv check cannot see it, because the set is emptied by the gate rather than by the caller.
Unreachable with today's argv, but it contradicted the comment directly above it — and "reports
that it proved everything while proving nothing" is the failure this whole file exists to remove.
"""
r = _run(f"{SCRIPT} assert --always --gated foo", _env(tmp_path, ETV_DOCS_ONLY="true"))
assert r.returncode != 0, (
f"the guard passed with an empty post-gate expectation set: {r.stdout!r}")
assert "no expected keys" in (r.stdout + r.stderr).lower() or "NO expected keys" in r.stderr
@pytest.mark.parametrize("revalidate", ["true", "false", "", None],
ids=lambda v: f"reval-{v if v is not None else 'unset'}")
@pytest.mark.parametrize("docs_only", ["true", "false", "", None],
ids=lambda v: f"docs-{v if v is not None else 'unset'}")
def test_the_skip_gate_over_the_WHOLE_value_matrix(docs_only, revalidate, tmp_path):
"""Every combination of the two gate values, not just the diagonal — cold review's last finding.
Round 3 fixed the suite's blindness to the production value `false`, but still only exercised
matched pairs and single-`true` cases. `(true, true)` is REACHABLE a docs-only PR merged to
`main` whose tree was already validated sets both and an exclusive-or regression would pass
every other test here while demanding all the gated markers on a run that legitimately skipped
those steps. That reddens BOTH required contexts, which is the false-red direction: it deadlocks
every merge rather than letting one through.
The property asserted is the whole contract in one line: with only the `--always` keys marked,
the guard passes exactly when the gate says the gated steps were skipped `true` in EITHER
variable, and nothing else. Sixteen cases, so no combination is a special case anyone has to
remember.
On `unset`: the workflow's `env:` block always defines both, emitting EMPTY for an output the
producing step never wrote, so unset is not reachable through the workflow. It is covered because
the script is also runnable by hand, and because "not exactly true" is the property that must
hold for every spelling rather than for an enumerated list.
"""
job = "test"
always, gated = _guard_buckets(job)
marks = [_mark_line(s) for s, k in _marked(job) if k in always]
r = _run("\n".join(["set -e", *marks, _guard(job)["run"]]),
_env(tmp_path, ETV_DOCS_ONLY=docs_only, ETV_REVALIDATE_SKIP=revalidate))
should_skip = docs_only == "true" or revalidate == "true"
assert (r.returncode == 0) is should_skip, (
f"with docs_only={docs_only!r} and revalidate={revalidate!r} the guard "
f"{'passed' if r.returncode == 0 else 'failed'}, expected it to "
f"{'skip the gated keys' if should_skip else 'require them'}. The gate must treat a value as "
"a skip if and only if it is exactly `true` in EITHER variable.\n" + r.stdout + r.stderr)
@@ -0,0 +1,243 @@
"""The `scan` job — the delimiter ban made fail-CLOSED on the release path (ersatztv#767).
WHAT THIS IS PROTECTING. #756 brought `build` into the delimiter ban, because a dropped
`Smoke + IPTV E2E` publishes a release candidate that was never booted and reports the job green.
But the ban was enforced ONLY by `test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body`
in `script-tests` `on: pull_request`, not a required context. Nothing re-checked it on a `v*` tag
push, which is exactly when the candidate is published.
WHY A JOB AND NOT A STEP IN `build`, and why this file is structural. The first cut of #767 put a
bespoke stdlib scanner in `build` itself. Two independent reviews killed it on two counts, and both
are worth keeping written down because both are easy to re-invent:
* A guard step inside `build` cannot protect `build`. If the runner drops it, the job carries on
and publishes fail-OPEN. The defence offered was "the guard's own body has no opener, so it
cannot be dropped", but the only thing enforcing THAT was the same PR-only test being
backstopped. Circular. As a `needs:` of `build`, a red here means `build` never runs at all.
* The bespoke scanner hand-parsed YAML (to avoid provisioning PyYAML on `build`'s bare runner) and
had ~10 false NEGATIVES within one review round flow mappings, a quoted `"run":` key, aliases,
multiline quoted scalars. It was strictly WEAKER than the check it backstopped, in the only
direction that matters. The fix was to delete it and run the real PyYAML-based test, which needs
no second definition of "what is a `run:` body" and so has no drift surface.
So the detection logic is not retested here it lives in `test_ci_dropped_step_guard.py` and this
job runs that file. What this file holds is the WIRING, which is what makes the ban fail-closed:
the job exists, `build` depends on it, nothing can skip it, its own steps cannot be silently
dropped, and it actually invokes the ban test.
WHAT THIS DOES NOT CLAIM. That no step can ever fail to run for a reason other than the
interpolation drop. This job's own steps carry #756 markers and a trailing assert, so the regress
terminates where the sibling guards' does — to fail open you must now drop the pytest step AND the
assert step, not either one. The end-to-end behaviour (a poisoned `Smoke` body reddens `scan` and
`build` never runs) is a LIVE measurement recorded on the issue, not something a static test here
can establish.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "docker-build.yml"
SCRIPT = REPO_ROOT / "scripts" / "ci-step-ran.sh"
_DOC = yaml.safe_load(WORKFLOW.read_text())
_OPENER = re.compile(r"\$\{\{")
_MARK = re.compile(r'ci-step-ran\.sh"?\s+mark\s+(\S+)')
JOB = "scan"
BAN_TEST_FILE = "scripts/tests/test_ci_dropped_step_guard.py"
def _job():
assert JOB in _DOC["jobs"], f"the `{JOB}` job is gone — the release path is unguarded again"
return _DOC["jobs"][JOB]
def _steps():
return _job()["steps"]
def _run_steps():
return [s for s in _steps() if s.get("run")]
def _guard():
"""The trailing assert step, located by CONTENT — never by index, so that
`test_the_guard_is_the_LAST_step` is not true by construction."""
hits = [s for s in _run_steps() if "ci-step-ran.sh assert" in s["run"]]
assert len(hits) == 1, f"expected exactly 1 assert step in `{JOB}`, found {len(hits)}"
return hits[0]
def _marked():
out = []
for s in _run_steps():
m = _MARK.search(s["run"])
if m:
out.append((s, m.group(1)))
return out
# ------------------------------------------------------------------------------------------------
# WIRING — the properties that make the ban fail-closed
# ------------------------------------------------------------------------------------------------
def test_build_DEPENDS_on_the_scan_job():
"""This single edge is the whole fail-closed property.
Without it the scan is advisory: it could go red while `build` publishes anyway.
"""
needs = _DOC["jobs"]["build"]["needs"]
needs = [needs] if isinstance(needs, str) else needs
assert JOB in needs, f"`build` no longer needs `{JOB}` — a red scan would not stop a release"
def test_the_scan_job_has_NO_job_level_if():
"""Two failure modes at once, in opposite directions.
An `if:` that excludes the tag push would leave the release path unguarded the exact hole
#767 closed. An `if:` that skipped it for any other reason would SKIP `build` too (a skipped
dependency skips its dependents), breaking every release. Neither is wanted: it always runs.
"""
job = _job()
assert "if" not in job, f"`{JOB}` must carry no job-level `if:`, found {job.get('if')!r}"
def test_the_scan_job_actually_invokes_the_ban_test():
"""Otherwise the job is an expensive no-op that reports green.
Asserted against the file path the ban test really lives in, so renaming that file without
updating the workflow is a red here rather than a silently unguarded release path.
"""
assert (REPO_ROOT / BAN_TEST_FILE).is_file()
assert any(BAN_TEST_FILE in s["run"] for s in _run_steps()), (
f"no step in `{JOB}` runs {BAN_TEST_FILE}"
)
def test_no_step_in_the_scan_job_is_advisory():
"""`continue-on-error: true` would make the whole gate a no-op while every other test here
stayed green it is the cheapest way to accidentally disarm this."""
offenders = [s.get("name") for s in _steps() if s.get("continue-on-error")]
assert not offenders, f"advisory step(s) in `{JOB}`: {offenders}"
@pytest.mark.parametrize(
"step_name",
[s.get("name", "?") for s in yaml.safe_load(WORKFLOW.read_text())["jobs"][JOB]["steps"]
if s.get("run")],
)
def test_every_run_body_in_the_scan_job_is_delimiter_free(step_name):
"""The guard must not be vulnerable to the defect it guards against.
Not a proof that it always runs a construction argument about ONE mechanism, the same axiom
the sibling guards rest on. It is asserted per step so a failure names which step regressed.
"""
step = next(s for s in _run_steps() if s.get("name", "?") == step_name)
assert not _OPENER.search(step["run"]), (
f"step {step_name!r} of `{JOB}` contains an expression delimiter; the runner would rewrite "
"the whole body and DROP the step while reporting success (ersatztv#751). Pass values "
"through `env:`, which is interpolated per value."
)
# ------------------------------------------------------------------------------------------------
# THE JOB'S OWN DROPPED-STEP GUARD
# ------------------------------------------------------------------------------------------------
def test_every_consequential_step_marks_itself():
"""Every `run:` step except the guard records that it executed."""
marked = {s.get("name") for s, _ in _marked()}
expected = {s.get("name") for s in _run_steps() if s is not _guard()}
assert marked == expected, f"unmarked step(s) in `{JOB}`: {expected - marked}"
def test_the_guard_expectations_match_the_markers_exactly():
"""The set the guard waits for IS the set the steps write — derived from the workflow, not
restated here, so adding a step without a marker is a red."""
argv = _guard()["run"].split()
assert "--always" in argv, argv
always = argv[argv.index("--always") + 1:]
assert "--gated" not in argv, "every step in this job is unconditional; there is nothing to gate"
assert sorted(always) == sorted(k for _, k in _marked())
def test_the_guard_is_the_LAST_step():
assert _steps()[-1] is _guard(), "the assert must run after the steps it checks"
def test_the_guard_has_no_if():
"""Same reasoning as the sibling guards: the default `success()` is wanted, because a genuine
early failure legitimately skips later steps and already fails the job."""
assert "if" not in _guard()
# ------------------------------------------------------------------------------------------------
# BEHAVIOURAL — the guard's REAL command line, against the steps' REAL marker lines
# ------------------------------------------------------------------------------------------------
def _mark_line(step) -> str:
"""The step's own marker line, verbatim from the workflow — never rebuilt in Python, so a
drift between the workflow and the script cannot hide behind a test that composed its own."""
return next(ln for ln in step["run"].splitlines() if _MARK.search(ln)).strip()
def _env(tmp_path, **extra):
env = {
"PATH": os.environ["PATH"],
"GITHUB_WORKSPACE": str(REPO_ROOT),
"RUNNER_TEMP": str(tmp_path),
"GITHUB_JOB": JOB,
"GITHUB_RUN_ID": "424242",
"GITHUB_RUN_ATTEMPT": "7",
}
env.update(extra)
return {k: v for k, v in env.items() if v is not None}
def _run(script: str, env):
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env,
capture_output=True, text=True)
def test_the_guard_PASSES_when_every_step_ran(tmp_path):
env = _env(tmp_path)
for step, _ in _marked():
assert _run(_mark_line(step), env).returncode == 0
res = _run(_guard()["run"], env)
assert res.returncode == 0, res.stderr
@pytest.mark.parametrize("dropped", [k for _, k in _marked()])
def test_the_guard_FAILS_when_a_step_was_dropped(tmp_path, dropped):
"""The positive control. Drop each key in turn — the guard must go red and NAME it.
A guard only ever exercised on the happy path is indistinguishable from one that passes
unconditionally, which is the failure this whole mechanism exists to remove.
"""
env = _env(tmp_path)
for step, key in _marked():
if key != dropped:
assert _run(_mark_line(step), env).returncode == 0
res = _run(_guard()["run"], env)
assert res.returncode != 0, f"guard passed despite '{dropped}' never running: {res.stdout}"
# BOTH streams: the script's `::error::` lands on stdout here while other diagnostics go to
# stderr, and a test that picked the wrong one would assert on an empty string and pass for the
# wrong reason on any message change.
assert dropped in (res.stdout + res.stderr), (res.stdout, res.stderr)
def test_the_guard_REFUSES_to_pass_with_no_expectations(tmp_path):
"""`assert` with an empty expectation set would report success having checked nothing."""
res = _run(f"{SCRIPT} assert --always", _env(tmp_path))
assert res.returncode != 0
+754 -15
View File
@@ -791,9 +791,13 @@ def test_no_OTHER_workflow_writes_the_review_verdict_status():
The trigger test above hardens ONE file, and by construction cannot see the more general
problem: the gate is forgeable by *any* head-resolved workflow holding credentials that can POST
a commit status, not only by the gate's own definition. `docker-build.yml` is exactly that — it
triggers on `pull_request` (correctly; it builds the PR's code) and carries `ETV_STATUS_AUTH`,
which a probe confirmed can WRITE statuses, not merely read them. That residual is tracked in
ersatztv#697 and is NOT closed by this test.
triggers on `pull_request` (correctly; it builds the PR's code) and carries `ETV_STATUS_AUTH`.
A probe once confirmed those credentials could WRITE statuses, not merely read them
(ersatztv#697); a follow-up probe confirmed `REGISTRY_PASSWORD` is now scoped off
`write:repository` and the same POST returns 403 (see `ci.actions-credential-scoping`). That
closes the registry-credential route, not the general problem `GITEA_TOKEN`, `RENOVATE_TOKEN`,
and a collaborator's own token can all still POST a status, and none of that is closed by this
test.
What this test does close is the cheap regression: a second workflow quietly starting to write
the context. It is a guard against drift, not a security boundary a workflow can still write
@@ -907,25 +911,38 @@ if "-X" in args and args[args.index("-X") + 1] == "POST":
# edit to either pattern cannot silently reroute one endpoint into the other's handler.
#
# Real shapes, measured on this instance and deliberately mirrored, because the job's guards are
# type-sensitive: this endpoint returns a BARE ARRAY of events, each with a `type`; a retarget is
# type-sensitive: a NON-EMPTY page is a bare array of events, each with a `type`, and a retarget is
# `change_target_branch` (confirmed on PR #703, the route-1 reproduction, which carries exactly two;
# and on PR #717, never retargeted, which carries none).
# and on PR #717, never retargeted, which carries none). A page PAST THE END is the JSON value `null`
# — NOT an empty array (measured again at 1.27.1 on PR #752, ersatztv#751). The earlier version of
# this comment claimed measured fidelity while the terminator below printed `[]`; that discrepancy is
# why the fence's type gate was never exercised and shipped rejecting every real timeline.
if "/timeline" in url:
mode = os.environ.get("STUB_TIMELINE_MODE", "none")
page = 1
for part in url.split("?", 1)[-1].split("&"):
if part.startswith("page="):
page = int(part.split("=", 1)[1])
if mode == "empty-first-page":
# A terminator on PAGE 1 — no real page ever read (ersatztv#751). The job must NOT certify a
# zero retarget count from this, so the exemption is withheld.
print(os.environ.get("STUB_TIMELINE_TERMINATOR", "null"))
sys.exit(0)
if mode == "unreadable":
print("<html>502 Bad Gateway</html>")
sys.exit(0)
if mode == "transport-error":
sys.exit(22)
# Page 2+ is always the validated EMPTY page that terminates the walk. Without a real terminator
# the job would page to its cap and refuse to trust the count, which is a different branch from
# the one most of these tests mean to exercise.
# Page 2+ terminates the walk. THE DEFAULT IS `null`, NOT `[]`, because that is what this
# endpoint really returns past the end — measured at Gitea 1.27.1 (ersatztv#751). This stub
# printed `[]` for two rounds of #706 work while the comment above claimed it mirrored measured
# reality, so every fence test was green against a shape the server never produces, and the
# fence's `array`-only type gate — which reads `null` as unreadable — was never exercised. On the
# real instance that made `rt_ok` false for EVERY pr, so the fence withheld every exemption.
# Parameterised rather than simply corrected: `/issues/{n}/comments` really does return `[]` when
# empty, so both shapes are live on this server and the job must accept either.
if page > 1:
print("[]")
print(os.environ.get("STUB_TIMELINE_TERMINATOR", "null"))
sys.exit(0)
n_before, n_after = 0, 0
if mode.startswith("stable:"):
@@ -952,6 +969,47 @@ if "/timeline" in url:
# endpoint no longer shows.
if "/statuses/" in url:
mode = os.environ.get("STUB_HISTORY_MODE", "none")
# PAGE, honoured. THE REASON IS THE TERMINATOR, NOT THE COUNTERS — and that distinction was itself
# a finding (#751 round 5). An earlier version of this comment said the guard sits ahead of the
# read-counting modes "so the page-2 probe cannot shift 'raced row appears on read N'", by analogy
# with the combined endpoint. Measured: moving this guard AFTER the counter modes reddens NOTHING,
# because no history mode that counts reads ever issues a page-2 request — `raced=1` on page 1
# short-circuits the probe. So that half of the rationale read as a checked reason and was not,
# which is the precise class this branch exists to retire.
#
# What IS load-bearing: page 2 must terminate for every mode that describes page 1 only, or the job
# re-reads page 1's rows as a second page and concludes the list is longer than it is. Dropping
# just the `print("[]")` below reddens `test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair`.
# (The ordering guard on the COMBINED endpoint is a different story and genuinely is counter-
# related — moving that one reddens three mid-run-race tests.)
#
# PAST THE END THIS ENDPOINT RETURNS `[]`, NOT `null` — measured at 1.27.1:
# `/statuses/{sha}?limit=100&page=99` is the two bytes `[]`, while `/commits/{sha}/status` past the
# end returns `{"statuses": null}`. A THIRD distinct empty shape on one server; the job tolerates
# both here precisely because guessing per endpoint has been wrong twice.
hist_page = 1
for part in url.split("?", 1)[-1].split("&"):
if part.startswith("page="):
try:
hist_page = int(part.split("=", 1)[1])
except ValueError:
hist_page = 1
if hist_page > 1 and mode == "second-page-garbage":
print("<html>502 Bad Gateway</html>")
sys.exit(0)
if hist_page > 1 and mode == "second-page-error":
sys.exit(22)
if hist_page > 1 and mode not in ("second-page", "second-page-garbage", "second-page-error"):
print("[]")
sys.exit(0)
if mode == "second-page":
# The history runs past page 1, so "no raced row on page 1" does not establish "no race". The
# rows are deliberately ORDINARY (no verdict, no sentinel): the point is that unread rows EXIST,
# not that a verdict was found, so the repair must fire on uncertainty alone.
print(json.dumps([{"id": 7000 + i, "context": "ci/other", "status": "success",
"creator": None, "description": "unrelated"} for i in range(3)]))
sys.exit(0)
rows = []
if mode.startswith("human-after-post"):
# The raced verdict: absent when the high-water mark is taken, present afterwards. Its id is
@@ -989,6 +1047,47 @@ if "/status" in url:
# never-overwrite short-circuit unreachable: neither could be made to fire, so mutations
# deleting them survived the whole suite.
mode = os.environ.get("STUB_STATUS_MODE", "none")
# PAGE, honoured — the job now reads page 2 to justify "no verdict exists" (ersatztv#751). The
# real endpoint pages properly: measured at 1.27.1 on a 6-context head, `?limit=3&page=2` returns
# 3 more rows and `page=9` returns the `statuses: null` terminator. Every mode below describes
# page 1 only, so page 2+ must terminate, or the job would read its own page-1 rows again and
# conclude the list is longer than it is. `twopage` is the one mode with a real second page.
status_page = 1
for part in url.split("?", 1)[-1].split("&"):
if part.startswith("page="):
try:
status_page = int(part.split("=", 1)[1])
except ValueError:
status_page = 1
# THE EMPTY SHAPE IS `statuses: null`, NOT `[]` (ersatztv#751). Measured on this instance: a head
# with no statuses returns `{"state":"pending","total_count":0,"statuses":null}` — PR #739's head
# 5fa672e2. This stub printed `{"statuses": []}` at all three no-verdict sites, which is a shape
# the server does not produce for that case, so the job's `.statuses | type == "array"` gate was
# never exercised against reality and its `exit 1` branch — posting nothing at all — was
# unreachable in the suite. Same class as the timeline terminator, one function over.
# Parameterised, not merely corrected: a head that HAS statuses really does return an array, so
# both shapes are live and the job must read either.
def empty_statuses():
if os.environ.get("STUB_STATUS_EMPTY_SHAPE", "null") == "array":
return json.dumps({"state": "pending", "total_count": 0, "statuses": []})
return json.dumps({"state": "pending", "total_count": 0, "statuses": None})
# BEFORE ANY READ-COUNTING MODE. The `appears-on-read:N` modes count how many times the job has
# LOOKED at the combined status, and the page-2 completeness probe is part of the same look, not a
# further one — letting it increment those counters shifted "the verdict appears on read N" by one
# and broke three mid-run-race tests. Page 2 also has to terminate here for every mode that
# describes page 1 only, or the job would re-read page 1's rows as a second page and conclude the
# list is longer than it is.
if status_page > 1 and mode == "page2-garbage":
print("<html>502 Bad Gateway</html>")
sys.exit(0)
if status_page > 1 and mode == "page2-error":
sys.exit(22)
if status_page > 1 and mode != "twopage":
print(empty_statuses())
sys.exit(0)
if mode.startswith("appears-on-read:"):
# A human verdict that does NOT exist at the first read and DOES exist at the re-read made
# immediately before the POST (ersatztv#706). Models a reviewer posting BLOCKED while the job
@@ -998,7 +1097,7 @@ if "/status" in url:
n = int(ctr.read_text()) if ctr.exists() else 0
ctr.write_text(str(n + 1))
if n + 1 < nth:
print(json.dumps({"statuses": []}))
print(empty_statuses())
sys.exit(0)
print(json.dumps({"statuses": [
{"context": "review-verdict/h10", "status": "failure",
@@ -1013,11 +1112,36 @@ if "/status" in url:
n = int(ctr.read_text()) if ctr.exists() else 0
ctr.write_text(str(n + 1))
if n + 1 < nth:
print(json.dumps({"statuses": []})); sys.exit(0)
print(empty_statuses()); sys.exit(0)
print(json.dumps({"statuses": [
{"context": "review-verdict/h10", "status": "pending", "creator": None,
"description": "Human verdict raced this exemption write — re-post the verdict"}]}))
sys.exit(0)
if mode == "twopage":
# Page 1 AND page 2 both carry decoy contexts and NO `review-verdict/h10`: the list is longer
# than one page, so "no verdict exists" is not established and the job must refuse. Models a
# head with more contexts than the server-wide page cap (measured 50 here) — which is exactly
# why the guard cannot be written as a comparison against a hardcoded limit.
print(json.dumps({"state": "pending", "total_count": 3,
"statuses": [{"context": f"ci/p{status_page}x{i}", "status": "pending"}
for i in range(3)]}))
sys.exit(0)
if mode.startswith("rows:"):
# N decoy contexts and NO `review-verdict/h10`, to exercise the truncation guard. `total_count`
# deliberately MIRRORS the page length, because that is what the real endpoint does — it is the
# count for the page returned, not for the commit (measured at 1.27.1: `?limit=1` on a 6-context
# head returns len=1, total_count=1). A stub that reported the true total would make a
# length-vs-total guard look like it worked, which is the trap this models.
n = int(mode.split(":", 1)[1])
print(json.dumps({"state": "pending", "total_count": n,
"statuses": [{"context": f"ci/decoy{i}", "status": "pending"}
for i in range(n)]}))
sys.exit(0)
if mode == "total-count-string":
# A schema-corrupted `total_count` as a STRING alongside a null `.statuses`. `jq -r` renders 0
# and "0" identically, so a text compare would accept this.
print(json.dumps({"state": "pending", "total_count": "0", "statuses": None}))
sys.exit(0)
if mode == "transport-error":
# Real `gh()` is `curl -sf`: an HTTP error exits 22 with EMPTY stdout.
sys.exit(22)
@@ -1044,7 +1168,7 @@ if "/status" in url:
"creator": ({"login": creator} if creator else None), "description": desc},
{"context": "Functional E2E", "status": "pending"}]}))
sys.exit(0)
print(json.dumps({"statuses": []}))
print(empty_statuses())
sys.exit(0)
print("{}")
@@ -1055,7 +1179,9 @@ def _run_classify(tmp_path, enum_stub: str | None, author: str = "timothy",
status_mode: str = "none", jq16: bool = False,
status_creator: str | None = "timothy",
status_desc: str = "Review-verdict: MERGEABLE @ a9e3e23 (base: main)",
timeline_mode: str = "none", history_mode: str = "none"):
timeline_mode: str = "none", history_mode: str = "none",
timeline_terminator: str = "null",
status_empty_shape: str = "null"):
"""Execute the workflow's classify `run:` block with a stubbed enumeration script.
Returns the status payload the job POSTed, or None if it posted nothing.
@@ -1081,6 +1207,8 @@ def _run_classify(tmp_path, enum_stub: str | None, author: str = "timothy",
env["STUB_STATUS_CREATOR"] = status_creator or ""
env["STUB_STATUS_DESC"] = status_desc
env["STUB_TIMELINE_MODE"] = timeline_mode
env["STUB_TIMELINE_TERMINATOR"] = timeline_terminator
env["STUB_STATUS_EMPTY_SHAPE"] = status_empty_shape
env["STUB_HISTORY_MODE"] = history_mode
env.update({
"GITEA_TOKEN": "stub",
@@ -1703,8 +1831,10 @@ def test_a_LEGACY_verdict_with_no_recorded_base_is_still_honoured(tmp_path):
def test_an_APPENDED_base_cannot_override_the_real_one(tmp_path):
"""The description is attacker-influencable by anyone who can POST a status (#697), so the parse
must not be trickable into reading a second, appended base.
"""The description is attacker-influencable by anyone who can POST a status — the registry
credential's own route closed with #697, but `GITEA_TOKEN`, `RENOVATE_TOKEN`, and a
collaborator's own token still can — so the parse must not be trickable into reading a second,
appended base.
This defeated the greedy `##` parse. The implementation no longer parses at all — it requires the
description to END with the exact literal marker AND to contain exactly one marker so a second
@@ -2111,3 +2241,612 @@ def test_a_SENTINEL_landing_above_the_mark_also_triggers_the_repair(tmp_path):
assert seq[1]["state"] == "pending"
assert seq[1]["description"] == "Human verdict raced this exemption write — re-post the verdict", (
f"repaired, but not to the sentinel, so the fixed point is broken: {seq[1]}")
# --- The expression-delimiter class (ersatztv#751) ----------------------------------------------
#
# WHAT HAPPENED, because the shape of it is what these tests are written against. A `run:` body is
# not shell yet when the runner reads it. The runner scans the whole scalar for the expression
# opener, and a single occurrence makes it rewrite the ENTIRE body into one `format(...)` call so
# the evaluated result can be spliced back in. That rewrite is all-or-nothing, so a payload that
# does not parse takes the whole step with it — and the runner then DROPS THE STEP AND CONCLUDES THE
# JOB `success`.
#
# The occurrence that did it was in a SHELL COMMENT: the #706 note explaining why a concurrency
# group does not work quoted a `concurrency:` snippet containing a PR-number expression as an
# illustration. `pr number` is not an expression. The step stopped running on 2026-08-03 and nothing
# went red until 2026-08-06.
#
# WHY EVERY EXISTING GUARD IN THIS FILE WAS BLIND TO IT, which is the part worth keeping: they all
# read `_code_lines()`, which strips comment lines. That choice is correct for what it was for — its
# own docstring explains that prose legitimately discusses `pulls/N/files`, and a raw scan would
# redden the repo over a piece of writing. But it encodes an assumption this bug falsifies: that a
# comment in a workflow cannot change behaviour. Inside a `run:` scalar it can. So the test below
# reads the RAW text, deliberately, and is the one test here that must never adopt `_code_lines`.
_EXPR = re.compile(r"\$\{\{(.*?)\}\}", re.S)
# Roots of a dotted context path, and the callable functions. Both lists are what the runner
# actually accepts; anything outside them cannot evaluate, and an expression that cannot evaluate
# does not fail loudly — it silently removes the step it appears in.
_EXPR_CONTEXTS = frozenset({
"github", "env", "vars", "secrets", "inputs", "runner", "steps", "needs", "matrix", "job",
"jobs", "strategy",
})
_EXPR_FUNCTIONS = frozenset({
"always", "success", "failure", "cancelled", "hashFiles", "format", "toJSON", "toJson",
"fromJSON", "fromJson", "contains", "startsWith", "endsWith", "join",
})
_EXPR_LITERALS = frozenset({"true", "false", "null"})
def _yaml_string_scalars(path: Path):
"""Every string scalar in the parsed document — keys and values, recursively.
Deliberately PARSED rather than raw (ersatztv#751, cold re-review). A `${{ … }}` in an ordinary
top-level YAML comment is inert: the runner never evaluates it, so redding on it would be a false
positive of exactly the kind this file has now produced twice. PyYAML drops those comments, which
is the behaviour wanted here.
A `run:` body IS one of these scalars, and it keeps its SHELL comments which is the whole point,
because inside a `run:` scalar a comment is not inert. So this covers the real defect class in
every workflow while ignoring the one place a delimiter is genuinely harmless.
"""
import yaml
out: list[str] = []
def walk(node):
if isinstance(node, str):
out.append(node)
elif isinstance(node, dict):
for k, v in node.items():
if isinstance(k, str):
out.append(k)
walk(v)
elif isinstance(node, list):
for v in node:
walk(v)
walk(yaml.safe_load(path.read_text()))
return out
def _workflow_files():
# `*.y*ml` for the reason test_no_OTHER_workflow_writes_the_review_verdict_status gives: a
# workflow added as `.yaml` is just as executable and would otherwise go unscanned.
return sorted((REPO_ROOT / ".gitea" / "workflows").glob("*.y*ml"))
def test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body():
"""The absolute rule, and it is scoped to this one file on purpose.
Other workflows legitimately interpolate into a `run:` body (5 occurrences today across
ci-image, docker-build's `api-docs`/`format`, and pr-checks' two git-diff gates), so a
repo-wide ban would be false and would be deleted the first time it got in someone's way.
`test`, `migrations` and `build` carry their own absolute ban see
scripts/tests/test_ci_dropped_step_guard.py (ersatztv#756). This file is different in two ways that justify the
strict rule: it writes the branch-protection-required status, so a dropped step here is a dead
merge gate rather than a failed build; and its `run:` bodies are ~700 lines of dense prose,
which is the only place the delimiter has ever appeared by accident.
The rule is also what keeps the dropped-step guard trustworthy. A guard that the guarded
mechanism can silently delete is worse than no guard, because its absence is silent too.
The `run:` SCALAR AS PARSED, comments and all never `_code_lines`, which strips them. The
distinction matters and is easy to garble: what must not be filtered is the SHELL comments inside
the body, because those are what the runner scans. Ordinary YAML comments outside a `run:` body are
genuinely inert and are handled by the repo-wide test below, which is why neither test reads the
file as flat text any more.
"""
offenders = []
for job_name, step in _iter_workflow_steps(WORKFLOW):
for m in _EXPR.finditer(step.get("run") or ""):
offenders.append(f"{job_name}/{step.get('name', '?')}: {m.group(1).strip()!r}")
assert not offenders, (
f"review-verdict.yml has an expression delimiter inside a run: body — {offenders}. Even in "
"a comment this is unsafe: the runner rewrites the WHOLE body into a format(...) call, and "
"if the payload does not parse it drops the step and reports the job GREEN, leaving the "
"required review-verdict/h10 unposted (ersatztv#751). To describe an expression in prose, "
"name it (`a github.event.pull_request.number expression`) instead of quoting the "
"delimiters. Pass values in through the step's `env:` block, which is interpolated per "
"value, so a bad payload there cannot take the body with it."
)
# ANTI-VACUITY, and it has to be the right property. A `run:` body the YAML walk never reached
# would make the assertion above vacuously green — the failure mode to guard against. An earlier
# version of this guard compared the file's TOTAL delimiter count against the count inside
# `with:`/`env:` values, which is a different and wrong claim: it banned expressions everywhere
# else in the file too. Both reviewers reproduced the false red — writing `if: ${{ always() }}`,
# the standard and equivalent spelling of the `if:` two steps below, turned this test red, as did
# a delimiter in an inert top-level YAML comment. Neither is unsafe, and a red here blocks every
# merge through the combined status, so the guard was strictly more dangerous than the thing it
# was protecting against.
#
# ANTI-VACUITY WITHOUT A SECOND PARSER. A first attempt counted `run:` keys in the raw text and
# compared that to the walk. Cold re-review showed the regex only recognised an indented `run:`
# whose value starts with `|` or `>`, so legal spellings (`- run: |`, a single-line
# `run: echo ok`) counted as zero declarations and false-redded the file, while a `run: |` line
# sitting INSIDE a shell heredoc counted as a declaration. Hand-parsing YAML to check a YAML parse
# is the wrong shape: it adds a second, worse parser whose disagreements are all false alarms, and
# a red here blocks every merge through the combined status.
#
# The property that actually matters is that the walk reached the body that carries the risk. The
# classifier is ~700 lines; a walk that returned nothing, or only the short steps, is the failure
# to catch. Both are asserted on content, which no spelling change can spoof.
bodies = [s["run"] for _, s in _iter_workflow_steps(WORKFLOW) if s.get("run")]
# NOT `>= 3`, and not merely non-empty either. `>= 3` had zero slack — cold review deleted the
# optional jq-preflight step, a legitimate simplification, and this redded claiming the classifier
# had not been examined, which was untrue. But relaxing it to `assert bodies` threw away the only
# check that the walk reached ALL run-bearing steps: `max(len) > 5000` proves it reached the
# classifier and nothing about the short ones, so a helper that silently stopped yielding them
# would let an unscanned delimiter through.
#
# So count against the job's own step list, read here rather than through the helper under test.
# That catches the failure the count existed for (a helper looking at the wrong key, or dropping
# steps) without breaking when a step is legitimately added or removed.
import yaml as _yaml
_steps = (_yaml.safe_load(WORKFLOW.read_text())["jobs"]["set-verdict-status"]["steps"] or [])
declared = sum(1 for s in _steps if isinstance(s, dict) and s.get("run"))
assert len(bodies) == declared, (
f"the YAML walk reached {len(bodies)} run: bodies but the job declares {declared} — the "
"assertion above did not examine every body, so a green here proves nothing")
assert max(len(b) for b in bodies) > 5000, (
"the YAML walk did not reach a substantial run: body — the ~700-line classifier is the one "
"that must be scanned, so this test would be vacuous")
def _iter_workflow_steps(path: Path):
import yaml
doc = yaml.safe_load(path.read_text()) or {}
for job_name, job in (doc.get("jobs") or {}).items():
for step in (job.get("steps") or []):
yield job_name, step
def test_every_workflow_expression_names_a_REAL_context_or_function():
"""The general form of #751, across every workflow and every field.
The strict test above hardens the gate file. It cannot see the class, which is not "prose in a
run body" but "a payload that does not evaluate" — a typo in an `if:`, a renamed output, a
context that does not exist. All of them fail the same silent way, and in an `if:` the
consequence is the same shape as #751: the step does not run and nothing is red.
BE PRECISE ABOUT WHAT THIS ENFORCES, because the first version of this docstring was not and
both reviewers caught it: it checks that THE HEAD TOKEN of each dotted path is a known context or
function. Nothing more. That catches the historical defect `pr number` fails on `pr` and a
payload naming a context that does not exist. It does NOT catch:
* syntactically invalid expressions whose tokens are all known: `${{ github.ref == }}` and
`${{ github.event.pull_request.head.sha + }}` both pass, verified;
* a renamed or misspelled output or property, because every token after the first is preceded
by `.` and is deliberately skipped: `steps.metadata.outputs.shortsha` passes;
* an unclosed opener, since `_EXPR` requires the closing braces to match at all.
A real fix for those is an expression parser, which is a different and much larger change. This is
a cheap net under the specific class that has bitten us, deliberately kept permissive so it cannot
false-red the repo (a red here blocks every merge through the combined status). Verified against
all 31 distinct payloads in this repo today, which pass. Do not restate this test as "catches any
payload that cannot evaluate" — that claim was in the docs and the decision record and was false.
"""
offenders = []
for wf in _workflow_files():
for scalar in _yaml_string_scalars(wf):
for m in _EXPR.finditer(scalar):
payload = m.group(1).strip()
# Strip string literals first: a path inside `hashFiles('web/package-lock.json')` is
# data, not an identifier, and would otherwise read as an unknown context.
bare = re.sub(r"'[^']*'", "''", payload)
for ident in re.finditer(r"(?<![.\w'])([A-Za-z_][A-Za-z0-9_-]*)", bare):
name = ident.group(1)
if name in _EXPR_CONTEXTS or name in _EXPR_FUNCTIONS or name in _EXPR_LITERALS:
continue
offenders.append(f"{wf.name}: {payload!r} -> unknown '{name}'")
assert not offenders, (
"these workflow expressions name something the runner cannot resolve, so they will fail to "
f"interpolate — which DROPS THE STEP and still reports the job green (ersatztv#751): "
f"{offenders}. If this is prose describing an expression, do not write the delimiters; if "
"it is a real new context or function, add it to _EXPR_CONTEXTS / _EXPR_FUNCTIONS here."
)
def test_a_dropped_classify_step_FAILS_the_job_instead_of_going_green():
"""The silent-green half of #751 — the actual defect, and the only part that generalises.
The stray delimiter was one bug in one comment. What made it cost three days of a dead merge
gate was the REPORTING: a dropped step concludes `success`, the workflow's own status context
goes green, and the required `review-verdict/h10` is merely ABSENT which on a normal PR is
indistinguishable from the correct "not reviewed yet" state. Nothing surfaced it. So the
classifier writes a start marker and a later `if: always()` step fails the job when it is
missing.
Asserted structurally, and the marker PATH is compared across the two steps rather than
hardcoded here twice: the failure mode of a divergent path is a job that reddens on every run
(fail-closed, but it would look like this guard is broken rather than like a real drop), and a
test carrying its own third copy of the literal could not see the divergence at all.
The premise that the runner still executes a LATER step after dropping an earlier one is
MEASURED, not assumed, and could not be settled from the #751 report because the classifier was
the job's last step. Gitea 1.27.1, 2026-08-06: probe run 1863 dropped the classifier on a
reintroduced bad payload, ran the guard anyway (`always()` evaluated true), and the job concluded
`failure`; run 1866 is the positive control. So the guard is load-bearing in production and the
body does not need to move into `scripts/`. This test pins the SHAPE; the probe pinned the
BEHAVIOUR, and neither substitutes for the other which is why the behavioural test below
EXECUTES the guard body rather than only reading it.
"""
# ONE parse, and both steps located inside it. Using `_classify_step()` here instead was wrong
# in a way worth recording, because it went green-adjacent rather than loud: that helper does its
# own `yaml.safe_load`, so the dict it returns is never the same OBJECT as the equivalent step in
# this list. An `is not` filter against it therefore excluded nothing, the classify step matched
# as its own guard, and the assertions below ran against the wrong step.
steps = [s for _, s in _iter_workflow_steps(WORKFLOW)]
classifiers = [i for i, s in enumerate(steps) if "review-verdict/h10" in (s.get("run") or "")]
assert classifiers, "no step in review-verdict.yml posts review-verdict/h10"
classify = steps[classifiers[0]]
# The FULL path expansion is compared between the two steps, not a basename. The path is keyed on
# the run id, so a basename match would accept two steps that agree on the prefix and disagree on
# the key — which is precisely the divergence that would make the guard fail on every run.
marker_write = re.search(r'(\w+)="(\$\{RUNNER_TEMP[^"]*)"', classify["run"])
assert marker_write, (
"the classify step no longer assigns a start-marker path under RUNNER_TEMP, so a step the "
"runner drops goes green again (ersatztv#751)")
var, marker_name = marker_write.group(1), marker_write.group(2)
assert re.search(rf':\s*>\s*"\${var}"', classify["run"]), (
f"the classify step defines {var} but never creates the marker, so the guard below will "
"fail on every run and read as broken rather than as a real dropped step")
guard_idx = [i for i, s in enumerate(steps)
if i != classifiers[0] and marker_name in (s.get("run") or "")]
assert guard_idx, (
f"no step checks for the {marker_name!r} start marker. Without it a dropped classify step "
"concludes success and the merge gate is silently dead (ersatztv#751)")
# AFTER the classifier, not merely present. A guard placed before it would read a marker that
# has not been written yet and fail on every run — fail-closed, but it would deadlock `main` and
# read as this guard being broken, which is how a correct-looking guard gets deleted.
assert guard_idx[0] > classifiers[0], (
f"the dropped-step guard is step {guard_idx[0]} but the classifier is step "
f"{classifiers[0]} — a guard that runs first always fails")
guard = steps[guard_idx[0]]
# `always()` and `${{ always() }}` are the same condition; the runner accepts both and the second
# is the more common spelling. Pinning the bare form EXACTLY would red the repo over a
# semantically identical edit, so normalise instead. What must not change is that the guard runs
# when the classifier failed.
guard_if = re.sub(r"\s+", "", str(guard.get("if", "")))
assert guard_if in ("always()", "${{always()}}"), (
f"the dropped-step guard's `if:` is {guard.get('if')!r}; it must be `always()` (bare or "
"wrapped), or it will be skipped on exactly the runs where the classifier failed")
# INSIDE the missing-marker branch, not merely somewhere in the body. Cold review pointed out
# that a bare `exit 1` substring is satisfied by an unreachable `if false; then exit 1; fi` while
# the real branch says `exit 0` — the test passes and a dropped classifier goes green again. The
# behavioural test below is the real proof; this keeps the structural one from being satisfiable
# by dead code.
missing_branch = re.search(r'if \[ ! -f "\$marker" \]; then(.*?)\bfi\b', guard["run"], re.S)
assert missing_branch, (
"the dropped-step guard no longer tests for a MISSING marker with `if [ ! -f \"$marker\" ]`, "
"so the assertion below cannot locate the branch that must fail the job")
assert re.search(r"exit\s+1", missing_branch.group(1)), (
"the dropped-step guard detects the missing marker but does not `exit 1` inside that branch, "
"so it observes the failure and still lets the job go green — which is the whole defect")
assert not _EXPR.search(guard["run"]), (
"the dropped-step guard's own run body contains an expression delimiter, so the mechanism "
"it guards against can drop the guard too — and that absence would be silent as well")
@pytest.mark.parametrize("terminator", ["null", "[]"], ids=["null-page", "empty-array-page"])
def test_the_fence_TRUSTS_the_count_and_POSTS_when_the_timeline_terminates(tmp_path, terminator):
"""The second defect found by the ersatztv#751 probe, and the one that actually kept the gate
from posting anything.
A page past the end of `/issues/{n}/timeline` is the JSON value `null` on this instance, not `[]`.
`count_retargets` gated on `type == "array"` and so treated the real terminator as unreadable: the
walk never reached a validated empty page, `rt_ok` was never `yes` for ANY pull request, and the
fence therefore withheld every exemption `success`. Renovate and docs-only PRs got NO status
the same user-visible outcome as #751, by an unrelated route.
It hid for two reasons worth keeping written down. It shipped in the same commit (8f6d4f443) that
stopped the step from executing, so the fence had never once run in production; and the test
double printed `[]` while its comment claimed to mirror measured reality, so the type gate was
never exercised by the suite either. A green suite over an unfaithful double is what let a
fail-closed-by-accident branch look deliberate.
Both shapes are asserted because both are live on this server: comments really do return `[]`.
Asserting the POSTED STATUS, not the log, because the log said `Decision: state=success` on the
real probe run and the job still posted nothing the decision and the write are different events,
and only the write is what a merge reads.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="stable:0",
timeline_terminator=terminator)
assert r.returncode == 0, r.stderr
assert posted is not None, (
f"a docs-only PR whose timeline terminates with `{terminator}` got NO status at all. The "
"fence could not establish a trusted retarget count, so it withheld the exemption — which "
"leaves the required review-verdict/h10 absent and the PR unmergeable with no bypass "
f"(ersatztv#751).\n{r.stdout[-1200:]}")
assert posted["state"] == "success", f"expected the docs-only exemption, got {posted}"
assert "trusted=yes" in r.stdout, (
"the exemption was posted but the fence did not report a trusted count — the two must agree, "
f"or this test is passing for a different reason than it claims.\n{r.stdout[-800:]}")
def _marker_steps():
"""(classify body PREFIX through the marker write, guard body that CHECKS it) — from one parse.
THE PREFIX, NOT THE MATCHING LINES. An earlier version picked out the lines matching `RAN_MARKER=`
and `: > "$RAN_MARKER"` and ran those two alone. Cold re-review showed that passes even if the
write is moved into a function nobody calls, or under `if false`: the extractor finds the text,
runs it at top level, the marker appears, and the test reports the guard proven while production
writes no marker at all. Executing the real prefix everything from the top of the body down to
and including the write reproduces the production control flow instead of a reconstruction of
it, so a write that is defined-but-not-reached simply does not happen and case B fails.
#
It also pins the property the marker needs anyway: that the write happens EARLY, before anything
in the classifier can fail. If it drifts down past code that needs credentials or network, this
prefix stops executing cleanly and the test says so.
"""
steps = [s for _, s in _iter_workflow_steps(WORKFLOW)]
idx = [i for i, s in enumerate(steps) if "review-verdict/h10" in (s.get("run") or "")]
classify = steps[idx[0]]
lines = classify["run"].splitlines()
write_at = [i for i, ln in enumerate(lines) if re.match(r'\s*:\s*>\s*"\$RAN_MARKER"', ln)]
assert len(write_at) == 1, (
f"expected exactly one `: > \"$RAN_MARKER\"` in the classify body, found {len(write_at)}")
prefix = "\n".join(lines[:write_at[0] + 1])
guard = next(s for i, s in enumerate(steps)
if i != idx[0] and "h10-classifier-started" in (s.get("run") or ""))
return prefix, guard["run"]
def test_the_dropped_step_guard_BEHAVIOURALLY_fails_without_the_marker_and_passes_with_it(tmp_path):
"""Executes the guard, instead of reading it — the structural test above cannot prove the exit
code, and cold review was right that a bare `exit 1` substring is satisfiable by dead code.
The marker is created by running THE CLASSIFY STEP'S OWN two prologue lines under the same
environment, never by rebuilding the path in Python. That is the point: it proves the two steps
AGREE on the path by construction. A test that computed the name itself would keep passing after
the two steps drifted apart, which is the one divergence that makes the guard fail on every run
and get deleted as broken.
`GITHUB_RUN_ID`/`GITHUB_RUN_ATTEMPT` are set to fixed values, so this also covers the run-keying
added after the live probes: if either step stopped interpolating them, the paths would differ and
case B would fail.
"""
prologue, guard = _marker_steps()
assert prologue.count("RAN_MARKER=") == 1 and ': > "$RAN_MARKER"' in prologue, (
f"could not extract the classify step's marker prologue; got {prologue!r}")
# The prefix must be the REAL top of the body, so a write hidden in an uncalled function is not
# executed by this test either.
assert prologue.lstrip().startswith("set -euo pipefail"), (
"the extracted prefix does not start at the top of the classify body, so it is a "
f"reconstruction rather than the production path: {prologue[:120]!r}")
env = {"PATH": os.environ["PATH"], "RUNNER_TEMP": str(tmp_path),
"GITHUB_RUN_ID": "424242", "GITHUB_RUN_ATTEMPT": "7"}
# A — the step was DROPPED: no marker exists. The job must fail.
a = subprocess.run(["bash", "-c", guard], env=env, capture_output=True, text=True)
assert a.returncode != 0, (
"the guard exited 0 with NO start marker present — a dropped classify step would go green "
f"again, which is the whole defect (ersatztv#751).\nstdout: {a.stdout}\nstderr: {a.stderr}")
assert "did not execute" in (a.stdout + a.stderr), (
f"the guard failed but without an actionable message: {a.stdout!r} {a.stderr!r}")
assert not list(tmp_path.glob("h10-classifier-started*")), (
"the guard itself created the marker it is supposed to be checking for")
# B — the classifier RAN: its own prologue created the marker. The guard must pass.
b = subprocess.run(["bash", "-c", prologue + "\n" + guard], env=env,
capture_output=True, text=True)
assert b.returncode == 0, (
"the guard rejected a marker written by the classify step's OWN prologue — the two steps "
f"disagree on the path, so this guard would fail on every run.\nstdout: {b.stdout}\n"
f"stderr: {b.stderr}")
# C — the write must be reached by STRAIGHT-LINE code. Guarding the prefix trick itself: if the
# write were wrapped in a function or an `if`, the prefix would still contain it but production
# might not reach it. Executing the prefix with the function/conditional intact is the test; this
# assertion makes the intent explicit and fails loudly rather than subtly.
body_before = "\n".join(ln for ln in prologue.splitlines()
if not ln.lstrip().startswith("#"))
# Covers all four bash spellings: `mk() {`, `mk(){`, `function mk {`, `function mk() {`. The third
# was added when review found the first regex missed it, and the FOURTH still slipped that fix —
# the union form is the natural next spelling once `function mk {` is caught. Budget three rounds
# for any string-matching predicate.
assert not re.search(r"^\s*(function\s+)?\w+\s*(\(\s*\))?\s*\{", body_before, re.M), (
"a function is defined before the marker write, so the write may be inside it and unreached "
f"in production while this test still passes:\n{body_before}")
written = [q.name for q in tmp_path.glob("h10-classifier-started*")]
assert written == ["h10-classifier-started-424242-7"], (
f"the marker is not keyed on the run id/attempt as intended; found {written}. A fixed name in "
"a shared RUNNER_TEMP lets a stale marker satisfy this guard on a run whose step was dropped")
@pytest.mark.parametrize("shape", ["null", "array"],
ids=["statuses-null", "statuses-empty-array"])
def test_a_head_with_NO_statuses_YET_is_readable_and_still_gets_its_exemption(tmp_path, shape):
"""The twin of the timeline terminator, found by cold review of the fix for that one (#751).
`GET /commits/{sha}/status` returns `{"state":"pending","total_count":0,"statuses":null}` for a
head that has no statuses yet measured on PR #739's head 5fa672e2. `read_existing_verdict`
gated on `.statuses | type == "array"`, so it read that as unreadable and took its `exit 1`
path: the job posted NOTHING. Fail-closed, but the user-visible result is exactly the outcome
this issue is about an exempt PR with no status and, since #743, no bypass.
Both shapes are asserted because a head that HAS statuses really does return an array, so the job
must read either. Restoring the `array`-only gate turns 40+ tests red with the corrected double,
which is the measure of how thoroughly the unfaithful stub was hiding this.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_empty_shape=shape)
assert r.returncode == 0, (
f"the classifier died reading a `statuses: {shape}` body instead of treating it as "
f"'no verdict yet', so nothing was posted at all.\n{r.stdout[-1000:]}\n{r.stderr[-600:]}")
assert posted is not None, (
f"a docs-only PR whose head has no statuses yet (shape: {shape}) got NO status at "
f"all.\n{r.stdout[-1000:]}")
assert posted["state"] == "success", f"expected the docs-only exemption, got {posted}"
@pytest.mark.parametrize("path", ["CLAUDE.md", "AGENTS.md"])
def test_the_GOVERNANCE_docs_are_protected_and_get_no_docs_only_exemption(tmp_path, path):
"""`CLAUDE.md`/`AGENTS.md` are not prose about the project — they define the completion protocol,
the merge-consent convention and the H10 rule itself. `DOCS_ONLY` (`^(docs/|[^/]*\\.md$)`) matched
them, so a PR editing the document that specifies what `.claude/` enforces was auto-exemptible
while `.claude/` itself was protected: the same self-exemption the workflow header rules out, one
directory over. Found by cold review of #751 by driving this exact case through the real body.
Latent until #751, because no exemption `success` was writable at all while the classify step was
dropped restoring the exemptions is what makes it reachable, which is why it is fixed there.
Asserted through the PROTECTED branch specifically, not merely "not exempt": `pending` is reached
by several routes and a test that accepted any of them could not tell a working guard from a dead
one.
"""
posted, r = _run_classify(tmp_path, _emitting(path))
assert posted is not None, f"the job posted nothing: {r.stderr[-1500:]}"
assert posted["state"] == "pending", (
f"{path} was granted an exemption ({posted}) — the document that DEFINES the merge gate must "
f"not be able to exempt itself from it.\n{r.stdout[-800:]}")
assert "protected" in r.stdout.lower(), (
f"{path} was not exempted, but not via the protected-path branch either, so that guard may be "
f"dead for it.\n{r.stdout[-800:]}")
def test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count(tmp_path):
"""`null` means "exhausted" only after a real page has been read.
THE INVARIANT, not a figure that rots: a real PR's timeline always carries at least one event on
page 1, because the PR is created by a push and that is itself an event. Spot-checked non-empty
across #752/#753/#749/#739/#717; the counts are not recorded, because an earlier version of this
docstring cited five and three were stale within days. So a terminator on page 1 is anomalous, not
empty. Trusting a zero count from it would mean
certifying that no retarget happened on the strength of a response we cannot explain, which is the
one thing the fence exists to refuse. Withholding the exemption is the safe direction: the PR asks
for a human verdict instead.
This narrows rather than closes the concern a wrong `null` on page 3 is still read as exhaustion,
and no bounded number of round-trips can rule that out.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="empty-first-page")
assert r.returncode == 0, r.stderr
assert posted is None, (
"an exemption `success` was posted from a timeline whose FIRST page was already the "
f"terminator, so no page of events was ever actually read: {posted}\n{r.stdout[-800:]}")
assert "trusted=no" in r.stdout, (
f"the exemption was withheld, but not because the count was untrusted:\n{r.stdout[-800:]}")
def test_a_SECOND_PAGE_of_statuses_refuses_to_conclude_that_no_verdict_exists(tmp_path):
"""Completeness for the "no verdict exists" conclusion — the one that licenses posting over a
verdict this job cannot see.
THE TWO OBVIOUS GUARDS ARE BOTH NO-OPS HERE, which is why this costs a round-trip:
* `.statuses | length` vs `.total_count` `total_count` is the count for the PAGE RETURNED, not
the commit. Measured at 1.27.1 on 3aed43c6 (6 contexts): `?limit=1` -> `len=1, total_count=1`.
Equal by construction. The stub mirrors that, so this test cannot pass for that wrong reason.
* "refuse when the page came back full at the requested limit of 100" this instance caps `limit`
at `MAX_RESPONSE_ITEMS`, measured at 50 (`/issues?limit=100` returns 50), so a response can never
hold 100 rows and the comparison was DEAD CODE. The repo already documented that cap in three
places; the guard was written against 100 anyway and a cold review caught it.
So the job asks the server, and only when the row is absent from page 1. Any rows on page 2 mean
the list is longer than one page and the verdict may be beyond it.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="twopage")
assert posted is None, (
"an exemption was posted while the status list ran to a second page, so an existing verdict "
f"beyond page 1 would have been silently overwritten: {posted}\n{r.stdout[-800:]}")
assert "page 2" in (r.stdout + r.stderr).lower(), (
f"nothing was posted, but not because of the page-2 completeness probe:\n{r.stdout[-800:]}")
def test_a_SINGLE_page_of_statuses_reads_normally(tmp_path):
"""Positive control for the probe above: without it, "refuse when page 2 has rows" could be
satisfied by refusing always, which deadlocks every PR while looking safe. 40 decoy contexts on
page 1, nothing on page 2.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="rows:40")
assert r.returncode == 0, r.stderr
assert posted is not None and posted["state"] == "success", (
f"a single page of statuses should read normally and still exempt; got {posted}\n"
f"{r.stdout[-800:]}")
def test_a_STRING_total_count_is_not_accepted_as_numeric_zero(tmp_path):
"""`jq -r` renders the JSON number 0 and the JSON string "0" identically, so a text compare
accepts a schema-corrupted `"total_count": "0"` as "no statuses" (cold re-review reproduced it).
The live schema uses an integer, so this is not a live failure it is the difference between a
guard that holds because the input happens to be well-formed and one that holds because it checks.
The accept path requires the TYPE to be number.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="total-count-string")
assert posted is None, (
f"a string total_count was accepted as numeric zero, so a body that merely lost its statuses "
f"array reads as 'no verdict exists': {posted}\n{r.stdout[-800:]}")
@pytest.mark.parametrize("mode,why", [
("page2-garbage", "a non-JSON page 2"),
("page2-error", "an HTTP error on page 2"),
], ids=["garbage", "transport-error"])
def test_an_UNREADABLE_page_2_refuses_to_conclude_that_no_verdict_exists(tmp_path, mode, why):
"""The two refuse branches of the completeness probe, which cold review found untested.
Worth a test rather than trusting the shape: this file's history is two consecutive guards that
were UNREACHABLE the `count_retargets` type gate that never saw a real terminator, and a
full-page check written against a limit of 100 on a server that caps at 50. An unexercised branch
here has a track record.
Both must fail CLOSED: the point of reading page 2 is to justify "no verdict exists", so a page 2
that cannot be read justifies nothing.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode=mode)
assert posted is None, (
f"an exemption was posted despite {why}, so 'no verdict exists' was concluded without "
f"evidence: {posted}\n{r.stdout[-800:]}")
assert "page 2" in (r.stdout + r.stderr).lower(), (
f"nothing was posted, but not because of the page-2 probe:\n{r.stdout[-800:]}")
def test_a_status_history_RUNNING_PAST_PAGE_1_repairs_rather_than_leaving_green(tmp_path):
"""The post-write race check reads ONE page of `/statuses/{sha}`, and `limit=100` clamps to the
server-wide cap (measured 50). So "no raced row on page 1" does not establish "no race" a raced
human `failure` can sit on a page this job never reads.
This is the ONE path in the design whose failure direction is toward SUCCESS: missing a raced
verdict leaves a forged green over a human rejection, permanently. So unread rows are treated as a
race and the exemption is repaired to `pending`, which is the conservative direction a stall a
reviewer can clear, rather than a rejection silently turned green.
The stub's second page carries ORDINARY rows, no verdict and no sentinel: what must trigger the
repair is the mere existence of rows this job did not read, not the discovery of a verdict.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), history_mode="second-page")
assert r.returncode == 0, r.stderr
seq = _posted_sequence(tmp_path)
assert len(seq) == 2, (
"the exemption was posted and left standing even though the status history ran past page 1, so "
f"a raced verdict beyond it would be buried. Posts: {seq}\n{r.stdout[-900:]}")
assert seq[0]["state"] == "success" and seq[1]["state"] == "pending", (
f"expected an exemption then a repair to pending; got {seq}")
assert seq[1]["description"] == "Human verdict raced this exemption write — re-post the verdict", (
f"repaired, but not to the sentinel, so the fixed point is broken: {seq[1]}")
@pytest.mark.parametrize("mode,why", [
("second-page-garbage", "a non-JSON page 2"),
("second-page-error", "an HTTP error on page 2"),
], ids=["garbage", "transport-error"])
def test_an_UNREADABLE_history_page_2_also_repairs_rather_than_leaving_green(tmp_path, mode, why):
"""Found by mutating the branch and watching nothing go red — my own coverage gap, in the same
class the review had just flagged twice.
This is the fail-toward-SUCCESS path, so uncertainty must resolve to `pending`. "I could not read
the rest of the history" is not evidence that no verdict raced this write; treating it as such is
exactly how a forged green survives over a human rejection.
"""
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), history_mode=mode)
assert r.returncode == 0, r.stderr
seq = _posted_sequence(tmp_path)
assert len(seq) == 2, (
f"the exemption was left standing despite {why} — a raced verdict beyond page 1 would be "
f"buried. Posts: {seq}\n{r.stdout[-900:]}")
assert seq[0]["state"] == "success" and seq[1]["state"] == "pending", (
f"expected an exemption then a repair to pending; got {seq}")
@@ -0,0 +1,186 @@
"""Tests for the tag-only-push exemption in `.claude/hooks/prepush-rebase-check.sh` (ersatztv#719).
H11 refuses to push a branch that is behind `origin/main`, to force a rebase instead of a merge.
But the release cut tags a commit on `main` from a branch that is behind `origin/main`, so H11
blocked every release -- and its "rebase first" advice did not even apply, because no branch was
being pushed. (Observed while cutting v26.13.0; see #719. `docs/ci-cd.md` -> "Cutting a release"
documents the tag step itself, not the release-notes-PR flow that puts the branch behind.) A tag
push cannot revert anyone's merged work (the failure mode H11 exists to prevent), so the fix skips
the freshness check when EVERY ref being pushed is under `refs/tags/`.
These tests use real local git repositories (a bare "origin" plus a work tree pushed one commit
behind it) rather than stubbing `git`, because the hook's decision hinges on genuine
`git fetch` / `merge-base` / `rev-list` behavior against an origin that has moved.
`test_zero_ref_lines_does_not_exempt` is the load-bearing negative case from the issue: "all pushed
refs are tags" is vacuously true over zero ref lines, so a naive implementation would disable H11
entirely whenever stdin is empty (hook run manually, or a caller that forgot to forward it). The fix
must require at least one parsed ref line before granting the exemption.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
HOOK = REPO_ROOT / ".claude" / "hooks" / "prepush-rebase-check.sh"
DUMMY_SHA_A = "a" * 40
DUMMY_SHA_B = "b" * 40
def _git(args, cwd):
r = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True)
assert r.returncode == 0, f"git {' '.join(args)} failed: {r.stderr}"
return r.stdout
@pytest.fixture
def behind_repo(tmp_path):
"""A work tree whose local `main` is exactly one commit behind `origin/main`."""
origin = tmp_path / "origin.git"
_git(["init", "--bare", "-q", str(origin)], cwd=tmp_path)
work = tmp_path / "work"
_git(["init", "-q", "-b", "main", str(work)], cwd=tmp_path)
_git(["config", "user.email", "test@example.com"], cwd=work)
_git(["config", "user.name", "Test"], cwd=work)
(work / "f.txt").write_text("one\n")
_git(["add", "f.txt"], cwd=work)
_git(["commit", "-q", "-m", "initial"], cwd=work)
_git(["remote", "add", "origin", str(origin)], cwd=work)
_git(["push", "-q", "-u", "origin", "main"], cwd=work)
# The bare repo's HEAD symref still points at the (nonexistent) default branch until something
# sets it explicitly; without this, `git clone` below checks out an unborn HEAD and "main" never
# exists as a local branch in `advancer`.
_git(["symbolic-ref", "HEAD", "refs/heads/main"], cwd=origin)
# Advance origin/main independently, via a second clone, so `work`'s local `main` falls behind.
advancer = tmp_path / "advancer"
_git(["clone", "-q", str(origin), str(advancer)], cwd=tmp_path)
_git(["config", "user.email", "test@example.com"], cwd=advancer)
_git(["config", "user.name", "Test"], cwd=advancer)
(advancer / "f.txt").write_text("two\n")
_git(["add", "f.txt"], cwd=advancer)
_git(["commit", "-q", "-m", "advance"], cwd=advancer)
_git(["push", "-q", "origin", "main"], cwd=advancer)
return work
def _run_hook(cwd, stdin_text):
env = dict(os.environ)
for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
env.pop(k, None)
return subprocess.run(
["bash", str(HOOK)],
cwd=str(cwd),
input=stdin_text,
env=env,
capture_output=True,
text=True,
)
def test_tag_only_push_from_a_behind_branch_is_allowed(behind_repo):
"""The fix: a tag-only push must not be blocked by H11 even though the branch is behind."""
stdin = f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
r = _run_hook(behind_repo, stdin)
assert r.returncode == 0, f"tag-only push was blocked: {r.stdout}{r.stderr}"
def test_tag_only_push_ignores_blank_lines(behind_repo):
stdin = f"\nrefs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n\n"
r = _run_hook(behind_repo, stdin)
assert r.returncode == 0, f"tag-only push (with blank lines) was blocked: {r.stdout}{r.stderr}"
def test_negative_control_branch_push_from_behind_is_still_blocked(behind_repo):
"""Required by #719: the fix must not weaken H11 for ordinary branch pushes."""
stdin = f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
r = _run_hook(behind_repo, stdin)
assert r.returncode == 1, "a branch push from a behind branch was allowed"
assert "H11" in r.stdout
def test_mixed_branch_and_tag_push_is_still_blocked(behind_repo):
stdin = (
f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
)
r = _run_hook(behind_repo, stdin)
assert r.returncode == 1, "a mixed branch+tag push was allowed through the tag exemption"
assert "H11" in r.stdout
def test_zero_ref_lines_does_not_exempt(behind_repo):
"""Vacuous-truth guard: 'all refs are tags' is trivially true over zero lines. Empty stdin
(hook run manually, or a caller that forgot to forward the ref lines) must fall through to the
existing behind-origin/main check, not silently disable H11."""
r = _run_hook(behind_repo, "")
assert r.returncode == 1, "empty stdin vacuously granted the tag exemption"
assert "H11" in r.stdout
def test_zero_ref_lines_of_only_blank_lines_does_not_exempt(behind_repo):
r = _run_hook(behind_repo, "\n\n\n")
assert r.returncode == 1, "stdin of only blank lines vacuously granted the tag exemption"
assert "H11" in r.stdout
# --- final line with NO trailing newline -------------------------------------------------------
# `read` returns non-zero on an unterminated final line, so a bare `while read` silently DROPS it.
# Both directions matter and they fail differently, which is why each is pinned:
# - tag-only, unterminated -> the line is dropped, no refs are seen, and H11 blocks the release
# tag push again, i.e. #719 quietly returns.
# - mixed, unterminated -> the BRANCH line is dropped, leaving only tag refs, and the
# exemption is granted for a push that includes a branch. That is the dangerous direction.
# Git always newline-terminates its ref lines and `.husky/pre-push` re-adds one via `printf '%s\n'`,
# so this is reachable only on a hand-piped run — but the guard is cheap and the failure is silent.
def test_unterminated_final_line_tag_only_is_still_exempt(behind_repo):
stdin = f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}" # no trailing \n
r = _run_hook(behind_repo, stdin)
assert r.returncode == 0, f"unterminated tag-only line was dropped, reinstating #719: {r.stdout}"
def test_tty_stdin_does_not_hang_and_does_not_exempt(behind_repo):
"""The hook gained a stdin reader in #719; before that it read nothing, and its own docs call
'run by hand' a supported case. Without the `[ -t 0 ] ||` guard an interactive run blocks
forever waiting on the terminal. A pty gives it a real TTY on fd 0; the `timeout` turns a
regression into a clean failure instead of a hung CI job."""
primary, secondary = os.openpty()
try:
r = subprocess.run(
["bash", str(HOOK)],
cwd=str(behind_repo),
stdin=secondary,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
pytest.fail("hook hung on TTY stdin — the `[ -t 0 ] ||` guard is missing or ineffective")
finally:
os.close(primary)
os.close(secondary)
# A TTY yields no ref lines, so this is the zero-line fall-through: H11 still applies.
assert r.returncode == 1, "TTY stdin vacuously granted the tag exemption"
assert "H11" in r.stdout
def test_unterminated_final_branch_line_is_not_swallowed_into_the_exemption(behind_repo):
"""The dangerous direction: if the unterminated BRANCH line is dropped, only tag refs remain
and a branch push wins the tag exemption."""
stdin = (
f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}" # no trailing \n
)
r = _run_hook(behind_repo, stdin)
assert r.returncode == 1, "an unterminated branch ref was swallowed into the tag exemption"
assert "H11" in r.stdout
+51
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getLibraryBrowseItems,
searchLibraryBrowseItems,
searchLibraryPickerOptions,
titleContainsQuery,
LIBRARY_PICKER_LUCENE_SPECIALS,
@@ -150,3 +151,53 @@ describe('searchLibraryPickerOptions (#651)', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('searchLibraryBrowseItems (#685 — AddItemsDialog sibling of searchLibraryPickerOptions)', () => {
afterEach(() => {
vi.restoreAllMocks();
});
// The reviewer proved this helper was dead code to the suite: deleting its clamp, or deleting
// its gate, both left the whole suite green. These three tests mirror the ones above for
// searchLibraryPickerOptions so the same bound is pinned for the sibling helper.
it('#651 F4: CLAMPS an oversized pageSize rather than forwarding it', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 20000 }));
await searchLibraryBrowseItems('Episode', 'Alpha', 5000);
expect(browseUrl(fetchMock).searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
});
it('issues NO request for a query below the minimum length, and resolves an empty result', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
expect(await searchLibraryBrowseItems('Episode', 'a')).toEqual({ items: [], totalCount: 0 });
expect(await searchLibraryBrowseItems('Episode', ' ')).toEqual({ items: [], totalCount: 0 });
expect(fetchMock).not.toHaveBeenCalled();
});
it('compiles/escapes the trimmed query, and returns the FULL row (mediaType present) plus totalCount', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({
page: [{ id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' }],
totalCount: 42
})
);
const result = await searchLibraryBrowseItems('Movie', ' Show Alpha ');
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = browseUrl(fetchMock);
expect(url.searchParams.get('query')).toBe(titleContainsQuery('Show Alpha'));
expect(url.searchParams.get('mediaType')).toBe('Movie');
expect(url.searchParams.get('pageNum')).toBe('0');
expect(url.searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
// The reason this helper exists rather than reusing searchLibraryPickerOptions: the full row
// (mediaType included), not the {id, name} shape.
expect(result).toEqual({
items: [{ id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' }],
totalCount: 42
});
});
});
+35
View File
@@ -111,6 +111,41 @@ export function searchLibraryPickerOptions(
);
}
export interface LibraryBrowseSearchResult {
items: LibraryBrowseItem[];
totalCount: number;
}
// Like `searchLibraryPickerOptions` above — same min-query gate, same clamp, same compiled query —
// but for a MULTI-select caller (`CollectionsScreen`'s `AddItemsDialog`) that needs the full
// `LibraryBrowseItem` row (mediaType + id, for `toAddItemsRequest`) rather than the `{id, name}`
// shape a single-select `SearchPicker` renders, plus the response's `totalCount` so the caller can
// surface how much of a match was actually returned. The gate/clamp/compile live HERE, not at the
// call site, so no caller can accidentally skip them (§3b — "the bound belongs to the helper, not
// the caller").
export function searchLibraryBrowseItems(
mediaType: LibraryBrowseMediaType,
text: string,
pageSize: number = LIBRARY_PICKER_RESULTS
): Promise<LibraryBrowseSearchResult> {
const trimmed = text.trim();
if (trimmed.length < LIBRARY_PICKER_MIN_QUERY) {
return Promise.resolve({ items: [], totalCount: 0 });
}
const boundedPageSize = Math.max(1, Math.min(Math.floor(pageSize), LIBRARY_PICKER_RESULTS));
return getLibraryBrowseItems({
mediaType,
pageNum: 0,
pageSize: boundedPageSize,
query: titleContainsQuery(trimmed)
}).then((result) => ({
items: result.page ?? [],
totalCount: result.totalCount ?? 0
}));
}
export function messageFromLibraryBrowseError(error: unknown, fallback = 'Unable to load library items'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+49 -29
View File
@@ -83,12 +83,15 @@ import { scanPageSizeSites } from './pageSizeScan';
* mechanism, and the row bound is a property of the CODE rather than of a
* caller's discipline (a clamp inside the shared helper for #651's
* library picker; a fixed small constant at the inline preview sites).
* Nothing is list-loaded, so there is no truncation to surface and the
* absence of a truncation hint is correct which is why such a site
* cannot be filed under 'class-b', whose defining evidence IS a surfaced
* `totalCount`. Applies only where the query is genuinely required: a
* site that degrades to an unfiltered browse when the query is empty is
* NOT search-bounded (see 'deviation').
* Nothing is list-loaded, so a truncation hint is not REQUIRED here but,
* unlike 'class-b', it is not the defining evidence either: a search-bounded
* site MAY surface its own per-kind cap (e.g. summed across kinds) once that
* count is actionable, without that hint reclassifying it as 'class-b'. What
* distinguishes the two classes is the SHAPE of the bound (one clamped
* search request per settled query vs. one bounded page of a list), not
* whether a hint is rendered. Applies only where the query is genuinely
* required: a site that degrades to an unfiltered browse when the query is
* empty is NOT search-bounded (see 'deviation').
* - 'class-b' one bounded page at (or under) the cap, with the real truncation
* (`totalCount` vs items shown) surfaced to the user. Post-#651 this no
* longer covers media-library pickers (those are 'search-bounded').
@@ -163,6 +166,35 @@ const REGISTRY: RegistryEntry[] = [
'correctly absent). The bound is a clamp, not a default: Math.min(pageSize, ' +
'LIBRARY_PICKER_RESULTS) inside the helper, so a caller cannot widen it.'
},
{
file: 'api/libraryBrowse.ts',
kind: 'literal',
value: 'boundedPageSize',
classification: 'search-bounded',
note:
"searchLibraryBrowseItems — the #685 review fix that moved AddItemsDialog.runSearch's " +
'pageSize call site out of screens/CollectionsScreen.tsx and into this shared helper (same ' +
'gate/clamp/compile as searchLibraryPickerOptions above), so a multi-select caller needing ' +
'full LibraryBrowseItem rows plus totalCount cannot skip the bound either. A deliberate ' +
'second occurrence of the same (file, kind, value) identity — the multiset comparison ' +
'requires it be discovered twice. No request is issued below LIBRARY_PICKER_MIN_QUERY (a ' +
'blank form submit and a kind-chip click below the gate both resolve every kind to ' +
'{items: [], totalCount: 0} via the HELPER\'s own gate — CollectionsScreen no longer keeps a ' +
'second copy of this check; the #685 second review proved the two masked each other), the ' +
'typed text is compiled via titleContainsQuery rather than forwarded raw, and each kind is ' +
'bounded to one request per settled query at LIBRARY_PICKER_RESULTS rows. Unlike the #685 ' +
'first fix, this is NOT "nothing left to hint at": the per-kind cap can still truncate the ' +
"real match count below what totalCount reports, and AddItemsDialog now sums each kind's " +
"totalCount and renders a 'Showing N of M' hint when it exceeds the rendered rows. The only " +
'remaining client-side filter in AddItemsDialog (ADDABLE_TYPES.has(item.mediaType)) is inert, ' +
'not a silent drop, and this is now enforced by the type system rather than by convention on ' +
'BOTH ingress paths into the searched kinds — MediaKindFilter (the explicit-chip path) and ' +
'DEFAULT_SEARCH_KINDS (the `all` fan-out) are each derived from ADDABLE_TYPE_LIST via ' +
'`(typeof ADDABLE_TYPE_LIST)[number]`, so adding a non-addable kind to either is a compile ' +
'error. Enforcing only the first was the #685 round-3 review finding: the hint sums ' +
'PRE-filter totalCounts against POST-filter rows, so one unenforced ingress is enough to ' +
'overstate it with every row of that kind dropped.'
},
{
file: 'api/paging.ts',
kind: 'shorthand',
@@ -246,22 +278,6 @@ const REGISTRY: RegistryEntry[] = [
'Playout block history: forwards a user-adjustable `pageSize` state (persisted, backed by a ' +
'page-size <Select>) to a real pager keyed off the response totalCount.'
},
{
file: 'screens/CollectionsScreen.tsx',
kind: 'literal',
value: '50',
classification: 'deviation',
issue: 685,
note:
'TRACKED §3b VIOLATION — #685. AddItemsDialog.runSearch is reachable with an EMPTY query ' +
'(blank form submit, and a kind-chip click, which calls it immediately), and ' +
'getLibraryBrowseItems omits a falsy query — so it degrades to an unfiltered 50-row window ' +
'over the whole media-library type, per kind. Nothing surfaces it: totalCount is never read ' +
'here and no hint renders, and merged.slice(0, 50) drops up to 100 of 150 fetched rows even ' +
'for a real query. Under the cap, so #644 and #650 both missed it. NOT search-bounded (the ' +
'query is not required) and NOT class-b (no hint) — labelling it either would make this ' +
'registry vouch for behaviour that does not exist.'
},
{
file: 'screens/FillerPresetsScreen.tsx',
kind: 'literal',
@@ -535,9 +551,11 @@ describe('pageSize call-site guard (#650)', () => {
it("every 'deviation' entry names the issue tracking it", () => {
const deviations = REGISTRY.filter((entry) => entry.classification === 'deviation');
// Anti-vacuity: if the deviations are ever all fixed, this must be deleted deliberately, not
// silently pass over an empty list while claiming to enforce something.
expect(deviations.length).toBeGreaterThan(0);
// No live 'deviation' entries as of #685 (the last one — CollectionsScreen.tsx's raw-50 window
// — was fixed and reclassified 'search-bounded' above). The anti-vacuity
// `expect(deviations.length).toBeGreaterThan(0)` this comment used to enforce is deleted
// DELIBERATELY here, per its own instruction, rather than left to silently pass over an empty
// list — re-add it the day a new 'deviation' entry is registered.
for (const entry of deviations) {
expect(entry.issue, `${entry.file}:${entry.value} is a deviation but names no tracking issue`).toEqual(
@@ -546,10 +564,12 @@ describe('pageSize call-site guard (#650)', () => {
expect(entry.issue!).toBeGreaterThan(0);
}
// The converse, so the field cannot drift into decoration: only a deviation carries one.
for (const entry of REGISTRY.filter((e) => e.classification !== 'deviation')) {
expect(entry.issue, `${entry.file}:${entry.value} is not a deviation but carries an issue`).toBeUndefined();
}
// The converse, so the field cannot drift into decoration: only a deviation carries one. With
// `deviations` currently empty, the loop above evaluates nothing — so this single bidirectional
// assertion is what actually gives the test teeth today: it fails the moment any non-deviation
// entry picks up an `issue` field, or a 'deviation' entry is added without one (#685 review
// finding 7).
expect(REGISTRY.filter((entry) => entry.issue !== undefined)).toEqual(deviations);
});
// Pins the report format, not the comparison key (#684 review M3): dropping the position from
+365 -3
View File
@@ -1,5 +1,11 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
LIBRARY_PICKER_LUCENE_SPECIALS,
LIBRARY_PICKER_MIN_QUERY,
LIBRARY_PICKER_RESULTS,
titleContainsQuery
} from '../api';
import { CollectionsScreen } from './CollectionsScreen';
// The rule builder's field catalog normally comes from a live /api/v1/search/fields fetch; mock it
@@ -401,7 +407,7 @@ describe('CollectionsScreen', () => {
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
@@ -419,7 +425,7 @@ describe('CollectionsScreen', () => {
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Seasons' }));
@@ -451,7 +457,7 @@ describe('CollectionsScreen', () => {
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Song' }));
@@ -475,6 +481,362 @@ describe('CollectionsScreen', () => {
});
});
it(
'backspacing the query below the min-query length after a search keeps the rows, checkmarks, and Add count (#685 review finding 1)',
async () => {
mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
fireEvent.click(within(dialog).getByText('Zathura'));
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
// Backspace the LIVE input down to one character — below LIBRARY_PICKER_MIN_QUERY — WITHOUT
// re-submitting. Before the fix, the render condition keyed the min-query guidance to the
// live input alone, so this wiped the visible row list and its checkmark even though
// `results` and `selected` were untouched in state.
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'z' }
});
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
expect(within(dialog).getByText('Adventure Time')).toBeInTheDocument();
expect(
within(dialog).queryByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
).not.toBeInTheDocument();
const zathuraRow = within(dialog).getByText('Zathura').closest('button');
expect(zathuraRow).toHaveAttribute('aria-pressed', 'true');
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
}
);
it(
'sums totalCount across kinds and hints at truncation once it exceeds the rendered rows (#685 review finding 4)',
{ timeout: 15000 },
async () => {
mockLargeLibraryApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
await within(dialog).findByText('Movie Item 1');
// 3 kinds (Movie, TelevisionShow, Artist) each report a 20,000-row totalCount but only
// LIBRARY_PICKER_RESULTS (25) rows land per kind, so the sum (60,000) is far above the 75
// rows actually rendered.
expect(within(dialog).getByText(/Showing 75 of 60000 matches/)).toBeInTheDocument();
}
);
it('does not render a truncation hint when totalCount does not exceed the rendered rows', async () => {
mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
await within(dialog).findByText('Zathura');
expect(within(dialog).queryByText(/Showing .* of .* matches/)).not.toBeInTheDocument();
});
it(
'labels the results with the query that produced them, and clearing without re-submitting keeps rows + label (#685 review)',
async () => {
mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
expect(
within(dialog).getByText(
(_content, element) => element?.tagName === 'P' && element.textContent === 'Results for “za”'
)
).toBeInTheDocument();
// Clear the input WITHOUT re-submitting: the rows and their label must still describe the
// original ("za") search — nothing says which query produced an on-screen row otherwise.
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: '' }
});
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
expect(
within(dialog).getByText(
(_content, element) => element?.tagName === 'P' && element.textContent === 'Results for “za”'
)
).toBeInTheDocument();
}
);
it(
'clearing the input and re-submitting shows the min-query guidance while the selection stays discoverable (#685 review)',
async () => {
mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
fireEvent.click(within(dialog).getByText('Zathura'));
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
// Clear the input and re-submit: the helper's gate resolves every kind to
// { items: [], totalCount: 0 }, so the (now query-less) results are empty.
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: '' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
await waitFor(() => {
expect(
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
).toBeInTheDocument();
});
// `selected` persists across queries (correct for a multi-select picker) — it must never be
// invisible-but-counted, and the Add button's count is where that stays discoverable even
// though the previously-ticked row isn't in the (now empty) result set.
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
// And the "Results for" heading is gone, not showing a stale/misleading query.
expect(within(dialog).queryByText(/^Results for /)).not.toBeInTheDocument();
}
);
it('clears the truncation hint on a failed search (#685 review)', async () => {
const fetchMock = mockApi({
onRequest: (url) => {
if (url.startsWith('/api/v1/library/browse')) {
return jsonResponse({ page: [], totalCount: 0 }, 500);
}
return null;
}
});
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
await waitFor(() => {
expect(within(dialog).getByRole('alert')).toBeInTheDocument();
});
expect(within(dialog).queryByText(/Showing .* of .* matches/)).not.toBeInTheDocument();
expect(fetchMock).toHaveBeenCalled();
// A failed search must NOT fall into the min-query guidance branch: the user typed a valid
// 2-character query and got a 500, so telling them to "type at least 2 characters" states
// something false about what they just did. The error banner is the whole message (#685
// review round 3 — that branch conflated "nothing searched yet" with "the last search failed").
expect(
within(dialog).queryByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
).not.toBeInTheDocument();
// Nor "No results", which asserts a search that COMPLETED and found nothing. Both empty-state
// messages are suppressed on error, leaving the alert banner as the whole message. Pinned
// positively as well as negatively so a refactor cannot satisfy this by rendering nothing at
// all (#685 review round 4).
expect(within(dialog).queryByText('No results — try a search above.')).not.toBeInTheDocument();
expect(within(dialog).getByRole('alert')).toHaveTextContent(/500/);
});
/* ---------- add-items search bounds (#685) ---------- */
function buildLibraryFixture(mediaType: string, count: number): { id: number; mediaItemId: number; mediaType: string; title: string }[] {
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
mediaItemId: index + 1,
mediaType,
title: `${mediaType} Item ${index + 1}`
}));
}
// Simulates a library with `rowsPerKind` rows of EACH media type (20,000 by default — large
// enough that "the client bounds itself" is the only thing keeping this fast), honoring the
// requested pageSize the way the real server-side clamp does (spa-conventions §3b).
function mockLargeLibraryApi(rowsPerKind = 20000) {
const fixtures = new Map<string, ReturnType<typeof buildLibraryFixture>>();
return mockApi({
onRequest: (url) => {
if (!url.startsWith('/api/v1/library/browse')) {
return null;
}
const params = new URL(url, 'http://localhost').searchParams;
const mediaType = params.get('mediaType') ?? 'Movie';
const pageSize = Number(params.get('pageSize') ?? '0');
if (!fixtures.has(mediaType)) {
fixtures.set(mediaType, buildLibraryFixture(mediaType, rowsPerKind));
}
const fixture = fixtures.get(mediaType) ?? [];
return jsonResponse({ page: fixture.slice(0, pageSize), totalCount: fixture.length });
}
});
}
function browseCallsOf(fetchMock: ReturnType<typeof mockApi>) {
return fetchMock.mock.calls.filter(([u]) => u.toString().startsWith('/api/v1/library/browse'));
}
it('issues zero requests for a blank submit, and renders the min-query guidance', async () => {
const fetchMock = mockLargeLibraryApi();
const dialog = await openAddItemsDialog();
expect(
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
// No await-for-settle race: a blank submit never issues a fetch, so there is nothing async
// to wait on — assert immediately that the guidance is still the only thing shown.
expect(
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
).toBeInTheDocument();
expect(browseCallsOf(fetchMock).length).toBe(0);
});
it(
`a ${LIBRARY_PICKER_MIN_QUERY - 1}-character submit (one below the minimum) issues zero requests`,
async () => {
// §3b: unit-test the INCLUSIVE endpoint directly — existing coverage only exercised 0 chars
// (blank submit) and a valid 2-char query, so a `>` for `>=` slip in the gate would have
// passed the whole suite (#685 review finding 3).
const fetchMock = mockLargeLibraryApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'z' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
).toBeInTheDocument();
expect(browseCallsOf(fetchMock).length).toBe(0);
}
);
it('a kind-chip click below the min-query gate issues zero requests', async () => {
const fetchMock = mockLargeLibraryApi();
const dialog = await openAddItemsDialog();
fireEvent.click(within(dialog).getByRole('button', { name: 'Song' }));
expect(
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
).toBeInTheDocument();
expect(browseCallsOf(fetchMock).length).toBe(0);
});
it(
'a valid query issues exactly one request per kind, bounded to LIBRARY_PICKER_RESULTS, with the compiled query',
{ timeout: 15000 },
async () => {
const fetchMock = mockLargeLibraryApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
await within(dialog).findByText('Movie Item 1');
const browseCalls = browseCallsOf(fetchMock);
// DEFAULT_SEARCH_KINDS fan-out: Movie, TelevisionShow, Artist — exactly one request each,
// never more.
expect(browseCalls.length).toBe(3);
const kindsRequested = browseCalls.map(
([u]) => new URL(u.toString(), 'http://localhost').searchParams.get('mediaType')
);
expect(new Set(kindsRequested)).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
for (const [u] of browseCalls) {
const params = new URL(u.toString(), 'http://localhost').searchParams;
expect(params.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
expect(params.get('query')).toBe(titleContainsQuery('za'));
}
}
);
it(
'compiles typed text and escapes a Lucene special character rather than forwarding it raw',
{ timeout: 15000 },
async () => {
const fetchMock = mockLargeLibraryApi();
// This is a smoke check that the compiled query reaches the wire intact for ONE special
// character — it does not need to be exhaustive, because the exhaustive per-character case
// (every character in LIBRARY_PICKER_LUCENE_SPECIALS) already lives in
// web/src/api/libraryBrowse.test.ts, against titleContainsQuery directly. Picking element 0
// here is an arbitrary representative, not a hand-copied "every special" sample (#685 review
// finding 6).
const special = LIBRARY_PICKER_LUCENE_SPECIALS[0];
const typed = `ro${special}ck`;
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: typed }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
await within(dialog).findByText('Movie Item 1');
const browseCall = browseCallsOf(fetchMock)[0];
const params = new URL(String(browseCall?.[0]), 'http://localhost').searchParams;
const sentQuery = params.get('query');
expect(sentQuery).toBe(titleContainsQuery(typed));
// A raw, unescaped forward would still contain the bare special character; the compiled
// form never does (it is backslash-escaped instead).
expect(sentQuery).not.toBe(`title:*${typed}*`);
expect(sentQuery).toContain(`\\${special}`);
}
);
it(
'renders every merged row across all kinds — no post-fetch drop',
{ timeout: 15000 },
async () => {
mockLargeLibraryApi(LIBRARY_PICKER_RESULTS);
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
await within(dialog).findByText('Movie Item 1');
// Each of the 3 default-fan-out kinds returns a full LIBRARY_PICKER_RESULTS-row page;
// nothing fetched should be discarded, so all of them must render.
const titleEls = dialog.querySelectorAll('.ctv-collections-picker-row-title');
expect(titleEls.length).toBe(3 * LIBRARY_PICKER_RESULTS);
}
);
/* ---------- reorder mode (#211) ---------- */
function reorderPages(id: number) {
+91 -13
View File
@@ -35,10 +35,11 @@ import {
getCollectionItems,
getCollectionItemsWithMeta,
getCollections,
getLibraryBrowseItems,
getSmartCollections,
LIBRARY_PICKER_MIN_QUERY,
messageFromCollectionError,
removeItemFromCollection,
searchLibraryBrowseItems,
toAddItemsRequest,
updateCollection,
updateCollectionCustomOrder,
@@ -52,8 +53,13 @@ import { SmartCollectionDialog } from '../builder/SmartCollectionDialog';
type Tab = 'manual' | 'smart';
// Every kind that can be added to a manual collection from the picker.
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = [
// Every kind that can be added to a manual collection from the picker. `as const satisfies`
// keeps this a literal-string tuple (rather than widening to the whole `mediaType` union) while
// still checking each entry against it, so `MediaKindFilter` below can be DERIVED from this list:
// adding a non-addable kind to `MEDIA_KIND_FILTERS` becomes a compile error instead of a
// convention nothing enforces (#685 review — the sum-of-totalCount hint is only correct because
// every filterable kind is addable, and this makes that structural rather than a comment).
const ADDABLE_TYPE_LIST = [
'Movie',
'TelevisionShow',
'TelevisionSeason',
@@ -64,15 +70,26 @@ const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = [
'OtherVideo',
'Image',
'RemoteStream'
];
] as const satisfies readonly LibraryBrowseItem['mediaType'][];
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
// The default fan-out excludes seasons so a multi-season show doesn't flood the
// results with per-season rows (issue #180); seasons (and the other narrower kinds
// added for #211) stay reachable via the explicit media-kind filter below.
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
// Derived from ADDABLE_TYPE_LIST for the same reason MediaKindFilter is: this is the SECOND
// ingress into `kinds` (the `all` branch of runSearch), and the truncation hint sums pre-filter
// totalCounts against post-ADDABLE_TYPES rows. A non-addable kind here would overstate the hint
// with every one of its rows dropped, and typing it as the whole mediaType union let that through
// (#685 review round 3 — one of two ingress paths was enforced, which is not an invariant).
const DEFAULT_SEARCH_KINDS: AddableKind[] = ['Movie', 'TelevisionShow', 'Artist'];
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
// The single derivation every ingress into the searched kinds goes through. Named rather than
// spelled inline at each use so the "one list, all ingresses" property is visible at a glance —
// a fourth ingress is most likely to be written by copying one of the existing three (#685
// review round 4).
type AddableKind = (typeof ADDABLE_TYPE_LIST)[number];
type MediaKindFilter = 'all' | AddableKind;
const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
{ label: 'All', value: 'all' },
@@ -217,34 +234,64 @@ function AddItemsDialog({
}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<LibraryBrowseItem[]>([]);
const [totalMatches, setTotalMatches] = useState(0);
// The query a *settled* search actually ran for — never the live input. Rendered as a heading
// over the results so a submitted-then-cleared input, or a selection made under an earlier
// query, is never shown without saying which query produced it (#685 review). Reset to '' when
// a search settles below LIBRARY_PICKER_MIN_QUERY (nothing was actually searched) or errors.
const [submittedQuery, setSubmittedQuery] = useState('');
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
// `searchLibraryBrowseItems` owns the min-query gate, the pageSize clamp, and the
// raw-Lucene-vs-compiled-query choice (§3b: "the bound belongs to the helper, not the caller").
// This screen used to keep a SECOND copy of the min-query check here — the #685 review proved
// the two masked each other (deleting either one left the whole suite green, so the boundary
// test pinned nothing) — so there is now exactly one gate, in the helper, and every caller
// (blank form submit, kind-chip click) routes through it unconditionally.
const runSearch = async (filter: MediaKindFilter = kindFilter) => {
const trimmed = query.trim();
setSearching(true);
setError(null);
try {
const trimmed = query.trim();
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
const perKind = await Promise.all(
kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
);
const perKind = await Promise.all(kinds.map((mediaType) => searchLibraryBrowseItems(mediaType, trimmed)));
const merged = perKind
.flatMap((result) => result.page ?? [])
.flatMap((result) => result.items)
.filter((item) => ADDABLE_TYPES.has(item.mediaType))
.sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' }));
setResults(merged.slice(0, 50));
// No slice: each kind is already bounded to LIBRARY_PICKER_RESULTS server-side by the helper,
// so nothing fetched is discarded here (#685 — merged.slice(0, 50) used to drop up to 100 of
// 150 rows). The per-kind cap can still truncate the real match count, though — surfaced
// below via totalMatches (#685 review finding 4).
setResults(merged);
setTotalMatches(perKind.reduce((sum, result) => sum + result.totalCount, 0));
// Below LIBRARY_PICKER_MIN_QUERY, the helper's own gate resolves every kind to
// { items: [], totalCount: 0 } without issuing a request — so `merged`/`totalMatches` above
// are already empty in that case, but `submittedQuery` must say "nothing was actually
// searched" rather than name the sub-gate text.
setSubmittedQuery(trimmed.length < LIBRARY_PICKER_MIN_QUERY ? '' : trimmed);
} catch (searchError) {
setError(messageFromCollectionError(searchError, 'Unable to search library'));
// A failed search must not leave a stale results/totalMatches pair rendering a confident
// "Showing N of M matches" hint beside the error banner (#685 review).
setResults([]);
setTotalMatches(0);
setSubmittedQuery('');
} finally {
setSearching(false);
}
};
// A kind-chip click is the second path into runSearch (a blank form submit is the other), and it
// is deliberately NOT gated here: runSearch is the single sink and owns the min-query gate for
// every caller via searchLibraryBrowseItems. §3b is explicit that an unreachable guard is an
// untested one — the next path added would be the one nobody remembers to gate.
const selectKindFilter = (filter: MediaKindFilter) => {
setKindFilter(filter);
void runSearch(filter);
@@ -341,13 +388,44 @@ function AddItemsDialog({
&ldquo;All&rdquo; searches movies, shows and artists; pick a specific kind to add seasons, episodes,
music videos, songs, other videos, images or remote streams.
</p>
{/* Conditionally mounted, unlike the aria-live hint below deliberately, not by oversight:
`role="alert"` is the one live-region role screen readers reliably announce on INSERTION,
so mounting it together with its content is right here and would be wrong there (#685
review round 4 flagged the divergence as unexplained). */}
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
{submittedQuery !== '' && (
// Names the query the CURRENT results (or the empty-results/"no matches" state) belong
// to — never the live input, so a submitted-then-cleared query, or a selection made under
// an earlier query, is never shown without saying which search produced it (#685 review).
<p className="ctv-collections-picker-note">
Results for &ldquo;{submittedQuery}&rdquo;
</p>
)}
{/* The live region is mounted UNCONDITIONALLY with the condition inside it: most screen
readers only announce mutations to a region that already existed, so creating the region
and its text in the same commit announces nothing (#685 review round 3). */}
<p aria-live="polite" className="ctv-collections-picker-note">
{totalMatches > results.length ? `Showing ${results.length} of ${totalMatches} matches — narrow your search.` : ''}
</p>
<div className="ctv-collections-picker-results">
{results.length === 0 && !searching ? (
{results.length === 0 && submittedQuery === '' && !error ? (
// Keyed to submittedQuery, not the live input: without it, backspacing the query back
// below the min-query length after a successful search wiped the rendered rows AND their
// checkmarks while `selected` (and the Add button's count) still held them (#685 review
// finding 1) — and, separately, submitting a blank/cleared query after a real selection
// must still say the selection persists rather than rendering as if nothing was ever
// searched (#685 second review).
<div className="ctv-collections-picker-empty">
Type at least {LIBRARY_PICKER_MIN_QUERY} characters to search.
</div>
) : results.length === 0 && !searching && !error ? (
// Also suppressed on `error`: "No results" asserts a search that COMPLETED and found
// nothing, which is false when the request failed. The role="alert" banner above is the
// whole message in that state (#685 review round 4).
<div className="ctv-collections-picker-empty">No results try a search above.</div>
) : (
results.map((item) => {