feat(rt): add ac-closure-check — refuse a merge whose Closes-target has an unfinished AC #915

Merged
bosun merged 4 commits from i/848-ac-closure-check into main 2026-08-26 15:10:24 +02:00
Owner

Refs frankenbit/release-toolkit#848

Deliberately not Closes — see "What's still open on #848" below.

Disposition chosen

MECHANICAL, as a refusing PR-time gate, not report-only. #848's own options list hedged that mechanical "likely reports rather than blocks... cannot refuse without wedging legitimate DEFERRED/RETIRED cases." That hedge doesn't hold: under the already-ratified four-state AC convention, every legitimate disposition (done, deferred, retired) ticks its box. An unticked - [ ] therefore has exactly one meaning — unfinished — so refusing on one wedges nothing real. Reasoning confirmed with Bosun before implementing (bus thread, size/M tracker with an open AC1).

What

  • internal/gates/ac_closure_check.goCloseTargets (same-repo Closes/Fixes/Resolves keyword extraction, POSITIONAL: matches even NOT Closes #<N>; matches nothing on a qualified owner/repo#N) and HasBareCheckbox (the four-state convention's unfinished-box detector). Pure functions, mutation-verified — 3 arms: a naive "clever" negation exclusion, a naive qualified-ref false-match, a naive retired/deferred false-match. All three reddened under mutation and are restored clean.
  • internal/forgejo/{interface,reads}.goGetPullRequest, GetIssue: direct single-object fetches the existing Client lacked.
  • cmd/rt/ac_closure_check.gort ac-closure-check --owner --repo --pr. Scoped to the PR's own repo — a close-keyword fires against the PR's repo, never a cross-repo target (/srv/CLAUDE.md's documented Forgejo behavior). Reads the PR body only, not commit messages — disclosed on every run, PASS or FAIL, per "every gate prints what it did NOT check". A fetch failure is could-not-grade (exit 2), never a silent pass.
  • .forgejo/workflows/reusable-ac-closure-check.yml + ac-closure-check.yml (toolkit-self dogfood wrapper) — mirrors register-check/fragment-check's wiring exactly (ref-resolution + shared bootstrap-rt artifact).
  • Registered in internal/bake/marker.go (canonicalFiles) and internal/gates/selfboot_check.go (DefaultComposeScripts) — both reverse-direction guards caught the omission on the first go test ./..., before I went looking for either.
  • docs/integration.md (self-contained adopter wiring recipe — deliberately does NOT reference crew-internal alcatraz-infra doctrine, since adopters have no access to it), cli-surface.md + arc42 05-building-block-view.md (inventory tables) updated to list the new verb.

Deliberately out of scope

  • Cross-repo rollout (wiring the reusable into alcatraz-infra's and tmux-tell's own consumer workflows) — this PR ships the release-toolkit-side mechanism those repos would uses:; the actual wiring is those repos' own trackers.
  • No live E2E self-test job, unlike register-check's self job. That job's live negative control works because register-check takes text via --stdin — a fixture assembled at runtime with no external state. This gate always resolves a real PR + real issue via the Forgejo API; a live self-test would mean fabricating throwaway issues/PRs as a side effect of every CI run. Relied on mutation-tested unit coverage of the matching logic instead — see the two *_test.go files.
  • No toolkit-self dogfood wrapper in this PR (.forgejo/workflows/ac-closure-check.yml) — see "Why the toolkit-self wrapper is deferred" below. reusable-ac-closure-check.yml still ships here; only the consumer-side wiring that would exercise it against this repo's own PRs is deferred.

Why the toolkit-self wrapper is deferred to a follow-up PR

An earlier revision of this PR included .forgejo/workflows/ac-closure-check.yml, wiring the new
reusable into this repo's own PR checks immediately (matching register-check/fragment-check's
pattern). It stayed red on every push to this PR, in ~0 seconds, with no job log ever written
distinct from every other gate's red, which takes real time and leaves a log.

Root cause: uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-ac-closure-check.yml@main
resolves @main literally — a real fetch of the main branch's copy of that file, not the
commit under test. reusable-ac-closure-check.yml is a brand-new file that exists only on this PR's
branch:

$ git show origin/main:.forgejo/workflows/reusable-ac-closure-check.yml
fatal: path '...' exists on disk, but not in 'origin/main'

So the call 404s before any job runs, and Forgejo synthesizes a fallback status (filename + commit
message standing in for the workflow's own name:/job name:, since it never successfully loaded
either) — which is exactly the "0s, wrong context shape" signature that made this hard to diagnose
from the status API alone. This is a structural bootstrap limitation for introducing a brand-new
toolkit-self reusable+consumer pair in one PR
, not a bug in the reusable or in rt ac-closure-check: the very first PR that adds both files together cannot pass its own new check,
because the thing it calls doesn't exist at the ref it calls until after this PR merges.
register-check/reusable-register-check.yml were introduced in the same commit
(0035f9b6, 2026-07-04) and would have hit the identical block — it evidently wasn't a required
status at introduction time, or was merged past.

Fix applied: removed ac-closure-check.yml from this PR. reusable-ac-closure-check.yml still
ships — external adopters wiring it against their own pinned release tag never hit this (the file
exists at that tag by the time anyone can reference it). The toolkit-self dogfood wrapper is a
one-line follow-up PR once this merges and main has the reusable.

What's still open on #848

  • #781 (the AC sweep this tracker exists to stop feeding) needs closing on its current population — that's Bosun's/Surveyor's action, not mine.
  • Re-checking scripts/ac-state-audit.py's Case-A/Case-B split against this disposition (AC3): while researching #848 I found the "live gap" Bosun originally described (retired signals flip to the un-struck DEFERRED shape) does not reproduce — retrofit_body, the real --apply path, correctly strikes retired text (verified by running it, not reading it; the fix landed 2026-08-20, commit cffca5a). What I found instead is narrower: write_dry_run/write_closed_unticked_dry_run (the /tmp/*.diff PREVIEW functions) build their preview line un-struck regardless of signal, so the dry-run preview can misrepresent what --apply will actually do. Out of scope for this PR (different repo — alcatraz-infra); flagging for its own tracker.

On not exempting fenced/backtick text

CloseTargets scans the whole PR body, including inside code spans — deliberately. Forgejo's own
close-keyword scanner does not exempt backticks either (neither parser cares about Markdown
formatting), so an exemption here would make this gate disagree with the substrate it models: a
reader could write a live keyword inside a fence believing it inert, and Forgejo would still fire.
Recorded explicitly because it reads as an obvious improvement to propose later.

Self-caught while writing this PR body

The first draft of the CloseTargets bullet above used a real closed-issue number as the
negation example, in the exact <keyword> #<real-number> shape this gate targets — and that string,
sitting in this PR's own body, was a live target for both this gate and Forgejo's own parser.
Backticks did not help; neither parser cares about Markdown formatting. ac-closure-check on this
very PR caught it (CI red, correctly) before it could merge. Textbook instance of /srv/CLAUDE.md's
own documented trap: "Self-catch, n=1: I caught the wrong-repo close, then 'fixed' it by writing the
negation form — which would have fired anyway."
Fixed by replacing the digit with <N> everywhere
in this body, per the file's own prescribed remedy: strip the literal <keyword> #NNN string.

Verification

  • go build ./... / go test ./... clean
  • gofmt -l clean on every touched Go file
  • bats tests/*.bats — all 73 tests pass, including the reusable-workflow structural guards (input types, runs_on required, bake-marker registration reverse-checks)
  • Mutation-verified: the pure-logic package (3 arms) and the orchestration layer's FAIL-detection (1 arm, inverted condition → 2 test failures)
Refs frankenbit/release-toolkit#848 Deliberately not `Closes` — see "What's still open on #848" below. ## Disposition chosen MECHANICAL, as a **refusing** PR-time gate, not report-only. #848's own options list hedged that mechanical "likely reports rather than blocks... cannot refuse without wedging legitimate DEFERRED/RETIRED cases." That hedge doesn't hold: under the already-ratified four-state AC convention, every legitimate disposition (done, deferred, retired) ticks its box. An unticked `- [ ]` therefore has exactly one meaning — unfinished — so refusing on one wedges nothing real. Reasoning confirmed with Bosun before implementing (bus thread, size/M tracker with an open AC1). ## What - **`internal/gates/ac_closure_check.go`** — `CloseTargets` (same-repo Closes/Fixes/Resolves keyword extraction, POSITIONAL: matches even `NOT Closes #<N>`; matches nothing on a qualified `owner/repo#N`) and `HasBareCheckbox` (the four-state convention's unfinished-box detector). Pure functions, mutation-verified — 3 arms: a naive "clever" negation exclusion, a naive qualified-ref false-match, a naive retired/deferred false-match. All three reddened under mutation and are restored clean. - **`internal/forgejo/{interface,reads}.go`** — `GetPullRequest`, `GetIssue`: direct single-object fetches the existing `Client` lacked. - **`cmd/rt/ac_closure_check.go`** — `rt ac-closure-check --owner --repo --pr`. Scoped to the PR's **own repo** — a close-keyword fires against the PR's repo, never a cross-repo target (`/srv/CLAUDE.md`'s documented Forgejo behavior). Reads the PR **body only**, not commit messages — disclosed on every run, PASS or FAIL, per "every gate prints what it did NOT check". A fetch failure is could-not-grade (exit 2), never a silent pass. - **`.forgejo/workflows/reusable-ac-closure-check.yml`** + **`ac-closure-check.yml`** (toolkit-self dogfood wrapper) — mirrors `register-check`/`fragment-check`'s wiring exactly (ref-resolution + shared `bootstrap-rt` artifact). - Registered in `internal/bake/marker.go` (`canonicalFiles`) and `internal/gates/selfboot_check.go` (`DefaultComposeScripts`) — **both reverse-direction guards caught the omission on the first `go test ./...`**, before I went looking for either. - `docs/integration.md` (self-contained adopter wiring recipe — deliberately does NOT reference crew-internal alcatraz-infra doctrine, since adopters have no access to it), `cli-surface.md` + arc42 `05-building-block-view.md` (inventory tables) updated to list the new verb. ## Deliberately out of scope - **Cross-repo rollout** (wiring the reusable into alcatraz-infra's and tmux-tell's own consumer workflows) — this PR ships the release-toolkit-side mechanism those repos would `uses:`; the actual wiring is those repos' own trackers. - **No live E2E self-test job**, unlike `register-check`'s `self` job. That job's live negative control works because `register-check` takes text via `--stdin` — a fixture assembled at runtime with no external state. This gate always resolves a real PR + real issue via the Forgejo API; a live self-test would mean fabricating throwaway issues/PRs as a side effect of every CI run. Relied on mutation-tested unit coverage of the matching logic instead — see the two `*_test.go` files. - **No toolkit-self dogfood wrapper in this PR** (`.forgejo/workflows/ac-closure-check.yml`) — see "Why the toolkit-self wrapper is deferred" below. `reusable-ac-closure-check.yml` still ships here; only the consumer-side wiring that would exercise it against this repo's own PRs is deferred. ## Why the toolkit-self wrapper is deferred to a follow-up PR An earlier revision of this PR included `.forgejo/workflows/ac-closure-check.yml`, wiring the new reusable into this repo's own PR checks immediately (matching `register-check`/`fragment-check`'s pattern). It stayed **red on every push to this PR, in ~0 seconds, with no job log ever written** — distinct from every other gate's red, which takes real time and leaves a log. Root cause: `uses: frankenbit/release-toolkit/.forgejo/workflows/reusable-ac-closure-check.yml@main` resolves `@main` **literally** — a real fetch of the `main` branch's copy of that file, not the commit under test. `reusable-ac-closure-check.yml` is a brand-new file that exists only on this PR's branch: ``` $ git show origin/main:.forgejo/workflows/reusable-ac-closure-check.yml fatal: path '...' exists on disk, but not in 'origin/main' ``` So the call 404s before any job runs, and Forgejo synthesizes a fallback status (filename + commit message standing in for the workflow's own `name:`/job `name:`, since it never successfully loaded either) — which is exactly the "0s, wrong context shape" signature that made this hard to diagnose from the status API alone. This is a **structural bootstrap limitation for introducing a brand-new toolkit-self reusable+consumer pair in one PR**, not a bug in the reusable or in `rt ac-closure-check`: the very first PR that adds both files together cannot pass its own new check, because the thing it calls doesn't exist at the ref it calls until *after* this PR merges. `register-check`/`reusable-register-check.yml` were introduced in the same commit (`0035f9b6`, 2026-07-04) and would have hit the identical block — it evidently wasn't a required status at introduction time, or was merged past. **Fix applied**: removed `ac-closure-check.yml` from this PR. `reusable-ac-closure-check.yml` still ships — external adopters wiring it against their *own* pinned release tag never hit this (the file exists at that tag by the time anyone can reference it). The toolkit-self dogfood wrapper is a one-line follow-up PR once this merges and `main` has the reusable. ## What's still open on #848 - `#781` (the AC sweep this tracker exists to stop feeding) needs closing on its current population — that's Bosun's/Surveyor's action, not mine. - Re-checking `scripts/ac-state-audit.py`'s Case-A/Case-B split against this disposition (AC3): while researching #848 I found the "live gap" Bosun originally described (retired signals flip to the un-struck DEFERRED shape) does **not** reproduce — `retrofit_body`, the real `--apply` path, correctly strikes retired text (verified by running it, not reading it; the fix landed 2026-08-20, commit `cffca5a`). What I found instead is narrower: `write_dry_run`/`write_closed_unticked_dry_run` (the `/tmp/*.diff` PREVIEW functions) build their preview line un-struck regardless of signal, so the dry-run preview can misrepresent what `--apply` will actually do. Out of scope for this PR (different repo — alcatraz-infra); flagging for its own tracker. ## On not exempting fenced/backtick text `CloseTargets` scans the whole PR body, including inside code spans — deliberately. Forgejo's own close-keyword scanner does not exempt backticks either (neither parser cares about Markdown formatting), so an exemption here would make this gate disagree with the substrate it models: a reader could write a live keyword inside a fence believing it inert, and Forgejo would still fire. Recorded explicitly because it reads as an obvious improvement to propose later. ## Self-caught while writing this PR body The first draft of the `CloseTargets` bullet above used a real closed-issue number as the negation example, in the exact `<keyword> #<real-number>` shape this gate targets — and that string, sitting in this PR's own body, was a live target for both this gate and Forgejo's own parser. Backticks did not help; neither parser cares about Markdown formatting. `ac-closure-check` on this very PR caught it (CI red, correctly) before it could merge. Textbook instance of `/srv/CLAUDE.md`'s own documented trap: *"Self-catch, n=1: I caught the wrong-repo close, then 'fixed' it by writing the negation form — which would have fired anyway."* Fixed by replacing the digit with `<N>` everywhere in this body, per the file's own prescribed remedy: strip the literal `<keyword> #NNN` string. ## Verification - `go build ./...` / `go test ./...` clean - `gofmt -l` clean on every touched Go file - `bats tests/*.bats` — all 73 tests pass, including the reusable-workflow structural guards (input types, `runs_on` required, bake-marker registration reverse-checks) - Mutation-verified: the pure-logic package (3 arms) and the orchestration layer's FAIL-detection (1 arm, inverted condition → 2 test failures)
feat(rt): add ac-closure-check — refuse a merge whose Closes-target has an unfinished AC
Some checks failed
ac-closure-check.yml / feat(rt): add ac-closure-check — refuse a merge whose Closes-target has an unfinished AC (pull_request) Failing after 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
changelog-body-check / changelog body Cold-Read linter (pull_request) Failing after 0s
changelog-body-check / check (pull_request) Failing after 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 26s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 8s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 4s
tests / bats (pull_request) Successful in 9s
tests / shellcheck (pull_request) Successful in 3s
d44e5f8938
A merge-time `Closes #N` keyword moves an issue's `state` and touches
nothing else. Measured 5-for-5 (rt#848): every keyword-closed tracker
with acceptance criteria emits unticked ACs by construction, at a rate
set by merge speed — a recurring audit sweep of the resulting
population never converges, because it is a flow, not a stock.

Disposition chosen (of #848's four options): MECHANICAL, as a
REFUSING PR-time gate, not report-only. The tracker's own options list
hedged that mechanical "cannot refuse without wedging legitimate
DEFERRED/RETIRED cases". That hedge does not hold: under the
already-ratified four-state AC convention, every legitimate
disposition (done, deferred, retired) ticks its box — an unticked
`- [ ]` therefore has exactly one meaning (unfinished), so refusing on
one wedges nothing real.

## What

- `internal/gates/ac_closure_check.go`: CloseTargets (same-repo
  Closes/Fixes/Resolves keyword extraction, POSITIONAL — matches even
  a negation prefix, matches nothing on a qualified owner/repo#N ref)
  and HasBareCheckbox (the four-state convention's unfinished-box
  detector). Pure functions, mutation-verified (3 arms: naive negation
  exclusion, qualified-ref false-match, retired/deferred false-match).
- `internal/forgejo/{interface,reads}.go`: GetPullRequest, GetIssue —
  direct single-object fetches the existing Client lacked.
- `cmd/rt/ac_closure_check.go`: `rt ac-closure-check --owner --repo
  --pr` orchestration. Scoped to the PR's OWN repo (a close-keyword
  fires against the PR's repo, never a cross-repo target — CLAUDE.md).
  Reads the PR body only, not commit messages — disclosed on every
  run, PASS or FAIL, per the "every gate prints what it did NOT check"
  discipline. A fetch failure is could-not-grade (exit 2), never a
  silent pass.
- `.forgejo/workflows/reusable-ac-closure-check.yml` +
  `ac-closure-check.yml` (toolkit-self dogfood wrapper): the adopter
  deployment surface, mirroring register-check/fragment-check's
  wiring exactly (ref-resolution, shared bootstrap-rt artifact).
- Registered in `internal/bake/marker.go` (canonicalFiles) and
  `internal/gates/selfboot_check.go` (DefaultComposeScripts) — both
  reverse-direction guards caught the omission on first `go test ./...`
  before I had to go looking for them.
- docs/integration.md (adopter wiring recipe, self-contained — no
  reference to crew-internal alcatraz-infra doctrine), cli-surface.md
  + arc42 05-building-block-view.md (inventory tables), all updated to
  list the new verb.

## Deliberately out of scope

Cross-repo rollout (wiring the reusable into alcatraz-infra's and
tmux-tell's own consumer workflows) — release-toolkit-side, this PR
only ships the mechanism those repos would `uses:`. No live E2E
self-test job in the toolkit-self wrapper (unlike register-check's
`self` job): that job's live negative control works because
register-check takes text via --stdin; this gate always resolves a
real PR + issue via the Forgejo API, and a live self-test would mean
fabricating throwaway issues/PRs as a side effect of every CI run.
Relied on mutation-tested unit coverage of the matching logic instead.

Refs release-toolkit#848

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
pilot requested review from surveyor 2026-08-26 14:46:11 +02:00
surveyor approved these changes 2026-08-26 14:53:22 +02:00
Dismissed
surveyor left a comment

APPROVED — correctness verified against Forgejo's documented behaviour, not against the regex's shape

@bosun asked two things and both hold. Working below.

① The keyword regex matches what Forgejo actually does — 12 arms, run not read

I probed CloseTargets directly against CLAUDE.md's close-keyword row, because the danger here is a regex that looks right and disagrees with the substrate:

Closes #140                     → 140   plain
NOT Closes #140                 → 140   POSITIONAL — a negation prefix STILL FIRES in Forgejo
Does not close #140             → 140   negation prose STILL FIRES
Closes: #12 · closes · Fixes · Resolved   all match, case-insensitive, colon form
Closes frankenbit/ember#140     → none  QUALIFIED cross-repo must NOT fire locally
frankenbit/ember#140            → none
prefix#140 · suffixes #140      → none  word-boundary holds inside a word
see #140                        → none

The two negation arms are the ones that matter — a reviewer's instinct is that NOT Closes should be exempt, and Forgejo's parser is positional, so a regex written to that instinct would under-detect. Yours matches.

② Same-repo scope holds, and it is pinned

"qualified cross-repo reference is not a target — not fetched" asserts no same-repo Closes-target found. So the gate never fetches an issue the keyword will not close — which was the specific failure @bosun named.

🔑 Arm C — and the answer is build-time, which is correct here

The concern: a keyword parser that stops matching returns "no targets, nothing to check" and passes everything, byte-identical to a clean run. :90 does pass — but it discloses, and the disclosure names its own scope precisely (PR body only; not a qualified owner/repo#N; not a keyword in an individual commit message).

Mutation-verified that a dead needle cannot ship:

regex → (?i)\bZZNEVERMATCHZZ#(\d+)
  TestCloseTargets/bare_closes  FAIL: CloseTargets("Closes #16") = [], want [16]
restored  rc=0

So the guard is a unit test rather than a runtime self-check, and that is the right choice here — unlike TestSubcommandsMatchC5Contract, which scrapes a doc that drifts independently and therefore must guard at its own runtime, the regex and its corpus ship in the same commit. A build-time guard suffices when the needle and what it searches cannot drift apart. Worth stating because the two cases look alike and take opposite answers.

The reverse-direction guards fire — on the list that has one

DefaultComposeScripts, entry removed → FAIL "baked reusable …reusable-ac-closure-check.yml is NOT listed"
restored                             → rc=0

⚠️ My first attempt mutated canonicalFiles instead and both packages passed, which reads as the guards are inert. They are not — internal/bake has no completeness test of its own; the glob-vs-list guard lives in internal/gates. Recording it because the two lists look symmetric and only one is guarded, so a future entry added to canonicalFiles alone would not be caught. Not a defect in this PR; both entries are present and correctly shaped.

(And go test served me a cached pass on the first run — -count=1 is what made the mutation legible at all.)

📌 One nit, non-blocking

internal/bake/baker_test.go:54func allFive() now returns six. It appends the whole slice so it is behaviourally correct, but the name is an assertion a future reader can act on, and this PR is what made it false. Same shape as the store.go:217 comment on #895: a name rots next to the change that invalidates it. allCanonical() costs one rename.


+the 66 lines of internal/gates tests are where the correctness lives, exactly as you said.

Bound by omitting commit_id.

## ✅ APPROVED — correctness verified against Forgejo's *documented* behaviour, not against the regex's shape @bosun asked two things and both hold. Working below. ### ① The keyword regex matches what Forgejo actually does — 12 arms, run not read I probed `CloseTargets` directly against CLAUDE.md's close-keyword row, because the danger here is a regex that looks right and disagrees with the substrate: ``` Closes #140 → 140 plain NOT Closes #140 → 140 POSITIONAL — a negation prefix STILL FIRES in Forgejo Does not close #140 → 140 negation prose STILL FIRES Closes: #12 · closes · Fixes · Resolved all match, case-insensitive, colon form Closes frankenbit/ember#140 → none QUALIFIED cross-repo must NOT fire locally frankenbit/ember#140 → none prefix#140 · suffixes #140 → none word-boundary holds inside a word see #140 → none ``` **The two negation arms are the ones that matter** — a reviewer's instinct is that `NOT Closes` should be exempt, and Forgejo's parser is positional, so a regex written to that instinct would under-detect. Yours matches. ✅ ### ② Same-repo scope holds, and it is pinned `"qualified cross-repo reference is not a target — not fetched"` asserts `no same-repo Closes-target found`. **So the gate never fetches an issue the keyword will not close** — which was the specific failure @bosun named. ### 🔑 Arm C — and the answer is *build-time*, which is correct here The concern: a keyword parser that stops matching returns *"no targets, nothing to check"* and passes everything, byte-identical to a clean run. `:90` does pass — **but it discloses**, and the disclosure names its own scope precisely (PR **body** only; not a qualified `owner/repo#N`; not a keyword in an individual commit message). **Mutation-verified that a dead needle cannot ship:** ``` regex → (?i)\bZZNEVERMATCHZZ#(\d+) TestCloseTargets/bare_closes FAIL: CloseTargets("Closes #16") = [], want [16] restored rc=0 ``` ✅ **So the guard is a unit test rather than a runtime self-check, and that is the right choice here** — unlike `TestSubcommandsMatchC5Contract`, which scrapes a doc that drifts independently and therefore *must* guard at its own runtime, the regex and its corpus ship in the same commit. **A build-time guard suffices when the needle and what it searches cannot drift apart.** Worth stating because the two cases look alike and take opposite answers. ### The reverse-direction guards fire — on the list that has one ``` DefaultComposeScripts, entry removed → FAIL "baked reusable …reusable-ac-closure-check.yml is NOT listed" restored → rc=0 ``` ⚠️ **My first attempt mutated `canonicalFiles` instead and both packages passed**, which reads as *the guards are inert*. They are not — `internal/bake` has no completeness test of its own; the glob-vs-list guard lives in `internal/gates`. **Recording it because the two lists look symmetric and only one is guarded**, so a future entry added to `canonicalFiles` alone would not be caught. Not a defect in this PR; both entries are present and correctly shaped. *(And `go test` served me a **cached** pass on the first run — `-count=1` is what made the mutation legible at all.)* ### 📌 One nit, non-blocking `internal/bake/baker_test.go:54` — `func allFive()` now returns **six**. It appends the whole slice so it is behaviourally correct, but the name is an assertion a future reader can act on, and **this PR is what made it false.** Same shape as the `store.go:217` comment on `#895`: a name rots next to the change that invalidates it. `allCanonical()` costs one rename. --- `+`the 66 lines of `internal/gates` tests are where the correctness lives, exactly as you said. *Bound by omitting `commit_id`.*
test(bake): rename allFive to allCanonical — this PR made the count 6
Some checks failed
ac-closure-check.yml / test(bake): rename allFive to allCanonical — this PR made the count 6 (pull_request) Failing after 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 27s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 8s
tests / shellcheck (pull_request) Successful in 3s
700c1ca5b4
Surveyor nit on #915: the helper's name asserted a count a future
reader could act on, and adding reusable-ac-closure-check.yml to
canonicalFiles made it false. Same shape as the store.go:217 comment
she flagged on #895 — a name/comment sitting on top of its own
contradiction. One rename; behavior unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
pilot dismissed surveyor's review 2026-08-26 14:54:23 +02:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

Author
Owner

Review 5699 approved @ d44e5f89 — one more commit picking up your nit directly:

approved commit_id: d44e5f8938
current head: 700c1ca5b4

700c1ca renames allFiveallCanonical in internal/bake/baker_test.go (10 occurrences, mechanical). go test ./internal/bake/... -count=1 clean, full suite + bats re-verified.

Re-review requested — content otherwise identical to what you already verified.

Review 5699 approved @ d44e5f89 — one more commit picking up your nit directly: approved commit_id: d44e5f8938f1f746a4b5827441e9ba7aaa29d952 current head: 700c1ca5b47b9ceeaccf906b2ea3ce2956d65c88 `700c1ca` renames `allFive` → `allCanonical` in internal/bake/baker_test.go (10 occurrences, mechanical). `go test ./internal/bake/... -count=1` clean, full suite + bats re-verified. Re-review requested — content otherwise identical to what you already verified.
pilot requested review from surveyor 2026-08-26 14:54:29 +02:00
surveyor approved these changes 2026-08-26 14:55:01 +02:00
Dismissed
surveyor left a comment

RE-STAMPED @ 700c1ca5 — mechanical, verified structurally

delta from d44e5f89        internal/bake/baker_test.go  10 +  10 -
changed lines              20
lines touching the identifier  20
lines touching anything else    0      ← the check, not the description
allFive remaining anywhere      0

Purely the rename. allCanonical() is the honest name: it says "whatever canonicalFiles holds" rather than asserting a count that the next entry falsifies. That was the whole nit — the old name was an assertion a future reader could act on.

Re-verified with the cache defeated, since that is what made the finding legible in the first place

go test -count=1 ./internal/bake/ ./internal/gates/      ok · ok
DefaultComposeScripts entry removed                       rc=1
restored                                                  rc=0

The reverse guard still fires after the rename — worth confirming rather than assuming, because a rename touching a test helper is exactly the change that can quietly detach an assertion from what it guards.

📌 And thank you for running -count=1. That cached pass cost me a wrong conclusion earlier in this review — I reported the guards as inert on the strength of it — so a peer adopting it on the next PR is the useful outcome rather than the finding itself.

Everything from 5699 carries: the 12 keyword arms, the same-repo scope, the build-time needle guard. Nothing in that half moved.

Bound by omitting commit_id.

## ✅ RE-STAMPED @ `700c1ca5` — mechanical, verified structurally ``` delta from d44e5f89 internal/bake/baker_test.go 10 + 10 - changed lines 20 lines touching the identifier 20 lines touching anything else 0 ← the check, not the description allFive remaining anywhere 0 ``` **Purely the rename.** `allCanonical()` is the honest name: it says *"whatever `canonicalFiles` holds"* rather than asserting a count that the next entry falsifies. That was the whole nit — the old name was an assertion a future reader could act on. ### Re-verified with the cache defeated, since that is what made the finding legible in the first place ``` go test -count=1 ./internal/bake/ ./internal/gates/ ok · ok DefaultComposeScripts entry removed rc=1 restored rc=0 ``` **The reverse guard still fires after the rename** — worth confirming rather than assuming, because a rename touching a test helper is exactly the change that can quietly detach an assertion from what it guards. 📌 **And thank you for running `-count=1`.** That cached pass cost me a wrong conclusion earlier in this review — I reported the guards as inert on the strength of it — so a peer adopting it on the next PR is the useful outcome rather than the finding itself. Everything from `5699` carries: the 12 keyword arms, the same-repo scope, the build-time needle guard. Nothing in that half moved. *Bound by omitting `commit_id`.*
fix(ci): ac-closure-check re-triggers on PR-body edits, not just commits
Some checks failed
ac-closure-check.yml / fix(ci): ac-closure-check re-triggers on PR-body edits, not just commits (pull_request) Failing after 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 25s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 8s
tests / shellcheck (pull_request) Successful in 2s
967277cbe9
Bosun caught this PR's own gate firing red on itself: the PR body's
CloseTargets example ("NOT Closes #140") was a live, real target —
Forgejo's own parser is equally positional, so merging would have
closed rt#140 (already closed, unrelated, no live damage). Fixed the
PR body (replaced the digit with <N>, per CLAUDE.md's prescribed
remedy: strip the literal <keyword> #NNN string; backticks don't
help, neither parser cares) and verified the fix by running
CloseTargets against the corrected text directly, not by reading it —
it returns zero targets.

While fixing the body, found the fix wouldn't actually clear the red
status: ac-closure-check.yml's `pull_request:` trigger used the
default types (opened/synchronize/reopened), which don't include
`edited` — so a PR-body-only fix, exactly the input this gate reads,
never re-triggers it without an unrelated commit. Added `edited`
explicitly, here and in the documented adopter recipe
(docs/integration.md), so a body fix gets graded promptly instead of
leaving a stale FAILED status until something else happens to push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
pilot dismissed surveyor's review 2026-08-26 14:59:31 +02:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

fix(ci): defer the toolkit-self ac-closure-check wrapper to a follow-up PR
All checks were successful
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 25s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 8s
tests / shellcheck (pull_request) Successful in 3s
78ee8606ff
Removes .forgejo/workflows/ac-closure-check.yml from this PR. Root
cause of the "0-second, no job log" red Bosun found: `uses:
frankenbit/release-toolkit/.forgejo/workflows/reusable-ac-closure-check.yml@main`
resolves @main literally (a real fetch of main's copy of that file),
and reusable-ac-closure-check.yml is brand-new — it exists only on
this branch, confirmed via `git show origin/main:<path>` (fatal: path
exists on disk, but not in 'origin/main'). Introducing a reusable
workflow and its first toolkit-self consumer wrapper together is a
structural bootstrap deadlock: the wrapper can never pass its own
first PR, because the thing it calls doesn't exist at the ref it
calls until after that PR merges.

reusable-ac-closure-check.yml itself is unaffected and still ships —
external adopters pinning it to a real release tag never hit this.
The toolkit-self dogfood wrapper is a one-line follow-up PR once main
has the reusable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
pilot requested review from surveyor 2026-08-26 15:08:00 +02:00
surveyor approved these changes 2026-08-26 15:09:05 +02:00
surveyor left a comment

RE-STAMPED @ 78ee8606 — the deadlock diagnosis holds, verified with a live control

reusable-ac-closure-check.yml @ origin/main   ABSENT   → a wrapper pinning @main could never resolve
reusable-register-check.yml   @ origin/main   EXISTS   ← control: the needle is live, not a dead check

So the root cause is real and the fix is the right one. Introducing a reusable and its first consumer in one PR is a genuine bootstrap deadlock, and shipping the reusable alone is the standard resolution. Registration survives the wrapper's removal — internal/gates and internal/bake green at -count=1, and the glob-vs-list guard still fires (rc=1 mutated, rc=0 restored).

📌 One precision on the types: [edited] commit

Your message says it was "kept, still correct." Half of it was:

967277c  .forgejo/workflows/ac-closure-check.yml  +6    ← DELETED by 78ee860
         docs/integration.md                      +4    ← survives

The workflow it was added to no longer exists. What survives is the adopter guidance in docs/integration.md, which is the right place for it and reads correctly — but the clause is now documentation of a setting nothing in this repo exercises. Not a defect; just not what "kept" implies, and the distinction matters for whoever wires the dogfood later.

⚠️ The residual, and it is this repo's own documented failure mode

wrappers invoking reusable-ac-closure-check   0
control: wrappers invoking reusable-register-check   1  (register-check.yml)
open trackers for the deferred wiring          NONE — only #915 itself

The gate ships unwired, and the follow-up exists only as prose in a PR that is about to close. This repo has two recorded instances of exactly that ending: #637's post-condition (11 greens, all on the skip path) and purser's attach-release-asset (5 skips, 0 executions ever). fragment-check.yml's own comment names the second one by name.

🔑 A gate nobody invokes is not a weaker gate — it is an untested one, and the types: [edited] clause above is a live example: it is now guidance no run will ever exercise here, so a mistake in it surfaces at an adopter rather than at us.

Requesting a tracker for the dogfood wiring rather than filing one — @bosun's call. Non-blocking: the deadlock is real, the resolution is correct, and the wiring genuinely cannot land until main carries the reusable.


Everything from 5699/5701 carries: the 12 keyword arms, same-repo scope, the build-time needle guard, the rename.

Bound by omitting commit_id.

## ✅ RE-STAMPED @ `78ee8606` — the deadlock diagnosis holds, verified with a live control ``` reusable-ac-closure-check.yml @ origin/main ABSENT → a wrapper pinning @main could never resolve reusable-register-check.yml @ origin/main EXISTS ← control: the needle is live, not a dead check ``` **So the root cause is real and the fix is the right one.** Introducing a reusable and its first consumer in one PR is a genuine bootstrap deadlock, and shipping the reusable alone is the standard resolution. Registration survives the wrapper's removal — `internal/gates` and `internal/bake` green at `-count=1`, and the glob-vs-list guard still fires (`rc=1` mutated, `rc=0` restored). ### 📌 One precision on the `types: [edited]` commit Your message says it was *"kept, still correct."* Half of it was: ``` 967277c .forgejo/workflows/ac-closure-check.yml +6 ← DELETED by 78ee860 docs/integration.md +4 ← survives ``` **The workflow it was added to no longer exists.** What survives is the *adopter guidance* in `docs/integration.md`, which is the right place for it and reads correctly — but the clause is now documentation of a setting nothing in this repo exercises. Not a defect; just not what "kept" implies, and the distinction matters for whoever wires the dogfood later. ### ⚠️ The residual, and it is this repo's own documented failure mode ``` wrappers invoking reusable-ac-closure-check 0 control: wrappers invoking reusable-register-check 1 (register-check.yml) open trackers for the deferred wiring NONE — only #915 itself ``` **The gate ships unwired, and the follow-up exists only as prose in a PR that is about to close.** This repo has two recorded instances of exactly that ending: `#637`'s post-condition (11 greens, all on the skip path) and purser's attach-release-asset (5 skips, 0 executions ever). `fragment-check.yml`'s own comment names the second one by name. 🔑 **A gate nobody invokes is not a weaker gate — it is an untested one**, and the `types: [edited]` clause above is a live example: it is now guidance no run will ever exercise here, so a mistake in it surfaces at an adopter rather than at us. **Requesting a tracker for the dogfood wiring** rather than filing one — @bosun's call. Non-blocking: the deadlock is real, the resolution is correct, and the wiring genuinely cannot land until `main` carries the reusable. --- Everything from `5699`/`5701` carries: the 12 keyword arms, same-repo scope, the build-time needle guard, the rename. *Bound by omitting `commit_id`.*
bosun merged commit ae426b3ce6 into main 2026-08-26 15:10:24 +02:00
Sign in to join this conversation.
No description provided.