ci: move the npm release into GitHub Actions - #137
Conversation
Move the release off a laptop and into CI, in two phases: - release.yaml keeps a version-bump PR open on main as changesets land, applying `changeset version` and opening the PR under the existing GitHub App identity so it triggers the check workflow. - publish.yaml is the release button: dispatch from main, re-run the full check suite, then publish behind an approval on the `npm-publish` environment. npm auth is a granular token rather than OIDC trusted publishing: `changeset publish` shells out to `pnpm publish` in a pnpm workspace, and pnpm 11 supports neither trusted publishing nor --provenance. The token is expanded from the environment when npm reads ~/.npmrc, so it is never written to disk. Tags are created as annotated tag objects through the GitHub API, which matches the tags published so far and avoids persisting a push-capable git credential in the checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pnpm run Clean` fails with ERR_PNPM_NO_SCRIPT -- the script is `clean`. With no `set -e` the failure was swallowed and the release continued without cleaning. Fix the name, add `set -eu`, and forward arguments so a one-time password can be passed for account-level 2FA. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add RELEASING.md covering the two-phase changesets flow, the one-time NPM_TOKEN and environment setup, why OIDC trusted publishing is not available on this toolchain, and the local fallback. Point AGENTS.md and the contributing steps at it, and tell contributors to add a changeset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WalkthroughChangesRelease automation
Estimated code review effort: 4 (Complex) | ~60 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/scripts/unpublished-packages.mjs:
- Around line 70-80: Update .github/scripts/unpublished-packages.mjs lines 70-80
to derive the release version from the unpublished package records, verify that
all records share one version, and fail when multiple versions are present; do
not use the umbrella package version. Update .github/workflows/publish.yaml
lines 208-234 to generate release metadata from this verified published-package
version, with no direct change required to other sites.
In @.github/workflows/publish.yaml:
- Around line 68-77: Update the publish workflow around the “Refuse if there is
nothing to publish” gate to record the expected release state before running
changeset publish. When unpublished count is zero, check whether the
corresponding tags and GitHub release exist; skip npm publication and continue
with repair when either is missing, and reject only when npm, tags, and the
GitHub release are all complete.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ead8de9c-1b3a-4214-a3e8-ee26edb1efd3
📒 Files selected for processing (7)
.github/scripts/unpublished-packages.mjs.github/workflows/publish.yaml.github/workflows/release.yamlAGENTS.mdCONTRIBUTING.mdRELEASING.mdbin/release
| // Every package is version-locked by the `linked` group in | ||
| // .changeset/config.json, so the umbrella package's version names the release. | ||
| const umbrella = results.find((result) => result.name === "agentcommercekit") | ||
|
|
||
| if (process.env.GITHUB_OUTPUT) { | ||
| appendFileSync( | ||
| process.env.GITHUB_OUTPUT, | ||
| [ | ||
| `count=${unpublished.length}`, | ||
| `packages=${unpublished.map((result) => result.name).join(" ")}`, | ||
| `version=${umbrella?.version ?? ""}`, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
jq '{fixed, linked}' .changeset/config.json
fd -0 '^package\.json$' packages | while IFS= read -r -d '' manifest; do
jq -r --arg manifest "$manifest" \
'[$manifest, .name, .version] | `@tsv`' "$manifest"
doneRepository: agentcommercekit/ack
Length of output: 725
🌐 Web query:
For @changesets/cli2.31.0, does alinkedgroup publish every member on each release? Contrastlinkedandfixed package groups using version-specific documentation.
💡 Result:
For @changesets/cli 2.31.0, a linked group does not publish every member on each release [1][2]. The behavior of linked and fixed package groups is contrasted as follows: Linked Packages: The linked configuration option allows you to specify groups of packages that should share a version [3][1]. Packages within a linked group are only version-bumped and published if they have associated changesets (either direct changes or as a result of being a dependent) [1][2]. When a package in a linked group is released, it is versioned to the highest current version in that group plus the highest bump type from the changesets within the group [1][2]. There is no guarantee that all packages in the group will be published together; only those with active changesets are processed [1][2]. Fixed Packages: The fixed configuration option declares that packages must be version-bumped and published together, regardless of whether individual members have received changes [4][5]. Unlike linked packages, all packages in a fixed group are updated to the same version whenever a release is triggered for the group, ensuring that they remain in sync even if some members have no new changes [4][5]. In summary, linked packages synchronize versioning logic for packages that have changes, while fixed packages enforce a strict, all-or-nothing release requirement for the entire group [1][4].
Citations:
- 1: https://changesets-docs.vercel.app/linked-packages.html
- 2: https://github.com/changesets/changesets/blob/main/docs/linked-packages.md
- 3: https://changesets-docs.vercel.app/config-file-options.html
- 4: https://changesets-docs.vercel.app/fixed-packages.html
- 5: https://github.com/changesets/changesets/blob/main/docs/fixed-packages.md
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
echo "== script outline =="
ast-grep outline .github/scripts/unpublished-packages.mjs --view compact || true
echo
echo "== relevant script lines =="
cat -n .github/scripts/unpublished-packages.mjs | sed -n '1,140p'
echo
echo "== workflow publish relevant lines =="
cat -n .github/workflows/publish.yaml | sed -n '180,250p'
echo
echo "== changeset files =="
git ls-files '.changeset/*' | sort | while IFS= read -r f; do
echo "--- $f"
cat -n "$f"
done
echo
echo "== package change logs/changelog mentions =="
rg -n "CHANGELOG|agentcommercekit|`@agentcommercekit`" -S packages .changeset 2>/dev/null | head -n 80Repository: agentcommercekit/ack
Length of output: 16114
Use the version of the packages being published.
A Changesets linked group does not publish every member on each release. Packages in a linked group are versioned only when they have active changesets, so the agentcommercekit version can stay stale while a scoped package publishes a newer version.
.github/scripts/unpublished-packages.mjs#L70-L80: derive the release version from theunpublishedpackage records and fail if one run contains multiple release versions. Use afixedgroup if every package must always publish together..github/workflows/publish.yaml#L208-234: create release metadata from the verified published-package version, not the umbrella manifest version.
📍 Affects 2 files
.github/scripts/unpublished-packages.mjs#L70-L80(this comment).github/workflows/publish.yaml#L208-L234
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/scripts/unpublished-packages.mjs around lines 70 - 80, Update
.github/scripts/unpublished-packages.mjs lines 70-80 to derive the release
version from the unpublished package records, verify that all records share one
version, and fail when multiple versions are present; do not use the umbrella
package version. Update .github/workflows/publish.yaml lines 208-234 to generate
release metadata from this verified published-package version, with no direct
change required to other sites.
| - name: Refuse if there is nothing to publish | ||
| env: | ||
| COUNT: ${{ steps.unpublished.outputs.count }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ "$COUNT" = "0" ]; then | ||
| echo "Refusing: every workspace package version is already on npm." >&2 | ||
| echo "Merge the version PR from the Release workflow first." >&2 | ||
| exit 1 | ||
| fi |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Allow recovery after npm publication succeeds.
If changeset publish succeeds and a later tag or release call fails, a rerun sees zero unpublished versions after npm registry visibility catches up. This gate then exits before it can create the missing tags or GitHub release.
Track the expected release state before the npm write. If no npm publication remains but tags or the release are absent, skip changeset publish and repair the remote release state. Reject only when both npm and GitHub release state are complete.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/publish.yaml around lines 68 - 77, Update the publish
workflow around the “Refuse if there is nothing to publish” gate to record the
expected release state before running changeset publish. When unpublished count
is zero, check whether the corresponding tags and GitHub release exist; skip npm
publication and continue with repair when either is missing, and reject only
when npm, tags, and the GitHub release are all complete.
Changes
mainnpm-publishenvironmentbin/releasecalls the correct clean script and stops on errorRELEASING.mdexplains the flow and the one-time setupProblem
The release ran only from a laptop.
pnpm run publish:packagesneeded aninteractive npm session, and account 2FA is set to
auth-and-writes. The lastrelease stopped halfway. Three packages went to npm and five did not. A second
run fixed it, but nothing checked the tree before the publish, and nothing
recorded who approved the release.
bin/releasealso calledpnpm run Clean. The script isclean, so the commandfailed with
ERR_PNPM_NO_SCRIPT. The file had noset -e, so the releasecontinued and skipped the clean step.
Solution
The release now runs in two phases, as changesets intends.
release.yamlruns on each push tomain. It applieschangeset versionandopens a version-bump PR on the
changeset-release/mainbranch. The PR uses theGitHub App identity that
audit-fix.yamlalready uses, so the check workflowruns on it. The job stops early when no changesets are pending.
publish.yamlis the button. You dispatch it frommainafter the version PRmerges. The
verifyjob refuses a dispatch from any other branch, lists thepackage versions that npm does not have, refuses to continue when that list is
empty, and then runs the full check suite and
pnpm audit signatures. Thepublishjob needs an approval on thenpm-publishenvironment before it writesto the registry.
npm authentication uses a granular access token, not OIDC trusted publishing.
changeset publishcallspnpm publishin a pnpm workspace, and pnpm 11supports neither trusted publishing nor
--provenance. It supports only--otp.OIDC would need the workflow to pack tarballs and publish them with
npmdirectly, which bypasses the changesets publish path. The token is written to
~/.npmrcas the literal text${NPM_TOKEN}. npm and pnpm expand it when theyread the file, so the secret never goes to disk.
The publish job creates tags through the GitHub API instead of
git push. Nopush-capable git credential stays in the checkout. The tags are annotated tag
objects, which matches every tag this repository has published.
Testing
actionlintpasses on all four workflow files.pnpm run check:formatandpnpm run knippass.node .github/scripts/unpublished-packages.mjsruns against the live registry.It correctly reports all eight packages at 0.11.0 as published, and writes
count,packages, andversiontoGITHUB_OUTPUT.${NPM_TOKEN}from a user-level~/.npmrc.pnpm audit signaturesworks on this workspace.git/ref/tags/API accepts scoped tag names such as@agentcommercekit/vc@0.10.1.The workflows cannot run end to end until the one-time setup is complete. See
RELEASING.md:npm-publishenvironment. Add required reviewers. Limit thedeployment branches to
main.NPM_TOKENas an environment secret onnpm-publish. It must be anautomation-class granular token, because a token that prompts for a one-time
password cannot work unattended.
AI usage
Claude Code (Opus 5) wrote the workflows, the pre-flight script, and the
documentation in this PR. I directed the design and reviewed each file.
Summary by CodeRabbit
New Features
Documentation
Bug Fixes