Release & versioning
For a hosted service, “who decides the bump” has a short answer: there is no bump. This page answers what counts as a release, how it is named, who writes the notes and when SemVer would come back.
Treat a successful production deploy from main as the release
Section titled “Treat a successful production deploy from main as the release”Impact: HIGH the approval is already the human decision
- One
deploy-prdsuccess on theworkflow_runpath ofdeploy.ymlis one release: approved, deployed and smoke-checked. A deploy that fails, is rejected or is cancelled is not a release; its commits ship in the next one. - A rollback (
workflow_dispatch) is not a release. It creates no tag and no notes. GitHub’s deployments API forproductionrecords what production ran and when. - “The release after” is a later tag. A wire removal that needs our client to have stopped using
the surface merges only once
git tag --contains <client-change-sha>lists a tag; the removal PR names that tag. See API evolution.
❌ Incorrect — a second human act that can name code production is not running:
# after approving the deploy, someone also remembers to…git tag v1.4.0 && git push --tags✅ Correct — the release follows the approval and the deploy:
deploy.yml run for main SHA a1b2c3d deploy-stg ✅ → [approval] → deploy-prd ✅ (migrate, deploy, smoke) └─ release → tag 2026.10.11.2 → GitHub Release → compat snapshotSource: notes/11-repo-operations/release-and-versioning.md · Decision 1
Name releases with a CalVer tag; the app keeps reporting its SHA
Section titled “Name releases with a CalVer tag; the app keeps reporting its SHA”Impact: MEDIUM people get a name, machines keep the SHA
- The tag is
YYYY.MM.DD.N: UTC date, zero-padded, and N is 1 + the number of tags for that date. It is a lightweight tag on the deployed SHA, created by the release API call. - No
vprefix (it invites SemVer tooling) and no-Nsuffix (a SemVer prerelease that sorts before the bare date). Without them,git tag --sort=-v:refnameorders tags correctly. - The tag is not baked into the build. It exists only after the deploy succeeded, so
SERVICE_VERSIONandx-app-revisionstay the SHA. Look up the name withgit tag --points-at <sha>.
❌ Incorrect — names that SemVer tools misread, or a run number that skips:
v2026.10.11.2 # v invites SemVer parsing2026.10.11-2 # a prerelease, sorts before 2026.10.110.0.46-nightly.20261010.2935✅ Correct — a date and a per-day counter:
tag 2026.10.11.2 ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[1-9][0-9]*$app x-app-revision: a1b2c3d4… (SERVICE_VERSION is still the SHA)Source: notes/11-repo-operations/release-and-versioning.md · Decision 2
Create the tag in a separate, idempotent release job
Section titled “Create the tag in a separate, idempotent release job”Impact: HIGH keeps contents: write away from production secrets
releaseis its own job indeploy.yml, withneeds: deploy-prd, environmentrelease(no reviewer,mainonly),contents: write, concurrencyreleasewithout cancellation. It holds no production secrets, and a notes failure does not look like a failed deploy.- Every step checks whether its output exists, so “re-run failed jobs” finishes a half-done
release without tagging twice.
deployment-gateaddsreleaseto itsneeds. deploy-stg’s last step writes the pending notes into the run summary, so the approver sees what they approve. After each release the job runspnpm compat:snapshot; ifendpointschanged, it opens one PR frombot/compat-snapshotwith a GitHub App token, because a PR opened withGITHUB_TOKENstarts no workflows.
❌ Incorrect — release steps bolted onto the production deploy job:
deploy-prd: permissions: { contents: write } # next to production secrets steps: # … alchemy deploy, smoke … - run: gh release create … # a notes failure turns the deploy red✅ Correct — a separate job, only after a successful deploy:
release: needs: deploy-prd if: github.event_name == 'workflow_run' && needs.deploy-prd.result == 'success' environment: release permissions: { contents: write } concurrency: { group: release, cancel-in-progress: false } timeout-minutes: 15Source: notes/11-repo-operations/release-and-versioning.md · Decision 3
Generate notes from squash subjects with our own script
Section titled “Generate notes from squash subjects with our own script”Impact: MEDIUM no generator dependency in the job holding write access
- Input is
git log --first-parentfrom the previous tag to the SHA: one subject per merged PR. The parser is thepr-titleregex plus the trailing(#N). The two copies change together. - Groups: Breaking takes any
!, Features takesfeat, Fixes takesfix,perfandrevert(asReverted: …). Omitted types are counted, not hidden. Unparseable subjects go under Other, verbatim. - Notes live only in GitHub Releases; there is no
CHANGELOG.md.pnpm release:notes [<from>] [<to>]previews the next release locally. See git workflow.
❌ Incorrect — a committed changelog that needs a bot push every release:
CHANGELOG.md## Unreleased ← untouched for months while the app kept deploying✅ Correct — grouped notes on the GitHub Release:
### Features- **billing:** invoices can be exported as CSV (#418)
### Fixes- Reverted: feat(search): results stream as you type (#421)
6 internal changes not listed (refactor, test, docs, build, ci, chore).Source: notes/11-repo-operations/release-and-versioning.md · Decision 4
Surface ! as Breaking with the upgrade steps copied in
Section titled “Surface ! as Breaking with the upgrade steps copied in”Impact: MEDIUM notes are self-contained and frozen at ship time
- Breaking is the first section, and the release name gets
· breaking. - Each Breaking line quotes the PR’s
### Upgrade steps, cut with the same awk as thepr-titlejob. If the section is gone, the line says “Upgrade steps: see #N” and the job warns; it does not fail, because production is already deployed. - A
!bumps no version: the app has none. An in-house removal is not!. See pull requests and review.
❌ Incorrect — a link only, or a major bump that means nothing:
### Breaking- **auth:** API keys require an expiry (#415) ← reader must click through✅ Correct — the steps quoted under the line:
### Breaking- **auth:** API keys require an expiry (#415) > **Upgrade steps** (from #415) > 1. Set `expiresAt` on every key …Source: notes/11-repo-operations/release-and-versioning.md · Decision 5
Add SemVer and changesets only when something is published
Section titled “Add SemVer and changesets only when something is published”Impact: LOW a bump field nobody reads is pure cost
- Today no package goes to a registry and the only client is redeployed with the server. Nothing compares version numbers.
- When a workspace package is published, it gets changesets and tags
@app/<pkg>@x.y.z, which cannot collide with a date. A!touching it then also carries amajorchangeset for that package, not for the app.
❌ Incorrect — a changeset per PR “to be ready”:
.changeset/brave-owls-sing.md # "patch" for a service nobody pins✅ Correct — commit subjects serve the app; changesets wait for a published package:
2026.10.11.2 ← the app@app/sdk@1.0.0 ← only once a package is publishedSource: notes/11-repo-operations/release-and-versioning.md · Decision 6
Deferred
Section titled “Deferred”- A check that a wire removal’s client change is already in a release tag — trigger: the first wire removal that shipped in the same release as the client change it depended on.
- SemVer and changesets for workspace packages — trigger: the first workspace package published to npm or another registry.
- A SemVer app version for installed clients, checked by a protocol handshake — trigger: the first client that is not redeployed with the server.
- One tag stream per deployable (
<deployable>/YYYY.MM.DD.N) — trigger: a second deployable that reaches production on its own approval.