Tags: malmoos/malmo
Tags
Release v0.4.1 — hosted host-agent ships version-stamped (#342) * Release flow: gate PRs into dev, one repo version, publish the image on release tags (#338) * Fix CI to gate PRs into dev, and gate cloud-image publish on release tags dev is now the default branch, but ci-go.yml and ci-web.yml only triggered on pull_request: branches: [main] — every feature PR (which now targets dev) ran no CI at all. Add dev to both triggers alongside main, since main still needs gating for the dev->main PR that cuts a release. Also give ci-cloud-image.yml a push: tags: ['v*'] trigger so a release tag builds, boot-tests, and publishes the hosted cloud image automatically, without duplicating the workflow_dispatch build-only/build+publish path. Compute publish eligibility once (job-level SHOULD_PUBLISH) instead of repeating a fragile per-step expression, and thread the release tag into the Hetzner snapshot description/labels when one is present. Update every doc that hardcoded main as the branch-off/PR-into target (CLAUDE.md, README.md, docs/README.md, docs/dev/contributing.md, docs/dev/running-locally.md, the PR template) to describe the dev/main model, and document the release flow in docs/dev/contributing.md. * Stamp real build identity: one repo VERSION + git commit, --version flags Adds VERSION at the repo root (0.4.0) as the single source of truth for malmo's one repo version (brain/UI/host-agent ship from one commit, so there's one version, not three independently-bumped ones). A new dumb internal/version package holds Version + Commit, both stamped at build time via -ldflags -X (Makefile's new LDFLAGS, and cmd/brain/Dockerfile via a --build-arg since .git is excluded from the Docker context). cmd/brain, cmd/host-agent-real, and cmd/host-agent (fake) each gain a --version flag. internal/hostagent.AgentVersion (previously the hardcoded "0.0.1-fake") now derives from the same stamped version.Version, with no "-fake" suffix: the systemStatus handler it feeds is shared code compiled into both the fake and the real binary, and already reports placeholder data for both alike (Hostname is hardcoded "malmo-dev" the same way) — a "-fake" suffix would have mislabeled host-agent-real too. make build deliberately still excludes host-agent-real: it's Linux + CGO + libpam0g-dev on both its build tags already, so folding it in would break the inner loop on macOS/Windows/WSL2-without-headers. It's still stamped via its own target for direct/cloud-image/nspawn builds. * version-mismatch: compatible range against a minimum, not exact equality cmd/brain's expectedAgentVersion (exact-match constant) is replaced by minimumAgentVersion, a floor. checkAgentVersion now raises only when host-agent's reported version is older than the floor; a newer agent never raises. This matches UPDATES.md #7's compatibility-matrix model (minimum_host_agent) and its update ordering (host-agent updates before brain, #1), under which a brain running ahead of its paired agent mid-rollout is normal, not a mismatch — the old exact-match check would have false-alarmed on every box during an apt-driven host-agent rollout window (#2, up to 24h). 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 -> modernc.org/libc.test), so `go mod tidy` promoted it to a direct require with no new go.sum hashes — no new entry in the supply-chain closure. Comparison strips any prerelease/build suffix first so a suffix like "-fake" can't make an otherwise-current version look older than a release core (x/mod/semver's prerelease-sorts-before- release rule would otherwise make the in-repo fake agent flag itself as mismatched against its own paired dev brain on every `make dev`). Adds GET /api/v1/system/version (brain's own stamped version + commit, no host round trip) on the existing system.go home; regenerates api/openapi.{json,yaml} and the web-ui TS client to match. Rewrites the version-mismatch health-issue detail string and its built-in Summary off exact-equality wording ("expects %s") to the minimum-version semantics in plain English. Adds TestAgentVersionAcceptable (table-driven, pins older/newer/exact/ suffix/garbled cases) and rewrites the existing TestCheckAgentVersion_* suite for the new semantics, including a dedicated newer-never-raises case for the behavior this change exists to fix. * ci-cloud-image: assert a pushed tag matches VERSION before the ~40min build A release tag that doesn't match the repo's VERSION file (e.g. the release PR forgot to bump it) used to only surface after the full mkosi build + seeded-boot gate ran — miserable to discover 40 minutes in. New early step, right after checkout, fails fast: v$(cat VERSION) must equal github.ref_name on a tag push. Gated to the push event only, so workflow_dispatch (no real version tag) is unaffected. Reconciles the publish step's Hetzner snapshot label with VERSION now that it exists: previously malmo-version=${MALMO_TAG}, left empty on a workflow_dispatch run to avoid a "bogus" label since ref_name isn't a version there. Now that VERSION is asserted equal to the tag on a tag push, and is the real built version on every trigger, the label reads $(cat VERSION) unconditionally — more accurate than the old tag-only conditional, not a redundant second field. ci-go.yml's path filter gains VERSION so a hypothetical release PR whose only diff is the version bump still triggers the Go gate. * Docs: one repo version + image-inherits-SemVer, and the range-based detector BUILD.md # Versioning rewritten: flips the locked "SemVer for host-agent and brain" (independent per-component) to one repo VERSION, and flips the locked "CalVer for the ISO" to the image inheriting that SemVer — both premised on multiple independently-versioned components bundled into the image, which no longer holds once there's one version to bundle. Artifact list and locked-decisions bullets updated to match. Two DECISIONS.md entries (2026-07-16), one per flip, each with its own why. HEALTH.md # Version: the version-mismatch row and the locus-C catalog row both updated from "lockstep pair" / "exact equality" wording to the compatible-range semantics; the cross-cutting debounce policy's 1-shot exception example updated too. RELEASE_MANIFEST.md: one-sentence note that its separate brain/ui semver fields are always equal now that there's one repo version — not a schema redesign, which stays out of scope (the manifest is unbuilt). docs/dev/contributing.md # Release model: the VERSION bump is now an explicit step in the release sequence (bump -> merge dev->main -> tag matching VERSION -> tag publishes), and the ci-cloud-image tag-assert gate is mentioned. docs/architecture.md: internal/version added to the package table. New progress entry docs/progress/repo-version-and-compat-range.md, appended to the bottom of docs/progress/README.md's index. * Phase 3: auto-tag main on VERSION bump, release+publish via reusable workflow Add .github/workflows/release.yml, triggered on push to main. It checks whether VERSION names a version with no matching vX.Y.Z tag yet; if the tag already exists the run is a clean no-op (most merges to main don't bump VERSION). If it doesn't exist, the workflow tags the merge commit, opens a GitHub Release with auto-generated notes, then invokes ci-cloud-image.yml directly as a reusable workflow (workflow_call, publish: true) to build and publish the hosted cloud image. Calling it directly rather than relying on ci-cloud-image.yml's existing push: tags trigger is required, not stylistic: a tag pushed with the default GITHUB_TOKEN does not fire another workflow's tag-push trigger (GitHub suppresses GITHUB_TOKEN-authored push events to prevent recursive runs), so relying on that event would silently tag a release and never build/publish anything. push: tags stays on ci-cloud-image.yml as the manual escape hatch for a human pushing a tag by hand; because the auto-tag never fires it, the two paths can't double-build. ci-cloud-image.yml's SHOULD_PUBLISH and its tag-assert step's `if: github.event_name == 'push'` already behave correctly under workflow_call with no logic changes (inputs.publish is populated the same way for workflow_call and workflow_dispatch), but both are commented to say so explicitly for the next reader. Tag+release are created before the image build, not after: a tag is a source-level fact independent of whether an image was later published from it, and a failed/slow multi-hour build is then a separable, retriable problem that doesn't block the release existing or block a following release. Updates docs/dev/contributing.md # Release model and docs/specs/BUILD.md # Versioning, both of which described manual tagging by the maintainer; they now describe the automatic flow. No progress entry: this is CI/workflow tooling with no product-facing behavior change, matching the precedent set by Phase 1 (contributing.md's "dev tooling goes in docs/dev/" rule). * Gate the tag-assert and publish on the ref being a tag, not just event_name In a reusable workflow the `github` context belongs to the CALLER, not the called workflow. release.yml is triggered by `push: branches: [main]`, so inside ci-cloud-image.yml on the workflow_call path github.event_name is `push` (not `workflow_call`) and github.ref is `refs/heads/main`. Two conditions keyed on `github.event_name == 'push'` were wrong as a result: - The tag-assert step would RUN on every auto-release and compare `main` against `v<VERSION>`, exiting 1. release.yml pushes the tag and cuts the GitHub Release before calling us, so every release would have produced a tag and a Release with no image, failing 30 seconds in. - SHOULD_PUBLISH evaluated true via the event_name half rather than inputs.publish. Correct today only by accident: a future caller passing publish=false would publish anyway. Keying both on `startsWith(github.ref, 'refs/tags/')` distinguishes a genuine tag push from a call made on a branch. The call path needs no assert — the tag is derived from VERSION by release.yml, so it matches by construction. * Lead the Hetzner snapshot description with the version desc is what the Hetzner console shows as the snapshot's name in the images list. The version was buried mid-string behind the run_id, so scanning the list for 'which malmo is this' meant reading past noise. Version first. * release.yml: fail loudly when VERSION is bumped to an already-tagged version The decide step keyed only on "does the tag exist?", which conflates two opposite situations. An ordinary merge that doesn't bump VERSION should no-op quietly. But a merge that DOES bump VERSION to a version already tagged is a real release that cannot be cut — and exiting green swallowed it: you'd merge the release PR, see all-green, and get no tag movement, no GitHub Release and no image, with nothing saying why. Not hypothetical. This workflow tags before building (deliberately), so a release whose image build fails leaves the tag behind. Merging the fix without first deleting that tag hit exactly this path, and the failure was silent. Now three outcomes: no tag -> release; tag + VERSION unchanged -> skip; tag + VERSION bumped -> error naming both versions, where the tag points, and how to recover. Needs fetch-depth: 0 to read VERSION at github.event.before and to resolve the tag. Where the previous VERSION can't be read (first push, force-push), it falls back to the old skip behaviour with a warning. Reported by Greptile on #338. * Stamp the hosted host-agent build so a released box doesn't self-flag (#340) A v0.4.0 Hetzner box raised version-mismatch and blocked app installs while otherwise healthy: dev/cloud/stage-control-plane.sh built the slim hosted host-agent with a bare `go build` and no -ldflags. It's the only go build in the cloud-image staging path (brain/UI go through Docker, whose Dockerfile reads `cat VERSION` itself), so it shipped internal/version's unstamped "dev" default. hostagent.AgentVersion is version.Version verbatim, so the agent reported "dev" to the brain, semver can't parse it, an unparseable core sorts before every valid version, and the check read it as older than 0.4.0. The >= comparison was never at fault — a stamped 0.4.0 agent satisfies a 0.4.0 minimum by equality. minimumAgentVersion is untouched (it moves only on a real protocol break) and no spec changes: BUILD.md # Versioning already required every build to stamp both fields; the cloud build silently violated it. stage_build_go now applies the Makefile's LDFLAGS via stage_version_ldflags. Recomputed rather than inherited: bootstrap.sh runs as `sudo -E`, so make's MALMO_VERSION/MALMO_COMMIT never reach it. git runs as CALLER (under sudo the repo is owned by the invoking user and git refuses a dubious-ownership repo as root), with the Makefile's `|| echo unknown` fallback rather than failing a ~40min image build; only Commit can degrade, Version comes from the file. cloud-assertions.sh step 1c asserts the baked binary reports "malmo X.Y.Z (g<sha>)". Verified it fails against the real unstamped v0.4.0 binary and passes against the stamped one. The boot lane is the only place this is observable: the stamp is applied by the build command, so a `go test` binary is unstamped by construction, and the release workflow's tag assert checks the VERSION file rather than what landed in the binary. * Bump VERSION to 0.4.1 (#341) Prepares the 0.4.1 release (docs/dev/contributing.md # Release model: the VERSION bump is what makes a dev->main merge a release). Ships #340: the hosted host-agent build is now version-stamped. v0.4.0 images are structurally broken — the agent reported "dev", which the brain's minimumAgentVersion check reads as older than every release, so every box provisioned from one raises version-mismatch and cannot install apps. The baked binary can't self-heal, so the fix only reaches boxes via a new image, which is what this bump cuts.
Release flow: gate PRs into dev, one repo version, publish the image … …on release tags (#338) (#339) * Fix CI to gate PRs into dev, and gate cloud-image publish on release tags dev is now the default branch, but ci-go.yml and ci-web.yml only triggered on pull_request: branches: [main] — every feature PR (which now targets dev) ran no CI at all. Add dev to both triggers alongside main, since main still needs gating for the dev->main PR that cuts a release. Also give ci-cloud-image.yml a push: tags: ['v*'] trigger so a release tag builds, boot-tests, and publishes the hosted cloud image automatically, without duplicating the workflow_dispatch build-only/build+publish path. Compute publish eligibility once (job-level SHOULD_PUBLISH) instead of repeating a fragile per-step expression, and thread the release tag into the Hetzner snapshot description/labels when one is present. Update every doc that hardcoded main as the branch-off/PR-into target (CLAUDE.md, README.md, docs/README.md, docs/dev/contributing.md, docs/dev/running-locally.md, the PR template) to describe the dev/main model, and document the release flow in docs/dev/contributing.md. * Stamp real build identity: one repo VERSION + git commit, --version flags Adds VERSION at the repo root (0.4.0) as the single source of truth for malmo's one repo version (brain/UI/host-agent ship from one commit, so there's one version, not three independently-bumped ones). A new dumb internal/version package holds Version + Commit, both stamped at build time via -ldflags -X (Makefile's new LDFLAGS, and cmd/brain/Dockerfile via a --build-arg since .git is excluded from the Docker context). cmd/brain, cmd/host-agent-real, and cmd/host-agent (fake) each gain a --version flag. internal/hostagent.AgentVersion (previously the hardcoded "0.0.1-fake") now derives from the same stamped version.Version, with no "-fake" suffix: the systemStatus handler it feeds is shared code compiled into both the fake and the real binary, and already reports placeholder data for both alike (Hostname is hardcoded "malmo-dev" the same way) — a "-fake" suffix would have mislabeled host-agent-real too. make build deliberately still excludes host-agent-real: it's Linux + CGO + libpam0g-dev on both its build tags already, so folding it in would break the inner loop on macOS/Windows/WSL2-without-headers. It's still stamped via its own target for direct/cloud-image/nspawn builds. * version-mismatch: compatible range against a minimum, not exact equality cmd/brain's expectedAgentVersion (exact-match constant) is replaced by minimumAgentVersion, a floor. checkAgentVersion now raises only when host-agent's reported version is older than the floor; a newer agent never raises. This matches UPDATES.md #7's compatibility-matrix model (minimum_host_agent) and its update ordering (host-agent updates before brain, #1), under which a brain running ahead of its paired agent mid-rollout is normal, not a mismatch — the old exact-match check would have false-alarmed on every box during an apt-driven host-agent rollout window (#2, up to 24h). 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 -> modernc.org/libc.test), so `go mod tidy` promoted it to a direct require with no new go.sum hashes — no new entry in the supply-chain closure. Comparison strips any prerelease/build suffix first so a suffix like "-fake" can't make an otherwise-current version look older than a release core (x/mod/semver's prerelease-sorts-before- release rule would otherwise make the in-repo fake agent flag itself as mismatched against its own paired dev brain on every `make dev`). Adds GET /api/v1/system/version (brain's own stamped version + commit, no host round trip) on the existing system.go home; regenerates api/openapi.{json,yaml} and the web-ui TS client to match. Rewrites the version-mismatch health-issue detail string and its built-in Summary off exact-equality wording ("expects %s") to the minimum-version semantics in plain English. Adds TestAgentVersionAcceptable (table-driven, pins older/newer/exact/ suffix/garbled cases) and rewrites the existing TestCheckAgentVersion_* suite for the new semantics, including a dedicated newer-never-raises case for the behavior this change exists to fix. * ci-cloud-image: assert a pushed tag matches VERSION before the ~40min build A release tag that doesn't match the repo's VERSION file (e.g. the release PR forgot to bump it) used to only surface after the full mkosi build + seeded-boot gate ran — miserable to discover 40 minutes in. New early step, right after checkout, fails fast: v$(cat VERSION) must equal github.ref_name on a tag push. Gated to the push event only, so workflow_dispatch (no real version tag) is unaffected. Reconciles the publish step's Hetzner snapshot label with VERSION now that it exists: previously malmo-version=${MALMO_TAG}, left empty on a workflow_dispatch run to avoid a "bogus" label since ref_name isn't a version there. Now that VERSION is asserted equal to the tag on a tag push, and is the real built version on every trigger, the label reads $(cat VERSION) unconditionally — more accurate than the old tag-only conditional, not a redundant second field. ci-go.yml's path filter gains VERSION so a hypothetical release PR whose only diff is the version bump still triggers the Go gate. * Docs: one repo version + image-inherits-SemVer, and the range-based detector BUILD.md # Versioning rewritten: flips the locked "SemVer for host-agent and brain" (independent per-component) to one repo VERSION, and flips the locked "CalVer for the ISO" to the image inheriting that SemVer — both premised on multiple independently-versioned components bundled into the image, which no longer holds once there's one version to bundle. Artifact list and locked-decisions bullets updated to match. Two DECISIONS.md entries (2026-07-16), one per flip, each with its own why. HEALTH.md # Version: the version-mismatch row and the locus-C catalog row both updated from "lockstep pair" / "exact equality" wording to the compatible-range semantics; the cross-cutting debounce policy's 1-shot exception example updated too. RELEASE_MANIFEST.md: one-sentence note that its separate brain/ui semver fields are always equal now that there's one repo version — not a schema redesign, which stays out of scope (the manifest is unbuilt). docs/dev/contributing.md # Release model: the VERSION bump is now an explicit step in the release sequence (bump -> merge dev->main -> tag matching VERSION -> tag publishes), and the ci-cloud-image tag-assert gate is mentioned. docs/architecture.md: internal/version added to the package table. New progress entry docs/progress/repo-version-and-compat-range.md, appended to the bottom of docs/progress/README.md's index. * Phase 3: auto-tag main on VERSION bump, release+publish via reusable workflow Add .github/workflows/release.yml, triggered on push to main. It checks whether VERSION names a version with no matching vX.Y.Z tag yet; if the tag already exists the run is a clean no-op (most merges to main don't bump VERSION). If it doesn't exist, the workflow tags the merge commit, opens a GitHub Release with auto-generated notes, then invokes ci-cloud-image.yml directly as a reusable workflow (workflow_call, publish: true) to build and publish the hosted cloud image. Calling it directly rather than relying on ci-cloud-image.yml's existing push: tags trigger is required, not stylistic: a tag pushed with the default GITHUB_TOKEN does not fire another workflow's tag-push trigger (GitHub suppresses GITHUB_TOKEN-authored push events to prevent recursive runs), so relying on that event would silently tag a release and never build/publish anything. push: tags stays on ci-cloud-image.yml as the manual escape hatch for a human pushing a tag by hand; because the auto-tag never fires it, the two paths can't double-build. ci-cloud-image.yml's SHOULD_PUBLISH and its tag-assert step's `if: github.event_name == 'push'` already behave correctly under workflow_call with no logic changes (inputs.publish is populated the same way for workflow_call and workflow_dispatch), but both are commented to say so explicitly for the next reader. Tag+release are created before the image build, not after: a tag is a source-level fact independent of whether an image was later published from it, and a failed/slow multi-hour build is then a separable, retriable problem that doesn't block the release existing or block a following release. Updates docs/dev/contributing.md # Release model and docs/specs/BUILD.md # Versioning, both of which described manual tagging by the maintainer; they now describe the automatic flow. No progress entry: this is CI/workflow tooling with no product-facing behavior change, matching the precedent set by Phase 1 (contributing.md's "dev tooling goes in docs/dev/" rule). * Gate the tag-assert and publish on the ref being a tag, not just event_name In a reusable workflow the `github` context belongs to the CALLER, not the called workflow. release.yml is triggered by `push: branches: [main]`, so inside ci-cloud-image.yml on the workflow_call path github.event_name is `push` (not `workflow_call`) and github.ref is `refs/heads/main`. Two conditions keyed on `github.event_name == 'push'` were wrong as a result: - The tag-assert step would RUN on every auto-release and compare `main` against `v<VERSION>`, exiting 1. release.yml pushes the tag and cuts the GitHub Release before calling us, so every release would have produced a tag and a Release with no image, failing 30 seconds in. - SHOULD_PUBLISH evaluated true via the event_name half rather than inputs.publish. Correct today only by accident: a future caller passing publish=false would publish anyway. Keying both on `startsWith(github.ref, 'refs/tags/')` distinguishes a genuine tag push from a call made on a branch. The call path needs no assert — the tag is derived from VERSION by release.yml, so it matches by construction. * Lead the Hetzner snapshot description with the version desc is what the Hetzner console shows as the snapshot's name in the images list. The version was buried mid-string behind the run_id, so scanning the list for 'which malmo is this' meant reading past noise. Version first. * release.yml: fail loudly when VERSION is bumped to an already-tagged version The decide step keyed only on "does the tag exist?", which conflates two opposite situations. An ordinary merge that doesn't bump VERSION should no-op quietly. But a merge that DOES bump VERSION to a version already tagged is a real release that cannot be cut — and exiting green swallowed it: you'd merge the release PR, see all-green, and get no tag movement, no GitHub Release and no image, with nothing saying why. Not hypothetical. This workflow tags before building (deliberately), so a release whose image build fails leaves the tag behind. Merging the fix without first deleting that tag hit exactly this path, and the failure was silent. Now three outcomes: no tag -> release; tag + VERSION unchanged -> skip; tag + VERSION bumped -> error naming both versions, where the tag points, and how to recover. Needs fetch-depth: 0 to read VERSION at github.event.before and to resolve the tag. Where the previous VERSION can't be read (first push, force-push), it falls back to the old skip behaviour with a warning. Reported by Greptile on #338.