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-codedFastAPI(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.jsonis 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
.envkeys for operators.
Considered options
- Stamp at build time from the tag.
ci.ymlpassesAPP_VERSION,APP_CHANNEL,APP_COMMITandAPP_BUILT_ATas build-args into both Dockerfiles; the backend reads them from the image environment, the frontend inlines them asNEXT_PUBLIC_APP_*atnext build. - Read the version from the manifests. Bump
frontend/package.jsonandFastAPI(version=...)on every release and read them at runtime. - Configure the version at runtime. Add
APP_VERSIONto the deployment.envfiles and let the server report whatever it is told. - 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) becomesAPP_VERSIONfor both images; the environment name becomesAPP_CHANNEL,github.shabecomesAPP_COMMIT, and a UTC ISO-8601 stamp becomesAPP_BUILT_AT. Defaults (0.0.0-dev,local,unknown, empty) make an unstamped build report exactly that. The backend serves them atGET /api/versionand onX-App-Version/X-App-Commitheaders; the frontend renders them asdata-app-*attributes on<html>, in the footer and navbar, and as a[DEV]title prefix off prod. - Asserted post-deploy. After
deploy.shreturns,ci.ymlpolls/api/versionand 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.Ztag pointing at the promoted commit and passesX.Y.Zas the drafter’sversioninput, 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=...)readsAPI_CONTRACT_VERSIONfromapp/core/version.py, bumped by hand when an endpoint or field changes.GET /api/versionreports it asapi_contractnext 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.jsonstays byte-stable across releases; the contract version moves only when the contract does.frontend/package.jsonstays at0.0.0and 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_VERSIONis 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 buildon thelocalchannel, 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.ymland 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.1andv1.3.0, and since the release-drafter v7 bump proposedpatchfor everything.
Links
- 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