Releasing
The two-click release flow, prereleases, hotfixes, the first publication of a new package, and what to do when publishing fails.
Releases use Changesets. The maintainer does two things: merge the Version packages pull request, then approve the publish.
The flow
- Every pull request that changes published behavior adds a changeset
(
pnpm changeset). Agents do this as part of the change. After merge,
release.ymlkeeps one open Version packages pull request up to date. It contains the new versions and the generatedCHANGELOG.md. No formatter runs on it, so keepCHANGELOG.mdout of formatters and linters that check Markdown. Two jobs build it:- Prepare version installs dependencies and runs
changeset versionwith a read-only token. It only uploads the result as a patch. - Version packages installs and runs nothing from the repository. It
rejects the patch if it changes anything in a
package.jsonbesides its version and the ranges of the repository's own packages, or touches anything other thanCHANGELOG.mdfiles and changeset files (not.changeset/config.json). Then it force-pusheschangeset-release/mainand opens or updates the pull request withgh. A last small job startscion it.
- Prepare version installs dependencies and runs
- Click 1: merge the Version packages pull request. Look at its diff
first:
package.jsonfiles should change only versions (and the ranges of the repository's own packages), plusCHANGELOG.mdfiles and.changeset/. Anything else means stop: close it and find out what pushed it. Read the new changelog sections too: they must list only changes since the last release. If itscirun waits for approval, approve it under Actions; see Troubleshooting. release.ymlbuilds and packs every package whose new version is not on npm yet, and rehearses the publish withnpm publish --dry-run. This job has no secrets.- Click 2: approve the deployment to the
npmenvironment in the Actions run. Read the run's summary first: "Release files unchanged since vX.Y.Z" means approve. "Release files changed" lists changed workflows,scripts/release.mjsor.changeset/config.json; look at each change before you approve. A changed release workflow is how a compromised dependency, app or agent would try to publish something else. The comparison starts at the tag of the version npm has now, and the tag ruleset keeps that tag from moving. You can approve beforecion the merge commit has finished: the publish job waits up to an hour for it. Approve one release at a time; when two runs wait, approve the newest one. An older run then fails its stale-version check and publishes nothing. - The publish job downloads the packed tarballs and runs
npm publish <tgz> --provenance --access public --ignore-scripts. It does not check out the repository and does not install anything, so no dependency code runs while it can publish. Authentication is npm trusted publishing (OIDC); there is no token. - The last job creates the Git tag on the commit that was packed and a GitHub release with the changelog section. If the tag already exists on another commit, the job fails instead.
release.yml offers a release only when no non-empty changesets are pending
on main and no Version packages pull request is open. It never packs main
ahead of its versions. A Version packages pull request left open after its
changesets were removed blocks releases until you close it.
To not release, leave the Version packages pull request open. To reject a
release after merging, reject the deployment; nothing is published. The
unpublished version is offered again by the next push to main that has no
pending changesets, or at once when you run release.yml by hand (Actions →
release → Run workflow on main). A manual run follows the same rule: it
re-offers a release that is already versioned on main, and does nothing
else while changesets are pending.
release.yml finds packages through pnpm-workspace.yaml. Fixed-version
package sets use a Changesets fixed group, so all packages in the set share
one version, one vX.Y.Z tag and one GitHub release. Packages that version
independently get one name@X.Y.Z tag and GitHub release each.
Writing a changeset
One summary line that a user of the library can understand. Present tense. Start with Fix, Add, Remove or Change.
---
'@lupinum/example': minor
---
Add a `delay` option to `useTour()`.A short body may follow after a blank line, for details users need. A breaking
change (a major bump, or any breaking change before 1.0) adds a Migration:
line:
---
'@lupinum/example': major
---
Remove the deprecated `useLegacyTour()` composable.
Migration: replace `useLegacyTour()` with `useTour()`.CI lints new changesets. When published code changed, CI also runs
changeset status against main, which fails if no changeset exists. For a
change users cannot notice (tests, internal refactors), add an empty one with
pnpm changeset --empty. A change to dependencies or peerDependencies of a
published package needs a changeset that bumps that package, at least patch;
an empty one does not count (see Dependencies).
When a contributor's pull request has no changeset, the maintainer, or an
agent the maintainer asks, pushes one to the contributor's branch (Allow
edits by maintainers makes this work for forks). Otherwise ask the
contributor to run pnpm changeset.
Previews
preview.yml publishes every pull request to
pkg.pr.new and comments install commands. It runs only for
branches in the repository, not for forks. Use it to try a change in a real
project before release.
Prereleases
pnpm changeset pre enter next # following Version packages PRs produce x.y.z-next.n
pnpm changeset pre exit # back to stable versionsUse next as the prerelease name. A prerelease line that started with
another name, such as beta, keeps it until its stable release; the
prerelease after that uses next.
Commit the command's change through a normal pull request. After pre exit
is merged, release.yml opens the Version packages pull request with the
stable versions, even when no new changeset is pending. Versions that
contain - publish under the next dist-tag, and their GitHub release is
marked as a prerelease. Stable versions publish under latest.
After pre exit and the stable release, next still points at the last
prerelease. The maintainer removes it, or points it at the new stable version
(2FA):
npm dist-tag rm @lupinum/example next
# or: npm dist-tag add @lupinum/example@1.0.0 nextThe audit warns while next is behind latest.
Hotfix
A hotfix is a normal release: a pull request with the fix and a patch
changeset, then the two clicks. Our own code has no waiting period. Only new
third-party dependency versions wait 24 hours; see
Dependencies.
We fix the latest major only, as .github/SECURITY.md says. The standard does not
automate releases from an older major. If one needs a fix anyway, record a
decision in internals/decisions.md and set up a branch for it, for example v1 from
the last v1 tag:
- In that branch's
release.yml: replace everymainwithv1(the trigger, the job condition, and in the version jobs the branch check, the push tochangeset-release/v1, thegh prbase and thegh workflow runref), and use the dist-tagv1-latestinstead oflatestin the pack and publish steps. - In that branch's
.changeset/config.json:"baseBranch": "v1". - A ruleset for
v1like the one formain, andv1in thenpmenvironment's deployment branches.
Remove the deployment branch when the fix is out. The audit fails while the
environment allows more than main, as a reminder.
First publication of a new package
npm can only attach a trusted publisher to a package that exists, so the very first version of a package is published by hand. That version has no provenance. Every later version does.
The run that packs it is the first push to main for a new repository (the
starting version, such as 0.1.0, is not on npm yet), or the merge of the
Version packages pull request that adds a package to an existing repository.
- Reject the
npmdeployment of that run. Download its
releaseartifact and publish only the tarballs of packages that do not exist on npm yet, with your 2FA code:bash gh run download RUN_ID --repo OWNER/REPO --name release --dir release npm publish ./release/PACKAGE.tgz --access public --ignore-scripts --tag latestUse
--tag nextif the first version is a prerelease.- Configure trusted publishing and publishing access for the new package as in Set up a repository.
If the artifact also held packages that already existed on npm, run
release.ymlby hand onmain(Run workflow) while no changesets are pending, and approve the deployment. It skips the version you published, publishes the others with provenance and creates the tag and GitHub release. If every package was new, create the release by hand from the artifact, with the commit that run built (repeat with.[1]and so on ifreleases.jsonhas more entries):bash gh release create "$(jq -r '.[0].tag' release/releases.json)" --repo OWNER/REPO \ --target COMMIT_SHA --notes "$(jq -r '.[0].notes' release/releases.json)" \ $(jq -r 'if .[0].prerelease then "--prerelease" else empty end' release/releases.json)
Publish the CI-built tarball, not a local build, so the bytes still come from CI.
When publishing fails
Read the first failing step. Then:
| Situation | What to do |
|---|---|
| Pack job failed | Nothing was published. Fix the cause in a pull request. After merge, release.yml packs the versions that are not on npm yet. You can also start it by hand on main (Run workflow) once no changesets are pending. |
E403/E404 from npm publish with OIDC | The trusted publisher does not match. Fix it on npmjs.com (see Troubleshooting), then Re-run failed jobs. |
| Some packages published, some not | Re-run failed jobs on the same run, not a new run. It publishes the remaining tarballs built with the others and skips versions already on npm. A new run would pack the missing packages again from the current main. Packages publish in dependency order, so a dependent is never on npm without the sibling it pins. |
| "cannot publish over the previously published version" | First check whether the version is already there: npm view @lupinum/example@1.2.3 version. Right after a publish, npm can take minutes to show it; wait and Re-run failed jobs, which then skips it. If it is on npm from somewhere else (a hand publish, an older run of different code), npm versions are immutable: add a changeset and release the next version. |
| Version packages job failed: "version.patch changes files it must not change" | Something other than Changesets changed files in the prepare job. Do not work around it. Treat it as a compromised dependency. |
| npm published, tag or GitHub release failed | Re-run failed jobs. It skips a release that already exists. If the tag exists on another commit, find out who created it; never move it. Or create the release by hand with gh release create as in First publication. |
| A published version is broken | Release a fix. Mark the bad version: npm deprecate @lupinum/example@1.2.3 "Broken, use 1.2.4". If needed, point latest back: npm dist-tag add @lupinum/example@1.2.2 latest. Do not unpublish unless it is a security or legal problem. |
A re-run uses the workflow file from the original run. If you changed
release.yml itself, merge the change and let a new run start.
Never add a token, publish a local build, or turn off a required check to get a release out.