Real build-version stamping, one repo SemVer, and the version-mismatch detector's compatible-range flip
- Status: done
- Date: 2026-07-16
- Specs touched: docs/specs/BUILD.md, docs/specs/DECISIONS.md, docs/specs/HEALTH.md, docs/specs/RELEASE_MANIFEST.md, docs/architecture.md
Phase 2 of the release-flow work; Phase 1 (CI gating dev->main, the ci-cloud-image.yml tag trigger — see that commit's message, no progress entry per contributing.md's "dev tooling goes in docs/dev/" rule) landed with no version identity behind it yet. This entry covers the two decision flips and the behavior change that makes --version and the version-mismatch health detector real rather than placeholder constants.
One repo version, sourced from a VERSION file. Added VERSION at the repo root (0.4.0) as the single source of truth for the whole monorepo — brain, UI, and host-agent all ship from one commit, so there is one version, not three independently-bumped ones. This flips BUILD.md # Versioning's prior lock ("SemVer for host-agent and brain", malmo-ui "versioned independently of the brain").
Real build stamping. internal/version (new, deliberately dumb — two vars + a String(), no logic, per CLAUDE.md's no-premature-abstraction rule) holds Version and Commit, both stamped via -ldflags -X at build time:
Makefile: a newLDFLAGSreadsVERSIONandgit rev-parse --short HEAD(falls back tounknownoutside a checkout), wired into thehost-agent,host-agent-real,host-agent-real-hosted, andbraintargets.cmd/brain/Dockerfile: the same two-Xflags, with the commit passed in as a--build-arg(the Docker build context excludes.gitvia.dockerignore, sogit rev-parsecan't run inside the image build) —Makefile'sbrain-imagetarget supplies it.--versionflag added tocmd/brain,cmd/host-agent-real, andcmd/host-agent(the fake) — each printsmalmo 0.4.0 (g1a2b3c)and exits 0. The fake additionally appends(fake)so it's still visibly identifiable as the dev stand-in on the command line, even though its self-reportedagent_version(below) does not carry a suffix.make buildwas deliberately NOT changed to includehost-agent-real. It's Linux + CGO +libpam0g-devon both its build tags (see its own header comment), so it already can't build on macOS/Windows/WSL2-without-headers — folding it into the defaultbuildtarget would break the inner loop on exactly the platforms CLAUDE.md promises "no platform-specific setup" for. It's still stamped via its ownmake host-agent-realtarget for anyone building it directly, or via the cloud-image/nspawn lanes that already invoke it.
internal/hostagent.AgentVersion derives from the stamped version, without a -fake suffix. The constant lived in agent.go as "0.0.1-fake", but the systemStatus handler it feeds is shared code — compiled into and called by both cmd/host-agent (fake) and cmd/host-agent-real, and today returns placeholder data for both alike (Hostname: "malmo-dev" is hardcoded the same way, a known, separate, unrelated gap). A -fake suffix would have mislabeled host-agent-real's self-report too, since it's the same field. It's now var AgentVersion = version.Version — plain, no suffix — with the reasoning recorded in the code comment. When real per-binary system-status reporting is eventually built out, that's the natural place to split identification by binary, not this stamp.
The version-mismatch detector moved from exact equality to a compatible range. cmd/brain/main.go's expectedAgentVersion constant (exact-match against host-agent's reported version) is replaced by minimumAgentVersion — a floor, not a pin — and checkAgentVersion now raises only when the reported agent version is older than the floor; a newer agent is always fine. This matches UPDATES.md # 7's compatibility-matrix model (minimum_host_agent on the release manifest, minimum_brain_version on host-agent) and its # 1 update ordering (host-agent updates before brain, so a brain running ahead of its paired agent mid-rollout — up to the 24h apt-lag window in # 2 — is normal, not a mismatch). Comparison uses golang.org/x/mod/semver — already resolved in the module graph at v0.16.0 via a transitive test dependency (go mod why golang.org/x/mod/semver -> modernc.org/libc.test), so no new module entered the supply-chain closure; go mod tidy promoted it to a direct require with no new go.sum hashes. Comparison strips any prerelease/build suffix first (versionCore) so a suffix like -fake can't make an otherwise-current version look older — without that, x/mod/semver's prerelease-sorts-before-release rule would make the in-repo fake agent flag itself as mismatched against its own paired dev brain on every make dev.
minimumAgentVersion is set to "0.4.0" — the same value as the current VERSION, not derived from it programmatically. With one repo version, host-agent and brain still ship from the same commit; the floor only needs to move when a genuine host-agent<->brain protocol break lands, which is a hand decision, not an automatic one, so it stays a separate brain-side constant per UPDATES.md's model (a stand-in for the release manifest's minimum_host_agent field until that manifest is actually wired up).
User-facing text. The health-issue detail string moved off "the dashboard expects %s" (implies exact match) to: "The system agent is running an older version (%s) than this malmo needs (%s or newer). It will update automatically; if this persists, check the box's internet connection." internal/health.go's built-in version-mismatch Summary moved from "mismatched versions" to "needs an update," matching the new asymmetric semantics (a newer agent is never the issue).
Surfaced on the API. GET /api/v1/system/version (extends the existing registerSystem/system.go home rather than adding a new file) returns {version, commit} — the brain's own stamped identity, no host round trip. api/openapi.{json,yaml} regenerated (make openapi); web-ui/src/generated/openapi.ts regenerated too (make check-web's npm run gen:api picks it up), since it changed and CI's ci-web.yml triggers on api/**. No dashboard UI work — that's a later slice per the task scope.
web-ui/package.json's "version": "0.0.1" was deliberately left unchanged. It's never npm publish'd — the UI ships as a baked bundle inside an OCI image, not a package with consumers that resolve semver — so syncing it to 0.4.0 would be a release-checklist item with no reader and no failure mode behind it. Noted here rather than silently skipped.
CI (ci-cloud-image.yml). Added an early step, right after checkout and before the ~40-minute mkosi build, that on a push: tags: v* event asserts github.ref_name == "v$(cat VERSION)" and fails fast (with a message pointing at the release-PR VERSION-bump step) if not; the step is if: github.event_name == 'push' so workflow_dispatch (which has no real version tag) never runs or fails it. The publish step's Hetzner snapshot label — previously malmo-version=${MALMO_TAG}, empty on a non-tag workflow_dispatch run to avoid a "bogus" label — now reads $(cat VERSION) unconditionally: since a tag push is now asserted equal to VERSION before this step ever runs, and a workflow_dispatch build is genuinely built from VERSION too (just not from a release tag), labeling every publish with the real built version is more accurate than the old tag-only conditional, not less. ci-go.yml's path filter gained VERSION (a hypothetical release PR whose only diff is the version bump should still trigger the Go gate, though in practice a dev->main PR's accumulated diff already includes .go/Makefile changes that trigger it regardless).
BUILD.md # Versioning rewritten for one repo version + image-inherits-SemVer; its artifact list and locked-decisions bullets updated to match (no more independent vX.Y.Z per component, no CalVer for the image). DECISIONS.md carries two entries dated 2026-07-16 — one per flip — since each is a load-bearing reversal of a previously locked position and each has its own independent "why." HEALTH.md # Version's version-mismatch row and the locus-C catalog row both updated to describe the range, not exact equality; the cross-cutting-policy 1-shot exception's parenthetical example updated too. RELEASE_MANIFEST.md gets a one-sentence note that its separate brain/ui fields are always equal now — not a schema redesign, which stays out of scope (the manifest is unbuilt). docs/dev/contributing.md # Release model gained the VERSION-bump step in the release sequence. docs/architecture.md's package table gained the version row.
make checkgreen (gofmt, vet,openapi-check, full Go suite includingpamverifier—libpam0g-devwas present, so the full suite ran, nottest-nopam).make check-webgreen (npm run gen:apiregenerated the TS client with no further diff after commit,npm run buildsucceeded).- Built and ran the actual binaries, not just compiled them:
make build host-agent-realthen./.dev/brain --version,./.dev/host-agent --version,./.dev/host-agent-real --version— all three printedmalmo 0.4.0 (g<short-sha>)(the fake additionally(fake)), proving the ldflags wiring actually stamps rather than silently emitting thedev/unknownzero-values. - New table-driven
TestAgentVersionAcceptableincmd/brain/main_test.gopins the comparison helper directly: older major/minor/patch all rejected, exact match and newer major/minor/patch all accepted, a prerelease/build suffix on an otherwise-current core is accepted (the-fake-safety case), a suffix on an older core is still rejected, and a garbled/empty version is rejected (fails closed, not open). - The existing
TestCheckAgentVersion_*suite was rewritten (not left pointing at deletedexpectedAgentVersion) for the new semantics: older-than-minimum raises, newer-than-minimum never raises (the point of the flip, given its own dedicated test), at-or-above-minimum clears, steady mismatch refresheslast_checked_atwithout re-raising, and an unreachable host-agent leaves state untouched — same shape as before, updated fixture versions. - Validated
.github/workflows/ci-cloud-image.ymlparses as YAML after the edits (python3 -c 'import yaml; yaml.safe_load(...)'); the tag-assert step and the publish-label change were not run against a real GitHub Actions runner (no tag was pushed as part of this change).
cmd/host-agent-real's real system-status reporting (including a real per-binaryagent_version) is not built — this was already true before this change (systemStatushardcodesHostname: "malmo-dev"for both binaries) and is out of scope here; noted soAgentVersion's no-suffix design reads as deliberate rather than an oversight.RELEASE_MANIFEST.md's schema keeps two semver fields (brain,ui) that are now always equal. Not collapsed to one, per the task's explicit scope boundary (the manifest is unbuilt; redesigning it is a separate, larger change). The note added there flags this for whoever builds it.- The tag-assert CI step and the reconciled publish label are untested against a real tag push (this repo's tags are the maintainer's call per
docs/dev/contributing.md) — only YAML-parse-validated and read through carefully. First real release will be the first live proof. - Dashboard surfacing of
GET /api/v1/system/versionis explicitly deferred, per the task's scope boundary — the endpoint exists and is tested at the API layer, but nothing inweb-ui/reads it yet.
- Wire
GET /api/v1/system/versioninto the dashboard (About/Settings section) once there's a natural home for it. - When
RELEASE_MANIFEST.mdis actually built, decide then whether to collapsebrain/uiinto one field or keep the redundant pair — this entry intentionally left that call to that future change. - Real per-binary system-status reporting for
cmd/host-agent-real(hostname, and a real self-reported version distinct from the fake's) — tracked as a pre-existing gap, not newly introduced here.