Release Process
End-to-end runbook for cutting, publishing, and rolling back a ServerlessIRCd release. This is the canonical process — every release follows this document top-to-bottom.
Distribution model. All workspace packages are "private": true. A
release is a signed git tag plus a GitHub Release whose body is
copied from CHANGELOG.md. There is no npm publish step in 0.x. See
§10 (Future: npm publish) for the criteria that would flip that on.
Current cadence. 0.x is "tag when ready" — there is no fixed
calendar release. Anyone with push rights may propose a release by
opening a PR that bumps versions and updates CHANGELOG.md; the
maintainer team cuts the tag.
Cross-reference: README.md (Roadmap), progress.md / tickets.md
(release content), docs/Cloudflare-Deployment-Guide.md (prod deploy
step §6), .github/workflows/ci.yml, .github/workflows/deploy-cf.yml.
1. Versioning
ServerlessIRCd follows Semantic Versioning with the 0.x convention:
| Bump | When |
|---|---|
0.x.0 → 0.(x+1).0 |
Any user-visible behavior change, new command/cap, new adapter, schema change to persisted DO state. |
0.x.y → 0.x.(y+1)* |
Internal-only fixes: tests, docs, refactors that don't change runtime behavior, perf work with identical output. (*Reserved for after 1.0; in 0.x every change is allowed to be a minor.) |
1.0.0 |
The Cloudflare and AWS adapters are deployed to staging, the parametrized contract suite passes on both, ≥3 reference clients connect cleanly (PLAN §9 exit criteria). |
The 0.x disclaimer. Per SemVer 0.x, anything may change at any
minor bump. Consumers of @serverless-ircd/* packages (today: only
this repo itself) should pin exact versions.
1.1 Workspace version lock
All sixteen package.json files (root + 15 workspace packages) carry the
same version string. They are bumped together in the release
commit. The pre-release checklist in §4 enforces this.
Rationale: even though the packages are private today, keeping a single version simplifies future npm publishing, release notes, and bug-report triage ("what version are you on?" has one answer).
1.2 Persisted-state schema
A change to PERSISTED_STATE_VERSION in
packages/cf-adapter/src/serialize.ts (or any equivalent versioned
blob in a future adapter) is a minor bump minimum, and requires a
migration entry in CHANGELOG.md under a ⚠️ Migration required
heading. See §9 (Rollback) for why rollback across such a bump is
not always possible.
2. Roles & permissions
| Role | Who has it today | Required for |
|---|---|---|
Push to main |
Maintainers | Merging the release PR. |
Push tags (v*) |
Maintainers | git push origin v0.x.0. |
| Create GitHub Release | Maintainers | Publishing the release notes. |
CLOUDFLARE_API_TOKEN secret |
Repo Settings → Secrets | Manual + CI deploys (single target). |
CF_SMOKE_URL variable |
Repo Settings → Variables | Smoke e2e in CI (manual dispatch). |
There is no separate "release engineer" role. The maintainer who cuts the tag owns the release end-to-end, including the production deploy and the post-release smoke check.
3. Branch & tag conventions
- Releases are cut from
main. No release branches in 0.x. - Tags are annotated, signed where possible, formatted
v<semver>(e.g.v0.1.0,v0.1.1,v1.0.0-rc.1). - Pre-release tags use SemVer suffixes:
v0.2.0-rc.1,v1.0.0-beta.3. GitHub marks the release as a pre-release automatically based on the suffix. - The release commit message is
chore(release): vX.Y.Z(matches the tag).
4. Pre-release checklist
Run every box before tagging. Most are verifiable from the repo root.
# 0. Sanity: clean tree on main, up to date with origin.
git status # working tree clean
git checkout main
git pull --ff-only origin main
# 1. All desired work merged. No pending tickets targeted for this release.
# Cross-check progress.md — every ticket listed under this release is ✅.
# 2. CI is green on the tip of main.
# Open: https://github.com/<owner>/<repo>/actions
# - CI workflow: lint / typecheck / coverage all green.
# - deploy-cf workflow: deploy + smoke e2e green (manual dispatch).
# 3. Local full verify.
pnpm install --frozen-lockfile
pnpm lint # biome check .
pnpm typecheck # tsc --noEmit across workspace
pnpm test # full suite
pnpm build # every package produces dist/
# 4. Coverage gate (enforced in CI per package).
# The per-package coverage gate (TICKET-048) runs in CI via
# `pnpm coverage`; Vitest fails the build on any threshold regression.
# Thresholds: 100% (lines/functions/branches/statements) on
# irc-core / irc-server / in-memory-runtime, and ≥90% on every
# other package (cf-adapter / local-cli / tcp-ws-forwarder). See the
# "Coverage gate" step in .github/workflows/ci.yml. CI must already
# be green (step 2), so locally re-run `pnpm coverage` as a
# belt-and-braces check before tagging.
# 5. Smoke e2e passes against the deployed tip-of-main.
node apps/cf-worker/scripts/smoke.mjs \
--url wss://serverless-ircd.<subdomain>.workers.dev
# 6. CHANGELOG.md is complete and accurate (§5).
# 7. All package.json versions are bumped and lock-step (§6).
# 8. The Release PR is merged (§7).
If any box fails, stop. Fix forward on main and restart the
checklist — do not tag over a broken tip.
4.1 What is explicitly out of scope for the 0.x line
Items still pending in progress.md that are not blockers for a
given release — they ship when ready and are called out under "Known
limitations" in the GitHub Release body:
- Client compatibility sweep (WeeChat / HexChat / IRCCloud / TheLounge /
matrix-IRC bridge) —
progress.mdTICKET-050. - Playwright headless-browser e2e for the web client — TICKET-138.
- Driving the sub-100% packages to full coverage — TICKET-143 through TICKET-148.
Their absence is not a blocker. If a release intentionally defers an in-progress ticket, name it under "Known limitations".
5. Writing CHANGELOG.md
Format: Keep a Changelog, newest
entry first. One ## [X.Y.Z] - YYYY-MM-DD section per release.
Categories (use only what applies):
- Added — new features, commands, caps, adapters.
- Changed — behavior changes in existing functionality.
- ⚠️ Migration required — anything that needs operator action on upgrade (DO schema bumps, persisted-state version bumps, config shape changes). One bullet per action item.
- Deprecated — functionality scheduled for removal.
- Removed — functionality removed in this release.
- Fixed — bug fixes.
- Security — vulnerability fixes (also request a CVE).
- Known limitations — explicit "not in this release" list so operators aren't surprised.
Source material for the entry:
progress.md— the ticket table. Every✅row since the previous release tag is a candidate bullet.tickets.md— the per-ticket Description field, for the prose.git log v<previous>..HEAD --oneline— for fixes that don't have a ticket.
Group by user-facing theme (Registration, Channel ops, IRCv3, Cloudflare adapter, …) rather than by ticket number. Operators read themes; developers cross-reference tickets.
Rule of thumb: a new operator reading only CHANGELOG.md should
understand what changed and what they need to do.
6. Bumping versions
There are sixteen package.json files (root + 15 workspace packages).
They must end up at the same version (tools/package.json is a parent
container with no version field and is not bumped):
./package.json (workspace root)
packages/irc-core/package.json
packages/irc-server/package.json
packages/in-memory-runtime/package.json
packages/irc-test-support/package.json
packages/cf-adapter/package.json
packages/aws-adapter/package.json
apps/local-cli/package.json
apps/cf-worker/package.json
apps/cf-tcp-container/package.json
apps/aws-stack/package.json
apps/web/package.json
tools/tcp-ws-forwarder/package.json
tools/load-test/package.json
tools/ci-hardening/package.json
scripts/package.json (deploy helpers)
To cut v0.X.Y:
# from the repo root, on a fresh branch off main:
git checkout -b release/v0.X.Y
# bump each file. The version field is the only change.
# (Optional helper: `pnpm -r exec -- node -e '...'` if you script it.)
# verify all files agree:
rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json scripts/package.json
The lockfile (pnpm-lock.yaml) is unaffected — none of the workspace
dependencies are version-pinned to the workspace root.
Do not run pnpm publish or npm version — those are for npm
distribution, which is not the current model.
7. The release PR
The release PR contains exactly two files changed:
CHANGELOG.md(new entry under the version being cut).- Every
package.jsonlisted in §6 (version bump).
Nothing else. If a code change is needed for the release, it lands in a separate PR first and the release PR is opened only after that PR merges.
PR title: chore(release): v0.X.Y
PR body:
This cuts v0.X.Y. See CHANGELOG.md for the full diff vs. v0.W.Z.
Pre-release checklist: docs/release.md §4 — all green.
Out-of-scope tickets: docs/release.md §4.1.
After merge: tag + GitHub Release + manual prod deploy per
docs/release.md §8.
The PR is squash-merged with the same title as the commit message.
8. Cutting the release
Run immediately after the release PR merges, so the tag points at the exact commit the changelog describes.
git checkout main
git pull --ff-only origin main
# Confirm the tip commit is the squashed release PR.
git log -1 --oneline # expect: chore(release): v0.X.Y
# Confirm versions are at the new release.
rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json scripts/package.json
8.1 Tag and push
git tag -a v0.X.Y -m "v0.X.Y"
git push origin v0.X.Y
Annotated (-a) tags carry tagger + date + message; use them over
lightweight tags. Sign with -s if you have a GPG / SSH signing key
configured (git config tag.gpgSign true).
8.2 Create the GitHub Release
- <https://github.com///releases/new> → choose the tag just pushed.
- Title:
v0.X.Y(matches the tag, nothing fancier). - Body: paste the matching section from
CHANGELOG.md. Strip the## [X.Y.Z] - YYYY-MM-DDheading line (it's redundant with the release title). - Set as latest release unless this is a pre-release. Pre-release
tags (
-rc.N,-beta.N, etc.) get the "Set as a pre-release" checkbox instead. - Publish release.
8.3 Production deploy (manual)
There is a single deploy target per platform (pnpm deploy:cf); the
staging/prod split was collapsed, so the same command ships to whichever
Cloudflare account the credentials target. The maintainer runs the
deploy by hand, from a clean checkout on the tag:
git checkout v0.X.Y
# Sanity-check the tag matches the released commit.
git log -1 --oneline # expect: chore(release): v0.X.Y
pnpm install --frozen-lockfile
pnpm build
pnpm typecheck
pnpm test
# Deploy the Cloudflare Worker.
pnpm deploy:cf
# → wrangler deploy
# Tail logs while the first connections arrive.
npx wrangler tail
# Smoke-check the deploy with the same script CI uses.
# You must point it at the wss:// URL:
node apps/cf-worker/scripts/smoke.mjs \
--url wss://<workers-subdomain>.workers.dev
If smoke fails on prod:
- Do not roll forward blindly. Tail logs, identify the regression.
- If a rollback is required, follow §9.
- If the regression is in the deployed Worker code (not the protocol core), a hotfix follows the same process as a normal release (new patch / minor → tag → GitHub Release → prod deploy).
8.4 Announce
- Update
progress.mdStatus header (if a "current release" marker was added there) — not currently present, optional. - Open an issue with the body "v0.X.Y released" if external tracking is desired. (Not currently wired to any external channel.)
9. Rollback
There are two independent rollback axes: code and data.
9.1 Code rollback
To redeploy a previous release:
git checkout v0.W.Z # the previous tag
pnpm install --frozen-lockfile
pnpm build
pnpm deploy:cf
This works as long as the previous release's code is compatible with the persisted DO state shape (see 9.2).
There is no in-CI "prod rollback" workflow. The maintainer runs the commands above from a checkout.
9.2 Data rollback (persisted DO state)
Persisted-state version is one-way. Once
packages/cf-adapter/src/serialize.ts ships a new
PERSISTED_STATE_VERSION, old code reading new blobs will reject
them, and new code reading old blobs may or may not migrate them
depending on whether a forward migration was shipped alongside.
Rules of thumb:
- If the release's
CHANGELOG.mdhas no ⚠️ Migration required entry, code rollback to the previous tag is safe — DO storage is forward- and backward-compatible. - If it does have a migration entry, assume code rollback will
not work without losing or transforming state. The migration entry
in
CHANGELOG.mdmust spell out the recovery procedure.
In a catastrophic prod failure where the new version's migration already ran, the path forward is:
- Freeze new connections (set
MOTD_LINESto a maintenance banner, or block at the edge with a Route rule). - Decide: fix forward on the new code, or wipe staging-state (NOT prod-state) and re-test the migration offline.
- Production DO namespaces must never be deleted — the data is
gone permanently (per
docs/deployment-cf.md§12.1).
10. Future: npm publish
When @serverless-ircd/irc-core becomes the first package consumers
outside this repo depend on (likely: the in-memory runtime is pulled
into a third-party test harness, or someone ships an alternate
adapter), the following gates apply before flipping "private": false:
- Public API audit. Every export from
src/index.tsis one we're willing to support across a minor. Anything experimental goes under a/internalsubpath. - Stable type surface. No
any, no@ts-expect-errorin the public surface, noexactOptionalPropertyTypessurprises. - README per package, not just the root.
CHANGELOG.mdis already disciplined (Keep a Changelog format, one section per version, dated).- CI coverage gate ≥90% (TICKET-048) is wired and green.
- Provenance & provenanced verification — npm publish from CI with Sigstore signatures, not a local laptop.
At that point this document grows a §11 "Publishing to npm" with
pnpm -r publish --filter @serverless-ircd/irc-core --access public
and a new publish.yml workflow. The git-tag-plus-GitHub-Release flow
above stays unchanged — npm publish becomes an additional step, not
a replacement.
11. Quick reference: cutting v0.X.Y
# 1. Pre-release checklist (docs/release.md §4).
git checkout main && git pull --ff-only origin main
pnpm install --frozen-lockfile
pnpm lint && pnpm typecheck && pnpm test && pnpm build
# 2. Branch, bump versions + CHANGELOG, open PR (§6, §7).
git checkout -b release/v0.X.Y
# ... edit 16 × package.json + CHANGELOG.md ...
git commit -am "chore(release): v0.X.Y"
git push -u origin release/v0.X.Y
# ... open PR, get it merged ...
# 3. Tag and push (§8.1).
git checkout main && git pull --ff-only origin main
git tag -a v0.X.Y -m "v0.X.Y"
git push origin v0.X.Y
# 4. GitHub Release (§8.2): paste CHANGELOG entry, publish.
# 5. Prod deploy (§8.3).
git checkout v0.X.Y
pnpm install --frozen-lockfile && pnpm build
pnpm deploy:cf
node apps/cf-worker/scripts/smoke.mjs --url wss://<prod>.workers.dev
Total wall-clock for a routine 0.x release: ~30 minutes including the prod deploy and smoke check.