diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7e41c4edb..647e3f441 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,13 +1,15 @@ --- name: release -description: Cut a Bobbit release — preflight checks, version bump, signed tag, npm publish (with optional binary sub-packages), GitHub release with generated notes. +description: Cut a Bobbit release — preflight checks, version bump, signed tag (CI publishes the root to npm via OIDC on tag push), optional binary sub-packages, GitHub release with generated notes. argument-hint: [major|minor|patch|] --- Drive an end-to-end release of Bobbit. The maintainer (human) must be at the -keyboard for `npm login` / OTP prompts and to sign the tag — pause and ask -when the flow needs them. **Never** run `npm publish` non-interactively or -behind their back. +keyboard to sign the tag, and for `npm login` / OTP prompts **only when +republishing binary sub-packages** — pause and ask when the flow needs them. +The root `@gresearch/bobbit` package publishes automatically via CI (npm +trusted publishing / OIDC) when the signed tag is pushed; **never** run a +manual root `npm publish`. Single source of truth for release mechanics: [`docs/releasing.md`](../../../docs/releasing.md). This skill orchestrates that doc + version bump + notes + GitHub release. @@ -37,8 +39,8 @@ git rev-parse origin/master # sha we'll release from git tag --sort=-v:refname | head -5 # find previous tag git log --oneline ..origin/master | head # must be non-empty (something to release) node -v # must satisfy engines.node (>=22.19.0) -npm whoami # must succeed; if not -> ask user to `npm login` -gh auth status # must be authed for SuuBro/bobbit +npm whoami # only needed if republishing binary sub-packages (§3); root ships via CI +gh auth status # must be authed for G-Research/bobbit git config --get user.signingkey || echo "NO_SIGNING_KEY" git config --get commit.gpgsign || echo "commit.gpgsign=unset" ``` @@ -50,7 +52,7 @@ so the session/primary worktree state is irrelevant. What matters is that **Stop and ask the user** if any of: - `origin/master` has nothing new since the previous tag (nothing to release), or it isn't the commit they expect to ship. -- `npm whoami` fails — ask them to run `npm login` (and enable 2FA if not already; npm requires OTP for publishes on this scope). +- `npm whoami` fails **and** step 3 will republish binary sub-packages — ask them to run `npm login` (and enable 2FA if not already; npm requires OTP for those sub-package publishes). The root `@gresearch/bobbit` publish needs no npm login — it goes through CI/OIDC. - `gh auth status` not logged in — ask them to run `gh auth login`. - No GPG/SSH signing key configured — confirm whether to proceed with **unsigned** tag or wait until they set one up. Default to waiting. @@ -202,15 +204,19 @@ the last irreversible step before the source and tag become public. Do not create the tag before the squash merge because the pre-merge commit will not be the commit that lands on `master`. -## 7. Publish to npm +## 7. Publish binary sub-packages (only if step 3 bumped binaries) -Only enter this step after the release PR is fully green and mergeable. -**Pause and confirm with the user** before publishing — npm publishes are -irreversible (you can `unpublish` for 72h but the version number is burned -either way). Use `ask_user_choices` with the exact `npm publish` commands -about to run. +**The root `@gresearch/bobbit` package is NOT published here.** It publishes automatically +via the `.github/workflows/release-publish.yml` workflow (npm trusted publishing +/ OIDC, with provenance) when the signed tag is pushed in §8 — there is no manual +root `npm publish`. Skip straight to §8 unless step 3 bumped the binary +sub-packages. -If step 3 bumped binaries: +If step 3 bumped binaries, publish the sub-packages now — **before** the tag push +in §8, so they are on npm before the root that pins them. **Pause and confirm with +the user** first; npm publishes are irreversible (you can `unpublish` for 72h but +the version number is burned either way). Use `ask_user_choices` with the exact +commands: ```bash npm publish ./binaries/binaries-darwin-arm64 @@ -220,21 +226,14 @@ npm publish ./binaries/binaries-linux-arm64 npm publish ./binaries/binaries-win32-x64 ``` -Then the root: - -```bash -npm publish --provenance -``` - Notes: -- `--provenance` attaches a signed npm provenance attestation (sigstore) so users can verify the package came from this repo. Requires the maintainer to be running npm ≥9.5 from a machine where the npm CLI can reach Sigstore. If the env doesn't support it (rare; older corp networks), drop the flag and tell the user the publish is unattested. - `publishConfig.access: "public"` is baked into each sub-package, so `--access public` is not needed. - npm will prompt for OTP — that's the maintainer's job; just wait. - If publish fails after some sub-packages went through, **do not** try to bump+republish under a new version. Re-run `npm publish` on the remaining packages with the same version once the issue is fixed. -## 8. Squash-merge the release PR, then tag it +## 8. Squash-merge the release PR, then tag it (this triggers the npm publish) -Immediately after npm succeeds, squash-merge the already-green release PR. +Once the release PR is fully green and mergeable, squash-merge it. Pin the subject and co-author trailer explicitly: ```bash @@ -252,20 +251,32 @@ git diff --exit-code HEAD "$MERGE_SHA" -- \ ``` Tag the PR's exact squash commit, not the current `origin/master` tip (another -PR may have merged immediately afterward): +PR may have merged immediately afterward). **Pushing the tag triggers the root +npm publish** (`.github/workflows/release-publish.yml`) and is irreversible — +pause and confirm with the user before the `git push`: ```bash git tag -s v "$MERGE_SHA" -m "Bobbit v" git tag -v v -git push origin v +git push origin v # -> release-publish.yml publishes @gresearch/bobbit to npm (OIDC + provenance) ``` If the user explicitly opted out of signing in step 0, use `git tag -a` instead and inspect it with `git show --no-patch v`. -If merging fails after npm publish, stop and repair the same release PR. Do -not change the version, create a second release commit, force-push `master`, -or tag the detached pre-merge commit. +Then watch the publish workflow and confirm it succeeds before moving on: + +```bash +gh run watch "$(gh run list --workflow release-publish.yml --branch v --limit 1 --json databaseId -q '.[0].databaseId')" --exit-status +``` + +If the publish job fails (e.g. transient registry error), **re-run the same +workflow run** for the same version — do not bump the version or re-tag; the +tag is already public and the version number is immutable. + +If merging fails, stop and repair the same release PR. Do not change the +version, create a second release commit, force-push `master`, or tag the +detached pre-merge commit. **Refresh the running dev server** so it picks up the release commit (its local `master` is now behind remote): @@ -294,9 +305,9 @@ In a scratch directory, prove the published artefact actually installs and resol ```bash cd $(mktemp -d) && npm init -y >/dev/null -npm install bobbit@ +npm install @gresearch/bobbit@ ls node_modules/@bobbit/binaries-*/bin/ -node -e "import('bobbit/dist/server/binaries.js').then(m => console.log(m.getFdPath(), m.getRgPath()))" +node -e "import('@gresearch/bobbit/dist/server/binaries.js').then(m => console.log(m.getFdPath(), m.getRgPath()))" ``` Both paths should print a real file. If they're `undefined`, the platform sub-package didn't install — investigate before announcing the release. @@ -319,8 +330,7 @@ the same checkout. Report to the user: - Version + tag + GitHub release URL (`gh release view v --json url -q .url`) -- npm package URL (`https://www.npmjs.com/package/bobbit/v/`) -- Whether provenance was attached +- npm package URL (`https://www.npmjs.com/package/@gresearch/bobbit/v/`) — provenance is attached automatically by CI - Whether binaries were republished, and which versions - Root and packed-consumer audit results (both must be clean) @@ -328,9 +338,9 @@ Report to the user: - **Signed tag, always.** Use `git tag -s`. Only fall back to `-a` if the maintainer explicitly opted out in step 0. - **Signed commit if a signing key is configured.** Add `-S` to `git commit`. Never override `user.name` / `user.email`; never silently disable signing. -- **`--provenance` on the root publish** whenever the environment supports it. -- **OTP is the human's job.** Pause and let them type it; don't try to read it from anywhere. -- **Never `npm publish --force`.** If a republish is genuinely needed, bump the patch version and republish cleanly. +- **The root publish is CI-only.** It runs via `release-publish.yml` (OIDC trusted publishing) on the tag push, with provenance automatic. Never run a manual root `npm publish`. +- **OTP is the human's job** — but only for the binary sub-package publishes. Pause and let them type it; don't try to read it from anywhere. The root publish uses no OTP. +- **Never `npm publish --force`.** If a sub-package republish is genuinely needed, bump the patch version and republish cleanly. If the CI root publish fails, re-run the same workflow run for the same version. - **Never delete a tag that's been pushed.** If you tagged wrong, bump the version and tag again — published version numbers are immutable. - **Use the required squash-merge PR flow.** Never tag the detached release commit; tag the PR's exact `mergeCommit` SHA after verifying its package version and release files. - **One release at a time.** Don't start a second version bump while the previous tag/publish is in flight. diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 000000000..ec2ce0fd0 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,44 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Guard tag matches package.json version + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(node -p "require('./package.json').version")" + [ "$TAG" = "$PKG" ] || { echo "tag v$TAG != package.json $PKG" >&2; exit 1; } + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Install + run: npm ci + + - name: Publish (OIDC trusted publishing) + run: | + echo "node: $(node -v) npm: $(npm -v)" + case "${GITHUB_REF_NAME#v}" in + *-*) DIST_TAG=next ;; + *) DIST_TAG=latest ;; + esac + echo "publishing @gresearch/bobbit to dist-tag: $DIST_TAG" + npm publish --provenance --tag "$DIST_TAG" --loglevel verbose diff --git a/docs/releasing.md b/docs/releasing.md index 1f2ad0abc..729908855 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Releasing Bobbit -This doc covers the Bobbit release checks that cannot be inferred from a normal development checkout: the installed consumer's security report and the bundled `fd`/`rg` binaries. +This doc covers the Bobbit release checks that cannot be inferred from a normal development checkout: the installed consumer's security report and the bundled `fd`/`rg` binaries. The root `@gresearch/bobbit` package itself is published automatically by [`.github/workflows/release-publish.yml`](../.github/workflows/release-publish.yml) (npm trusted publishing / OIDC, with provenance) when a `v*` tag is pushed; only the binary sub-packages below are published manually. ## Required packed-consumer audit @@ -82,17 +82,18 @@ changes. (the build script no longer auto-bumps these). Update the matching pin in the root `package.json` `optionalDependencies` block to the new version. -7. Publish each sub-package, then the root: +7. Publish each sub-package (the root is not published here — it ships via + CI on the tag push): ```bash npm publish ./binaries/binaries-darwin-arm64 npm publish ./binaries/binaries-darwin-x64 npm publish ./binaries/binaries-linux-x64 npm publish ./binaries/binaries-linux-arm64 npm publish ./binaries/binaries-win32-x64 - npm publish ``` - `publishConfig.access: "public"` is baked into each sub-package, so - `--access public` is no longer needed on the CLI. + Do this **before** pushing the release tag, so the sub-packages are on npm + before the root that pins them. `publishConfig.access: "public"` is baked + into each sub-package, so `--access public` is not needed on the CLI. ### Decoupled versioning @@ -145,7 +146,7 @@ npm pack ./binaries/binaries-$(node -e 'console.log(process.platform+"-"+process mkdir /tmp/bobbit-smoke && cd /tmp/bobbit-smoke && npm init -y npm install /path/to/bobbit-*.tgz ls node_modules/@bobbit/binaries-*/bin/ -node -e "import('bobbit/dist/server/binaries.js').then(m => console.log(m.getFdPath(), m.getRgPath()))" +node -e "import('@gresearch/bobbit/dist/server/binaries.js').then(m => console.log(m.getFdPath(), m.getRgPath()))" ``` ### Docker sandbox diff --git a/package-lock.json b/package-lock.json index 8cc12b024..be5eb5966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "bobbit", + "name": "@gresearch/bobbit", "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "bobbit", + "name": "@gresearch/bobbit", "version": "0.15.0", "dependencies": { "@earendil-works/pi-agent-core": "0.81.1", diff --git a/package.json b/package.json index 83cccc729..bd6486f0b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,18 @@ { - "name": "bobbit", + "name": "@gresearch/bobbit", "version": "0.15.0", "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/G-Research/bobbit.git" + }, + "homepage": "https://github.com/G-Research/bobbit#readme", + "bugs": { + "url": "https://github.com/G-Research/bobbit/issues" + }, + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=22.19.0" }, diff --git a/tests2/core/release-skill-preflight-order.test.ts b/tests2/core/release-skill-preflight-order.test.ts index 1d80923a8..47eb06946 100644 --- a/tests2/core/release-skill-preflight-order.test.ts +++ b/tests2/core/release-skill-preflight-order.test.ts @@ -13,6 +13,7 @@ import { const skill = readFileSync(resolve(process.cwd(), ".claude/skills/release/SKILL.md"), "utf8"); const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")) as { + name?: string; scripts?: Record; }; const preflight = skill.match(/## 2\. Pre-flight quality gates[\s\S]*?```bash\n([\s\S]*?)\n```/)?.[1]; @@ -33,6 +34,21 @@ function position(command: string): number { return index; } +describe("release skill scoped package identity", () => { + it("verifies and reports the package published by CI", () => { + assert.equal(packageJson.name, "@gresearch/bobbit"); + assert.match(skill, /npm install @gresearch\/bobbit@/); + assert.match(skill, /import\('@gresearch\/bobbit\/dist\/server\/binaries\.js'\)/); + assert.match( + skill, + /https:\/\/www\.npmjs\.com\/package\/@gresearch\/bobbit\/v\//, + ); + assert.doesNotMatch(skill, /npm install bobbit@/); + assert.doesNotMatch(skill, /import\('bobbit\//); + assert.doesNotMatch(skill, /npmjs\.com\/package\/bobbit\//); + }); +}); + describe("release skill pre-flight order", () => { it("audits the built tarball consumer before type-checking and tests", () => { assert.equal( diff --git a/tests2/tests-map.json b/tests2/tests-map.json index c99234e19..960f98cf3 100644 --- a/tests2/tests-map.json +++ b/tests2/tests-map.json @@ -940,7 +940,7 @@ }, { "path": "tests2/core/release-skill-preflight-order.test.ts", - "reason": "Pins the clean-release pre-flight order so the build emits dist/server declarations before tsconfig.tests2 follows intentional runtime imports of those artifacts.", + "reason": "Pins the scoped npm identity used by release verification/reporting and the clean-release pre-flight order required by emitted test declarations.", "execution": { "runner": "vitest", "tier": "unit",