Skip to content

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-prd success on the workflow_run path of deploy.yml is 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 for production records 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:

Terminal window
# 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 snapshot

Source: 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 v prefix (it invites SemVer tooling) and no -N suffix (a SemVer prerelease that sorts before the bare date). Without them, git tag --sort=-v:refname orders tags correctly.
  • The tag is not baked into the build. It exists only after the deploy succeeded, so SERVICE_VERSION and x-app-revision stay the SHA. Look up the name with git tag --points-at <sha>.

❌ Incorrect — names that SemVer tools misread, or a run number that skips:

v2026.10.11.2 # v invites SemVer parsing
2026.10.11-2 # a prerelease, sorts before 2026.10.11
0.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

  • release is its own job in deploy.yml, with needs: deploy-prd, environment release (no reviewer, main only), contents: write, concurrency release without 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-gate adds release to its needs.
  • 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 runs pnpm compat:snapshot; if endpoints changed, it opens one PR from bot/compat-snapshot with a GitHub App token, because a PR opened with GITHUB_TOKEN starts 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: 15

Source: 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-parent from the previous tag to the SHA: one subject per merged PR. The parser is the pr-title regex plus the trailing (#N). The two copies change together.
  • Groups: Breaking takes any !, Features takes feat, Fixes takes fix, perf and revert (as Reverted: …). 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 the pr-title job. 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 a major changeset 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 published

Source: notes/11-repo-operations/release-and-versioning.md · Decision 6

  • 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.