diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 713b756..d1ad232 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -67,6 +67,15 @@ jobs: run: go install github.com/go-task/task/v3/cmd/task@latest - name: PCS compare exit gate (RELSE-03 — REQ-PCS-S09) run: bash hack/compare/exitgate_test.sh + # AUD-S02 (REQ-AUD-S02-02) — CHANGELOG.md drift gate, main + schedule only. + # NOT on pull_request (D-125): there actions/checkout builds refs/pull/N/merge, a + # merge commit minted at CI time whose subject ("Merge into ") git-cliff + # renders through the catch-all commit parser. That line cannot exist in any + # committed CHANGELOG.md, so a PR-scoped step would be red by construction and + # unfixable by the author. `task check` runs this gate on every local commit. + - name: changelog drift gate (AUD-S02 — REQ-AUD-S02-02) + if: github.event_name != 'pull_request' + run: task changelog-verify # RELSE-03: release exit gate includes goreleaser snapshot + docs-build — main push and # weekly schedule only; PRs run compare-exitgate above without this job. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f99687..72b3287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,114 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Release notes are generated from gitmoji-conventional commits on the default branch using [git-cliff](https://git-cliff.org/). +## Compatibility notes + +Long-lived notes for consumers of released artifacts. They live in `cliff.toml`'s changelog +header because `CHANGELOG.md` is regenerated in full from commit history and a hand-edit here +would be silently overwritten by the next `task changelog-write`. + +- **`pins.toolDigest` changes value after `v0.1.0` (D-120).** DecisionRecords emitted by +`v0.1.0` and earlier pin `toolDigest` as sha256 over the *tool version string*, so every +build stamped with the same version shared one digest; records carrying that derivation are +identifiable by exactly it. Builds after D-120 derive `toolDigest` from the binary's +canonical Go build info instead (main module path/version/sum, dependency checksums, VCS +revision and dirty flag), falling back to `sha256("buildinfo-unavailable\n" + toolVersion)` +when that build info is absent or does not identify the main module's content (no module sum +and no VCS revision — `go build -buildvcs=false`, test binaries). The field, its type and the +frozen `v1alpha1` schema are +unchanged — records published with `v0.1.0` remain schema-valid — but the *value* is not +comparable across the boundary: a mismatch between a pre-D-120 and a post-D-120 record means +"derived differently", not "different build". + ## Unreleased +### Chores +- :wrench: chore(changelog): render version headings in Keep-a-Changelog bracket form + +### Documentation +- :memo: docs(decisions): close D-111 E9 exit gate after v0.1.0 +- :memo: docs(release): Homebrew tap operator runbook and honest install +- :memo: docs(install): document live Homebrew tap install +- :memo: docs(decisions): record D-111 Homebrew Formula published +- :memo: docs(openspec): Formula live; residual is PAT rotate +- :memo: docs(adr): ADR-0020 forge snapshot changed-file completeness (REL-07 P1) +- :memo: docs(decisions): D-119..D-123 audit-remediation designs +- :memo: docs(openspec): P5-AUD audit-remediation epic — 18 stories, 3 release conditions +- :memo: docs(adr): drop AI-provenance marker + review polish (D-019) +- :memo: docs(openspec): review polish — S01 lane coordination, SEC-08 alias, RELSE-08 residual +- :memo: docs(conformance): point each AUD-S01 catalog row at the test that proves it +- :memo: docs(adr): ADR-0011 Amendment 3 -- boundary enforcement mechanism made true (D-123) +- :memo: docs(release): note the jq dependency and fix the release-tooling list numbering +- :memo: docs(usage): CLI reference pinned to the binary's help output (REQ-AUD-S05-02) +- :memo: docs(usage): cover the two compare exit codes outside the gate contract (REQ-AUD-S05-02) +- :memo: docs(schemas): publish the D-120 toolDigest description (annotation only) +- :memo: docs(cli): correct the `assent run` step order to emit-before-reconcile (D-122) +- :memo: docs(install): `go install` binaries report 0.0.0-dev, not a stamped version (DOC-11) +- :memo: docs(contract): fileEvents ships add/delete — retire the "not yet implemented" note (DOC-06) +- :memo: docs(walkthrough): per-step Shipped/Planned banners replace the design-fiction header (DOC-09) +- :memo: docs(meta-plan): renumber the Phase-5 epic table to the epics that executed (DOC-10) +- :memo: docs(adr): ADR-0020 is Accepted — its contract shipped in AUD-S01 +- :memo: docs(examples): drop the pre-alpha banner and the "once it exists" harness caveat +- :memo: docs(usage): document the checkout-less enumeration contract on the -checkout flag +- :memo: docs(release): mandate patch tags over in-place asset replacement (SEC-07) +- :memo: docs(install): narrow the 0.0.0-dev consequence to the version string (D-120) +- :memo: docs(decisions): record D-124 — the AUD-S06 docs gates are unwired, Lane B owns the wiring +- :memo: docs(decisions): fold three unfixed residuals into D-124 +- :memo: docs(changelog): warn record consumers that pins.toolDigest changed value (D-120) +- :memo: docs(decisions): record D-125 — CHANGELOG drift gate placement and its cost +- :memo: docs(release): the changelog drift gate is in task check now, not outside it +- :memo: docs(changelog): name the second toolDigest fallback branch + +### Features +- :sparkles: feat(cli): dispatch-table help listing the real subcommands (REQ-AUD-S05-01) + +### Fixes +- :bug: fix(ci): install uv on release-exitgate for docs-build +- :bug: fix(release): do not skip=publish so Homebrew tap can push +- :bug: fix(release): use a POSIX class, not \t, in the gate-step if: guard +- :bug: fix(cli): show GITLAB_TOKEN in the run usage form (REQ-AUD-S05-01) +- :bug: docs(readme): pass the repo root to lint/test, and execute the quick-start (DOC-07) +- :bug: docs(readme): point the ADR-0014 link at the file that exists (DOC-05) +- :bug: fix(docs-gates): the scripts claimed a wiring that does not exist +- :bug: docs(examples): starter packs advertised a subcommand that does not exist + +### Other +- :construction_worker: ci(lint): depguard deny-rules for the D-123 pure tree (REQ-AUD-S07-01) +- :closed_lock_with_key: ci(release): gate the release job on verify green at the tag SHA +- :construction_worker: ci(release): enforce the verify-tag gate's own test in CI and document it +- :construction_worker: ci(release): run the verify-tag gate's test in task check +- :construction_worker: ci(schemadrift): fence the D-120 toolDigest annotation edit +- :construction_worker: ci(release): run the CHANGELOG drift gate in task check and on main CI +- :construction_worker: ci(docs): wire the AUD-S06 docs truth-lag gates into task check (D-124) +- :construction_worker: ci(lint): wire the depguard polarity proof into task check (AUD-S07) +- :construction_worker: ci(release): wire the changelog gate test itself into task check + +### Security +- :lock: fix(forge): prove changed-file enumeration completeness or declare a gap +- :lock: fix(run): degrade a checkout-less run to REVIEW when enumeration is incomplete +- :lock: fix(release): reject a tag whose only verify run is a pull_request run +- :lock: fix(run): derive pins.toolDigest from Go build info (D-120) +- :lock: fix(run): emit the DecisionRecord before forge reconcile (D-122) + +### Testing +- :white_check_mark: test(forge): model truncation and diff-endpoint failure in the fake +- :white_check_mark: test(cmd): serve the paginated diffs cassette in the run-path fakes +- :white_check_mark: test(conformance): require the changed-file-completeness cases +- :white_check_mark: test(core): extend the purity walk to evaldecode, compare and schemas (REQ-AUD-S07-02) +- :white_check_mark: test(lint): fail the depguard gate on an unmapped deny target (REQ-AUD-S07-01) +- :white_check_mark: test(release): scope the gate-step wiring assertions to the gate step +- :white_check_mark: test(release): make the all-runs-green rule discriminate, and pin the gate step armed +- :white_check_mark: test(release): bind the query string, drop the pipelines, and control the negatives +- :white_check_mark: test(cli): prove each dispatch name reaches its own handler (REQ-AUD-S05-01) +- :white_check_mark: test(cli): make the binding probe unsatisfiable by the usage listing (REQ-AUD-S05-01) +- :white_check_mark: test(cli): walk the whole cmd/ tree in the stale-claim pin (REQ-AUD-S05-01) +- :white_check_mark: test(schemadrift): derive the D-120 baseline anchor instead of pinning it +- :white_check_mark: test(run): pin the atomic --emit replace by target file mode (D-122) +- :white_check_mark: test(run): pin the emit-before-reconcile invariant on stdout too (D-122) +- :white_check_mark: test(docs): pin the retired truth-lag claims so they cannot come back (DOC-05/06/09/10/11) +- :white_check_mark: test(release): pin the CHANGELOG drift gate content, wiring and polarity +## [0.1.0] - 2026-08-05 + ### Chores - :tada: chore: scaffold repository — vision, ADRs, C4, meta-plan, specs skeleton, Go module, examples - build(deps): bump actions/setup-go from 5.6.0 to 7.0.0 diff --git a/Taskfile.yml b/Taskfile.yml index bdd0e3f..5fa4ae8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -76,10 +76,40 @@ tasks: - task: build - task: dogfood-comparison - task: compare-exitgate-test + # AUD-S02 (REQ-AUD-S02-02): CHANGELOG.md drift gate. It is the LOCAL half of the + # wiring — CI runs it only off pull_request (D-125), because on the PR merge ref + # the generated changelog always carries a synthetic merge subject. Consequence, + # stated plainly: a commit that changes history without regenerating CHANGELOG.md + # makes the NEXT `task check` red until `task changelog-write` is committed. + - task: changelog-verify + # AUD-S02: the drift gate's own guard, and the ONLY thing that reds when the + # `- task: changelog-verify` line above is deleted. Runs after changelog-verify + # because it probes the real CHANGELOG.md (append + trap restore) — a restore + # failure must not red the simpler gate for the wrong reason. + - task: release-changelog-gate-test # AUD-S03: the release gate's own guard. Its only other CI caller is release-exitgate, # which is skipped on pull_request — without this a regression merges green and only # reds main afterwards. Fast and offline (stubbed `gh`); needs jq. - task: release-verify-tag-gate-test + # D-124: the AUD-S06 docs truth-lag gates shipped green and invoked by nothing, + # which is the same manual-gate defect they exist to close. Sequential (not a + # `deps:` entry) — go-task runs deps in parallel and readme_smoke_test.sh builds + # a binary while `fmt` rewrites the tree. + - task: docs-gates + # AUD-S07 / D-123: `task lint` proves only the CLEAN depguard polarity. Without + # this, deleting `- depguard` from .golangci.yml passes every wired gate. + - task: lint-depguard-test + + lint-depguard-test: + desc: "AUD-S07 gate: adversarial proof the D-123 depguard boundary rules fire (REQ-AUD-S07-01)" + cmds: + - bash hack/lint/depguard_test.sh + + docs-gates: + desc: "D-124 docs truth-lag gates: README quick-start executes (REQ-AUD-S06-01) + 18 doc pins (REQ-AUD-S06-02)" + cmds: + - bash hack/docs/readme_smoke_test.sh + - bash hack/docs/truthlag_pins_test.sh compare-exitgate-test: desc: "PCS-S09 exit gate: full suite + E6 seed dir + schema drift guard (REQ-PCS-S09-01..03)" @@ -178,6 +208,11 @@ tasks: cmds: - bash hack/release/verify_test.sh + release-changelog-gate-test: + desc: "AUD-S02 gate: the CHANGELOG drift gate is wired and fires (REQ-AUD-S02-01/02)" + cmds: + - bash hack/release/changelog_gate_test.sh + release-verify-tag-gate-test: desc: "AUD-S03 gate: verify-green-on-tag-SHA polarity table + release.yaml step order (REQ-AUD-S03-01/02)" cmds: diff --git a/cliff.toml b/cliff.toml index e56cd36..f0d9093 100644 --- a/cliff.toml +++ b/cliff.toml @@ -12,9 +12,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Release notes are generated from gitmoji-conventional commits on the default branch using [git-cliff](https://git-cliff.org/). +## Compatibility notes + +Long-lived notes for consumers of released artifacts. They live in `cliff.toml`'s changelog +header because `CHANGELOG.md` is regenerated in full from commit history and a hand-edit here +would be silently overwritten by the next `task changelog-write`. + +- **`pins.toolDigest` changes value after `v0.1.0` (D-120).** DecisionRecords emitted by + `v0.1.0` and earlier pin `toolDigest` as sha256 over the *tool version string*, so every + build stamped with the same version shared one digest; records carrying that derivation are + identifiable by exactly it. Builds after D-120 derive `toolDigest` from the binary's + canonical Go build info instead (main module path/version/sum, dependency checksums, VCS + revision and dirty flag), falling back to `sha256("buildinfo-unavailable\\n" + toolVersion)` + when that build info is absent or does not identify the main module's content (no module sum + and no VCS revision — `go build -buildvcs=false`, test binaries). The field, its type and the + frozen `v1alpha1` schema are + unchanged — records published with `v0.1.0` remain schema-valid — but the *value* is not + comparable across the boundary: a mismatch between a pre-D-120 and a post-D-120 record means + "derived differently", not "different build". + """ body = """ -{% if version %}## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }}{% else %}## Unreleased{% endif %} +{% if version %}## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}{% else %}## Unreleased{% endif %} {% for group, commits in commits | group_by(attribute="group") %} ### {{ group }} {% for commit in commits %}- {{ commit.message | split(pat="\n") | first }} diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md index d89b4ae..94a44fa 100644 --- a/docs/decisions/decisions.md +++ b/docs/decisions/decisions.md @@ -129,3 +129,4 @@ project/process decisions. | D-122 | 2026-08-06 | **REL-08 — DecisionRecord emit precedes Reconcile; emit is atomic and fail-closed.** New invariant: NO forge write without a schema-valid, durably-emitted DecisionRecord. Order in `orchestrate`: build → marshal → schema-validate → EMIT (`--emit`: write `.tmp` same-dir + `os.Rename`; stdout: unchanged) → Reconcile → summary. Emit failure → hard error, zero forge writes. Pure reordering: `recordJSON` is fully determined pre-Reconcile (receipt lives in the summary line, not the record) — byte-identical records, marker digests and determinism gate unaffected. Rejected: post-reconcile record stamping (breaks record byte-stability vs marker `decision` digest). Revert: move emit back below Reconcile. | | D-123 | 2026-08-06 | **ARCH-01 — boundary enforcement automated (depguard + extended purity walk); ADR-0011 Amendment 3 truths the "arch-lint enforced" claim.** Two layers: (1) golangci `depguard` deny-rules — `internal/core/**`, `internal/change/**`, `internal/glob`, `internal/lint`, `internal/catalogue`, `internal/evaldecode`, `internal/compare`, `schemas/**` may import none of `internal/forge/**`, `internal/render/**`, `cmd/**`, `net/**`; (2) `TestCorePurity` walk extends to `../evaldecode`, `../compare`, and `../../schemas` (call-level: `time.Now`/`os.Getenv`/`os.Environ`/rand/net, adversarial self-test retained). Scope note: this EXTENDS the AGENTS.md rule-7 pure tree — `internal/evaldecode` (engine input decode) and `internal/compare` (D-116/D-117 gate determinism) join the determinism guard; `schemas` is embedded compile-time authority. Acceptance: a synthetic violating import/call fails CI both ways. Revert: drop depguard rules + walk dirs and re-amend ADR-0011. | | D-124 | 2026-08-07 | **AUD-S06 residual — the two docs truth-lag gates exist but are UNWIRED; wiring is assigned to Lane B.** `hack/docs/readme_smoke_test.sh` (REQ-AUD-S06-01, executes every README quick-start command) and `hack/docs/truthlag_pins_test.sh` (REQ-AUD-S06-02, 18 grep/diff pins over DOC-05/06/09/10/11 + the ADR status index) are green and mutation-proven, but nothing invokes them: `Taskfile.yml` and `.github/workflows/**` belong to **Lane B** (AUD-S02/S03/S09/S14), so AUD-S06 could not add them. **Consequence, stated plainly: until they are wired, a future README or docs edit reopens DOC-07 (a quick-start that exits 2) or any pinned claim with NOTHING going red** — the mechanism is a manual gate, which is the same class of defect AUD-S06 exists to close. Assignment: Lane B adds a `docs-gates` task (`bash hack/docs/readme_smoke_test.sh && bash hack/docs/truthlag_pins_test.sh`) as a `check:` dependency, following the `hack/compare/exitgate_test.sh` precedent (D-118), alongside its AUD-S09/S14 workflow work. **Extend when wiring** (known pin gaps, both accepted for now): (a) the DOC-09 walkthrough check asserts banner PRESENCE, not polarity — flipping a step's `Planned` to `Shipped` stays green; (b) the DOC-05 link-resolution loop is scoped to `README.md`, so the relative links in `examples/README.md` are unpinned; (c) AUD-S05's `TestNoStaleProductClaims` (`cmd/assent/main_help_test.go`, Lane A5's file) walks only `cmd/`, `internal/` and `docs/` — markdown under `hack/`, `.github/` and `test/` is grepped by no pin at all. **Known unfixed truth-lag, deliberately not corrected in AUD-S06 to keep the reviewed diff narrow — fix when next editing these files:** (i) `docs/planning/meta-plan.md` closes the Phase-5 epic table with "Ordering constraint: E7 starts early (alongside E1)", directly under the new heading asserting the table is the numbering that actually executed — E7 in fact landed after E6 (E6 tip `ec91226` is an ancestor of E7-S08 `f27457d`, both 2026-08-04); (ii) `docs/usage/install.md` credits a stamped version to "the Homebrew **bottle**", but `.goreleaser.yaml`'s `brews:` block publishes a **Formula** whose `url_template` points at the release archive — no bottle is built or hosted; the version claim is true, only the term is wrong. Revert: delete the two scripts and reopen DOC-05/06/07/09/10/11 as live findings. | +| D-125 | 2026-08-07 | **AUD-S02 judgment call (b) — the CHANGELOG drift gate runs in `task check` on every local commit and in CI on push-to-main + schedule, NOT on `pull_request`.** The spec asked for both placements; the PR placement is not merely noisy, it is red by construction. Evidence (reproduced locally, not reasoned): `hack/release/verify-changelog.sh` diffs the WHOLE generated changelog against the committed file, and `cliff.toml`'s parser list ends in a catch-all `{ message = ".*", group = "Other" }`, so merge commits are rendered — merging a probe branch put `- Merge 1234567 into 89abcde` in the generated output. On `pull_request`, `actions/checkout` checks out `refs/pull/N/merge`: a merge commit minted at CI time whose subject is exactly that shape. No committed `CHANGELOG.md` can contain a line naming a SHA that did not exist when it was written, so a PR-scoped step fails on every PR with no author fix (and would push commit SHAs into the changelog, contra **D-101**). Walk-back taken under the spec's decide-and-log sanction, using the `release-exitgate` precedent already in the file: `if: github.event_name != 'pull_request'`. **Consequence, stated plainly:** PR CI does not catch changelog drift. The `check:` entry does — one commit later, by construction: `task check` is green at HEAD, the next commit makes `CHANGELOG.md` stale, and the following `task check` is red until `task changelog-write` is committed. Push-to-main is the backstop, so a lane that lands without regenerating reds main until a regeneration commit follows. **Working rule:** a regeneration commit must be subject-prefixed `:memo: chore(release):` or `:wrench: chore(release):` — the two forms `cliff.toml` skips — or it creates fresh drift itself; and a lane must regenerate AFTER its last content commit and after any `git merge origin/main`. Two companion changes ship with it: version headings render Keep-a-Changelog style (`## [0.1.0] - 2026-08-05`, matching the hand-written `[0.0.0]` stub), and the **D-120** `pins.toolDigest` record-consumer warning lives in `cliff.toml`'s `[changelog] header` — CHANGELOG.md is regenerated in full, so a hand-edit there would be wiped by the next `changelog-write` with the drift gate still green. **Closes the D-124 assignment** in the same `check:` list: `docs-gates` (`hack/docs/readme_smoke_test.sh` + `truthlag_pins_test.sh`) and `lint-depguard-test` (`hack/lint/depguard_test.sh`) are now sequential `check:` commands — sequential, not `deps:`, because go-task runs deps in parallel and the smoke test builds a binary while `fmt` rewrites the tree. The depguard proof is local-only: CI lints via `golangci-lint-action`, which leaves no binary on PATH for a later step, and that gate refuses to skip when `golangci-lint` is absent. D-124's known pin gaps (a)/(b)/(c) and truth-lag items (i)/(ii) are NOT addressed here and remain open. Revert: drop the `check:` entries and the verify.yaml step — reopens RELSE-01 and D-124. | diff --git a/hack/docs/readme_smoke_test.sh b/hack/docs/readme_smoke_test.sh index f3dd01f..c13a174 100755 --- a/hack/docs/readme_smoke_test.sh +++ b/hack/docs/readme_smoke_test.sh @@ -10,15 +10,16 @@ # Two command families are DELIBERATELY skipped, loudly (never silently dropped): # go install … — needs the network and the module proxy; and what it produces is # pinned separately by the DOC-11 caveat pin in truthlag_pins_test.sh. -# task … — this script is INTENDED to run inside `task check` (D-124: wiring is -# Lane B's, not yet landed), so invoking `task check` from here would -# recurse the moment that wiring exists. +# task … — this script RUNS inside `task check` (via `task docs-gates`, D-124), +# so invoking `task check` from here would recurse without terminating. +# The skip is load-bearing, not a convenience: if this case ever stops +# matching, `task check` calls itself forever. # Every skip is printed with its reason and counted, so deleting the executed lines # cannot leave the script trivially green (see the "no assent command" fail below). # -# NOT YET WIRED INTO ANY GATE (D-124): `Taskfile.yml` is Lane B's file, so today this -# runs only when invoked by hand. Until Lane B adds it (and truthlag_pins_test.sh) to -# `task check`, a README edit can reopen DOC-07 with nothing going red. +# WIRED (D-124/D-125): `task docs-gates` runs this and truthlag_pins_test.sh, and +# `task check` runs `docs-gates`. A README edit that reopens DOC-07 reds the gate every +# developer runs before every commit. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -80,7 +81,7 @@ while IFS= read -r line; do continue ;; task*) - echo "SKIP $cmd (this script belongs inside \`task check\`; invoking it here would recurse once wired — D-124)" + echo "SKIP $cmd (this script runs inside \`task check\` via \`task docs-gates\`; invoking it here would recurse — D-124)" skipped=$((skipped + 1)) continue ;; diff --git a/hack/docs/truthlag_pins_test.sh b/hack/docs/truthlag_pins_test.sh index b6eed7e..32cc68b 100755 --- a/hack/docs/truthlag_pins_test.sh +++ b/hack/docs/truthlag_pins_test.sh @@ -20,9 +20,9 @@ # Every check prints PASS or FAIL and the script exits 1 if any failed, so a # regression names the finding it reopens. # -# NOT YET WIRED INTO ANY GATE (D-124): `Taskfile.yml` is Lane B's file, so today this -# runs only when invoked by hand. Until Lane B adds it (and readme_smoke_test.sh) to -# `task check`, a docs edit can reopen these findings with nothing going red. +# WIRED (D-124/D-125): `task docs-gates` runs this and readme_smoke_test.sh, and +# `task check` runs `docs-gates`. A docs edit that reopens one of these findings reds +# the gate every developer runs before every commit. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" diff --git a/hack/release/README.md b/hack/release/README.md index 3a828e0..e40987d 100644 --- a/hack/release/README.md +++ b/hack/release/README.md @@ -15,16 +15,30 @@ template emits **categorized subject lines only — no commit SHAs** (D-101, oss | `task changelog-write` | Regenerate `CHANGELOG.md` from tags + unreleased commits | | `task changelog-verify` | Fail closed if `CHANGELOG.md` drifts from `cliff.toml` output (release gate) | | `bash hack/release/verify-changelog.sh` | Same check as `task changelog-verify` (script entry point) | - -**Not in `task check`:** regenerating `CHANGELOG.md` is a separate commit (`task changelog-write`). -Running verify inside every local `task check` would chicken-egg — any commit after -`changelog-write` fails until the next regeneration. Instead: - -- **Local / PR:** run `task changelog-verify` (or `bash hack/release/changelog_test.sh`) when - touching release docs or before opening a release-prep PR. -- **CI (E9-S05):** PRs that touch release paths run the `snapshot` job in - `.github/workflows/release.yaml` (goreleaser `--snapshot --skip=publish`). Changelog drift is - still a separate gate (`task changelog-verify`) — not bundled into `task check`. +| `task release-changelog-gate-test` | AUD-S02: proves the drift gate is wired and fires — in `task check` (REQ-AUD-S02-01/02) | + +Long-lived notes for consumers of released artifacts (currently the **D-120 `pins.toolDigest`** +warning) live in `cliff.toml`'s `[changelog] header`, not in `CHANGELOG.md`: the file is +regenerated in full, so a hand-edit is wiped by the next `changelog-write` while the drift gate +stays green. + +**In `task check` since AUD-S02 (D-125).** `verify-changelog.sh` shipped in E9-S03 wired to +nothing, and `CHANGELOG.md` lost its released section entirely after the v0.1.0 tag with nothing +going red. Where it runs now: + +- **Local:** `task check` runs `changelog-verify` after `compare-exitgate-test`. It is + deliberately one commit behind — `check` is green at HEAD, the next commit makes the changelog + stale, and the following `check` is red until `task changelog-write` is committed. Prefix the + regeneration commit `:memo: chore(release):` or `:wrench: chore(release):` (the two subjects + `cliff.toml` skips), or the regeneration itself creates fresh drift. Regenerate after your last + content commit and after any `git merge origin/main`. +- **CI:** the `verify` job in `.github/workflows/verify.yaml`, guarded + `if: github.event_name != 'pull_request'` — push-to-main and the weekly schedule only. On + `pull_request`, `actions/checkout` builds `refs/pull/N/merge`, a merge commit minted at CI time + whose subject git-cliff renders through the catch-all parser; that line cannot exist in any + committed `CHANGELOG.md`, so a PR-scoped step is red by construction (D-125). +- **Release paths:** PRs touching them still run the `snapshot` job in + `.github/workflows/release.yaml` (goreleaser `--snapshot --skip=publish`). ### Release workflow (E9-S05) diff --git a/hack/release/changelog_gate_test.sh b/hack/release/changelog_gate_test.sh new file mode 100755 index 0000000..a71328d --- /dev/null +++ b/hack/release/changelog_gate_test.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# REQ-AUD-S02-01/02 — the CHANGELOG drift gate is REGENERATED, WIRED, and FIRES. +# +# `hack/release/verify-changelog.sh` has existed since E9-S03 and was invoked by +# nothing: no `task` target listed it, no workflow ran it. A gate nobody calls is +# a comment, which is how `CHANGELOG.md` came to have no `[0.1.0]` section at all +# after the v0.1.0 tag. This script pins the three things that make it a gate: +# +# 1. content — CHANGELOG.md carries the released `## [0.1.0] - 2026-08-05` +# section (REQ-AUD-S02-01). +# 2. wiring — `task check` runs `changelog-verify`, and the `verify:` job in +# verify.yaml runs it too (REQ-AUD-S02-02). The same wiring +# assertions cover the three OTHER gates that shipped unwired and +# are wired by this lane: `docs-gates` (D-124) and +# `lint-depguard-test` (AUD-S07 / D-123). +# 3. polarity — a stale CHANGELOG.md makes `verify-changelog.sh` exit non-zero. +# +# Anti-vacuity discipline (this epic shipped several gates that could not fail): +# * every "is it wired" assertion is a FUNCTION over a file path, and each one is +# re-run against a temp copy with the wired line DELETED — if it does not go red +# there, the assertion is not testing what it claims and this script fails. +# * every extraction (task block, workflow job block, workflow step block) is +# positive-controlled: non-empty AND containing a known-present line AND not +# containing a line from the NEXT block, so a broken awk range fails loudly +# instead of silently asserting nothing over an empty string. +# * no `grep -q` on the read end of a pipe (SIGPIPE 141 under `pipefail`); +# extractions go to files first. +# * the drift probe is verified to have LANDED in the file before its red is +# believed — `verify-changelog.sh` resolves its own repo root from +# ${BASH_SOURCE}, so a "temp copy of CHANGELOG.md" probe would never reach it. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +TASKFILE="$ROOT/Taskfile.yml" +WORKFLOW="$ROOT/.github/workflows/verify.yaml" + +WORK="$(mktemp -d)" +CHANGELOG_BACKUP="$WORK/CHANGELOG.md.orig" +cp "$ROOT/CHANGELOG.md" "$CHANGELOG_BACKUP" +# Restore unconditionally: the drift probe mutates the REAL CHANGELOG.md (see above). +trap 'cp -f "$CHANGELOG_BACKUP" "$ROOT/CHANGELOG.md"; rm -rf "$WORK"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# ---------------------------------------------------------------- extraction -- + +# extract_block — the body of a top-level 2-space-indented +# mapping key (a Taskfile task, or a workflow job), up to the next such key. +# Task names may contain a colon ("tools:git-cliff"), hence the exact-string match. +extract_block() { + awk -v name="$2" ' + $0 == " " name ":" { inblk = 1; next } + inblk && /^ [A-Za-z0-9_.:-]+:[[:space:]]*$/ { inblk = 0 } + inblk { print } + ' "$1" +} + +# extract_step — one workflow step (6-space "- " item) whose +# first line matches , up to the start of the next step. +extract_step() { + extract_block "$1" "$2" >"$WORK/job.for-step" + awk -v pat="$3" ' + /^ - / { instep = ($0 ~ pat) } + instep { print } + ' "$WORK/job.for-step" +} + +# ------------------------------------------------- assertions (file -> status) -- + +# check_lists_task — is a sequential command of +# the `check` task? Not "does the task exist" and not "does the name appear in the +# file": deleting the line from `check:` while leaving the task defined must fail. +check_lists_task() { + extract_block "$1" check >"$WORK/check.block" + grep -qE "^[[:space:]]+- task: $2\$" "$WORK/check.block" +} + +# verify_job_runs_changelog_gate — does verify.yaml's `verify:` job run +# `task changelog-verify`? +verify_job_runs_changelog_gate() { + extract_block "$1" verify >"$WORK/verify.block" + grep -qE 'task changelog-verify' "$WORK/verify.block" +} + +# ------------------------------------------------------- 0. positive controls -- + +echo "== 0. extraction positive controls ==" + +extract_block "$TASKFILE" check >"$WORK/check.control" +[[ -s "$WORK/check.control" ]] || fail "Taskfile check: block extracted EMPTY — the awk range is broken, every wiring assertion below would be vacuous" +grep -qE '^[[:space:]]+- task: fmt$' "$WORK/check.control" \ + || fail "Taskfile check: block does not contain the known-present '- task: fmt' — extraction is wrong" +grep -qE '^[[:space:]]+- task: compare-exitgate-test$' "$WORK/check.control" \ + || fail "Taskfile check: block does not contain the known-present '- task: compare-exitgate-test' (D-118 precedent) — extraction is wrong" +if grep -q 'compare-exitgate-test:' "$WORK/check.control"; then + fail "Taskfile check: block ran past the end of check into the next task definition" +fi +echo "OK: check: block extracted ($(wc -l <"$WORK/check.control" | tr -d ' ') lines), anchored on fmt + compare-exitgate-test, bounded" + +extract_block "$WORKFLOW" verify >"$WORK/verify.control" +[[ -s "$WORK/verify.control" ]] || fail "verify.yaml verify: job extracted EMPTY — the awk range is broken" +grep -q 'hack/compare/exitgate_test.sh' "$WORK/verify.control" \ + || fail "verify.yaml verify: job does not contain the known-present compare exit-gate step — extraction is wrong" +if grep -q 'hack/release/exitgate_test.sh' "$WORK/verify.control"; then + fail "verify.yaml verify: job ran past its end into release-exitgate:" +fi +echo "OK: verify: job extracted ($(wc -l <"$WORK/verify.control" | tr -d ' ') lines), anchored on the compare exit gate, bounded before release-exitgate" + +# --------------------------------------------- 1. REQ-AUD-S02-01 (content) -- + +echo "== 1. REQ-AUD-S02-01: CHANGELOG.md carries the released v0.1.0 section ==" +grep -qE '^## \[0\.1\.0\] - 2026-08-05$' "$ROOT/CHANGELOG.md" \ + || fail "CHANGELOG.md has no '## [0.1.0] - 2026-08-05' section — run 'task changelog-write' and commit (REQ-AUD-S02-01)" +grep -qE '^## Unreleased$' "$ROOT/CHANGELOG.md" \ + || fail "CHANGELOG.md has no '## Unreleased' section (REQ-AUD-S02-01)" +# Post-tag commits must sit under Unreleased, i.e. ABOVE the released section. +unrel_line="$(grep -nE '^## Unreleased$' "$ROOT/CHANGELOG.md" | head -1 | cut -d: -f1)" +rel_line="$(grep -nE '^## \[0\.1\.0\] - 2026-08-05$' "$ROOT/CHANGELOG.md" | head -1 | cut -d: -f1)" +[[ "$unrel_line" -lt "$rel_line" ]] \ + || fail "CHANGELOG.md orders [0.1.0] before Unreleased (line $rel_line vs $unrel_line)" +[[ $((rel_line - unrel_line)) -gt 2 ]] \ + || fail "CHANGELOG.md Unreleased section is empty — the post-tag commits are missing (REQ-AUD-S02-01)" +echo "OK: [0.1.0] at line $rel_line, non-empty Unreleased above it at line $unrel_line" + +# The D-120 record-consumer warning is generated from cliff.toml's header, so a +# hand-edit of CHANGELOG.md cannot carry it and `changelog-write` cannot wipe it. +grep -q 'pins.toolDigest' "$ROOT/CHANGELOG.md" \ + || fail "CHANGELOG.md carries no pins.toolDigest compatibility note — AUD-S04 changed the value of a published record field with no warning to record consumers (D-120)" +grep -q 'pins.toolDigest' "$ROOT/cliff.toml" \ + || fail "the pins.toolDigest note is in CHANGELOG.md but not in cliff.toml — the next 'task changelog-write' will wipe it" +echo "OK: D-120 toolDigest note present in CHANGELOG.md and sourced from cliff.toml" + +# --------------------------------------------- 2. REQ-AUD-S02-02 (wiring) -- + +echo "== 2. REQ-AUD-S02-02: gates wired into 'task check' ==" +# changelog-verify is this story's gate; the other two are the gates that AUD-S06 +# (D-124) and AUD-S07 shipped green but unwired — this lane owns Taskfile.yml. +WIRED_TASKS=(changelog-verify docs-gates lint-depguard-test) +for t in "${WIRED_TASKS[@]}"; do + check_lists_task "$TASKFILE" "$t" \ + || fail "'task check' does not run '$t' — the gate is defined but invoked by nothing" + extract_block "$TASKFILE" "$t" >"$WORK/def.$t" + [[ -s "$WORK/def.$t" ]] || fail "'$t' is listed in check: but not defined in Taskfile.yml" + echo "OK: check runs $t (and $t is defined)" +done + +# The task bodies must still invoke the scripts they exist for — a wired task with +# a gutted body is the same defect one level down. +grep -q 'hack/release/verify-changelog.sh' "$WORK/def.changelog-verify" \ + || fail "the changelog-verify task no longer runs hack/release/verify-changelog.sh" +grep -q 'hack/docs/readme_smoke_test.sh' "$WORK/def.docs-gates" \ + || fail "the docs-gates task no longer runs hack/docs/readme_smoke_test.sh (D-124)" +grep -q 'hack/docs/truthlag_pins_test.sh' "$WORK/def.docs-gates" \ + || fail "the docs-gates task no longer runs hack/docs/truthlag_pins_test.sh (D-124)" +grep -q 'hack/lint/depguard_test.sh' "$WORK/def.lint-depguard-test" \ + || fail "the lint-depguard-test task no longer runs hack/lint/depguard_test.sh (D-123/AUD-S07)" +echo "OK: each wired task still invokes its script" + +echo "== 2b. the wiring assertion itself can fail (mutation) ==" +for t in "${WIRED_TASKS[@]}"; do + mutant="$WORK/Taskfile.no-$t.yml" + grep -vE "^[[:space:]]+- task: $t\$" "$TASKFILE" >"$mutant" + # Prove the mutation landed before believing anything about its result. + if grep -qE "^[[:space:]]+- task: $t\$" "$mutant"; then + fail "mutation did not land: '- task: $t' is still in $mutant" + fi + if [[ "$(wc -l <"$mutant")" -eq "$(wc -l <"$TASKFILE")" ]]; then + fail "mutation did not land: $mutant has the same line count as Taskfile.yml" + fi + if check_lists_task "$mutant" "$t"; then + fail "check_lists_task reports '$t' wired in a Taskfile with that line deleted — the assertion is vacuous" + fi + echo "OK: deleting '- task: $t' from check: turns the assertion red" +done + +echo "== 3. REQ-AUD-S02-02: verify.yaml's verify: job runs the changelog gate ==" +verify_job_runs_changelog_gate "$WORKFLOW" \ + || fail "the verify: job in verify.yaml does not run 'task changelog-verify' (REQ-AUD-S02-02)" +grep -q 'go-task/task/v3/cmd/task@' "$WORK/verify.control" \ + || fail "the verify: job runs 'task changelog-verify' but never installs Task" + +extract_step "$WORKFLOW" verify 'changelog' >"$WORK/step.changelog" +[[ -s "$WORK/step.changelog" ]] || fail "could not extract the changelog gate step from the verify: job" +grep -q 'task changelog-verify' "$WORK/step.changelog" \ + || fail "the extracted step does not run task changelog-verify — extraction matched the wrong step" +# D-125: the step is deliberately NOT run on pull_request. On that event +# actions/checkout builds refs/pull/N/merge, a merge commit minted at CI time whose +# subject git-cliff renders through the catch-all parser — a line that cannot exist +# in any committed CHANGELOG.md. Without this guard every PR is red by construction. +grep -qF "github.event_name != 'pull_request'" "$WORK/step.changelog" \ + || fail "the changelog gate step has no 'github.event_name != '\''pull_request'\''' guard — on the PR merge ref the generated changelog always contains a synthetic merge subject, so the step would be red on every PR by construction (D-125)" +echo "OK: verify: job runs task changelog-verify, guarded off pull_request (D-125)" + +echo "== 3b. the workflow assertion itself can fail (mutation) ==" +mutant="$WORK/verify.no-changelog.yaml" +grep -v 'task changelog-verify' "$WORKFLOW" >"$mutant" +if grep -q 'task changelog-verify' "$mutant"; then + fail "mutation did not land: $mutant still runs task changelog-verify" +fi +[[ "$(wc -l <"$mutant")" -lt "$(wc -l <"$WORKFLOW")" ]] || fail "mutation did not land: $mutant has the same line count as verify.yaml" +if verify_job_runs_changelog_gate "$mutant"; then + fail "verify_job_runs_changelog_gate reports the gate present in a workflow with that line deleted — the assertion is vacuous" +fi +echo "OK: deleting the run line from verify.yaml turns the assertion red" + +# ------------------------------------------------ 4. drift polarity (in-tree) -- + +echo "== 4. verify-changelog.sh passes on the committed tree ==" +bash "$ROOT/hack/release/verify-changelog.sh" >"$WORK/clean.out" 2>&1 || { + cat "$WORK/clean.out" >&2 + fail "verify-changelog.sh is red on the committed tree — run 'task changelog-write' and commit" +} +echo "OK: clean polarity green" + +echo "== 5. verify-changelog.sh fails closed on a stale CHANGELOG.md ==" +printf '\n\n' >>"$ROOT/CHANGELOG.md" +grep -q 'AUD-S02 drift probe' "$ROOT/CHANGELOG.md" \ + || fail "drift probe did not land in CHANGELOG.md — the red below would prove nothing" +if bash "$ROOT/hack/release/verify-changelog.sh" >"$WORK/drift.out" 2>&1; then + cat "$WORK/drift.out" >&2 + fail "verify-changelog.sh exited 0 on a stale CHANGELOG.md — the drift gate does not fire (REQ-AUD-S02-02)" +fi +grep -q 'drift' "$WORK/drift.out" \ + || fail "verify-changelog.sh failed on the stale CHANGELOG.md but did not report drift — it failed for some other reason" +cp -f "$CHANGELOG_BACKUP" "$ROOT/CHANGELOG.md" +if grep -q 'AUD-S02 drift probe' "$ROOT/CHANGELOG.md"; then + fail "restore failed: the drift probe is still in CHANGELOG.md" +fi +echo "OK: stale CHANGELOG.md is caught, probe restored" + +echo "PASS: changelog drift gate regenerated, wired into task check + verify.yaml, and proven at both polarities (REQ-AUD-S02-01/02)"