Releasing @blbaker/daily-dispatch to npm
The @blbaker/daily-dispatch package in packages/daily-dispatch is published by
GitHub Actions with npm trusted publishing. The repository stores no npm
token, and every release waits on npm as a staged version until a
maintainer approves it with 2FA.
How a release works
- Conventional commits land on
main. Release Please opens or updates a release PR titledchore(main): release X.Y.Z. It bumps.release-please-manifest.json, theversioninpackages/daily-dispatch/package.json, andCHANGELOG.md. - A maintainer merges the release PR. Release Please creates the tag
vX.Y.Zand a GitHub release. - The tag starts
.github/workflows/release.yml. It runs the gates inpackage.yml(the same workflow every pull request runs), then the publish job:- checks that the tag matches the package version, and that the package metadata and the tarball contents are what we intend;
- runs
npm publish --dry-run; - when the repository variable
DD_PUBLISH_ENABLEDistrue, runsnpm stage publish --provenance. GitHub's OIDC token authenticates the job, and npm attaches a provenance statement.
- The staged version stays private until a maintainer approves it.
The app's version in app/app.json is not part of this. scripts/release.sh
owns it.
One-time setup
Do these in order. Steps 1 and 2 need your npm account with 2FA, and npm 11.15.0 or newer on your machine:
npm install -g npm@11.21.1
npm login
1. Create the package name with a throwaway staged version
npm trust only works on a package that already exists, and @blbaker/daily-dispatch
does not exist yet. Staging a throwaway prerelease creates the name. It is
never approved, so nothing is ever published under it.
git clone https://github.com/blbaker/daily-dispatch.git
cd daily-dispatch
pnpm install --frozen-lockfile
pnpm -r build
cd packages/daily-dispatch
npm version 0.0.0-bootstrap.0 --no-git-tag-version
npm stage publish --tag bootstrap --provenance=false
git checkout -- package.json
npm needs --tag for a prerelease version. --provenance=false is needed
here because package.json asks for provenance, and only a CI run can
produce it.
Leave the staged placeholder alone until the first real release is approved
(step 4). Rejecting it now would leave the package with no versions at all,
which unpublishes the name: npm trust then fails with a 404, and npm refuses
to stage anything under the name for 24 hours.
2. Add the trusted publisher
Do this no more than two days before the first release: npm expires a trusted publisher that goes unused for two days.
npm trust github @blbaker/daily-dispatch \
--file release.yml \
--repository blbaker/daily-dispatch \
--environment npm-publish \
--allow-stage-publish \
--yes
npm trust list @blbaker/daily-dispatch
Run it while the placeholder from step 1 is still staged.
npm trust always asks for 2FA, through a link it opens in the browser.
--allow-stage-publish without --allow-publish means a workflow run can
only stage a version, never publish one directly. The same setting is on
npmjs.com under the package's Settings, Trusted publishing.
3. Add the GitHub secret and variable
A tag that Release Please creates with the default GITHUB_TOKEN does not
start other workflows, so release.yml would never run after a merge. Give
Release Please its own token: a fine-grained personal access token for
blbaker/daily-dispatch only, with Contents and Pull requests set to read and
write. Then store it, and turn publishing on:
gh secret set RELEASE_PLEASE_TOKEN --repo blbaker/daily-dispatch
gh variable set DD_PUBLISH_ENABLED --body true --repo blbaker/daily-dispatch
gh secret set prompts for the token value.
Optional, once the repository is public (private repositories need a paid plan for this): require your approval before the publish job starts.
gh api -X PUT repos/blbaker/daily-dispatch/environments/npm-publish --input - <<EOF
{"reviewers": [{"type": "User", "id": $(gh api users/blbaker --jq .id)}]}
EOF
4. Approve the first release
The first release is 0.1.0, the version already in the files. Release Please
would otherwise propose 0.2.0, because the manifest records 0.1.0 as the
current version. The commit that set up this release config carries a
Release-As: 0.1.0 footer to prevent that. Check the release PR's title
before you merge it. If it says anything other than 0.1.0, push this commit
to main and wait for the PR to update:
git commit --allow-empty -m "chore: release 0.1.0" -m "Release-As: 0.1.0"
git push origin main
Merge the release PR, then watch the release run. Each job's result is listed separately:
gh run list --workflow release.yml --limit 1
gh run view RUN_ID --json jobs --jq '.jobs[] | "\(.conclusion)\t\(.name)"'
Replace RUN_ID with the ID from the first command. When publish succeeds,
check the staged version and approve it:
npm stage list @blbaker/daily-dispatch
npm stage view STAGE_ID
npm stage approve STAGE_ID
npm stage view shows the version and file list. Approve only a staged
version you expect. You can also approve on npmjs.com.
Finally, install it like a user would:
cd "$(mktemp -d)"
npm install @blbaker/daily-dispatch
npx @blbaker/daily-dispatch --help
npm audit signatures
npm audit signatures should report a verified attestation for
@blbaker/daily-dispatch.
Now reject the placeholder from step 1. Find the 0.0.0-bootstrap.0 entry in
npm stage list @blbaker/daily-dispatch and run npm stage reject STAGE_ID with its
ID. Afterwards, npm view @blbaker/daily-dispatch versions may list 0.0.0-stage: npm
adds that placeholder itself when it creates a name for staging.
Every later release
Merge the release PR, then approve the staged version with
npm stage list @blbaker/daily-dispatch and npm stage approve STAGE_ID.
What stops an accidental publish
release.ymlruns on a pushedv*tag and nothing else.- The publish job needs the gates job, which calls
package.yml. - The staging step runs only when
DD_PUBLISH_ENABLEDistrue. To turn publishing off, rungh variable delete DD_PUBLISH_ENABLED --repo blbaker/daily-dispatch. - The trusted publisher allows staged publishing only, and each staged version needs a maintainer's 2FA approval.
tools/release/workflows-rules.tsfails CI if a workflow change weakens any of the above.
Checks you can run locally
pnpm install --frozen-lockfile
pnpm -r build
pnpm run release:check
pnpm run release:test
pnpm run release:rehearse
release:checkruns the metadata gate, the tarball gate, and the workflow policy plus actionlint.release:testseeds one violation per rule and checks that each gate fails.release:rehearsepublishes the package to a throwaway registry on 127.0.0.1, installs it by name in an empty directory, and runs the installed CLI. Nothing leaves your machine except the download of the registry server itself.