Skip to content

Commit 330c950

Browse files
anandgupta42claude
andauthored
feat: [release] harden /release — preflight script, fail-closed tags, publish gates (#1032)
* feat: [release] harden /release from 3-release retro — preflight script, fail-closed tags, publish gates Retro of the v0.8.10 / v0.9.1 / v0.9.2 release traces (.github/meta/release-retro-2026-07-22.md) found recurring failure classes; this change closes them: - `script/release-preflight.ts` (+34 unit tests): deterministic fail-closed gate — clean tree, HEAD == fresh `origin/main` (stage `pre`) or ancestor (stage `tag`), local+remote tag-collision check (never auto-delete), version sanity vs npm, prerelease-line ancestry, `release-blocker` PRs, `PREV_TAG` dry-run, marker guard with full output in one pass - `release.yml`: `publish-npm` now gated on the `test` job (npm could previously publish with tests red); tag format/SHA/CHANGELOG validated BEFORE publish; pre-publish smoke test asserts the exact tag version (a stale 0.7.3 binary once passed while releasing 0.9.2) - `/release` skill: preflight as Step 2 gate; three-step verified commit/tag + `git push --atomic origin HEAD:main v{V}`; stop-never-improvise on structural failures; deferred findings require durable issue links; P0-candidates verified or explicitly downgraded; exit-status integrity rule (no trailing status echoes); smoke-test the dist binary by path; every step visible in the summary table; personas deliver via SendMessage before idling - RELEASING.md: targeted staging instead of `git add -A`, atomic push, honest AUR-disabled / Docker-best-effort wording Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: [release] codex-review fixes — fail-closed gates, ordering, PREV_TAG upper bound Codex review of the release-hardening commit found fail-open paths; all fixed: - skill/RELEASING.md: tag verification now aborts (`|| exit 1`) instead of echo-and-continue; stage-`tag` preflight moved AFTER the release commit (it previously ran against a tree dirtied by the CHANGELOG edit and could never pass); `cd` blocks made subshells so repo-root paths stay valid; smoke test builds with `OPENCODE_VERSION` injected and asserts equality - `release-preflight.ts`: npm publish-check only treats E404 as "not published" (other errors FAIL); `gh` errors FAIL (WARN only when the binary is missing); malformed `gh` JSON FAILs instead of double-reporting; `run()` no longer throws on missing binaries; SemVer prerelease compare is numeric-aware (`beta.10` > `beta.2`); `selectPrevTag` picks the greatest stable tag strictly LOWER than the target (a merged fork-inherited `v1.18.3` must not become PREV_TAG for `v0.9.x`) — same fix applied to `release.yml`'s PREV_TAG loop - `/release-beta`: wires in the preflight (`--allow-prerelease`) and fail-closed tag collision checks before pushing the beta tag - tests: 34 -> 38 (numeric prerelease precedence, prefix ranking, fork-inherited higher-tag exclusion) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: [release] address PR #1032 review — strict SemVer, fail-closed npm, authoritative tag check Addresses all 7 chatgpt-codex-connector review comments: - P1 prerelease ancestry now checks against HEAD, not origin/main — cutting beta.N+1 from a release branch no longer trips on beta.N being unmerged - P1 npm unreachable/unparseable is now FAIL, not WARN — an inconclusive registry query must not defer failure to the irreversible release workflow - P1 release.yml validates the tag via `git ls-remote origin` (with annotated `^{}` peel) instead of an unforced fetch that a force-moved tag would defeat - P1 prerelease targets are also compared against the current `beta` dist-tag so a stale beta.N cannot move new beta installs backward - P2 `parseSemver` + the workflow tag regex now enforce semver.org rules: no leading zeros in numeric identifiers, no empty prerelease identifiers - P2 ci.yml marker-guard job runs the preflight test suite (root bunfig.toml pins `bun test` away from the repo root, so it runs from `script/`) - P2 marker-guard check FAILs when analyze.ts degrades to pattern-only detection because the upstream remote is missing tests: 38 -> 41 (leading-zero rejection, empty-identifier rejection, zero/alphanumeric prerelease acceptance) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop internal release-retro notes from the PR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 537d3b7 commit 330c950

7 files changed

Lines changed: 1103 additions & 79 deletions

File tree

.claude/commands/release-beta.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,13 @@ DIST=$(find packages/opencode/dist -type d -name '*'"$(uname -m | sed s/arm64/ar
9191
# 4. marker guard
9292
bun run script/upstream/analyze.ts --markers --base main --strict
9393

94-
# 5. Local Verdaccio sanity IF docker + a native-platform build are available
94+
# 5. Deterministic preflight (tag collisions, version sanity vs npm, prerelease
95+
# ancestry, release-blocker PRs). For a beta cut from main use it as-is; for a
96+
# branch beta the `base` check will FAIL by design — then rely on the manual
97+
# tag verification in Step 4 instead.
98+
bun script/release-preflight.ts --version X.Y.Z-beta.N --stage tag --allow-prerelease
99+
100+
# 6. Local Verdaccio sanity IF docker + a native-platform build are available
95101
# (the docker image is linux; on a mac you cannot cross-build the linux NAPI dist,
96102
# so this validates the current platform only — CI covers the rest):
97103
# (cd packages/dbt-tools && bun run build) && docker compose \
@@ -108,7 +114,12 @@ The `-beta.N` suffix is what routes to the beta channel. Do NOT omit it.
108114

109115
```bash
110116
BETA_TAG="vX.Y.Z-beta.N"
117+
# Fail closed on collisions — a stale local tag makes `git tag` no-op and the
118+
# push would publish whatever the old tag points at. Never delete-and-recreate.
119+
git rev-parse -q --verify "refs/tags/$BETA_TAG" && { echo "LOCAL TAG EXISTS — STOP"; exit 1; }
120+
git ls-remote origin "refs/tags/$BETA_TAG" | grep -q . && { echo "REMOTE TAG EXISTS — STOP"; exit 1; }
111121
git tag "$BETA_TAG"
122+
test "$(git rev-parse "$BETA_TAG")" = "$(git rev-parse HEAD)" || { echo "TAG MISMATCH — STOP"; exit 1; }
112123
git push origin "$BETA_TAG" # push the TAG (not necessarily main)
113124
```
114125

.claude/commands/release.md

Lines changed: 108 additions & 51 deletions
Large diffs are not rendered by default.

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,12 @@ jobs:
509509
run: bun test
510510
working-directory: script/upstream
511511

512+
- name: Run release preflight tests
513+
# Root bunfig.toml pins `bun test` away from the repo root, so run from
514+
# script/ — same pattern as the marker parser tests above.
515+
run: bun test release-preflight.test.ts
516+
working-directory: script
517+
512518
- name: Check for missing altimate_change markers
513519
run: |
514520
if [[ "${{ github.event_name }}" == "push" ]]; then

.github/workflows/release.yml

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,58 @@ jobs:
181181

182182
publish-npm:
183183
name: Publish to npm
184-
needs: [build, sanity-verdaccio]
184+
# altimate_change — gate publishing on the test job. Previously publish-npm only
185+
# needed [build, sanity-verdaccio], so npm could publish while typecheck/tests
186+
# were red (found in the 2026-07-22 release retro).
187+
needs: [test, build, sanity-verdaccio]
185188
runs-on: ubuntu-latest
186189
timeout-minutes: 60
187190
permissions:
188191
contents: read
189192
steps:
190193
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
191194

195+
# altimate_change — validate the tag BEFORE any publish work. Tag-format
196+
# validation previously lived in github-release, which runs AFTER publish-npm,
197+
# so a malformed tag could publish to npm and only fail afterwards.
198+
- name: Validate release tag
199+
run: |
200+
# Strict SemVer: no leading zeros in numeric identifiers, no empty
201+
# prerelease identifiers (rejects v01.2.3, v1.2.3-01, v1.2.3-a..b).
202+
SEMVER_ID='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)'
203+
if ! echo "$CURRENT_TAG" | grep -qE "^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-${SEMVER_ID}(\.${SEMVER_ID})*)?$"; then
204+
echo "::error::Invalid tag format: $CURRENT_TAG — refusing to publish"
205+
exit 1
206+
fi
207+
# Ask origin directly what the tag points to — the event payload and
208+
# the checkout's local tag ref are both stale if the tag was force-moved
209+
# between the push event and this job. An unforced `git fetch` would
210+
# refuse to clobber the local tag, so ls-remote is the authority.
211+
LS=$(git ls-remote origin "refs/tags/$CURRENT_TAG" "refs/tags/$CURRENT_TAG^{}") || {
212+
echo "::error::Could not query origin for tag $CURRENT_TAG"
213+
exit 1
214+
}
215+
# Annotated tags list a peeled ^{} line pointing at the commit; prefer it.
216+
TAG_SHA=$(echo "$LS" | grep '\^{}' | cut -f1 | head -1)
217+
[ -z "$TAG_SHA" ] && TAG_SHA=$(echo "$LS" | cut -f1 | head -1)
218+
if [ -z "$TAG_SHA" ]; then
219+
echo "::error::Tag $CURRENT_TAG no longer exists on origin — refusing to publish"
220+
exit 1
221+
fi
222+
HEAD_SHA=$(git rev-parse HEAD)
223+
if [ "$TAG_SHA" != "$HEAD_SHA" ]; then
224+
echo "::error::Tag $CURRENT_TAG points at $TAG_SHA on origin but workflow checked out $HEAD_SHA (tag moved since the push event?)"
225+
exit 1
226+
fi
227+
VERSION="${CURRENT_TAG#v}"
228+
if ! grep -q "\[$VERSION\]" CHANGELOG.md && [ "${CURRENT_TAG#*-}" = "$CURRENT_TAG" ]; then
229+
echo "::error::CHANGELOG.md has no entry for $VERSION — stable releases require a changelog entry"
230+
exit 1
231+
fi
232+
echo "Tag validation passed: $CURRENT_TAG @ $TAG_SHA"
233+
env:
234+
CURRENT_TAG: ${{ github.ref_name }}
235+
192236
- uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
193237
with:
194238
bun-version: "1.3.14"
@@ -276,9 +320,19 @@ jobs:
276320
# comment for why this matters. The binary must start without
277321
# walking the workspace for node_modules.
278322
cd "${RUNNER_TEMP:-/tmp}"
279-
env -u NODE_PATH "$BINARY" --version
280-
echo "Pre-publish smoke test passed"
323+
# altimate_change — assert the EXACT version, not just "it starts".
324+
# The 2026-07-22 release retro found a smoke test once validated a
325+
# stale binary (0.7.3) while releasing 0.9.2.
326+
REPORTED=$(env -u NODE_PATH "$BINARY" --version)
327+
EXPECTED="${CURRENT_TAG#v}"
328+
if [ "$REPORTED" != "$EXPECTED" ]; then
329+
echo "::error::Binary reports version '$REPORTED' but tag says '$EXPECTED'"
330+
exit 1
331+
fi
332+
echo "Pre-publish smoke test passed ($REPORTED)"
281333
fi
334+
env:
335+
CURRENT_TAG: ${{ github.ref_name }}
282336

283337
- name: Publish to npm
284338
run: bun run packages/opencode/script/publish.ts
@@ -321,15 +375,18 @@ jobs:
321375
322376
# Get the previous tag.
323377
# altimate_change — pick the newest STABLE tag that is an ANCESTOR of this
324-
# commit, excluding the current tag and any prerelease (-beta etc). Plain
325-
# `--sort=-version:refname | head -2 | tail -1` picked the 2nd-highest tag by
326-
# name, which for a stable release lands on a prerelease or a stray/divergent
327-
# tag (e.g. a dangling v0.9.0), producing a bogus compare range. Restricting to
328-
# `--merged HEAD` non-prerelease tags yields the real previous release.
329-
PREV_TAG=$(git tag --merged HEAD --sort=-version:refname \
330-
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
331-
| grep -vx "$CURRENT_TAG" \
332-
| head -1)
378+
# commit AND strictly LOWER than the current tag. Excluding only equality is
379+
# not enough: if upstream history is ever merged, fork-inherited tags (e.g.
380+
# v1.18.3) would beat the real previous release for a v0.9.x target and the
381+
# compare range would silently omit history.
382+
PREV_TAG=""
383+
for t in $(git tag --merged HEAD --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$'); do
384+
if [ "$t" != "$CURRENT_TAG" ] && \
385+
[ "$(printf '%s\n%s\n' "$t" "$CURRENT_TAG" | sort -V | head -1)" = "$t" ]; then
386+
PREV_TAG="$t"
387+
break
388+
fi
389+
done
333390
334391
# Generate changelog from commits between tags
335392
echo "## What's Changed" > notes.md

docs/RELEASING.md

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,22 @@ The version is injected into the binary via esbuild defines at compile time.
3434

3535
## Release Process
3636

37-
### 1. Update CHANGELOG.md
37+
### 1. Run preflight (before touching anything)
38+
39+
**MANDATORY** — the deterministic gate. Run it while the tree is still clean;
40+
it fails on a dirty worktree by design:
41+
42+
```bash
43+
# Checks: clean tree, HEAD == fresh origin/main, no tag collisions (local OR
44+
# remote), version sanity vs npm, prerelease-line ancestry, release-blocker
45+
# PRs, PREV_TAG dry-run, marker guard.
46+
bun script/release-preflight.ts --version 0.5.0 --stage pre
47+
```
48+
49+
Do NOT proceed if any check fails. If it reports a tag collision, resolve it
50+
explicitly — never delete-and-recreate a tag blind.
51+
52+
### 2. Update CHANGELOG.md
3853

3954
Add a new section at the top of `CHANGELOG.md`:
4055

@@ -48,42 +63,57 @@ Add a new section at the top of `CHANGELOG.md`:
4863
- ...
4964
```
5065

51-
### 2. Run pre-release sanity check
66+
### 3. Run pre-release sanity check
5267

5368
**MANDATORY** — this catches broken binaries before they reach users:
5469

5570
```bash
56-
cd packages/opencode
57-
bun run pre-release
71+
(cd packages/opencode && OPENCODE_VERSION=0.5.0 bun run pre-release)
5872
```
5973

60-
This verifies:
74+
`pre-release` verifies:
6175
- All required NAPI externals are in `package.json` dependencies
6276
- They're installed in `node_modules`
6377
- A local build produces a binary that actually starts
6478

6579
Do NOT proceed if any check fails.
6680

67-
### 3. Commit and tag
81+
### 4. Commit, re-preflight, tag, push
82+
83+
Stage files **individually** — never `git add -A` (release worktrees carry
84+
scratch files that must not ship in the release commit). Releasing does not
85+
require being literally on the `main` branch: the invariant is that a clean
86+
HEAD equals freshly fetched `origin/main` (worktrees push via `HEAD:main`).
6887

6988
```bash
70-
git add -A
89+
git add CHANGELOG.md <other release files>
7190
git commit -m "release: v0.5.0"
91+
92+
# Re-run preflight now that the release commit exists. Stage `tag` allows
93+
# local commits ahead of origin/main (the tree must be clean again).
94+
bun script/release-preflight.ts --version 0.5.0 --stage tag || exit 1
95+
7296
git tag v0.5.0
73-
git push origin main v0.5.0
97+
# Verify the tag points at HEAD BEFORE pushing — a pre-existing tag makes
98+
# `git tag` silently fail, and pushing a stale tag publishes old code.
99+
test "$(git rev-parse v0.5.0)" = "$(git rev-parse HEAD)" || exit 1
100+
# Atomic: branch and tag land together or not at all.
101+
git push --atomic origin HEAD:main v0.5.0
74102
```
75103

76-
### 4. What happens automatically
104+
### 5. What happens automatically
77105

78106
The `v*` tag triggers `.github/workflows/release.yml` which:
79107

80-
1. **Builds** all platform binaries (linux/darwin/windows, x64/arm64)
81-
2. **Publishes to npm** — platform-specific binary packages + wrapper package
82-
3. **Creates GitHub Release** — with auto-generated release notes and binary attachments
83-
4. **Updates AUR** — pushes PKGBUILD update to `altimate-code-bin`
84-
5. **Publishes Docker image** — to `ghcr.io/altimateai/altimate-code`
108+
1. **Runs release-critical tests** — typecheck + branding/install tests (gates npm publish)
109+
2. **Builds** all platform binaries (linux/darwin/windows, x64/arm64)
110+
3. **Validates the tag** — format, tag-SHA == checked-out SHA, CHANGELOG entry present (before any publish)
111+
4. **Publishes to npm** — platform-specific binary packages + wrapper package
112+
5. **Creates GitHub Release** — with auto-generated release notes and binary attachments
113+
6. **Publishes Docker image (best-effort)** — to `ghcr.io/altimateai/altimate-code`; failures are logged but do NOT fail the release, so verify manually if you need the image
114+
7. ~~Updates AUR~~ — currently **disabled** (the workflow step is commented out; see `publish.ts` for setup steps to re-enable)
85115

86-
### 5. Verify
116+
### 6. Verify
87117

88118
After the workflow completes:
89119

0 commit comments

Comments
 (0)