onsystem is published to npm and the MCP Registry, and its GitHub Action tagged, from .github/workflows/release.yml, never from a laptop.
- A pull request with a changeset merges to
main. The release workflow opens or updates the release pull request,chore: release:changeset versionmoves the version and writesCHANGELOG.md, andscripts/sync-versions.mjscopies the version intoserver.jsonand the plugin packages (CONTRIBUTING.md has the list). - CI runs on the release pull request as on any other (see the release GitHub App). Merge it once its required checks pass, or let it merge by itself (
RELEASE_AUTO_MERGE, below). - The merge runs the release workflow again. With no changesets left, it publishes to npm what npm doesn't have yet, with provenance (npm trusted publishing, no token), and
changesets/actioncreates the tagonsystem@X.Y.Zand its GitHub release. Then the workflow creates the tagvX.Y.Zand its GitHub release, movesv0to the release, and publishesserver.jsonto the MCP Registry.
On a push to main, the workflow runs these jobs, one after the other:
- Validate the package, with read permissions:
pnpm typecheck,pnpm build,pnpm smoke,pnpm testandpnpm pack-test, on the commit the next job versions or publishes. - Version or publish, the only job that can write or publish (its
id-tokenis for npm trusted publishing and the MCP Registry login):changesets/action, which opens the release pull request or runspnpm release(pnpm build, thenchangeset publish). It runs no tests, and installs without the pnpm store cache, which the jobs that run the dependencies' code save. Then:- with changesets pending,
node scripts/check-published.mjs npmchecks that npm has the version onmain, or that the version has its tag, since npm can take a few minutes to serve a new version (see release day). The version is read beforechangesets/action, which switches to the release branch and versions the package there. When the check fails, the steps after it, the merge step included, don't run; - with none,
node scripts/check-published.mjs releasegives the version onmainwhat it lacks, if npm has it: the tagonsystem@X.Y.Zand its GitHub release, with the version'sCHANGELOG.mdentry; the tagvX.Y.Zand its GitHub release; andv0on the version's commit, while npm's latest is that version. Each goes on the commit the version was published from: the oneonsystem@X.Y.Znames, the run's own commit when the run published the version, or else the commit npm's provenance for it names. Thennode scripts/check-published.mjs registrysays whether the MCP Registry needs the version (not when it lists it already, nor while npm's latest is another version), and when it does,mcp-publisherpublishesserver.json, once its archive matches the sha256 in the workflow (MCP_PUBLISHER_SHA256).
- with changesets pending,
- Run the release from npm and the Action at v0, with read permissions, after a Version or publish job that found no pending changesets:
npx -y onsystem@X.Y.Z --versionprints the version,v0'spackage.jsonnames npm's latest, anduses: dgesteves/onsystem@v0passes the demo's clean page.
Every action is pinned to a commit, with its version in a comment, and Dependabot moves both. The one exception is dgesteves/onsystem@v0 in the last job, which checks what @v0 users get.
The changelog entries come from scripts/changelog.mjs, which wraps @changesets/changelog-github: each entry links its pull request and its commit, and thanks whoever wrote the changeset, except the maintainers named in .changeset/config.json. The release pull request and the GitHub release show the same entries.
uses: dgesteves/onsystem@v0 runs the action at the commit v0 names, and the action runs the project's install of onsystem, or without one the CLI version in its package.json, so v0 follows each release. vX.Y.Z never moves, for whoever pins a version. The changesets tag can't serve as either: a uses: ref is split on @ into two parts, so the Marketplace's "Use latest version" on the release onsystem@0.6.0 gave uses: dgesteves/onsystem@onsystem@0.6.0, which doesn't run.
The release vX.Y.Z is the one for the Marketplace. Its notes say how to pin it, and link the onsystem@X.Y.Z release. Publishing a release to the Marketplace is a box on the release's page, which the workflow can't tick: after each release, edit the vX.Y.Z release, tick "Publish this Action to the GitHub Marketplace" (GitHub may ask for 2FA), leave "Set as the latest release" as it is, and update it.
onsystem@X.Y.Z is the latest release, and vX.Y.Z is created without that mark. If the Marketplace's "Use latest version" still gives the onsystem@ tag, add a repository variable RELEASE_ACTION_LATEST set to true: from the next release on, vX.Y.Z is created as the latest release instead. For a release already out: gh release edit vX.Y.Z --latest.
The pull_request runs of a push made with the workflow's own token wait for approval, and the checks of a CI run the workflow starts itself (gh workflow run ci.yml) don't count for the pull request. So before the App, each release pull request needed an empty chore: run CI commit before main would take it (#49, #70, #85, #109). A push made with the App's token runs CI as any other push does.
The App has two repository permissions, Contents and Pull requests, read and write, and is installed on this repository. Two repository secrets hold it: RELEASE_APP_ID, its client ID (or app ID), and RELEASE_APP_PRIVATE_KEY, a private key generated on its settings page. When both are set, the workflow uses the App's token, and the version commit, the release pull request, the tags and the GitHub releases are the App's. To rotate the key, generate a new one on the App's page, replace RELEASE_APP_PRIVATE_KEY, then delete the old key there.
Without the secrets, the workflow uses its own token, as before. It starts CI on the release branch itself, but GitHub doesn't count those checks for the pull request: on the pull request, approve the CI run that waits for approval, or push an empty commit to changeset-release/main.
The ruleset on main asks for approval of unattributed changes (require_extra_approval_for_unattributed_changes). On the first release pull request the App pushes, check whether it waits for an approval: if it does, approve it, or turn that setting off, before turning on RELEASE_AUTO_MERGE.
To have each release pull request merge by itself once its CI passes, allow auto-merge (Settings, General) and add a repository variable RELEASE_AUTO_MERGE set to true. A merged changeset then reaches npm as soon as the release pull request's CI passes, so leave it off while releases are timed by hand (release day). The merge step runs after the check that npm has the version on main, and only when that check passes: a release pull request never merges by itself over a version that never reached npm. With the variable set and auto-merge not allowed, the merge step fails, after the release pull request is updated and npm is checked.
Run the workflow by hand on a branch: Actions, Release, Run workflow, or
gh workflow run release.yml --ref <branch>It publishes, pushes and creates nothing:
- Validate the package runs as on a push.
- Version or publish doesn't run, and neither does the run from npm and the Action. In their place, Dry run, with read permissions only, gets a read-only token from the App when its secrets are set (if that step fails, the App or its secrets need fixing), then:
- counts the pending changesets as
changeset versiondoes. With some, it checks that npm has the version on the branch (check-published.mjs npm). With none, as right after a release, it says what a run would create or move for the version on the branch, and whether the MCP Registry needs it (check-published.mjs release --dry-runandregistry --dry-run); - with pending changesets only, runs
pnpm version-packagesand prints the diff the release pull request would have, changelog entries included; - builds and packs the package in the runner, and prints its name, its version and how many files it has;
- downloads the
mcp-publisherarchive and checks it againstMCP_PUBLISHER_SHA256, without running it.
- counts the pending changesets as
A dry run only reads, so it doesn't queue with the releases: one started on main neither replaces nor waits for the run of a push to main. Dry runs of the same branch run one at a time.
Do one after any change to the release workflow, before the next release.
The workflow runs one release at a time. A run that waits behind another is replaced when a newer push to main arrives, and that can lose a version: if the run for the release pull request's merge is replaced by the run of a pull request with a changeset, that run only updates the release pull request, and the merged version never reaches npm.
So, before merging the release pull request, wait for any Release run in progress to finish. After merging it, merge nothing else until its Release run has started. If it is replaced anyway, the next run's "The version on main is on npm" step fails and names the version: re-run the replaced run, as below.
pnpm 11 installs only versions published at least a day ago by default (minimum-release-age, 1440 minutes in pnpm 11.6.0), and the plugin runs the exact version it was released with (docs/troubleshooting.md). Release more than 24 hours before anything points people at the new version.
Re-running a Release run is safe: changeset publish publishes only the versions npm doesn't have, the tags and releases step creates only what is missing, and the registry step publishes only a version the MCP Registry doesn't list.
-
The Release run for the release pull request's merge was cancelled, or a later run's "The version on main is on npm" step fails. Open the run for the merge commit (Actions, Release) and re-run all its jobs. It checks out that commit, publishes its version, then creates its tags and releases, moves
v0and publishes to the MCP Registry. -
npm has the version, but a tag, a GitHub release or the
v0move is missing. Re-run the run: it publishes nothing, and creates or moves what is missing. The next push tomaindoes it too. Either way, the tags go on the commit the version was published from, not on the commit of the run. Without a tagonsystem@X.Y.Zor provenance (https://registry.npmjs.org/-/npm/v1/attestations/onsystem@X.Y.Z), the step warns and creates nothing: create them by hand at the merge commit of the release pull request, with the version'sCHANGELOG.mdentry as the notes ofonsystem@X.Y.Z:gh release create onsystem@X.Y.Z --target <merge commit> --title onsystem@X.Y.Z --notes-file <entry> gh release create vX.Y.Z --target <merge commit> --title vX.Y.Z --latest=false --notes "Release notes: https://github.com/dgesteves/onsystem/releases/tag/onsystem%40X.Y.Z" gh api -X PATCH repos/dgesteves/onsystem/git/refs/tags/v0 -f sha=<merge commit> -F force=true
-
"Does the MCP Registry need this version" fails, saying npm doesn't serve the version yet. The registry only lists a version npm serves. Once
npm view onsystem@X.Y.Z versionprints it, re-run the job. -
"Publish to the MCP Registry" fails at
sha256sum. The archive isn't the one the checksum was taken from: don't run it. To move to anothermcp-publisher, changeMCP_PUBLISHER_VERSIONandMCP_PUBLISHER_SHA256together, from that release'sregistry_<version>_checksums.txt, and do a dry run. -
The tags step warns that
vX.Y.Znames another commit. Someone created the tag by hand. The workflow never moves a version's tag; check that it names the commit ofonsystem@X.Y.Z, or delete it so the next run creates it there. -
Validate the package fails on
main. Nothing was versioned or published. Fixmain, and the next push runs the release again. -
"Run the release from npm and the Action at v0" fails. npm doesn't serve the version yet (re-run the job),
v0doesn't name npm's latest (re-run the Version or publish job), or the Action atv0fails the demo's clean page: every@v0user gets that, so roll back, below. -
"Get a token from the release GitHub App" fails. The App isn't installed on this repository, or a secret is wrong. Fix it and re-run, or delete both secrets to release with the workflow's own token.
npm never takes the same version twice, and unpublishing breaks the installs that have it. A broken onsystem@X.Y.Z stays on npm, so point everything back at the version before it, then release the fix as a patch changeset, as usual.
-
On npm, logged in as an owner of the package, make the version before it the latest, then deprecate the broken one:
npm dist-tag add onsystem@<previous> latest npm deprecate onsystem@X.Y.Z "<what is broken, and the version to use>"
Do this first. The release workflow moves
v0, and publishes to the MCP Registry, only while npm's latest is the version onmain, so from now on it leaves both alone. -
Put
v0back on the previous release's commit.vX.Y.Zstays, for whoever pinned it.gh api -X PATCH repos/dgesteves/onsystem/git/refs/tags/v0 -F force=true \ -f sha="$(gh api repos/dgesteves/onsystem/commits/onsystem%40<previous> --jq .sha)" -
If the MCP Registry lists the broken version, deprecate it there, from a machine with
mcp-publisherlogged in (mcp-publisher login github):mcp-publisher status --status deprecated --message "<what is broken, and the version to use>" io.github.dgesteves/onsystem X.Y.Z -
The Claude Code plugin and the packages for other agents pin
onsystem@X.Y.Zexactly, andnode scripts/sync-versions.mjs --checkkeeps them at the version inpackage.json: they move with the next release. Release the fix as a patch changeset. Its release makes the fix npm's latest, and the workflow movesv0to it and publishes it to the MCP Registry.