From 56b41d5197b45c9d2397cbcff0602eb0be5e747e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:57:50 +0100 Subject: [PATCH 01/27] chore: capture two findings from the release-gate wiring lane The derived content commit is not bound to the release it gates, so an earlier release's receipts admit a later release that recorded none; and commands/launch.md describes the receipt gate at a position it no longer holds (after the tag, deriving ^2^). Refs: iss-2609251755386183, iss-2609251751298408 Assisted-by: Claude:claude-opus-5-5 --- ...unch-md-describes-the-release-job-s-semantic.md | 14 ++++++++++++++ ...job-s-semantic-receipt-gate-admits-a-release.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md create mode 100644 .abcd/work/issues/open/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md diff --git a/.abcd/work/issues/open/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md b/.abcd/work/issues/open/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md new file mode 100644 index 000000000..238661883 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251751298408" +slug: "commands-launch-md-describes-the-release-job-s-semantic" +severity: "minor" +category: "documentation" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "commands/launch.md" +--- + +commands/launch.md describes the release job's semantic receipt gate at a position it no longer holds. Four places — the Ship intro ('the tag is already created by then'), the release-day failure list ('The tag exists by then'), Semantic receipts ('release.yml derives the content commit as ^2^') and 'Prove the gate before you merge' ('receipt_gate runs inside the release job, which is after the tag is created') — predate adr-52 and iss-355: release.yml runs the gate in its verify job, which the tag job needs, so on the auto-release path a refusal leaves no tag and the version free, and the content commit is derived from the receipts directory of the released tree (record-lint --derive-content-sha), not from merge ancestry. Only a hand-pushed tag exists before the gate. An operator reading the page believes a refusal consumes the version and reaches for a tag deletion the machinery no longer needs. diff --git a/.abcd/work/issues/open/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md b/.abcd/work/issues/open/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md new file mode 100644 index 000000000..72736d37b --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251755386183" +slug: "the-release-job-s-semantic-receipt-gate-admits-a-release" +severity: "major" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/releasegate_derive.go" +--- + +The release job's semantic receipt gate admits a release that carries no receipts of its own, whenever an earlier release's receipts are in the tree. record-lint --derive-content-sha (lint.DeriveReleaseContentSha) arms the gate against the NEAREST commit on the released lineage that a .abcd/work/reviews// directory names, and never checks that the commit belongs to this release. A roll to a new version with no new receipts derives the previous release's content commit, whose PROMOTE receipts are valid, so receipt_gate passes and the release publishes unreviewed. Reproduced on a scratch clone of this repository at the v0.10.0 tip: a commit rolling CHANGELOG to 0.11.0 derived 64ea8f62 (the v0.10.0 cut) and the armed gate printed no finding. Every release after the first that ever recorded receipts is exposed; the itd-93 scaffolded gate and abcd launch receipts inherit it through the same reader. Fix direction: bind the derived commit to the release — its newest dated CHANGELOG version must equal the released tree's, else fail closed naming both. From c04a12ded8a322393c967876da76774f9eff4ee1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:57:56 +0100 Subject: [PATCH 02/27] fix(release-gate): bind the derived content commit to the release it gates record-lint --derive-content-sha armed the semantic receipt gate against the nearest commit on the released lineage that a receipts directory names, and never checked that the commit belonged to this release. A roll that recorded no receipts of its own derived the previous release's cut, whose PROMOTE receipts are valid, and the gate admitted a release nobody reviewed (reproduced on a scratch clone at the v0.10.0 tip). The derivation now requires the derived commit to carry the released tree's own newest dated CHANGELOG version, read from each commit's blob, and fails closed naming both versions otherwise. The rehearsal and the batched-queue shape are unaffected: both compare equal versions. Refs: iss-2609251755386183 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/release-gate/README.md | 8 ++- internal/core/changelog/anchor.go | 8 +++ internal/core/lint/releasegate_derive.go | 52 +++++++++++++++++++ internal/core/lint/releasegate_derive_test.go | 39 ++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index c447ee3d6..ed84f2e26 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -100,8 +100,12 @@ name the commit they gate, so `record-lint --derive-content-sha` reads the `.abcd/work/reviews//` entry on the released lineage and returns that `` — not the merge commit, and not `^2^` ancestry, which a batched merge-queue push can point at an unrelated PR's commit (`github.sha` is the batch -tip, iss-355). `subject.digest.gitCommit` therefore still matches the armed -commit exactly and the gate stays strict. (Before this, the gate armed with the tagged merge commit, whose +tip, iss-355). The entry must also belong to THIS release: the commit it names +carries the released tree's own newest dated CHANGELOG version, or the derivation +fails closed — the nearest entry on a release that recorded no receipts of its +own is the previous release's, whose valid receipts would otherwise admit it +unreviewed (iss-2609251755386183). `subject.digest.gitCommit` therefore still +matches the armed commit exactly and the gate stays strict. (Before this, the gate armed with the tagged merge commit, whose tree can never hold a receipt naming itself — an unsatisfiable self-reference. Dormant while the repo was private, it surfaced at the first public release and fail-closed it, v0.3.0, iss-108.) diff --git a/internal/core/changelog/anchor.go b/internal/core/changelog/anchor.go index f2e73faf4..4e049600f 100644 --- a/internal/core/changelog/anchor.go +++ b/internal/core/changelog/anchor.go @@ -132,6 +132,14 @@ func LatestChangelogVersion(root string) (launch.Semver, bool, error) { } return launch.Semver{}, false, err } + return LatestVersionIn(data) +} + +// LatestVersionIn is LatestChangelogVersion over CHANGELOG bytes the caller +// already holds — a blob read out of a commit rather than the working tree, +// which is how the release gate compares the version a receipt's commit carries +// with the version being released. +func LatestVersionIn(data []byte) (launch.Semver, bool, error) { for _, line := range strings.Split(string(data), "\n") { m := datedHeadingRe.FindStringSubmatch(strings.TrimRight(line, "\r")) if m == nil { diff --git a/internal/core/lint/releasegate_derive.go b/internal/core/lint/releasegate_derive.go index 3a9485593..48c53ca11 100644 --- a/internal/core/lint/releasegate_derive.go +++ b/internal/core/lint/releasegate_derive.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/intentdriven/abcd/internal/core/changelog" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -100,6 +101,27 @@ func DeriveReleaseContentSha(root, released string) (string, error) { return "", fmt.Errorf("release-gate: two receipts directories are equidistant from the released commit; the content commit is ambiguous (fail-closed)") } + // Nearest is not enough: the nearest receipts directory can be an EARLIER + // release's, when this release recorded none of its own, and its PROMOTE + // receipts would then admit a release nobody reviewed (iss-2609251755386183). + // The content commit a release's receipts name is the commit that rolled the + // CHANGELOG to this release's version, so it carries the released tree's own + // newest dated version. A candidate carrying any other version is not this + // release's content commit, and the derivation fails closed on it. + want, err := releaseVersionAt(root, released) + if err != nil { + return "", err + } + got, err := releaseVersionAt(root, best) + if err != nil { + return "", err + } + if got != want { + return "", fmt.Errorf("release-gate: the nearest receipts directory names %s, whose newest CHANGELOG version is %s, "+ + "not this release's %s; no receipts directory names this release's content commit (fail-closed)", + best, versionOrNone(got), versionOrNone(want)) + } + // Return the full 40/64-hex sha so the armed gate's receiptShaRe check and the // receipt's subject digest compare against a canonical form, never an // abbreviated directory name. @@ -140,3 +162,33 @@ func receiptDirNames(root, released string) ([]string, error) { } return names, nil } + +// releaseVersionAt reads the newest dated CHANGELOG version out of rev's tree +// — the version auto-release tags when rev is released. "" means rev carries no +// CHANGELOG.md or no dated heading in it; an unreadable blob or a malformed +// heading is an error, never a silent "". +func releaseVersionAt(root, rev string) (string, error) { + if _, err := gitutil.Run(root, "cat-file", "-e", rev+":CHANGELOG.md"); err != nil { + return "", nil + } + blob, err := gitutil.Run(root, "cat-file", "blob", rev+":CHANGELOG.md") + if err != nil { + return "", fmt.Errorf("release-gate: reading CHANGELOG.md at %s: %w", rev, err) + } + v, found, err := changelog.LatestVersionIn([]byte(blob)) + if err != nil { + return "", fmt.Errorf("release-gate: CHANGELOG.md at %s: %w", rev, err) + } + if !found { + return "", nil + } + return v.String(), nil +} + +// versionOrNone renders a release version for a refusal, naming its absence. +func versionOrNone(v string) string { + if v == "" { + return "none" + } + return v +} diff --git a/internal/core/lint/releasegate_derive_test.go b/internal/core/lint/releasegate_derive_test.go index 5106c727b..18d093ccb 100644 --- a/internal/core/lint/releasegate_derive_test.go +++ b/internal/core/lint/releasegate_derive_test.go @@ -169,3 +169,42 @@ func TestDeriveReleaseContentSha_FailsClosedWithNoReceipts(t *testing.T) { t.Errorf("error = %q, want a fail-closed message", err) } } + +// TestDeriveReleaseContentSha_RefusesAnEarlierReleasesReceipts is +// iss-2609251755386183: the nearest receipts directory on the released lineage +// is not necessarily this release's. A roll to 1.0.0 that records no receipts +// of its own would otherwise derive the 0.9.0 cut, whose PROMOTE receipts are +// valid, and the gate would admit an unreviewed release. The derived commit +// must carry the released tree's own release version, or the derivation fails +// closed and names both. +func TestDeriveReleaseContentSha_RefusesAnEarlierReleasesReceipts(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("CHANGELOG.md", "## [Unreleased]\n") + r.Commit("base") + + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [0.9.0] - 2025-12-01\n") + r.Commit("roll 0.9.0 (old content)") + old := r.Git("rev-parse", "HEAD") + receiptsFor(r, old) + + // The next release: rolled, never reviewed. + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n\n## [0.9.0] - 2025-12-01\n") + r.Commit("roll 1.0.0 (content, no receipts)") + released := r.Git("rev-parse", "HEAD") + + got, err := lint.DeriveReleaseContentSha(r.Root(), released) + if err == nil { + t.Fatalf("derived %s — the 0.9.0 cut's receipts — for the 1.0.0 release; must fail closed", got) + } + for _, want := range []string{"fail-closed", "0.9.0", "1.0.0"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to name %q", err, want) + } + } + + // Its own receipts, in the second commit, make it derivable again. + receiptsFor(r, released) + if got, err = lint.DeriveReleaseContentSha(r.Root(), r.Git("rev-parse", "HEAD")); err != nil || got != released { + t.Errorf("with its own receipts the 1.0.0 roll must derive: got %s, %v", got, err) + } +} From 2f019681dd62870f43bb2fa2647fdd6dcdfeba04 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:58:06 +0100 Subject: [PATCH 03/27] =?UTF-8?q?chore:=20resolve=20iss-2609251755386183?= =?UTF-8?q?=20=E2=80=94=20the=20receipt=20gate=20binds=20its=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251755386183 Assisted-by: Claude:claude-opus-5-5 --- ...elease-job-s-semantic-receipt-gate-admits-a-release.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md (70%) diff --git a/.abcd/work/issues/open/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md b/.abcd/work/issues/resolved/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md similarity index 70% rename from .abcd/work/issues/open/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md rename to .abcd/work/issues/resolved/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md index 72736d37b..dd3096bbf 100644 --- a/.abcd/work/issues/open/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md +++ b/.abcd/work/issues/resolved/iss-2609251755386183-the-release-job-s-semantic-receipt-gate-admits-a-release.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/releasegate_derive.go" +resolution: "The derivation binds the derived content commit to the release: it must carry the released tree's newest dated CHANGELOG version, else it fails closed naming both." +impact: fix +resolved_by: + commit: "c04a12de" --- The release job's semantic receipt gate admits a release that carries no receipts of its own, whenever an earlier release's receipts are in the tree. record-lint --derive-content-sha (lint.DeriveReleaseContentSha) arms the gate against the NEAREST commit on the released lineage that a .abcd/work/reviews// directory names, and never checks that the commit belongs to this release. A roll to a new version with no new receipts derives the previous release's content commit, whose PROMOTE receipts are valid, so receipt_gate passes and the release publishes unreviewed. Reproduced on a scratch clone of this repository at the v0.10.0 tip: a commit rolling CHANGELOG to 0.11.0 derived 64ea8f62 (the v0.10.0 cut) and the armed gate printed no finding. Every release after the first that ever recorded receipts is exposed; the itd-93 scaffolded gate and abcd launch receipts inherit it through the same reader. Fix direction: bind the derived commit to the release — its newest dated CHANGELOG version must equal the released tree's, else fail closed naming both. + +## Grounds + +- pursued: a roll with no receipts of its own now fails at --derive-content-sha instead of arming against the previous release's cut (TestDeriveReleaseContentSha_RefusesAnEarlierReleasesReceipts); it would be shown wrong by a real release whose roll commit legitimately carries a different newest dated version than its merged tree, which no release shape produces From 273f8cbcc58cb243e59e6f5050a50741caedbc21 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:58:16 +0100 Subject: [PATCH 04/27] feat(launch): run the release job's receipt gate locally and end the emit step with its protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit itd-93 AC7 and AC8 (spc-2609230613193436). `abcd launch receipts` is the release job's semantic-receipt gate, run on the release branch before the merge. It shares the job's reader rather than modelling it: lint.CheckReleaseReceipts reads the required gates from the committed release.yml (the list the job enforces), derives the content commit with DeriveReleaseContentSha, arms with ArmReceiptGate and judges with checkReceiptGate — the three functions record-lint runs. It names each missing or non-PROMOTE receipt and the commit it must name, refuses on an uncommitted receipt change (the job reads the committed tree), and exits 0/1/2. A test runs the workflow's own step, lifted from the committed release.yml against a record-lint built from this tree, beside the verb over five repository states and holds them to one verdict and one set of reasons. The emit step of `abcd launch ship` ends with the receipts protocol as a numbered checklist, composed in core (release.ReceiptsProtocolFor) from the same workflow list and carried in --json as receipts_protocol; a workflow that arms no gate gets a checklist that requires no receipt. commands/launch.md documents the verb and the checklist, and its account of where the gate runs is brought to the workflow as it is (in verify, before the tag on the auto-release path; content commit from the receipts directory). Refs: iss-2609251751298408 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/04-launch.md | 29 +- .abcd/development/release/surface.json | 5 + commands/launch.md | 86 ++--- docs/reference/cli/commands.md | 6 + internal/core/lint/releasereceipts.go | 230 ++++++++++++++ internal/core/lint/releasereceipts_test.go | 206 ++++++++++++ internal/core/release/protocol.go | 75 +++++ internal/core/release/protocol_test.go | 84 +++++ internal/surface/cli/cli.go | 3 + internal/surface/cli/launch_receipts.go | 129 ++++++++ internal/surface/cli/launch_receipts_test.go | 295 ++++++++++++++++++ internal/surface/cli/ship.go | 18 +- 12 files changed, 1128 insertions(+), 38 deletions(-) create mode 100644 internal/core/lint/releasereceipts.go create mode 100644 internal/core/lint/releasereceipts_test.go create mode 100644 internal/core/release/protocol.go create mode 100644 internal/core/release/protocol_test.go create mode 100644 internal/surface/cli/launch_receipts.go create mode 100644 internal/surface/cli/launch_receipts_test.go diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index f342028c0..ef201b04f 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -35,6 +35,7 @@ workflow that tags it. | Verb | Bucket | Status | |---|---|---| | `archive` | gate | shipped | +| `receipts` | gate | shipped | | `scaffold` | — | shipped | | `ship` | gate | shipped | @@ -78,6 +79,26 @@ refusal, so nothing unpinned can be published. `auto-release.yml` runs it on the pushed commit before the tag is made, and the release workflow runs it again on the tagged commit, each run bound to the repository the workflow runs in. +**The emit step ends with the receipts protocol.** After the cut's report, the +render closes with a numbered checklist, composed in the core and carried in +the machine-readable report too: commit the roll, run each semantic gate the +release workflow requires against that commit, key every receipt to it, commit +the receipts on top so the branch is exactly two commits, then prove the gate +locally. The gate names come from the committed `release.yml`, the list the +release job enforces, so the checklist cannot ask for a gate the release does not +require. A workflow that arms no semantic gate gets a checklist that says no +receipt is required. + +**The receipts check is the release job's receipt gate, run before the merge.** +It reads the required gates from the committed `release.yml`, derives the +content commit from the receipts directory the way the release job does, and +runs the release job's own check over it — one reader, which a test holds to the +release job's verdict and reasons on the same repository state by running the +workflow's step beside it. It names each missing or non-PROMOTE receipt and the +commit the receipt must name, and refuses on an uncommitted receipt change, +because the release job reads the committed tree. It exits 0 when the gate would +admit (or nothing is armed), 1 when it would refuse, and 2 on a structural fault. + `commands/launch.md` carries the emit, compose and ingest orchestration over the `release-changelog-composer` agent, including the release page's retry loop. The deterministic emit alone is `abcd changelog`, read-only and prose-free. @@ -623,7 +644,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd launch` -Sub-verbs: `abcd launch archive`, `abcd launch scaffold`, `abcd launch ship`, `abcd launch smoke-pages`. +Sub-verbs: `abcd launch archive`, `abcd launch receipts`, `abcd launch scaffold`, `abcd launch ship`, `abcd launch smoke-pages`. | Flag | Type | |---|---| @@ -643,6 +664,12 @@ Sub-verbs: none. | `--tag` | string | | `--verify` | bool | +### `abcd launch receipts` + +Sub-verbs: none. + +Flags: none. + ### `abcd launch scaffold` Sub-verbs: none. diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index c70363503..aa5730316 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1457,6 +1457,11 @@ } ] }, + { + "path": "abcd launch receipts", + "hidden": false, + "flags": [] + }, { "path": "abcd launch scaffold", "hidden": false, diff --git a/commands/launch.md b/commands/launch.md index 75bcc2fd1..613d6dfa4 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -1,7 +1,7 @@ --- name: launch -description: Preview the public launch — the file bundle, the secret/PII scan, and the release gates — in dry-run mode, cut a release by deriving its version and composing its changelog and release page, render and verify the release's pinned plugin archive, and scaffold the changelog-driven release gate into a managed repo. The preview writes only its pre-flight report, to the gitignored local tier; `ship` writes the dated CHANGELOG heading, the RELEASE.md page and the archive pin and never publishes; `archive` writes one zip where it is told and never publishes; `scaffold` writes the release workflows and never publishes. -argument-hint: "[--dry-run [--deep-smoke] [--baseline ] [--fetch-baseline]] | ship [--changelog-json ] [--payload-dir ] [--allow-dirty] [--fetch-baseline] | archive --out [--tag ] [--verify] [--repository ] | scaffold" +description: Preview the public launch — the file bundle, the secret/PII scan, and the release gates — in dry-run mode, cut a release by deriving its version and composing its changelog and release page, render and verify the release's pinned plugin archive, run the release job's semantic-receipt gate locally before the merge, and scaffold the changelog-driven release gate into a managed repo. The preview writes only its pre-flight report, to the gitignored local tier; `ship` writes the dated CHANGELOG heading, the RELEASE.md page and the archive pin and never publishes; `archive` writes one zip where it is told and never publishes; `receipts` writes nothing; `scaffold` writes the release workflows and never publishes. +argument-hint: "[--dry-run [--deep-smoke] [--baseline ] [--fetch-baseline]] | ship [--changelog-json ] [--payload-dir ] [--allow-dirty] [--fetch-baseline] | archive --out [--tag ] [--verify] [--repository ] | receipts | scaffold [--confirm]" --- # `/abcd:launch` release preview and release cut @@ -137,10 +137,13 @@ Six failures are worth recognising, because each looks like something else. its environment secrets perfectly well, provided every caller above it passes `secrets: inherit` — measured on a canary secret, and pinned by `TestReleaseChainPassesSecretsAtEveryLevel`. -- **The release job fails on `Semantic-gate receipts`.** The receipts do not - match the commit the workflow derived. The tag exists by then and the workflow - never moves a tag, so the version is consumed: it needs the tag deleted and the - release re-cut. Step 2 exists to catch this before the merge — run it. +- **`verify` fails on `Semantic-gate receipts`.** The receipts do not match + the commit the workflow derived from the receipts directory. On the + `auto-release` path the gate runs before the tag, so nothing was tagged and the + version is still free: land a follow-up pull request carrying the missing or + corrected receipts, and its merge retries. A hand-pushed tag exists before the + gate runs, so there the version is consumed. Step 2 exists to catch this + before the merge — run it. - **`auto-release` fails in `detect`, on `Plugin archive reproduces the committed pin, before the tag`, and no tag appears.** The merged commit renders a different archive from the one the ship pinned — a payload file (`commands/`, @@ -262,9 +265,11 @@ delegated composition, a validating ingest. Those three steps write the CHANGELOG heading. They do **not** finish the release. Two host-run semantic passes must also run and record receipts, and the release branch has to carry them in a second commit — see *Semantic receipts* -below. A branch that skips them merges and tags cleanly and then fails at -`release.yml`'s fail-closed receipt gate, which is the most expensive place to -find out: the tag is already created by then, and the workflow never moves a tag. +below. A branch that skips them merges cleanly and then fails `release.yml`'s +fail-closed receipt gate, which is the most expensive place to find out: the +release run is spent, and the fix is another pull request. The emit step ends +with this protocol as a numbered checklist, so the report you read before +composing already says what follows it. ### 1. Emit the cut (deterministic, writes nothing) @@ -282,6 +287,14 @@ intent, a removed intent or anything still planned). The human render lists them under `release page:`, or says `release page: none` for a cut that ships fixes alone. Read-only preview of the same thing: `abcd changelog --json`. +The emit render ends with the **receipts protocol**, a numbered checklist the +binary composes from the committed `release.yml`: commit the roll, run each +semantic gate the release job requires against that commit, key every receipt +to its full sha, commit the receipts on top so the branch is exactly two +commits, then run `launch receipts`. `--json` carries it as `receipts_protocol` +(`required_gates`, `steps`). Relay it with the cut; in a repository whose release +workflow arms no semantic gate it says no receipt is required. + Exit codes gate the flow: - **0** — the cut is ready. Continue to step 2. @@ -594,10 +607,12 @@ the commit it names, because adding it would change that commit's sha. So: archive move. This is what the reviewers read. 2. **The receipts** — a commit recording the semantic verdicts that name commit 1. -On merge, `release.yml` derives the content commit as `^2^` and finds its -receipts in the released tree. A one-commit branch breaks this: the single commit -is taken as the receipts commit, the gate arms against whatever preceded it, and -no receipt names that commit. +On merge, `release.yml` derives the content commit from the receipts directory +of the released tree: the nearest commit on the released lineage that a +`.abcd/work/reviews//` directory names, which must carry this release's own +CHANGELOG version — an earlier release's receipts never stand in for this one's. +A one-commit branch breaks this: no receipt can name the commit that carries it, +so the release has no receipts for its content and the gate refuses. ### Running the passes @@ -623,33 +638,32 @@ around, and the receipts cannot be hand-written to unblock a release. ### Prove the gate before you merge -`receipt_gate` runs inside the release job, which is **after** the tag is -created. A refusal there does not block the release, it consumes the version: the -workflow never moves a tag, and its recovery path rebuilds from the tagged -commit, whose tree can never gain the missing receipts. Recovering means deleting -a tag the machinery treats as immutable (recorded as `adr-52`, undecided). - -So reproduce the gate's verdict locally, on the release branch, while nothing is -tagged. From the repository root: +`receipt_gate` runs in `release.yml`'s `verify` job, on the merged commit. On +the `auto-release` path that is before the tag, so a refusal leaves the version +free — but it still spends the release run and needs another pull request to +fix. So run the same gate on the release branch first, while nothing has merged: ```bash -go run ./cmd/record-lint --release-gate \ - --require-gate docs-currency-reviewer \ - --require-gate iss35-brief-surface-crosscheck +"${CLAUDE_PLUGIN_ROOT}/abcd" launch receipts --json ``` -- `` is the **full 40-character** sha of the commit the - receipts name, which on a correctly shaped release branch is the receipts - commit's parent (`git rev-parse HEAD^`). Use the full sha: an abbreviated one - is well-formed, finds no receipt, and makes the gate refuse as though the - semantic pass had never run. -- `record-lint` is a repository-local program, not an installed binary. `go run - ./cmd/record-lint` is the invocation; there is no `record-lint` on `PATH`. -- The required-gate names come from `release.yml`, which owns that list on - purpose. If they diverge, the workflow is right and this command is stale. - -**Exit 0 means the release will pass the gate.** A non-zero exit names what is -missing, and costs nothing to fix, because no tag exists yet. +It is the release job's receipt gate, not a model of it: it reads the +required-gate names from the committed `release.yml` (which owns that list), derives +the content commit from the receipts directory the way the release job does, and +runs the release job's own check over it. It reads the working tree, so it +refuses on an uncommitted receipt change — the release job reads the committed +tree and would not see it. + +Exit codes: + +- **0** — the release job's receipt gate admits this state (or `release.yml` arms + no semantic gate, and nothing is required). Merge. +- **1** — it would refuse. The report names each missing or non-PROMOTE receipt + and the full sha of the commit it must name (`commit`; `derived` says whether it + came from the receipts directory or is the roll at `HEAD`, before any receipt + exists). Relay `problems`, fix the receipts commit, and run it again. It costs + an amend; nothing has merged. +- **2** — a structural fault (the repository or its workflow could not be read). ## Archive — the release's pinned plugin archive diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 9e0bbf887..43aa0c94b 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1170,6 +1170,12 @@ Render the release's plugin archive and (--verify) prove the committed catalog p --verify refuse (exit 1) unless the committed catalog pins this archive's address and digest ``` +#### `abcd launch receipts` + +Run the release job's semantic-receipt gate locally, before the merge (exit 1 when it would refuse) + +**Usage:** `abcd launch receipts` + #### `abcd launch scaffold` Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook) into this repo diff --git a/internal/core/lint/releasereceipts.go b/internal/core/lint/releasereceipts.go new file mode 100644 index 000000000..717173881 --- /dev/null +++ b/internal/core/lint/releasereceipts.go @@ -0,0 +1,230 @@ +package lint + +// The local half of the release job's receipt gate (itd-93 AC7, iss-327). +// +// release.yml's verify job runs two commands on the released tree: +// +// content="$(go run ./cmd/record-lint --derive-content-sha)" +// go run ./cmd/record-lint --release-gate "$content" --require-gate ... +// +// A refusal there costs a release run. `abcd launch receipts` asks the same +// question on the release branch, before anything merges, and it must never +// answer differently: a local check that passes where the release job refuses +// is worse than no check, because it is the reason the operator merged. So it +// shares the release job's reader rather than re-implementing it — +// DeriveReleaseContentSha is the derivation, ArmReceiptGate the arming and +// checkReceiptGate the verdict, the three functions record-lint runs — and it +// reads the required-gate list from the same place the release job gets it, +// the committed release workflow, never from a list of its own. + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// ReleaseWorkflowPath is the workflow that arms the receipt gate: the release +// job's `--require-gate` list is the trust root for which semantic gates a +// release requires (the in-tree record-lint config is committer-editable and is +// deliberately not consulted for it). +const ReleaseWorkflowPath = ".github/workflows/release.yml" + +// recordLintConfigPath is the config record-lint loads before it arms the gate; +// the local check loads the same file so a configured receipts directory means +// the same thing to both. +const recordLintConfigPath = ".abcd/record-lint.json" + +// maxWorkflowBytes caps the release-workflow read. A workflow is a short text +// file; a larger one is refused rather than streamed. +const maxWorkflowBytes = 1 << 20 + +// requireGateRe captures one `--require-gate ` operand from a run script. +var requireGateRe = regexp.MustCompile(`--require-gate[ \t]+("[^"]*"|'[^']*'|[^\s\\]+)`) + +// releaseGateCallRe matches record-lint armed as the release gate, which a +// workflow that lists no `--require-gate` still runs (and which then fails +// closed on the empty list). +var releaseGateCallRe = regexp.MustCompile(`record-lint\S*[ \t]+--release-gate`) + +// ReleaseGate is what a repository's release workflow arms its receipt gate +// with, read from the committed workflow. +type ReleaseGate struct { + // Workflow is the repo-relative path that was read. + Workflow string `json:"workflow"` + // Present reports that the workflow exists. + Present bool `json:"present"` + // Armed reports that the workflow runs the receipt gate at all. A workflow + // with no semantic gate configured runs none, and the deterministic gates + // alone admit its releases. + Armed bool `json:"armed"` + // Gates are the required gate names, in the order the workflow lists them. + Gates []string `json:"required_gates"` +} + +// ReadReleaseGate reads the receipt-gate arming out of root's release +// workflow. Comment lines are skipped, so prose describing the gate never arms +// it. An absent workflow is not an error: it arms nothing, and Present says so. +func ReadReleaseGate(root string) (ReleaseGate, error) { + g := ReleaseGate{Workflow: ReleaseWorkflowPath, Gates: []string{}} + data, err := fsutil.ReadGuarded(filepath.Join(root, filepath.FromSlash(ReleaseWorkflowPath)), maxWorkflowBytes) + if err != nil { + if os.IsNotExist(err) { + return g, nil + } + return g, err + } + g.Present = true + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if releaseGateCallRe.MatchString(trimmed) { + g.Armed = true + } + for _, m := range requireGateRe.FindAllStringSubmatch(trimmed, -1) { + g.Armed = true + g.Gates = append(g.Gates, unquoteShellWord(m[1])) + } + } + return g, nil +} + +// unquoteShellWord strips one layer of matching shell quotes, the way bash +// hands the operand to record-lint. +func unquoteShellWord(s string) string { + if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] { + return s[1 : len(s)-1] + } + return s +} + +// ReceiptProblem is one reason the receipt gate refuses: the gate it concerns +// ("" for a refusal of the arming itself), the receipt path it expected, and +// the release job's own message, which names the commit the receipt must name. +type ReceiptProblem struct { + Gate string `json:"gate,omitempty"` + Path string `json:"path"` + Message string `json:"message"` +} + +// ReceiptCheck is the local verdict of the release job's receipt gate. +type ReceiptCheck struct { + Gate ReleaseGate `json:"release_gate"` + // Released is HEAD, the commit the derivation reads the receipts tree of. + Released string `json:"released,omitempty"` + // Commit is the content commit the receipts must name: the one derived from + // the receipts directory, or — when no receipts directory names a commit on + // this lineage — HEAD, the roll commit a first receipt must name. + Commit string `json:"commit,omitempty"` + // Derived reports that Commit came from the release job's derivation. When + // false the release job's derive step itself refuses, and DeriveError is its + // reason. + Derived bool `json:"derived"` + DeriveError string `json:"derive_error,omitempty"` + // Problems is every refusal the gate would raise, one per gate. + Problems []ReceiptProblem `json:"problems"` + // Uncommitted lists working-tree changes under the receipts directory. The + // release job reads the committed tree, so a receipt that exists only here + // would pass locally and be missing there: any such change refuses. + Uncommitted []string `json:"uncommitted,omitempty"` + // Pass is the verdict: exactly when the release job's receipt gate admits + // the same repository state. + Pass bool `json:"pass"` +} + +// ErrReceiptConfig is returned when the gate is armed but record-lint's config +// cannot be loaded — the release job would stop at the same point. +var ErrReceiptConfig = errors.New("receipt gate: cannot load the record-lint config the release job arms") + +// CheckReleaseReceipts runs the release job's receipt gate against root's HEAD, +// locally. It is the release job's reader, not a model of it: the same +// derivation, the same arming and the same check, with the required gates read +// from the committed release workflow. A workflow that arms no gate passes with +// nothing required, exactly as the release job has no gate step to fail. +func CheckReleaseReceipts(root string) (ReceiptCheck, error) { + gate, err := ReadReleaseGate(root) + if err != nil { + return ReceiptCheck{}, err + } + check := ReceiptCheck{Gate: gate, Problems: []ReceiptProblem{}} + if !gate.Armed { + check.Pass = true + return check, nil + } + + cfg, err := LoadConfig(filepath.Join(root, filepath.FromSlash(recordLintConfigPath))) + if err != nil { + // record-lint exits 2 on an unloadable config before it arms anything, + // so the release job fails here too; the local check refuses in kind. + check.Problems = append(check.Problems, ReceiptProblem{ + Path: recordLintConfigPath, + Message: ErrReceiptConfig.Error() + ": " + pathFree(err), + }) + return check, nil + } + + released, err := gitutil.Run(root, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return ReceiptCheck{}, err + } + check.Released = released + + commit := released + if content, derr := DeriveReleaseContentSha(root, released); derr != nil { + // Root-relative, as record-lint prints it: the reason reaches --json. + check.DeriveError = fsutil.RedactRoot(derr.Error(), root, ".") + } else { + commit, check.Derived = content, true + } + check.Commit = commit + + armed := ArmReceiptGate(cfg, commit, gate.Gates) + findings, err := checkReceiptGate(root, armed.Rules["receipt_gate"]) + if err != nil { + return ReceiptCheck{}, err + } + for _, f := range findings { + check.Problems = append(check.Problems, ReceiptProblem{ + Gate: gateOfReceiptPath(f.File, commit), Path: filepath.ToSlash(f.File), Message: f.Message, + }) + } + + status, err := gitutil.Run(root, "status", "--porcelain", "--untracked-files=all", "--", reviewsSubdir) + if err != nil { + return ReceiptCheck{}, err + } + for _, line := range strings.Split(status, "\n") { + if line = strings.TrimSpace(line); line != "" { + check.Uncommitted = append(check.Uncommitted, line) + } + } + + check.Pass = check.Derived && len(check.Problems) == 0 && len(check.Uncommitted) == 0 + return check, nil +} + +// gateOfReceiptPath names the gate a finding's receipt path belongs to: +// //.json. A finding about the arming itself names no gate. +func gateOfReceiptPath(file, commit string) string { + file = filepath.ToSlash(file) + dir, base := filepath.Split(file) + if !strings.HasSuffix(base, ".json") || filepath.Base(filepath.Clean(dir)) != commit { + return "" + } + return strings.TrimSuffix(base, ".json") +} + +// pathFree renders a filesystem error without the absolute path it carries. +func pathFree(err error) string { + var pe *os.PathError + if errors.As(err, &pe) { + return pe.Op + ": " + pe.Err.Error() + } + return err.Error() +} diff --git a/internal/core/lint/releasereceipts_test.go b/internal/core/lint/releasereceipts_test.go new file mode 100644 index 000000000..a541da831 --- /dev/null +++ b/internal/core/lint/releasereceipts_test.go @@ -0,0 +1,206 @@ +package lint_test + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/lint" + "github.com/intentdriven/abcd/internal/gittest" +) + +// armedWorkflow is a release workflow whose verify job runs the receipt gate +// with two required gates, in the shape the release template renders it — the +// comment above it mentions the gate too, which must arm nothing. +const armedWorkflow = `name: release +jobs: + verify: + steps: + # record-lint --release-gate is described here; --require-gate prose-gate + - name: Semantic-gate receipts (fail-closed, before tag) + run: | + content="$(go run ./cmd/record-lint --derive-content-sha)" + go run ./cmd/record-lint --release-gate "$content" \ + --require-gate docs-currency-reviewer \ + --require-gate "iss35-brief-surface-crosscheck" +` + +// bareWorkflow runs no receipt gate: the deterministic gates alone admit it. +const bareWorkflow = `name: release +jobs: + verify: + steps: + - name: Build + run: go build ./... +` + +// minimalRecordLintConfig is the smallest config record-lint loads: the +// receipt gate present and disabled, as the committed config carries it. +const minimalRecordLintConfig = `{"rules":{"receipt_gate":{"enabled":false,"severity":"blocker","receipts_dir":".abcd/work/reviews","required_gates":[]}}}` + +func TestReadReleaseGateReadsTheWorkflowsRequireGateList(t *testing.T) { + r := gittest.NewRepo(t) + g, err := lint.ReadReleaseGate(r.Root()) + if err != nil { + t.Fatal(err) + } + if g.Present || g.Armed || len(g.Gates) != 0 { + t.Errorf("no workflow must arm nothing, got %+v", g) + } + + r.Write(lint.ReleaseWorkflowPath, bareWorkflow) + if g, _ = lint.ReadReleaseGate(r.Root()); !g.Present || g.Armed { + t.Errorf("a workflow with no receipt gate is present and unarmed, got %+v", g) + } + + r.Write(lint.ReleaseWorkflowPath, armedWorkflow) + g, err = lint.ReadReleaseGate(r.Root()) + if err != nil { + t.Fatal(err) + } + want := []string{"docs-currency-reviewer", "iss35-brief-surface-crosscheck"} + if !g.Armed || !reflect.DeepEqual(g.Gates, want) { + t.Errorf("armed workflow read as %+v, want armed with %v (comments never arm)", g, want) + } +} + +// TestReadReleaseGateReadsAbcdsOwnReleaseWorkflow pins the reader to the +// workflow this repository actually releases with, so a change to how the +// template spells the gate cannot silently disarm the local check. +func TestReadReleaseGateReadsAbcdsOwnReleaseWorkflow(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) + g, err := lint.ReadReleaseGate(root) + if err != nil { + t.Fatal(err) + } + want := []string{"docs-currency-reviewer", "iss35-brief-surface-crosscheck"} + if !g.Present || !g.Armed || !reflect.DeepEqual(g.Gates, want) { + t.Errorf("abcd's release.yml read as %+v, want armed with %v", g, want) + } +} + +// promote is a receipt the gate admits for gate at commit. +func promote(gate, commit string) string { + return `{"subject":{"digest":{"gitCommit":"` + commit + `"}},"verificationResult":"PROMOTE",` + + `"policy":{"detector":"` + gate + `"},"judgeModel":"claude-opus-4-8"}` + "\n" +} + +// releaseFixture is a repository with the armed workflow and a release branch +// whose tip is the CHANGELOG roll; it returns the roll commit. +func releaseFixture(t *testing.T) (*gittest.Repo, string) { + t.Helper() + r := gittest.NewRepo(t) + r.Write(lint.ReleaseWorkflowPath, armedWorkflow) + r.Write(".abcd/record-lint.json", minimalRecordLintConfig) + r.Write("CHANGELOG.md", "## [Unreleased]\n") + r.Commit("base") + r.Git("switch", "-c", "release") + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n") + r.Commit("roll (content)") + return r, r.Git("rev-parse", "HEAD") +} + +func TestCheckReleaseReceiptsNamesEveryMissingReceiptAndTheCommit(t *testing.T) { + r, roll := releaseFixture(t) + check, err := lint.CheckReleaseReceipts(r.Root()) + if err != nil { + t.Fatal(err) + } + if check.Pass || check.Derived || check.DeriveError == "" { + t.Fatalf("a roll with no receipts must refuse at the derivation, got %+v", check) + } + if check.Commit != roll { + t.Errorf("the receipts must name the roll commit %s, got %s", roll, check.Commit) + } + gates := map[string]bool{} + for _, p := range check.Problems { + gates[p.Gate] = true + if !strings.Contains(p.Message, roll) { + t.Errorf("problem %+v must name the commit the receipt must name (%s)", p, roll) + } + } + for _, g := range []string{"docs-currency-reviewer", "iss35-brief-surface-crosscheck"} { + if !gates[g] { + t.Errorf("missing receipt for %s not named; problems %+v", g, check.Problems) + } + } +} + +func TestCheckReleaseReceiptsNamesANonPromoteReceipt(t *testing.T) { + r, roll := releaseFixture(t) + dir := ".abcd/work/reviews/" + roll + "/" + r.Write(dir+"docs-currency-reviewer.json", promote("docs-currency-reviewer", roll)) + r.Write(dir+"iss35-brief-surface-crosscheck.json", + strings.Replace(promote("iss35-brief-surface-crosscheck", roll), "PROMOTE", "HOLD", 1)) + r.Commit("receipts") + + check, err := lint.CheckReleaseReceipts(r.Root()) + if err != nil { + t.Fatal(err) + } + if check.Pass || !check.Derived || check.Commit != roll { + t.Fatalf("want a derived refusal at the roll %s, got %+v", roll, check) + } + if len(check.Problems) != 1 || check.Problems[0].Gate != "iss35-brief-surface-crosscheck" || + !strings.Contains(check.Problems[0].Message, "not PROMOTE") { + t.Errorf("want exactly the HOLD receipt named, got %+v", check.Problems) + } +} + +func TestCheckReleaseReceiptsPassesAValidTwoCommitBranch(t *testing.T) { + r, roll := releaseFixture(t) + dir := ".abcd/work/reviews/" + roll + "/" + r.Write(dir+"docs-currency-reviewer.json", promote("docs-currency-reviewer", roll)) + r.Write(dir+"iss35-brief-surface-crosscheck.json", promote("iss35-brief-surface-crosscheck", roll)) + r.Commit("receipts") + + check, err := lint.CheckReleaseReceipts(r.Root()) + if err != nil { + t.Fatal(err) + } + if !check.Pass || check.Commit != roll || len(check.Problems) != 0 { + t.Errorf("a valid two-commit branch must pass against the roll, got %+v", check) + } + + // An uncommitted receipt is invisible to the release job, which reads the + // committed tree: the local check refuses on it rather than agreeing with a + // state the release will never see. + r.Write(dir+"extra.json", "{}\n") + if check, _ = lint.CheckReleaseReceipts(r.Root()); check.Pass || len(check.Uncommitted) == 0 { + t.Errorf("an uncommitted receipt change must refuse, got %+v", check) + } +} + +func TestCheckReleaseReceiptsRequiresNothingWhenTheWorkflowArmsNoGate(t *testing.T) { + r := gittest.NewRepo(t) + r.Write(lint.ReleaseWorkflowPath, bareWorkflow) + r.Commit("base") + check, err := lint.CheckReleaseReceipts(r.Root()) + if err != nil { + t.Fatal(err) + } + if !check.Pass || check.Gate.Armed || len(check.Problems) != 0 { + t.Errorf("no configured semantic gate requires nothing, got %+v", check) + } +} + +func TestCheckReleaseReceiptsRefusesAnArmedGateWithNoRecordLintConfig(t *testing.T) { + r, _ := releaseFixture(t) + if err := os.Remove(filepath.Join(r.Root(), ".abcd", "record-lint.json")); err != nil { + t.Fatal(err) + } + check, err := lint.CheckReleaseReceipts(r.Root()) + if err != nil { + t.Fatal(err) + } + if check.Pass || len(check.Problems) != 1 || check.Problems[0].Path != ".abcd/record-lint.json" { + t.Errorf("record-lint stops on a missing config, so the local check must refuse in kind, got %+v", check) + } + if strings.Contains(check.Problems[0].Message, r.Root()) { + t.Errorf("the refusal must be path-free, got %q", check.Problems[0].Message) + } +} diff --git a/internal/core/release/protocol.go b/internal/core/release/protocol.go new file mode 100644 index 000000000..aec1217db --- /dev/null +++ b/internal/core/release/protocol.go @@ -0,0 +1,75 @@ +package release + +import ( + "strings" + + "github.com/intentdriven/abcd/internal/core/lint" +) + +// ReceiptsProtocol is the receipts protocol the emit step ends with (itd-93 +// AC8, iss-327): what the operator does between the cut and the merge so the +// release job's receipt gate admits the release. The steps are composed here, +// from the release workflow's own required-gate list; the front door numbers +// and renders them. +// +// It exists because the protocol used to live only in the runbook, and a first +// release is exactly when nobody has read the runbook: a one-commit release +// branch reached a tag and failed there, the most expensive place to learn it. +type ReceiptsProtocol struct { + // Workflow is the release workflow the gate list was read from. + Workflow string `json:"workflow"` + // Armed reports that the workflow runs a receipt gate at all. + Armed bool `json:"armed"` + // RequiredGates are the semantic gates the release job requires, in the + // workflow's order. Empty when the workflow arms none. + RequiredGates []string `json:"required_gates"` + // Steps are the checklist, in order, unnumbered. + Steps []string `json:"steps"` +} + +// ReceiptsProtocolFor composes the receipts protocol for the repository at +// root, reading which semantic gates to run from its committed release +// workflow — the same list the release job and `abcd launch receipts` read, so +// the checklist can never ask for a gate the release does not require, or omit +// one it does. +func ReceiptsProtocolFor(root string) (ReceiptsProtocol, error) { + gate, err := lint.ReadReleaseGate(root) + if err != nil { + return ReceiptsProtocol{}, err + } + p := ReceiptsProtocol{Workflow: gate.Workflow, Armed: gate.Armed, RequiredGates: gate.Gates} + + roll := "Ingest the composed changelog (`abcd launch ship --changelog-json `) and commit the " + + "result on a release branch. That commit is the content commit: the commit the release publishes " + + "from and every receipt names." + switch { + case !gate.Present: + p.Steps = []string{ + roll, + "No `" + gate.Workflow + "` exists, so no receipt gate is armed and no receipt is required. " + + "`abcd launch scaffold` writes the release workflows; until then nothing tags or publishes the cut.", + } + case !gate.Armed: + p.Steps = []string{ + roll, + "`" + gate.Workflow + "` requires no semantic gate, so no receipt is required: the deterministic " + + "gates alone admit the release.", + "Open the release pull request and merge it once its checks are green; the auto-release workflow " + + "tags the merged commit and `" + gate.Workflow + "` publishes it.", + } + default: + p.Steps = []string{ + roll, + "Run each semantic gate `" + gate.Workflow + "` requires against the content commit: " + + strings.Join(gate.Gates, ", ") + ".", + "Record each PROMOTE receipt at `.abcd/work/reviews//.json`, keyed to the full " + + "40-character sha of the content commit (`git rev-parse HEAD` right after step 1) — never the tag, " + + "and never the merge.", + "Commit the receipts on top. The release branch is exactly two commits: the roll, then the receipts " + + "naming it. Amending the roll after this gives it a new sha and orphans every receipt.", + "Run `abcd launch receipts` on the release branch. It runs the release job's receipt gate locally " + + "and names each missing or non-PROMOTE receipt; merge only when it exits 0.", + } + } + return p, nil +} diff --git a/internal/core/release/protocol_test.go b/internal/core/release/protocol_test.go new file mode 100644 index 000000000..25dfcb6ab --- /dev/null +++ b/internal/core/release/protocol_test.go @@ -0,0 +1,84 @@ +package release_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/release" +) + +func writeWorkflow(t *testing.T, root, body string) { + t.Helper() + path := filepath.Join(root, ".github", "workflows", "release.yml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestReceiptsProtocolNamesTheGatesTheCommitAndTheTwoCommitShape is itd-93 +// AC8: the emit step ends with the receipts protocol as a numbered checklist — +// the semantic gates to run (read from the release workflow, the list the +// release job enforces), the commit to key the receipts to, and the two-commit +// branch shape — so a first-time operator learns it from the verb. +func TestReceiptsProtocolNamesTheGatesTheCommitAndTheTwoCommitShape(t *testing.T) { + root := t.TempDir() + writeWorkflow(t, root, "jobs:\n verify:\n steps:\n - run: |\n"+ + " go run ./cmd/record-lint --release-gate \"$content\" \\\n"+ + " --require-gate docs-currency-reviewer \\\n"+ + " --require-gate iss35-brief-surface-crosscheck\n") + p, err := release.ReceiptsProtocolFor(root) + if err != nil { + t.Fatal(err) + } + if len(p.RequiredGates) != 2 { + t.Fatalf("required gates = %v, want the workflow's two", p.RequiredGates) + } + all := strings.Join(p.Steps, "\n") + for _, want := range []string{ + "docs-currency-reviewer", "iss35-brief-surface-crosscheck", // the gates to run + "content commit", ".abcd/work/reviews//.json", // the commit to key receipts to + "exactly two commits", // the branch shape + "abcd launch receipts", // the local proof before merge + } { + if !strings.Contains(all, want) { + t.Errorf("protocol must carry %q; steps:\n%s", want, all) + } + } + if len(p.Steps) < 4 { + t.Errorf("protocol is a checklist of at least four steps, got %d", len(p.Steps)) + } +} + +// TestReceiptsProtocolWithNoGateRequiresNoReceipt is the AC3 degradation seen +// from the emit step: a workflow that arms no semantic gate gets a protocol +// that says no receipt is required, never one that asks for receipts nothing +// will read. +func TestReceiptsProtocolWithNoGateRequiresNoReceipt(t *testing.T) { + root := t.TempDir() + writeWorkflow(t, root, "jobs:\n verify:\n steps:\n - run: go build ./...\n") + p, err := release.ReceiptsProtocolFor(root) + if err != nil { + t.Fatal(err) + } + all := strings.Join(p.Steps, "\n") + if len(p.RequiredGates) != 0 || !strings.Contains(all, "no receipt is required") { + t.Errorf("an unarmed workflow requires no receipt; got gates %v, steps:\n%s", p.RequiredGates, all) + } + if strings.Contains(all, "two commits") { + t.Errorf("with no receipt there is no second commit to describe; steps:\n%s", all) + } + + // No release workflow at all names the scaffold that writes one. + bare := t.TempDir() + if p, err = release.ReceiptsProtocolFor(bare); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(p.Steps, "\n"), "abcd launch scaffold") { + t.Errorf("a repository with no release workflow must be pointed at the scaffold; steps: %v", p.Steps) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 088ff9354..9cac1005d 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -394,6 +394,9 @@ func NewRootCommand() *cobra.Command { // auto-release.yml, runbook) into a managed repo that lacks it (itd-93). It // extends 04-launch because launch already owns how a release is cut and gated. launchCmd.AddCommand(newLaunchScaffoldCommand(&asJSON)) + // `receipts` runs the release job's semantic-receipt gate locally, before + // the merge, through the same reader the job runs (itd-93 AC7). + launchCmd.AddCommand(newLaunchReceiptsCommand(&asJSON)) // `smoke-pages` is the deep installability tier's child process (itd-66): // hidden and operator-internal, re-executed by the preview and the cut. launchCmd.AddCommand(newLaunchSmokePagesCommand()) diff --git a/internal/surface/cli/launch_receipts.go b/internal/surface/cli/launch_receipts.go new file mode 100644 index 000000000..420cb0376 --- /dev/null +++ b/internal/surface/cli/launch_receipts.go @@ -0,0 +1,129 @@ +package cli + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/intentdriven/abcd/internal/core/lint" + "github.com/intentdriven/abcd/internal/core/release" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" + "github.com/spf13/cobra" +) + +// newLaunchReceiptsCommand builds `abcd launch receipts` (itd-93 AC7, iss-327): +// the release job's receipt gate, run locally on the release branch before the +// merge. A red result here costs an amend; the same result in the release job +// costs a release run. +// +// It is the release job's reader, not a model of it (lint.CheckReleaseReceipts): +// the content commit is derived from the receipts directory the way the job +// derives it, the required gates are read from the committed release workflow +// the job runs, and the verdict is the job's own check. So the two cannot +// disagree about a repository state, and a test holds them to that. +// +// Exit codes: +// +// - 0 — the release job's receipt gate admits this state, or the release +// workflow arms no receipt gate (nothing is required). +// - 1 — it refuses; the report names each missing or non-PROMOTE receipt and +// the commit it must name. +// - 2 — a structural fault (the repository or its workflow could not be read). +func newLaunchReceiptsCommand(asJSON *bool) *cobra.Command { + return &cobra.Command{ + Use: "receipts", + Short: "Run the release job's semantic-receipt gate locally, before the merge (exit 1 when it would refuse)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + check, err := lint.CheckReleaseReceipts(cwd) + if err != nil { + return &exitError{Code: 2, Msg: "abcd launch receipts: " + scrubPaths(err)} + } + if rerr := render(cmd.OutOrStdout(), *asJSON, check, func(w io.Writer) { + renderReceiptCheck(w, check) + }); rerr != nil { + return rerr + } + if !check.Pass { + return &exitError{Code: 1} + } + return nil + }, + } +} + +// renderReceiptCheck prints the local verdict: the gate list and where it came +// from, the commit the receipts must name, and every refusal. Receipt contents +// and paths come from the working tree, so every one is sanitised. +func renderReceiptCheck(w io.Writer, c lint.ReceiptCheck) { + verdict := "PASS" + if !c.Pass { + verdict = "REFUSED" + } + fmt.Fprintf(w, "abcd launch receipts — %s\n", verdict) + switch { + case !c.Gate.Present: + fmt.Fprintf(w, " %s is absent, so no receipt gate is armed and no receipt is required.\n", c.Gate.Workflow) + return + case !c.Gate.Armed: + fmt.Fprintf(w, " %s requires no semantic gate: the deterministic gates alone admit the release.\n", c.Gate.Workflow) + return + } + fmt.Fprintf(w, " required gates: %s (from %s)\n", + termsafe.Sanitize(strings.Join(c.Gate.Gates, ", ")), c.Gate.Workflow) + if c.Commit != "" { + how := "derived from the receipts directory, as the release job derives it" + if !c.Derived { + how = "HEAD, the roll: no receipts directory names a commit on this branch yet" + } + fmt.Fprintf(w, " receipts must name: %s (%s)\n", c.Commit, how) + } + if c.DeriveError != "" { + fmt.Fprintf(w, " release job's derivation refuses: %s\n", termsafe.Sanitize(scrubMessage(c.DeriveError))) + } + for _, p := range c.Problems { + label := p.Gate + if label == "" { + label = "gate" + } + fmt.Fprintf(w, " - %s: %s\n", termsafe.Sanitize(label), termsafe.Sanitize(scrubMessage(p.Message))) + } + if len(c.Uncommitted) > 0 { + fmt.Fprintln(w, " uncommitted receipt changes (the release job reads the committed tree, so commit them first):") + for _, u := range c.Uncommitted { + fmt.Fprintf(w, " %s\n", termsafe.Sanitize(u)) + } + } + if c.Pass { + fmt.Fprintln(w, " the release job's receipt gate admits this state.") + } +} + +// scrubMessage redacts the two developer-identity roots — the working +// directory and the home directory — out of a message that is text rather than +// an error, the way scrubPaths does for an error. +func scrubMessage(msg string) string { + if cwd, e := os.Getwd(); e == nil { + msg = fsutil.RedactRoot(msg, cwd, ".") + } + if home, e := os.UserHomeDir(); e == nil { + msg = fsutil.RedactRoot(msg, home, "~") + } + return msg +} + +// renderReceiptsProtocol ends the emit step's render with the receipts protocol +// as a numbered checklist (itd-93 AC8). The steps are composed in core, from +// the release workflow's own required-gate list. +func renderReceiptsProtocol(w io.Writer, p release.ReceiptsProtocol) { + fmt.Fprintln(w, " receipts protocol (before you merge the release):") + for i, step := range p.Steps { + fmt.Fprintf(w, " %d. %s\n", i+1, termsafe.Sanitize(step)) + } +} diff --git a/internal/surface/cli/launch_receipts_test.go b/internal/surface/cli/launch_receipts_test.go new file mode 100644 index 000000000..45082840a --- /dev/null +++ b/internal/surface/cli/launch_receipts_test.go @@ -0,0 +1,295 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/lint" + "github.com/intentdriven/abcd/internal/core/release" + "github.com/intentdriven/abcd/internal/gittest" +) + +// committedReleaseWorkflow is this repository's own release.yml — the workflow +// whose receipt gate `launch receipts` must agree with. +func committedReleaseWorkflow(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRootForTest(t), filepath.FromSlash(lint.ReleaseWorkflowPath))) + if err != nil { + t.Fatalf("read the committed release workflow: %v", err) + } + return string(data) +} + +// repoRootForTest is the module root, two directories above this package. +func repoRootForTest(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Clean(filepath.Join(wd, "..", "..", "..")) +} + +// receiptsRepo is a release branch whose tip is the CHANGELOG roll, in a +// repository carrying abcd's own release workflow and a record-lint config. +func receiptsRepo(t *testing.T) (*gittest.Repo, string) { + t.Helper() + r := gittest.NewRepo(t) + r.Write(lint.ReleaseWorkflowPath, committedReleaseWorkflow(t)) + r.Write(".abcd/record-lint.json", + `{"rules":{"receipt_gate":{"enabled":false,"severity":"blocker","receipts_dir":".abcd/work/reviews","required_gates":[]}}}`+"\n") + r.Write("CHANGELOG.md", "## [Unreleased]\n") + r.Commit("base") + r.Git("switch", "-c", "release") + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n") + r.Commit("roll (content)") + return r, r.Git("rev-parse", "HEAD") +} + +func receiptJSON(gate, commit, verdict, detector string) string { + return `{"subject":{"digest":{"gitCommit":"` + commit + `"}},"verificationResult":"` + verdict + `",` + + `"policy":{"detector":"` + detector + `"},"judgeModel":"claude-opus-4-8"}` + "\n" +} + +// TestLaunchReceiptsNamesEachMissingReceiptAndTheCommit is itd-93 AC7's +// first half, wired: on a release branch whose content commit has no receipts, +// the verb names every required gate's missing receipt and the commit it must +// name, and exits 1; once the second commit carries valid receipts it exits 0. +func TestLaunchReceiptsNamesEachMissingReceiptAndTheCommit(t *testing.T) { + r, roll := receiptsRepo(t) + + out, err := shipIn(t, r, "launch", "receipts") + if code := exitCodeOf(err); code != 1 { + t.Fatalf("exit = %d, want 1 (the gate refuses)\n%s", code, out) + } + for _, want := range []string{"REFUSED", roll, "docs-currency-reviewer", "iss35-brief-surface-crosscheck"} { + if !strings.Contains(string(out), want) { + t.Errorf("render does not name %q:\n%s", want, out) + } + } + + dir := ".abcd/work/reviews/" + roll + "/" + r.Write(dir+"docs-currency-reviewer.json", receiptJSON("docs-currency-reviewer", roll, "PROMOTE", "docs-currency-reviewer")) + r.Write(dir+"iss35-brief-surface-crosscheck.json", + receiptJSON("iss35-brief-surface-crosscheck", roll, "PROMOTE", "iss35-brief-surface-crosscheck")) + r.Commit("receipts") + + out, err = shipIn(t, r, "launch", "receipts", "--json") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("exit = %d, want 0 on a valid two-commit branch\n%s", code, out) + } + var check lint.ReceiptCheck + if err := json.Unmarshal(out, &check); err != nil { + t.Fatalf("--json: %v\n%s", err, out) + } + if !check.Pass || check.Commit != roll || !check.Derived { + t.Errorf("want a derived pass keyed to the roll %s, got %+v", roll, check) + } +} + +// TestLaunchReceiptsFailsIdenticallyToTheReleaseJobsGate is itd-93 AC7's +// second half. It runs the release job's receipt step itself — the run script +// lifted verbatim out of this repository's committed release.yml, against a +// record-lint built from this tree — and `abcd launch receipts` over the same +// repository states, and holds them to one verdict and one set of reasons. The +// two share a reader by construction (lint.CheckReleaseReceipts calls the +// derivation, the arming and the check record-lint runs); this is the proof +// that no difference in how either front door reaches it has crept in. +func TestLaunchReceiptsFailsIdenticallyToTheReleaseJobsGate(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is required to run the release job's step") + } + recordLint := filepath.Join(t.TempDir(), "record-lint") + build := exec.Command("go", "build", "-o", recordLint, "./cmd/record-lint") + build.Dir = repoRootForTest(t) + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build record-lint: %v\n%s", err, out) + } + script := strings.ReplaceAll(receiptStepScript(t, committedReleaseWorkflow(t)), "go run ./cmd/record-lint", recordLint) + + type state func(r *gittest.Repo, roll string) + receipts := func(docs, cross string) state { + return func(r *gittest.Repo, roll string) { + dir := ".abcd/work/reviews/" + roll + "/" + if docs != "" { + r.Write(dir+"docs-currency-reviewer.json", docs) + } + if cross != "" { + r.Write(dir+"iss35-brief-surface-crosscheck.json", cross) + } + r.Commit("receipts") + } + } + cases := []struct { + name string + state func(roll string) state + pass bool + }{ + {"no receipts at all", func(string) state { return func(*gittest.Repo, string) {} }, false}, + {"one receipt missing", func(roll string) state { + return receipts(receiptJSON("", roll, "PROMOTE", "docs-currency-reviewer"), "") + }, false}, + {"one receipt HOLD", func(roll string) state { + return receipts(receiptJSON("", roll, "PROMOTE", "docs-currency-reviewer"), + receiptJSON("", roll, "HOLD", "iss35-brief-surface-crosscheck")) + }, false}, + {"a receipt bound to the wrong detector", func(roll string) state { + return receipts(receiptJSON("", roll, "PROMOTE", "docs-currency-reviewer"), + receiptJSON("", roll, "PROMOTE", "docs-currency-reviewer")) + }, false}, + {"every receipt PROMOTE", func(roll string) state { + return receipts(receiptJSON("", roll, "PROMOTE", "docs-currency-reviewer"), + receiptJSON("", roll, "PROMOTE", "iss35-brief-surface-crosscheck")) + }, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, roll := receiptsRepo(t) + tc.state(roll)(r, roll) + + job := exec.Command("bash", "-c", script) + job.Dir = r.Root() + job.Env = append(r.Env(), "GITHUB_OUTPUT="+filepath.Join(t.TempDir(), "output")) + jobOut, jobErr := job.CombinedOutput() + jobPass := jobErr == nil + + out, err := shipIn(t, r, "launch", "receipts", "--json") + code := exitCodeOf(err) + if code != 0 && code != 1 { + t.Fatalf("launch receipts exit = %d\n%s", code, out) + } + var check lint.ReceiptCheck + if err := json.Unmarshal(out, &check); err != nil { + t.Fatalf("--json: %v\n%s", err, out) + } + + if jobPass != tc.pass || check.Pass != tc.pass || (code == 0) != tc.pass { + t.Fatalf("verdicts disagree: release job pass=%v, launch receipts pass=%v (exit %d), want %v\nrelease job:\n%s\nlaunch receipts:\n%s", + jobPass, check.Pass, code, tc.pass, jobOut, out) + } + // Same reasons: every refusal the local check names is one the + // release job printed, and the job printed no receipt_gate finding + // the local check left out. + // When the derivation itself refuses, the release job stops there + // and prints only that; the local check says the same thing first + // and then names the receipts the roll still needs, which is what + // the operator acts on. + if !check.Derived { + if check.DeriveError == "" || !strings.Contains(string(jobOut), check.DeriveError) { + t.Errorf("the derivation refusal %q is not the one the release job printed:\n%s", check.DeriveError, jobOut) + } + return + } + for _, p := range check.Problems { + if !strings.Contains(string(jobOut), p.Message) { + t.Errorf("launch receipts names %q, which the release job did not print:\n%s", p.Message, jobOut) + } + } + if n := strings.Count(string(jobOut), "receipt_gate]"); n != len(check.Problems) { + t.Errorf("release job printed %d receipt_gate finding(s), launch receipts named %d\n%s\n%+v", + n, len(check.Problems), jobOut, check.Problems) + } + }) + } +} + +// receiptStepScript lifts the run script of release.yml's receipt step out of +// the workflow, dedented, exactly as the runner hands it to bash. +func receiptStepScript(t *testing.T, workflow string) string { + t.Helper() + lines := strings.Split(workflow, "\n") + start := -1 + for i, l := range lines { + if strings.Contains(l, "- name: Semantic-gate receipts") { + start = i + break + } + } + if start < 0 { + t.Fatal("release.yml carries no Semantic-gate receipts step") + } + for i := start + 1; i < len(lines); i++ { + l := lines[i] + if strings.TrimSpace(l) != "run: |" { + continue + } + indent := len(l) - len(strings.TrimLeft(l, " ")) + var body []string + bodyIndent := -1 + for _, b := range lines[i+1:] { + if strings.TrimSpace(b) == "" { + body = append(body, "") + continue + } + bi := len(b) - len(strings.TrimLeft(b, " ")) + if bi <= indent { + break + } + if bodyIndent < 0 { + bodyIndent = bi + } + body = append(body, b[bodyIndent:]) + } + return strings.Join(body, "\n") + "\n" + } + t.Fatal("the Semantic-gate receipts step has no run block") + return "" +} + +// TestLaunchShipEmitEndsWithTheReceiptsProtocol is itd-93 AC8, wired: the +// emit step's render ends with the receipts protocol as a numbered checklist, +// and --json carries the same steps. +func TestLaunchShipEmitEndsWithTheReceiptsProtocol(t *testing.T) { + r := shipReadyRepo(t) + r.Write(lint.ReleaseWorkflowPath, committedReleaseWorkflow(t)) + r.Commit("the release workflow") + + out, err := shipIn(t, r, "launch", "ship") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("exit = %d, want 0\n%s", code, out) + } + text := strings.TrimRight(string(out), "\n") + head := strings.Index(text, "receipts protocol") + if head < 0 { + t.Fatalf("the emit render carries no receipts protocol:\n%s", text) + } + tail := text[head:] + proto, err := release.ReceiptsProtocolFor(r.Root()) + if err != nil { + t.Fatal(err) + } + for i, step := range proto.Steps { + if !strings.Contains(tail, "\n "+strconv.Itoa(i+1)+". "+step) { + t.Errorf("checklist step %d is not rendered numbered:\n%s", i+1, tail) + } + } + lastLine := text[strings.LastIndex(text, "\n")+1:] + if want := " " + strconv.Itoa(len(proto.Steps)) + ". " + proto.Steps[len(proto.Steps)-1]; lastLine != want { + t.Errorf("the render must END with the protocol's last step;\n got %q\nwant %q", lastLine, want) + } + for _, want := range []string{"docs-currency-reviewer", "exactly two commits", "abcd launch receipts"} { + if !strings.Contains(tail, want) { + t.Errorf("protocol render lacks %q:\n%s", want, tail) + } + } + + out, err = shipIn(t, r, "launch", "ship", "--json") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("--json exit = %d\n%s", code, out) + } + var got struct { + Ready bool `json:"ready"` + Protocol release.ReceiptsProtocol `json:"receipts_protocol"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("--json: %v\n%s", err, out) + } + if !got.Ready || len(got.Protocol.Steps) != len(proto.Steps) { + t.Errorf("--json must keep the cut's fields and carry the protocol; got %+v", got) + } +} diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go index d78c8f587..1af8c9235 100644 --- a/internal/surface/cli/ship.go +++ b/internal/surface/cli/ship.go @@ -357,8 +357,16 @@ func newLaunchShipCommand(asJSON *bool) *cobra.Command { if err != nil { return &exitError{Code: 2, Msg: "abcd launch ship: " + scrubPaths(err)} } - if rerr := render(cmd.OutOrStdout(), *asJSON, cut, func(w io.Writer) { + // The emit step ends with the receipts protocol (itd-93 AC8), so a + // first-time operator learns it from the verb, not a failed release. + proto, err := release.ReceiptsProtocolFor(cwd) + if err != nil { + return &exitError{Code: 2, Msg: "abcd launch ship: " + scrubPaths(err)} + } + emitted := shipEmit{Cut: cut, ReceiptsProtocol: proto} + if rerr := render(cmd.OutOrStdout(), *asJSON, emitted, func(w io.Writer) { renderCut(w, "abcd launch ship", cut) + renderReceiptsProtocol(w, proto) }); rerr != nil { return rerr } @@ -381,6 +389,14 @@ func newLaunchShipCommand(asJSON *bool) *cobra.Command { return cmd } +// shipEmit is the emit step's report: the cut, unchanged in shape (embedded, +// so its JSON fields stay where they were), plus the receipts protocol it ends +// with. +type shipEmit struct { + release.Cut + ReceiptsProtocol release.ReceiptsProtocol `json:"receipts_protocol"` +} + // runShipIngest is the ingest step of `abcd launch ship`: validate the composed // prose against the cut, write the dated heading, and — when the repository // declares that its release publishes the plugin archive — render that archive From 8b38458ce2f0eade135d11dfddacf90936519578 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:58:25 +0100 Subject: [PATCH 05/27] =?UTF-8?q?chore:=20resolve=20iss-2609251751298408?= =?UTF-8?q?=20=E2=80=94=20launch.md=20places=20the=20receipt=20gate=20wher?= =?UTF-8?q?e=20it=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251751298408 Assisted-by: Claude:claude-opus-5-5 --- ...ands-launch-md-describes-the-release-job-s-semantic.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md (70%) diff --git a/.abcd/work/issues/open/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md b/.abcd/work/issues/resolved/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md similarity index 70% rename from .abcd/work/issues/open/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md rename to .abcd/work/issues/resolved/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md index 238661883..0207036c8 100644 --- a/.abcd/work/issues/open/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md +++ b/.abcd/work/issues/resolved/iss-2609251751298408-commands-launch-md-describes-the-release-job-s-semantic.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "commands/launch.md" +resolution: "commands/launch.md states where the receipt gate runs as release.yml runs it: in verify, before the tag on the auto-release path, with the content commit derived from the receipts directory." +impact: fix +resolved_by: + commit: "273f8cbc" --- commands/launch.md describes the release job's semantic receipt gate at a position it no longer holds. Four places — the Ship intro ('the tag is already created by then'), the release-day failure list ('The tag exists by then'), Semantic receipts ('release.yml derives the content commit as ^2^') and 'Prove the gate before you merge' ('receipt_gate runs inside the release job, which is after the tag is created') — predate adr-52 and iss-355: release.yml runs the gate in its verify job, which the tag job needs, so on the auto-release path a refusal leaves no tag and the version free, and the content commit is derived from the receipts directory of the released tree (record-lint --derive-content-sha), not from merge ancestry. Only a hand-pushed tag exists before the gate. An operator reading the page believes a refusal consumes the version and reaches for a tag deletion the machinery no longer needs. + +## Grounds + +- pursued: an operator reading the page now expects a refused gate to leave the version free on the auto-release path and to consume it only on a hand-pushed tag; it would be shown wrong if release.yml's receipt step moved out of the verify job the tag job needs From 1cb31e2184d1887fb2f23223dade4440511f60a6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:05:01 +0100 Subject: [PATCH 06/27] feat(launch): wire the scaffold to the repo's own CI checks and write the reviews charter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit itd-93 AC1 (the missing half) and AC5 (the missing half), spc-2609230613193436. `launch scaffold` reads the managed repo's pull-request and merge-queue workflows and derives the check names they report (DeriveCIChecks): a job's own name, else its id; a matrix job, an expression-named job and a reusable-workflow call are omitted, never guessed; every name passes an allowlist that excludes every YAML and shell metacharacter before it is written. The names reach the runbook's new merge-gate section (the contexts to require on the default branch), the bare release.yml verify header, and the report (ci_checks). abcd's own rendering is unchanged, so self-scaffold parity holds. The scaffold also writes .abcd/development/release-gate/check-reviews.sh, the reviews charter (RD001): dated review directories keep their shape, and sha-keyed receipt directories (full SHA-1 or SHA-256) are exempt. The bare verify job runs it as deterministic gate 6, and the runbook numbers it from the same source. The runbook's semantic-gate account and procedure now teach the receipts-directory derivation and the two-commit protocol with `launch receipts`. Audit: zizmor is not installed where the tests run and is not a dependency, so TestScaffoldedWorkflowsPassTheWorkflowAudit asserts its two named classes with the repository's own tools over every profile — no duplicate mapping key, no ${{ }} inside a run script, and every expression resolving under the strict actionsexpr evaluator against a context set no pull-request author writes. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/04-launch.md | 13 +- commands/launch.md | 17 +- docs/reference/cli/commands.md | 2 +- internal/core/launch/scaffold/cichecks.go | 254 +++++++++++ internal/core/launch/scaffold/render.go | 24 +- internal/core/launch/scaffold/scaffold.go | 20 +- .../core/launch/scaffold/scaffold_test.go | 19 +- .../core/launch/scaffold/substitutions.go | 15 +- .../scaffold/templates/check-reviews.sh.tmpl | 53 +++ .../scaffold/templates/release.yml.tmpl | 10 + .../launch/scaffold/templates/runbook.md.tmpl | 81 +++- internal/core/launch/scaffold/wiring_test.go | 411 ++++++++++++++++++ internal/surface/cli/scaffold.go | 14 +- 13 files changed, 885 insertions(+), 48 deletions(-) create mode 100644 internal/core/launch/scaffold/cichecks.go create mode 100644 internal/core/launch/scaffold/templates/check-reviews.sh.tmpl create mode 100644 internal/core/launch/scaffold/wiring_test.go diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index ef201b04f..91c65d555 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -114,8 +114,17 @@ version flag at all: the version is derived, never authored ([adr-31](../../decisions/adrs/0031-derived-versioning-from-intents.md)). **The scaffold writes the release machinery into a managed repo that lacks -it**: the two release workflows and the adr-37 release runbook, wired to the -repo's own default branch and Go version, token-scoped and injection-safe. The +it**: the two release workflows, the adr-37 release runbook and a reviews-charter +check, wired to the repo's own default branch and Go version and to the check +names its own pull-request CI reports, token-scoped and injection-safe. The check +names are read from the repo's pull-request and merge-queue workflows — a name +only a run knows (a matrix job, an expression-named job, a reusable-workflow +call) is omitted rather than guessed, and every name is held to an injection-safe +allowlist — and written into the runbook as the contexts to require on the default +branch and into the release workflow's verify header as its merge gate. The +reviews-charter check holds dated review directories to their shape and exempts +the sha-keyed receipt directories, and the scaffolded verify job runs it as a +deterministic gate, so a release's own receipts never fail the charter. The workflows ship from a single embedded template that abcd's own release workflows are regenerated from, proved byte-exact by a test, so a scaffolded repo and this one cannot drift. The scaffolded workflow carries a **rehearsal** that arms the diff --git a/commands/launch.md b/commands/launch.md index 613d6dfa4..95e6ef0a2 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -715,7 +715,8 @@ already has the machinery). It **never publishes**. "${CLAUDE_PLUGIN_ROOT}/abcd" launch scaffold --json ``` -It writes three files, wired to the repo's own default branch and Go version: +It writes four files, wired to the repo's own default branch and Go version and +to the check names its own pull-request CI reports: - `.github/workflows/release.yml` — verify → build → publish, the verify gate armed against the reviewed **content** commit (`HEAD^2^` on the auto-release @@ -723,7 +724,19 @@ It writes three files, wired to the repo's own default branch and Go version: receipt-vs-tag self-reference. - `.github/workflows/auto-release.yml` — newest dated CHANGELOG heading → tag that commit → call `release.yml`. `GITHUB_TOKEN`-only, no personal access token. -- `.abcd/development/release-gate/README.md` — the adr-37 runbook. +- `.abcd/development/release-gate/README.md` — the adr-37 runbook, including the + merge gate: the repo's own pull-request check names, to require on the default + branch. +- `.abcd/development/release-gate/check-reviews.sh` — the reviews charter (RD001): + dated review directories keep their shape, and the sha-keyed receipt + directories are exempt. The scaffolded `verify` job runs it. + +The check names come from the repo's workflows triggered by `pull_request` or +`merge_group`; a name only a run knows (a matrix job, an expression-named job, a +reusable-workflow call) is left out rather than guessed. Relay `ci_checks` and tell +the operator to require them on the default branch — the scaffold holds no token +and sets no branch protection. An empty `ci_checks` means no pull-request CI was +found, and the runbook says so. The workflows come from one embedded template that abcd-cli's own release workflows are regenerated from (self-scaffold parity), so every abcd release diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 43aa0c94b..7e71ff9fa 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1178,7 +1178,7 @@ Run the release job's semantic-receipt gate locally, before the merge (exit 1 wh #### `abcd launch scaffold` -Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook) into this repo +Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook, reviews charter) into this repo **Usage:** `abcd launch scaffold [--confirm] [flags]` diff --git a/internal/core/launch/scaffold/cichecks.go b/internal/core/launch/scaffold/cichecks.go new file mode 100644 index 000000000..7129b56ac --- /dev/null +++ b/internal/core/launch/scaffold/cichecks.go @@ -0,0 +1,254 @@ +package scaffold + +// Wiring to the managed repo's own CI check names (itd-93 AC1). +// +// A changelog-driven release is decided by merging the release pull request, +// so what gates that merge is what gates the release: the repository's own +// pull-request CI. The scaffold cannot configure branch protection — it writes +// files and holds no token — so it reads the repository's CI workflows, derives +// the check names its pull requests report, and writes them into the machinery +// it lays down: the runbook's merge-gate section, which tells the operator to +// require exactly these contexts on the default branch, and the release +// workflow's verify-job header, which names them as the merge gate the release +// job re-runs a subset of. Nothing is hard-coded to abcd's own check names. +// +// The derivation is a line reader over the GitHub Actions layout, not a YAML +// parser (no dependency is added for it), and it fails toward omission: a job +// whose check name GitHub computes at run time — a matrix job, a job named by an +// expression, a reusable-workflow call — is left out rather than guessed, and +// the runbook says a hand-added context may be needed. Every name that is kept +// is held to an allowlist before it is written anywhere, because the scaffold +// writes it into YAML and a hostile workflow file must not be able to inject. + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// workflowsDir is where GitHub Actions discovers workflows. +const workflowsDir = ".github/workflows" + +// maxWorkflowFiles bounds the directory walk; a repository with more workflow +// files than this is not one whose CI the scaffold can summarise faithfully. +const maxWorkflowFiles = 64 + +// checkNameRe is the allowlist for a derived check name written into the +// scaffolded files: letters, digits, space and a small punctuation set. It +// excludes every YAML and shell metacharacter (`:`, `#`, quotes, `$`, braces, +// backticks, newlines), so a name can only ever be inert text. +var checkNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._()/+-]{0,99}$`) + +// jobKeyRe matches a job id line under `jobs:`; the capture is the id. +var jobKeyRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*):\s*(#.*)?$`) + +// DeriveCIChecks reads repoRoot's own CI workflows and returns the check names +// its pull requests report, sorted and de-duplicated. Only workflows triggered +// by `pull_request`, `pull_request_target` or `merge_group` gate a merge, and +// the scaffold's own release workflows are never counted. An unreadable or +// absent workflows directory yields nil: the runbook then says no merge gate +// was found. +func DeriveCIChecks(repoRoot string) []string { + dir := filepath.Join(repoRoot, filepath.FromSlash(workflowsDir)) + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + seen := map[string]bool{} + files := 0 + for _, e := range entries { + name := e.Name() + if e.IsDir() || !(strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")) { + continue + } + if rel := workflowsDir + "/" + name; rel == ReleaseYMLPath || rel == AutoReleaseYMLPath { + continue + } + if files++; files > maxWorkflowFiles { + break + } + data, err := fsutil.ReadGuarded(filepath.Join(dir, name), maxWorkflowBytes) + if err != nil { + continue + } + for _, check := range workflowChecks(string(data)) { + seen[check] = true + } + } + if len(seen) == 0 { + return nil + } + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + sort.Strings(out) + return out +} + +// workflowChecks returns the check names one workflow file reports, or nil +// when the workflow does not gate a pull request. +func workflowChecks(src string) []string { + lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") + if !gatesAMerge(topLevelBlock(lines, "on")) { + return nil + } + jobs := topLevelBlock(lines, "jobs") + var out []string + for _, job := range splitJobs(jobs) { + if name, ok := job.checkName(); ok { + out = append(out, name) + } + } + return out +} + +// topLevelBlock returns the value of a top-level key: its inline remainder +// followed by every indented line up to the next top-level key. +func topLevelBlock(lines []string, key string) []string { + var out []string + in := false + for _, l := range lines { + if l == "" || strings.HasPrefix(strings.TrimSpace(l), "#") { + if in { + out = append(out, l) + } + continue + } + top := !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "\t") + if top { + if in { + break + } + k, rest, ok := strings.Cut(l, ":") + k = strings.Trim(strings.TrimSpace(k), `"'`) + if ok && k == key { + in = true + if r := strings.TrimSpace(rest); r != "" { + out = append(out, r) + } + } + continue + } + if in { + out = append(out, l) + } + } + return out +} + +// mergeTriggerRe matches an event that runs a workflow against a pull request +// or its merge-queue entry. +var mergeTriggerRe = regexp.MustCompile(`(^|[^A-Za-z_])(pull_request|pull_request_target|merge_group)([^A-Za-z_]|$)`) + +// gatesAMerge reports whether an `on:` block names a pull-request or +// merge-queue event. +func gatesAMerge(on []string) bool { + for _, l := range on { + t := strings.TrimSpace(l) + if strings.HasPrefix(t, "#") { + continue + } + if i := strings.Index(t, " #"); i >= 0 { + t = t[:i] + } + if mergeTriggerRe.MatchString(t) { + return true + } + } + return false +} + +// ciJob is one job's id and its own direct keys. +type ciJob struct { + id string + name string + named bool + matrix bool + uses bool +} + +// splitJobs reads the jobs block into its jobs, recording each job's own +// `name:`, whether it declares a matrix, and whether it calls a reusable +// workflow. Job ids sit at the block's first indentation; a job's own keys at +// the next one. +func splitJobs(block []string) []ciJob { + jobIndent := -1 + keyIndent := -1 + var jobs []ciJob + for _, l := range block { + t := strings.TrimSpace(l) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + ind := len(l) - len(strings.TrimLeft(l, " ")) + if jobIndent < 0 { + jobIndent = ind + } + switch { + case ind == jobIndent: + m := jobKeyRe.FindStringSubmatch(t) + if m == nil { + // Not a job id (a flow mapping, an anchor): stop trusting the block. + return jobs + } + jobs = append(jobs, ciJob{id: m[1]}) + keyIndent = -1 + case ind > jobIndent && len(jobs) > 0: + if keyIndent < 0 { + keyIndent = ind + } + job := &jobs[len(jobs)-1] + if ind == keyIndent { + k, v, _ := strings.Cut(t, ":") + switch strings.TrimSpace(k) { + case "name": + job.name, job.named = scalar(v), true + case "uses": + job.uses = true + } + } else if strings.HasPrefix(t, "matrix:") { + job.matrix = true + } + } + } + return jobs +} + +// checkName is the check context GitHub reports for the job, when it is +// knowable from the file: the job's `name:`, else its id. A matrix job, a +// reusable-workflow call and an expression-named job report names only the run +// knows, so they are omitted, never guessed. +func (j ciJob) checkName() (string, bool) { + if j.matrix || j.uses { + return "", false + } + name := j.id + if j.named { + name = j.name + } + if !checkNameRe.MatchString(name) { + return "", false + } + return name, true +} + +// scalar reads a plain or quoted YAML scalar from the text after a key's colon, +// dropping a trailing comment on an unquoted value. +func scalar(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') { + if end := strings.IndexByte(v[1:], v[0]); end >= 0 { + return v[1 : 1+end] + } + return v + } + if i := strings.Index(v, " #"); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(v) +} diff --git a/internal/core/launch/scaffold/render.go b/internal/core/launch/scaffold/render.go index d5caebf4f..75ac6c6ae 100644 --- a/internal/core/launch/scaffold/render.go +++ b/internal/core/launch/scaffold/render.go @@ -31,7 +31,7 @@ import ( "text/template" ) -//go:embed templates/release.yml.tmpl templates/auto-release.yml.tmpl templates/runbook.md.tmpl +//go:embed templates/release.yml.tmpl templates/auto-release.yml.tmpl templates/runbook.md.tmpl templates/check-reviews.sh.tmpl var templatesFS embed.FS // Gate is one named verify-job step: a display name and the shell it runs. The @@ -71,6 +71,13 @@ type Substitutions struct { // (`--require-gate `). Empty means no semantic detector is configured and // the deterministic gates alone admit the release (spc-14 clean degradation). SemanticGates []string + // CIChecks are the check names the managed repo's own pull-request CI + // reports (DeriveCIChecks): the merge gate the release roll passes through. + // The bare rendering names them in release.yml's verify header and lists them + // in the runbook as the contexts to require on DefaultBranch. Each is held to + // an injection-safe allowlist before it gets here. abcd's own rendering + // leaves this empty; its merge gate is ci.yml, described in its own runbook. + CIChecks []string } // Rendered is the file set a scaffold run produces, keyed by repo-relative path. @@ -78,6 +85,8 @@ type Rendered struct { ReleaseYML []byte AutoReleaseYML []byte Runbook []byte + // CheckReviews is the reviews-charter check (RD001) the bare verify job runs. + CheckReviews []byte } // Repo-relative destinations the scaffold writes. Fixed by the GitHub Actions @@ -86,9 +95,14 @@ const ( ReleaseYMLPath = ".github/workflows/release.yml" AutoReleaseYMLPath = ".github/workflows/auto-release.yml" RunbookPath = ".abcd/development/release-gate/README.md" + // CheckReviewsPath is the scaffolded reviews-charter check (RD001, with the + // sha-keyed receipt directories exempt). It sits beside the runbook, in the + // release-gate directory the scaffold already owns, rather than in a scripts + // directory the managed repository may lay out its own way. + CheckReviewsPath = ".abcd/development/release-gate/check-reviews.sh" ) -// Render binds the three templates against subs and returns their bytes. A +// Render binds the four templates against subs and returns their bytes. A // template parse or execute fault is a programming error in the embedded // templates, surfaced as an error rather than a panic. func Render(subs Substitutions) (Rendered, error) { @@ -104,7 +118,11 @@ func Render(subs Substitutions) (Rendered, error) { if err != nil { return Rendered{}, err } - return Rendered{ReleaseYML: rel, AutoReleaseYML: auto, Runbook: book}, nil + charter, err := renderOne("templates/check-reviews.sh.tmpl", subs) + if err != nil { + return Rendered{}, err + } + return Rendered{ReleaseYML: rel, AutoReleaseYML: auto, Runbook: book, CheckReviews: charter}, nil } // renderOne parses and executes a single embedded template with the `<%`/`%>` diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go index f4fe34b45..b30a331dd 100644 --- a/internal/core/launch/scaffold/scaffold.go +++ b/internal/core/launch/scaffold/scaffold.go @@ -66,10 +66,13 @@ type Report struct { // into them: they point setup-go at go.mod, so this reports the go directive // the run read. It is reported because an adopter should see which toolchain // their release lane is about to use, and see it before the first tag. - GoVersion string `json:"go_version"` - Files []FileOutcome `json:"files"` - Wrote int `json:"wrote"` - Refused int `json:"refused"` + GoVersion string `json:"go_version"` + // CIChecks are the managed repo's own pull-request check names the + // scaffolded files were wired to (DeriveCIChecks); empty when none was found. + CIChecks []string `json:"ci_checks"` + Files []FileOutcome `json:"files"` + Wrote int `json:"wrote"` + Refused int `json:"refused"` // NoOp is true when every file was already current (the idempotent re-run). NoOp bool `json:"no_op"` } @@ -84,8 +87,9 @@ type Request struct { } // Scaffold writes the changelog-driven release machinery into RepoRoot: a -// generic (bare-repo) release.yml, auto-release.yml, and the adr-37 runbook, each -// wired to the repo's own default branch. The Go toolchain is not wired in: the +// generic (bare-repo) release.yml, auto-release.yml, the adr-37 runbook and the +// reviews-charter check, wired to the repo's own default branch and to the check +// names its own pull-request CI reports. The Go toolchain is not wired in: the // workflows point setup-go at the repo's go.mod, so they follow its go directive // with no re-scaffold. It is idempotent and fail-safe: // @@ -99,12 +103,13 @@ type Request struct { func Scaffold(req Request) (Report, error) { branch, goVersion := DeriveRepoFacts(req.RepoRoot) subs := BareSubstitutions(branch) + subs.CIChecks = DeriveCIChecks(req.RepoRoot) rendered, err := Render(subs) if err != nil { return Report{}, err } - report := Report{Substitutions: subs, DefaultBranch: branch, GoVersion: goVersion} + report := Report{Substitutions: subs, DefaultBranch: branch, GoVersion: goVersion, CIChecks: subs.CIChecks} planned := []struct { rel string data []byte @@ -112,6 +117,7 @@ func Scaffold(req Request) (Report, error) { {ReleaseYMLPath, rendered.ReleaseYML}, {AutoReleaseYMLPath, rendered.AutoReleaseYML}, {RunbookPath, rendered.Runbook}, + {CheckReviewsPath, rendered.CheckReviews}, } // First pass: classify every file WITHOUT writing. A refusal on any file with diff --git a/internal/core/launch/scaffold/scaffold_test.go b/internal/core/launch/scaffold/scaffold_test.go index bff855aee..c15a2be5a 100644 --- a/internal/core/launch/scaffold/scaffold_test.go +++ b/internal/core/launch/scaffold/scaffold_test.go @@ -101,7 +101,7 @@ func TestBareRenderOmitsAbcdMachinery(t *testing.T) { // The abcd-specific detectors and steps must be gone. for _, needle := range []string{ "record-lint", "docs-currency-reviewer", "iss35-brief-surface-crosscheck", - "check-reviews.sh", "make smoke", "make build", "make fmt-check", "abcd docs lint", + "scripts/check-reviews.sh", "make smoke", "make build", "make fmt-check", "abcd docs lint", "semantic-release-gate", "Cross-compile the four binaries", "./internal/...", } { @@ -132,15 +132,15 @@ func TestBareRenderOmitsAbcdMachinery(t *testing.T) { // TestBareRunbookGateListMatchesWorkflow is the in-template gate_lockstep // property: the runbook's numbered deterministic-gate list must be exactly the -// generic five when no extra gate is configured, so a managed repo's own lockstep -// check stays green. +// generic five plus the scaffolded reviews-charter shape, the one extra gate a +// managed repo inherits, so a managed repo's own lockstep check stays green. func TestBareRunbookGateListMatchesWorkflow(t *testing.T) { rendered, err := Render(BareSubstitutions("main")) if err != nil { t.Fatal(err) } book := string(rendered.Runbook) - for _, g := range []string{"1. Format (gofmt)", "5. Test (race)"} { + for _, g := range []string{"1. Format (gofmt)", "5. Test (race)", "6. Reviews-charter shape (RD001)"} { if !strings.Contains(book, g) { t.Errorf("bare runbook must list deterministic gate %q", g) } @@ -150,8 +150,8 @@ func TestBareRunbookGateListMatchesWorkflow(t *testing.T) { if strings.Contains(book, "Test (race, internal)") { t.Error("bare runbook must not name the abcd-specific internal race leg") } - if strings.Contains(book, "6. ") { - t.Error("bare runbook must not number a sixth gate (no extra gates configured)") + if strings.Contains(book, "7. Plugin") || strings.Contains(book, "\n7. ") { + t.Error("bare runbook must not number a seventh gate (the charter is the only extra gate)") } } @@ -297,13 +297,14 @@ func TestScaffoldIdempotentAndRefusesHandEdit(t *testing.T) { mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n") gitInit(t, dir, "release-line") - // First run: three files written. + // First run: four files written — the two workflows, the runbook and the + // reviews-charter check. rep, err := Scaffold(Request{RepoRoot: dir}) if err != nil { t.Fatalf("first scaffold: %v", err) } - if rep.Wrote != 3 || rep.NoOp { - t.Fatalf("first run should write 3 files, got wrote=%d noop=%v", rep.Wrote, rep.NoOp) + if rep.Wrote != 4 || rep.NoOp { + t.Fatalf("first run should write 4 files, got wrote=%d noop=%v", rep.Wrote, rep.NoOp) } // Wired to the repo's own facts. if rep.DefaultBranch != "release-line" || rep.GoVersion != "1.22" { diff --git a/internal/core/launch/scaffold/substitutions.go b/internal/core/launch/scaffold/substitutions.go index 192d534ab..ff78cadc5 100644 --- a/internal/core/launch/scaffold/substitutions.go +++ b/internal/core/launch/scaffold/substitutions.go @@ -34,16 +34,25 @@ func AbcdSubstitutions() Substitutions { } } +// bareExtraGates are the deterministic verify steps a managed repo inherits +// beyond the generic Go leg: the reviews-charter shape (RD001) the scaffold +// writes beside the runbook, so a release's sha-keyed receipt directories are +// held exempt by the same file that holds the dated reviews to their shape. +var bareExtraGates = []Gate{ + {Name: "Reviews-charter shape (RD001)", Run: "bash " + CheckReviewsPath}, +} + // BareSubstitutions is the degraded fact set a managed repo with no semantic -// detectors receives: the deterministic Go gates alone, a generic build, and no -// host-run semantic gate (spc-14 clean degradation). DefaultBranch is the repo's +// detectors receives: the deterministic Go gates and the reviews-charter shape, +// a generic build, and no host-run semantic gate (spc-14 clean degradation). +// CIChecks is the caller's to set from DeriveCIChecks. DefaultBranch is the repo's // own fact, derived by the caller; the Go toolchain is not a substitution at all, // because the rendered workflows read it out of the adopter's go.mod. func BareSubstitutions(defaultBranch string) Substitutions { return Substitutions{ DefaultBranch: defaultBranch, Abcd: false, - ExtraGates: nil, + ExtraGates: bareExtraGates, SemanticGates: nil, } } diff --git a/internal/core/launch/scaffold/templates/check-reviews.sh.tmpl b/internal/core/launch/scaffold/templates/check-reviews.sh.tmpl new file mode 100644 index 000000000..e20446cfe --- /dev/null +++ b/internal/core/launch/scaffold/templates/check-reviews.sh.tmpl @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Reviews-charter shape (RD001), scaffolded by `abcd launch scaffold`. +# +# Each directory under .abcd/work/reviews/ is a dated review: it is named +# - and carries a 00-summary.md. The release gate's +# semantic receipts live in the same directory under a different key — the full +# sha of the commit they gate (.abcd/work/reviews//.json) — +# and are a distinct artefact class with their own check (the release job's +# receipt gate). They are exempt from the dated shape here, so the two review +# conventions never collide and a release's receipts never fail this charter. +# +# release.yml's verify job runs this on the released commit; it is safe to run +# locally from anywhere in the checkout. +set -euo pipefail + +rc=0 +toplevel="$(git rev-parse --show-toplevel 2>&1)" || rc=$? +if [ "$rc" -ne 0 ]; then + echo "check-reviews: not a git repository — refusing rather than reporting a vacuous pass:" >&2 + echo "$toplevel" >&2 + exit 2 +fi +cd "$toplevel" + +ROOT=".abcd/work/reviews" +[ -d "$ROOT" ] || { echo "check-reviews: no $ROOT — nothing to check"; exit 0; } + +fail=0 +checked=0 +for d in "$ROOT"/*/; do + [ -d "$d" ] || continue + base="$(basename "$d")" + # A semantic-gate receipt directory: keyed by a full SHA-1 or SHA-256 commit + # id, never by a date. Exempt from RD001. + if printf '%s' "$base" | grep -Eq '^([0-9a-f]{40}|[0-9a-f]{64})$'; then + continue + fi + checked=$((checked + 1)) + if ! printf '%s' "$base" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(-[a-z0-9]+)*$'; then + echo " RD001 $d — directory name must be - (or a full commit sha, for receipts)" >&2 + fail=1 + fi + if [ ! -f "${d}00-summary.md" ]; then + echo " RD001 $d — missing required 00-summary.md" >&2 + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + echo "check-reviews: FAILED — reviews-charter shape (RD001)" >&2 + exit 1 +fi +echo "check-reviews: OK — $ROOT ($checked dated review dir(s)), RD001 clean; sha-keyed receipt dirs exempt" diff --git a/internal/core/launch/scaffold/templates/release.yml.tmpl b/internal/core/launch/scaffold/templates/release.yml.tmpl index d464a2d58..89f0b6fd1 100644 --- a/internal/core/launch/scaffold/templates/release.yml.tmpl +++ b/internal/core/launch/scaffold/templates/release.yml.tmpl @@ -99,6 +99,16 @@ jobs: # is built or published. Checked out at github.sha — the commit the pushed tag # pointed at, never the default-branch tip or the re-resolvable tag name — so the # gate exercises exactly the commit that will ship. + # + # The MERGE gate is this repository's own pull-request CI: merging the release + # roll is the release decision, so these checks decide whether it lands at all. + # Require them on <% .DefaultBranch %> (derived from its workflows when this file + # was scaffolded; the release-gate runbook lists them with the how-to): +<%- range .CIChecks %> + # - <% . %> +<%- else %> + # (none found: no pull-request CI workflow existed; see the runbook) +<%- end %> <%- end %> verify: timeout-minutes: 15 diff --git a/internal/core/launch/scaffold/templates/runbook.md.tmpl b/internal/core/launch/scaffold/templates/runbook.md.tmpl index 0b17aaa65..19bf2d9b4 100644 --- a/internal/core/launch/scaffold/templates/runbook.md.tmpl +++ b/internal/core/launch/scaffold/templates/runbook.md.tmpl @@ -26,6 +26,32 @@ precondition for trusting the gate with a real tag: it proves the gate can be satisfied before the repo goes public, closing the private→public activation gap that otherwise surfaces only at the first real release. +<%- if not .Abcd %> +## Merge gate — this repository's CI checks + +Merging the release pull request is the release decision, so the checks that +gate that merge gate the release. +<%- if .CIChecks %> `abcd launch scaffold` read this repository's +pull-request workflows and found these checks. Require each of them on +`<% .DefaultBranch %>` — in a branch ruleset or a branch protection rule — so the +release roll merges only green: +<% range .CIChecks %> +- `<% . %>` +<%- end %> + +A job whose check name GitHub computes at run time — a matrix job, a job named +by an expression, a call to a reusable workflow — is not listed; add its +contexts by hand. When the CI changes, re-run `abcd launch scaffold`: it +refuses the files this list no longer matches and names them, and `--confirm` +rewrites them with the new list. +<%- else %> `abcd launch scaffold` found no workflow in this repository +triggered by `pull_request` or `merge_group`, so nothing but review gates the +release roll's merge. Add a CI workflow for pull requests, require its checks on +`<% .DefaultBranch %>`, and re-run `abcd launch scaffold --confirm` to record +them here and in `release.yml`. +<%- end %> + +<% end -%> ## Deterministic gates (CI-enforced) The `release.yml` `verify` job runs these, in order, on the released commit. @@ -55,8 +81,8 @@ next push to `<% .DefaultBranch %>` retries. A hand-pushed tag exists before ## Semantic gates (host-run, before the tag) <% if .SemanticGates %> -CI cannot run these — they spawn host-side LLM agents. Run each against the exact -commit to be tagged and record its verdict as a receipt: +CI cannot run these — they spawn host-side LLM agents. Run each against the +release's CHANGELOG roll commit and record its verdict as a receipt: <% range .SemanticGates %> - `<% . %>` <%- end %> @@ -65,15 +91,19 @@ A receipt names the commit its reviewer **read**, and lives in a **later** commit — it can never sit in the tree of the commit it names (adding it would change that commit's sha). So the release branch is two commits: the CHANGELOG roll (the reviewed content commit), then the receipts naming it. On merge, -`release.yml` arms the gate with the **content** commit (`^2^` for an -auto-release merge, `^` for a manual tag), whose receipts are present in the -released tree, so the receipt-vs-tag self-reference never blocks the release. +`release.yml` arms the gate with the **content** commit, derived from the +receipts directory of the released tree — the nearest commit a +`.abcd/work/reviews//` entry names, which must carry this release's own +CHANGELOG version — so the receipt-vs-tag self-reference never blocks the +release, and an earlier release's receipts never stand in for this one's. Receipts live at `.abcd/work/reviews//.json`. A receipt is bound to its gate by its `policy.detector` value, not its filename, so one PROMOTE receipt cannot be copied across every gate. A missing, mismatched, HOLD, or wrong-detector receipt **blocks** the release (fail-closed — an un-run -semantic pass is never a silent pass). +semantic pass is never a silent pass). `abcd launch receipts` runs the same gate +on the release branch before the merge and names each missing or non-PROMOTE +receipt and the commit it must name. <%- else %> No semantic detector is configured for this repo, so the deterministic gates alone admit a release and the receipt gate requires nothing — no host-run pass is @@ -82,23 +112,38 @@ gate; until then the release publishes on the deterministic gates. <%- end %> ## Reviews charter — sha-keyed receipt directories - -Should this repo adopt a dated-review-dir discipline, the sha-keyed receipt -directories under `.abcd/work/reviews//` are **exempt** from the -dated-directory shape: they are keyed by commit, not date, so the two review -conventions do not collide. +<% if .Abcd %> +The sha-keyed receipt directories under `.abcd/work/reviews//` are +**exempt** from the dated-directory shape: they are keyed by commit, not date, so +the two review conventions do not collide. +<%- else %> +`.abcd/development/release-gate/check-reviews.sh` holds `.abcd/work/reviews/` to +the dated-review shape (RD001): each directory is `-/` +and carries a `00-summary.md`. The `verify` job runs it as a deterministic gate. +The sha-keyed receipt directories under `.abcd/work/reviews//` are +**exempt**: they are keyed by the full sha of the commit they gate, not by date, +and the receipt gate checks them, so the two review conventions do not collide +and a release's own receipts never fail the charter. +<%- end %> ## Procedure -1. Land all work; open the release the normal way (branch → PR → merge). The - `verify` job gates the merge. <% if .SemanticGates -%> -2. On the merged commit, run the semantic gates above and record each verdict as - a receipt keyed to that commit's sha. -3. `auto-release.yml` has `release.yml` verify, tag `vX.Y.Z` on the merged - commit and publish. Once the fail-closed gate is armed, no tag is made unless - every semantic receipt is present and PROMOTE. +1. On a release branch, roll `## [Unreleased]` into the dated heading and commit + it. That commit is the content commit. +2. Run the semantic gates above against it and record each verdict as a receipt + keyed to its full sha; commit the receipts on top. The branch is exactly two + commits. +3. Run `abcd launch receipts`; it exits 0 only when the release job's receipt + gate would admit the branch. +4. Open the pull request and merge it once the merge gate is green. +5. `auto-release.yml` has `release.yml` verify, tag `vX.Y.Z` on the merged + commit and publish. No tag is made unless every semantic receipt is present + and PROMOTE. <%- else -%> +1. Land all work; open the release the normal way (branch → PR → merge), rolling + `## [Unreleased]` into the dated heading. The merge gate above gates the + merge. 2. `auto-release.yml` has `release.yml` verify, tag `vX.Y.Z` on the merged commit and publish; the tag and the Release are gated on the deterministic `verify` job alone. diff --git a/internal/core/launch/scaffold/wiring_test.go b/internal/core/launch/scaffold/wiring_test.go new file mode 100644 index 000000000..37c6ab7df --- /dev/null +++ b/internal/core/launch/scaffold/wiring_test.go @@ -0,0 +1,411 @@ +package scaffold + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/actionsexpr" + "github.com/intentdriven/abcd/internal/gittest" +) + +// managedCI is a managed repository's own CI: a pull-request workflow with a +// plainly named job, a display-named job, a matrix job and an expression-named +// job (whose check names only the run knows), a reusable-workflow call, and a +// push-only workflow that gates no merge. +const managedCI = `name: ci +"on": + pull_request: + push: + branches: [trunk] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - run: echo lint + unit: + name: Unit tests (linux) # the context GitHub reports + runs-on: ubuntu-latest + steps: + - run: echo test + matrixed: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - run: echo + hostile: + name: "x ${{ github.event.pull_request.title }}" + runs-on: ubuntu-latest + steps: + - run: echo + injected: + name: "ok: }}\n evil: true" + runs-on: ubuntu-latest + reused: + uses: ./.github/workflows/other.yml +` + +const pushOnly = `name: nightly +on: + schedule: + - cron: '0 0 * * *' +jobs: + nightly: + runs-on: ubuntu-latest + steps: + - run: echo +` + +const mergeQueue = `name: queue +on: [merge_group] +jobs: + queue-check: + runs-on: ubuntu-latest + steps: + - run: echo +` + +// TestDeriveCIChecksReadsTheRepositorysOwnPullRequestChecks is itd-93 AC1's +// missing half: the check names are the managed repository's own, derived from +// its pull-request and merge-queue workflows, never abcd's; a name GitHub only +// knows at run time is omitted rather than guessed; and nothing outside the +// injection-safe allowlist is ever returned. +func TestDeriveCIChecksReadsTheRepositorysOwnPullRequestChecks(t *testing.T) { + dir := t.TempDir() + wf := filepath.Join(dir, ".github", "workflows") + mustWrite(t, filepath.Join(wf, "ci.yml"), managedCI) + mustWrite(t, filepath.Join(wf, "nightly.yaml"), pushOnly) + mustWrite(t, filepath.Join(wf, "queue.yml"), mergeQueue) + // The scaffold's own workflows are never counted as the merge gate. + mustWrite(t, filepath.Join(wf, "release.yml"), "on: [pull_request]\njobs:\n verify:\n runs-on: x\n") + + got := DeriveCIChecks(dir) + want := []string{"Unit tests (linux)", "lint", "queue-check"} + if !reflect.DeepEqual(got, want) { + t.Errorf("DeriveCIChecks = %q, want %q", got, want) + } + for _, c := range got { + if !checkNameRe.MatchString(c) { + t.Errorf("derived check %q escapes the allowlist", c) + } + } + if DeriveCIChecks(t.TempDir()) != nil { + t.Error("a repository with no workflows derives no checks") + } +} + +// TestScaffoldWiresTheRepositorysOwnCIChecks carries the derivation through the +// write path: the report names the checks, the runbook lists them as the +// contexts to require on the repository's own default branch, and release.yml's +// verify header names them — and no hostile name reaches either file. +func TestScaffoldWiresTheRepositorysOwnCIChecks(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n") + mustWrite(t, filepath.Join(dir, ".github", "workflows", "ci.yml"), managedCI) + gitInit(t, dir, "trunk") + + rep, err := Scaffold(Request{RepoRoot: dir}) + if err != nil { + t.Fatal(err) + } + if want := []string{"Unit tests (linux)", "lint"}; !reflect.DeepEqual(rep.CIChecks, want) { + t.Errorf("report ci_checks = %q, want %q", rep.CIChecks, want) + } + book := readFile(t, filepath.Join(dir, filepath.FromSlash(RunbookPath))) + rel := readFile(t, filepath.Join(dir, filepath.FromSlash(ReleaseYMLPath))) + for _, want := range []string{"- `Unit tests (linux)`", "- `lint`", "Require each of them on\n`trunk`"} { + if !strings.Contains(book, want) { + t.Errorf("runbook lacks %q", want) + } + } + for _, want := range []string{"# - Unit tests (linux)\n", "# - lint\n", "Require them on trunk"} { + if !strings.Contains(rel, want) { + t.Errorf("release.yml verify header lacks %q", want) + } + } + for _, doc := range []string{book, rel} { + for _, bad := range []string{"pull_request.title", "evil", "matrixed", "reused"} { + if strings.Contains(doc, bad) { + t.Errorf("a name GitHub computes at run time, or a hostile one, reached a scaffolded file: %q", bad) + } + } + } + + // No pull-request CI at all: the files say so, never a borrowed list. + bare := t.TempDir() + mustWrite(t, filepath.Join(bare, "go.mod"), "module example.com/y\n\ngo 1.22\n") + gitInit(t, bare, "main") + if rep, err = Scaffold(Request{RepoRoot: bare}); err != nil { + t.Fatal(err) + } + if len(rep.CIChecks) != 0 { + t.Errorf("no CI derives no checks, got %q", rep.CIChecks) + } + if !contains(t, filepath.Join(bare, filepath.FromSlash(RunbookPath)), "found no workflow in this repository") || + !contains(t, filepath.Join(bare, filepath.FromSlash(ReleaseYMLPath)), "(none found") { + t.Error("with no CI the runbook and release.yml must say none was found") + } +} + +// TestScaffoldedCharterExemptsShaKeyedReceiptDirs is itd-93 AC5's missing +// half, run for real: the reviews-charter check the scaffold writes holds a +// dated review directory to its shape, and leaves a sha-keyed receipt +// directory alone — the collision abcd-cli once had cannot recur. +func TestScaffoldedCharterExemptsShaKeyedReceiptDirs(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is required to run the charter check") + } + rendered, err := Render(BareSubstitutions("main")) + if err != nil { + t.Fatal(err) + } + run := func(r *gittest.Repo) (string, bool) { + script := filepath.Join(t.TempDir(), "check-reviews.sh") + mustWrite(t, script, string(rendered.CheckReviews)) + cmd := exec.Command("bash", script) + cmd.Dir = r.Root() + cmd.Env = r.Env() + out, err := cmd.CombinedOutput() + return string(out), err == nil + } + + r := gittest.NewRepo(t) + if out, ok := run(r); !ok { + t.Fatalf("no reviews directory must pass:\n%s", out) + } + sha1 := strings.Repeat("a", 40) + sha256 := strings.Repeat("b", 64) + r.Write(".abcd/work/reviews/"+sha1+"/docs-currency-reviewer.json", "{}\n") + r.Write(".abcd/work/reviews/"+sha256+"/other-gate.json", "{}\n") + r.Write(".abcd/work/reviews/2026-09-01-plan-review/00-summary.md", "# summary\n") + if out, ok := run(r); !ok { + t.Fatalf("sha-keyed receipt directories (SHA-1 and SHA-256) must be exempt from RD001:\n%s", out) + } + + r.Write(".abcd/work/reviews/"+sha1[:12]+"/docs-currency-reviewer.json", "{}\n") + out, ok := run(r) + if ok || !strings.Contains(out, "RD001") { + t.Errorf("an abbreviated sha is not a receipt key and must fail RD001:\n%s", out) + } + if err := os.RemoveAll(filepath.Join(r.Root(), ".abcd", "work", "reviews", sha1[:12])); err != nil { + t.Fatal(err) + } + + r.Write(".abcd/work/reviews/2026-09-02-no-summary/01-notes.md", "# notes\n") + if out, ok = run(r); ok || !strings.Contains(out, "missing required 00-summary.md") { + t.Errorf("a dated review without its summary must fail RD001:\n%s", out) + } + + // The verify job runs the charter as a numbered deterministic gate, and the + // runbook lists it at the same number (gate_lockstep, by construction). + if !strings.Contains(string(rendered.ReleaseYML), "run: bash "+CheckReviewsPath) { + t.Error("the bare verify job must run the scaffolded charter check") + } +} + +// TestScaffoldedWorkflowsPassTheWorkflowAudit is itd-93 AC1's audit half. The +// intent names zizmor; it is not a dependency of this repository and is not +// installed where these tests run, so the two finding classes the criterion +// names are asserted here with the repository's own tools: +// +// - duplicate keys: no mapping in either workflow repeats a key (a YAML +// loader keeps the last one silently, which is how a gate goes missing); +// - template injection: no `${{ }}` expression appears inside a run script, +// and every expression anywhere in the file resolves, under the strict +// actionsexpr evaluator, against contexts a pull request cannot write — +// an attacker-controlled context such as a pull request's title fails the +// evaluation instead of passing unnoticed. +// +// Every profile the templates render is audited, with the hostile managed CI +// above feeding the derived check names. +func TestScaffoldedWorkflowsPassTheWorkflowAudit(t *testing.T) { + semantic := BareSubstitutions("main") + semantic.SemanticGates = []string{"docs-currency-reviewer"} + withChecks := BareSubstitutions("trunk") + withChecks.CIChecks = []string{"Unit tests (linux)", "lint"} + profiles := map[string]Substitutions{ + "abcd": AbcdSubstitutions(), + "bare": BareSubstitutions("main"), + "bare+ci-checks": withChecks, + "bare+semantic": semantic, + } + for name, subs := range profiles { + rendered, err := Render(subs) + if err != nil { + t.Fatal(err) + } + for file, doc := range map[string]string{ + "release.yml": string(rendered.ReleaseYML), "auto-release.yml": string(rendered.AutoReleaseYML), + } { + where := name + "/" + file + for _, dup := range duplicateKeys(doc) { + t.Errorf("%s: duplicate key %s", where, dup) + } + for _, inj := range expressionsInRunScripts(doc) { + t.Errorf("%s: a ${{ }} expression inside a run script (template injection): %s", where, inj) + } + for _, expr := range workflowExpressions(doc) { + if _, err := actionsexpr.EvalValue(expr, trustedContext); err != nil { + t.Errorf("%s: expression %s reads a context outside the trusted set: %v", where, expr, err) + } + } + } + } +} + +// trustedContext is every expression context the scaffolded workflows may +// read: the run's own identity, the caller's inputs, job and step outputs, and +// the built-in token. Nothing a pull request's author writes (a title, a body, +// a branch name, a commit message) is in it, so an expression reading one +// fails the strict evaluation. +var trustedContext = map[string]any{ + "inputs.tag": "v1.2.3", "inputs.ref": "", "inputs.create_tag": true, + "github.sha": strings.Repeat("a", 40), "github.ref_name": "v1.2.3", "github.token": "t", + "github.event_name": "push", "github.repository": "example/fixture", + "github.event.repository.default_branch": "main", "github.event.repository.private": false, + "secrets.GITHUB_TOKEN": "t", + "needs.verify.result": "success", "needs.tag.result": "success", "needs.release.result": "success", + "needs.verify.outputs.content_sha": strings.Repeat("b", 40), + "needs.detect.outputs.version": "1.2.3", + "needs.detect.outputs.need_tag": "true", + "needs.detect.outputs.need_release": "true", + "needs.detect.outputs.release_ref": "", + "steps.detect.outputs.version": "1.2.3", + "steps.detect.outputs.need_tag": "true", + "steps.detect.outputs.need_release": "true", + "steps.detect.outputs.release_ref": "", + "steps.receipts.outputs.content_sha": strings.Repeat("b", 40), + "env.CONTENT_SHA": strings.Repeat("b", 40), + "cancelled()": false, + "success()": true, + "failure()": false, + "always()": true, +} + +var exprRe = regexp.MustCompile(`\$\{\{.*?\}\}`) + +// workflowExpressions returns every `${{ }}` expression outside a comment. +func workflowExpressions(doc string) []string { + var out []string + for _, l := range strings.Split(doc, "\n") { + if strings.HasPrefix(strings.TrimSpace(l), "#") { + continue + } + out = append(out, exprRe.FindAllString(l, -1)...) + } + return out +} + +// expressionsInRunScripts returns every `${{` that sits inside a run script, +// inline or block — the shape zizmor reports as template injection, because +// the runner splices the value into the script before the shell parses it. +func expressionsInRunScripts(doc string) []string { + var out []string + lines := strings.Split(doc, "\n") + for i := 0; i < len(lines); i++ { + l := lines[i] + t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "- ")) + if !strings.HasPrefix(t, "run:") { + continue + } + val := strings.TrimSpace(strings.TrimPrefix(t, "run:")) + if !strings.HasPrefix(val, "|") && !strings.HasPrefix(val, ">") { + if strings.Contains(val, "${{") { + out = append(out, val) + } + continue + } + indent := len(l) - len(strings.TrimLeft(l, " ")) + for i+1 < len(lines) { + b := lines[i+1] + if strings.TrimSpace(b) != "" && len(b)-len(strings.TrimLeft(b, " ")) <= indent { + break + } + i++ + if strings.Contains(b, "${{") { + out = append(out, strings.TrimSpace(b)) + } + } + } + return out +} + +// duplicateKeys walks a workflow's block mappings by indentation and reports +// every key a mapping repeats. Block scalars are skipped whole; a list item +// opens a fresh mapping. +func duplicateKeys(doc string) []string { + type frame struct { + indent int + keys map[string]bool + } + keyRe := regexp.MustCompile(`^("[^"]*"|'[^']*'|[A-Za-z0-9_.\-/]+):(\s|$)`) + var stack []frame + var dups []string + lines := strings.Split(doc, "\n") + for i := 0; i < len(lines); i++ { + l := lines[i] + t := strings.TrimSpace(l) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + indent := len(l) - len(strings.TrimLeft(l, " ")) + item := false + if strings.HasPrefix(t, "- ") { + item = true + t = strings.TrimSpace(t[2:]) + indent += 2 + } + for len(stack) > 0 && (stack[len(stack)-1].indent > indent || (item && stack[len(stack)-1].indent == indent)) { + stack = stack[:len(stack)-1] + } + m := keyRe.FindStringSubmatch(t) + if m == nil { + continue + } + if len(stack) == 0 || stack[len(stack)-1].indent < indent { + stack = append(stack, frame{indent: indent, keys: map[string]bool{}}) + } + top := stack[len(stack)-1] + key := strings.Trim(m[1], `"'`) + if top.keys[key] { + dups = append(dups, "line "+itoa(i+1)+": "+key) + } + top.keys[key] = true + // A block scalar's body is text, not keys: skip it. + rest := strings.TrimSpace(t[len(m[0]):]) + if strings.HasPrefix(rest, "|") || strings.HasPrefix(rest, ">") { + for i+1 < len(lines) { + b := lines[i+1] + if strings.TrimSpace(b) != "" && len(b)-len(strings.TrimLeft(b, " ")) <= indent { + break + } + i++ + } + } + } + return dups +} + +// TestDuplicateKeysDetectsARepeatedKey keeps the audit above honest: the +// checker must find a duplicate it is shown, at any depth, or its silence on +// the real workflows proves nothing. +func TestDuplicateKeysDetectsARepeatedKey(t *testing.T) { + doc := "jobs:\n verify:\n steps:\n - name: a\n run: x\n run: y\n verify:\n runs-on: z\n" + got := duplicateKeys(doc) + if len(got) != 2 { + t.Errorf("want the repeated step key and the repeated job id, got %v", got) + } + if n := len(expressionsInRunScripts(" - run: |\n echo ${{ github.event.issue.title }}\n")); n != 1 { + t.Errorf("an expression in a block run script must be reported, got %d", n) + } + if _, err := actionsexpr.EvalValue("${{ github.event.pull_request.title }}", trustedContext); err == nil { + t.Error("an attacker-controlled context must fail the strict evaluation") + } + +} diff --git a/internal/surface/cli/scaffold.go b/internal/surface/cli/scaffold.go index 121e42e6c..ba82a04ca 100644 --- a/internal/surface/cli/scaffold.go +++ b/internal/surface/cli/scaffold.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/intentdriven/abcd/internal/core/launch/scaffold" "github.com/intentdriven/abcd/internal/termsafe" @@ -13,8 +14,9 @@ import ( // newLaunchScaffoldCommand builds `abcd launch scaffold` (itd-93, spc-14): it // writes the changelog-driven release machinery — release.yml, auto-release.yml, -// and the adr-37 runbook — into a managed repo that lacks it, wired to the repo's -// own default branch and Go version, GITHUB_TOKEN-only and injection-safe. +// the adr-37 runbook and the reviews-charter check — into a managed repo that +// lacks it, wired to the repo's own default branch and pull-request CI check +// names, GITHUB_TOKEN-only and injection-safe. // // It is idempotent and fail-safe (AC4): a re-run on current machinery is a no-op, // and a hand-edited file is refused (exit 1) rather than clobbered unless @@ -28,7 +30,7 @@ func newLaunchScaffoldCommand(asJSON *bool) *cobra.Command { var confirm bool cmd := &cobra.Command{ Use: "scaffold [--confirm]", - Short: "Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook) into this repo", + Short: "Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook, reviews charter) into this repo", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { cwd, err := os.Getwd() @@ -80,6 +82,12 @@ func renderScaffold(w io.Writer, rep scaffold.Report, blocked bool) { } fmt.Fprintf(w, "abcd launch scaffold — %s (branch %s, go %s)\n", verdict, termsafe.Sanitize(rep.DefaultBranch), termsafe.Sanitize(rep.GoVersion)) + if len(rep.CIChecks) > 0 { + fmt.Fprintf(w, " merge gate: %s (require these on %s)\n", + termsafe.Sanitize(strings.Join(rep.CIChecks, ", ")), termsafe.Sanitize(rep.DefaultBranch)) + } else { + fmt.Fprintln(w, " merge gate: no pull-request CI workflow found (the runbook says how to add one)") + } for _, f := range rep.Files { // f.Path is a fixed repo-relative constant; f.Detail interpolates a reason. fmt.Fprintf(w, " [%s] %s", f.Status, f.Path) From 09e0af6c6ecd01c43c877d8d4d7391ea361be84e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:08:46 +0100 Subject: [PATCH 07/27] test(launch): drive a scaffolded repo's first release through the merge path to a published release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit itd-93 AC2 (spc-2609230613193436). The test runs the rendered workflows themselves: a small runner reads the jobs and steps out of the rendered YAML, evaluates every if/env/with/output expression with the strict actionsexpr evaluator, and runs every run script with bash in a fresh clone. Only the edge is faked — a local bare repository is the forge's remote, a fake gh keeps releases and attestations in a directory, checkout is a clone, setup-go is the local toolchain, attest records subject digests, and `go run ./cmd/record-lint` runs as a record-lint built from this tree. No real forge is contacted. Two profiles, each: scaffold, commit, run the workflow_dispatch rehearsal green (verify and rehearsal succeed, tag and release skipped, nothing tagged or published), roll the CHANGELOG on a release branch (plus the receipts commit for the semantic profile), merge --no-ff, run auto-release. Asserted: v0.1.0 is published from the merged commit under a tag at that commit, and with a semantic gate the receipt gate armed against the roll — the reviewed content commit — not the tagged merge commit, and the receipts it admitted were attested. Watched red on a scratch copy with the verify step armed against `git rev-parse HEAD` (the self-reference): auto-release fails and nothing publishes. Assisted-by: Claude:claude-opus-5-5 --- .../launch/scaffold/mergepath_release_test.go | 771 ++++++++++++++++++ 1 file changed, 771 insertions(+) create mode 100644 internal/core/launch/scaffold/mergepath_release_test.go diff --git a/internal/core/launch/scaffold/mergepath_release_test.go b/internal/core/launch/scaffold/mergepath_release_test.go new file mode 100644 index 000000000..d81d8e5c0 --- /dev/null +++ b/internal/core/launch/scaffold/mergepath_release_test.go @@ -0,0 +1,771 @@ +package scaffold + +// itd-93 AC2: a repository with the scaffolded gate and a green rehearsal on +// record cuts its first release through the merge path, and the release +// PUBLISHES — the gate armed against the reviewed content commit, never the +// tagged merge commit, so the receipt-vs-tag self-reference cannot fail it. +// +// The test runs the rendered workflows themselves, not a description of them: +// a small runner reads each job and step out of the rendered YAML, evaluates +// every `if:`, `env:`, `with:` and output expression with the strict +// actionsexpr evaluator, and runs every `run:` script with bash in a fresh +// clone, the way a runner does. What it cannot run it fakes at the edge, and +// only there: a local bare repository is the forge's git remote, a fake `gh` +// keeps the forge's releases and attestations in a directory, `actions/checkout` +// is a clone, `actions/setup-go` is the Go toolchain already on PATH, and +// `actions/attest` records the subject digests the fake `gh attestation verify` +// later checks. `go run ./cmd/record-lint` — abcd's own program, which the +// semantic profile's receipt gate invokes — runs as a record-lint built from +// this tree. No real forge is ever contacted. + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/actionsexpr" + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestScaffoldedGateCutsAFirstReleaseThatPublishes drives two profiles through +// the whole merge path: the bare profile `launch scaffold` writes, and the same +// profile with a semantic gate configured, whose receipt gate is the one the +// self-reference once failed. +func TestScaffoldedGateCutsAFirstReleaseThatPublishes(t *testing.T) { + if testing.Short() { + t.Skip("drives the release workflows end to end") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is required to run the workflow scripts") + } + for _, semantic := range []bool{false, true} { + name := "bare" + if semantic { + name = "semantic-gate" + } + t.Run(name, func(t *testing.T) { cutFirstRelease(t, semantic) }) + } +} + +func cutFirstRelease(t *testing.T, semantic bool) { + goEnv := hostGoEnv(t) // before gittest pins HOME, so the build cache stays warm + f := newFakeForge(t, goEnv) + r := f.work + + // A managed repository with no release workflow: a Go module, a changelog, + // and its own pull-request CI. + r.Write("go.mod", "module example.com/fixture\n\ngo "+strings.TrimPrefix(runtime.Version(), "go")+"\n") + r.Write("main.go", "package main\n\nfunc main() {}\n") + r.Write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n") + r.Write(".github/workflows/ci.yml", "name: ci\non: [pull_request]\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: go build ./...\n") + + if semantic { + // The semantic profile, rendered through the same templates the + // scaffold uses, with one required detector. + subs := BareSubstitutions("main") + subs.CIChecks = DeriveCIChecks(r.Root()) + subs.SemanticGates = []string{"docs-currency-reviewer"} + rendered, err := Render(subs) + if err != nil { + t.Fatal(err) + } + r.Write(ReleaseYMLPath, string(rendered.ReleaseYML)) + r.Write(AutoReleaseYMLPath, string(rendered.AutoReleaseYML)) + r.Write(RunbookPath, string(rendered.Runbook)) + r.Write(CheckReviewsPath, string(rendered.CheckReviews)) + r.Write(".abcd/record-lint.json", `{"rules":{"receipt_gate":{"enabled":false,"severity":"blocker","receipts_dir":".abcd/work/reviews","required_gates":[]}}}`+"\n") + f.recordLint = buildRecordLint(t, goEnv) + } else { + rep, err := Scaffold(Request{RepoRoot: r.Root()}) + if err != nil { + t.Fatalf("scaffold: %v", err) + } + if rep.Wrote != 4 || len(rep.CIChecks) != 1 || rep.CIChecks[0] != "build" { + t.Fatalf("scaffold report %+v: want four files wired to the repo's own `build` check", rep) + } + } + r.Commit("adopt the scaffolded release gate") + r.Git("push", "-q", "origin", "main") + scaffolded := r.Git("rev-parse", "HEAD") + + // 1. The rehearsal, on record and green: a workflow_dispatch of release.yml. + rehearsal := f.run(ReleaseYMLPath, event{name: "workflow_dispatch", sha: scaffolded, refName: "main"}) + for job, want := range map[string]string{"verify": "success", "rehearsal": "success", "tag": "skipped", "release": "skipped"} { + if got := rehearsal[job].result; got != want { + t.Fatalf("rehearsal: job %s = %s, want %s\n%s", job, got, want, f.log.String()) + } + } + if tags := r.Git("ls-remote", "--tags", "origin"); tags != "" || len(f.releases()) != 0 { + t.Fatalf("the rehearsal must publish nothing; tags %q, releases %v", tags, f.releases()) + } + + // 2. The release branch: the CHANGELOG roll (the reviewed content commit), + // then — with a semantic gate — the receipts naming it. + r.Git("switch", "-q", "-c", "release") + r.Write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n## [0.1.0] - 2026-09-25\n\n### Added\n\n- the first release.\n") + r.Commit("release: roll the changelog to 0.1.0") + content := r.Git("rev-parse", "HEAD") + if semantic { + r.Write(".abcd/work/reviews/"+content+"/docs-currency-reviewer.json", + `{"subject":{"digest":{"gitCommit":"`+content+`"}},"verificationResult":"PROMOTE",`+ + `"policy":{"detector":"docs-currency-reviewer"},"judgeModel":"claude-opus-4-8"}`+"\n") + r.Commit("release: receipts for the roll") + } + + // 3. The merge: the release pull request lands on main. + r.Git("switch", "-q", "main") + r.Git("merge", "-q", "--no-ff", "-m", "Merge the 0.1.0 release", "release") + r.Git("push", "-q", "origin", "main") + merged := r.Git("rev-parse", "HEAD") + + // 4. The push to main runs auto-release, which calls release.yml. + runs := f.run(AutoReleaseYMLPath, event{name: "push", sha: merged, refName: "main"}) + if runs["detect"].result != "success" || runs["release"].result != "success" { + t.Fatalf("auto-release did not complete: detect=%s release=%s\n%s", + runs["detect"].result, runs["release"].result, f.log.String()) + } + + // The release PUBLISHED, from the merged commit, under the tag the chain made. + rel := f.releases() + if got := rel["v0.1.0"]; got != merged { + t.Fatalf("published releases %v: want v0.1.0 built from the merged commit %s\n%s", rel, merged, f.log.String()) + } + if tagged := r.Git("ls-remote", "origin", "refs/tags/v0.1.0^{}"); !strings.HasPrefix(tagged, merged) { + t.Errorf("tag v0.1.0 = %q, want it at the merged commit %s", tagged, merged) + } + + if testing.Verbose() { + t.Log("workflow run log:\n" + f.log.String()) + } + if semantic { + // The receipt gate armed against the reviewed CONTENT commit — the roll — + // not the tagged merge commit, whose tree can never hold a receipt naming + // itself. That is the self-reference, and it did not fire. + armed := runs["release/verify"].outputs["content_sha"] + if armed != content { + t.Errorf("the receipt gate armed against %q, want the reviewed content commit %s", armed, content) + } + if armed == merged { + t.Error("the gate armed against the tagged commit: the receipt-vs-tag self-reference") + } + if !f.attested(".abcd/work/reviews/" + content + "/docs-currency-reviewer.json") { + t.Error("the release did not attest the receipts of the commit the gate admitted") + } + } +} + +// --------------------------------------------------------------------------- +// The fake forge and the workflow runner. + +type event struct { + name string + sha string + refName string + inputs map[string]any +} + +type jobRun struct { + result string + outputs map[string]string +} + +type fakeForge struct { + t *testing.T + work *gittest.Repo + origin string // the forge's git remote (a bare repository) + state string // releases/ and attestations/ live here + bin string // the fake gh + env []string + recordLint string + log strings.Builder +} + +func newFakeForge(t *testing.T, goEnv []string) *fakeForge { + t.Helper() + r := gittest.NewRepo(t) + f := &fakeForge{t: t, work: r, origin: t.TempDir(), state: t.TempDir(), bin: t.TempDir()} + bare := exec.Command("git", "init", "-q", "--bare", "--initial-branch=main", f.origin) + bare.Env = r.Env() + if out, err := bare.CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + r.Git("remote", "add", "origin", f.origin) + mustWrite(t, filepath.Join(f.bin, "gh"), fakeGH) + if err := os.Chmod(filepath.Join(f.bin, "gh"), 0o755); err != nil { + t.Fatal(err) + } + env := append([]string{}, r.Env()...) + for i, kv := range env { + if strings.HasPrefix(kv, "PATH=") { + env[i] = "PATH=" + f.bin + string(os.PathListSeparator) + strings.TrimPrefix(kv, "PATH=") + } + } + f.env = append(append(env, goEnv...), + "FORGE_STATE="+f.state, "FORGE_ORIGIN="+f.origin, + "GITHUB_REPOSITORY=example/fixture", "GITHUB_ACTIONS=true", "GOTOOLCHAIN=local", + "GIT_AUTHOR_NAME=Fixture", "GIT_AUTHOR_EMAIL=fixture@example.invalid", + "GIT_COMMITTER_NAME=Fixture", "GIT_COMMITTER_EMAIL=fixture@example.invalid") + return f +} + +// fakeGH is the forge's command line, answering exactly the calls the +// scaffolded workflows make and refusing anything else loudly. +const fakeGH = `#!/usr/bin/env bash +set -euo pipefail +case "${1:-} ${2:-}" in +"release view") + [ -f "$FORGE_STATE/releases/$3" ] ;; +"release create") + tag="$3"; shift 3 + verify=0 + while [ $# -gt 0 ]; do + case "$1" in --verify-tag) verify=1 ;; --title) shift ;; *) ;; esac + shift + done + [ "$verify" = 1 ] || { echo "fake gh: release create without --verify-tag" >&2; exit 98; } + [ ! -f "$FORGE_STATE/releases/$tag" ] || { echo "release $tag already exists" >&2; exit 1; } + commit="$(git --git-dir="$FORGE_ORIGIN" rev-parse -q --verify "refs/tags/$tag^{commit}")" || { + echo "tag $tag does not exist on the forge" >&2; exit 1; } + mkdir -p "$FORGE_STATE/releases" + printf '%s\n' "$commit" > "$FORGE_STATE/releases/$tag" ;; +"run list") + printf '\n' ;; +"attestation verify") + digest="$(git hash-object "$3")" + [ -f "$FORGE_STATE/attestations/$digest" ] || { echo "no attestation for $3" >&2; exit 1; } ;; +"api "*) + case "$2" in + repos/*/git/ref/heads/*) + git --git-dir="$FORGE_ORIGIN" rev-parse "refs/heads/${2##*/heads/}" ;; + *) echo "fake gh: unsupported api $2" >&2; exit 97 ;; + esac ;; +*) + echo "fake gh: unsupported: $*" >&2; exit 97 ;; +esac +` + +// releases is the forge's published releases: tag -> the commit it was built from. +func (f *fakeForge) releases() map[string]string { + out := map[string]string{} + entries, _ := os.ReadDir(filepath.Join(f.state, "releases")) + for _, e := range entries { + data, _ := os.ReadFile(filepath.Join(f.state, "releases", e.Name())) + out[e.Name()] = strings.TrimSpace(string(data)) + } + return out +} + +// attested reports whether the forge holds an attestation for the file at rel +// in the released tree. +func (f *fakeForge) attested(rel string) bool { + cmd := exec.Command("git", "-C", f.work.Root(), "rev-parse", "HEAD:"+rel) + cmd.Env = f.work.Env() + out, err := cmd.Output() + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(f.state, "attestations", strings.TrimSpace(string(out)))) + return err == nil +} + +// run executes one workflow file, as committed at ev.sha, for one event, and +// returns every job's result keyed by job id ("/" for the jobs +// of a called workflow). +func (f *fakeForge) run(path string, ev event) map[string]jobRun { + f.t.Helper() + src := f.work.Git("show", ev.sha+":"+path) + wf := parseYAML(src) + results := map[string]jobRun{} + f.runWorkflow(wf, ev, "", results) + return results +} + +func (f *fakeForge) runWorkflow(wf *ynode, ev event, prefix string, results map[string]jobRun) { + jobs := wf.get("jobs") + if jobs == nil { + f.t.Fatal("workflow has no jobs") + } + local := map[string]jobRun{} + for _, id := range jobs.keys { + job := jobs.m[id] + needs := listOf(job.get("needs")) + ctx := f.baseContext(ev) + allGreen := true + for _, n := range needs { + nr := local[n] + ctx["needs."+n+".result"] = nr.result + for k, v := range nr.outputs { + ctx["needs."+n+".outputs."+k] = v + } + if nr.result != "success" { + allGreen = false + } + } + ctx["success()"], ctx["failure()"], ctx["cancelled()"] = allGreen, false, false + run := true + if cond := job.str("if"); cond != "" { + run = f.evalIf(cond, ctx) + } else { + run = allGreen + } + if !run { + local[id] = jobRun{result: "skipped"} + results[prefix+id] = local[id] + fmt.Fprintf(&f.log, "job %s%s: skipped\n", prefix, id) + continue + } + var jr jobRun + if uses := job.str("uses"); uses != "" { + jr = f.callWorkflow(job, uses, ev, ctx, prefix+id+"/", results) + } else { + jr = f.runJob(prefix+id, job, wf, ev, ctx) + } + local[id] = jr + results[prefix+id] = jr + } +} + +// callWorkflow runs a reusable workflow (`uses: ./.github/workflows/x.yml`) as +// GitHub does: same event and commit, inputs from the caller's `with:`. +func (f *fakeForge) callWorkflow(job *ynode, uses string, ev event, ctx map[string]any, prefix string, results map[string]jobRun) jobRun { + if !strings.HasPrefix(uses, "./") { + f.t.Fatalf("unsupported reusable workflow %q", uses) + } + inputs := map[string]any{} + if with := job.get("with"); with != nil { + for _, k := range with.keys { + // Typed, as GitHub hands a called workflow its inputs: a boolean + // input stays a boolean, so `if: inputs.create_tag` reads false as false. + inputs[k] = f.evalAny(with.m[k].scalar, ctx) + } + } + called := parseYAML(f.work.Git("show", ev.sha+":"+strings.TrimPrefix(uses, "./"))) + // Declared inputs the caller did not pass take their defaults. + if on := called.get("on"); on != nil { + if wc := on.get("workflow_call"); wc != nil { + if decl := wc.get("inputs"); decl != nil { + for _, k := range decl.keys { + if _, ok := inputs[k]; !ok { + d := decl.m[k].str("default") + if d == "false" || d == "true" { + inputs[k] = d == "true" + } else { + inputs[k] = strings.Trim(d, `'"`) + } + } + } + } + } + } + sub := map[string]jobRun{} + f.runWorkflow(called, event{name: ev.name, sha: ev.sha, refName: ev.refName, inputs: inputs}, prefix, sub) + res := jobRun{result: "success"} + for k, v := range sub { + results[k] = v + if v.result == "failure" { + res.result = "failure" + } + } + return res +} + +func (f *fakeForge) baseContext(ev event) map[string]any { + ctx := map[string]any{ + "github.sha": ev.sha, "github.ref_name": ev.refName, "github.event_name": ev.name, + "github.repository": "example/fixture", "github.token": "fake-token", "secrets.GITHUB_TOKEN": "fake-token", + "github.event.repository.default_branch": "main", "github.event.repository.private": false, + } + for k, v := range ev.inputs { + ctx["inputs."+k] = v + } + return ctx +} + +// unknownPathRe names the context path the strict evaluator could not find. +var unknownPathRe = regexp.MustCompile(`unknown context path "([^"]+)"`) + +// withMissing retries an evaluation, supplying GitHub's null for an absent +// input, output or env value — and only those: any other unknown context is a +// failure, so an expression reading something the runner does not model can +// never pass by accident. +func (f *fakeForge) withMissing(ctx map[string]any, eval func() error) { + f.t.Helper() + for i := 0; i < 16; i++ { + err := eval() + if err == nil { + return + } + m := unknownPathRe.FindStringSubmatch(err.Error()) + if m == nil || !(strings.HasPrefix(m[1], "inputs.") || strings.HasPrefix(m[1], "env.") || + (strings.HasPrefix(m[1], "steps.") || strings.HasPrefix(m[1], "needs.")) && strings.Contains(m[1], ".outputs.")) { + f.t.Fatalf("expression evaluation: %v", err) + } + ctx[m[1]] = nil + } + f.t.Fatal("expression evaluation did not converge") +} + +func (f *fakeForge) evalIf(cond string, ctx map[string]any) bool { + var got bool + f.withMissing(ctx, func() (err error) { got, err = actionsexpr.EvalIf(cond, ctx); return err }) + return got +} + +func (f *fakeForge) eval(raw string, ctx map[string]any) string { + return actionsexpr.Stringify(f.evalAny(raw, ctx)) +} + +func (f *fakeForge) evalAny(raw string, ctx map[string]any) any { + var got any + f.withMissing(ctx, func() (err error) { got, err = actionsexpr.EvalValue(raw, ctx); return err }) + return got +} + +// runJob runs one job's steps in a fresh workspace. +func (f *fakeForge) runJob(id string, job, wf *ynode, ev event, ctx map[string]any) jobRun { + f.t.Helper() + ws := f.t.TempDir() + jobEnv := map[string]string{} + if we := wf.get("env"); we != nil { + for _, k := range we.keys { + jobEnv[k] = f.eval(we.m[k].scalar, ctx) + } + } + failed := false + steps := job.get("steps") + for i, step := range steps.seq { + sid := step.str("id") + ctx["success()"], ctx["failure()"] = !failed, failed + for k, v := range jobEnv { + ctx["env."+k] = v + } + cond := step.str("if") + if cond == "" { + cond = "success()" + } + if !f.evalIf(cond, ctx) { + fmt.Fprintf(&f.log, " %s step %d %q: skipped\n", id, i, step.str("name")) + continue + } + outFile := filepath.Join(f.t.TempDir(), "output") + envFile := filepath.Join(f.t.TempDir(), "env") + mustWrite(f.t, outFile, "") + mustWrite(f.t, envFile, "") + var ok bool + var out string + switch uses := step.str("uses"); { + case strings.HasPrefix(uses, "actions/checkout@"): + ref := ev.sha + if with := step.get("with"); with != nil && with.get("ref") != nil { + if v := f.eval(with.get("ref").scalar, ctx); v != "" { + ref = v + } + } + ok, out = f.shell(ws, jobEnv, nil, "git clone -q \"$FORGE_ORIGIN\" . && git checkout -q --detach "+ref, "", "") + case strings.HasPrefix(uses, "actions/setup-go@"): + ok = true // the toolchain on PATH is the one go.mod names (GOTOOLCHAIN=local) + case strings.HasPrefix(uses, "actions/attest@"): + subject := f.eval(step.get("with").get("subject-path").scalar, ctx) + ok, out = f.shell(ws, jobEnv, nil, `mkdir -p "$FORGE_STATE/attestations" && for s in `+subject+ + `; do [ -f "$s" ] || exit 1; : > "$FORGE_STATE/attestations/$(git hash-object "$s")"; done`, "", "") + case uses != "": + f.t.Fatalf("job %s step %q uses %s, which this runner does not model", id, step.str("name"), uses) + default: + stepEnv := map[string]string{} + if se := step.get("env"); se != nil { + for _, k := range se.keys { + stepEnv[k] = f.eval(se.m[k].scalar, ctx) + } + } + script := step.str("run") + if f.recordLint != "" { + script = strings.ReplaceAll(script, "go run ./cmd/record-lint", f.recordLint) + } + ok, out = f.shell(ws, jobEnv, stepEnv, script, outFile, envFile) + } + fmt.Fprintf(&f.log, " %s step %d %q: ok=%v\n%s", id, i, step.str("name"), ok, indent(out)) + for k, v := range readKV(f.t, outFile) { + if sid != "" { + ctx["steps."+sid+".outputs."+k] = v + } + } + for k, v := range readKV(f.t, envFile) { + jobEnv[k] = v + } + if !ok { + failed = true + } + } + jr := jobRun{result: "success", outputs: map[string]string{}} + if failed { + jr.result = "failure" + } + if outs := job.get("outputs"); outs != nil { + for _, k := range outs.keys { + jr.outputs[k] = f.eval(outs.m[k].scalar, ctx) + } + } + fmt.Fprintf(&f.log, "job %s: %s %v\n", id, jr.result, jr.outputs) + return jr +} + +// shell runs a script the way a runner's default bash shell does. +func (f *fakeForge) shell(dir string, jobEnv, stepEnv map[string]string, script, outFile, envFile string) (bool, string) { + cmd := exec.Command("bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script) + cmd.Dir = dir + env := append([]string{}, f.env...) + for _, m := range []map[string]string{jobEnv, stepEnv} { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + env = append(env, k+"="+m[k]) + } + } + env = append(env, "GITHUB_OUTPUT="+outFile, "GITHUB_ENV="+envFile, "RUNNER_TEMP="+f.t.TempDir()) + cmd.Env = env + out, err := cmd.CombinedOutput() + return err == nil, string(out) +} + +func readKV(t *testing.T, path string) map[string]string { + t.Helper() + out := map[string]string{} + if path == "" { + return out + } + fh, err := os.Open(path) + if err != nil { + return out + } + defer fh.Close() + sc := bufio.NewScanner(fh) + for sc.Scan() { + if k, v, ok := strings.Cut(sc.Text(), "="); ok { + out[k] = v + } + } + return out +} + +func indent(s string) string { + if s == "" { + return "" + } + return " | " + strings.ReplaceAll(strings.TrimRight(s, "\n"), "\n", "\n | ") + "\n" +} + +// hostGoEnv is the test process's Go caches, so the workflow's go commands +// build against a warm cache under the fixture's hermetic HOME. +func hostGoEnv(t *testing.T) []string { + t.Helper() + out, err := exec.Command("go", "env", "GOCACHE", "GOMODCACHE", "GOPATH").Output() + if err != nil { + t.Skipf("go env: %v", err) + } + vals := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(vals) != 3 { + t.Fatalf("go env printed %q", out) + } + return []string{"GOCACHE=" + vals[0], "GOMODCACHE=" + vals[1], "GOPATH=" + vals[2], "GOFLAGS=", "CGO_ENABLED=" + cgo()} +} + +func cgo() string { + out, err := exec.Command("go", "env", "CGO_ENABLED").Output() + if err != nil { + return "0" + } + return strings.TrimSpace(string(out)) +} + +// buildRecordLint builds abcd's record-lint from this tree — the program the +// semantic profile's receipt gate runs as `go run ./cmd/record-lint`. +func buildRecordLint(t *testing.T, goEnv []string) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "record-lint") + cmd := exec.Command("go", "build", "-o", bin, "./cmd/record-lint") + cmd.Dir = repoRoot(t) + cmd.Env = append(os.Environ(), goEnv...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build record-lint: %v\n%s", err, out) + } + return bin +} + +func listOf(n *ynode) []string { + if n == nil { + return nil + } + s := strings.TrimSpace(n.scalar) + s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]") + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// --------------------------------------------------------------------------- +// A reader for the block-style YAML subset the templates are written in: +// block mappings, block sequences, plain and quoted scalars, flow sequences +// kept as text, and literal block scalars. It is enough to run the workflows; +// it is not a YAML parser. + +type ynode struct { + scalar string + keys []string + m map[string]*ynode + seq []*ynode +} + +func (n *ynode) get(k string) *ynode { + if n == nil || n.m == nil { + return nil + } + return n.m[k] +} + +func (n *ynode) str(k string) string { + if c := n.get(k); c != nil { + return c.scalar + } + return "" +} + +type yparser struct { + lines []string + i int +} + +func parseYAML(src string) *ynode { + p := &yparser{lines: strings.Split(src, "\n")} + p.skip() + if p.i >= len(p.lines) { + return &ynode{} + } + return p.block(p.indentOf(p.i)) +} + +func (p *yparser) indentOf(i int) int { + return len(p.lines[i]) - len(strings.TrimLeft(p.lines[i], " ")) +} + +// skip moves past blank and comment-only lines. +func (p *yparser) skip() { + for p.i < len(p.lines) { + t := strings.TrimSpace(p.lines[p.i]) + if t != "" && !strings.HasPrefix(t, "#") { + return + } + p.i++ + } +} + +func (p *yparser) block(ind int) *ynode { + p.skip() + if p.i < len(p.lines) && strings.HasPrefix(strings.TrimSpace(p.lines[p.i]), "- ") { + return p.sequence(ind) + } + return p.mapping(ind) +} + +func (p *yparser) sequence(ind int) *ynode { + n := &ynode{} + for { + p.skip() + if p.i >= len(p.lines) || p.indentOf(p.i) != ind || !strings.HasPrefix(strings.TrimSpace(p.lines[p.i]), "- ") { + return n + } + // Rewrite "- key: v" as a mapping line two columns in, and read the item. + l := p.lines[p.i] + p.lines[p.i] = l[:ind] + " " + l[ind+2:] + if !strings.Contains(strings.TrimSpace(p.lines[p.i]), ": ") && !strings.HasSuffix(strings.TrimSpace(p.lines[p.i]), ":") { + n.seq = append(n.seq, &ynode{scalar: plain(strings.TrimSpace(p.lines[p.i]))}) + p.i++ + continue + } + n.seq = append(n.seq, p.mapping(ind+2)) + } +} + +func (p *yparser) mapping(ind int) *ynode { + n := &ynode{m: map[string]*ynode{}} + for { + p.skip() + if p.i >= len(p.lines) || p.indentOf(p.i) != ind || strings.HasPrefix(strings.TrimSpace(p.lines[p.i]), "- ") { + return n + } + t := strings.TrimSpace(p.lines[p.i]) + k, rest, _ := strings.Cut(t, ":") + k = strings.Trim(k, `"'`) + rest = strings.TrimSpace(rest) + p.i++ + var child *ynode + switch { + case strings.HasPrefix(rest, "|") || strings.HasPrefix(rest, ">"): + child = &ynode{scalar: p.blockScalar(ind)} + case rest == "" || strings.HasPrefix(rest, "#"): + p.skip() + if p.i < len(p.lines) && p.indentOf(p.i) > ind { + child = p.block(p.indentOf(p.i)) + } else { + child = &ynode{} + } + default: + child = &ynode{scalar: plain(rest)} + } + if _, seen := n.m[k]; !seen { + n.keys = append(n.keys, k) + } + n.m[k] = child + } +} + +// blockScalar reads a literal block scalar's body, dedented. +func (p *yparser) blockScalar(ind int) string { + var body []string + bodyInd := -1 + for p.i < len(p.lines) { + l := p.lines[p.i] + if strings.TrimSpace(l) == "" { + body = append(body, "") + p.i++ + continue + } + li := len(l) - len(strings.TrimLeft(l, " ")) + if li <= ind { + break + } + if bodyInd < 0 { + bodyInd = li + } + body = append(body, l[bodyInd:]) + p.i++ + } + return strings.TrimRight(strings.Join(body, "\n"), "\n") + "\n" +} + +// plain reads a scalar: quotes stripped, a trailing comment dropped from an +// unquoted value. +func plain(v string) string { + if len(v) >= 2 && (v[0] == '\'' || v[0] == '"') { + if end := strings.LastIndexByte(v, v[0]); end > 0 { + return v[1:end] + } + } + if i := strings.Index(v, " #"); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(v) +} From 62e76ca3b051b912297124ab79dd024afc919657 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:09:59 +0100 Subject: [PATCH 08/27] chore: close spc-2609230613193436 and ship itd-93 The remainder spec's five criteria are delivered: the scaffold wired to the managed repo's own CI check names with an audit-clean render (AC1), the merge path driven to a published release against a fake forge (AC2), the scaffolded reviews charter exempting sha-keyed receipt directories (AC5), `launch receipts` sharing the release job's reader and proven equal to it (AC7), and the emit step ending with the receipts protocol (AC8). spc-14 delivered the rest. itd-93 declares no impact; closed as additive. Delivers: itd-93 Assisted-by: Claude:claude-opus-5-5 --- ...-sets-up-the-ci-a-repo-requires-and-reports-what-it-did.md | 2 +- ...scaffolds-a-hardened-changelog-driven-release-gate-into.md | 4 +++- .abcd/development/plans/2026-07-18-next-drain-run-queue.md | 2 +- ...230613193436-ci-name-wiring-receipts-check-and-protocol.md | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md (99%) rename .abcd/development/specs/{open => closed}/spc-2609230613193436-ci-name-wiring-receipts-check-and-protocol.md (93%) diff --git a/.abcd/development/intents/drafts/itd-106-abcd-sets-up-the-ci-a-repo-requires-and-reports-what-it-did.md b/.abcd/development/intents/drafts/itd-106-abcd-sets-up-the-ci-a-repo-requires-and-reports-what-it-did.md index 6bf0a6868..980499614 100644 --- a/.abcd/development/intents/drafts/itd-106-abcd-sets-up-the-ci-a-repo-requires-and-reports-what-it-did.md +++ b/.abcd/development/intents/drafts/itd-106-abcd-sets-up-the-ci-a-repo-requires-and-reports-what-it-did.md @@ -44,7 +44,7 @@ private-plan repos where no ruleset is possible. Deterministic expert work that repeats per repo is precisely the facilitation abcd exists to absorb; leaving it manual means most repos simply never get the bar. -The seam already exists. [itd-93](../planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md) +The seam already exists. [itd-93](../shipped/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md) has abcd scaffolding the hardened release workflows into a managed repo, parity-tested against the live workflows so the template cannot drift from reality. This intent generalises that machinery to the standing CI gates and diff --git a/.abcd/development/intents/planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md b/.abcd/development/intents/shipped/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md similarity index 99% rename from .abcd/development/intents/planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md rename to .abcd/development/intents/shipped/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md index 60acdb9b7..bf51b217e 100644 --- a/.abcd/development/intents/planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md +++ b/.abcd/development/intents/shipped/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md @@ -9,6 +9,7 @@ builds_on: [] related_adrs: [adr-37] severity: minor related_issues: [iss-327] +impact: additive --- # abcd Scaffolds a Release Gate That Works on the First Try @@ -219,4 +220,5 @@ queued in `../../plans/2026-07-24-next-run-queue.md` (Track 1)._ ## Audit Notes -_Empty. Populated by intent-fidelity-reviewer when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-1957b22ad5cc). diff --git a/.abcd/development/plans/2026-07-18-next-drain-run-queue.md b/.abcd/development/plans/2026-07-18-next-drain-run-queue.md index 5fae3c070..b2d95619b 100644 --- a/.abcd/development/plans/2026-07-18-next-drain-run-queue.md +++ b/.abcd/development/plans/2026-07-18-next-drain-run-queue.md @@ -20,7 +20,7 @@ PR #99) — scaffold `release.yml`/`auto-release.yml` (armed against the reviewe content commit), the runbook, and the sha-keyed-receipt/RD001 interop into a managed repo, so its first public release cannot hit the self-reference abcd-cli paid to discover. Backing intent: -[`../intents/planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md`](../intents/planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md). +[`../intents/planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md`](../intents/shipped/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md). **NOT yet run-ready — readiness gates (a drain burst must check these first and SKIP-with-reason if unmet, per the protocol's skip filter):** diff --git a/.abcd/development/specs/open/spc-2609230613193436-ci-name-wiring-receipts-check-and-protocol.md b/.abcd/development/specs/closed/spc-2609230613193436-ci-name-wiring-receipts-check-and-protocol.md similarity index 93% rename from .abcd/development/specs/open/spc-2609230613193436-ci-name-wiring-receipts-check-and-protocol.md rename to .abcd/development/specs/closed/spc-2609230613193436-ci-name-wiring-receipts-check-and-protocol.md index b46f03a5f..9436466f5 100644 --- a/.abcd/development/specs/open/spc-2609230613193436-ci-name-wiring-receipts-check-and-protocol.md +++ b/.abcd/development/specs/closed/spc-2609230613193436-ci-name-wiring-receipts-check-and-protocol.md @@ -9,8 +9,8 @@ production_mode: hand-written ## Summary -The remainder of [itd-93](../../intents/planned/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md) -that [spc-14](../closed/spc-14-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md) did not deliver. spc-14 closed on +The remainder of [itd-93](../../intents/shipped/itd-93-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md) +that [spc-14](spc-14-abcd-scaffolds-a-hardened-changelog-driven-release-gate-into.md) did not deliver. spc-14 closed on 2026-09-23 with acceptance criteria 3, 4 and 6 delivered and criteria 1 and 5 in part: `launch scaffold` writes `release.yml`, `auto-release.yml` and the runbook with a `GITHUB_TOKEN`-only gate, the bare render states that no semantic detector is configured, a re-run is an idempotent no-op that refuses a hand edit, and the rehearsal publishes nothing. This spec carries what did not ship. The delivered part was already announced in the [0.4.1] changelog section, From 27ce9237ace21773843e4c5283c99d86d2da75b9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:12:23 +0100 Subject: [PATCH 09/27] fix(launch): say "full sha" in the receipts protocol, and report no CI checks as [] The protocol named the receipt key a 40-character sha, which a SHA-256 repository's commits are not; the gate and the charter accept both. The scaffold's --json reported an absent CI-check list as null; it is []. Assisted-by: Claude:claude-opus-5-5 --- internal/core/launch/scaffold/scaffold.go | 3 +++ internal/core/release/protocol.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go index b30a331dd..efd8a6286 100644 --- a/internal/core/launch/scaffold/scaffold.go +++ b/internal/core/launch/scaffold/scaffold.go @@ -104,6 +104,9 @@ func Scaffold(req Request) (Report, error) { branch, goVersion := DeriveRepoFacts(req.RepoRoot) subs := BareSubstitutions(branch) subs.CIChecks = DeriveCIChecks(req.RepoRoot) + if subs.CIChecks == nil { + subs.CIChecks = []string{} // --json reports an empty list, never null + } rendered, err := Render(subs) if err != nil { return Report{}, err diff --git a/internal/core/release/protocol.go b/internal/core/release/protocol.go index aec1217db..e4bc554fc 100644 --- a/internal/core/release/protocol.go +++ b/internal/core/release/protocol.go @@ -63,7 +63,7 @@ func ReceiptsProtocolFor(root string) (ReceiptsProtocol, error) { "Run each semantic gate `" + gate.Workflow + "` requires against the content commit: " + strings.Join(gate.Gates, ", ") + ".", "Record each PROMOTE receipt at `.abcd/work/reviews//.json`, keyed to the full " + - "40-character sha of the content commit (`git rev-parse HEAD` right after step 1) — never the tag, " + + "sha of the content commit (`git rev-parse HEAD` right after step 1) — never the tag, " + "and never the merge.", "Commit the receipts on top. The release branch is exactly two commits: the roll, then the receipts " + "naming it. Amending the roll after this gives it a new sha and orphans every receipt.", From ddb84cb8bab331da254ff976bcfc322d1c6767c2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:39:55 +0100 Subject: [PATCH 10/27] chore: capture six release-gate review findings The review of the release-gate wiring (fix round 1 of lane gatewire) named two security holes in the content-commit derivation's version binding, two false refusals, a wording slip and a follow-up audit. Refs: iss-2609251939461459, iss-2609251939468296, iss-2609251939460232, iss-2609251939466588, iss-2609251939476304, iss-2609251939472371 Assisted-by: Claude:claude-opus-5-5 --- ...gate-s-content-commit-derivation-applies-the.md | 14 ++++++++++++++ ...e-gate-s-content-commit-derivation-binds-the.md | 14 ++++++++++++++ ...e-s-sha-pattern-admits-7-to-64-hex-digits-so.md | 14 ++++++++++++++ ...se-changelog-head-such-as-1-0-0-rc-1-date-is.md | 14 ++++++++++++++ ...olded-release-workflows-in-repo-audit-covers.md | 14 ++++++++++++++ ...otocol-text-says-the-receipts-step-reads-the.md | 14 ++++++++++++++ 6 files changed, 84 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md create mode 100644 .abcd/work/issues/open/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md create mode 100644 .abcd/work/issues/open/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md create mode 100644 .abcd/work/issues/open/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md create mode 100644 .abcd/work/issues/open/iss-2609251939472371-the-scaffolded-release-workflows-in-repo-audit-covers.md create mode 100644 .abcd/work/issues/open/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md diff --git a/.abcd/work/issues/open/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md b/.abcd/work/issues/open/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md new file mode 100644 index 000000000..d9414ad35 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251939460232" +slug: "the-release-gate-s-content-commit-derivation-applies-the" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/releasegate_derive.go" +--- + +The release gate's content-commit derivation applies the version binding to the NEAREST receipts directory only. A co-batched pull request that carries its own commit-keyed receipts directory either ties with the release roll's directory or sits nearer and carries the previous version; both refuse although the roll's correct receipts are on the lineage, and the cut cannot be rerun without a fresh reviewed commit. Candidates should be filtered by version first and the nearest taken from those. diff --git a/.abcd/work/issues/open/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md b/.abcd/work/issues/open/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md new file mode 100644 index 000000000..6db0c1d28 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251939461459" +slug: "the-release-gate-s-content-commit-derivation-binds-the" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/releasegate_derive.go" +--- + +The release gate's content-commit derivation binds the receipts to the released version with a string equality that admits two empty versions: a released tree with no dated CHANGELOG heading, or with no CHANGELOG.md at all, derives the nearest receipts directory, and the previous release's PROMOTE receipts admit it. releaseVersionAt returns an empty version with no error for a missing file, against its own comment. Reachable on the hand-pushed-tag path, where the verify job's receipts step runs and nothing cross-checks the tag against the CHANGELOG. diff --git a/.abcd/work/issues/open/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md b/.abcd/work/issues/open/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md new file mode 100644 index 000000000..25642a3e4 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251939466588" +slug: "the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/lint.go" +--- + +The receipt gate's sha pattern admits 7 to 64 hex digits, so an abbreviated receipts directory for the same commit ties with its full-sha twin and the derivation refuses. The reviews charter, the release protocol and the runbook all say the directory is named by the full sha; the pattern should require 40 or 64 hex digits. diff --git a/.abcd/work/issues/open/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md b/.abcd/work/issues/open/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md new file mode 100644 index 000000000..7ded2cae1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251939468296" +slug: "a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/releasegate_derive.go" +--- + +A pre-release CHANGELOG head such as '## [1.0.0-rc.1] - ' is invisible to the dated-heading reader, so the release gate's version binding compares against the previous version and the previous release's receipts admit the release. The release workflow's tag pattern accepts -rc and +build suffixes, so a hand-pushed pre-release tag reaches the gate. The derivation should refuse when the newest release heading is one the reader cannot parse. diff --git a/.abcd/work/issues/open/iss-2609251939472371-the-scaffolded-release-workflows-in-repo-audit-covers.md b/.abcd/work/issues/open/iss-2609251939472371-the-scaffolded-release-workflows-in-repo-audit-covers.md new file mode 100644 index 000000000..2cf5de6af --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251939472371-the-scaffolded-release-workflows-in-repo-audit-covers.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251939472371" +slug: "the-scaffolded-release-workflows-in-repo-audit-covers" +severity: "minor" +category: "future-work-seed" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/launch/scaffold/cichecks.go" +--- + +The scaffolded release workflows' in-repo audit covers injection and duplicate keys for every profile, but it is not a full zizmor stand-in for the bare profile: action pinning, permissions and credential classes are unchecked there, and only the abcd profile is zizmor-audited in CI. Follow-up: run zizmor over a rendered bare profile in CI. diff --git a/.abcd/work/issues/open/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md b/.abcd/work/issues/open/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md new file mode 100644 index 000000000..b884e805f --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251939476304" +slug: "the-release-protocol-text-says-the-receipts-step-reads-the" +severity: "nitpick" +category: "documentation" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/release/protocol.go" +--- + +The release protocol text says the receipts step reads 'the commit the release publishes from', but the release publishes from the tagged merge (runbook step 5 has it right); the content commit is the one the receipts name. From 2ccf16b73b3b870a493bf9a526dac30cbb330c9c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:43:30 +0100 Subject: [PATCH 11/27] fix(release-gate): bind receipts to a version the reader can read The content-commit derivation bound the nearest receipts directory to the released tree's CHANGELOG version with a lenient reader, and judged the nearest candidate only. Four ways past or into it: - A released tree with no dated heading, or no CHANGELOG.md, read as "" and an earlier commit's receipts matched on "" == "". The released tree must now name a version, or the derivation refuses. - A pre-release, build-metadata or undated head was skipped, so the binding compared against the previous release, whose PROMOTE receipts admitted this one. A new strict reader, changelog.ReleasedVersionIn, takes the newest "## [" heading other than [Unreleased] and refuses one the dated-heading reader does not parse. - Candidates are filtered by version first and the nearest taken from those, so a co-batched pull request's own receipts directory can no longer tie with or shadow the roll's and wedge a correct cut. - receiptShaRe requires a full 40- or 64-hex sha, as the charter, protocol and runbook say, so an abbreviated twin is a stray entry, not a tying candidate. This also narrows --release-gate to full shas; the release job passes the derived full sha. Item 2's shape: refuse an unreadable newest heading, rather than pass the tag in and require tag == CHANGELOG == receipts. Refusal lives in the one reader both front doors share, so `abcd launch receipts` (which has no tag) and the release job keep one verdict (itd-93 AC7). Passing the tag would split them and change the pinned workflow's verify step. The rehearsal simulated its roll as "## [0.0.0-rehearsal]", which the gate read past, landing on the previous version. The strict reader refuses it, so the rehearsal rolls "## [0.0.0]" in release.yml and in both branches of the scaffold template. TestScaffoldedGateCutsAFirstReleaseThatPublishes/semantic-gate was RED on it and is green again. Refs: iss-2609251939461459, iss-2609251939468296, iss-2609251939460232, iss-2609251939466588 Assisted-by: Claude:claude-opus-5-5 --- .github/workflows/release.yml | 7 +- commands/launch.md | 11 +- .../core/capture/resolve_provenance_test.go | 2 +- internal/core/changelog/anchor.go | 38 +++++ .../scaffold/templates/release.yml.tmpl | 14 +- .../launch/scaffold/templates/runbook.md.tmpl | 10 +- internal/core/lint/lint.go | 9 +- internal/core/lint/releasegate_derive.go | 122 ++++++++++----- internal/core/lint/releasegate_derive_test.go | 140 ++++++++++++++++++ 9 files changed, 300 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b19611c7c..1fcd0760a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -645,12 +645,15 @@ jobs: date="$(date -u +%Y-%m-%d)" # Content commit on a scratch branch: simulate rolling [Unreleased] into a # dated heading — the reviewed content commit a real release names. + # The heading is a plain vX.Y.Z (0.0.0, below every real release): the + # gate's reader refuses a newest heading it cannot parse, so a + # pre-release spelling here would refuse the rehearsal. git switch -q -c rehearsal-sim if grep -q '^## \[Unreleased\]' CHANGELOG.md 2>/dev/null; then - awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0-rehearsal] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal + awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal mv CHANGELOG.rehearsal CHANGELOG.md else - printf '\n## [0.0.0-rehearsal] - %s\n' "$date" >> CHANGELOG.md + printf '\n## [0.0.0] - %s\n' "$date" >> CHANGELOG.md fi git add -A git commit -q -m 'rehearsal: simulated changelog roll (content commit)' diff --git a/commands/launch.md b/commands/launch.md index e70e76159..a9cf951a8 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -621,9 +621,14 @@ the commit it names, because adding it would change that commit's sha. So: 2. **The receipts** — a commit recording the semantic verdicts that name commit 1. On merge, `release.yml` derives the content commit from the receipts directory -of the released tree: the nearest commit on the released lineage that a -`.abcd/work/reviews//` directory names, which must carry this release's own -CHANGELOG version — an earlier release's receipts never stand in for this one's. +of the released tree: of the commits on the released lineage that a +`.abcd/work/reviews//` directory names, the nearest one carrying this +release's own CHANGELOG version. A directory carrying another version is passed +over, so an earlier release's receipts never stand in for this one's and a +batch-mate's receipts never shadow them. The released tree's newest release +heading must be a dated `## [X.Y.Z] - ` heading: a pre-release or undated +head, or a tree with no dated release, refuses, because there is no version to +bind the receipts to. A one-commit branch breaks this: no receipt can name the commit that carries it, so the release has no receipts for its content and the gate refuses. diff --git a/internal/core/capture/resolve_provenance_test.go b/internal/core/capture/resolve_provenance_test.go index cb2aa7632..c701af57d 100644 --- a/internal/core/capture/resolve_provenance_test.go +++ b/internal/core/capture/resolve_provenance_test.go @@ -195,7 +195,7 @@ func TestFindRecordFileProbe(t *testing.T) { // A SHA-256 repo's commits are 64 hex chars; the shape check must accept them // (iss-356 item 5): the value is a provenance stamp, not a filter, and spc-25's // own rationale is "must not refuse a legitimate resolution". The sibling -// receipt gate (lint.go receiptShaRe) already accepts {7,64}. +// receipt gate (lint.go receiptShaRe) accepts a 64-hex sha too. func TestResolveAcceptsASHA256Commit(t *testing.T) { repo, ir, issID := provenanceFixture(t) sha := strings.Repeat("0123abcd", 8) // 64 hex chars diff --git a/internal/core/changelog/anchor.go b/internal/core/changelog/anchor.go index 4e049600f..2732d129a 100644 --- a/internal/core/changelog/anchor.go +++ b/internal/core/changelog/anchor.go @@ -1,6 +1,8 @@ package changelog import ( + "errors" + "fmt" "os" "path/filepath" "regexp" @@ -157,3 +159,39 @@ func LatestVersionIn(data []byte) (launch.Semver, bool, error) { } return launch.Semver{}, false, nil } + +// releaseHeadingPrefix opens every Keep-a-Changelog version heading, dated or +// not, and unreleasedHeading is the one such heading that names no release. +const ( + releaseHeadingPrefix = "## [" + unreleasedHeading = "## [Unreleased]" +) + +// ErrUnreadableReleaseHeading is returned by ReleasedVersionIn when the newest +// release heading is one the dated-heading reader does not parse. +var ErrUnreadableReleaseHeading = errors.New("the newest CHANGELOG release heading is not a dated vX.Y.Z heading") + +// ReleasedVersionIn is the strict reading of the version a released tree names, +// for a caller that BINDS something to that version rather than merely reports +// it. LatestVersionIn skips every heading datedHeadingRe does not match, which is +// right for a preview but wrong for a binding: a pre-release head ("## [1.0.0-rc.1] +// - …"), a build-metadata head or an undated version head would be skipped, and +// the reader would answer with the PREVIOUS release's version. +// +// So this reader takes the newest "## [" heading other than "## [Unreleased]" +// and requires it to be a dated heading LatestVersionIn parses; anything else is +// ErrUnreadableReleaseHeading, naming the line. found=false means the file names +// no release heading at all, which the caller decides about. +func ReleasedVersionIn(data []byte) (launch.Semver, bool, error) { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, "\r") + if !strings.HasPrefix(line, releaseHeadingPrefix) || strings.HasPrefix(line, unreleasedHeading) { + continue + } + if !datedHeadingRe.MatchString(line) { + return launch.Semver{}, false, fmt.Errorf("%w: %q", ErrUnreadableReleaseHeading, line) + } + return LatestVersionIn([]byte(line)) + } + return launch.Semver{}, false, nil +} diff --git a/internal/core/launch/scaffold/templates/release.yml.tmpl b/internal/core/launch/scaffold/templates/release.yml.tmpl index 89f0b6fd1..47b627231 100644 --- a/internal/core/launch/scaffold/templates/release.yml.tmpl +++ b/internal/core/launch/scaffold/templates/release.yml.tmpl @@ -735,12 +735,15 @@ jobs: date="$(date -u +%Y-%m-%d)" # Content commit on a scratch branch: simulate rolling [Unreleased] into a # dated heading — the reviewed content commit a real release names. + # The heading is a plain vX.Y.Z (0.0.0, below every real release): the + # gate's reader refuses a newest heading it cannot parse, so a + # pre-release spelling here would refuse the rehearsal. git switch -q -c rehearsal-sim if grep -q '^## \[Unreleased\]' CHANGELOG.md 2>/dev/null; then - awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0-rehearsal] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal + awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal mv CHANGELOG.rehearsal CHANGELOG.md else - printf '\n## [0.0.0-rehearsal] - %s\n' "$date" >> CHANGELOG.md + printf '\n## [0.0.0] - %s\n' "$date" >> CHANGELOG.md fi git add -A git commit -q -m 'rehearsal: simulated changelog roll (content commit)' @@ -797,12 +800,15 @@ jobs: date="$(date -u +%Y-%m-%d)" # Content commit on a scratch branch: simulate rolling [Unreleased] into a # dated heading — the reviewed content commit a real release names. + # The heading is a plain vX.Y.Z (0.0.0, below every real release): the + # gate's reader refuses a newest heading it cannot parse, so a + # pre-release spelling here would refuse the rehearsal. git switch -q -c rehearsal-sim if grep -q '^## \[Unreleased\]' CHANGELOG.md 2>/dev/null; then - awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0-rehearsal] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal + awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal mv CHANGELOG.rehearsal CHANGELOG.md else - printf '\n## [0.0.0-rehearsal] - %s\n' "$date" >> CHANGELOG.md + printf '\n## [0.0.0] - %s\n' "$date" >> CHANGELOG.md fi git add -A git commit -q -m 'rehearsal: simulated changelog roll (content commit)' diff --git a/internal/core/launch/scaffold/templates/runbook.md.tmpl b/internal/core/launch/scaffold/templates/runbook.md.tmpl index 19bf2d9b4..31e31857d 100644 --- a/internal/core/launch/scaffold/templates/runbook.md.tmpl +++ b/internal/core/launch/scaffold/templates/runbook.md.tmpl @@ -93,9 +93,13 @@ change that commit's sha). So the release branch is two commits: the CHANGELOG roll (the reviewed content commit), then the receipts naming it. On merge, `release.yml` arms the gate with the **content** commit, derived from the receipts directory of the released tree — the nearest commit a -`.abcd/work/reviews//` entry names, which must carry this release's own -CHANGELOG version — so the receipt-vs-tag self-reference never blocks the -release, and an earlier release's receipts never stand in for this one's. +`.abcd/work/reviews//` entry names among those carrying this +release's own CHANGELOG version — so the receipt-vs-tag self-reference never +blocks the release, an earlier release's receipts never stand in for this +one's, and a batch-mate's receipts never shadow them. The released tree's +newest release heading must be a dated `## [X.Y.Z] - ` heading; a +pre-release or undated head refuses, because there is no version to bind the +receipts to. Receipts live at `.abcd/work/reviews//.json`. A receipt is bound to its gate by its `policy.detector` value, not its filename, so one diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 5819c8bc7..d2494038b 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -79,8 +79,13 @@ var ( // Surface registry Command cell: the bare "/abcd" top-level, or "/abcd:". surfaceCmdRe = regexp.MustCompile(`^/abcd(?::([a-z0-9-]+))?$`) // receipt_gate arming inputs are release-time and become externally supplied - // (release.yml) — validated as safe path components before use. - receiptShaRe = regexp.MustCompile(`^[0-9a-f]{7,64}$`) + // (release.yml) — validated as safe path components before use. A receipts + // directory is named by the FULL commit sha (the reviews charter, the release + // protocol and the runbook all say so): 40 hex digits, or 64 in a SHA-256 + // repository. An abbreviation is not admitted, so an abbreviated twin of a + // real directory is a stray entry rather than a second candidate that ties + // with it (iss-2609251939466588). + receiptShaRe = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) receiptGateRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // gate_lockstep hand-parsers (no YAML library — the repo has none and adds no // dependency): a markdown numbered-list item; the `jobs:` line; a 2-space job diff --git a/internal/core/lint/releasegate_derive.go b/internal/core/lint/releasegate_derive.go index 48c53ca11..f75935a5d 100644 --- a/internal/core/lint/releasegate_derive.go +++ b/internal/core/lint/releasegate_derive.go @@ -75,12 +75,30 @@ func DeriveReleaseContentSha(root, released string) (string, error) { return "", fmt.Errorf("release-gate: no receipts directory under %s names a commit on the released lineage; the semantic gate has nothing to arm (fail-closed)", reviewsSubdir) } - // Nearest ancestor wins: the fewest commits between a candidate and the - // released commit is this release's content commit; every earlier release's - // receipts sit further back on the shared first-parent history. Two candidates - // equidistant from `released` make the content commit ambiguous — fail closed - // rather than pick one. + // The receipts must be THIS release's, and the content commit a release's + // receipts name is the commit that rolled the CHANGELOG to this release's + // version, so it carries the released tree's own newest release version. The + // released tree must name one, strictly read: with no version to bind, an + // earlier commit's receipts (which carry none either) would match on two + // empty strings (iss-2609251939461459), and a head the reader skips — a + // pre-release, say — would bind to the PREVIOUS release, whose PROMOTE + // receipts would then admit this one (iss-2609251939468296). + want, err := releasedVersionAt(root, released) + if err != nil { + return "", err + } + + // Filter by version FIRST, then take the nearest (iss-2609251939460232). A + // receipts directory carrying another version is not this release's, however + // near it sits: a co-batched pull request branched before the roll carries + // its own commit-keyed directory at the previous version, and judging only + // the nearest candidate would let it tie with, or shadow, the roll's own + // receipts and wedge a correct cut. Among the candidates that carry this + // release's version, the fewest commits between a candidate and the released + // commit wins; two equidistant ones make the content commit ambiguous, and + // the derivation fails closed rather than pick one. best, bestCount, tie := "", -1, false + nearest, nearestCount, nearestVersion := "", -1, "" for _, c := range candidates { out, err := gitutil.Run(root, "rev-list", "--count", c+".."+released) if err != nil { @@ -90,6 +108,16 @@ func DeriveReleaseContentSha(root, released string) (string, error) { if err != nil { return "", fmt.Errorf("release-gate: parsing distance from receipt commit %s: %w", c, err) } + got, err := candidateVersionAt(root, c) + if err != nil { + return "", err + } + if nearestCount == -1 || n < nearestCount { + nearest, nearestCount, nearestVersion = c, n, got + } + if got != want { + continue + } switch { case bestCount == -1 || n < bestCount: best, bestCount, tie = c, n, false @@ -97,29 +125,15 @@ func DeriveReleaseContentSha(root, released string) (string, error) { tie = true } } - if tie { - return "", fmt.Errorf("release-gate: two receipts directories are equidistant from the released commit; the content commit is ambiguous (fail-closed)") - } - - // Nearest is not enough: the nearest receipts directory can be an EARLIER - // release's, when this release recorded none of its own, and its PROMOTE - // receipts would then admit a release nobody reviewed (iss-2609251755386183). - // The content commit a release's receipts name is the commit that rolled the - // CHANGELOG to this release's version, so it carries the released tree's own - // newest dated version. A candidate carrying any other version is not this - // release's content commit, and the derivation fails closed on it. - want, err := releaseVersionAt(root, released) - if err != nil { - return "", err - } - got, err := releaseVersionAt(root, best) - if err != nil { - return "", err - } - if got != want { + if best == "" { + // The nearest receipts directory can be an EARLIER release's, when this + // release recorded none of its own (iss-2609251755386183); name it. return "", fmt.Errorf("release-gate: the nearest receipts directory names %s, whose newest CHANGELOG version is %s, "+ "not this release's %s; no receipts directory names this release's content commit (fail-closed)", - best, versionOrNone(got), versionOrNone(want)) + nearest, versionOrNone(nearestVersion), want) + } + if tie { + return "", fmt.Errorf("release-gate: two receipts directories carrying %s are equidistant from the released commit; the content commit is ambiguous (fail-closed)", want) } // Return the full 40/64-hex sha so the armed gate's receiptShaRe check and the @@ -163,28 +177,60 @@ func receiptDirNames(root, released string) ([]string, error) { return names, nil } -// releaseVersionAt reads the newest dated CHANGELOG version out of rev's tree -// — the version auto-release tags when rev is released. "" means rev carries no -// CHANGELOG.md or no dated heading in it; an unreadable blob or a malformed -// heading is an error, never a silent "". -func releaseVersionAt(root, rev string) (string, error) { - if _, err := gitutil.Run(root, "cat-file", "-e", rev+":CHANGELOG.md"); err != nil { - return "", nil - } - blob, err := gitutil.Run(root, "cat-file", "blob", rev+":CHANGELOG.md") +// releasedVersionAt reads the version the released tree names, strictly: +// the tree must carry a CHANGELOG.md whose newest release heading is a dated +// vX.Y.Z heading (changelog.ReleasedVersionIn). A missing file, no release +// heading, or a head the reader cannot parse each refuses — never a silent "", +// which would bind the receipts to nothing. +func releasedVersionAt(root, released string) (string, error) { + blob, present, err := changelogAt(root, released) if err != nil { - return "", fmt.Errorf("release-gate: reading CHANGELOG.md at %s: %w", rev, err) + return "", err } - v, found, err := changelog.LatestVersionIn([]byte(blob)) + if !present { + return "", fmt.Errorf("release-gate: the released tree %s carries no CHANGELOG.md, so it names no release version to bind receipts to (fail-closed)", released) + } + v, found, err := changelog.ReleasedVersionIn([]byte(blob)) if err != nil { - return "", fmt.Errorf("release-gate: CHANGELOG.md at %s: %w", rev, err) + return "", fmt.Errorf("release-gate: CHANGELOG.md at %s: %w; the receipts cannot be bound to a version the tagger never reads (fail-closed)", released, err) } if !found { + return "", fmt.Errorf("release-gate: CHANGELOG.md at %s names no dated release, so there is no release version to bind receipts to (fail-closed)", released) + } + return v.String(), nil +} + +// candidateVersionAt reads the version a receipts candidate carries, by the +// same strict reader. A candidate that names no readable release version cannot +// be this release's content commit, so absence and an unreadable head are both +// "" — a non-match against the released version, which is never empty — rather +// than an error that would let one stray commit wedge every later cut. Only a +// git failure is returned. +func candidateVersionAt(root, rev string) (string, error) { + blob, present, err := changelogAt(root, rev) + if err != nil || !present { + return "", err + } + v, found, err := changelog.ReleasedVersionIn([]byte(blob)) + if err != nil || !found { return "", nil } return v.String(), nil } +// changelogAt reads CHANGELOG.md out of rev's tree. present=false means rev +// carries no such file; an unreadable blob is an error. +func changelogAt(root, rev string) (string, bool, error) { + if _, err := gitutil.Run(root, "cat-file", "-e", rev+":CHANGELOG.md"); err != nil { + return "", false, nil + } + blob, err := gitutil.Run(root, "cat-file", "blob", rev+":CHANGELOG.md") + if err != nil { + return "", false, fmt.Errorf("release-gate: reading CHANGELOG.md at %s: %w", rev, err) + } + return blob, true, nil +} + // versionOrNone renders a release version for a refusal, naming its absence. func versionOrNone(v string) string { if v == "" { diff --git a/internal/core/lint/releasegate_derive_test.go b/internal/core/lint/releasegate_derive_test.go index 18d093ccb..7d6d100b9 100644 --- a/internal/core/lint/releasegate_derive_test.go +++ b/internal/core/lint/releasegate_derive_test.go @@ -208,3 +208,143 @@ func TestDeriveReleaseContentSha_RefusesAnEarlierReleasesReceipts(t *testing.T) t.Errorf("with its own receipts the 1.0.0 roll must derive: got %s, %v", got, err) } } + +// TestDeriveReleaseContentSha_RefusesAReleasedTreeWithNoVersion is +// iss-2609251939461459 (review probes S6 and S6b): a released tree whose +// CHANGELOG.md names no dated release, or that carries no CHANGELOG.md at all, +// has no version to bind receipts to. An earlier commit's receipts carry no +// version either, and "" == "" must not read as a match. +func TestDeriveReleaseContentSha_RefusesAReleasedTreeWithNoVersion(t *testing.T) { + for name, released := range map[string]func(r *gittest.Repo){ + "undated head (S6)": func(r *gittest.Repo) { + r.Write("CHANGELOG.md", "## [Unreleased]\n\n- more work\n") + r.Commit("more unreleased work") + }, + "no CHANGELOG.md (S6b)": func(r *gittest.Repo) { + r.Git("rm", "-q", "CHANGELOG.md") + r.Commit("drop the changelog") + }, + } { + t.Run(name, func(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("CHANGELOG.md", "## [Unreleased]\n") + r.Commit("base, no release") + earlier := r.Git("rev-parse", "HEAD") + receiptsFor(r, earlier) + released(r) + + got, err := lint.DeriveReleaseContentSha(r.Root(), r.Git("rev-parse", "HEAD")) + if err == nil { + t.Fatalf("derived %s for a released tree that names no version; must fail closed", got) + } + for _, want := range []string{"fail-closed", "CHANGELOG"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to name %q", err, want) + } + } + }) + } +} + +// TestDeriveReleaseContentSha_RefusesAnUnparseableNewestHeading is +// iss-2609251939468296 (review probe S5): a pre-release head is invisible to the +// dated-heading reader, so the binding would compare against the PREVIOUS +// version and that release's receipts would admit this one. The newest release +// heading must be one the reader parses, or the derivation refuses naming it. +func TestDeriveReleaseContentSha_RefusesAnUnparseableNewestHeading(t *testing.T) { + for name, head := range map[string]string{ + "pre-release (S5)": "## [1.0.0-rc.1] - 2026-01-01", + "build metadata": "## [1.0.0+build.7] - 2026-01-01", + "undated version": "## [1.0.0]", + } { + t.Run(name, func(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [0.9.0] - 2025-12-01\n") + r.Commit("roll 0.9.0") + old := r.Git("rev-parse", "HEAD") + receiptsFor(r, old) + + r.Write("CHANGELOG.md", "## [Unreleased]\n\n"+head+"\n\n## [0.9.0] - 2025-12-01\n") + r.Commit("roll the next head (content, no receipts)") + + got, err := lint.DeriveReleaseContentSha(r.Root(), r.Git("rev-parse", "HEAD")) + if err == nil { + t.Fatalf("derived %s — the 0.9.0 cut — under the head %q; must fail closed", got, head) + } + for _, want := range []string{"fail-closed", head} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to name %q", err, want) + } + } + }) + } +} + +// TestDeriveReleaseContentSha_SkipsANearerReceiptsDirOfAnotherVersion is +// iss-2609251939460232 (review probes S3 and S3b): a co-batched pull request, +// branched before the roll and merged after it, carries its own commit-keyed +// receipts directory. That directory ties with, or sits nearer than, the roll's; +// it carries the previous version, so it is not this release's, and the roll's +// own receipts further back on the lineage must still derive. +func TestDeriveReleaseContentSha_SkipsANearerReceiptsDirOfAnotherVersion(t *testing.T) { + for name, extra := range map[string]int{"tie (S3)": 0, "nearer (S3b)": 2} { + t.Run(name, func(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [0.9.0] - 2025-12-01\n") + r.Commit("base at 0.9.0") + + // The batch-mate, branched before the roll. Commits of its own before + // its reviewed content count toward the roll's distance and not + // toward its own, which is what puts its directory nearer. + r.Git("switch", "-c", "batchmate") + for i := 0; i < extra; i++ { + r.Write("feature-prep.txt", strings.Repeat("x", i+1)+"\n") + r.Commit("batch-mate preparation") + } + r.Write("feature.txt", "feature\n") + r.Commit("batch-mate content") + mate := r.Git("rev-parse", "HEAD") + receiptsFor(r, mate) + r.Git("switch", "main") + + // The roll, reviewed, merged first. + r.Git("switch", "-c", "release") + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n\n## [0.9.0] - 2025-12-01\n") + r.Commit("roll 1.0.0 (content)") + content := r.Git("rev-parse", "HEAD") + receiptsFor(r, content) + r.Git("switch", "main") + r.Git("merge", "-q", "--no-ff", "-m", "merge release", "release") + r.Git("merge", "-q", "--no-ff", "-m", "merge batch-mate", "batchmate") + + got, err := lint.DeriveReleaseContentSha(r.Root(), r.Git("rev-parse", "HEAD")) + if err != nil { + t.Fatalf("derive: %v; the roll's own receipts are on the lineage", err) + } + if got != content { + t.Errorf("derived %s, want the roll %s (the batch-mate is %s)", got, content, mate) + } + }) + } +} + +// TestDeriveReleaseContentSha_IgnoresAnAbbreviatedReceiptsDir is +// iss-2609251939466588 (review probe S9): the charter names receipts +// directories by the FULL sha, so an abbreviated twin for the same commit is a +// stray entry, not a second candidate that ties with the real one. +func TestDeriveReleaseContentSha_IgnoresAnAbbreviatedReceiptsDir(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("CHANGELOG.md", "## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n") + r.Commit("roll 1.0.0 (content)") + content := r.Git("rev-parse", "HEAD") + receiptsFor(r, content) + receiptsFor(r, content[:12]) + + got, err := lint.DeriveReleaseContentSha(r.Root(), r.Git("rev-parse", "HEAD")) + if err != nil { + t.Fatalf("derive: %v; an abbreviated directory must not tie with its full twin", err) + } + if got != content { + t.Errorf("derived %s, want %s", got, content) + } +} From 4318ca41c1a70e6194c4644697b05f9aed77200e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:43:42 +0100 Subject: [PATCH 12/27] =?UTF-8?q?chore:=20resolve=20iss-2609251939461459?= =?UTF-8?q?=20=E2=80=94=20a=20released=20tree=20with=20no=20version=20refu?= =?UTF-8?q?ses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251939461459 Assisted-by: Claude:claude-opus-5-5 --- ...-release-gate-s-content-commit-derivation-binds-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md (65%) diff --git a/.abcd/work/issues/open/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md b/.abcd/work/issues/resolved/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md similarity index 65% rename from .abcd/work/issues/open/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md rename to .abcd/work/issues/resolved/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md index 6db0c1d28..c8538c5ca 100644 --- a/.abcd/work/issues/open/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md +++ b/.abcd/work/issues/resolved/iss-2609251939461459-the-release-gate-s-content-commit-derivation-binds-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/releasegate_derive.go" +resolution: "The derivation refuses a released tree with no CHANGELOG.md or no dated release heading instead of matching two empty versions." +impact: fix +resolved_by: + commit: "2ccf16b73b3b870a493bf9a526dac30cbb330c9c" --- The release gate's content-commit derivation binds the receipts to the released version with a string equality that admits two empty versions: a released tree with no dated CHANGELOG heading, or with no CHANGELOG.md at all, derives the nearest receipts directory, and the previous release's PROMOTE receipts admit it. releaseVersionAt returns an empty version with no error for a missing file, against its own comment. Reachable on the hand-pushed-tag path, where the verify job's receipts step runs and nothing cross-checks the tag against the CHANGELOG. + +## Grounds + +- pursued: a released tree naming no version refuses with a fail-closed message naming CHANGELOG (TestDeriveReleaseContentSha_RefusesAReleasedTreeWithNoVersion, review probes S6 and S6b); a derived sha for either shape would show it wrong. From 7d099751c4925db969cdec085a63b59c0080840e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:43:44 +0100 Subject: [PATCH 13/27] =?UTF-8?q?chore:=20resolve=20iss-2609251939468296?= =?UTF-8?q?=20=E2=80=94=20an=20unreadable=20newest=20heading=20refuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251939468296 Assisted-by: Claude:claude-opus-5-5 --- ...e-release-changelog-head-such-as-1-0-0-rc-1-date-is.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md (59%) diff --git a/.abcd/work/issues/open/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md b/.abcd/work/issues/resolved/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md similarity index 59% rename from .abcd/work/issues/open/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md rename to .abcd/work/issues/resolved/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md index 7ded2cae1..bcf0388c1 100644 --- a/.abcd/work/issues/open/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md +++ b/.abcd/work/issues/resolved/iss-2609251939468296-a-pre-release-changelog-head-such-as-1-0-0-rc-1-date-is.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/releasegate_derive.go" +resolution: "A strict reader (changelog.ReleasedVersionIn) refuses a newest release heading the dated-heading reader cannot parse, so a pre-release head no longer binds to the previous release." +impact: fix +resolved_by: + commit: "2ccf16b73b3b870a493bf9a526dac30cbb330c9c" --- A pre-release CHANGELOG head such as '## [1.0.0-rc.1] - ' is invisible to the dated-heading reader, so the release gate's version binding compares against the previous version and the previous release's receipts admit the release. The release workflow's tag pattern accepts -rc and +build suffixes, so a hand-pushed pre-release tag reaches the gate. The derivation should refuse when the newest release heading is one the reader cannot parse. + +## Grounds + +- pursued: a pre-release, build-metadata or undated newest heading refuses naming the heading (TestDeriveReleaseContentSha_RefusesAnUnparseableNewestHeading, review probe S5); deriving the previous cut's receipts under such a head would show it wrong. From 5bd26000338f08baa350116d627bfe0339470dbe Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:43:46 +0100 Subject: [PATCH 14/27] =?UTF-8?q?chore:=20resolve=20iss-2609251939460232?= =?UTF-8?q?=20=E2=80=94=20version=20filter=20precedes=20nearest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251939460232 Assisted-by: Claude:claude-opus-5-5 --- ...elease-gate-s-content-commit-derivation-applies-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md (61%) diff --git a/.abcd/work/issues/open/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md b/.abcd/work/issues/resolved/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md similarity index 61% rename from .abcd/work/issues/open/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md rename to .abcd/work/issues/resolved/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md index d9414ad35..58eaa75bb 100644 --- a/.abcd/work/issues/open/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md +++ b/.abcd/work/issues/resolved/iss-2609251939460232-the-release-gate-s-content-commit-derivation-applies-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/releasegate_derive.go" +resolution: "Candidates are filtered by version before the nearest is taken, so a co-batched pull request's receipts directory neither ties with nor shadows the roll's." +impact: fix +resolved_by: + commit: "2ccf16b73b3b870a493bf9a526dac30cbb330c9c" --- The release gate's content-commit derivation applies the version binding to the NEAREST receipts directory only. A co-batched pull request that carries its own commit-keyed receipts directory either ties with the release roll's directory or sits nearer and carries the previous version; both refuse although the roll's correct receipts are on the lineage, and the cut cannot be rerun without a fresh reviewed commit. Candidates should be filtered by version first and the nearest taken from those. + +## Grounds + +- pursued: the roll derives when a batch-mate's receipts directory ties with or sits nearer than its own (TestDeriveReleaseContentSha_SkipsANearerReceiptsDirOfAnotherVersion, review probes S3 and S3b); a tie or version refusal there would show it wrong. From 066e12b811ea1f4661340f7823a5df5f43220c4c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:43:48 +0100 Subject: [PATCH 15/27] =?UTF-8?q?chore:=20resolve=20iss-2609251939466588?= =?UTF-8?q?=20=E2=80=94=20receipt=20dirs=20require=20a=20full=20sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251939466588 Assisted-by: Claude:claude-opus-5-5 --- ...ipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md (61%) diff --git a/.abcd/work/issues/open/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md b/.abcd/work/issues/resolved/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md similarity index 61% rename from .abcd/work/issues/open/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md rename to .abcd/work/issues/resolved/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md index 25642a3e4..6979d2a81 100644 --- a/.abcd/work/issues/open/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md +++ b/.abcd/work/issues/resolved/iss-2609251939466588-the-receipt-gate-s-sha-pattern-admits-7-to-64-hex-digits-so.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/lint.go" +resolution: "receiptShaRe requires 40 or 64 hex digits, so an abbreviated receipts directory is a stray entry rather than a tying candidate." +impact: fix +resolved_by: + commit: "2ccf16b73b3b870a493bf9a526dac30cbb330c9c" --- The receipt gate's sha pattern admits 7 to 64 hex digits, so an abbreviated receipts directory for the same commit ties with its full-sha twin and the derivation refuses. The reviews charter, the release protocol and the runbook all say the directory is named by the full sha; the pattern should require 40 or 64 hex digits. + +## Grounds + +- pursued: a full-sha directory derives beside its 12-hex twin (TestDeriveReleaseContentSha_IgnoresAnAbbreviatedReceiptsDir, review probe S9); a tie refusal would show it wrong. From 3ab36f809474b925e7dd1f0825766167aafe8d13 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:44:46 +0100 Subject: [PATCH 16/27] fix(release): the receipts protocol says the release publishes from the tagged merge The roll step called the content commit 'the commit the release publishes from'. The release publishes from the tagged merge, as the runbook's last step says; the content commit is the one the reviewers read and every receipt names. Refs: iss-2609251939476304 Assisted-by: Claude:claude-opus-5-5 --- internal/core/release/protocol.go | 4 ++-- internal/core/release/protocol_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/core/release/protocol.go b/internal/core/release/protocol.go index e4bc554fc..1bcc74611 100644 --- a/internal/core/release/protocol.go +++ b/internal/core/release/protocol.go @@ -40,8 +40,8 @@ func ReceiptsProtocolFor(root string) (ReceiptsProtocol, error) { p := ReceiptsProtocol{Workflow: gate.Workflow, Armed: gate.Armed, RequiredGates: gate.Gates} roll := "Ingest the composed changelog (`abcd launch ship --changelog-json `) and commit the " + - "result on a release branch. That commit is the content commit: the commit the release publishes " + - "from and every receipt names." + "result on a release branch. That commit is the content commit: the commit the reviewers read and " + + "every receipt names. The release itself publishes from the tagged merge, not from this commit." switch { case !gate.Present: p.Steps = []string{ diff --git a/internal/core/release/protocol_test.go b/internal/core/release/protocol_test.go index 25dfcb6ab..445081a90 100644 --- a/internal/core/release/protocol_test.go +++ b/internal/core/release/protocol_test.go @@ -82,3 +82,28 @@ func TestReceiptsProtocolWithNoGateRequiresNoReceipt(t *testing.T) { t.Errorf("a repository with no release workflow must be pointed at the scaffold; steps: %v", p.Steps) } } + +// TestReceiptsProtocolSaysTheReleasePublishesFromTheTaggedMerge is +// iss-2609251939476304: the content commit is the one every receipt names, not +// the one the release publishes from. The release publishes from the tagged +// merge, as the runbook's last step says, and the roll step must not tell the +// operator otherwise. +func TestReceiptsProtocolSaysTheReleasePublishesFromTheTaggedMerge(t *testing.T) { + root := t.TempDir() + writeWorkflow(t, root, "jobs:\n verify:\n steps:\n - run: |\n"+ + " go run ./cmd/record-lint --release-gate \"$content\" \\\n"+ + " --require-gate docs-currency-reviewer\n") + p, err := release.ReceiptsProtocolFor(root) + if err != nil { + t.Fatal(err) + } + roll := p.Steps[0] + if strings.Contains(roll, "publishes from and") { + t.Errorf("the roll step says the content commit is what the release publishes from:\n%s", roll) + } + for _, want := range []string{"every receipt names", "tagged merge"} { + if !strings.Contains(roll, want) { + t.Errorf("the roll step must carry %q:\n%s", want, roll) + } + } +} From bbce2576fe926aea20e9a6480f836a6253435029 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:44:49 +0100 Subject: [PATCH 17/27] =?UTF-8?q?chore:=20resolve=20iss-2609251939476304?= =?UTF-8?q?=20=E2=80=94=20the=20protocol=20names=20the=20tagged=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251939476304 Assisted-by: Claude:claude-opus-5-5 --- ...ease-protocol-text-says-the-receipts-step-reads-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md (53%) diff --git a/.abcd/work/issues/open/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md b/.abcd/work/issues/resolved/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md similarity index 53% rename from .abcd/work/issues/open/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md rename to .abcd/work/issues/resolved/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md index b884e805f..a08f51a92 100644 --- a/.abcd/work/issues/open/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md +++ b/.abcd/work/issues/resolved/iss-2609251939476304-the-release-protocol-text-says-the-receipts-step-reads-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/release/protocol.go" +resolution: "The roll step names the content commit as the one the reviewers read and every receipt names, and says the release publishes from the tagged merge." +impact: fix +resolved_by: + commit: "3ab36f809474b925e7dd1f0825766167aafe8d13" --- The release protocol text says the receipts step reads 'the commit the release publishes from', but the release publishes from the tagged merge (runbook step 5 has it right); the content commit is the one the receipts name. + +## Grounds + +- pursued: the emitted protocol's roll step says 'tagged merge' and no longer calls the content commit what the release publishes from (TestReceiptsProtocolSaysTheReleasePublishesFromTheTaggedMerge); the old phrase in the step would show it wrong. From 477269a1baf5391b9be03db2b69bb6c90aa44f45 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:45:47 +0100 Subject: [PATCH 18/27] docs(launch): say the scaffold's workflow audit is not a full zizmor run abcd's tests audit every rendered workflow profile for duplicate keys and template injection, and only abcd's own workflows run under zizmor in CI. The launch page and the scaffolded runbook now say that action pinning, job permissions and credential handling go unchecked for the bare profile, and tell the operator to run their own workflow auditor. Running zizmor over a rendered bare profile in CI stays open as a follow-up. abcd's own release-gate runbook also describes the stricter content-commit derivation: version filter before nearest, full-sha entries, a strictly read released heading, and the rehearsal's plain 0.0.0 roll. Refs: iss-2609251939472371, iss-2609251939460232, iss-2609251939466588, iss-2609251939468296, iss-2609251939461459 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/release-gate/README.md | 9 ++++++++- commands/launch.md | 7 +++++++ internal/core/launch/scaffold/templates/runbook.md.tmpl | 9 +++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index ed84f2e26..6096b65c5 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -104,7 +104,14 @@ tip, iss-355). The entry must also belong to THIS release: the commit it names carries the released tree's own newest dated CHANGELOG version, or the derivation fails closed — the nearest entry on a release that recorded no receipts of its own is the previous release's, whose valid receipts would otherwise admit it -unreviewed (iss-2609251755386183). `subject.digest.gitCommit` therefore still +unreviewed (iss-2609251755386183). Entries carrying another version are passed +over before the nearest is taken, so a co-batched pull request's own sha-keyed +entry cannot tie with or shadow the roll's (iss-2609251939460232), and an entry +must be named by the full sha (iss-2609251939466588). The released tree's newest +release heading is read strictly: a pre-release or undated head, or a tree that +names no dated release, refuses, because there is no version to bind the receipts +to (iss-2609251939468296, iss-2609251939461459) — which is why the rehearsal +rolls a plain `## [0.0.0]` heading. `subject.digest.gitCommit` therefore still matches the armed commit exactly and the gate stays strict. (Before this, the gate armed with the tagged merge commit, whose tree can never hold a receipt naming itself — an unsatisfiable self-reference. Dormant while the repo was private, it surfaced at the first public release and diff --git a/commands/launch.md b/commands/launch.md index a9cf951a8..c6857e392 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -766,6 +766,13 @@ real release — it arms the full gate against a simulated changelog roll and reviewed-content commit, proves the gate admits, and publishes nothing (no tag, Release, or attestation). +abcd's own tests audit every workflow profile it renders for duplicate keys and +template injection. That audit is not a full zizmor stand-in for the bare +profile a managed repo receives: action pinning, job permissions and credential +handling are not checked there. Only abcd's own workflows run under zizmor in +its CI. The scaffolded runbook says so. Tell the operator to run their workflow +auditor over the written files. + It is idempotent and fail-safe. Exit codes gate the flow: - **0** — every file written, or already current (a no-op re-run). Report the diff --git a/internal/core/launch/scaffold/templates/runbook.md.tmpl b/internal/core/launch/scaffold/templates/runbook.md.tmpl index 31e31857d..06df87944 100644 --- a/internal/core/launch/scaffold/templates/runbook.md.tmpl +++ b/internal/core/launch/scaffold/templates/runbook.md.tmpl @@ -51,6 +51,15 @@ release roll's merge. Add a CI workflow for pull requests, require its checks on them here and in `release.yml`. <%- end %> +## Workflow audit — what abcd checked, and what it did not + +abcd audits the workflows it writes for two finding classes: duplicate keys, +and template injection (no `${{ }}` expression inside a run script, and none +anywhere that a pull request can write). That is not a full zizmor audit. +Action pinning, job permissions and credential handling are not checked here. +Run zizmor, or the workflow auditor this repository already uses, over +`.github/workflows/` after every scaffold. + <% end -%> ## Deterministic gates (CI-enforced) From 4ff767ecf188a91ab2879300296bb6918d4aace3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:46:02 +0100 Subject: [PATCH 19/27] chore: capture the release gate's missing tag-to-CHANGELOG binding Found while choosing the shape of the pre-release-head fix. The shape chosen refuses an unreadable newest heading in the reader both front doors share, so the tag is not consulted. A hand-pushed tag naming another version therefore still passes under the CHANGELOG version's receipts. Closing that needs a ruling on the verify step and on the local check's parity, so it is filed rather than fixed in this round. Refs: iss-2609251945586202 Assisted-by: Claude:claude-opus-5-5 --- ...e-never-binds-the-pushed-tag-to-the-released.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md diff --git a/.abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md b/.abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md new file mode 100644 index 000000000..637f17b0e --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251945586202" +slug: "the-release-gate-never-binds-the-pushed-tag-to-the-released" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/releasegate_derive.go" +--- + +The release gate never binds the pushed tag to the released tree's CHANGELOG version. The content-commit derivation reads the version from the CHANGELOG only, and the release workflow's tag-shape check admits any vX.Y.Z tag, so a hand-pushed tag naming a version other than the tree's newest dated heading (for example v0.9.1 on a tree whose head is 0.9.0) is gated by the receipts for the CHANGELOG's version and publishes under a version nobody reviewed. abcd's own release has the archive-pin step, which names the tag and probably refuses first; a scaffolded repository has no such step. The fix shape is to pass TAG into record-lint --derive-content-sha on the tag path and require it to equal the bound version, which changes the pinned verify step in release.yml and its template, and needs a ruling on how the local launch receipts check, which has no tag, stays equal to the release job's verdict. From 3f613d3a4d39b7f83dcad31510ada0d83d37d2d1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:23:48 +0100 Subject: [PATCH 20/27] fix(release): bind the pushed tag to the released CHANGELOG version A hand-pushed tag naming a version other than the released tree's newest dated CHANGELOG heading (v0.1.1, v0.2.0 or v0.1.0-rc.1 on a 0.1.0 tree) passed a scaffolded semantic profile's verify job under the receipts of the CHANGELOG's version, and published under a version nobody reviewed. record-lint gains --released-version, a standalone mode that prints the version the released tree (HEAD) names, read by the strict reader the receipt derivation binds with (lint.ReleasedVersion over changelog.ReleasedVersionIn), and fails closed with no output where the derivation does. The template's verify job gains a step, in the semantic block, before the receipts gate, guarded like the archive step (github.event_name != 'workflow_dispatch'): it refuses unless TAG equals "v" plus that version. The comparison is string equality in the workflow, which holds the tag; no heading is read in bash, so no second reader exists to drift (anchor.go). The derivation and the tag-less launch receipts are untouched (AC7). Chosen over --derive-content-sha --expect-tag: the assertion then rides the receipts step, which is also dormant on a private repository, and couples the tag check to a derivation it does not need. abcd's own profile was already closed by launch archive --tag (cli/archive.go), which refuses a tag that is not the newest dated version before the receipts step. The step rides into abcd's own release.yml for free through template parity. TestScaffoldedGateRefusesATagNamingAnotherVersion drives the rendered workflow on a tag push through the fake forge: each mismatched tag fails verify at the binding with the receipts gate skipped and nothing published, and v0.1.0 on the same commit publishes. Refs: iss-2609251945586202 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/release-gate/README.md | 8 +- .github/workflows/release.yml | 24 ++++ cmd/record-lint/main.go | 21 ++++ .../launch/scaffold/mergepath_release_test.go | 115 +++++++++++++++--- .../scaffold/templates/release.yml.tmpl | 24 ++++ .../launch/scaffold/templates/runbook.md.tmpl | 5 +- internal/core/lint/releasegate_derive.go | 10 ++ internal/core/lint/releasegate_derive_test.go | 44 +++++++ 8 files changed, 234 insertions(+), 17 deletions(-) diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index 6096b65c5..5611b81f7 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -215,7 +215,13 @@ semantic refusal blocks the release without the version-consuming wedge of a gat that sat in the publish path (iss-2608231226347380). `verify` supplies the content commit and the required-gate list from the workflow (the trust root), not the in-tree config: `record-lint --release-gate --require-gate …`, -where `` is `record-lint --derive-content-sha`. The rule is skipped on the +where `` is `record-lint --derive-content-sha`. Before it, on a tag push, +`verify` requires the tag to be `v` plus the version `record-lint +--released-version` reads from the released tree — the strict reader the +receipts are bound with — so a hand-pushed tag naming another version cannot +publish under the receipts of the CHANGELOG's version (iss-2609251945586202). +On abcd's own profile `launch archive --tag` refuses such a tag earlier still. +The rule is skipped on the rehearsal path (`workflow_dispatch`), where no real receipts exist. Once `verify` has admitted the release, `release.yml`'s publish job signs the receipts with `actions/attest` (predicate `.../semantic-release-gate/v1`) and verifies the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fcd0760a..56f31a883 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,6 +171,30 @@ jobs: mkdir -p "$out" go run ./cmd/abcd launch archive --out "$out" --tag "${TAG}" --verify --repository "${GITHUB_REPOSITORY}" + # --- The release tag names the released version (iss-2609251945586202) --- + # The receipts gate below binds the receipts to the version the released + # tree's CHANGELOG names, not to the tag. A hand-pushed tag naming another + # version (v0.9.1 on a tree whose newest dated heading is 0.9.0, or a + # v0.9.0-rc.1) would otherwise publish under a version nobody reviewed, + # admitted by the receipts of the CHANGELOG's. So the tag must be exactly + # "v" plus that version, read by record-lint's strict reader, the one the + # receipts are bound with; this step compares the two and reads no heading + # itself. It refuses before the receipts gate and before the tag job, so + # nothing is built, tagged or published. Skipped on the rehearsal path, + # where TAG is the branch name. TAG reaches the script through the + # workflow env (injection-safe). + - name: The release tag names the released CHANGELOG version (fail-closed) + if: github.event_name != 'workflow_dispatch' + run: | + set -euo pipefail + released="$(go run ./cmd/record-lint --released-version)" + test -n "$released" # never compare against an empty version (fail closed) + if [ "$TAG" != "v$released" ]; then + printf "refusing to release '%s': the released tree's CHANGELOG names %s, so its release tag is v%s\n" "$TAG" "$released" "$released" >&2 + exit 1 + fi + echo "release tag $TAG names the released version $released" + # --- Semantic release gate, armed BEFORE the tag (adr-52, Alternative 1) --- # The semantic gate (the LLM passes CI cannot run) refuses HERE, in verify, # alongside the deterministic gates — not in the `release` publish job below. diff --git a/cmd/record-lint/main.go b/cmd/record-lint/main.go index b6474c271..aa0dc10d1 100644 --- a/cmd/record-lint/main.go +++ b/cmd/record-lint/main.go @@ -22,6 +22,7 @@ func main() { rootPath := flag.String("root", "", "repo root to lint (default: git toplevel, or cwd)") releaseGate := flag.String("release-gate", "", "arm the receipt_gate rule for a release: fail closed unless a PROMOTE semantic-pass receipt exists for this commit sha (release-time only; a CI workflow supplies the sha)") deriveContentSha := flag.Bool("derive-content-sha", false, "print the reviewed content commit the release's semantic gate must arm against, derived from the receipts directory of the released tree (iss-355: HEAD-ancestry misresolves under a batched merge queue); fails closed with no output on a wrong or absent receipts directory") + releasedVersion := flag.Bool("released-version", false, "print the release version the released tree (HEAD) names, read strictly from its CHANGELOG.md's newest release heading by the reader --derive-content-sha binds the receipts with; the release workflow compares it with the pushed tag (iss-2609251945586202); fails closed with no output on a missing CHANGELOG.md, no dated release, or an unreadable newest heading") var requireGates multiFlag flag.Var(&requireGates, "require-gate", "a required semantic gate name for --release-gate (repeatable); overrides the config list so the workflow, not the in-tree file, is the trust root") agentDiff := flag.String("agent-diff", "", "a git revision or revision range (e.g. origin/main...HEAD); arms agent_contract's per-agent changelog sub-check over that diff, which is otherwise a no-op because it asks whether a CHANGE announced itself") @@ -39,6 +40,26 @@ func main() { // batched merge-queue push cannot make it resolve an unrelated PR's commit // (iss-355). It fails closed: any error prints to stderr and exits non-zero // with nothing on stdout, so the caller never arms the gate with a guess. + // --released-version is the same kind of standalone mode: it prints the + // version the released tree names and exits. The tag comparison stays in the + // workflow, which holds the tag; the CHANGELOG reading stays here, in the one + // strict reader, so no second reader of the heading exists to drift from the + // one the receipts are bound with (changelog.datedHeadingRe). + if *releasedVersion { + released, err := gitutil.Run(root, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + fmt.Fprintln(os.Stderr, "record-lint: resolve HEAD:", scrubPaths(err, root)) + os.Exit(2) + } + v, err := lint.ReleasedVersion(root, released) + if err != nil { + fmt.Fprintln(os.Stderr, "record-lint:", scrubPaths(err, root)) + os.Exit(2) + } + fmt.Println(v) + return + } + if *deriveContentSha { released, err := gitutil.Run(root, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { diff --git a/internal/core/launch/scaffold/mergepath_release_test.go b/internal/core/launch/scaffold/mergepath_release_test.go index d81d8e5c0..c6fd2ed95 100644 --- a/internal/core/launch/scaffold/mergepath_release_test.go +++ b/internal/core/launch/scaffold/mergepath_release_test.go @@ -67,21 +67,7 @@ func cutFirstRelease(t *testing.T, semantic bool) { r.Write(".github/workflows/ci.yml", "name: ci\non: [pull_request]\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: go build ./...\n") if semantic { - // The semantic profile, rendered through the same templates the - // scaffold uses, with one required detector. - subs := BareSubstitutions("main") - subs.CIChecks = DeriveCIChecks(r.Root()) - subs.SemanticGates = []string{"docs-currency-reviewer"} - rendered, err := Render(subs) - if err != nil { - t.Fatal(err) - } - r.Write(ReleaseYMLPath, string(rendered.ReleaseYML)) - r.Write(AutoReleaseYMLPath, string(rendered.AutoReleaseYML)) - r.Write(RunbookPath, string(rendered.Runbook)) - r.Write(CheckReviewsPath, string(rendered.CheckReviews)) - r.Write(".abcd/record-lint.json", `{"rules":{"receipt_gate":{"enabled":false,"severity":"blocker","receipts_dir":".abcd/work/reviews","required_gates":[]}}}`+"\n") - f.recordLint = buildRecordLint(t, goEnv) + adoptSemanticProfile(t, f, goEnv) } else { rep, err := Scaffold(Request{RepoRoot: r.Root()}) if err != nil { @@ -161,6 +147,105 @@ func cutFirstRelease(t *testing.T, semantic bool) { } } +// adoptSemanticProfile writes the semantic profile into the forge's working +// repository, rendered through the same templates the scaffold uses, with one +// required detector, and builds the record-lint its receipt gate runs. +func adoptSemanticProfile(t *testing.T, f *fakeForge, goEnv []string) { + t.Helper() + r := f.work + subs := BareSubstitutions("main") + subs.CIChecks = DeriveCIChecks(r.Root()) + subs.SemanticGates = []string{"docs-currency-reviewer"} + rendered, err := Render(subs) + if err != nil { + t.Fatal(err) + } + r.Write(ReleaseYMLPath, string(rendered.ReleaseYML)) + r.Write(AutoReleaseYMLPath, string(rendered.AutoReleaseYML)) + r.Write(RunbookPath, string(rendered.Runbook)) + r.Write(CheckReviewsPath, string(rendered.CheckReviews)) + r.Write(".abcd/record-lint.json", `{"rules":{"receipt_gate":{"enabled":false,"severity":"blocker","receipts_dir":".abcd/work/reviews","required_gates":[]}}}`+"\n") + f.recordLint = buildRecordLint(t, goEnv) +} + +// TestScaffoldedGateRefusesATagNamingAnotherVersion is iss-2609251945586202 on a +// scaffolded profile: a hand-pushed tag naming a version other than the released +// tree's newest dated CHANGELOG heading must not publish under the receipts of +// the CHANGELOG's version. The rendered release.yml runs on the tag push; verify +// refuses at the tag binding, BEFORE the receipts gate, and nothing publishes. +// The tag the tree names publishes from the same commit, so the refusal is the +// binding and not a broken fixture. +func TestScaffoldedGateRefusesATagNamingAnotherVersion(t *testing.T) { + if testing.Short() { + t.Skip("drives the release workflows end to end") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is required to run the workflow scripts") + } + goEnv := hostGoEnv(t) + f := newFakeForge(t, goEnv) + r := f.work + r.Write("go.mod", "module example.com/fixture\n\ngo "+strings.TrimPrefix(runtime.Version(), "go")+"\n") + r.Write("main.go", "package main\n\nfunc main() {}\n") + r.Write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n") + r.Write(".github/workflows/ci.yml", "name: ci\non: [pull_request]\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: go build ./...\n") + adoptSemanticProfile(t, f, goEnv) + r.Commit("adopt the scaffolded release gate") + + // The 0.1.0 roll and its PROMOTE receipts, on main. + r.Write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n## [0.1.0] - 2026-09-25\n\n### Added\n\n- the first release.\n") + r.Commit("release: roll the changelog to 0.1.0") + content := r.Git("rev-parse", "HEAD") + r.Write(".abcd/work/reviews/"+content+"/docs-currency-reviewer.json", + `{"subject":{"digest":{"gitCommit":"`+content+`"}},"verificationResult":"PROMOTE",`+ + `"policy":{"detector":"docs-currency-reviewer"},"judgeModel":"claude-opus-4-8"}`+"\n") + r.Commit("release: receipts for the roll") + r.Git("push", "-q", "origin", "main") + released := r.Git("rev-parse", "HEAD") + + const ( + bindStep = "The release tag names the released CHANGELOG version (fail-closed)" + receiptsStep = "Semantic-gate receipts (fail-closed, before tag)" + ) + for _, tag := range []string{"v0.1.1", "v0.2.0", "v0.1.0-rc.1"} { + r.Git("tag", "-a", tag, "-m", "hand-pushed "+tag, released) + r.Git("push", "-q", "origin", "refs/tags/"+tag) + f.log.Reset() + runs := f.run(ReleaseYMLPath, event{name: "push", sha: released, refName: tag}) + if got := runs["verify"].result; got != "failure" { + t.Fatalf("tag %s on a 0.1.0 tree: verify = %s, want failure\n%s", tag, got, f.log.String()) + } + if got := runs["release"].result; got != "skipped" { + t.Errorf("tag %s: release = %s, want skipped", tag, got) + } + log := f.log.String() + if !strings.Contains(log, fmt.Sprintf("%q: ok=false", bindStep)) { + t.Errorf("tag %s: the tag binding step did not refuse\n%s", tag, log) + } + if !strings.Contains(log, fmt.Sprintf("%q: skipped", receiptsStep)) { + t.Errorf("tag %s: the receipts gate ran; the binding must refuse before it\n%s", tag, log) + } + if !strings.Contains(log, "refusing to release '"+tag+"': the released tree's CHANGELOG names 0.1.0") { + t.Errorf("tag %s: the refusal does not name the released version 0.1.0\n%s", tag, log) + } + } + if rel := f.releases(); len(rel) != 0 { + t.Fatalf("a mismatched tag published %v", rel) + } + + // The tag the released tree names passes the binding and publishes. + r.Git("tag", "-a", "v0.1.0", "-m", "v0.1.0", released) + r.Git("push", "-q", "origin", "refs/tags/v0.1.0") + f.log.Reset() + runs := f.run(ReleaseYMLPath, event{name: "push", sha: released, refName: "v0.1.0"}) + if runs["verify"].result != "success" || runs["release"].result != "success" { + t.Fatalf("v0.1.0 on a 0.1.0 tree: verify=%s release=%s\n%s", runs["verify"].result, runs["release"].result, f.log.String()) + } + if got := f.releases()["v0.1.0"]; got != released { + t.Errorf("published releases %v: want v0.1.0 from %s", f.releases(), released) + } +} + // --------------------------------------------------------------------------- // The fake forge and the workflow runner. diff --git a/internal/core/launch/scaffold/templates/release.yml.tmpl b/internal/core/launch/scaffold/templates/release.yml.tmpl index 47b627231..28950fb48 100644 --- a/internal/core/launch/scaffold/templates/release.yml.tmpl +++ b/internal/core/launch/scaffold/templates/release.yml.tmpl @@ -232,6 +232,30 @@ jobs: <%- end %> <%- if .SemanticGates %> + # --- The release tag names the released version (iss-2609251945586202) --- + # The receipts gate below binds the receipts to the version the released + # tree's CHANGELOG names, not to the tag. A hand-pushed tag naming another + # version (v0.9.1 on a tree whose newest dated heading is 0.9.0, or a + # v0.9.0-rc.1) would otherwise publish under a version nobody reviewed, + # admitted by the receipts of the CHANGELOG's. So the tag must be exactly + # "v" plus that version, read by record-lint's strict reader, the one the + # receipts are bound with; this step compares the two and reads no heading + # itself. It refuses before the receipts gate and before the tag job, so + # nothing is built, tagged or published. Skipped on the rehearsal path, + # where TAG is the branch name. TAG reaches the script through the + # workflow env (injection-safe). + - name: The release tag names the released CHANGELOG version (fail-closed) + if: github.event_name != 'workflow_dispatch' + run: | + set -euo pipefail + released="$(go run ./cmd/record-lint --released-version)" + test -n "$released" # never compare against an empty version (fail closed) + if [ "$TAG" != "v$released" ]; then + printf "refusing to release '%s': the released tree's CHANGELOG names %s, so its release tag is v%s\n" "$TAG" "$released" "$released" >&2 + exit 1 + fi + echo "release tag $TAG names the released version $released" + # --- Semantic release gate, armed BEFORE the tag (adr-52, Alternative 1) --- # The semantic gate (the LLM passes CI cannot run) refuses HERE, in verify, # alongside the deterministic gates — not in the `release` publish job below. diff --git a/internal/core/launch/scaffold/templates/runbook.md.tmpl b/internal/core/launch/scaffold/templates/runbook.md.tmpl index 06df87944..148d6e8e8 100644 --- a/internal/core/launch/scaffold/templates/runbook.md.tmpl +++ b/internal/core/launch/scaffold/templates/runbook.md.tmpl @@ -108,7 +108,10 @@ blocks the release, an earlier release's receipts never stand in for this one's, and a batch-mate's receipts never shadow them. The released tree's newest release heading must be a dated `## [X.Y.Z] - ` heading; a pre-release or undated head refuses, because there is no version to bind the -receipts to. +receipts to. On a tag push, `verify` first requires the tag to be `v` plus that +same version, read by the same reader (`record-lint --released-version`), so a +hand-pushed tag naming another version refuses before the receipt gate runs and +cannot publish under the receipts of the CHANGELOG's version. Receipts live at `.abcd/work/reviews//.json`. A receipt is bound to its gate by its `policy.detector` value, not its filename, so one diff --git a/internal/core/lint/releasegate_derive.go b/internal/core/lint/releasegate_derive.go index f75935a5d..dd1e588c3 100644 --- a/internal/core/lint/releasegate_derive.go +++ b/internal/core/lint/releasegate_derive.go @@ -177,6 +177,16 @@ func receiptDirNames(root, released string) ([]string, error) { return names, nil } +// ReleasedVersion is the version the released tree at rev names, read by the +// same strict reader the derivation binds the receipts with. The release gate +// compares it with the pushed tag (iss-2609251945586202), so the tag and the +// receipts are bound to one reading of the tree and never to two. It refuses +// exactly where the derivation does: no CHANGELOG.md, no dated release, or a +// newest release heading the reader cannot parse. +func ReleasedVersion(root, rev string) (string, error) { + return releasedVersionAt(root, rev) +} + // releasedVersionAt reads the version the released tree names, strictly: // the tree must carry a CHANGELOG.md whose newest release heading is a dated // vX.Y.Z heading (changelog.ReleasedVersionIn). A missing file, no release diff --git a/internal/core/lint/releasegate_derive_test.go b/internal/core/lint/releasegate_derive_test.go index 7d6d100b9..882b34d99 100644 --- a/internal/core/lint/releasegate_derive_test.go +++ b/internal/core/lint/releasegate_derive_test.go @@ -348,3 +348,47 @@ func TestDeriveReleaseContentSha_IgnoresAnAbbreviatedReceiptsDir(t *testing.T) { t.Errorf("derived %s, want %s", got, content) } } + +// TestReleasedVersion_ReadsTheStrictHead is iss-2609251945586202: the release +// gate binds the pushed tag to the version the released tree names, through the +// same strict reader the derivation binds the receipts with, so the two can +// never disagree about which release a tree is. A v-prefixed head reads as its +// core; a pre-release head, an undated head and a missing CHANGELOG refuse. +func TestReleasedVersion_ReadsTheStrictHead(t *testing.T) { + for name, c := range map[string]struct { + changelog string // "" removes CHANGELOG.md + want string + refusal string + }{ + "dated head": {changelog: "## [Unreleased]\n\n## [0.2.0] - 2026-02-01\n\n## [0.1.0] - 2026-01-01\n", want: "0.2.0"}, + "v-prefixed head": {changelog: "## [Unreleased]\n\n## [v1.4.2] - 2026-02-01\n", want: "1.4.2"}, + "pre-release head": {changelog: "## [Unreleased]\n\n## [1.0.0-rc.1] - 2026-02-01\n\n## [0.9.0] - 2026-01-01\n", refusal: "1.0.0-rc.1"}, + "unreleased only": {changelog: "## [Unreleased]\n\n- work\n", refusal: "names no dated release"}, + "no CHANGELOG.md": {refusal: "carries no CHANGELOG.md"}, + } { + t.Run(name, func(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("README.md", "fixture\n") + if c.changelog != "" { + r.Write("CHANGELOG.md", c.changelog) + } + r.Commit("the released tree") + got, err := lint.ReleasedVersion(r.Root(), r.Git("rev-parse", "HEAD")) + if c.refusal != "" { + if err == nil { + t.Fatalf("read %q; must fail closed", got) + } + if !strings.Contains(err.Error(), c.refusal) || !strings.Contains(err.Error(), "fail-closed") { + t.Errorf("error = %q, want it to name %q and fail closed", err, c.refusal) + } + return + } + if err != nil { + t.Fatalf("read: %v", err) + } + if got != c.want { + t.Errorf("released version = %q, want %q", got, c.want) + } + }) + } +} From 857a2e8c8450d3abf513292aee64593b341e860e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:24:06 +0100 Subject: [PATCH 21/27] docs(launch): the scaffolded verify derives from the receipts directory commands/launch.md's scaffold section said the scaffolded release.yml arms its gate against HEAD^2^ on the merge path and HEAD^ on a direct tag. It derives the content commit from the receipts directory of the released tree, as the same page says in its Ship and Semantic receipts sections; the bullet says so, applies it to the semantic profile the receipt gate exists in, and names the tag binding verify makes first. The same stale claim sat in the bare profile's rehearsal step comment ("mirrors release.yml's resolve step exactly"): that profile arms no receipt gate, so release.yml has no resolve step to mirror. The comment says what the step proves. The runbook template and the release-gate README carry no such phrase. Assisted-by: Claude:claude-opus-5-5 --- commands/launch.md | 12 ++++++++---- .../core/launch/scaffold/templates/release.yml.tmpl | 8 +++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/commands/launch.md b/commands/launch.md index c6857e392..b615c8a22 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -738,10 +738,14 @@ already has the machinery). It **never publishes**. It writes four files, wired to the repo's own default branch and Go version and to the check names its own pull-request CI reports: -- `.github/workflows/release.yml` — verify → build → publish, the verify gate - armed against the reviewed **content** commit (`HEAD^2^` on the auto-release - merge path, `HEAD^` on a direct tag), so the first public release cannot hit the - receipt-vs-tag self-reference. +- `.github/workflows/release.yml` — verify → build → publish. With semantic + gates configured, `verify` arms the receipt gate against the reviewed + **content** commit it derives from the receipts directory of the released + tree, so the first public release cannot hit the receipt-vs-tag + self-reference, and on a tag push it first refuses a tag that is not `v` plus + the released tree's newest dated CHANGELOG version (`record-lint + --released-version`, the reader the receipts are bound with), so a hand-pushed + tag cannot publish under another version's receipts. - `.github/workflows/auto-release.yml` — newest dated CHANGELOG heading → tag that commit → call `release.yml`. `GITHUB_TOKEN`-only, no personal access token. - `.abcd/development/release-gate/README.md` — the adr-37 runbook, including the diff --git a/internal/core/launch/scaffold/templates/release.yml.tmpl b/internal/core/launch/scaffold/templates/release.yml.tmpl index 28950fb48..da93b38b7 100644 --- a/internal/core/launch/scaffold/templates/release.yml.tmpl +++ b/internal/core/launch/scaffold/templates/release.yml.tmpl @@ -851,9 +851,11 @@ jobs: echo "REHEARSAL_CONTENT_SHA=$content" >> "$GITHUB_ENV" - name: Resolve the reviewed content commit (release.yml's derivation) - # Mirrors release.yml's resolve step exactly: HEAD^2^ on the merge path, - # HEAD^ on a direct tag. Asserts the resolution lands on the simulated - # content commit and is an ancestor of the released commit. + # This profile arms no receipt gate, so release.yml derives no content + # commit. The step proves the two-commit release shape instead: HEAD^2^ + # of the merge (HEAD^ on a direct tag) lands on the simulated content + # commit, an ancestor of the released commit, which is the commit the + # receipts directory names once a semantic gate is configured. run: | set -euo pipefail released="$(git rev-parse --verify HEAD)" From 7f5ded9aa87871af329ef461c79d85884d90ccf4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:24:29 +0100 Subject: [PATCH 22/27] =?UTF-8?q?chore:=20resolve=20iss-2609251945586202?= =?UTF-8?q?=20=E2=80=94=20the=20pushed=20tag=20binds=20to=20the=20released?= =?UTF-8?q?=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record carries its correction: the hole was confined to scaffolded profiles, since abcd's own verify job refuses a mismatched tag at launch archive --tag before the receipts step. Resolves: iss-2609251945586202 Assisted-by: Claude:claude-opus-5-5 --- ...er-binds-the-pushed-tag-to-the-released.md | 14 ---------- ...er-binds-the-pushed-tag-to-the-released.md | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) delete mode 100644 .abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md create mode 100644 .abcd/work/issues/resolved/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md diff --git a/.abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md b/.abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md deleted file mode 100644 index 637f17b0e..000000000 --- a/.abcd/work/issues/open/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -schema_version: 1 -id: "iss-2609251945586202" -slug: "the-release-gate-never-binds-the-pushed-tag-to-the-released" -severity: "minor" -category: "security" -source: "agent-finding" -found_during: "autonomous run A resumed 2026-09-25" -origin: researcher-authored -production_mode: hand-written -found_at: "internal/core/lint/releasegate_derive.go" ---- - -The release gate never binds the pushed tag to the released tree's CHANGELOG version. The content-commit derivation reads the version from the CHANGELOG only, and the release workflow's tag-shape check admits any vX.Y.Z tag, so a hand-pushed tag naming a version other than the tree's newest dated heading (for example v0.9.1 on a tree whose head is 0.9.0) is gated by the receipts for the CHANGELOG's version and publishes under a version nobody reviewed. abcd's own release has the archive-pin step, which names the tag and probably refuses first; a scaffolded repository has no such step. The fix shape is to pass TAG into record-lint --derive-content-sha on the tag path and require it to equal the bound version, which changes the pinned verify step in release.yml and its template, and needs a ruling on how the local launch receipts check, which has no tag, stays equal to the release job's verdict. diff --git a/.abcd/work/issues/resolved/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md b/.abcd/work/issues/resolved/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md new file mode 100644 index 000000000..8876e184f --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609251945586202-the-release-gate-never-binds-the-pushed-tag-to-the-released.md @@ -0,0 +1,26 @@ +--- +schema_version: 1 +id: "iss-2609251945586202" +slug: "the-release-gate-never-binds-the-pushed-tag-to-the-released" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/releasegate_derive.go" +resolution: "A scaffolded semantic profile's verify job refuses a pushed tag that is not v plus the released tree's newest dated CHANGELOG version, read by record-lint --released-version (the receipts' strict reader), before the receipts gate; abcd's own profile was already closed by launch archive --tag." +impact: fix +resolved_by: + commit: "3f613d3a4d39b7f83dcad31510ada0d83d37d2d1" +--- + +The release gate never binds the pushed tag to the released tree's CHANGELOG version. The content-commit derivation reads the version from the CHANGELOG only, and the release workflow's tag-shape check admits any vX.Y.Z tag, so a hand-pushed tag naming a version other than the tree's newest dated heading (for example v0.9.1 on a tree whose head is 0.9.0) is gated by the receipts for the CHANGELOG's version and publishes under a version nobody reviewed. abcd's own release has the archive-pin step, which names the tag and probably refuses first; a scaffolded repository has no such step. The fix shape is to pass TAG into record-lint --derive-content-sha on the tag path and require it to equal the bound version, which changes the pinned verify step in release.yml and its template, and needs a ruling on how the local launch receipts check, which has no tag, stays equal to the release job's verdict. + +## Correction + +(a) The hole was confined to scaffolded profiles. On abcd's own profile the verify job's plugin-archive step runs `launch archive --tag "$TAG"`, which refuses a tag that is not the version CHANGELOG.md dates newest (internal/surface/cli/archive.go, archiveRenderRequest) before the receipts step runs, so a mismatched tag never reached abcd's receipts gate; the "probably refuses first" above is confirmed. The scaffolded template renders that archive step only for abcd (`<% if .Abcd %>`), so a scaffolded repository had no such check. (b) The release job's tag-shape step is not the simpler place for the fix: it runs after verify has already passed the receipts gate and before Go is set up, so it could compare only through a second reader of the CHANGELOG heading in bash. The check lives in verify instead, before the receipts gate, through record-lint's strict reader; the tag-less `launch receipts` needs no ruling, because it and the derivation are unchanged. + +## Grounds + +- pursued: a hand-pushed tag naming another version (v0.1.1, v0.2.0, v0.1.0-rc.1 on a 0.1.0 tree) fails the rendered verify job at the binding with the receipts gate skipped and nothing published, while the tree's own tag publishes (TestScaffoldedGateRefusesATagNamingAnotherVersion); a scaffolded release published under a tag other than v plus the released tree's newest dated version would show it wrong From 47508ce1c93a8f7123f5f75aa7817a02d1e7215f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:24:48 +0100 Subject: [PATCH 23/27] chore: capture the derivation's shadowing by a post-roll receipts dir The reviewer's sharpening (only the roll, whose first parent carries a different version, is the content commit) would refuse a release branch whose receipts name a later CHANGELOG revision, so it waits on a ruling rather than landing in this fix round. Refs: iss-2609252024442310 Assisted-by: Claude:claude-opus-5-5 --- ...-s-content-commit-derivation-can-be-shadowed.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md diff --git a/.abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md b/.abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md new file mode 100644 index 000000000..62595968f --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252024442310" +slug: "the-release-gate-s-content-commit-derivation-can-be-shadowed" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/releasegate_derive.go" +--- + +The release gate's content-commit derivation can be shadowed by a pull request that lands between the roll merge and the tag. DeriveReleaseContentSha keeps the nearest receipts directory carrying the released version, so a PR branched after the roll merged (it carries the new version) with its own sha-keyed receipts directory, merged on top before the tag, wins: the gate then judges that commit's receipts, either a genuinely reviewed nearer commit or a fail-closed wedge that ignores the roll's real receipts (review2-gatewire probe S11). It is reachable only when a PR lands in that window (a failed auto-release re-run by hand) and admits nothing past the committed-receipts trust boundary. The reviewer's sharpening, admitting only the candidate whose first parent carries a DIFFERENT version (the roll itself), is not contained: it refuses a release branch whose receipts name a later CHANGELOG revision (roll, then a prose fix the reviewers read), whose first parent already carries the new version, which the derivation admits today and commands/launch.md does not forbid. It needs a ruling on whether the reviewed content commit must be the roll itself before the derivation narrows. From c4937b12ae49c92a0bc6b2b1abc5a3b54553d056 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:30:04 +0100 Subject: [PATCH 24/27] chore: capture the abcd runbook's glued deterministic-gates heading Refs: iss-2609252029191920 Assisted-by: Claude:claude-opus-5-5 --- ...-runbook-template-renders-the-abcd-profile-s.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md diff --git a/.abcd/work/issues/open/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md b/.abcd/work/issues/open/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md new file mode 100644 index 000000000..0e4c24584 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252029191920" +slug: "the-scaffold-s-runbook-template-renders-the-abcd-profile-s" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/launch/scaffold/templates/runbook.md.tmpl" +--- + +The scaffold's runbook template renders the abcd profile's 'Deterministic gates' heading glued onto the end of the rehearsal paragraph ('...first real release.## Deterministic gates (CI-enforced)'): the '<%- if not .Abcd %>' before the merge-gate and audit sections trims the blank line ahead of it, and the '<% end -%>' after them trims the newline behind, so when the block is skipped nothing separates the paragraph from the heading. The heading is then not a heading, and a gate_lockstep-style reader of the rendered runbook finds no deterministic-gate list at all. Found by a lockstep test over every rendered profile (TestRunbookGateListMatchesVerifySteps). From a55a86d58e0fd0bb552c7c00619b22a9ab7742cc Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:30:06 +0100 Subject: [PATCH 25/27] fix(scaffold): the runbooks list the tag-binding gate, and abcd's heading renders The tag-binding step added to verify is a deterministic gate, and gate_lockstep refused abcd's own release-gate README for not listing it. The README lists it as gate 11 and says what it guards; the runbook template lists it after the archive gate in every semantic profile. TestRunbookGateListMatchesVerifySteps holds the rendered runbook's numbered list equal to the verify job's gate steps in every profile. It also found that the abcd profile rendered no list at all: the trim markers around the non-abcd merge-gate and audit sections swallowed both newlines around the skipped block, gluing "## Deterministic gates" onto the end of the rehearsal paragraph. The opening marker now trims after itself instead of before, so the paragraph keeps its blank line in every profile. Refs: iss-2609252029191920 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/release-gate/README.md | 7 +++ .../core/launch/scaffold/scaffold_test.go | 51 +++++++++++++++++++ .../launch/scaffold/templates/runbook.md.tmpl | 9 +++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/.abcd/development/release-gate/README.md b/.abcd/development/release-gate/README.md index 5611b81f7..4c711097d 100644 --- a/.abcd/development/release-gate/README.md +++ b/.abcd/development/release-gate/README.md @@ -27,6 +27,13 @@ this list is the human-readable mirror. 8. Reviews-charter discipline (RD001-RD003) 9. Smoke every command (self-discovering harness) 10. Plugin archive reproduces the committed pin (fail-closed) +11. The release tag names the released CHANGELOG version (fail-closed) + +Gate 11 runs on a real release only too: it refuses unless the release tag is +`v` plus the version `record-lint --released-version` reads from the released +tree, the reader the semantic receipts are bound with. Gate 10 refuses such a +tag first on this repository's own release; gate 11 is the check a scaffolded +repository, which has no archive gate, relies on (iss-2609251945586202). Gate 10 runs on a real release only (a rehearsal has no release tag to bind). It re-renders the release's plugin archive from the tagged commit and refuses unless diff --git a/internal/core/launch/scaffold/scaffold_test.go b/internal/core/launch/scaffold/scaffold_test.go index c15a2be5a..454d298d6 100644 --- a/internal/core/launch/scaffold/scaffold_test.go +++ b/internal/core/launch/scaffold/scaffold_test.go @@ -4,7 +4,9 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" + "strconv" "strings" "testing" @@ -529,3 +531,52 @@ func TestClassifySymlinkLeafIsPathFree(t *testing.T) { t.Errorf("reason leaks the absolute path: %q", reason) } } + +// TestRunbookGateListMatchesVerifySteps is the gate_lockstep invariant over +// every profile the templates render: the runbook's numbered deterministic-gate +// list is exactly the verify job's steps, in order, less the setup steps and the +// semantic receipts step (which the runbook describes in its own section and +// abcd's gate_lockstep config ignores). A verify gate added to one side only, +// such as the tag binding (iss-2609251945586202), fails here in every profile, +// not only in abcd's own README. +func TestRunbookGateListMatchesVerifySteps(t *testing.T) { + semantic := BareSubstitutions("main") + semantic.SemanticGates = []string{"docs-currency-reviewer"} + notGates := map[string]bool{ + "Check out the pushed commit": true, + "Set up Go": true, + "Semantic-gate receipts (fail-closed, before tag)": true, + } + itemRe := regexp.MustCompile(`^(\d+)\. (.+)$`) + for name, subs := range map[string]Substitutions{ + "abcd": AbcdSubstitutions(), "bare": BareSubstitutions("main"), "bare+semantic": semantic, + } { + rendered, err := Render(subs) + if err != nil { + t.Fatal(err) + } + var steps []string + for _, line := range strings.Split(jobSection(t, string(rendered.ReleaseYML), "verify"), "\n") { + if s, ok := strings.CutPrefix(strings.TrimSpace(line), "- name: "); ok && !notGates[s] { + steps = append(steps, s) + } + } + var listed []string + in := false + for _, line := range strings.Split(string(rendered.Runbook), "\n") { + if strings.HasPrefix(line, "#") { + in = strings.Contains(strings.ToLower(line), "deterministic gate") + continue + } + if m := itemRe.FindStringSubmatch(line); in && m != nil { + if m[1] != strconv.Itoa(len(listed)+1) { + t.Errorf("%s: runbook item %q is numbered %s, want %d", name, m[2], m[1], len(listed)+1) + } + listed = append(listed, m[2]) + } + } + if strings.Join(listed, "\n") != strings.Join(steps, "\n") { + t.Errorf("%s: runbook deterministic gates\n %q\nare not the verify job's gate steps\n %q", name, listed, steps) + } + } +} diff --git a/internal/core/launch/scaffold/templates/runbook.md.tmpl b/internal/core/launch/scaffold/templates/runbook.md.tmpl index 148d6e8e8..73d72aa4b 100644 --- a/internal/core/launch/scaffold/templates/runbook.md.tmpl +++ b/internal/core/launch/scaffold/templates/runbook.md.tmpl @@ -26,7 +26,7 @@ precondition for trusting the gate with a real tag: it proves the gate can be satisfied before the repo goes public, closing the private→public activation gap that otherwise surfaces only at the first real release. -<%- if not .Abcd %> +<% if not .Abcd -%> ## Merge gate — this repository's CI checks Merging the release pull request is the release decision, so the checks that @@ -80,8 +80,13 @@ The `release.yml` `verify` job runs these, in order, on the released commit. <%- end %> <%- if .Abcd %> <% add (len .ExtraGates) 6 %>. Plugin archive reproduces the committed pin (fail-closed) +<%- end %> +<%- if .SemanticGates %> +<% if .Abcd %><% add (len .ExtraGates) 7 %><% else %><% add (len .ExtraGates) 6 %><% end %>. The release tag names the released CHANGELOG version (fail-closed) +<%- end %> +<%- if .Abcd %> -The last gate also checks that the pinned address is this repository's own +The plugin-archive gate also checks that the pinned address is this repository's own release. On the `auto-release.yml` path every gate here runs before the tag — `release.yml`'s `tag` job needs `verify` — so a refusal tags nothing, and the next push to `<% .DefaultBranch %>` retries. A hand-pushed tag exists before From b411fafe340b9159f9530690b2576b9f3b164fc8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:30:13 +0100 Subject: [PATCH 26/27] =?UTF-8?q?chore:=20resolve=20iss-2609252029191920?= =?UTF-8?q?=20=E2=80=94=20the=20abcd=20runbook=20heading=20renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252029191920 Assisted-by: Claude:claude-opus-5-5 --- ...ffold-s-runbook-template-renders-the-abcd-profile-s.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md (65%) diff --git a/.abcd/work/issues/open/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md b/.abcd/work/issues/resolved/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md similarity index 65% rename from .abcd/work/issues/open/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md rename to .abcd/work/issues/resolved/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md index 0e4c24584..f80ae914a 100644 --- a/.abcd/work/issues/open/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md +++ b/.abcd/work/issues/resolved/iss-2609252029191920-the-scaffold-s-runbook-template-renders-the-abcd-profile-s.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/launch/scaffold/templates/runbook.md.tmpl" +resolution: "The runbook template's opening trim marker trims after itself, so the abcd profile's Deterministic gates heading renders on its own line after a blank line." +impact: internal +resolved_by: + commit: "a55a86d58e0fd0bb552c7c00619b22a9ab7742cc" --- The scaffold's runbook template renders the abcd profile's 'Deterministic gates' heading glued onto the end of the rehearsal paragraph ('...first real release.## Deterministic gates (CI-enforced)'): the '<%- if not .Abcd %>' before the merge-gate and audit sections trims the blank line ahead of it, and the '<% end -%>' after them trims the newline behind, so when the block is skipped nothing separates the paragraph from the heading. The heading is then not a heading, and a gate_lockstep-style reader of the rendered runbook finds no deterministic-gate list at all. Found by a lockstep test over every rendered profile (TestRunbookGateListMatchesVerifySteps). + +## Grounds + +- pursued: every rendered profile's runbook parses a numbered deterministic-gate list equal to its verify job's gate steps (TestRunbookGateListMatchesVerifySteps); a rendered runbook whose heading shares a line with the paragraph before it would show it wrong From 5d55740aec30607e79a598120a26e781efab9e1f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:47:43 +0100 Subject: [PATCH 27/27] chore: defer the receipts-shadowing sharpening out loud pending a protocol ruling Refs: iss-2609252024442310 Assisted-by: Claude:claude-opus-5-5 --- ...-release-gate-s-content-commit-derivation-can-be-shadowed.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md b/.abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md index 62595968f..53c6d1477 100644 --- a/.abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md +++ b/.abcd/work/issues/open/iss-2609252024442310-the-release-gate-s-content-commit-derivation-can-be-shadowed.md @@ -9,6 +9,8 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/releasegate_derive.go" +deferred_after: "v0.10.0" +deferral_reason: "needs a product-thinker ruling on the receipts protocol: must the reviewed content commit always be the CHANGELOG roll itself, or may receipts name a later revision the reviewers read (a roll, then a prose fix)? The derivation admits the second shape today and the docs do not forbid it; the sharpening that would stop a post-roll receipts directory shadowing the roll depends on the answer. Ruling S in autonomous run A's rulings-owed list, 2026-09-25" --- The release gate's content-commit derivation can be shadowed by a pull request that lands between the roll merge and the tag. DeriveReleaseContentSha keeps the nearest receipts directory carrying the released version, so a PR branched after the roll merged (it carries the new version) with its own sha-keyed receipts directory, merged on top before the tag, wins: the gate then judges that commit's receipts, either a genuinely reviewed nearer commit or a fail-closed wedge that ignores the roll's real receipts (review2-gatewire probe S11). It is reachable only when a PR lands in that window (a failed auto-release re-run by hand) and admits nothing past the committed-receipts trust boundary. The reviewer's sharpening, admitting only the candidate whose first parent carries a DIFFERENT version (the roll itself), is not contained: it refuses a release branch whose receipts name a later CHANGELOG revision (roll, then a prose fix the reviewers read), whose first parent already carries the new version, which the derivation admits today and commands/launch.md does not forbid. It needs a ruling on whether the reviewed content commit must be the roll itself before the derivation narrows.