Release process
This repo ships through a milestone-driven pipeline. The happy path is close the milestone, walk away. This document covers what runs automatically, the boundary contracts between the workflows, and the manual recovery paths if any link in the chain breaks.
Pipeline overview
v2.x milestone closed │ ▼ release.yml ─────────────────────────────► chore/release-2.x.0 PR │ (auto-merge enabled) │ bumps package.json + dates │ CHANGELOG, runs quality gate │ ▼ PR merges to main │ ▼ tag-on-release-merge.yml ───────────────► v2.x.0 tag pushed │ + workflow_dispatch on │ publish-on-tag.yml ▼ publish-on-tag.yml │ quality gate, version sanity check │ npm publish --provenance (OIDC, no NPM_TOKEN) │ gh release create --generate-notes ▼ Published to npm + GitHub ReleaseWorkflows
.github/workflows/release.yml
- Trigger:
milestone: closed(auto) orworkflow_dispatchwith amilestoneinput. - Output: opens
chore/release-<VERSION>PR againstmain, attached to the originating milestone, with auto-merge enabled. - Node: 24 via
actions/setup-node@v4(matchespublish-on-tag). - Milestone attachment: PR is created without
--milestone(the milestone is already closed by the time this runs, andgh pr create --milestoneonly resolves open milestones by title), then attached viagh api -X PATCH .../issues/<PR> -F milestone=<id>.
.github/workflows/tag-on-release-merge.yml
- Trigger: any merged PR whose head branch starts with
chore/release-. - Behaviour: reads the version from
package.json, pushesv<VERSION>, then explicitly dispatchespublish-on-tag.ymlviagh workflow run. - Why the explicit dispatch: GitHub’s anti-recursion rule means
tags pushed using the default
GITHUB_TOKENdo not fire theon: push: tagstrigger of another workflow. Rather than introduce a PAT or GitHub App token, this workflow invokes the publish workflow directly (it already supportsworkflow_dispatchwith ataginput). npm Trusted Publishing via OIDC handles the actual registry authentication on the publish side. - Permissions: needs
contents: write(push tag) +actions: write(dispatch the publish workflow).
.github/workflows/publish-on-tag.yml
- Trigger:
push: tags: [v*.*.*](when a human pushes a tag) orworkflow_dispatchwith ataginput (when invoked bytag-on-release-merge.yml). - Auth: npm Trusted Publishing via OIDC (
id-token: write,environment: npm-publish). No long-livedNPM_TOKENstored in the repo. - Node: 24 via
actions/setup-node@v4(npm 11.x ships with Node 24, which is the minimum for Trusted Publishing).
Manual recovery
If the chain breaks midway, fire the next stage manually with
workflow_dispatch. All downstream steps are idempotent.
| If the failure is at… | Recover with |
|---|---|
release.yml (PR not opened or unmilestoned) | gh workflow run release.yml -f milestone=v2.x |
tag-on-release-merge.yml (tag not pushed) | git tag -a v2.x.0 -m "Release v2.x.0" && git push origin v2.x.0 from main at the merged release commit |
| Publish not invoked after tag push | gh workflow run publish-on-tag.yml -f tag=v2.x.0 |
| Publish ran but failed mid-flight | Fix the root cause, then re-run: gh workflow run publish-on-tag.yml -f tag=v2.x.0 |
publish-on-tag.yml will refuse to publish if op-array@<VERSION> is
already on npm (the npm view pre-flight check), so re-running after
a successful publish is safe — it will simply error out at the
sanity check.
Re-merging a release branch. If a release PR is reverted and re-merged,
tag-on-release-merge.ymlwill fall through to the publish dispatch even when the tag already exists. The dispatch uses--ref "$TAG", so publish runs against the existing tag’s tree, not the new merge commit. If the re-merge introduced new code that should be published, delete the tag (git push --delete origin v2.x.0 && git tag -d v2.x.0) before re-merging so the workflow re-creates it at the new commit.
Versioning rules
- Milestone titles must match
vMAJOR.MINOR(e.g.v2.2). - The minor version closes as
MAJOR.MINOR.0. Patch releases are ad-hoc and not yet automated; cut them by manually bumpingpackage.json, opening achore/release-MAJOR.MINOR.PATCHPR, and lettingtag-on-release-merge.ymltake over. CHANGELOG.mdmust contain an unreleased## [MAJOR.MINOR.0]heading beforerelease.ymlruns; the workflow date-stamps it in place.
Out of scope
dist-tagbranching (latestvsnext): a v3-era concern.- Migrating the broader CI (
main.yml) off mise: only the release-time workflows need to be on the npm-Trusted-Publishing-compatible Node.