ADR-0028: Release & Versioning — semantic-release in the deploy pipeline
Last Updated: 2026-07-29
Status: Active
Context: Decksmith
Context
Decksmith deploys on every push to main (CI → deploy, ADR-0026). Until now the only identifier of a deployment was the commit SHA — there was no human-readable version, no changelog, and no way to see, from the running app, which release is live. We already write conventional commits (feat/fix/chore…), so a version can be derived from them automatically.
The design constraint is the existing pipeline: deploy.yml is triggered by workflow_run on a successful CI run. Any versioning tool that commits or tags from CI must not loop back into CI, and must run early enough for the computed version to be baked into the images.
Current Decision
Adopt semantic-release as a release job at the head of the deploy pipeline (release → build-{api,web,statics} → deploy).
- What it does: derives the SemVer version from the conventional commits since the last tag (
feat→ minor,fix→ patch,BREAKING CHANGE→ major), creates the git tag and a GitHub Release with generated notes, and bumps the rootpackage.jsonto the new version. Plugins:commit-analyzer,release-notes-generator,exec(pnpm pkg set version),github,git. - Root
package.jsonbump: the version is written to the rootpackage.jsononly (the workspace packages stay0.0.0— they areprivate, never published, so theirversionfield is irrelevant; the root serves as the repo's single human-readable version reference). This is a deliberate reversal of the original tag-only design — see Evolution History. - One
[skip ci]commit: writing the bump means semantic-release pushes achore(release): x.y.zcommit tomain. That commit carries[skip ci]in its message, so GitHub skips the CI workflow for it — and since the deploy is gated on CI, the deploy does not re-fire either. This is the standard, well-understood loop-breaker (mainhas no branch protection, so the defaultGITHUB_TOKENcan push directly). - Version propagation: the
releasejob exposes the version as a job output. The build jobs tag the images with it (alongside the SHA) and bake it in via build-args —APP_VERSIONfor the API (surfaced atGET /api/version) andVITE_APP_VERSIONfor the web (shown in the footer, mono type). Deployment still pins the SHA in the server.env(IMAGE_TAG), so determinism is unchanged; the version tag is an additional, human-readable reference. - Fallback: on a push with no releasable commits (e.g. docs-only), no new version is published; the job stamps the latest existing tag instead (or
0.0.0before the first release), so the app always shows a coherent version.
Rationale
- Deterministic behavior — releases are a pure function of the commit history; no manual version bumping, no drift. The deploy still pins the exact tested SHA.
- Explicit data contracts — the version is exposed through a stable endpoint (
/api/version) and the footer, not guessed. - Single source of version truth — the root
package.jsonalways reflects the latest release, so the version is visible in the repo, not only in the tag list. The[skip ci]marker keeps the CI-loop closed with a single, well-understood convention. - Maintainability — conventional commits were already the norm; this makes them load-bearing and self-documenting via the generated release notes.
Trade-offs
Benefits:
- Automated SemVer + changelog + GitHub Releases from existing commit discipline.
- Version visible in production (API endpoint + web footer) and in the image tags.
- No CI loop, minimal permissions (the release job adds
contents/issues/pull-requests: writefor tagging and PR/issue release comments; build/deploy jobs keep their least-privilege scopes).
Costs:
- The deploy pipeline gains a job and a third-party action (
cycjimmy/semantic-release-action, SHA-pinned per the supply-chain policy). - A release is created on
mainHEAD; ifmainmoves between the tested push and the release job, the tag could sit on a slightly later commit than the deployed SHA (rare, low-impact for a solo project).
Risks:
- A malformed commit convention could mis-classify a bump (e.g. a breaking change not marked
BREAKING CHANGE). Mitigation: the history is auditable and tags are cheap to correct. @semantic-release/githubposting comments needsissues/pull-requests: write. Accepted for the traceability benefit (PR ↔ shipped version); can be disabled later to drop those scopes.- The
chore(release)commit pushes tomain. Ifmainever gains branch protection requiring PRs or status checks, the defaultGITHUB_TOKENpush would be rejected — a PAT or a protection exception for the release bot would then be needed. Not an issue today (no protection).
Alternatives Considered
- Version = SHA only (status quo). Simple, but no human-readable version, no changelog, no release history. Rejected: the goal is precisely to surface a real version.
- Tag-only, no repo commit (the original design). Sidesteps the CI loop by construction (CI triggers on branches, not tags), but leaves the repo's
package.jsonfiles stuck at0.0.0— the version lives only in the tag list. Rejected on review: we want the version visible in the repo itself (rootpackage.json). The[skip ci]commit is a small, standard price for that. - Bumping every workspace
package.json. Rejected: the packages areprivateand never published, so theirversionis meaningless churn. Only the root is bumped. - Manual tagging / CalVer. Less tooling, but reintroduces manual bookkeeping and drift.
Evolution History
2026-07-29: Bump the root package.json
- Before merging: added the root
package.jsonversion bump. semantic-release now also runs@semantic-release/exec(pnpm pkg set version) +@semantic-release/gitto commit the bump aschore(release): x.y.z [skip ci]. The[skip ci]marker keeps CI (and therefore the deploy) from re-firing on the release commit. - Reason: the version should be visible in the repo, not only in the tag list. Only the root is bumped — workspace packages are
private/unpublished, so theirversionis left at0.0.0. mainhas no branch protection, so the defaultGITHUB_TOKENpushes the commit directly.
2026-07-29: Initial decision
- semantic-release as a
releasejob at the head of the deploy pipeline; tag + GitHub Release only, no repo commit (no CI loop). Version baked into images via build-args and exposed at/api/version+ the web footer; deployment still pins the SHA. - Plugins: commit-analyzer, release-notes-generator, github (with PR/issue comments kept on).
- Alternatives rejected: SHA-only (no readable version),
@semantic-release/git(CI-loop footgun), manual/CalVer (drift).
References
- ADR-0026: Reverse Proxy & Deployment Topology (the CI → deploy pipeline this extends)
.releaserc.json— semantic-release configuration.github/workflows/deploy.yml— thereleasejob + version propagationapps/api/src/plugins/version.ts— the/api/versionendpoint- Conventional Commits — the commit convention releases are derived from