868ab657d8ecd2e0874c91b502f4e2b7aa1411cb
163
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
786d1c3c4b |
docs(885): an unmeasured failure TIME, replaced by the structural fact it stood for
`scripts/ci-toolchain-image-resolves.sh` and `docs/ci-cd.md` both stated that the #772 container jobs "died after 1-2s", and the header used the same number to argue the preflight needs no `needs:` gate. Nobody measured it, and it cannot be measured from a working session without reproducing a deleted-tag incident. What the number stood for is structural and IS known: a container job that cannot pull its image fails AT the pull, before it runs a step, so it wastes no work waiting to be told and the argument against serialising the five jobs survives intact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
e656583247 |
style(885): the tag-protection paragraph reads as unverified in its opening clause too
"unaffected for the release operator" asserted the outcome the rest of the paragraph then marks unverified. Both places now say what is intended and what is measured, and the blockquote is rewrapped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
af9c2349a7 |
fix(885): three claims this branch added, dated to what was actually measured
`review-verdict.yml`'s residual list said the injected `GITEA_TOKEN` on the `pull_request` route is BOUNDED by `docker-build.yml`'s workflow-level `permissions: code: read`. That block lives in the head-supplied file on exactly that route: a PR author deletes it, and with the owner-level Actions default at `permissive` that alone yields a write-capable token. It is NARROWED for the committed file, and it stays in the residual set the paragraph exists to enumerate — which is what `release.verdict-status-check` and `test_pr_changed_files.py` already say. The same reword lands in `ci.pr-route-carries-no-stored-credential`, where the allow-list reason is now the store the token is not in rather than a bound. The "dies at image pull in 1-2s" figure was never measured on this branch — the 1-2s in `ci-toolchain-image-resolves.sh`'s header is an observation from the #772 incident, not a property of this change. The loud/silent asymmetry is what carries the argument, so the claim is now that a container job dies at image pull before it runs a step, which is true by construction. `ci.actions-credential-scoping`'s reworded `mechanics:` said "all three are now confined to the `build` job". `build` declares no `container:` at all; the buildcache write and the base-image pull are what it confines, and the `container:` pull is credential-free everywhere. `docs/ci-cd.md` asserted the `renovate` bot can no longer push a `v*` tag while `release.tag-protection-v-star` records that as NOT VERIFIED. The rule is read back live and real; what is unmeasured is Gitea honouring it against an account only the operator can test. Both docs now say expected, unverified. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
23cef5c9d1 |
fix(885): two dependencies with opposite failure directions, and a token leg that got zero retries
The header comment and the record's `rule:` said the registry reads and the commit-status reads "both depend on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private fails those jobs loudly at image pull, never silently". Wrong in both clauses for the repo half, and this branch has already been sent back twice for exactly this shape of mechanism claim. Measured 2026-09-05: the `ersatztv-ci` package is linked to no repository (every version reports `"repository": null`), so the repo's visibility does not gate the anonymous pull token at all; and the only thing it does gate — the combined-status GET — fails in the opposite direction, because `ci-detect-already-validated.sh` answers a failed `curl -sf` with `emit false; exit 0`. That job stays GREEN and the #420 cross-run skip silently stops firing. So the two dependencies are now stated apart, each with its own failure direction, in `docker-build.yml`, in the preflight's header, in the record and in the `ci-cd.md` outcome table; the preflight's own 401/403 messages stop sending an operator to the repo's visibility when it is the package's. `token_leg_done` was set once per RUN, before the attempt, so a token endpoint that could not be reached failed the preflight with no retry while an identical blip on the manifest read got three. The stated reason — "a registry genuinely refusing anonymous reads is asked once rather than once per pin" — is a per-pin argument that never covered the per-attempt axis. It is now sorted by what the endpoint SAID rather than by which leg it happened on: an answer (no token in the body, a challenge naming no realm, no challenge at all) settles the question and is asked once per run; an endpoint that could not be reached, or that answered 5xx, settled nothing and is retried on the same `ETV_CI_ATTEMPTS` budget as the manifest read, because a red here denies a merge (the consent hook reads the COMBINED status, #598) and the two legs of one read must not have opposite flake tolerances. The token-leg message now reports the attempts it actually made. Driven against the SHIPPED predecessor rather than a hand-written mutant: the three new behavioural assertions are red on it (1 token call where 3 are required, and a blip shorter than the budget failing the run), while the two that pin the property the retry must not cost pass on both. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
8885fdd9d2 |
fix(885): the swept symbol's last live-voice site, and a caveat that is now a measurement
`ETV_STATUS_AUTH` is gone from `test`, `migrations` and `functional-e2e`, and three same-subject sites were reworded to match. `review-verdict.yml`'s comment was the fourth and still named the symbol as a live thing; it now names the credential by what it is, and says the PR route materialises none to refuse. `docs/remote-state-inventory.md`'s row for `ci-toolchain-image-resolves.sh` listed "an unusable credential" among the shapes that fail the job — that script holds no credential any more. The row names the three refused-anonymous-read shapes the shipped script actually has instead, and re-confirms the `UNSAFE-KNOWN` grade against the anonymous script: the tag it reads is mutable either way. That is #909's first half; its other half, `docs/guard-inventory.md`, stays with the session holding that file. `ci.pr-route-carries-no-stored-credential`'s `mechanics:` carried one self-declared unmeasured claim — whether act_runner's daemon performs the credential-free `container:` pull. Measured 2026-09-05 on the runner host 192.168.1.99, which runs both act_runner containers and creates every job container on its own docker socket: a `docker pull` of the pinned tag with a scratch docker config holding only `{}` exits 0. The two things that run did not exercise — an anonymous layer download, and act_runner's own pull call path — replace the open unknown rather than being dropped, and `docs/ci-cd.md` cites both measurements. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
ccc604aef7 |
fix(885): the never-asked message reports what the RUN did, not what the registry sent
"WITHOUT issuing a Bearer challenge" is a claim about the registry's response that this script never checks. `probe` enters the token leg on a `401` only, so a `403` carrying a perfectly good `Www-Authenticate` would be refused with that sentence having never looked at the header — the same defect one branch over, in the message written to fix it. It now says NO TOKEN WAS EVER REQUESTED, which is a fact about the run: the token leg was not entered, and this answer was never followed as a challenge. The assertion and the outcome-table row move with it, and the comment says why the weaker claim is the honest one. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
985a6e1505 |
fix(885): a challenge-less 401 lands in the TOKEN-LEG arm, not the never-asked one — the table and the comment said otherwise
The row added for the third refusal shape was written as "401 / 403 carrying NO `Www-Authenticate` challenge at all", and the script comment beside it made the same binding. Both are wrong for the 401 half: `probe` enters the token leg on a 401, so a challenge-less 401 DOES call `acquire_token`, which sets `token_leg_done=1` and abandons for want of a realm — it reports `could NOT OBTAIN an anonymous pull token`, the row above. Only a FIRST-READ 403 reaches the never-asked arm. The parametrised test already drives both codes and asserts exactly that split; the prose beside them did not match it. The three rows now bind one shape each: a refusal surviving a bearer the run really obtained, a 401 whose token leg yielded none (no challenge header, no realm, or no token in the answer), and a first-read 403 that asked for nothing. Prose between arms regenerates mis-bindings — which is why the arms are stated as one self-binding row apiece rather than as a category sentence covering two. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
2f661be684 |
fix(885): the outcome table gains the third refusal arm, and the collector stops keeping a second copy of its own walk
Two follow-ons from the fixes in this branch, both of the class the branch is about. `docs/ci-cd.md`'s preflight outcome table listed two token-leg rows and now needs three: a `401`/`403` carrying no `Www-Authenticate` at all never reaches the token leg, and the table is what an operator reads to decide where a red preflight sends them. The paragraph after it named "the two token-leg rows" and now says why the three are worded apart at all — a message naming a step the run skipped is evidence for a diagnosis nobody performed. `secret_name_counts` was added beside `secret_names` as a second traversal with a different accumulator. That is a copy of a mechanism, free to drift from the one the assertion runs on — the guard reproducing, inside itself, the defect it was just widened to catch. There is now ONE walk: the counting one, with `secret_names` derived from it, which is the lossless direction. Re-witnessed after the refactor — the workflow-scope hoist into the shipped `docker-build.yml` still reports 3 failed, the clean tree 17 passed. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
bbd919bfe3 |
fix(885): a refusal carrying NO challenge never ran the token leg, so it stops claiming one
`probe` enters `acquire_token` on a `401` only. A registry answering `403` on the first read — or a `401` with no `Www-Authenticate` — therefore leaves `token_leg_done=0` and `token=""`, the guard at the `401|403` arm is false, and the run fell through to the message that says the read was refused "even after a Bearer token was obtained". Probed 2026-09-05 with a curl shim answering `403` and dumping only `HTTP/1.1 403 Forbidden`: that message is printed, EXIT=1, and no token was ever requested. The fail direction was safe; the diagnosis was not. It sends an operator to package visibility on evidence that does not exist (`dont-narrate-mechanisms-you-didnt-measure`) — in a script whose whole design is that its refusal messages are worded apart on purpose. The arm now branches on what actually ran, `token` first so the never-asked case cannot borrow either other mechanism: * `token` non-empty -> refused after a GOOD bearer (an answer about the PACKAGE) * token leg attempted -> challenged but produced no token (about the TOKEN ENDPOINT) * neither -> refused with no challenge at all (about ACCESS) The pre-existing `403` test could not reach this: `CURL_SHIM` answered `401` + a challenge to every unauthenticated read regardless of the configured code, so the `403` parameter was only ever observable AFTER the token leg. The shim grew a challenge-less behaviour (`CHALLENGE=none`, `REFUSAL=403|401`) rather than the assertion being written against the old one, and both codes are driven because they take different paths — the challenge-less `401` still enters and abandons the token leg. Witnessed red on the predecessor script (2 failed) and green on the fix. `docs/ci-cd.md`'s "Cutting a release" runbook — the section an operator reads at cut time — gains the `v*` tag protection, the account it whitelists, the fact that its positive half is unverified, and the `DELETE .../tag_protections/1` unblock. The tag-protection note already in this file sits inside the `main`-direct-push discussion, which is not where a release cut is driven from, and `release.tag-protection-v-star` names its own failure mode as a cut that will not push. `ci.pr-route-carries-no-stored-credential` records that `docs/remote-state-inventory.md`'s row for the preflight still lists "an unusable credential" among the shapes that fail the job, which this issue deleted. That file is held by a concurrent change, so the one-clause edit is tracked as #909 rather than made here. Refs #885, #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
7355873c39 |
fix(885): a head-authored run may hold no stored secret, so the PR route reads the registry and the status API anonymously
`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that
run executes contributor-authored YAML and every `secrets.*` it names is materialised into it.
Six jobs held `REGISTRY_PASSWORD` that way — `toolchain-preflight`, `test`, `migrations`,
`functional-e2e`, `api-docs`, `format` — two of them branch-protection required contexts.
The read-only pull PAT the issue asked to cost first was REJECTED, and the measurement is the
reason: this registry already issues an anonymous pull token for `timothy/ersatztv-ci`
(`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200), that token reads the pinned
manifest and its config blob (200/200), and the combined-status GET answers 200 unauthenticated.
A read-only PAT would grant exactly what anonymity grants while adding one more credential to the
store head-supplied YAML reaches. So the stronger form was implemented instead: no PR-route job
names a stored secret at all.
- `.gitea/workflows/docker-build.yml`: the five `container: credentials:` blocks, the
`ETV_REGISTRY_AUTH` step env and the three `ETV_STATUS_AUTH` step envs are gone. `build` keeps
the PAT; it is gated `if: github.event_name != 'pull_request'`.
- `scripts/ci-toolchain-image-resolves.sh`: reads `realm` out of the `Www-Authenticate` challenge,
exchanges it once per run for an anonymous pull token, retries with the bearer. Every refusal
direction is preserved — a 401/403 after the token leg, a token endpoint yielding no token, and
one that cannot be reached all `fail` rather than degrading to could-not-tell — and the message
now names the cause an operator can act on (the repo or package has stopped being public).
- `scripts/ci-detect-already-validated.sh`: the status GET is anonymous. No credential override is
kept: the URL names one instance, that instance is public, and an unusable `":"` would draw a 401
and turn a working read into a permanent skip=false.
- `scripts/tests/test_workflow_persist_credentials.py`: the invariant, derived from the git index by
"every job of a `pull_request`-triggered workflow that names a `secrets.*`" — never the six-name
list, and never "every `container:` job", which names five of six because `toolchain-preflight` is
container-free. Witnessed red against the unfixed workflow naming all six jobs; green after.
Live tag protection applied and read back: `POST /repos/timothy/ersatztv/tag_protections`
`{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}` -> id 1. A non-`v*` probe tag pushed
and deleted proves tag pushes still work at all. The POSITIVE release-cut verification is DEFERRED
to the operator's next real cut: pushing a `v*` tag publishes the `:prod` image, which is a release,
not a verification step.
What this does not close, stated so the records are not cited as a boundary: `REGISTRY_PASSWORD`
stays in the Actions store for `build`, and head YAML can still name it, `RENOVATE_TOKEN` or
`SERVERMGMT_DEPLOY_KEY`. Blast radius, not the route.
New records `ci.pr-route-carries-no-stored-credential` and `release.tag-protection-v-star`;
`ci.workflow-dispatch-ref-unrestricted`, `ci.actions-credential-scoping` and
`release.main-direct-push-disabled` updated to match; catalog regenerated. Closes #885.
Decisions-Edit: yes
Proves: scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
a7d91bf15a |
fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk |
||
|
|
fb55929711 |
fix(869,893): re-establish the 1.25.4-dated CI claims on 1.27.1, and settle the page_statuses asymmetry from source (#905)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
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 4m17s
Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/. Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named. Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution. #893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away. Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance. Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN. fixes #869 fixes #893 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Whe75djeAEuZpdNk6KU7No Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
dd0f75f1b6 |
fix(855): two glob dialects cannot be canonicalised into one, so model one shape and refuse the rest (#902)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m4s
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 4m16s
`ci-image.yml`'s `on.push.paths` decides which pushes to `main` publish a toolchain image; `ci-image-pin`'s `git log` pathspec decides what the pin must name. #744 removed the shared self-reference that kept them in step, leaving the agreement carried by three prose comments, and divergence is silent and green in the dangerous direction. The guard derives both lists from the workflow documents and compares them for set equality in both directions. The comparison is deliberately narrow: it accepts a publish entry spelled exactly `<dir>/**` against a pathspec entry spelled exactly `<dir>`, segments restricted to `[A-Za-z0-9._-]`, and raises on every other spelling rather than deciding what that spelling would have selected. That narrowness is the substance. Measured against Gitea 1.27.1's own in-tree compiler (`modules/actions/workflowpattern` -> `modules/glob.CompileWorkflow`) and real git: a bare `docker/ci` in `paths:` compiles to an anchored `^docker/ci` and selects none of the directory's contents while the git pathspec `docker/ci` selects all of them; `<file>/**` matches nothing while the pathspec `<file>` tracks the file; a leading `/` is literal to Gitea while git refuses it outright. A canonicaliser mapping the two dialects onto one string form was built twice and defeated twice, each repair surfacing another spelling, so it was deleted rather than extended per `testing.verification-code-needs-its-own-proof`. The guard also asserts from the git index that each named path really is a directory, since `<file>/**` and the pathspec `<file>` spell the same string; takes the pathspec from the `git log` assignment rather than any `git log` in the job; refuses a `<<` token on a code line (a herestring excluded) and a second bare `--`; and treats an absent and an empty `paths:` alike, because Gitea's `Skip` returns false on an empty sequence, so `paths: []` filters nothing and every push publishes. The docstring states the boundaries rather than implying coverage: the guard compares the pathspec the pin job writes and does not establish that the staleness comparison consumes it, and a descendant whose path below `<dir>` contains a newline is matched by the git pathspec but not by the publish pattern. Verified by nine independent cold-review rounds, none of which found a false green; the last fuzzed 27,720 publish/pathspec pairs against a port of the deployed compiler and real `git ls-files`. fixes #855 |
||
|
|
4c44a13e3d |
docs(release): record the v26.15.0 release notes
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 23s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 12s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m1s
Adds the `v26.15.0` row to the release table in `docs/ci-cd.md`. The tag goes on `736649b3b`, NOT on this commit and not on `main`'s head. Every `Build & push image (amd64)` since `e8f80c42c` fails: that commit added `web/src/api/completeAnnotations.guard.test.ts`, a third importer of `virtual:etv-tracked-source-files`, without adding it to the hand-maintained `--exclude` list in the Dockerfile's `web-build` stage — and that stage has no git index, by construction (#887, claimed and in progress elsewhere). The guard is behaving correctly; it refuses to fall back to a filesystem walk. Measured: run 2459 on `736649b3b` ran the image job for 6m45s and published; run 2515 on `cf5f42edf` died in web-build after 86s. `736649b3b` is therefore the newest commit on `main` that can produce a release image. Consequence recorded in the row itself: #880 (scheduling recurrence) slips to the next release, since it merged after the break. Release-boundary sweep (docs/ci-cd.md -> "Before cutting a release"): - `decisions_validate.py` -> OK; 0 legacy-unmigrated records remain - `build_decisions_catalog.py` -> no drift - record ceiling: 45/218 over 60 lines (fraction 0.21, inside the blocking 0.02-0.25 band). The validator notes the 60 has drifted below the tail boundary (p90=104, p95=142) and asks for re-derivation when convenient — a maintenance signal about the constant, not a blocker for this cut. |
||
|
|
0e40ac283b |
fix(870): an empty timeline page is not exhaustion — the walk reads to its cap (#896)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 5s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m56s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m2s
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 1m35s
`count_pr_mutations` treated an empty page past page 1 as proof it had reached the end of the PR timeline. Gitea does not mean that: `ListIssueCommentsAndTimeline` applies the LIMIT/OFFSET in `FindComments` at the DATABASE level and filters AFTERWARDS, dropping `CommentTypeCode` rows and inaccessible cross-references into a nil slice that serializes as bare `null`. A page of 50 inline review comments is byte-identical to a page past the end while later pages still hold events, and rows are ASCENDING, so the events a fence looks for are the furthest from page 1. Fifty comments, which a PR author can create on their own PR, truncated both walks at the same place: both counts agreed, the sha comparison agreed, and an ABA force-push yielded an exemption `success` over a diff no single head justified. The walk no longer infers the end from an empty page BEFORE its cap. Such a page is skipped; the loop reads every page to its 20-page cap and trusts the counts only when the LAST page came back empty. An empty FIRST page and any unreadable shape still end the walk untrusted. NARROWED, NOT CLOSED, and the docs say so in one unit: the page-20 terminator is still trusted for the same unprovable reason, so the defeat now costs a timeline of over 1000 rows rather than ~100, with the same 50-row filtered block pinned to offsets 950..999. Measured at Gitea 1.27.1, ruling out the cheaper fixes: `X-Total-Count` on this endpoint is the post-filter length of the PAGE, not a total (`?limit=1` returns 1 on a 14-row timeline), while `/activities/feeds` returns a true total; `limit` clamps to 50; the only query params are `since`, `before`, `page`, `limit`, so the paged and serialized sets cannot be made to agree. Also: each page bounded `--connect-timeout 5 --max-time 15` and retried once, mirroring `page_statuses`, because the walk went from ~2 requests to a fixed 20 and the third call site runs after the exemption `success` is posted. Costs stated rather than hidden — worst case 40 requests and 20 sleeps, wall-clock pessimum 620s per walk, and the suite roughly doubled (202s -> 474s). Seven tests, each mutation-witnessed red; three reproduce the defeat against the shipped predecessor. Two independent cold reviews plus a re-review of the fix: no Blocker or High in the code. Their real finding was prose claiming the hole was closed, and cost arithmetic wrong twice. One reviewer claim was refuted by execution. Fixes #870 Refs: #803, #706, #664, #751, #893 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
72cca9a280 |
fix(849): round 7 — the arms judged the wrong snapshot, and two comments that invited a bug
A fourth cold review of the tip. No Blockers, no High: it enumerated every POST site and
every exit and could not construct an input where this branch writes a `success` that
`origin/main` would not.
## The arms judged the wrong snapshot
`mark_declined_row_if_any`'s three refusals all read `$pre_*` — the FIRST read — while the
POST replaces whatever row is CURRENT. So a reviewer's verdict arriving between the two
reads slipped past every refusal written to protect it: the base mismatch clears
`ex_attributable` so the mid-run abstain declines, `pre_creator` is empty so the allow-list
loop declines, and the arm marks a row nobody evaluated. Executed trace, control and case.
Both snapshots are consulted now, and either one vetoes.
Recovery was not free, which is why it mattered: the next run's reconciliation counts that
`Review-verdict:` row as buried and upgrades to the human-only sentinel — exactly the cost
the refusal exists to avoid.
The arm also marked this job's OWN ordinary machine `pending`. Every PR past its first run
carries one, so "kept off the commonest path in this job" was true only of a head with no
status at all. Scoped on the DESCRIPTION rather than on `creator: null`, which would also
exclude a machine `success` from another workflow — the row this marking exists for.
## Two comments that invited a bug
- One still described the round-4 REGRESSION as the intended behaviour ("a malformed row
reads as no creator, hence re-derived"), two lines below the block recording that it was
fixed. Adjacent comments giving contradictory accounts of one line, and the stale one
licenses reinstating it.
- The fault token's justification said "no Gitea status field contains a NUL". The token is
SOH (0x01). That is not pedantry: `$'\000…'` is the EMPTY STRING in bash, so an editor
correcting the code to match the comment would make every legitimately-absent field
compare equal to the token and send every clean head down the fail-closed route — the gate
would stall every PR.
## Docs
The record quoted a predicate that no longer exists (`[ "$ex_desc" != "$pre_desc" ]`, now
`$row_replaced`); `docs/ci-cd.md` stated the reconciliation witness unconditionally when the
code degrades to a description match where the server omits `id`; one of the six unproven
clauses carried a wrong `because` (the conclusion holds via `(.id | numbers) // -1` over a
validated array, not via the schema-fault route, which governs a different endpoint's row);
and the record's own counts read as a contradiction cold — 20 surviving MUTANTS collapse
onto 6 distinct CLAUSES, several clauses admitting more than one disarming edit. The
run-by-run provenance moved to the issue, where `docs.no-session-narrative` says it belongs.
Two existing mutation proofs lost their binding to the reworded clauses and failed loudly
rather than measuring the unmutated body, which is what that count assertion is for. Rebound.
refs #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
28e82fcb59 |
fix(849): round 4 — a regression round 3 introduced, and the clauses it left unproven
A third cold review, which ran the mutants itself, found one measured direction regression against `origin/main`, one ordering inversion, and four clauses this branch claims as fixes that survived mutation of their own text. ## The regression Round 3 type-tested the four consumed fields of the existing `h10` row and resolved a failure to `""`. For `.creator` that means "no creator" — unattributable — which is a LICENCE TO RE-DERIVE. Measured, same fixture, both bodies: a head carrying `h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and posted NOTHING on `main`, which died on `.creator.login` before any write. Fail-closed became fail-open. The rationale that produced it came from #763, whose site is the POST-WRITE filter: there, dying leaves a green already published, so dropping the row is the safe direction. Here the alternative is dying BEFORE any write. The deferral rationale did not transfer — which is the shape this repo has a record for. A wrong TYPE is now distinguished from a legitimately ABSENT value: `null` is the machine creator, an unset description and every field of the `{}` no-verdict row; anything else is unknown state and takes the route an unreadable ELEMENT already took. ## The ordering inversion `mark_declined_row_if_any` was scoped to "the head carries any row", so it fired on a head carrying `$REPAIR_DESC` and replaced the human-only marker with the machine-clearable one — inverting the ordering the SAME commit added a floor to protect at the repair site. One mechanism, three writers, and only two had the rule. It also buried a verdict an ALLOW-LISTED reviewer wrote for another base. "Declined" is decided against this event's `$BASE_REF`, so such a row is still the right answer for the base it names and the successor run for that base short-circuits on it; burying it costs a manual re-post on an ordinary retarget-onto-the-reviewed-base flow. Membership is tested on the raw creator, not on `ex_human`, which the base check has already cleared — the question is who wrote the row, not whether it governs this diff. ## The unproven clauses Four claims survived mutation, including the headline one. The witness fixture had been designed AROUND its own discriminator — its comment said a seed with an unrelated id "would make this run carry the sentinel forward … and the guard under test would never be reached", which is a description of the test not reaching it. Eleven proofs added, covering the witness-by-id, the head arm's own call site (two callers of one helper, one fixture), the mark helper's result propagation, and the round-4 behaviour above. `raced_why`'s human value is a named constant now: it is the one such value that is also a PREDICATE, compared twice, and a drift in either copy silently downgrades the human `::error::` — the only message that tells a reviewer their verdict was buried. ## Docs The renamed sentinel literal in two places; three documents still asserting the fence "writes NOTHING"; the record's `mechanics:` still describing round 2's witness; the replacement-site list, which had grown by four; a residual pointing "below" at something above it; and `CLAUDE.md`'s "closed", which is stronger than the record it points at — that record lists six residuals including both endpoints failing at once. The proof inventory is stated as an invariant (every clause with a predecessor is mutated back to it) rather than a count that rots. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
168fe21088 |
fix(849): round 3 — replace every unknown state, and prove the clauses that claim to
Two more cold reviews — cross-family (Codex/GPT-5.6) and a cold Claude reviewer that ran
the mutants itself — converged on two separate things: a remaining class of paths that
still left an unknown state standing, and, more importantly, that several clauses this
branch claimed as fixes SURVIVED mutation of the exact text they name.
## Behaviour
1. The reconciliation witness matches the CURRENT row's `id`, not merely a row with the
sentinel's description. Description alone is satisfied by an OLDER identical sentinel —
which is what a fixed point produces — so a read carrying only the earlier row cleared
the sentinel while the verdict buried under the current one ended up below the fresh
mark. Falls back to the description where the server omits `id`.
2. The two OBSERVED-mutation arms mark a head that carries a row this run declined, instead
of only abstaining. They are still right not to post their CLASSIFICATION — computed
against a base or head the PR may no longer have — but a declined row must not stay
authoritative for the whole window until a successor finishes, and for a PR's FIRST push
no successor is queued at all. Scoped to `pre_state` being non-empty, so the common path
stays quiet.
3. `replace_unknown_state` RETURNS a status. Its first version ended the failure arm with a
successful `echo`, so it reported 0 after both POSTs failed and the fence caller's
`exit 0` reported an abstention that had not happened.
4. An `id` difference counts only when BOTH reads supplied one. A response that omits `id`
beside one that includes it otherwise reads as a replacement, and this guard's reaction
is to abstain — over a row the classification had already declined.
5. Every element and every consumed field of the combined response is type-checked before
extraction, and a schema failure routes to the replacement. `.statuses` being an array
was checked; its ELEMENTS were not, so one scalar made `select(.context == $c)`
hard-error and `set -e` took the step down before any path could mark the head.
6. The path-predicate failure replaces rather than merely exiting, for the same reason.
7. `$UNVERIFIED_DESC` says "Status write", not "Exemption write". It is now written on paths
that grant no exemption at all, and it is the operator-facing text of a required check.
8. The no-op-repair skip keeps the human `::error::`. Skipping the WRITE is right — the head
already carries the strongest marker — but that message is the only place a reviewer is
told their verdict was buried. `raced_why` is a sentence now, not the token `human`.
## Proof
The cold reviewer measured three of the six round-2 claims surviving mutation of their own
clause, one against the verbatim predecessor from the previous commit. Nine proofs added:
the no-mark downgrade's SCOPE (not just the description it writes), the page-2 refusals, the
untrusted-fence write, the row-`id` comparison, the repair floor, the no-op skip, both `$own`
exclusions, the write-result return, and the both-ids-present rule.
Two of those needed the test double to grow: the combined-status stub emitted no `id` at
all, so the `ex_id` clause had never once run with a non-empty value; and POSTs always
succeeded, so both write helpers' failure arms were unreachable.
The `$own` exclusions and the no-op skip are OUTCOME-redundant — mutating either alone leaves
the post sequence unchanged, which is how duplicate guards hide each other. Their proofs
assert the LOG, because what the exclusions alone decide is whether the job reports a race
against its own row. One clause is left deliberately unproven and named as such in the record
and the guard inventory rather than counted: the path-predicate failure branch has no fixture
that can reach it.
## Also
Round 2 left two comment paragraphs duplicated verbatim and a block header narrower than its
block; both fixed. Stale prose corrected in the workflow ("dies WITHOUT posting", "post-write
verification never runs for it", "this block only runs after a `success`"), `docs/ci-cd.md`
("the fence never re-counts", "the history is read twice" — it is three now),
`ci.exemption-provenance` and `docs/guard-inventory.md`.
refs #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
957a328f33 |
fix(849): round 2 — the uncertainty paths that still resolved toward success
Two independent cold reviews (Codex/GPT-5.6 cross-family, and a cold Claude reviewer in
its own worktree) converged on the same class: paths where "this job cannot establish
what is on the head" still resolved by leaving the head alone, which protects a real
verdict and leaves a forged one.
Behaviour:
1. The four page-2 completeness refusals now replace the unknown state too. They were
excluded on the reasoning that the probe fires when NO row for this context was on page
1, so there is no green of any provenance to leave standing — self-contradictory, since
the only reason page 2 is read is that the row may be beyond page 1, which the probe's
own message says. Accepted cost, stated in the record: a head with more CONTEXTS than
the 50-row cap stalls every run; measured 2026-08-29, this repo puts 8 on a `main` head,
and that case already stalled with an ABSENT check.
2. The no-mark downgrade covers every re-derivable write, not only `success`. Restricting
it analysed the wrong PR: the damaging case is one that IS exemptible and got the
generic `pending` only from a transient enumeration failure. That description carries no
marker, nothing verifies it without a mark, and the next run re-derives it into the
exemption with the human row below its own mark — route 2's damage through route 1's
condition. `$REPAIR_DESC` stays exempt, being stronger and not re-derivable.
3. The fence branch that cannot trust its retarget count while holding a derived `success`
writes the sentinel instead of abstaining. It is reached only after the classification
DECLINED to inherit the row the head carries, so posting nothing left that row current;
the message said the context "stays absent", true only of a head that had none.
4. Reconciliation needs a WITNESS: it may clear only over a complete history containing the
sentinel's own row. `ex_unverified` means the combined endpoint just returned that row
and `/statuses/{sha}` keeps one per POST, so a complete-but-empty history contradicts a
write that demonstrably happened — and `page_statuses` accepts an empty page 1 as
complete, which is what made it reachable. Both reviewers reproduced the clear-then-exempt
outcome. The shipped positive test used exactly that impossible fixture, so it was
pinning the defect; it now seeds the sentinel row, and an impossible-empty negative plus
a witness mutation proof were added.
5. The mid-run "did this row change" comparison now includes the row ID. The two sentinels
are byte-identical by design, so a mid-run replacement of one by another was invisible to
a state/creator/description triple. Measured 2026-08-29 (Gitea 1.27.1, head
|
||
|
|
e30702111f |
fix(849): verify every write, and mark a head nothing could verify
The gate's post-write verification had five routes that all ended the same way — an exemption `success`, or a generic `pending` a later run turns into one, standing over a human `failure`. Two of these were attempted inside #742 and withdrawn, and the withdrawal is what shaped this change. That attempt withheld the exemption by writing a GENERIC `pending`, which is exactly what a later run re-derives into `success` — it moved which run posted the forged green rather than stopping it — and it had no retry path, because this workflow triggers only on `pull_request_target` types, so a transient failure on a PR's last event stalled an exempt PR until a human nudged it. The fix therefore needs two properties at once: sticky, so a later run cannot re-derive it, and reconcilable, so a blip does not cost a head its exemption permanently. Neither the repair sentinel nor a generic `pending` has both, which is why there is now a second sentinel rather than a reuse of the first. What changed: 1. No high-water mark => the exemption is WITHHELD before the POST and the head is marked with the new `UNVERIFIED_DESC` sentinel. Withholding before the write rather than posting and repairing matters because the defect is known in advance: publishing a green to take it back opens a window branch protection, and an already-scheduled auto-merge, can see. 2. Post-write verification runs after EVERY write, not only `success`. A generic `pending` masks a rejection landing in its own write window just as well, and carries no marker, so the next run re-derives it with the human's row now below THAT run's mark. 3. `.description` is type-tested before `startswith`. `(.description // "")` does not replace a NUMBER, so `startswith` hard-errors on one, killing the whole count — the genuine verdict beside the malformed row is lost with it. 4. The retarget count is re-taken AFTER the POST on the exemption path, closing the PERMANENT forged green `ci.verdict-write-retarget-fence` listed as its residual 1. The retarget axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. 5. An unreadable combined-status read retries once and then REPLACES the unknown state instead of declining to write. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and the job then went red on a status branch protection does not read. One defect this introduced and fixed on the way: widening the post-write gate to every write made the job match its OWN row, because the machine-sentinel arm selects on a null creator. A run taking the carry-forward path POSTed `$REPAIR_DESC`, then found "a sentinel above the mark", then repaired to the identical description. `--arg own "$desc"` excludes it, by description rather than by id — the id of the row just written is not knowable there. Reconciliation is what bounds the stall: a later run pages `/statuses/{sha}` in full and either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it upgrades to the repair sentinel, clearable only by a human — or finds none and clears it. It is sound because the two endpoints disagree: a masked verdict is invisible on the combined endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. Tests: each fix is paired with a `test_MUTATION_…` proof that restores the exact predecessor text through a new `_run_classify(mutate=…)` knob, whose count assertion is the binding — a clause that has since moved substitutes zero times and fails loudly rather than measuring the unmutated body. Two CHAINED tests feed run N's real output into run N+1, because both sentinels are fixed points and a single hop cannot assert a fixed point: the raced-`pending` repair must survive the run that would otherwise grant the exemption, and the unverified sentinel must not decay while it cannot be reconciled. Docs: new record `ci.verdict-unverified-write-sentinel`; the now-false guarantee prose in `ci.verdict-write-retarget-fence` (its `rule:` frontmatter, the "resolves it" opener, "the fence above closes", the truncating-block claim and residual 1), `ci.exemption-provenance`, `docs/ci-cd.md`, `docs/remote-state-inventory.md` and `CLAUDE.md` corrected by concept rather than by phrase, per the scope boundary recorded on the issue. fixes #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
5d955000f3 |
fix(845): the verdict writer checks that the gate will honour what it just posted (#889)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 31s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 15m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m46s
`review-verdict.yml` inherits an existing `review-verdict/h10=success` only from a status whose `.creator.login` is on its `H10_REVIEWERS` allow-list (#742). `post-review-verdict.sh` wrote those verdicts with whatever account owned the credential in the environment and never asked whose it was. Two coupled values, nothing asserting the coupling, and the failure was the silent kind: the status is written, the tool reports success, and the next `pull_request_target` event re-derives it and posts over it. The PR stalls with no visible cause. The writer now READS ITS OWN STATUS BACK, identifies that write by state and description, and refuses — before the verdict comment, so the surviving half-state is the documented `ask` one — unless the recorded creator is allow-listed. Measured after the write rather than probed before it: that tests what Gitea recorded as the author, which is the value the gate reads, and needs no scope beyond the repo access the POST already required. Membership is required for a `success` ONLY, mirroring the gate's own asymmetry: a `failure` is inherited from any attributable account, so requiring it there would refuse a verdict the gate honours and leave an off-list reviewer no supported way to record a rejection. The allow-list is DERIVED from the gate's own literal by the new `scripts/lib/h10-reviewers.sh` — one declaration, not two plus a parity test. It is a parse rather than a shared declaration both sides source because the gate runs against a checkout of the PR's BASE sha: a PR whose base predates such a file would not have it, and a missing `source` under `set -euo pipefail` kills the job, which posts no `review-verdict/h10` at all and blocks every merge including its own repair (#743). `scripts/post-review-verdict.sh` moves BEHAVIOUR-ONLY -> MUTATION in the guard inventory, which the manifest's own note called "the most valuable upgrade on this list". The declared clause lives in the GATE: rewriting `H10_REVIEWERS` while the posting account stays fixed reddens the accept path only if the writer reads the list live AND the comparison gates the outcome. Two defects were caught by probing the live instance rather than re-reading the code. Reading `.state` instead of `.status` per row would have refused EVERY verdict — a repo-wide deadlock, shipped green, because the test shim replayed the POST payload as the read-back body and so agreed with the parser by construction. Then a `(.status // .state)` fallback added as defensiveness recreated #845 exactly: the writer would accept a shape the gate cannot read and report success. Nine independent cold review rounds, all worktree-isolated, one cross-family (GPT-5.6 via Codex). Round 8 caught the most important one: a `set -u` "correction" made mid-branch had inverted a TRUE statement in live merge-gate code, because the probe used a plain `$UNSET` while the validator uses `${#arr[@]}` — different shapes, different behaviour. Withdrawn wholesale; both libraries are byte-identical to `main` again. Verification: full `scripts/tests` suite green (1278 passed, 2 skipped); the declared mutation executes every run and reddens its named proof with the manifest's `expect` string; every clause disarmed individually and confirmed to redden its own named test; live probes against Gitea 1.27.1 for the row shape, the description round-trip, the paging order and the required-check list. Docs: `ci.exemption-provenance` records the coupling as asserted rather than as a tracked residual, plus `docs/ci-cd.md`, `CLAUDE.md`, `docs/guard-inventory.md`, `docs/remote-state-inventory.md`, `ci.script-tests-job` and the `script-tests` population comment in `pr-checks.yml`. Deferred: the refused-verdict residual (a non-inheritable status left standing with no comment) is the `ask` half-state `release.verdict-writes-status-before-comment` designates as safe; a second corrective write is the sticky-sentinel mechanism #849 is separately designing. fixes #845 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
1d50241833 |
docs(853): workflow_dispatch can't be ref-restricted at 1.27.1 — and restricting it would close nothing (#888)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m10s
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 21s
Probed every reachable surface rather than stopping at the endpoint #853 already knew 404s: the dispatch API takes `ref` as a required free-form string with no allow-list; protected environments do not exist at 1.27.1 (0 of 308 documented paths mention "environment", secrets are org/repo/user-scoped only); the loaded `app.ini` sets two `[actions]` keys; and the CLI's sole Actions subcommand is `generate-runner-token`. So option 3 is unavailable. Accepted on a different ground than the issue proposed. "Anyone with repository write can already do worse" is unfalsifiable and hides the cheaper route. The operative reason is that dispatch is not the cheapest path: `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches every secret in the store — six of its jobs hold `REGISTRY_PASSWORD` on that route and two are branch-protection required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one. Corrections to #853's own table, verified against the tree: `dependency-scan.yml` references no secrets at all; the "four workflows" count is right. Deliberately not applied: a `v*` tag protection (`tag_protections` is empty and 1.27.1 supports it) — protection-class config whose failure mode is a broken release cut, so it needs its own change and verification. Tracked with the `pull_request:` residual in #885. The web UI was not swept, and the record says so explicitly rather than claiming exhaustiveness — a Gitea Actions control can exist with no API surface at all. fixes #853 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
94a3d13495 |
fix(836): never pass --depth to a checkout that may already be complete (#884)
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 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 32s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 16m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m7s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 2m0s
`git fetch --depth=N` grafts a complete clone shallow. `scripts/ci-detect-docs-only.sh` applied a depth chosen for its three `fetch-depth: 2` consumers to `build`'s `fetch-depth: 0` checkout, so the `git describe --tags` in the next step found no reachable tag and a `|| echo v0.0.0` fallback turned that into a version: every `:latest` image shipped `InformationalVersion 0.0.0-<sha>` from 2026-07-17 (#416) until now. Both fetch sites now go through `fetch_ref`, which passes `--depth` only when the checkout is already shallow. `Compute version and tags` fails the job instead of defaulting, so no `:latest` is published rather than a mislabelled one; releases are unaffected because the tag path never calls `describe`. Ships a guard that drives the real script over real `file://` clones with a negative control, a declared clause mutation, and a decision record `ci.fetch-depth-never-grafts-a-complete-clone`. fixes #836 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
736649b3b7 |
fix(812): classify the narrative sites by who-benefits; keep the detector's reach (#882)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Skipped
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 4m52s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
90f96c14a5 |
fix(803,664): fence the HEAD alias on the PR timeline's pull_push count (#873)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m37s
A force-push H1 -> H2 -> H1 spanning `pr-changed-files.sh`'s paging leaves its final
`.head.sha` comparison equal while the middle pages came from H2, so a mixed file list
could produce a docs-only exemption `success` no single head ever justified. The base
alias had been fenced since #706 by a monotonic `change_target_branch` count; the head
axis had nothing, and three contracts asserted otherwise.
`count_retargets` becomes `count_pr_mutations`: one timeline walk, two tallies, one shared
trust flag, a separate fence arm and diagnostic per axis. The advisory hook re-reads
`.head.sha` at the same hoist and off the same response as the base re-read. All three
overclaiming contracts are corrected, plus four paraphrases the first sweep missed.
Measured, not assumed: Gitea 1.27.1 still serves no `files` on `compare/{base}...{head}`;
every push is a `pull_push` event and its count cannot alias; PR #761 really went
`8798a1d ->
|
||
|
|
4b3f6f6c90 |
fix(786,789): workflows declare their own per-job metadata; guard populations derive from it (#872)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m21s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m58s
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 4m8s
Closes #786 and #789, bundled because working either alone would build the artifact the other removes. Every job in all six tracked workflows declares `env.CI_JOB_ROLE` (guard/report-only/none); the `docker-build.yml` jobs also declare `env.CI_EXECUTION_CLASS` (toolchain/bare-runner). Both guard populations derive from those markers; the `TOOLCHAIN_JOBS`/`BARE_RUNNER_JOBS` literals are deleted. A missing or unrecognised marker is a hard failure in both checkers. #789's literal had a real justification — set equality between two DERIVED sets is blind to a member leaving both at once — so the marker is the anchor that replaces it, and the cost (proximity to the `container:` block) is paid by a THIRD derivation from each job's own steps, which is also the only check that sees the failure #789 filed: a .NET step moved into a bare-runner job, where no set changes. The residual is disclosed: drop the block, flip the marker AND hide the tool behind a script and all three go blind, bounded by the failure mode being a loud missing-binary crash. #786's guard jobs join a machine-checked population: a new `test_workflow_job_guards.py` asserts set equality both ways against a new "Workflow-job guards" table, and the four jobs with no dropped-step guard each carry a recorded decision. Two issue claims were refuted by measurement: #789's "editing docker-build.yml re-points the pin" (the pathspec is `docker/ci` only) and #786's job count (17, not 15). Four cold adversarial review rounds across two model families; rounds 1-3 BLOCKED, all findings fixed and each fix demonstrated by reproducing the reviewer's own test. The recurring defect class was prose drifting from code, including a mechanism claim in the decision record that execution refuted. All five mutation proofs redden when their shipped detector is disarmed. New decision record: `testing.workflow-declares-its-own-job-metadata`. Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
609fd852c2 |
fix(763): page both /statuses/{sha} reads to a validated terminator (#868)
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 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m10s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m9s
|
||
|
|
5fb9c8537a |
docs(747): re-verify the Gitea 1.25.4-pinned CI claims on 1.27.1, and measure the merge-gate semantics that were source-attested (#867)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m25s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m48s
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 4m10s
Population derived from `git ls-files`, not the issue's 9-key list (~21 claim sites). Re-confirmed unchanged on 1.27.1: the distinct `skipped` commit-status state; `compare` serving no `files`; no agent-side cancel route (REST route + swagger only); `branches: [main]` suppressing the run off a non-main base. Newly measured on four throwaway scratch bases, `main`'s rule never PATCHed: an absent required context blocks an ORDINARY merge without needing `block_admin_merge_override` (that field governs the FORCE path only), and `enable_bypass_allowlist` with an empty list is NOT a substitute for it. Trap recorded: the PR API reports `mergeable: true` while such a merge is refused. Left explicitly dated with reasons: push-supersession auto-cancel, `pull_request_target` overlap, `--depth=1` no-merge-base, and the scope-enum/`reqRepoWriter`/403 items. Not a corpus sweep, and `ci.actions-credential-scoping` now says so. `review-verdict.yml` untouched — #763 holds that file. Five adversarial review rounds (21/12/9/6/2). Caveat: all same-model-family; Codex was rate-limited. fixes #747 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
462118d98d |
fix(763): round 9 — assert the repair's reason positively, not by absence
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 21s
review-verdict/h10 Review-verdict: MERGEABLE @ 462118d (base: main)
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Failing after 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 1m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
A sixth cold review found everything in round 8 clean except one line, and it
is the rule this branch keeps rediscovering: the test pinned the new
`raced_why` only by asserting the ABSENCE of the borrowed wording. Measured —
replacing the string with `zzz` left the suite green while an operator would
get `::error::… — zzz.` beside a sticky sentinel. The sibling test 330 lines
away states the rule and follows it; this one did not.
Now asserted positively, with the em-dash and full stop discriminating the
`::error::` reason from the `::warning::` text that continues ", which cannot
be true". The `zzz` mutation reddens it.
Three nits from the same review, all verified by execution rather than reading:
- the earlier fixture's row was excluded by the strict `> $since` because the
mark became its OWN id, not because it sat below the mark.
- the predecessor comment said `main` "warned only on `null`". True of the two
EMPTY shapes being contrasted; an empty body and a non-array object warned
as well. Scoped.
- `docs/ci-cd.md` and the record described the `::error::` as a two-way split
(found vs unverifiable). Round 8's whole argument is that a complete read
returning an IMPOSSIBLE answer is a third case, not a variety of the second
— which is the operator-facing point, since it decides whether to go looking
for an API failure that never happened. Both now say three.
The review re-verified, by comment-stripped diff, that round 8 changed no
executable line beyond the `raced_why` string and the if/elif restructure, and
independently reproduced both inertness measurements and the `origin/main`
predecessor behaviour.
Verification: `scripts/tests` 1097 passed, 2 skipped; decisions_validate and
build_decisions_catalog --check exit 0.
refs #763
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e6f04cc739 |
fix(763): round 7 — reject an empty post-write history, and the fourth overclaim
A fourth cold review returned NOT-MERGEABLE on two Mediums. Both fixed, plus
its three Lows.
MEDIUM, and a defect this branch introduced. Tolerating a `null`/`[]` page 1 as
"complete, zero rows" is correct for the PRE-write caller — a head nothing has
posted to genuinely has no statuses — and impossible for the POST-write one,
which has just written a row to that sha. The body is well-formed, so nothing
retries it, and the walk reports success: `raced=0` concluded from a list that
cannot be real, on the one path whose failure direction is toward SUCCESS.
Worse than the code it replaced, which at least emitted a `::warning::` — a
logged fail-open had become an unlogged one. Reviewer measured both directions.
The post-write caller now rejects an empty result itself; the walk stays
caller-agnostic because the pre-write caller genuinely needs the empty answer.
This is NOT the withdrawn currency witness: that asked whether ANY row sat above
the mark, which an unrelated newer row satisfied while the rejection stayed
hidden, and it fired on schema-valid staleness. This asks only whether the list
is EMPTY — a state no unrelated row can produce and no ordering can disguise.
It carries neither defect. Proved by fixture; disarming it reddens the named
test, and the previously-uncovered `null`-at-page-1 clause is now covered too.
MEDIUM — the fourth overclaim of the same class, in the decision record body:
"Uncertainty must fail closed at both ends … Both repair now." The page-2 probe
was DELETED, not converted; it repairs nothing. It also contradicted the
record's own `rule:` ("the two directions are NOT symmetric") and the bullet
directly beneath it. Round 5 retracted this wording in `docs/ci-cd.md` only —
the sweep was by subject, not by the retracted words.
Also fixed:
- the record presented "an empty FIRST page is legitimate" as a property of
the walk; it is a property of the pre-write caller.
- `docs/ci-cd.md` called the numeric-only id comparisons a fix for mark
inflation; they are a TYPE guard, closing the string half. A corrupt but
genuinely numeric id still inflates the mark — not attacker-controllable,
since ids are server-assigned, and now stated rather than implied.
- `test_a_partial_mark_is_SAFE...`'s self-guard promised to detect that the
fallback ran; it keys on a warning emitted by a different condition, so
deleting the fallback left it green. Its sibling is what reddens; the
message now says what it actually pins.
- the order-faithful fixture appended the job's own POST after the reversal,
serving the NEWEST row on the OLDEST page — the opposite of DESC, in the one
fixture that exists to be ordering-faithful.
- "twice per walk" for the wasted sleep; it is once per walk, twice per run.
- a dead counter read in the DESC mode.
Rebased onto
|
||
|
|
89db6022d9 |
fix(763): round 6 — measure the partial-mark fallback under the REAL ordering
A fourth cold review (Opus, isolated worktree, tests/double/docs focus)
reported no correctness bugs in shipped behaviour but two coverage defects on
exactly the two things this change advertises. Both are closed.
The partial-mark fallback's safety is a claim ABOUT THE ORDERING — page 1 holds
the newest rows, so a walk that fails later still saw the true maximum. The
fixture pinning it served ASCENDING ids, i.e. the arrangement the design calls
unsafe, and passed anyway because the raced row's id sat above even the partial
mark. It could not distinguish safe from unsafe.
The stub now HONOURS the sort parameter: order-faithful modes serve DESC by
default and ASC when the request asks. The new fixture holds a PRE-EXISTING
base-mismatched verdict at id 7055 among 60 rows. Under DESC the salvaged mark
is 7059 and that row is below it — the exemption correctly stands. Under ASC
the mark would be 7049 and that untouched row tests as NEWER, a sticky repair
on a head nothing raced. So re-adding `sort=highestindex` now reddens by
BEHAVIOUR, not only by the structural assertion added in round 5. Measured:
re-adding it reds both tests.
Most modes stay ordering-blind on purpose and now say so: they test walk
COMPLETENESS, which is order-independent, and insertion order is what lets a
fixture place a row beyond page 1.
Also fixed:
- `null` is accepted as an empty page. An array-only gate is the exact shape
of #751 — `count_retargets` had one, the timeline really did return `null`
past the end, and the fence withheld EVERY exemption from the day it
shipped. The same narrowing here is worse, because this walk's failure is
the STICKY sentinel: every exempt PR would need a hand-posted verdict, per
head. Tolerating `null` cannot misread `[]`. Proved by fixture.
- the fail-closed comment said "past the 1000-row page cap"; the bound is 950,
as the walk's own comment and both docs already said.
- the docs claimed "only a read returning no rows at all abandons the mark".
False: a VALIDATED empty history yields a mark of 0 and is not abandoned —
that is the normal first run. What abandons it is a read that both FAILED
and returned nothing. Corrected in ci-cd.md and the record `rule:`.
- a comment pointed at the page-2 probe "a few lines further down"; it was
deleted, so the deixis pointed at nothing.
- the stub claimed its logical-read counter "is only reached on a SUCCESSFUL
page-1 serve" — measured false; it counts page-1 requests, retries included.
- five `(round N)` markers removed. A round number is session chronology and
does not parse for a reader who never saw it (`docs.no-session-narrative`);
an issue number does. The four that remain predate this change.
Verification: `scripts/tests` 1096 passed, 2 skipped. Thirteen executed
mutations across rounds 2-6. The reviewer independently re-ran the earlier
matrix and confirmed it, with one correction carried here: two of those
mutations redden MORE than their named test, so "each reddening exactly its
named test" was wrong — they redden at least it.
refs #763
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
53ee44cbc1 |
fix(763): round 5 — correct the overstated motivation, and fix the string-id twin
A third cold review (Opus, isolated worktree) returned NOT MERGEABLE with one High and three Medium. All are addressed. HIGH — the stated motivation was wrong, and self-contradictory once round 4 landed. Under the server default (`created_unix DESC`) page 1 holds the NEWEST rows and ids are monotonic with `created_at`, so page 1 already carried the true maximum id AND every row newer than the mark — the only rows the post-write check selects on. A single-page read therefore missed a raced verdict only if more than 50 rows were created INSIDE the write window, not merely on "a head with more than 50 rows", which the issue, the comments and the docs all asserted. Reviewer executed an order-faithful DESC stub: a page-1-only reader repairs identically to the full walk. What actually removed #761's stall is retiring #751's page-2 probe, not the paging. The walk still earns its place, for a reason now stated instead of the false one: it stops the gate's one fail-toward-SUCCESS path depending on an undocumented ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). That measurement was deleted in commit 1 and is restored, since round 4's safety argument rests on exactly it. MEDIUM/real defect — the string-id TWIN, live on `main` and one expression away from the fix already made: `select((.id? // 0) > $since)`. jq orders strings above every number, so a PRE-EXISTING row with `"id": "3"` reads as newer than any mark, is counted as having raced the write, and gets the sticky sentinel plus a false "was overwritten" on EVERY later run — a permanent per-sha stall no re-trigger clears. Now numeric-only, with a test. Also fixed: a non-empty history carrying no numeric id was collapsed to a mark of 0 (making every pre-existing row look newer); it is now reported unusable and the check is skipped. `sleep` no longer fires after the final attempt. Three unpinned clauses now have tests, each proved by an executed mutation: - the page cap is a refusal, not a terminator (1050-row fixture) - the `::error::` found-vs-unverifiable distinction (forcing `raced_why=human` reddened nothing before) - the walk requests no sort order — a structural guard on round 4's withdrawal, which nothing mechanical protected. It reads request LINES, not comments, since the withdrawal note names the parameter to explain it. Honest scoping, not new code: the test double is ordering-blind, so the paging tests prove WALK COMPLETENESS, not that a real raced verdict would otherwise be missed — under DESC it would not be. The stub comment and the docstrings now say so rather than implying the stronger claim. Docs: `ci-cd.md` and the record's `rule:` carry the corrected reachability, the DESC dependency of the partial-mark fallback, and both rejected alternatives stated as rejected alternatives rather than as draft chronology (`docs.no-session-narrative`). Verification: `scripts/tests` 1094 passed, 2 skipped; eleven executed mutations across rounds 2-5, each reddening exactly its named test; decisions_validate and build_decisions_catalog --check exit 0. refs #763 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1eb7b41fc1 |
fix(763): round 4 — withdraw the ASC sort, which inverted the partial-mark fallback
Round 3 added `sort=highestindex` to close a mid-walk-insert gap: under the server default (`created_unix DESC`) a row inserted while the walk is running lands at position 0, on a page already read, so the walk never sees it. That fix and the round-2 partial-mark fallback are incompatible. ASC puts the OLDEST rows on page 1, so an incomplete walk takes its high-water mark over the oldest rows — leaving every pre-existing row above the mark and read as "raced". That is a spurious STICKY repair on a head nothing raced, which is precisely the #761 failure this whole issue exists to remove. Under the default DESC the newest row is on page 1 by construction and ids are monotonic with `created_at` (measured), so a partial mark is at or very near the true maximum and "lower is safe" actually holds. Two defects from one mechanism again, so the mechanism goes rather than getting patched: the sort is withdrawn and the mid-walk-insert residual is ACCEPTED and documented. It is bounded — a row arriving after this job's POST is not one this job overwrote, and being newest it wins on the combined endpoint branch protection reads. Both the code comment and the docs record the withdrawal and the reason, so the next reader does not re-adopt it. Verification: `scripts/tests` 1090 passed, 2 skipped; the partial-mark mutation still reddens `test_a_PRE_WRITE_paging_failure_still_yields_a_usable_high_water_mark`; decisions_validate and build_decisions_catalog --check both exit 0. refs #763 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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
|