diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index d9f083dc3..ee526531f 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: Cut an Inspector v2 release — run npm audit and bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on. +description: "Cut an Inspector v2 release — two PRs and then a GitHub Release. PR 1 puts the npm audit, any fixes it forces, and the version bump on v2/main; PR 2 merges v2/main into main and is smoke-tested from the production build with a ledger artifact for the maintainers; the maintainer then tags and publishes through the GitHub UI. Also covers the v1 line and what the publish jobs gate on." disable-model-invocation: true --- @@ -24,14 +24,35 @@ job or the coverage gate red: --access public --provenance`. - **`publish-github-container-registry`** — the GHCR image. +## The shape: two PRs, then the Release + There is **one version number** (only the root `package.json` has one — the -clients carry none), so the flow is three steps. +clients carry none), and the release moves through **two pull requests** in +order. They are not interchangeable and neither one's content belongs on the +other. + +| | PR 1 — prep | PR 2 — the milestone merge | +| --- | --- | --- | +| Branch | `v2/chore/-bump-`, cut from `origin/v2/main` | the milestone-merge branch, cut from `origin/main` | +| Base | **`v2/main`** | **`main`** | +| Carries | the `npm audit` report, **any fixes the audit forces**, and the **version bump** — all three, one PR | the milestone's work, arriving whole from `v2/main`. **No commits of its own.** | +| Verified by | `npm run local:gate` | `npm run local:gate` **plus** a hand-driven smoke of every contribution in the milestone, from the **production build**, written up as a **ledger artifact** | +| Merged when | reviewed and green | the ledger is reviewed by the maintainers and clean | + +Then, and only then, a maintainer tags and publishes the **GitHub Release** +(step 3), which is what triggers the publish jobs. -## 1. `npm audit`, then bump, on `v2/main` — before the milestone merge +⚠️ **Do not fold the two together.** The bump must exist on `v2/main` before the +merge (see [Why the bump goes on `v2/main` first](#why-the-bump-goes-on-v2main-first-2010)), +and PR 2 must stay a pure merge — a commit authored on the merge branch is a +change that exists downstream of `v2/main` and nothing carries it back. -Both are part of the milestone's work, so both belong on the develop branch and -flow into `main` together, in the same PR — audit first, so the bump sits on top -of a tree you have just checked. +## 1. PR 1 — audit, audit fixes and the bump, on `v2/main` + +All three are part of the milestone's work, so all three belong on the develop +branch and flow into `main` together, **in the same PR** — audit first, so the +bump sits on top of a tree you have just checked, and so a reviewer sees the +report and the fixes it forced as one change. ```sh # Branch from the REMOTE ref, and read the version only once you are on it. @@ -92,7 +113,11 @@ and the tag would land on a `v2/main` commit — but the release must be cut fro `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released. -## 2. Merge `v2/main` → `main` +**PR 1 merges before PR 2 is opened.** The merge branch is cut from `main` and +takes `v2/main` whole, so opening it early means merging a `v2/main` that does +not yet carry the bump. + +## 2. PR 2 — merge `v2/main` → `main`, smoke-test it, and write the ledger Through the usual milestone-merge branch. It now carries the bump, so the release lands on `main` with the version already correct. @@ -104,10 +129,78 @@ milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. -## 3. Tag `origin/main` and draft the Release +### 2a. Smoke-test the release candidate from the production build + +The merge branch's tree **is** the release candidate. Check that rather than +assume it — the merge commit's tree and `origin/v2/main`'s must be identical: + +```sh +git rev-parse origin/v2/main^{tree} +git rev-parse ^{tree} # must print the same hash +``` -Derive the tag from the version that just landed, rather than typing one — a -hard-coded tag is either already taken (so `git tag` aborts) or, worse, wrong: +Then drive it. Work from a **dedicated worktree** with its own full +`npm install` (a symlinked `node_modules` passes lint and tests and then fails +every story file), run `npm run local:gate` there, and exercise the app from the +**production build** — the packaged bin and the built bundles, not `vite dev`. +The `local-dev`, `test-servers` and `pre-push-gate` skills cover the mechanics; +`pack:verify` inside the gate is what proves the tarball a consumer installs +actually resolves. + +**Every contribution closed in the milestone gets driven, not read.** The bar is +observed behavior from the running app — a rendered panel, a status attribute, a +server's own stderr — against a real test server, through whichever clients the +change touches (web, CLI, TUI). "Its tests pass" is not evidence for this step; +the gate already said that. For a change with no observable surface, the +evidence is the thing that holds it — a probe that makes the guard fire, a +counted before/after, a resolved binary path. + +### 2b. The ledger artifact + +Write the results up as a **published artifact** for the maintainers to review, +and link it from PR 2. Shape it like the +[v2.5.0 ledger](https://claude.ai/code/artifact/6f25d292-3623-419f-af7f-26aba57247ef): + +- **Masthead** — repo, PR number and merge commit, version, date; and a + standfirst saying what tree was tested and that its hash matches + `origin/v2/main`, plus whether the milestone payload is complete (the only + issue left open should be the merge itself). +- **Verdict band** — `local:gate` result, milestone issues verified as `N / N`, + distinct test count, regressions found. +- **The automated gate** — one cell per stage with its number (file counts, + test counts, smoke count, `pack:verify` size), and a note on what is new this + milestone. +- **One section per theme**, each a table of *Issue · What was driven · + Observed · Status*. One row per closed issue, issue-linked, with the actual + output in the Observed cell. +- **Notes / findings** — anything that is a caveat rather than a pass, called + out rather than folded into a row. + +A row that says "verified" without saying what was run is not a ledger entry. + +### 2c. When the smoke finds something + +**The fix goes on `v2/main`, never on the merge branch.** File the issue, fix it +through an ordinary PR against `v2/main`, then merge `v2/main` into the merge +branch again so the fix arrives the same way everything else did. That keeps the +merge tree byte-identical to `origin/v2/main` — which is both the invariant +checked in 2a and the reason a finding here does not create a commit that only +exists downstream (#2000 → #2092; #2215 → #2216–2224). + +Re-run the affected part of the smoke afterwards and update the ledger; it is +the artifact the maintainers approve the merge on. + +## 3. Tag and publish the Release + +**Normally this is done by a maintainer through the GitHub UI**, after PR 2 has +merged: *Releases → Draft a new release → Choose a tag → type the bare `x.y.z` +→ Create new tag on publish*, with **Target: `main`**, then generate the notes +and publish. Publishing the Release is what fires the `publish` and +`publish-github-container-registry` jobs. + +The equivalent by hand, for when the UI is not an option — derive the tag from +the version that just landed rather than typing one, since a hard-coded tag is +either already taken (so `git tag` aborts) or, worse, wrong: ```sh git fetch origin main @@ -122,13 +215,15 @@ resolves through whatever merge-or-rebase strategy you have configured, so a divergent local `main` can quietly produce or replay local commits. Tagging `HEAD` there tags a commit that is not on `origin/main`, and `git push origin ` pushes only the tag — leaving a release whose commit was never published. +The UI path avoids this by construction: the target is `main` itself. ⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — which is why -the command above tags `$VERSION` and not `v$VERSION`. npm's own `tag-version-prefix` defaults to `v` and the repo -sets no `.npmrc`, so a bare `npm version` would have produced a mismatched tag; -tagging by hand is what keeps it right. (The workflow's assert step strips a -leading `v` before comparing, so a `v`-prefixed tag would still publish — it -would just be inconsistent with every previous release.) +the command above tags `$VERSION` and not `v$VERSION`, and why the tag typed +into the UI carries no prefix either. npm's own `tag-version-prefix` defaults to +`v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a +mismatched tag; tagging by hand is what keeps it right. (The workflow's assert +step strips a leading `v` before comparing, so a `v`-prefixed tag would still +publish — it would just be inconsistent with every previous release.) The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying the v2 workflow.