ADR 0011 — linux/macOS/windows are first-class; OS behavior behind build-tag splits

  • Status: Accepted
  • Date: 2026-07-01
  • Tracking: legacy tracker 1759, legacy tracker 1731 (Linux-assumption regressions); release/install overhaul epic

Context

madtea ships binaries for linux, macOS, and windows (GoReleaser builds linux_{amd64,arm64}, darwin_{amd64,arm64}, windows_amd64). Yet the code carried Unix-only assumptions that surfaced only as Windows bugs:

  • internal/safeio fsynced the parent directory after a rename — legal on POSIX, unsupported on Windows (FlushFileBuffers on a directory handle errors), so every credentialed write failed on Windows (legacy tracker 1759).
  • internal/selfreplace relied on os.Rename overwriting an existing destination — POSIX-atomic, but Windows refuses, and a locked running .old image blocked the self-update (legacy tracker 1731).
  • madtea install hardcoded /usr/local/bin + sudo cp/chmod — meaningless on Windows.

Each was caught late (by a user on Windows, or by a manual GOOS=windows build during review) because CI only builds and tests on linux (golang:1.26-alpine). The implicit default was “Unix,” and nothing structurally enforced otherwise.

Decision

linux, macOS, and windows are first-class targets. OS-specific behavior lives behind build-tag splits, and every published OS target is cross-compiled in CI.

  • No Unix-only assumptions in shared code. Filesystem, path, process, and install logic must be correct on all three targets. Where behavior genuinely differs, split it behind //go:build tags with a small, named seam — a real implementation and a no-op/alternative counterpart — as done for atomic_{unix,windows}.go (directory fsync: real on POSIX, no-op on Windows) and oldpath_{unix,windows}.go (aside-rename: overwrite on POSIX, retry + unique-name fallback on Windows). Prefer pure-Go stdlib primitives (os.Rename, io.Copy, filepath) over shelling out to cp/chmod/sudo, which don’t exist on Windows.
  • GOOS-per-target go build ./... is the cross-platform CI gate. There is no Windows or macOS runner, so a Linux-hosted job cross-compiles for each published target (GOOS=windows, GOOS=darwin, GOOS=linux) and fails on any compile error in an OS-tagged file. Cross-compilation cannot exercise runtime behavior, so a build-tag-split file that carries real logic must also have unit tests under its build tag, and a PR touching an OS-specific path states that runtime verification on that OS is pending until a native runner exists.

Consequences

  • The legacy tracker 1759/legacy tracker 1731 bug class is structurally guarded. A new Unix-only assumption that breaks the Windows build now fails the cross-compile gate in CI instead of reaching a user. (Runtime-only Windows bugs still need a native run — the gate catches compile-level regressions, not all behavior.)
  • A small standing cost. OS-specific behavior costs a build-tag file pair and a per-target CI build — the accepted price of a first-class cross-platform tool.
  • Consistent with ADR 0010. The install/release overhaul assumes a cross-platform madtea install and cross-platform release artifacts; this ADR records the standard they depend on.

Alternatives considered

  • Linux-first, best-effort elsewhere. Rejected: madtea is distributed for all three (Homebrew/Scoop/binaries), and legacy tracker 1759/legacy tracker 1731 show “best-effort” silently means “broken on Windows.”
  • Runtime runtime.GOOS guards instead of build-tag splits. Fine for a one-liner, but build-tag splits keep OS-specific code out of the other platforms’ compiled binary and read more clearly; splits are preferred for anything beyond a trivial branch.
  • Stand up Windows/macOS CI runners now. Deferred: costly (the runner is a single Linux Incus VM today). Cross-compilation catches the compile-level class cheaply; native runners are a later, separately-tracked investment.

Canonical source: docs/adr/0011-cross-platform-first-class.md in the madtea repo.