Skip to main content

0007. Stamp the build identity from the release tag and keep it apart from the API contract version

Context and problem statement

Until now nothing running in a deployment could say which release it was. The backend advertised a hard-coded FastAPI(version="0.1.0") and frontend/package.json carried a stale 0.1.0 that nothing read, so support could not ask “which version are you on?”, a dev deployment was indistinguishable from prod in the browser, and the deploy job had no way to confirm that the containers it had just restarted were the ones it had just built. Two related drifts compounded this. The prod Release Drafter draft guessed its version from PR labels, while the maintainer promotes the exact commit validated on dev under the same number (v1.3.0 and dev-v1.3.0 point at one commit), so the draft’s title was renamed by hand and its body kept the guessed number. And the only version string the OpenAPI document had was the same hard-coded one, so wiring the release version into it would have rewritten the committed docs/openapi.json on every tag without a single endpoint changing. The tension is between one honest, verifiable version for the running artefact and a spec version that only moves when the contract does.

Decision drivers

  • One source of truth. The tag the maintainer pushes (see ADR-0001) is the release; every surface must derive from it, never from a file someone forgot to bump.
  • Verifiable after deploy. CI must be able to assert that the stack actually serving traffic is the build it just shipped.
  • No plausible fakes. A local or unstamped build must render as such, never as a made-up semver.
  • A stable committed spec. docs/openapi.json is regenerated by CI and asserted equal to the served schema; it must not churn per release.
  • Nothing new to configure. No new secrets, variables or .env keys for operators.

Considered options

  1. Stamp at build time from the tag. ci.yml passes APP_VERSION, APP_CHANNEL, APP_COMMIT and APP_BUILT_AT as build-args into both Dockerfiles; the backend reads them from the image environment, the frontend inlines them as NEXT_PUBLIC_APP_* at next build.
  2. Read the version from the manifests. Bump frontend/package.json and FastAPI(version=...) on every release and read them at runtime.
  3. Configure the version at runtime. Add APP_VERSION to the deployment .env files and let the server report whatever it is told.
  4. Keep resolving the prod version from labels. Leave the Release Drafter resolver as the source of the prod number.

Decision outcome

Chosen option: stamp at build time from the tag, with two companion rules, because it is the only option where the reported version cannot disagree with the code in the image. The design:
  • One value fans out. resolve-release.outputs.version (the tag with its prefix stripped) becomes APP_VERSION for both images; the environment name becomes APP_CHANNEL, github.sha becomes APP_COMMIT, and a UTC ISO-8601 stamp becomes APP_BUILT_AT. Defaults (0.0.0-dev, local, unknown, empty) make an unstamped build report exactly that. The backend serves them at GET /api/version and on X-App-Version / X-App-Commit headers; the frontend renders them as data-app-* attributes on <html>, in the footer and navbar, and as a [DEV] title prefix off prod.
  • Asserted post-deploy. After deploy.sh returns, ci.yml polls /api/version and the served HTML on the target host until both report the tag’s version and commit, and fails the run otherwise. The version-only layers sit after the expensive dependency layers in both Dockerfiles, so a rebuild for a new tag reuses the cache.
  • The prod version is inherited from the dev tag. The prod drafter workflow finds the dev-vX.Y.Z tag pointing at the promoted commit and passes X.Y.Z as the drafter’s version input, so tag, title and body agree with what was validated on dev; only when no such tag exists does the label-based resolver guess, with a warning.
  • The spec version is the contract version. FastAPI(version=...) reads API_CONTRACT_VERSION from app/core/version.py, bumped by hand when an endpoint or field changes. GET /api/version reports it as api_contract next to the build fields, so a client can see both.

Positive consequences

  • Every deployment answers “which release, which commit, built when” without a login, and a mismatch between what CI built and what runs is a red pipeline, not a support ticket.
  • Prod release notes carry the number the maintainer validated on dev, with no manual retitling.
  • docs/openapi.json stays byte-stable across releases; the contract version moves only when the contract does.
  • frontend/package.json stays at 0.0.0 and stops pretending to be a version.

Negative consequences

  • Two version strings exist (build and contract), which needs one sentence of explanation wherever they meet; both are documented at /api/version.
  • API_CONTRACT_VERSION is bumped by hand, so it can be forgotten. Mitigated by the schema tests and the OpenAPI sync workflow, which surface a changed spec in review.
  • A locally built image always reads as dev build on the local channel, which is intended but occasionally surprising.

Pros and cons of the options

Option 1: stamp at build time from the tag

  • + The version is a property of the artefact, so it cannot be misconfigured or forgotten.
  • + Post-deploy assertion becomes possible.
  • − Four more build-args in CI, the Dockerfiles, docker-compose.yml and the deploy scripts to keep aligned.

Option 2: read the version from the manifests

  • + Conventional; no build plumbing.
  • − Requires a bump commit on every release, in two files, that the tag can silently disagree with.
  • − Ties the OpenAPI version to the release, churning the committed spec.

Option 3: configure the version at runtime

  • + Trivial to change without a rebuild.
  • − That is the flaw: an operator can run image X while the server claims version Y, which defeats the post-deploy check and support diagnosis.

Option 4: keep resolving the prod version from labels

  • + No workflow change.
  • − Cannot know the dev number the maintainer chose; produced wrong bodies for v1.2.1 and v1.3.0, and since the release-drafter v7 bump proposed patch for everything.
  • Related ADRs: 0001-two-branch-model
  • Docs: Maintainers handbook, Endpoint groups
  • Code: .github/workflows/ci.yml, .github/workflows/release-drafter-prod.yml, backend/Dockerfile, frontend/Dockerfile, backend/app/core/version.py, backend/app/core/build_headers.py, backend/app/routers/version.py, frontend/src/lib/build-info.ts