Publishing Canary Releases from Pull Requests
Reviewing a library change by reading the diff only goes so far; the real test is installing it into the application that needs it. Canary releases — throwaway prerelease versions published from a pull request — let anyone run npm install your-lib@0.0.0-pr-812-3f9c2a1 and try the exact change, without cloning, building or linking. Done carelessly, canaries leak into latest, collide with real versions, or let untrusted fork code publish under your name — each of which turns a testing convenience into a supply-chain risk. This guide builds a canary workflow with Changesets snapshots (and a plain npm alternative), keeps canaries isolated from stable channels, and secures the publishing step.
What makes a good canary
A canary release should be:
- Unambiguous — its version encodes the pull request and commit, so nobody confuses it with a real release:
0.0.0-pr-812-3f9c2a1or4.2.0-canary.812.3f9c2a1. - Isolated — published under its own dist-tag (
canaryorpr-812), neverlatest, and outside every consumer's normal ranges. - Reproducible — built from a specific commit, so the version maps to exactly one tree.
- Disposable — deprecated or left to age out after the pull request closes.
The channel model behind this is covered in Release Channels and Dist-Tags.
Canaries with Changesets snapshots
Changesets has a snapshot mode designed for this. It turns pending changesets into snapshot versions without consuming them:
# Version every package with pending changesets as a snapshot
pnpm changeset version --snapshot pr-812
# e.g. @acme/ui 0.0.0-pr-812-20260114093022
# Publish with a dedicated tag; do not create git tags for throwaway versions
pnpm changeset publish --tag pr-812 --no-git-tag
Snapshot versions default to 0.0.0-<tag>-<timestamp>, which sorts below every real version, so no consumer's range will ever resolve to a canary by accident. To include the base version or the commit SHA instead, configure the template in .changeset/config.json:
{
"snapshot": {
"useCalculatedVersion": true,
"prereleaseTemplate": "{tag}-{commit}"
}
}
With useCalculatedVersion, a pending minor on 4.1.3 yields 4.2.0-pr-812-3f9c2a1, which communicates what the change will become. Only packages with changesets (and their dependents) are published, so a pull request without a changeset produces no canary — a useful nudge to add one.
Canaries without Changesets
With plain npm, set a unique prerelease version in the job and publish with a tag:
sha=$(git rev-parse --short HEAD)
npm version "0.0.0-pr-${PR_NUMBER}-${sha}" --no-git-tag-version
npm publish --tag "pr-${PR_NUMBER}"
--no-git-tag-version keeps the version change inside the CI workspace; nothing is committed. Run the same build and test steps as the stable release before publishing, so a canary is exactly what the release would contain if the pull request merged unchanged — a canary that skips tests is a convenient way to distribute bugs.
The workflow, secured
Publishing from pull requests needs care: pull request code is untrusted until reviewed, and a workflow that runs it with publish rights hands those rights to anyone who can open a pull request.
name: canary
on:
pull_request:
types: [labeled, synchronize]
permissions:
contents: read
pull-requests: write
id-token: write
jobs:
canary:
# Only same-repository branches, and only when a maintainer applied the label
if: >
github.event.pull_request.head.repo.full_name == github.repository &&
contains(github.event.pull_request.labels.*.name, 'publish-canary')
runs-on: ubuntu-latest
environment: npm-canary
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 24, cache: pnpm, registry-url: https://registry.npmjs.org }
- run: pnpm install --frozen-lockfile
- run: pnpm -r build
- run: pnpm changeset version --snapshot "pr-${{ github.event.number }}"
- run: pnpm changeset publish --tag "pr-${{ github.event.number }}" --no-git-tag
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number,
body: `Canary published: \`npm install @acme/ui@pr-${context.issue.number}\``
})
Key protections:
- No forks. The
head.repo.full_name == github.repositorycondition keeps pull requests from forks out, because their code would run with the job's publishing identity. - Explicit opt-in. A maintainer-applied label means someone reviewed the change enough to publish it.
- A separate environment (
npm-canary) can have its own protection rules and, with trusted publishing, its own trusted publisher entry.
Choosing a version scheme
The version string is the canary's only label once it leaves the pull request, so choose a scheme deliberately.
Zero-based snapshots are the safest default: 0.0.0-... sorts below every real version, so no range and no latest confusion can ever pick one up by accident. Calculated prereleases (4.2.0-pr-812-...) are more informative and still excluded from normal ranges, but if two pull requests both target 4.2.0 their canaries sort by the suffix, which can surprise anyone installing ^4.2.0-0. Always include the commit SHA or a timestamp so two publishes from the same pull request never collide — npm refuses to publish the same version twice, and a collision fails the job on the second push.
Canaries in monorepos
In a monorepo, a change to one package usually affects its dependents too. Changesets' snapshot mode handles this: it versions every package with a changeset plus internal dependents according to updateInternalDependencies, rewrites their internal ranges to the exact snapshot versions, and publishes them together under the same tag. A consumer who installs @acme/react@pr-812 gets the matching @acme/core@pr-812 snapshot through the rewritten dependency range, so the set is coherent. Without Changesets, replicate that behaviour explicitly: compute the affected packages, give them all the same snapshot suffix, rewrite internal ranges to exact versions before packing, and publish them in dependency order. Skipping the rewrite is the common mistake — a canary of @acme/react that still depends on ^5.1.0 of core installs the stable core and does not contain the change being tested.
Cleaning up
Canaries accumulate: every pull request can publish several. They do no harm under their own tags, but tags multiply and the package's version list grows. Periodically remove per-PR tags for closed pull requests (npm dist-tag rm your-lib pr-812) and consider deprecating old canary versions with a message such as "Canary build for PR #812; not for production". Do not unpublish them: consumers testing a canary may have it in a lockfile, and registry policy limits unpublishing.
A scheduled job can do this automatically by listing tags that match pr-*, checking each pull request's state through the source-control API, and removing tags for closed ones. Using a single rolling canary tag instead of per-PR tags keeps the tag list short, at the cost of npm install your-lib@canary pointing at whichever pull request published last — fine for "latest main build" canaries, confusing for per-PR testing.
Worked example: testing a fix in three applications
A bug report comes in from three internal applications using @acme/ui. The fix is a one-line change, but its effect depends on each application's styles. The maintainer opens a pull request, adds a changeset and applies the publish-canary label. The workflow publishes @acme/ui@0.0.0-pr-812-20260114093022 under the tag pr-812 and comments the install command. Each application team installs the canary on a branch, confirms the fix in their own preview environment, and approves. The pull request merges, the stable release goes out as 4.8.3, and the pr-812 tag is removed by the weekly cleanup job.
Prevention and guardrails
- Never publish canaries from fork pull requests.
- Use versions that cannot collide with or outrank real releases, such as
0.0.0-pr-<n>-<sha>. - Publish under a dedicated tag and verify with
npm dist-tag lsin the job. - Clean up per-PR tags after merge or close.
Frequently Asked Questions
Will consumers on ^4.1.0 ever get a canary?
No. Ranges exclude prereleases unless they name a prerelease on the same version, and 0.0.0-... snapshots are below every real version anyway.
Do canaries need provenance? They get it automatically with trusted publishing. Provenance on canaries is useful — it proves which commit and workflow produced the build.
Can I publish canaries to a private registry instead?
Yes, and it is a good option for internal libraries: point the canary job's publishConfig or registry flag at a private registry so experimental builds never appear on the public registry at all.
Should every pull request publish a canary automatically? Usually not. Automatic canaries for every push create many versions nobody installs. An opt-in label, or a comment command restricted to maintainers, publishes only when someone intends to test the build.
What about main-branch canaries?
Publishing a canary for every merge to main under a rolling canary tag gives early adopters the newest unreleased code. It suits projects with frequent contributions and users who want fixes before the next release; use the same version scheme and never let it touch latest.
How do I stop canaries appearing in the package's version list on npm? You cannot hide published versions. Deprecating old canaries with a clear message, and using a private registry for internal libraries' canaries, keep the public picture tidy.
Related
- Release Channels and Dist-Tags describes how canaries fit among other channels.
- Publishing Prereleases with Changesets Pre Mode covers planned prerelease cycles.
- Testing Local Packages with npm link and yalc is the local alternative to canaries.
- Publishing from CI with npm Trusted Publishing secures the canary job's publishing identity.