007d2fd3df9b62dc4d02ded155f25ee750ecdf9d
130
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
007d2fd3df |
fix(763): round 2+3 — close the fail-opens the paging change introduced
Two independent cold reviews (Codex GPT-5.6 cross-family, and an isolated Opus agent) converged on the same blocker, which is fixed here along with everything else they found. BLOCKER — the mark walk turned a fail-closed case into a fail-open. The high-water mark gates the post-write race check entirely: `max_id_before=-1` skips it. Before paging, only a failure of the single page-1 request could reach that. Requiring a COMPLETE walk newly routed a page-2 hiccup, an over-cap history, or one malformed id on a later page into the same hole, so a human rejection racing the write was left green where `main` repaired. A partial list now still yields a mark: it can only be LOWER than the true maximum, which makes the check more eager, never blinder. Only a read returning no rows at all abandons it — the pre-existing #849 gap, unchanged and now asserted by a test so it stays visible. WITHDRAWN — the "currency witness". It produced two defects from one mechanism, which is the signal to remove rather than patch twice: counting ANY row above the mark does not witness this job's write, so a stale-but-valid snapshot carrying an unrelated newer row passed while hiding a rejection; and a schema-valid stale read is not retried, so one such response turned a transient anomaly into a permanent sentinel. The hazard has no mechanism here either — Gitea is a single instance with no read replicas. Removing it restores the pre-change exposure on that path, a non-regression. Also fixed, each a fail-open with a fixture and an executed mutation: - `.creator` is type-tested before indexing. `.creator.login` on a non-object exits jq 5 and `set -e` took the step down after the green was posted and before the repair. Reproduced by both reviewers. - the mark is the max over NUMERIC ids only. jq orders strings above every number, so one `"id": "99999"` passed the numeric gate and inflated the mark until nothing looked newer. - an unusable `raced` count now repairs instead of "not acting on it". - `sort=highestindex` (ASC, measured) so a row inserted mid-walk appends at the end rather than at position 0 on a page already read. An unknown sort value silently falls back to DESC, so this is insurance, not load-bearing, and the comment says so. - `ph_ok`/`ph_rows` renamed off `read_existing_verdict`'s `st_ok`. No live bug, but a name collision in a 1400-line step. Tests the reviews showed were missing, each proved by an executed mutation: - verdict beyond a SHORT page (a deliberately unfaithful truncated response — against a faithful double a short page is always the last, so the rule "terminate only on an EMPTY page" was unobservable) - pre-write paging failure still yields a usable mark - pre-write read returning nothing abandons the mark and says so - a TRANSIENT page failure is retried (the retry was unproven code: every other error mode fails on every attempt, so disarming it reddened nothing) - a string id cannot inflate the mark - a malformed `creator` row does not kill the job Stub corrections, both the same class as the earlier `[]`-vs-`null` gap: it served one flat list (so paging was unobservable) and computed its own-post id with `max()` over mixed str/int, which raised TypeError and made the string-id test pass because the DOUBLE crashed rather than because the mark was right. Mutation matrix, all executed, each reddening exactly its named test: retry disarmed; numeric-max reverted; partial-mark fallback removed; short-page terminates; page-1-only walk; post-write fail-closed flipped open; jq type-guard reverted. The unusable-count arm is unreachable by any fixture and is annotated as such rather than claimed as proved. Verification: `scripts/tests` 1090 passed, 2 skipped; decisions_validate and build_decisions_catalog --check both exit 0; terminator, clamp, sort order and id monotonicity all re-measured live on Gitea 1.27.1. refs #763 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4368cc8cbe |
fix(763): page both /statuses/{sha} reads to a validated terminator
`review-verdict.yml` read the per-POST status history twice with a single `?limit=100` request. `limit` clamps to the server-wide `MAX_RESPONSE_ITEMS` (measured 50), so on a head carrying more rows than the clamp both reads saw a partial list. The high-water mark was only page 1's maximum, and — the direction that matters — a raced human verdict beyond page 1 was invisible to the post-write race check, leaving a forged green over a rejection. Both reads now walk to a validated empty page (`[]` on this endpoint, measured 2026-08-28 against PR #761's 114-row head: pages 1-2 return 50, page 3 returns 14, page 4 is `[]`), never terminating on a short page, under a 20-page cap and retrying each page once. Correctness does not depend on the cap value. This retires #751's page-2 "assume raced" probe, which repaired every head that outgrew one page. It fired on Renovate PR #761: an `::error::` claimed a human verdict had been overwritten on a head carrying none, and the sticky sentinel then refused re-exemption on every later run. Two properties replace it. Uncertainty now fails closed at both ends — the unreadable-history branch warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself; this is affordable only because paging removed the common trigger. And the post-write read must witness the job's own write: reaching a validated empty page proves the walk finished, not that it saw a current list, so at least one row above the pre-write mark must exist because the job just posted one. The `::error::` now distinguishes a verdict actually found from an unverifiable read. The sentinel description stays generic — the classification recognises it as a fixed point, so its wording is load-bearing. The stub gained faithful paging (50-row slices, `[]` past the end, one snapshot per logical read so a counter mode cannot describe two different histories across pages) and, separately, modelling of the job's own POST appearing in the history — which it had never done, so in its world every ordinary run looked like a head nothing had been posted to. `own-write-invisible` withholds exactly that detail as the negative control for the currency witness. Mutation-proved by execution, one clause at a time: - walk reads page 1 only -> RUNNING_PAST_PAGE_1_is_PAGED_and_the_exemption_ STANDS, raced_verdict_on_PAGE_2_is_detected_and_repaired and both UNREADABLE history tests go red - currency-witness zero branch deleted -> CANNOT_SEE_OUR_OWN_WRITE red - fail-closed flipped to fail-open -> both UNREADABLE history tests red Verification: `scripts/tests` 1085 passed, 2 skipped; decisions_validate and build_decisions_catalog --check both exit 0. fixes #763 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
761e575836 |
fix(787): derive the dropped-step guard's scope, and reconcile its snapshot against the server (#861)
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 / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
8aebba4d89 |
fix(748): declare permissions: on all six workflows, and prove the declaration binds (#860)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m4s
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 4m20s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
45b17e58e1 |
fix(744,835): ci-image.yml publishes from main only; guard persist-credentials with no exemption (#857)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m15s
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 4m27s
Closes the push route into ci-image.yml (#744) and ships the persist-credentials guard that was waiting on it (#835). ci-image.yml's push trigger had no branches: filter and was path-scoped to docker/ci/** AND to the workflow file itself. Gitea resolves a push workflow's definition from the pushed ref, so any branch push touching those paths ran that branch's own YAML on a docker-capable runner holding the credential that writes ersatztv:prod and the ersatztv-ci:<sha> five container: jobs execute. Be precise about what the filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route. This closes the DRIVE-BY case - publication as a side effect of an ordinary push - and is not a boundary against a writer who intends to run their own YAML. The wider class is #853. The self-reference left both paths: and ci-image-pin's expected in the same change - a decided tradeoff with both prices stated, not a necessity. Branch publishing moves to workflow_dispatch, probed live: run 2340 on this branch published ersatztv-ci:43b1e45 and left :latest unchanged. With both mechanical blockers gone, ci-image.yml's checkout takes persist-credentials: false (16 of 16) and scripts/tests/test_workflow_persist_credentials.py holds the convention with NO exemption list - git-index population, declared clause mutation re-run every suite, guard-inventory rows. fixes #744 fixes #835 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
d68ce42fcb |
fix(742): inherit an h10 verdict only from an allow-listed reviewer (#850)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m55s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m0s
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 4m25s
`review-verdict.yml` decided whether an existing `review-verdict/h10` was worth INHERITING by testing `.creator.login != null` — satisfied by any account's credential, including the `renovate` bot's `RENOVATE_TOKEN`, a `write:repository` PAT that cannot be scoped down the way #697 scoped the registry credential. The test is now membership in `H10_REVIEWERS="timothy"`, a literal in the base-resolved definition. The design that survived 11 cold review rounds: * `read_existing_verdict` carries TWO flags. `ex_human` (attributable AND allow-listed) gates INHERITANCE; `ex_attributable` gates the last-moment re-read, which asks the opposite question and must stay broad. Narrowing both — the first draft — makes the job post its exemption over a mid-run rejection, and the post-write repair does not cover that. * The two calls no longer compute an identical predicate, so "changed" is made explicit: the state/creator/description triple from the first read is snapshotted and compared. * The allow-list governs an inherited `success` ONLY. An existing `failure` inherits on attributability alone, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR. A symmetric rule was a measured fail-open. * The post-write raced check stays broad — not because narrowing would let a rejection go green (a real reviewer is on the list by construction), but for the misconfiguration case. Two mechanisms were WITHDRAWN rather than patched a third time, and both withdrawals are recorded in `ci.exemption-provenance` so they are not re-attempted: a `::warning::` annotation that produced three defects in three rounds, and a post-write fix whose generic `pending` would have been re-derived anyway and which had no retry trigger. Verified: the inheritance predicate driven against the LIVE Gitea API on a probe-named context, both allow-list directions; every clause mutation-proven against the shipped file; `scripts/tests` 1012 passed, 2 skipped. Follow-ups filed: #845 (post-review-verdict.sh does not check its own account is allow-listed) and #849 (post-write verification: three routes leaving an exemption `success` over a human `failure`, plus the retarget fence's post-POST gap, plus the prose sweep that lands with the behaviour). fixes #742 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
469d19852c |
fix(788): one declarative H10 verdict vocabulary, derived by both sides (#846)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m17s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
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 4m22s
The verdict words lived in two hand-written shell copies — the `case` arms of post-review-verdict.sh (write) and the POS_RE/NEG_RE regexes of check-review-verdict.sh (read) — held together by nothing but a comment that had already gone stale. scripts/lib/review-verdict-vocabulary.sh now declares them once and both sides derive; neither script enumerates a verdict word any more. Only the WORD SET moved. The grammar stays in check-review-verdict.sh, where every #629 false-open actually lived. No parity test: #774 shipped one and withdrew it after six rounds, because a regex over shell source is not a shell parser. The proof is behavioural and graded MUTATION — the harness restores the pre-#788 hardcoded POS_RE each run and requires it to redden. Enforcement is a DATA dependency, not a control-flow gate. Review round 1 found a real fail-open in the first commit: `${#arr[@]}` is nounset-safe only for a declared-empty array, and under `set -u` that error inside a function called as `if ! validate` skips BOTH branches — so on the reader (deliberately no `set -e`) an explicit BLOCKED @ head classified `positive`, exit 0. Validation now sets a sentinel on its last line and the derived views refuse without it. Six cold review rounds; rounds 2-6 found no fail-open across differential fuzzing (4788 / 2612 / 7560 payloads, zero divergences from origin/main's grammar), sentinel forgery, environment poisoning, declare -p evasion on bash 5.3 and 3.2, path/symlink resolution and probe TOCTOU. Every malformation fails closed: reader exit 2, writer exit 1 with nothing posted. Also corrected: CLAUDE.md and release.review-verdict-gate both enumerated the vocabulary without LGTM, a word the code has accepted since #629. fixes #788 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
f2551b778e |
fix(746): drop the persisted checkout credential; unmask the base-ref fetches (#842)
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 / CI toolchain image resolves (push) Successful in 24s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 1m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m1s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
78fc283627 |
docs(772): the merge gate decides on the COMBINED state, and skipped counts as green
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 8s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 34s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Review verdict / Set review-verdict status (pull_request_target) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m0s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
review-verdict/h10 Review-verdict: MERGEABLE @ 78fc283 (base: main)
Graded a nit and not blocked on, but it is a claim about a neighbouring subsystem that is one notch too strong: the hook decides on the combined `.state` and its filters exclude `skipped` (#593). Left as-is it would have taught the next reader that any non-success context denies, which is what #593 exists to correct. refs #772 |
||
|
|
8c72a5de9e |
docs(772): correct the twins of two claims this branch already fixed
Both remaining review findings were the same shape as the one before them, and it is the
shape this repo keeps recording: a claim corrected in one place, its copy left standing
somewhere else in the tree.
* `docs/ci-cd.md` said "gates nothing" in the small-lane paragraph while the section 1441
lines below said the opposite. A red preflight lands in the PR's combined status, which
the merge gate reads (#598) — what it does not do is SKIP the jobs it diagnoses, and
that is now the sentence in both places.
* Two docstrings in the preflight's test file still described the disarmed script as
warning and exiting 0. Built the mutant and ran it: it emits an error and exits 1. The
exit code separates nothing now that an unverifiable answer fails too — the DIAGNOSTIC
is what the mutation destroys, which is what `mutation_manifest.py` already said and
the prose next to it contradicted.
Nits from the same pass: the admin-cron URL is quoted (`?` globs in zsh, the operator's
shell); the retry assertion's message quoted a threshold it does not use; the arm table
omitted the malformed-credential shape the code and tests both have; `buildx inspect` no
longer `--bootstrap`s a builder just to read its name, and an empty capture no longer
produces a noisy `buildx use ""`.
Swept the tree for the shape rather than the two reported lines: the surviving "exits 0"
and "could-not-tell" hits are other subsystems, or the concept named as a concept.
refs #772
|
||
|
|
d304c8573e |
docs(772): state the trap, not the draft history
The advisory narrative check was right about both new passages: 'an earlier draft warned and exited 0' and 'both cold reviews found it independently' only parse to someone who saw the session. What a cold reader needs is that warning-and-exiting-0 is the natural way to write this check and is wrong, and what it costs — which is now what the doc says. refs #772 |
||
|
|
30640bb780 |
fix(772,792): close the fix-round review — the doc still described the semantics the fix reversed
The re-review's one HIGH was mine and was the obvious one to miss: the previous commit changed the preflight so an unverifiable answer FAILS, and left a `docs/ci-cd.md` paragraph two screens away still saying "anything else is reported as could-not-tell". That paragraph is the one an operator reads when the job goes red, and it would have talked them into reinstating the defect. Replaced with the full arm table, including the two rows the first draft got wrong and why. * "gates nothing" was false in the way this repo has recorded before (#598): the merge-consent hook reads the COMBINED status, so a red preflight blocks the merge like any other red job. It does not SKIP the jobs it diagnoses; that is the accurate claim, in ci-cd.md and in the remote-state row. * The production retry defaults were evaluated by nothing — every test overrode both knobs. A test now drops the overrides and measures three attempts and a real pause, so editing the default to 1/0 (which would falsify the "a blip does not redden a PR" argument) goes red. * `journalctl -u gitea | grep ExecuteCleanupRules` is not a reproduction: that identifier reaches the log only through slow-query warnings, so an empty grep on a healthy host reads as "the rule never ran" — the inverse. Replaced with the admin cron API, which answers deterministically. * The recovery recipe's `docker buildx use default` needs the containerd image store to `--push` (both named hosts have it, checked today) and mutated the operator's builder selection without restoring it. * The stub's comment claimed both halves of real curl's transport failure mattered; only the exit status is observable, because `|| resp=""` discards what curl printed. * The empty-half credential refusal echoed the username; it needs no value at all. The 401/403 arm aborts the remaining pins while 404 continues — deliberate, now stated. * `curl -u "$VAR"` puts a credential in argv, and this job runs container-free on a shared host. NOT fixed here: it is the shape all five `scripts/` callers already use, so fixing one site leaves the class and splits the codebase. Filed as #821 and named at the site. refs #772 refs #792 |
||
|
|
0cf355e494 |
fix(772,792): address both cold reviews — an unverified pin is no longer a green job
Two independent reviewers (one cross-family) converged on the same defect, and it was the
important one: the preflight WARNED and exited 0 on every answer that was not 200 or 404,
so a missing `curl`, a moved registry or a DNS change would have left it green forever —
"the check could not run" presenting as "the pin is fine", in a script whose own header
disclaimed exactly that. Unknown answers are now retried (3x, 5s) and then FAIL, with
wording kept distinct from the deleted case because the two send an operator to different
places.
Also from the reviews:
* An absent secret does not arrive as an unset variable. `${{ secrets.X }}:${{ secrets.Y }}`
interpolates to ":", a perfectly non-empty and perfectly useless credential, and the
tests covered only the unset shape. Both halves are now required, and the parametrised
test drives the production shape.
* HTTP 200 is not a manifest. A proxy or a login page answers 200 too, so the body is
fetched and matched for `schemaVersion` (a shell `case`, so no jq dependency and no
pipeline that can inject).
* The curl stub ignored `-u` and answered 200 regardless, so deleting the real `-u` would
have left the suite green while the live registry rejected every request. It now 401s an
unauthenticated read, as the registry does.
* The mutation's declared diagnostic changed with the script: now that unknown fails too,
the exit code no longer separates "deleted" from "could not check", so the proof turns on
the message and `expect` says so.
* docs/ci-cd.md: `scan` is no longer the only `docker-build.yml` job on the small lane, so
the tag-push exclusivity claim and the lane membership were both false. Fixed.
* "Immutable" was overstated: `ci-image.yml` tags `rev-parse --short HEAD`, so a dispatch or
a weekly no-cache run at the same HEAD republishes that tag from a rebuilt image. Stated,
along with what the rebuild recovery does NOT restore (mutable bases and apt, so equivalent
rather than bit-identical).
* The recovery recipe left you in a worktree checked out at the pin commit — where the
verify script does not exist, and where the workflow carries the pre-bump pin. It now
keeps `$repo`, returns, and removes the worktree. It also needed BuildKit's `http = true`
caveat: the container driver does not inherit the daemon's insecure-registries.
* The root cause carries its evidentiary limit and its reproduction commands, and says what
to conclude if a pin vanishes after server-management#842 lands (refuted, not re-applied).
* The `ci.required-job-step-execution-markers` carve-out named one container-free job; there
are two now, and the membership is what rots.
* The decision record's `''` YAML escapes leaked into rendered prose; "status, no comment ->
ask" is qualified (a prior positive verdict for the SAME head still satisfies condition
(c)); "exits 1" is "exits non-zero" (usage exits 2, jq its own status, signals 128+n).
refs #772
refs #792
Decisions-Edit: yes
|
||
|
|
5ba442c11c |
fix(772,792): name the missing toolchain image, and stop a refusal leaving a verdict comment
#772 — the pinned CI toolchain image can be deleted out from under us, and when it was (2026-08-11..13) all five `container:` jobs died at image pull, both required contexts included, with the cause buried in each job's log. Root cause is registry-side and is now established rather than guessed: an owner-level Gitea package cleanup rule (keep_count 15, remove_days 1, remove_pattern `.*`, keep_pattern no 7-hex sha can match) deletes a sha tag once 15 newer versions exist, and `ExecuteCleanupRules` ran nightly through the window. The `ersatztv` package carries the same rule's fingerprint exactly — every sha tag older than the 15-slot window is gone, every keep_pattern tag back to 26.3.1 survives. Version deletes leave no audit row, so the specific run cannot be replayed; that limit is stated where the claim is made. The durable fix belongs to the registry's repo: server-management#842. What lands here is what a consumer of someone else's registry can do: * `toolchain-preflight`, a container-free job (a job consuming the image could not run to report it missing) resolving every pin against the registry and failing with a message that names the tag and the recovery. Not a `needs:` of the jobs it diagnoses — gating five jobs behind a checkout and one curl taxes every green run to speed up a rare red one, and they already fail fast. * Only HTTP 404 means gone. Everything else is could-not-tell, and rejected credentials fail rather than pass as unknown — "the check could not run" must never present as "the pin is fine". * A recovery path that does not need CI: rebuild the SAME tag from the commit it names and push it. The push half was verified against this registry on 2026-08-22 with a throwaway package (created, resolved 200, deleted). #792 — the reported defect was the exit code, and re-measuring says that premise is false: every no-status path already exits 1, and eight refusal modes now assert it against the real predecessor, where they pass. The observed 0 came from the invocation, not the script. What WAS broken is the half-state the issue describes second: the comment was written before the status, so every refusal left `Review-verdict: MERGEABLE @ <head>` on a PR with no gating status behind it. The two writes are now ordered status-then-comment, which makes the only reachable half-state the safe one — a status with no comment leaves the merge hook's condition (c) with nothing to classify, which is an `ask`. The refusals themselves are untouched. Ordering rather than compensating deletion: an orphaned-comment cleanup needs a Gitea call, and these refusals are usually caused by Gitea being unreachable. Proof for the ordering is the split against origin/main's script: the 8 orphan/ordering tests go red there, the 8 exit-code tests stay green. fixes #772 fixes #792 Refs: server-management#842 Decisions-Edit: yes |
||
|
|
d4c72697f2 |
feat(780): commit a ruff config and enforce it in CI (#813)
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 / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
Python lint here was a property of the operator's laptop: the global instructions
say to run ruff, no workflow ran it, and with no committed config ruff fell back
to whichever ~/.config/ruff/ruff.toml the machine happened to have.
- ruff.toml at the root, pinned ruff==0.12.11 in the script-tests job.
- Both lint steps pass an EXPLICIT population from `git ls-files` with
`--no-force-exclude`, never `ruff check .` — an `exclude` empties a
discovery-based run into a GREEN one (top level empties both commands, [lint]
empties check, [format] empties format --check), and `ruff check .` over zero
files exits 0 with only a stderr warning. Guarded by an empty-population arm.
- Tree clean: 74 findings at
|
||
|
|
6a4265d81d |
feat(784): a doc records the end state — generalize the no-session-narrative rule (#811)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 40s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 17s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
15d2439915 |
feat(794): witness a fix's test failing BEFORE the fix, and check the claim in CI (#801)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m14s
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 4m30s
Mechanises the defect that took #776 and #793 six review rounds each: a fix's test
written to confirm the fix, not to discriminate against its absence.
testing.guard-ships-with-mutation-proof generalised from guards to fixes.
prove-fix.sh runs the selector at the commit (control, must be GREEN) and again in a
separate fresh worktree with the non-test files reverted (must be RED = pytest exit 1
exactly; 2/3/4/5/143 are refused, and --continue-on-collection-errors keeps add-a-file
fixes provable). pytest's status comes from a marker written only after it returns,
because ( cd X && pytest ); rc=$? returns the SUBSHELL's status. Opt-in by a Proves:
trailer; CI checks every commit that carries one and says out loud when a PR has none.
THE TOOL REJECTED ITS OWN AUTHOR. Three commits on the branch claimed
Proves: scripts/tests/test_prove_fix.py; the job returned UNPROVEN for all three,
because reverting the script restored a working earlier version the suite also passed.
Two had been "verified" against hand-written mutants that did not match the code that
actually shipped. The tests were rewritten until both go RED against
|
||
|
|
cfdab63a3a |
fix(767): gate the release path on the delimiter ban with a prerequisite job (#770)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m40s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m30s
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 4m27s
The delimiter ban protecting `build`'s `Smoke + IPTV E2E` was enforced only by a pytest in `script-tests` — `on: pull_request`, not a required context — so nothing re-checked it on a `v*` tag push, which is exactly when the candidate image is published. A `scan` job now runs the ban test and `build` lists it in `needs:`, so a red `scan` skips `build` and no image is built. Measured both directions without cutting a release: run 1928 (poisoned Smoke) → scan failed, `Build & push` skipped; run 1929 (control) → scan green, build ran. The gate rests on three different KINDS of check, because each single kind was defeated in review: the ban test; an execution probe against a poisoned copy with all three `env:` tiers layered; and `scripts/ci-prove-ban-detects.sh`, which is not a test — it poisons the real checkout and vouches only for the ban test's `build` parametrisation failing. Eight review rounds; rounds 1-5 each found a real defect in the previous fix. Refs: #767 Decisions-Edit: yes Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
f9f8f65cee |
docs(720): name all three Komodo stacks and the label-based resolution rule (#769)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m31s
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 6m9s
A live Komodo stack literally named `ersatztv` owns the TEST channel, not prod. `DeployStack ersatztv` succeeds, looks healthy, and promotes nothing — silent and plausible. Extends the existing callout with all three stack names and the resolution rule: identify the prod stack by the container's `com.docker.compose.project.config_files` label, not by stack name. Container labels re-verified live on jazz 2026-08-11; the verification date is scoped to what was actually re-measured, after review flagged the stamp as covering unchecked values. The server-management half (the `komodo` skill still uses the dead `media-servers` as its worked deploy target) cannot land in this repo and is tracked as server-management#743. #720's box 2 was re-scoped to that hand-off rather than ticked as though the skill were fixed. fixes #720 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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>
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
5fa672e2e5 |
docs(release): record the v26.14.0 release notes
Adds the v26.14.0 row to the release table. This is the commit the v26.14.0 tag will be pushed onto, matching how v26.13.0 was cut (#716). Contents since v26.13.0: the #726 bitmap-subtitle -readrate starvation fix (headline), the #674/#688 decisions-validator PyYAML cross-check, the #706/#707/#711 review-verdict raced-sentinel fix, and two Renovate bumps. Replaces the branch behind PR #738, which never received a pull_request CI run across six trigger attempts (two pushes, a force-push after rebase, a close/reopen, a body edit, and a spaced push on an idle queue). A workflow_dispatch run did complete green but writes no commit statuses, so it cannot satisfy the required checks. Opening a fresh PR produces an `opened` event rather than a synchronize, which is a different path. No [skip ci] token -- this branch's merge commit is the v26.14.0 tag target. |
||
|
|
0ff9671393 |
fix(688): pin the minority band's constants and inclusivity; two prose corrections
Review round 7. Its adjudication of the round-6 dispute went the branch's way — measured
at `f394d6ce`, the sha the record actually cites, the #620-era distribution is n=167,
min 2, median 26, p90 52. Round 6 had measured `fefd11dff` (p90 57), a different tree.
The number stays as written.
BLOCKING FINDING: the 2%/25% constants and their inclusive boundaries were not pinned at
all. Mutating 0.02 -> 0.03, 0.25 -> 0.30, or either `<=` to `<` passed all eight
calibration tests. Those are not free parameters — they ARE the documented CI-red
thresholds, so a silent shift would quietly falsify the 38/718 figures in
docs.corpus-size-signal and docs/ci-cd.md (a strict cap reds after 37 long additions, a
strict floor after 717 short ones).
test_the_minority_band_BOUNDARIES_are_exactly_where_documented pins all four. It uses
100-record fixtures so k over the ceiling IS k%, and both 2/100 and 25/100 are exactly
representable and compare equal to the constants — true boundary cases, not near-misses.
Verified by mutation: all four now fail it.
PROSE
- corpus-size-signal said what stays blocking is "what routine growth cannot break",
immediately before explaining that 38 routine additions break it. Now "what no SINGLE
ordinary addition can break", which is what is actually true.
- docs/ci-cd.md said the fine claim is "never asserted"; it is never asserted AGAINST THE
LIVE CORPUS, and IS asserted on synthetic distributions the tests own. Corrected — the
distinction is the whole design.
Correction to an earlier commit message in this branch (
|
||
|
|
b24c51ab51 |
fix(688): stop enumerating multiplicities, pin n and the keyless filter, de-couple the vacuity floors
Review round 5. One reviewer returned MERGEABLE with prose findings; the other found four more, two of them real test gaps. Both are addressed here. THE MULTIPLICITIES WERE WRONG AGAIN — fourth commit running. The "measured" sequence 59, 59, 60, 60 -> 81 is measured nowhere: origin/main has 59, 59, 59, 60, 60 and HEAD has four 59s and three 60s. I had even tagged it `(measured)` in a canonical decision record. So this stops enumerating them. All four sites now state only the load-bearing, stable fact: the lengths climb to the ceiling and then jump STRAIGHT to 81 with nothing in between, so one record moves p90 by 21 lines. The multiplicities change with every record added; the gap is the point. This is the same "fix the boundary, not the site" move the tests got three rounds ago, applied to prose that had failed four times. TEST GAPS - Deleting the over-tight test removed the only pin on CeilingCalibration.n: a mutation returning n=1 passed all 19 relevant tests while printing a wrong denominator in the drift notice. Pinned. - The `if r.key` filter was load-bearing in production and unpinned: main() passes the UNFILTERED list (194 entries, 11 keyless, one a 106-line "Records formerly in this file" scaffolding block), while every test handed the function a pre-filtered list — oracle and production agreed only by accident. Pinned. - The --record-ceiling 0 arm's claim that it "cannot go vacuous for any non-empty corpus" was FALSE: an empty record body is validator-valid and record_prose_lines returns 0, so a corpus of empty-bodied records has no offender at 0. Now -1, which makes the claim true. - The three `len(recs) > 100` vacuity floors were themselves growth-coupled — 83 legitimate retirements would red them even with the ceiling still calibrated, which is the #688 class in the guard rather than the assertion. Lowered to >20 where a floor is meaningful, and to plain non-empty on the derived-ceiling test, whose derivations need nothing more. - test_main_FEEDS_the_crosscheck now compares against `set(record_wing_files())` instead of a hardcoded basename, killing the same mutation with zero corpus dependence. PROSE - "ordinary growth cannot cross it — NOT immune" contradicted itself in four places. Now: no SINGLE ordinary addition can cross it; this is measured headroom, not immunity. - "trimming or archiving 15" blurred two different denominators. Trimming leaves 3/183 = 1.64%; archiving leaves 3/168 = 1.79% because the denominator moves too. Both verified, both under the floor, now stated separately. Verification: 431 scripts/tests pass; ruff at baseline parity (47 — a 121-char docstring line briefly took it to 48 and is rewrapped); validator exits 0 with no drift notice; corpus at p90=60, 18/183, calibrated; record still 60 lines. Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c56dfdd539 |
fix(688): delete the last over-tight test, pin the crosscheck's INPUT, fix 4 prose defects
Review round 4, both reviewers. Both report the code path SOUND and the #688 coupling class analytically gone (rows proved, not merely observed green); one caught 28 of 30 mutations. What blocked was one over-tight test, two unpinned mutations, and prose — including two defects the PREVIOUS commit introduced while claiming to fix numbers. TESTS - Deleted test_adding_ordinary_records_cannot_RED_the_blocking_property. It appended two long records to the LIVE corpus and asserted flags_minority on the RESULT, so it crossed the cap two records before production does (56/221 vs 54/219) — a test named "cannot RED the blocking property" being a tighter tripwire than the property. Fourth instance of the #688 defect in this change. Deleted rather than tuned: both its jobs are already covered off live data (the synthetic v4/v5 contrast, and the deliberate live guard at the production threshold). - test_main_FEEDS_the_crosscheck_the_REAL_wing_files closes a mutation hole found by review: replacing `pyyaml_frontmatter_faults(record_wing_files())` with `...([])` in main() left the ENTIRE suite green. Both existing wiring tests monkeypatch the function, so they pinned that its RETURN reaches errs, never that its ARGUMENT is the corpus — the '#609 marker that printed OK while doing nothing' defect one level up, which is the exact thing the new record indicts. Verified: the mutation now fails this test. - test_main_actually_REPORTS_... went vacuous whenever the ceiling legitimately goes green (`False is False` passes with the whole warning branch deleted). Added an arm at --record-ceiling 0, which no non-empty corpus can make vacuous. - Pinned two surviving mutations: ceiling_calibration's n_over boundary (it recomputes the count, so oversized_records' exclusivity test does not cover it — `>` vs `>=` differs by the 3 records sitting exactly on the ceiling) and p95's quantile (the 95/5 fixture cannot tell 0.95 from 0.99). PROSE — two of these were introduced by the previous commit, whose stated job was fixing numbers. That is the pattern worth naming, not the individual typos. - "so ONE new record could move p90 lines" — the previous commit deleted the magnitude and left the sentence ungrammatical. Now "by 21 lines". - It also introduced a THIRD variant of the sequence it was correcting ("60, 60, 60") and missed a FOURTH site in ci-cd.md still saying "twenty lines". All four sites now read the measured 59, 59, 60, 60 -> 81, and 21 lines. - 59- and 60-line records were described as "above the ceiling"; they are at or below it. - "routine growth cannot cross it" overstated the bound: it is deliberately less sensitive, not immune. Reworded, and the THIRD and tightest arm is now documented wherever the other two appear: consolidating 15 of the 18 offenders drops below the 2% floor (verified: 3/183 = 1.64%). That is in real tension with test_oversized_records_can_go_green and is stated as accepted — at 3/183 the constant genuinely is mis-calibrated — with the remedy named: a consolidation PR that large should re-derive the ceiling in the same change. - Corrected a docstring that called the 999-ceiling failure "silently deleting the assertion"; it would go red, not silent. Verification: 430 scripts/tests pass; ruff at baseline parity (47); validator exits 0 with no drift notice; corpus at p90=60, 18/183, calibrated. The two new claims were measured, not assumed: the empty-list mutation fails the new test, and 15 consolidations reaches 1.64%. Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b3a8826281 |
fix(688): stop asserting live-corpus order statistics anywhere in the suite
Review round 2 (both reviewers, independently) found the round-1 fix incomplete: the live-corpus coupling survived in two more tests. This is the THIRD instance of one defect class in this change, so the fix is to remove the coupling rather than patch another site. BLOCKER — test_adding_ordinary_records still asserted live order statistics. The `if before.marks_tail:` guard made the PRECONDITION conditional but left the CONCLUSION (`assert not after.marks_tail`) an assertion about the live p90. Verified: appending 16 ordinary 30-line records — nothing long, nothing unusual — makes both sides true and fires it, reddening the blocking job for an unrelated author. Exactly what #688 exists to abolish. The v4-vs-v5 contrast moved to test_v4_would_have_reddened_where_v5_holds, built on a distribution the test OWNS, reproducing the shape that matters (a sparse gap just above the ceiling). The real-corpus test now asserts only the robust claims: the additions were counted, v5 holds, and the measured headroom. Same treatment for the "bad ceiling" teeth test, which hard-coded that 200/229/230 stay rejected on the live corpus — three new 200+ line records flip it. Teeth now demonstrated synthetically; the only live-corpus assertion left is that today's ceiling is accepted, which needs 38 over-ceiling or 718 short additions to break. The IFF drift test could lose its quiet branch: one 61-line record makes BOTH the 60 and 999 ceilings drift, at which point an UNCONDITIONAL notice would pass. Both ceilings are now DERIVED — p90 itself (always calibrated, since p90 <= p90 <= p95) and max+1 (always off the tail) — so each branch is guaranteed by construction, and the test asserts it exercised both. Added the missing regression test for the typed-mapping-key TypeError: removing `key=str` now fails a test instead of only a manual probe. Corrected against measurement: the v5 row of the record's own version table still stated the REJECTED first-draft bound (`0 < f < 1/3`) — the canonical artefact contradicting both the code and its own next paragraph; accepted range is 43..180, not "roughly 45..150"; breaching the cap takes 38 additions, not 37 (37 lands exactly on 0.25, which passes under `<=`); "5x headroom below the floor" was inverted; ci-cd.md said four versions "all ratcheted" when v1 was vacuous and v2 accepted an absurd ceiling; and the crosscheck record overstated protection — script-tests is NOT a required check, so "no broken record has reached main" is procedural, not structural. Evidence the coupling is actually gone: mid-fix the corpus sat at marks_tail=False (an edit pushed this branch's own record to 61 lines, moving p90) and the suite stayed fully green. Under the old assertions that state reddened CI. The record is trimmed back to 60 so main ships calibrated and no drift notice nags. Verification: 428 scripts/tests pass; ruff at baseline parity (47); validator exits 0 with no drift notice; corpus at p90=60, 18/183 over the ceiling. Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
80818aa294 |
fix(674,688): address independent review — restore the coarse bound's teeth
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 33s
PR Gates / Docs update reminder (pull_request) Successful in 31s
PR Gates / decisions lifecycle (pull_request) Successful in 41s
Review verdict / Set review-verdict status (pull_request_target) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m32s
PR Gates / Script tests (pytest) (pull_request) Successful in 2m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m53s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 20m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Two independent cold reviews (one cross-family) agreed on the top two findings. 1. The #688 fix was defeated by its own complement test. test_main_is_QUIET_about_ drift asserted the drift notice was ABSENT while running main() over the LIVE corpus — whose failure condition is bit-for-bit v4's assertion, in the same blocking job, three functions down. p90 sat exactly on 60, so one over-ceiling record would have reddened it. Replaced with an IFF test that uses ceiling_calibration as its oracle, so it asserts the WIRING rather than the corpus's current state, plus a guard that at least one branch fires. 2. The coarse bound was nearly unfalsifiable. `0 < fraction_over < 1/3` accepted EVERY ceiling from 39 to 229 on the real corpus — including the ceiling of 200 my own docstring offered as the case it catches, because one 230-line record keeps the count nonzero. That claim was simply false and is corrected. The floor is now a FRACTION (2%) and the cap 25%, which rejects 200/229/230 and 20, and accepts roughly 45..150. Headroom measured, not estimated: 37 consecutive over-ceiling additions, against ONE record to break v4. 3. yaml.safe_load raises a bare ValueError, not a YAMLError, on a well-shaped but impossible date (stale-after: 2026-06-31), which escaped as a traceback and killed the validator on any machine with PyYAML. The except is now deliberately broad, with a test. 4. PyYAML returns TYPED mapping keys, so a stray `1: x` made sorted(set|set) raise TypeError. Sorted with key=str. 5. The headroom prose was arithmetically wrong (~42/~40 where the real values are 63/64; each addition moves numerator AND denominator) and the record counts were stale. Corrected against measurement. 6. test_adding_ordinary_records passed identically with its two additions removed. It now asserts the additions were counted, and that they break the v4 property while leaving v5 satisfied — guarded by `if`, never asserted, since whether v4 currently holds is a fact about the live distribution and asserting it would rebuild the ratchet. Also recorded honestly in docs.frontmatter-pyyaml-crosscheck: decisions-guard installs no PyYAML, so in CI the cross-check always skips and script-tests already caught both hazards — the CI delta is close to zero and the real fix is the local loop plus the tool/suite agreement. And the new record was trimmed 64 -> 58 prose lines: at 64 it moved p90 to 64 by itself, i.e. this PR would have reddened the old blocking job. That is now cited in the test as the live demonstration. Verification: 426 scripts/tests pass; ruff at baseline parity (47); validator exits 0; corpus back to p90=60, 18/183 over the ceiling, marks_tail and flags_minority both true. Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9d2b30dc3b |
fix(674,688): cross-check frontmatter against PyYAML; split the ceiling calibration claim
Two defects in scripts/decisions_validate.py, fixed together because they share the validator and its pytest suite. #674 — the validator reported OK on frontmatter PyYAML rejects. The hand-rolled reader is deliberately dependency-free (decisions-guard and the Husky hooks install nothing), so it cannot see a bare apostrophe closing a single-quoted scalar. Hit twice in one session by two independent agents. `pyyaml_frontmatter_faults()` now cross-checks the parse against PyYAML whenever PyYAML is importable, and is SKIPPED with a ::notice:: when it is not — the read path stays dependency-free. The two known hazards fail differently and the fix covers both: the apostrophe makes PyYAML reject the document, while an unquoted ` #` parses fine and silently TRUNCATES the value. So the check compares parsed results key by key rather than try/except-ing the load, which is also what makes it generalize past the two known characters. PyYAML wrote these files, so on disagreement it is authoritative and the file is the defect. The comparison has one implementation, called by the validator and by the existing test_decisions_lib agreement test, so the tool and the suite cannot drift. #688 — test_real_corpus_ceiling_sits_at_the_TAIL_BOUNDARY asserted p90 <= 60 <= p95 in the BLOCKING script-tests job. p90 sat exactly on the ceiling and the distribution above it is sparse, so one ordinary record moved p90 by twenty lines and reddened CI for whoever wrote it; it reproduced twice live (#672, #706) and both times the only in-scope remedy was trimming the new record to fit the constant. v5 splits the claim by robustness instead of hunting for a better single assertion. The blocking test now asserts only the coarse, non-ratcheting property (the ceiling flags a nonempty proper minority, 0 < fraction_over < 1/3); the fine tail-boundary claim is measured every run and REPORTED as a ::notice::, on the same reasoning stale_records already uses — a constant going out of date is the passage of corpus growth, not a defect in the commit under test. The fine property is still asserted, against synthetic distributions the test owns. The ceiling stays 60. Verification: 424 scripts/tests pass; ruff at baseline parity (47 before and after); the cross-check is clean on all 183 real records; a positive control pins that record_wing_faults alone still reports both hazard files as clean, so the new red cannot pass for the wrong reason; and a test demonstrates that appending #672's 62-line and #687's 107-line records to the real corpus does not red the blocking property. Docs: new record docs.frontmatter-pyyaml-crosscheck, docs.corpus-size-signal updated for the v5 split, catalog regenerated, docs/ci-cd.md updated for both. fixes #674 fixes #688 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8f6d4f4432 |
fix(706,707,711): fence the review-verdict write on the timeline retarget count
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
review-verdict/h10 Awaiting review verdict for 8f6d4f4
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m26s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three related defects in the `review-verdict/h10` gate, all surfaced by the cross-family review of PR #705. #706 race 1 — a stale run could overwrite a fresher verdict, permanently. The race was reproduced live rather than reasoned about (Gitea 1.25.4): with every other workflow stripped, probe PR #722 showed run 7520 (`opened`) finishing 20s AFTER run 7521 (`synchronize`) started. `pull_request_target` runs for one PR genuinely overlap, older finishing last. The issue proposed serializing with a non-cancelling concurrency group. That is REFUTED by measurement: with the group active, runs 7528/7529 still overlapped and 7528 ended 36s after 7529 began. A first probe appeared to show the group working — a negative control with no `concurrency:` key at all showed the same cancellations, revealing Gitea auto-cancels superseded `push` runs on its own and the probe had measured that, not the group. The auto-cancel does not extend to `pull_request_target`. The fix leaves the runs unserialized and instead makes an overtaken run decline to write: count `change_target_branch` events on the PR timeline at start and again just before the POST, and post 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 — how #698 route 1 forged its exemption). Abstaining is a handoff, not a stall: every retarget fires `edited`, so the event that makes a run abstain has already queued its successor. `updated_at` was rejected as the key precisely because it moves for comments/labels, which queue nothing. #706 race 2 — a human BLOCKED landing in the unclosable window between the pre-POST re-read and the POST was silently turned green. After an exemption `success` the job now re-reads the per-POST history and repairs its own status to `pending` if a human verdict appeared above a high-water mark taken just before the write. The repair is `pending`, never a copy of the human's state. The id comparison is load-bearing: a presence test would fire forever on a base-mismatched verdict and deadlock that PR's exemption. #707 — `pr-changed-files.sh` bound `.base.ref` and `.head.sha` across the enumeration but never `.base.sha`, so an ordinary advance of `main` mid-paging could drop a code path from an offset-paged diff and leave a complete-looking docs-only list. Now bound from the JSON already fetched (no new round trips). #711 — `.codex/` added to PROTECTED. It mirrors `.claude/hooks/` byte for byte, including the merge-consent hook, so the "a PR that can weaken the gate cannot exempt itself" rule had an incomplete path list. Latent today (untracked), live the moment anyone tracks it. Residuals are stated, not implied: a retarget inside the final round-trip, and the repair being itself a read-then-write. Gitea's status API has no compare-and-set, so neither reaches zero; both now fail toward `pending`. Tests: 398 pass in scripts/tests. Each new guard was mutation-checked — the fence's motion comparison, the untrusted-count gate, the repair POST and the id high-water mark were each neutered in turn and the intended test went red while its positive control stayed green. fixes #706 fixes #707 fixes #711 Decisions-Edit: yes |
||
|
|
016a05ced8 |
docs(release): record the v26.13.0 release notes
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 25s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 31s
PR Gates / decisions lifecycle (pull_request) Successful in 45s
PR Gates / Docs update reminder (pull_request) Successful in 44s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m4s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 1m4s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m18s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
Review verdict / Set review-verdict status (pull_request_target) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
73 PRs merged since v26.12.0. Adds the release-table row in docs/ci-cd.md covering the RuleBuilder maturation, the On Now/Next overlay, searchable library pickers, the watermark/QSV correctness fixes, and the H10 review-verdict gate hardening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d4c600149d |
fix(698): review round 4 — the PROTECTED guard was DEAD; define before use, fail closed, fix prescriptive docs
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
review-verdict/h10 Awaiting review verdict for d4c6001
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-3 cross-family review returned BLOCKED with 3 Mediums. The first was serious
and self-inflicted.
THE PROTECTED GUARD WAS A NO-OP. Round 3's `count_matching` / `count_not_matching`
helpers were defined AFTER the classification chain that calls them, so
`count_matching` was `command not found` on every run, `$( )` yielded an empty string,
`[ "" -gt 0 ]` errored, and the `elif` was simply skipped — the protected-path check
never executed at all. Confirmed by direct execution before fixing.
Three "protected path" tests stayed GREEN throughout, because a protected path is also
not a manifest and not docs-only, so the job still reached `pending` down a different
route. Asserting the STATE could not distinguish a working guard from a dead one. The
mutation battery missed it too: I had mutated the predicates, not their reachability.
Fixed three ways:
* helpers are defined immediately after `gh()`, before any use;
* the three counts are evaluated ONCE at TOP LEVEL and validated numeric, because
`exit 1` inside `$( )` leaves only the subshell and, with the substitution sitting
in a conditional, `set -e` never fires either — so a grep error had been silently
reading as "no match". A non-numeric result now aborts with nothing posted, and an
absent required check blocks the merge;
* the helpers return a non-numeric sentinel instead of trying to `exit`.
Verified: an invalid regex now exits 2 and posts NOTHING (previously it classified and
posted). Renaming the helper at its definition turns six tests red.
TESTS, aimed at the failure mode rather than the symptom:
* assert the DISCRIMINATOR (the job's `Decision:` reason line), not the outcome —
when several branches yield the same verdict, the verdict cannot tell you which ran.
A first draft of this test asserted the status description and failed against a
WORKING guard, because for `pending` the description is constant;
* a cheap stderr sweep for `command not found` / `integer expression expected` /
`unbound variable` across four representative PR shapes. Each of those makes an `if`
condition merely false while the job exits 0 and posts a plausible status, so this
catches a whole family of silently-skipped guards.
DOCS. The record and ci-cd.md still PRESCRIBED the here-string that round 3 removed —
following them would have reintroduced the temp-storage failure. Both now prescribe
counting, define-before-use, top-level evaluation and numeric validation. The workflow's
measurement paragraph still said the npm manifests "are included" three lines above the
note saying they are excluded; corrected.
379 tests pass.
Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d8bd1dcba9 |
fix(698): review round 3 — count instead of matching, re-read before the POST, fix stale docs
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / decisions lifecycle (pull_request) Successful in 31s
review-verdict/h10 Awaiting review verdict for d8bd1dc
Review verdict / Set review-verdict status (pull_request_target) Successful in 36s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
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 19m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-2 cross-family review returned BLOCKED: 2 High + 3 Medium. HIGH — here-strings traded one fail-open for another. `grep -q… <<< "$data"` fixes the SIGPIPE inversion, but bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable — and since these sit inside `if`/`!`, that failure flips the predicate exactly as SIGPIPE did. It did NOT reproduce on my bash 3.2, DID on the reviewer's Linux bash 5.x, and CI is Linux; the disagreement is itself the argument for a construct that cannot fail either way. Path predicates now COUNT with `grep -c`, which drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file), and grep's status is read honestly: exit 1 means "zero matches", a legitimate answer, while >1 is a real error that FAILS THE JOB rather than silently reading as "no match". `set -e` does not catch these on its own — they sit in command substitution inside a conditional. Verified correct under 171KB input AND an unwritable TMPDIR. The description test became a `case` prefix match, removing another pipeline from a security predicate. New record `ci.grep-q-pipefail-inversion` covers the whole class. HIGH — a human verdict landing mid-run was still overwritten, and the code claimed otherwise. The job read statuses once, classified over several round-trips, then posted: a reviewer posting BLOCKED in between had it replaced by an exemption `success`, turning an explicit rejection into a merge. Added a re-read immediately before the POST which refuses to write over a human verdict found then. The heading no longer says "never overwrite" — it cannot promise that, since there is no compare-and-set on Gitea's status API. Remainder tracked as #706. MEDIUM — documentation was stale in three places, all mine. The record's frontmatter `rule:` still listed the npm manifests (I fixed the body and forgot the frontmatter, so the canonical rule AND the generated catalog were wrong); docs/ci-cd.md still said `edited` was absent from `types:`, contradicting a section I had just updated; and the workflow header still implied the `edited` re-run settles the ABA race. All corrected to say detection, not atomicity. TESTS. 373 pass. New: a mid-run human verdict via a status stub that returns nothing on the first read and BLOCKED on the re-read, and large-input regression tests for the ADVISORY hook, which had none — the copy with less authority is the one that quietly keeps a bug (#649's whole point). Mutation-verified: reverting the hook predicate, the workflow predicates, or the pre-POST re-read turns exactly the intended tests red while every positive control stays green. Refs: #698 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bafb487eaa |
fix(698): review round 2 — close a PRE-EXISTING SIGPIPE inversion, narrow the bot allow-list, correct two overclaims
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / Docs update reminder (pull_request) Successful in 24s
PR Gates / decisions lifecycle (pull_request) Successful in 33s
review-verdict/h10 Awaiting review verdict for bafb487
PR Gates / Script tests (pytest) (pull_request) Successful in 59s
Review verdict / Set review-verdict status (pull_request_target) Successful in 34s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m30s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m36s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cross-family adversarial review returned BLOCKED with 3 Blockers + 2 Highs. Two were verified independently before acting on them. BLOCKER 1 (pre-existing on main, not introduced here). `producer | grep -q…` inverts under `set -o pipefail`: grep exits at its first match, the producer takes SIGPIPE and exits 141 once the list exceeds the pipe buffer, so a MATCH is reported as a FAILED pipeline. Reproduced at 1901 paths / ~171KB, inside the enumerator's 2000-file cap: * `A.cs` + 1900 docs paths -> docs_only=yes, status 141 (a C# PR exempted) * `.gitea/…` + 1900 docs -> the PROTECTED guard MISSES entirely The second reaches a green required status on a PR rewriting the gate, with no retarget, no bot account and no forged status. This construct shipped with the original docs-only check (review-verdict.yml:257 on main), so it was live. Fixed in both the enforced gate and the advisory hook by using here-strings, which have no writer to signal. New record `ci.grep-q-pipefail-inversion`. BLOCKER 2 (mine). `web/package.json` / `web/package-lock.json` are removed from BOT_MANIFESTS. renovate.json enables only nuget/github-actions/dockerfile, so npm is unmanaged here and the entry bought nothing — while package.json `scripts` are EXECUTED by CI (npm ci, npm run build). It widened an exemption onto a code-execution path for no benefit. BLOCKER 3 + HIGH (documentation was wrong, code unchanged). The claim that `edited` made the retarget residual "non-durable" is retracted: runs are not serialized, so a stale run can post `success` after the reclassifying run posts `pending`. The ABA transition is narrowed and observable, NOT closed. Likewise the provenance check asks "posted by a user credential", not "posted by a reviewer" — ETV_STATUS_AUTH is basic auth, so a #697 forgery gets a non-null creator AND an attacker-chosen description and is preserved as human. Both now stated at full strength. TESTS. 4 large-input cases crossing the pipe buffer, each paired with a large-input POSITIVE control so "large lists now fail closed" (a deadlock) cannot pass as a fix. Verified by mutation: reverting the here-strings turns all three negatives red while the control stays green. Two of my own weak tests fixed — the "base advances" case called head_moves_to(SHA) with the already-current sha (a duplicate positive control, now a structural assertion that the comparator is .base.ref and never .base.sha), and the arity test counted five arguments without checking the fifth was the base. The whole class was invisible because every previous test used a handful of short paths: a guard whose behaviour depends on a buffer threshold needs a test that crosses it. Refs: #698 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f523fc535d |
fix(698): bind the base, constrain the bot exemption by content, re-derive unattributable successes
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 28s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m8s
review-verdict/h10 Review-verdict: BLOCKED @ f523fc5 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 23m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The `review-verdict/h10` exemption path decided from mutable or unattributed PR state, and a machine-written `success` was never revalidated. Three routes, one root cause, so one change. Route 1 (reproduced live as probe PR #703, closed unmerged): `/pulls/{n}/files` diffs against the PR's LIVE base, so retargeting moves the answer without moving the head sha. A PR opened into `main` and retargeted mid-run enumerated docs-only and was granted `h10=success` while its diff against `main` carried a C# file; retargeting back reclassified nothing. `scripts/pr-changed-files.sh` now takes the expected base branch as a REQUIRED 5th argument (optional would be a silent opt-out) and checks it before and after paging; the workflow passes it from the `pull_request_target` payload, which a retarget cannot rewrite, and `edited` is in `types:` so a retarget reclassifies. A pinned two-sha diff would close route 1 outright but Gitea 1.25.4 cannot serve one: `compare/{base}...{head}` returns no `files`, and a `--depth=1` fetch of the two shas has no merge base. Measured, not assumed. The residual window is stated in the code and the record rather than papered over. Route 2: `pull_request.user.login` is the PR's immutable CREATOR while its head is not, so pushing code onto an open Renovate branch kept the exemption. The bot exemption now also requires EVERY path to be a dependency manifest — a set measured across all 11 Renovate PRs this repo has had, not guessed. Route 3: the never-overwrite short-circuit exited on ANY `success`, so a forgery obtained once was inherited forever. It now fires only for a status positively identified as a human verdict (non-null `.creator.login` AND a `Review-verdict:` description — measured: user-posted statuses carry a creator, Actions-posted ones carry null). Written in the positive direction so an unrecognised shape is re-derived rather than trusted. The two exemptions are composed, not chained: as an `elif` chain a Renovate docs-only PR lost the docs-only exemption. Caught before commit and pinned by a test. Tests: 17 new cases in scripts/tests/test_pr_changed_files.py, each verified by mutating the clause it covers (8 mutations, 8 kills). Both records trimmed under the 60-line prose ceiling so the corpus tail-boundary check stays calibrated. Does NOT close the class: anyone who can POST a status directly can still impersonate a verdict — that is #697, deliberately left open. Refs: #698 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
20b7171fba |
fix(672): review round 2 -- correct a stale rule: field, document the self-test gap
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Script tests (pytest) (pull_request) Successful in 52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the fix commits returned MERGEABLE (nine trigger mutations all caught, every prior finding verified against independent sources) with four low-severity findings. All four are addressed here. F1: `release.verdict-status-check`'s `rule:` frontmatter still said "A `pull_request` workflow auto-passes the two exempt classes". Round 1 past-tensed that record's BODY and left its `rule:` stale -- which is the exact failure mode the previous commit cites as the reason to put limitations in `rule:` in the first place. The catalog row mirrors this field verbatim and it mirrors again per-`key:` into MemPalace, so a stale `rule:` propagates further than a stale paragraph. F2: same record, "is what makes the rollout self-hosting" -> past tense. It described #630 and now reads as a live property. F3, the one that matters operationally: base resolution cuts BOTH ways. A change to `review-verdict.yml` is no longer exercised by its own PR -- the PR runs the version already on `main` -- so an edit goes live only ON MERGE, repo-wide, having never run. A broken edit merges green and then breaks the gate for every subsequent PR, and the PR that would repair it is gated by the same broken workflow. The recipe for verifying one safely (scratch base + throwaway PR + probe-named context) now lives in docs/ci-cd.md, which is where an operator looks, rather than in the record. F4: the sibling-workflow guard globbed `*.yml`, so a workflow added as `.yaml` would be silently unscanned. Latent today, which is when it is cheap. The record lost its meta-justification paragraph to the 60-line prose ceiling. Fifth trim this session; the operational recipe moving to ci-cd.md is better placement anyway, but it was forced rather than chosen. ersatztv#688. Refs: #672 Decisions-Edit: yes |
||
|
|
b2a5c72bfe |
docs(672): widen the residual to the real inventory (#697, #698)
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Script tests (pytest) (pull_request) Successful in 43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 24m4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cross-family review established the residual is materially larger than the previous commit said, and that saying "docker-build.yml / ETV_STATUS_AUTH" understates it. Gitea injects `GITEA_TOKEN` into EVERY job automatically, defaulting to read/write. So the set of workflows that can POST `review-verdict/h10` is not a short list to audit -- it is all of them, plus `workflow_dispatch` (1.24+ loads the definition from the selected branch) and `push`-triggered ones. A collaborator's own write-scoped API token is a route with no workflow at all, because branch protection binds the required CONTEXT, not its issuer. Recorded in #697. The same review found three defects in the exemption path itself, none of them introduced here and none closed here: a retarget race that enumerates a docs-only diff against a scratch base while the enumerator revalidates only head.sha, a Renovate-PR hijack (the exemption reads the immutable PR creator, not who pushed the head), and an inherited `success` that short-circuits before any PR/base/author/file check. Filed together as #698, since they share one root cause -- the gate trusts state it cannot attribute. Also drops the claim that the three properties are "pinned by tests". The tests pin the workflow's SHAPE; no in-repository test can establish status-authority isolation, and the sibling-workflow guard added in the previous commit catches only a workflow that names the context in plain text. Trimmed to the 60-line prose ceiling for the third time in this session. That is ersatztv#688 -- the ceiling is now deleting rationale two reviewers asked for. Refs: #672 Refs: #697 Refs: #698 Decisions-Edit: yes |
||
|
|
35a8ea8aef |
fix(672): review round 1 -- pin the trigger set exactly, sweep the stale claims
Cold review found the first cut of the test satisfiable by a still-vulnerable
config, and two prose claims that outran the evidence.
The test asserted "pull_request_target present, pull_request absent". Adding
`workflow_dispatch:` or `push:` ALONGSIDE it kept that green, and both are
ref-resolved with secrets, so either one restores an equivalent
self-supplied-definition path. Enumerating those two would have the same hole one
trigger later, so the assertion now pins the whole set: exactly
{pull_request_target}, nothing else. Verified by mutation -- adding
`workflow_dispatch` now reds.
Adds the guard that would have caught the residual below rather than only the
instance: no workflow OTHER than review-verdict.yml may reference
`review-verdict/h10` in executable lines. Scoped honestly in its docstring as a
drift guard, not a security boundary -- a workflow can still write the status
through an indirection a text scan cannot see.
`release.verdict-status-check` item 4 still asserted, in the present tense, that
a PR editing review-verdict.yml is judged by its own edited copy. That is now
false for this workflow, and it is the record a reader resolving the gate from
the catalog actually lands on. Past-tensed, with the surviving residual named.
The probe count said three. There were four; the omitted one is the only one with
a negative result, which is what turns an honest partial into an overclaim.
Corrected in both the record and ci-cd.md, along with what was NOT measured
(`reopened`/`ready_for_review` firing under the new trigger).
Also records two operational consequences a maintainer will otherwise hit cold:
retargeting a PR onto `main` leaves it statusless until its next push (`edited`
is not in `types:`), and the required contexts carry a literal `(pull_request)`
suffix, so repeating this fix on docker-build.yml would rename them and deadlock
merges unless branch protection is edited in the same operation.
Trimmed the record back under the 60-line prose ceiling -- for the second time
this session, which is ersatztv#688 reproducing, not a defect here.
Refs: #672
Refs: #697
Decisions-Edit: yes
|
||
|
|
8b73234d78 |
docs(672): record that the fix closes the route, not the class (#697)
Probing rather than reasoning turned up a second instance of the same vulnerability class while this fix was in review. `docker-build.yml` also triggers on `pull_request`, so it is head-resolved too, and it carries `ETV_STATUS_AUTH` (`REGISTRY_USER:REGISTRY_PASSWORD`) for the #420 revalidation read. Basic auth is not scoped: an account that can read commit statuses can write them. Confirmed with a scratch PR that POSTed a probe-named context using those credentials and succeeded — so a PR rewriting `docker-build.yml` can still post `review-verdict/h10=success` for its own head. That workflow cannot take the same fix. It builds and tests the PR's code, so it must resolve from the head; `pull_request_target` there would be the real footgun. It needs a read-only status identity instead. Filed as #697. The `rule:` field carries the limitation, not just the body, because the predecessor record's documented failure was exactly a reassuring sentence in the position a catalog reader stops at. Also trims the record to the 60-line prose ceiling. Adding it at 62 lines pushed p90 past the ceiling and reddened the blocking `script-tests` job — which is ersatztv#688 reproducing live, not a defect in this change. Refs: #672 Refs: #697 Decisions-Edit: yes |
||
|
|
cf907f0988 |
fix(672): trigger the verdict gate on pull_request_target scoped to main
`review-verdict.yml` writes `review-verdict/h10`, the status branch protection requires on `main`. It triggered on `pull_request`, and Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit — so a PR editing this file ran its own rewritten copy and could post `h10=success` for itself. `PROTECTED` did not help (the rewrite defines it too), and neither did branch protection, which requires the context rather than an author and carries `required_approvals: 0`. The #649 base-ref checkout binds the SCRIPTS this job runs to the base. It cannot bind the job definition. Only the trigger can. Measured on this instance (Gitea 1.25.4) with three throwaway scratch PRs rather than inferred from GitHub, since the premise is a claim about someone else's software: A. A PR rewriting the workflow posted its own probe context and the real `review-verdict/h10` was never written — the base definition never ran. B. Under `pull_request_target` the identical rewrite was ignored: the BASE definition ran and posted `h10=pending`, on `opened` and `synchronize` alike, with `secrets` still available. C. With `branches: [main]`, a PR into a non-main base produced no run and no status at all. The probes only ever posted probe-named contexts, never a forged `h10`. `branches: [main]` is half the fix, not a refinement: base resolution means the BASE branch supplies the definition, so without it the rewrite simply moves to an attacker-pushed base — and a status forged there is inherited by any later real PR with the same head sha (#663). `pull_request_target` is safe here only because this job never checks out or executes head-supplied code; the base-ref checkout is what makes the trigger usable, so the two are one decision. Rejected `required_approvals: 1` as the cheaper fix: Gitea forbids approving your own PR and this is effectively a single-maintainer repo, so it would deadlock every PR rather than gate the dangerous ones. Three mutations confirm the new test discriminates rather than merely passing: reverting to `pull_request`, dropping the `branches` filter, and re-adding `pull_request` alongside the safe trigger each go red with a distinct message. It parses the YAML instead of substring-matching because `pull_request` is a prefix of `pull_request_target`. Refs: #672 Decisions-Edit: yes |
||
|
|
8de02d5bde |
Merge pull request 'fix(649): point the ENFORCED review-verdict gate at the shared PR-file enumeration' (#666) from fix/649-enforced-verdict-guard into main
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 / EF migration integrity (SQLite + MySql) (push) Successful in 16m56s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 17m36s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 22m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 14m40s
Renovate / Renovate (push) Successful in 5m16s
|