Skip to content

Latest commit

 

History

History
57 lines (37 loc) · 12.2 KB

File metadata and controls

57 lines (37 loc) · 12.2 KB

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.

What was done

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 new LDFLAGS reads VERSION and git rev-parse --short HEAD (falls back to unknown outside a checkout), wired into the host-agent, host-agent-real, host-agent-real-hosted, and brain targets.
  • cmd/brain/Dockerfile: the same two -X flags, with the commit passed in as a --build-arg (the Docker build context excludes .git via .dockerignore, so git rev-parse can't run inside the image build) — Makefile's brain-image target supplies it.
  • --version flag added to cmd/brain, cmd/host-agent-real, and cmd/host-agent (the fake) — each prints malmo 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-reported agent_version (below) does not carry a suffix.
  • make build was deliberately NOT changed to include host-agent-real. It's Linux + CGO + libpam0g-dev on 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 default build target would break the inner loop on exactly the platforms CLAUDE.md promises "no platform-specific setup" for. It's still stamped via its own make host-agent-real target 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).

How it maps to the specs

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.

How it was tested

  • make check green (gofmt, vet, openapi-check, full Go suite including pamverifierlibpam0g-dev was present, so the full suite ran, not test-nopam).
  • make check-web green (npm run gen:api regenerated the TS client with no further diff after commit, npm run build succeeded).
  • Built and ran the actual binaries, not just compiled them: make build host-agent-real then ./.dev/brain --version, ./.dev/host-agent --version, ./.dev/host-agent-real --version — all three printed malmo 0.4.0 (g<short-sha>) (the fake additionally (fake)), proving the ldflags wiring actually stamps rather than silently emitting the dev/unknown zero-values.
  • New table-driven TestAgentVersionAcceptable in cmd/brain/main_test.go pins 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 deleted expectedAgentVersion) 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 refreshes last_checked_at without re-raising, and an unreachable host-agent leaves state untouched — same shape as before, updated fixture versions.
  • Validated .github/workflows/ci-cloud-image.yml parses 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).

Known gaps & deviations

  • cmd/host-agent-real's real system-status reporting (including a real per-binary agent_version) is not built — this was already true before this change (systemStatus hardcodes Hostname: "malmo-dev" for both binaries) and is out of scope here; noted so AgentVersion'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/version is explicitly deferred, per the task's scope boundary — the endpoint exists and is tested at the API layer, but nothing in web-ui/ reads it yet.

What's next

  • Wire GET /api/v1/system/version into the dashboard (About/Settings section) once there's a natural home for it.
  • When RELEASE_MANIFEST.md is actually built, decide then whether to collapse brain/ui into 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.