Releasing
The CLI is the only published artifact (handbill on npm). The Worker is deployed from a checkout, not released.
Publishing from CI uses npm trusted publishing (OIDC): no npm token anywhere, and provenance is attached automatically. Granular access tokens that bypass 2FA are deprecated and lose publishing rights in early 2027, so they are not used here.
One-time setup
- The repository is public. Provenance links the package to the public source and workflow run.
- Publish the first version by hand. npm only lets you configure a trusted publisher on a package that already exists, so the very first version is published locally with your own account and 2FA:
This version has no provenance attestation; every later one will.
Terminal window npm login # browser + 2FAbun installbun run --cwd apps/cli buildcd apps/cli && npm publish --access public - Configure the trusted publisher. npmjs.com → the
handbillpackage → Settings → Trusted Publisher → GitHub Actions:- Organization or user:
viktoravelino - Repository:
handbill - Workflow filename:
release.yml(exact, case-sensitive) - Environment:
npm - Allowed actions:
npm publish
- Organization or user:
- Create the
npmenvironment (repository Settings → Environments) with “Deployment branches and tags” set to themainbranch and thev*tag pattern, and no required reviewers. The publish job runs under it, so a run dispatched from any other ref cannot mint the OIDC token npm expects, whatever that ref’s copy of the workflow says. No secrets live there. - Tag the version you just published so the GitHub release exists too. The workflow sees it is already on npm, skips the publish, and creates the release:
Terminal window git tag v0.1.0 && git push origin v0.1.0
Every later release
Two commands, with the maintainer’s merge in between:
scripts/release.sh bump 0.1.2 # branch, set the version, run the checks, open the PR# … merge the PR …scripts/release.sh tag # tag main as v0.1.2 and push itA minor release carries three hand edits on the bump branch, because nothing scripts them and they are the only three places the repository claims a version shipped:
README.md, the roadmap row for that version:(releasing)→(released).docs/2026-08-28-prd.html, the roadmap table’s second cell for that row:<em>releasing</em>→<em>shipped <date></em>, matching the 0.1 and 0.2 rows above it.docs/2026-08-28-prd.html, the matching bullet in the Status section: releasing → shipped <date>.
Do them in the bump PR. After the tag they are the thing nobody remembers, and the PRD’s two are the ones furthest from the diff anyone is looking at.
Before any release that announces the paid tier: the Polar sandbox organisation lives in apps/worker/wrangler.staging.jsonc and stays there. apps/worker/wrangler.production.jsonc carries no POLAR_API or POLAR_PRODUCT_ID at all, so the checkout route answers 404 on production — by design, until the production organisation exists. Creating it means adding those two vars to wrangler.production.jsonc and putting a production POLAR_ACCESS_TOKEN and POLAR_WEBHOOK_SECRET as secrets on the production Worker; until all four are in place, do not announce the tier, because every checkout the CLI would hand out either 404s or takes no money.
The 0.4.0 checklist
0.4 is the first release that takes money, so it has an order of its own. In it:
- The production Polar organisation exists, past KYC, with the two subscription products — $8/month and $80/year — and a webhook endpoint at
https://api.handbill.dev/v1/billing/webhook. POLAR_ACCESS_TOKENandPOLAR_WEBHOOK_SECRETare set as secrets on the production Worker.wrangler.production.jsonccarriesPOLAR_API(https://api.polar.sh) and bothPOLAR_PRODUCT_IDs, comma-separated.scripts/release.sh bump 0.4.0, the maintainer merges,scripts/release.sh tag.bun run --cwd apps/worker deploy:prodfrom the tag.docs/DRILL.mdscenario E, once, on production with a real card — subscribe,handbill accountsayspaid, cancel, the lapse lands — then refund that payment in Polar’s dashboard and fill in theResult:lines.- The PRD versions row and the README roadmap line say shipped, with the date (the three hand edits above).
scripts/release.sh bump 0.5.0-dev.
Steps 1 to 3 are the announcement gate: until all of them are done a checkout either 404s or takes no money, so nothing may say the tier exists.
bump refuses a release version while CLIENT_ID in apps/cli/src/github.ts is still the placeholder — a handbill login against an app that does not exist cannot work, and a release is the point past which that is no longer fixable in a branch. On this repository the gate is dormant: the constant holds the client id of the “handbill CLI” GitHub App on the maintainer’s account, registered with device flow enabled and no permissions. It still binds anyone standing the project up on their own account, who has to create the app first (GitHub → Settings → Developer settings → GitHub Apps or OAuth Apps, with device flow enabled) and set the constant; the client id is public and has no secret, so it belongs in the source. -dev versions are never published and are allowed to carry the placeholder.
bump is the only thing that edits a version: apps/cli/package.json is the source of truth (the CLI’s --version reads it at build time) and the script mirrors it into the apps/cli entry of bun.lock, which bun does not rewrite on its own. tag refuses to run off main, with a dirty tree, behind origin, if the tag exists, if that version is already on npm, or if it is a -dev version. DRY_RUN=1 in front of either command runs the checks and the build but prints the branch, commit, push, PR and tag steps instead of doing them, and leaves the tree as it found it.
When main starts carrying the next minor’s features, say so with a -dev version, so nightlies name the release they lead up to instead of the next patch:
scripts/release.sh bump 0.2.0-dev # main is heading toward 0.2.0; nightlies become 0.2.0-nightly.…A -dev version is never published: tag and the workflow both refuse it, and the release is the same two commands as always (bump 0.2.0, merge, tag). Leaving main on a release version is fine too — nightlies then take the next patch, which is right while only fixes are landing.
The Release workflow then runs: scripts/release-version.sh (the tag-vs-version guard, and the dist-tag), typecheck, lint, test, build, npm publish via OIDC, GitHub release with generated notes.
The workflow refuses a tag whose version does not match apps/cli/package.json or whose commit is not on origin/main, and skips the publish step if that version is already on npm. A prerelease version (anything with a -, such as 0.2.0-rc.0) is published under the next dist-tag and its GitHub release is marked as a prerelease, so latest and plain npx handbill are untouched. To rehearse the pipeline without a version at all, use the nightly dry run below.
Nightly
The same workflow runs every day at 05:00 UTC and publishes main under the nightly dist-tag, so a merged feature is installable before it is released:
npx handbill@nightly --version # <next>-nightly.202608290500.g86df0a5npm i -g handbill@nightlyThe version is the release main is heading toward — the -dev version of apps/cli/package.json without its suffix, or the next patch of a release version — then the UTC date and time, and the commit (g + short sha, as git describe writes it) — a prerelease, so latest and next are never touched and nothing resolves to a nightly unless asked for by tag. It is set in the CI checkout only; main keeps the real version and bump remains the only thing that edits it. When main has not moved since the last nightly (the commit is already in handbill@nightly’s version) the job publishes nothing. Nightlies get no GitHub release; the commit in the version is the changelog.
Run one by hand, or rehearse without publishing:
gh workflow run release.yml # publish a nightly nowgh workflow run release.yml -f dry_run=true # build, pack and `npm publish --dry-run`; the publish job does not runscripts/release-version.sh # locally: the version the next run would publishA manual run publishes main only. Dispatching from another ref (--ref my-branch) is refused unless it is a dry run, so a branch cannot end up behind the nightly tag.
To pull a nightly back: npm dist-tag rm handbill nightly removes the tag, npm deprecate handbill@<version> "<why>" marks the version; npm does not allow unpublishing after 72 hours.
If a release fails
- Before the publish step: the
release tagsruleset forbids deleting or moving av*tag for anyone but the repository admin, sov0.1.1is not retagged — fix onmainand publish the next patch version (0.1.2) instead. The bad tag stays pushed, unpublished, as a record of the failed attempt. - After the publish step (for example the GitHub release step): do not re-tag; re-run the workflow — the publish is skipped and the release gets created — or
gh release create v0.1.1 --generate-notes. - npm never allows republishing a version; a fix after a successful publish is the next patch version.
Deploying the Worker
Production deploys from the release tag only: check out the tag, then deploy from it.
git checkout v0.4.0bun run --cwd apps/worker deploy:prodscripts/deploy-prod.sh (what deploy:prod runs) refuses unless the working tree is clean, HEAD is exactly a tag matching v[0-9]*, and that tag is on origin/main — so a deploy always ships a commit the maintainer merged and tagged, never a branch in progress. VERSION comes from the tag and must match apps/worker/package.json.
main deploys itself to staging on every push that touches the Worker or the contract (deploy:staging); production never deploys from a branch tip.
version and build on GET /v1/health exist only because deploy:prod and deploy:staging pass them as --vars (VERSION from the tag or package.json, BUILD from the short commit) — they are not baked into the Worker any other way. A bare wrangler deploy --config wrangler.production.jsonc ships the same bindings but skips the tag gate and passes no --vars, so it deploys silently with version/build absent from health. A production health response with no build is that sign; the fix is not to add the vars by hand but to redeploy from the tag with deploy:prod, which is the only path that both stamps them and proves the deploy came from a merged, tagged commit.
--force skips all three checks — clean tree, tag, reachability — with one loud warning line naming the commit and, when the tree is dirty, saying so. VERSION falls back to apps/worker/package.json. It is for a hotfix or the kill-switch drill (docs/DRILL.md scenario D, which deploys mid-drill with an intentionally uncommitted edit) and nothing else.
Why not staged publishing
npm’s staged publishing (npm stage publish in CI, a human approves with 2FA) is the other supported path. Here the tag is the approval: the release tags ruleset lets only the repository admin create, move or delete a v* tag, the version script refuses a tag that is not on origin/main, and the npm environment refuses to publish from any other ref. Write access alone reaches none of latest, next or the tag namespace. Nightlies publish with no human in the loop, which is fine for the same reason it is fine for next: they never touch latest, and the script on main is the only one that decides the dist-tag.