New Perry Release (tag-last)
Model (important — read before acting)
Perry releases are tag-last: nothing public happens until the test gate and every build leg are green. You dispatch release-packages.yml with cut_release=true on a branch pinned at the release candidate; the workflow then:
preflight— resolvesvX.Y.ZfromCargo.tomlon that SHA, fails fast if the tag already exists, if CLAUDE.md's**Current Version:**disagrees, or ifchangelog.d/has no fragments.await-tests— dispatchestest.yml(withtier=full) +simctl-tests.ymlon the branch if no suitable run exists on the SHA yet, then polls by head SHA until both are green. Fortest.ymlonly a run whosefull-suite-gatejob succeeded counts — a green push-to-main sweep or PR-tier run on the same SHA is not release-grade (see docs/src/testing/ci-tiers.md).build+build-cross— all release binaries, archives as workflow artifacts.create-release— only now creates the tag + GitHub Release (notes concatenated fromchangelog.d/fragments viacut_release_notes.sh --notes-only), and dispatches the tag-rider workflows (docs, benchmark, container-tests) on the new tag.publish-assets→ homebrew / apt / apt-repo / winget / npm / update-workers.
So a red gate or a broken build leg costs nothing: no burned tag, no half-published release. Version numbers only get consumed by releases that actually shipped.
/release does not bump versions, commit code, or write release notes — versions ride on every merged PR, and notes are the accumulated changelog.d/ fragments.
Steps
1. Sanity checks
git status— must be clean. If not, STOP and report.git rev-parse --abbrev-ref HEAD— must bemain. If not, STOP.git fetch origin && git log HEAD..origin/main --oneline— must be empty. If origin is ahead, pull/resolve first.ls changelog.d/[0-9]*.md— at least one fragment must exist (they become the release notes). None → nothing to release, STOP.
2. Read the version + verify the tag is free
VERSIONfrom[workspace.package]inCargo.toml; must match CLAUDE.md's**Current Version:**line. Disagreement → STOP (preflight would also catch it, but catch it here in seconds).git ls-remote origin "refs/tags/v$VERSION"— must be empty. A hit means this version already shipped; land a bump first.
3. Pin the candidate + dispatch
git branch "release/v$VERSION" HEAD
git push origin "release/v$VERSION"
gh workflow run release-packages.yml --ref "release/v$VERSION" -f cut_release=true
The pinned branch matters because workflow_dispatch always runs on a ref's tip (so main moving would shift the SHA under you). (test.yml's dispatch runs are keyed per run-id, so a dispatch on main no longer cancels a running nightly — but pin anyway.)
Optional pre-warm: dispatch test.yml (-f tier=full) + simctl-tests.yml on the branch yourself right away — the gate matches any full-tier run on the SHA, so pre-flighted runs subtract their time from the critical path. If you skip this, await-tests dispatches them for you.
4. Watch
gh run watch $(gh run list --workflow="Release Packages" --limit 1 --json databaseId --jq '.[0].databaseId')
Expected timeline: gate ~30 min (queue-dependent; 120-min budget) → builds ~30-40 min → create-release seconds → publish ~20 min.
5. After success: fold fragments + clean up
The release exists, but the fragments that became its notes are still on main:
./scripts/cut_release_notes.sh --fold "v$VERSION" # removes exactly the fragments recorded at the tag, commits
git push origin main # or via PR, per protection rules
git push origin ":release/v$VERSION" # drop the pin branch
--fold is tag-scoped: fragments merged after the release SHA survive for the next release.
6. Verify (optional but recommended)
cargo install --path crates/perry --force && perry --version # should print X.Y.Z
Report back: GitHub release URL, whether release-packages was green end-to-end, local perry --version.
Failure modes
- Preflight red (tag exists / version drift / no fragments): nothing ran. Fix, re-dispatch.
- Gate or build red: nothing was tagged or published. Fix on
main(normal PR, version bumps at merge), delete + re-pin the branch at the new candidate, re-dispatch. No version number is burned — the samevX.Y.Zcan try again if the version didn't move. - Publish leg red (after create-release): the tag + release exist with partial assets.
gh run rerun <run-id> --failedreruns the failed legs and everything downstream of them; the npm job is idempotent (skips already-published versions). Never delete/re-create the tag. - Branch moved under the dispatch:
await-testsfails fast with "ref moved" — re-pin and re-dispatch.
Fallback: legacy tag-first path
Still fully supported if CI dispatch is unavailable: ./scripts/cut_release_notes.sh vX.Y.Z from a clean main creates the tag + release (notes from fragments, removal committed for you); the release: published event runs the same pipeline with the same test gate. Re-publish an existing release with gh workflow run release-packages.yml -f existing_tag=vX.Y.Z (add -f publish_npm=true to redo npm).
What NOT to do
- Do not create the tag or GitHub Release yourself in the tag-last flow —
create-releasedoes it, and a pre-existing tag makes preflight abort. - Do not re-tag or force-push tags, ever. A failed publish is rerun-able; a mutated public tag is not.
- Do not commit anything during the release except the
--foldcommit afterwards. - Do not use
git add -Aanywhere in this skill.