Skip to content

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

  1. The repository is public. Provenance links the package to the public source and workflow run.
  2. 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:
    Terminal window
    npm login # browser + 2FA
    bun install
    bun run --cwd apps/cli build
    cd apps/cli && npm publish --access public
    This version has no provenance attestation; every later one will.
  3. Configure the trusted publisher. npmjs.com → the handbill package → Settings → Trusted Publisher → GitHub Actions:
    • Organization or user: viktoravelino
    • Repository: handbill
    • Workflow filename: release.yml (exact, case-sensitive)
    • Environment: npm
    • Allowed actions: npm publish
  4. Create the npm environment (repository Settings → Environments) with “Deployment branches and tags” set to the main branch and the v* 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.
  5. 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:

Terminal window
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 it

A 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:

  1. README.md, the roadmap row for that version: (releasing) → (released).
  2. docs/2026-08-28-prd.html, the roadmap table’s second cell for that row: <em>releasing</em> → <em>shipped &lt;date&gt;</em>, matching the 0.1 and 0.2 rows above it.
  3. 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:

  1. 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.
  2. POLAR_ACCESS_TOKEN and POLAR_WEBHOOK_SECRET are set as secrets on the production Worker.
  3. wrangler.production.jsonc carries POLAR_API (https://api.polar.sh) and both POLAR_PRODUCT_IDs, comma-separated.
  4. scripts/release.sh bump 0.4.0, the maintainer merges, scripts/release.sh tag.
  5. bun run --cwd apps/worker deploy:prod from the tag.
  6. docs/DRILL.md scenario E, once, on production with a real card — subscribe, handbill account says paid, cancel, the lapse lands — then refund that payment in Polar’s dashboard and fill in the Result: lines.
  7. The PRD versions row and the README roadmap line say shipped, with the date (the three hand edits above).
  8. 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:

Terminal window
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:

Terminal window
npx handbill@nightly --version # <next>-nightly.202608290500.g86df0a5
npm i -g handbill@nightly

The 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:

Terminal window
gh workflow run release.yml # publish a nightly now
gh workflow run release.yml -f dry_run=true # build, pack and `npm publish --dry-run`; the publish job does not run
scripts/release-version.sh # locally: the version the next run would publish

A 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 tags ruleset forbids deleting or moving a v* tag for anyone but the repository admin, so v0.1.1 is not retagged — fix on main and 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.

Terminal window
git checkout v0.4.0
bun run --cwd apps/worker deploy:prod

scripts/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.