Closes the release-record commitment carried across three releases
(`docs/audits/release-readiness-v3.3.0.md`, "Process lesson"): **CI must
exercise the goreleaser release path between releases.** A retired
`.goreleaser.yml` `before.hooks` entry once sat broken for 20 days
because only a real `v*` tag runs that path.
## What lands
- `.github/workflows/release-path-smoke.yml` — full GoReleaser snapshot,
publishing skipped.
- `tests/scripts/lib/release-snapshot-smoke.sh` — the one smoke path,
shared by CI and its negative witness.
- `tests/scripts/release-path-smoke.bats` — the negative witness (10
tests).
- `.github/workflows/nightly.yml` — calls the smoke via `workflow_call`,
wired into the summary table + failure-issue condition.
- `CHANGELOG.md` + `docs/CHANGELOG.md` — `[Unreleased]` entry
(byte-identical mirror).
## Exact invocation, and why
```
goreleaser release --snapshot --clean --skip=publish --config <cfg> --timeout 20m
```
Installed with the **same** pinned
`goreleaser/goreleaser-action@f06c13b6… # v7` and `version: '~> v2'`
that `release.yml` uses (`install-only: true`), so the smoke exercises
the binary the publisher would actually get.
**`goreleaser check` is deliberately not the gate.** Verified against
the repo's config on goreleaser 2.16.0: `goreleaser check` exits **1**
on deprecation warnings alone (the config's `brews:` block), so it
cannot gate without changing release behavior — and it never runs hooks,
builds, archives, or the Homebrew formula template, which are precisely
the stages that broke. The snapshot exits 0 on the same config. So the
evidence below is from the **real snapshot**, not the cheap proxy.
## Acceptance → evidence
**1. Nightly + release-plumbing-input triggers.**
- Nightly: `nightly.yml` gains `release-path-smoke: uses:
./.github/workflows/release-path-smoke.yml`, added to `summary.needs`,
the summary table, and both failure conditions.
- PR/push: `paths:` filter on `.goreleaser.yml`,
`.github/workflows/release.yml`, `cli/go.mod`, `cli/Formula/**`, plus
the smoke script and its bats file (so edits to the gate re-run the
gate). A **dedicated workflow** rather than a `validate.yml` filter
branch — same isolation rationale as `workflow-scripts-syntax.yml` ("no
shared job registry to drift"), and it keeps `validate.yml`'s `changes`
output list untouched.
- **Runtime choice: full six-target matrix, no split.** Measured
locally: `11.5s` wall for `darwin/linux/windows × amd64/arm64`
end-to-end (build + archive + checksums + formula render). A
single-platform snapshot would leave five publisher targets unexercised
for no meaningful time saving. Job `timeout-minutes: 20`.
**2. Negative witness.** `tests/scripts/release-path-smoke.bats` runs
**the CI smoke path itself** against deliberately broken temp copies of
`.goreleaser.yml`:
- `red: a retired before-hook makes the release path FAIL` — reproduces
the exact 20-day bug (config prepended with `before: hooks: -
./scripts/this-script-was-retired.sh`), asserts `status -eq 1`.
- `red: a structurally invalid config makes the release path FAIL` —
asserts `status -eq 1`.
- Plus usage-error legs asserting **exit 2** so "goreleaser missing /
bad flags" is never mistaken for "release path broken".
```
$ AGENTOPS_RELEASE_SMOKE_FULL=1 bats tests/scripts/release-path-smoke.bats
1..10 … ok 1 … ok 10 # all 10 pass, both RED legs fire
```
The job is workflow-only (no new registry gate), so per the intent I
confirmed the ratchet stays green:
```
$ cd cli && go test ./internal/gates/checks/ -run TestBlockingGatesHaveProvenNegativeWitness
ok (1 passed)
```
**3. Passes against current main's config.** The green leg is the real
snapshot, not `goreleaser check`:
```
$ bash tests/scripts/lib/release-snapshot-smoke.sh
… building binaries (6 targets) … archives … checksums … homebrew formula
release succeeded after 11s
OK release path built end-to-end (publish skipped) # rc=0
```
Also covered as the bats `green:` test (gated behind
`AGENTOPS_RELEASE_SMOKE_FULL=1` so the generic `bats
tests/scripts/*.bats` job stays fast; the smoke workflow sets it).
**4. CHANGELOG.** `[Unreleased] → Added` entry in `CHANGELOG.md`,
mirrored byte-identically to `docs/CHANGELOG.md` (`diff` clean).
## Local gates (all rc=0)
| Check | Result |
|---|---|
| `cd cli && go build ./...` | 0 |
| `cd cli && go vet ./...` | 0 |
| `cd cli && go test ./...` | 0 |
| `bash scripts/regen-all.sh --check` | 0 — "All generated projections
are current." |
| `bats --jobs 4 tests/scripts/*.bats` | 0 — 953 ok, 0 not ok |
| `ao gate check --full --workflow-coverage --require-workflow-parity` |
0 — 68/68 passed |
| `shellcheck tests/scripts/lib/release-snapshot-smoke.sh` | clean |
| YAML parse (both workflows) | OK |
## Scope
Write scope honored: `.github/workflows/**`, `tests/scripts/**`,
`CHANGELOG.md`, `docs/CHANGELOG.md`. **`.goreleaser.yml` is
read/verified only — not modified.** No `cli/**` Go code, no
`skills/**`.
## Residual risk
- The `brews:` deprecation is left in place (out of scope). It is a
warning today; if GoReleaser v3 removes it, this smoke is exactly what
will surface it between releases instead of at tag time — which is the
point.
- Nightly `workflow_call` inherits caller permissions; the reusable
workflow declares `permissions: contents: read` and the nightly job pins
the same.
scripts/lib/ratchet.sh: the one implementation of the grandfather/baseline
ratchet mechanics 7 gates hand-roll today — per-consumer parse modes
(raw|cr-strip|strip|trailing-comment), scope-aware changed-file collection
incl. upstream, fail-closed growth guard (comm -13 + intersection authority,
RATCHET_GROWTH_GUARD=off escape for migrated gates), parameterized stale
predicates, generic regenerate (header-fn + LC_ALL=C sorted body), added-hunk
guard with ENVIRON-passed ERE.
tests/scripts/lib/ratchet-difftest.bash: byte-parity differential harness
(stdout, stderr, exit code separately) for the migration slices.
tests/scripts/ratchet-lib.bats: 24 cases — real pinned-shape round-trips per
parse mode, growth rejection, self-allowlist refusal, stale by set-diff and
by predicate, regenerate golden, added-hunk guard, harness smoke.
cli/internal/gates/checks/ratchet_routing_test.go: self-extending FM3 closure —
every check script sourcing the lib must route lib edits via its Match globs.
Pre-mortem: .agents/council/2026-07-10-pre-mortem-ratchet-lib.md (3 rounds).
Lib is FROZEN for the rest of the arc per FM6.
Claude-Session: https://claude.ai/code/session_01RDWPYn4ZS2SyBkcBvAM18u