ADR 0148 — Raxx versioning scheme: Major.Minor.Revision-Build, reset to 0.0.1
Status: Accepted
Date: 2026-07-31 UTC
Deciders: Kristerpher (operator directive, verbatim, 2026-07-31); sre-agent (implementation)
Scope: Root VERSION file, backend_v2/version.txt, backend_v2/api/version.py,
backend_v2/api/routes/system.py (/api/system/status, /api/system/version),
Sentry release tagging (backend_v2/api/observability/sentry_init.py),
.woodpecker/deploy-staging.yaml, .woodpecker/deploy-prod.yaml,
scripts/ci/compute_version.py, docs/ops/runbooks/gatekeeper-develop-to-release.md,
docs/release/major-release-checklist.md
Refs: ADR-0115 (develop → release → main branching model, tag-gated promotion),
ADR-0135 (Woodpecker CI migration), docs/security/release-readiness-policy.md,
docs/release/release-please-multi-package.md
1. Context
Raxx has shipped 147 v1.x.y-style tags since inception (v1.0.1 through
v1.12.3 as of this ADR), applied ad hoc at the release → main boundary
per ADR-0115's tag-gated promotion model. The tag content itself has never
carried a defined meaning — v1.12.3 does not tell anyone whether that
release was a full verification pass, a routine promotion, or (as was
actually the case) an emergency hotfix (#4346, session-auth exemption).
There is no distinction in the tag between "we checked everything" and "we
shipped what was on develop."
Separately, three OTHER versioning mechanisms already exist in the repo and are explicitly not touched by this ADR:
- release-please per-component tags (
console-v0.10.7, historicallytrademaster-api-v2.9.0-dev.1for the now-retiredfrontend/trademaster_uiCRA) —.release-please-config.json/.release-please-manifest.json. These auto-generateCHANGELOG.mdentries per component from conventional-commit messages and are a working, intentional system. Out of scope. - The console's own semver (
console/app/version.py, linked from the console footer asconsole-v{{ console_version }}) — managed by release-please'sconsolepackage. Out of scope; this is a genuinely different, correctly-functioning versioning system for one component's changelog, not a stand-in for the overall release train version. VERSION/backend_v2/version.txtat repo root — these existed (holding stale content2.14.0-dev.1) as orphaned artifacts of an abandoned root-level release-please package. Perdocs/release/release-please-multi-package.md: release-please never had a rootpackage.jsonto resolve against, so it never actually bumped these files after the root package was dropped from.release-please-config.json.backend_v2/tests/version_sync_tests.pystill asserts against that abandoned config (and has been silently dead since — the file is namedversion_sync_tests.py, which does not match pytest's defaulttest_*.py/*_test.pydiscovery pattern, so it has never actually run in CI). This ADR repurposes the rootVERSIONfile as the canonical file for the NEW scheme below; it does not attempt to resurrect the abandoned root release-please package, and does not fix the orphaned test file (noted as a follow-up, not blocking).
This ADR defines the scheme for the release-ceremony tag — the v*.*.*
tag cut at the release → main boundary (ADR-0115 §Tag-gated promotion
model) — and wires it into the two places that actually display or transmit
a version at runtime: the Raptor system-info endpoint and Sentry release
tagging.
2. Decision — the scheme
vMAJOR.MINOR.REVISION-BUILD
Major (X.0.0)
Released only when every item in docs/release/major-release-checklist.md
is checked for the exact commit being promoted: all surfaces smoke-tested,
all flags reconciled, security review passed, migrations verified, monitoring
green. Raxx has never done a full verification pass across every surface —
we have not earned 1.x yet. The checklist file is a stub as of this ADR
(no automation enforces it; see that file's own header for why it is
procedural today, not a CI gate).
Minor (x.X.0)
A full release through staging → production (the ADR-0115 tag-gated
ceremony) that falls short of Major — i.e. it does not check everything, and
does not introduce a new service or feature tier significant enough to
warrant the Major bar. This is the default outcome of a release
ceremony. The operator bumps VERSION's MAJOR.MINOR pair by hand as part
of cutting the release (see §4 below for why this is manual and why that is
correct, not a shortcut).
Revision (x.x.X)
Enumerated for everything — every merged PR to develop bumps the
revision. Unlike Major/Minor, this is never hand-maintained and never
committed by a bot. See §4 (Revision mechanism) for the full mechanical
design and the alternative that was rejected.
Build (-NNNNN)
Appended with a hyphen. A single, continuous, monotonically increasing
number that never resets, for the lifetime of the repository. This is
the Woodpecker CI pipeline number ($CI_PIPELINE_NUMBER on
ci.moosequest.net, currently in the ~3,800s, confirmed monotonic per-repo
across all pipelines — not per-workflow). We do not invent a second
counter. CI_PIPELINE_NUMBER already satisfies every property the Build
segment needs (monotonic, forever, already exists, already visible in every
WP run), so introducing a parallel counter (a file, a database row, a GitHub
Actions run number) would be redundant machinery solving an already-solved
problem — pure toil.
Reset to 0.0.1
The old v1.0.1 .. v1.12.3 tags remain in git history, untouched,
immutable — they are simply what Raxx shipped before this scheme existed.
New tagging starts at v0.0.1. 1.0.0 becomes the earned launch milestone,
gated on the Major checklist passing for the first time. This is a one-time
reset event, not a recurring operation.
3. Why this reset, and why now
Continuing to increment v1.12.x would imply Raxx already crossed the
Major bar once (1.x existing at all implies some 1.0.0 release passed
whatever the "Major" bar was supposed to be) — but no such verification pass
has ever happened; v1.0.0 through v1.12.3 were cut using the same ad-hoc
judgment call every time, with no checklist behind any of them. Continuing
the 1.x numbering under the new scheme would be dishonest about what has
actually been verified. Resetting to 0.0.1 is the honest statement: pre-Major,
everything-still-to-prove, exactly matching where Raxx actually is on
2026-07-31.
4. Revision mechanism — the actual mechanical design
Two options were considered for how "every merged PR to develop bumps the revision" gets implemented:
Option A (rejected): CI auto-commits a revision bump to develop on every merge
Rejected. Under ADR-0115, develop merges land via squash-merge PRs
gated by branch protection (required status checks). A bot commit that
bumps VERSION on every merge would need to either:
- push directly to develop (fights branch protection — the same class of
problem ADR-0115's gatekeeper was redesigned to avoid: see that ADR's
"GH006: protected branch update failed" failure mode), or
- open a bot PR for every single merge (doubles the PR volume on the
busiest branch in the repo, for a change that carries zero information —
pure toil, and a race-condition magnet: two PRs merging within seconds of
each other would both try to bump from the same base revision).
Both sub-options were also incompatible with the tag-gated promotion model's design goal (ADR-0115 §Problem): reduce, not add, automated-write traffic to protected branches.
Option B (chosen): derive the revision at build/deploy time from git history
Chosen. scripts/ci/compute_version.py:
- Reads
MAJOR.MINORfrom theVERSIONfile at the ref being built. - Walks
VERSION's own commit history (newest-first,git log -- VERSION) to find the epoch commit — the oldest commit in the unbroken run that shares the currentMAJOR.MINORpair. (The commit that introduces a newMAJOR.MINOR— i.e. a release-ceremony minor/major bump — is always the epoch for that new track.) - Counts commits from the epoch (inclusive) to the ref
(
git rev-list --count epoch^..ref) — the epoch commit itself is revision 1.
This works cleanly because develop is squash-merge-only: empirically
verified (git log origin/develop --oneline --merges returns zero results
— every commit reachable from origin/develop has exactly one parent), so
"commit count since epoch" and "merged-PR count since epoch" are the exact
same number. No separate bookkeeping, no bot writes, no race conditions —
the git log itself IS the ledger.
Nuance documented, not hidden: on release / main (which do carry
--no-ff promotion merge commits per ADR-0115 §Merge strategy, and
occasionally direct hotfix commits), the count also includes those
non-develop commits. A --first-parent-restricted count limited strictly to
develop's lineage was considered and rejected as needless precision for a
human-facing revision number — "every change" in the operator's own framing
reasonably includes a promotion or hotfix commit, and a stricter count would
require the script to know which branch's lineage to prefer, adding
complexity for a distinction nobody downstream needs to act on. The revision
is monotonically non-decreasing on any given ref; that is the only property
that matters.
Verified mechanically (scratch test repo, not committed to this repo):
an epoch commit + 3 squash-merge-equivalent commits computed 0.0.4; adding
a minor-bump commit (VERSION → 0.1.0) then computed 0.1.1 for that
exact commit — confirming both the running count and the reset-on-bump
behavior.
Practical consequence: VERSION's checked-in third digit is a
one-time seed, not an authority. It reads 0.0.1 in this very PR
because this PR IS the epoch commit for the 0.0 track — once merged to
develop, compute_version.py --ref develop returns exactly 0.0.1 for
that commit (nothing has merged after it yet). Every subsequent squash-merge
to develop advances it: 0.0.2, 0.0.3, .... The file's digit is not
re-committed on every merge; it only changes again at the next release
ceremony's Minor/Major bump.
5. Where the version is wired
| Surface | Wired? | How |
|---|---|---|
VERSION (repo root) |
Canonical source | MAJOR.MINOR.SEED, hand-edited only at release-ceremony bump time |
backend_v2/version.txt |
Mirrored | Legacy release-please artifact (§1); kept content-identical to VERSION for consistency, not read by any runtime code |
backend_v2/api/version.py |
New | get_release_version() — reads RELEASE_VERSION env (deploy-stamped) or falls back to VERSION file |
GET /api/system/status, GET /api/system/version |
Wired | version / components.backend now call get_release_version(), replacing hardcoded "1.10.0" |
Sentry release tag (sentry_init.py) |
Already wired, now populated | sentry_sdk.init(release=os.environ.get("RELEASE_VERSION") or ...) already existed; this ADR is what makes RELEASE_VERSION actually get set (deploy pipelines stamp it as a Heroku config var — see §6) |
deploy-staging.yaml (raxx-api-staging) |
New step | Computes RELEASE_VERSION, heroku config:set before the code push |
deploy-prod.yaml (raxx-api-prod) |
New step | Same computation, Heroku Platform API PATCH /apps/.../config-vars (no Heroku CLI in that step by design — direct HTTP matches the existing direct-push pattern) before the code push |
Explicitly NOT wired (and why)
GET /health(public, unauthenticated). Per the 2026-04-24 security review (M2, comment already inbackend_v2/api/__init__.py), this endpoint deliberately returns only{"status": "ok"}— a prior version leaked app name + version to unauthenticated callers. This ADR does not reopen that decision. Version information stays behind/api/system/status//api/system/version, which sit behind@require_session/ CF Access in staging and prod.- Console footer (
console-v{{ console_version }}). This is release-please's own working versioning system for the console component (§1, item 2). Repointing it at the globalRELEASE_VERSIONwould be a behavior change to a system that already works correctly and is unrelated to the release-ceremony tag — out of scope per this ADR's "no behavior change to what deploys" constraint. frontend/raxx-next/package.json("0.1.0"),console/package.json("1.0.0"). Neither surfaces anywhere in the running UI (grepped; noAPP_VERSION/npm_package_versionusage in Antlers Next). Wiring a value nobody displays is pure scope creep.- Antlers Next Sentry release tag (
lib/sentryBrowser.ts). Noreleaseoption is set today. Antlers Next's CF Pages deploy pipeline is not yet part of the Woodpecker migration (still GHA per the Wave C/D notes indeploy-staging.yaml; tracked under#4018). Wiring frontend Sentry releases is a natural follow-up once that pipeline moves to WP — filed as atype:reliabilityaction item, not done here, to keep this PR to the Woodpecker-only mandate it was scoped for. .release-please-config.json/.release-please-manifest.json. Deliberately not touched. Adding a root package pointed atVERSIONwould hand an automated actor (release-please) write authority over the exact file this ADR defines as manually-bumped-at-ceremony-time — directly contradicting §4's Option A rejection. The two release-please packages that exist today (console) continue unaffected.
6. Build stamping — mechanically, step by step
At deploy-staging.yaml (deploy-raptor step) and deploy-prod.yaml
(deploy-raptor-prod step), immediately before the Heroku code push:
FULL_VERSION=$(python3 scripts/ci/compute_version.py --ref HEAD)
RELEASE_VERSION="${FULL_VERSION}-${CI_PIPELINE_NUMBER}"
# staging: heroku config:set RELEASE_VERSION="$RELEASE_VERSION" -a raxx-api-staging
# prod: PATCH https://api.heroku.com/apps/raxx-api-prod/config-vars {"RELEASE_VERSION": ...}
Setting the config var before the push means the same dyno restart that
picks up the new code also picks up the new RELEASE_VERSION — no separate
restart needed for the env var to take effect. sentry_init.py reads
RELEASE_VERSION at process import time (before create_app()), so the
next boot after the push tags every captured error with the exact
X.Y.Z-NNNNN that was deployed.
Both call sites already run in a python:3.11-slim container with git
installed and a prior unshallow fetch in the same pipeline (deploy-raptor
does its own git fetch --unshallow; deploy-raptor-prod inherits the
unshallow already performed by gatekeeper-prod in the same workspace) —
compute_version.py needs no new dependencies and no new container image.
7. Tag-pattern compatibility (no code changes required)
Checked every .woodpecker/*.yaml and docs/ops/runbooks/*.md file for
hardcoded v1.*-shaped matching that would reject the new v0.x.y tags:
deploy-prod.yamltrigger isref: refs/tags/v*— an unanchored glob, already acceptsv0.0.1with no change.gatekeeper-prodstep's ancestry check (git merge-base --is-ancestor $CI_COMMIT_SHA origin/main) is content-agnostic — it checks the tagged commit's ancestry, not the tag's digits.create-github-releasestep's payload (tag_name,name: f"Release {tag_name}") is a passthrough of whatever tag fired the pipeline — no regex validation of tag shape exists anywhere in this pipeline.
No pipeline or script in this repo validates the tag's digits against a
v1.*-specific pattern. The only changes needed were documentation
examples that showed v1.4.0/v1.2.3-style illustrative tags — updated in
docs/ops/runbooks/gatekeeper-develop-to-release.md and (as an amendment)
ADR-0115's example command. (Unrelated v1.x-shaped strings — Playwright
Docker image tags mcr.microsoft.com/playwright:v1.44.0-jammy, FreeScout
module versions, Terraform CLI version notes — are vendor/tool versions, not
Raxx release tags, and were left alone.)
8. Alternatives considered
- Keep
v1.xnumbering, just add meaning to Major/Minor/Revision going forward. Rejected — see §3; it would misrepresent that a Major bar was ever cleared. - Auto-bump
VERSIONon every merge via a bot commit (Option A above). Rejected — see §4. - A second, repo-owned build counter (a committed file, a KV entry) instead
of
CI_PIPELINE_NUMBER. Rejected —CI_PIPELINE_NUMBERalready has every required property (monotonic, per-repo, never resets, already visible in every pipeline run via$CI_PIPELINE_NUMBER). A second counter would be redundant machinery. - Recompute the revision at runtime, inside the running app, instead of at
deploy time. Rejected — a Heroku slug does not carry full git history
(and even if it did, per-dyno-boot
git logwalks are wasted CPU on every restart for a value that is fixed at deploy time). The deploy pipeline is the one place that has the history and only needs to compute it once.
9. Consequences
- Every future
release → mainceremony requires the operator to make one explicit decision at tag-cut time: bumpVERSION'sMAJOR.MINOR(or leave it, if this ceremony doesn't warrant a Minor bump — see open question below) as part of the promotion PR, before cutting thev*.*.*tag. docs/release/major-release-checklist.mdexists as a stub; it has zero automation today. If/when a real Major candidate approaches, lifting this checklist into a CI gate is a natural, scoped follow-up (not done here).- Two pre-existing, already-broken assertions in
backend_v2/tests/version_sync_tests.py(asserting against the abandoned root release-please package) remain broken — unrelated to this change, never collected by CI (wrong filename pattern for pytest discovery), and explicitly out of scope for this PR. Filed as atype:reliabilitycleanup ticket rather than fixed inline, to keep this PR's diff to its stated mandate. - Antlers Next Sentry release tagging is a documented follow-up (§5), not delivered in this PR.
10. Open questions
- Does every Minor release require an explicit
VERSIONbump commit, or can a Minor ceremony reuse the sameMAJOR.MINORas the prior one (i.e. is "Minor" strictly 1:1 with "new0.Xvalue") ? This ADR's mechanical design supports either — the revision keeps climbing under an unchangedMAJOR.MINORif the operator chooses not to bump it for a given ceremony. The operator's verbatim scheme description treats "Minor" as describing any full staging→prod ceremony that falls short of Major, which reads as compatible with revision-only advancement between minor bumps. Left to operator judgment at ceremony time, not hard-coded.
11. Revisit when
- A real Major-release candidate is being planned (lift
docs/release/major-release-checklist.mdinto an enforced CI gate). - Antlers Next's deploy pipeline moves off GHA onto Woodpecker (
#4018) — revisit frontend Sentry release tagging at that point. CI_PIPELINE_NUMBERsemantics ever change (e.g. WP introduces per-workflow numbering instead of per-repo) — the Build segment's "never resets" guarantee depends on the current per-repo behavior.