Raxx · internal docs

internal · gated

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:

  1. release-please per-component tags (console-v0.10.7, historically trademaster-api-v2.9.0-dev.1 for the now-retired frontend/trademaster_ui CRA) — .release-please-config.json / .release-please-manifest.json. These auto-generate CHANGELOG.md entries per component from conventional-commit messages and are a working, intentional system. Out of scope.
  2. The console's own semver (console/app/version.py, linked from the console footer as console-v{{ console_version }}) — managed by release-please's console package. 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.
  3. VERSION / backend_v2/version.txt at repo root — these existed (holding stale content 2.14.0-dev.1) as orphaned artifacts of an abandoned root-level release-please package. Per docs/release/release-please-multi-package.md: release-please never had a root package.json to 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.py still asserts against that abandoned config (and has been silently dead since — the file is named version_sync_tests.py, which does not match pytest's default test_*.py / *_test.py discovery pattern, so it has never actually run in CI). This ADR repurposes the root VERSION file 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:

  1. Reads MAJOR.MINOR from the VERSION file at the ref being built.
  2. 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 current MAJOR.MINOR pair. (The commit that introduces a new MAJOR.MINOR — i.e. a release-ceremony minor/major bump — is always the epoch for that new track.)
  3. 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 (VERSION0.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)


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:

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


9. Consequences


10. Open questions


11. Revisit when