ADR 006 Build strategy - nekrozis/qtogo GitHub Wiki

ADR-006: Building and testing

Status: Accepted (2026-10-07).

Context

The project is a Go CLI whose archive extractor is C, so a build needs two toolchains and a decision about the second one. Three things said that decision was being made by accident:

  • The Makefile that drove the build assumed a POSIX shell. On Windows that means Git Bash or a second set of commands copied out of the README, and the commands it ran — rm -rf, test -z, grep | awk — are shell idioms rather than build logic.
  • CI set no CC at all. On Linux the binary therefore linked against whatever glibc the runner image happened to carry, so the floor a published binary required moved whenever GitHub updated the image, and was never a number this project chose.
  • The race detector needs tsan symbols, which zig cc does not provide, so a single compiler for everything is not available even in principle.

Decision

  1. A Go program is the build driver. bootstrap/main.go lives in the main module, so go build ./..., go vet, golangci-lint and CI cover it like any other code. It offers build, test, check and release, needs nothing beyond the Go toolchain the project already requires, and treats paths, arguments and subprocesses the same way on every platform. It names the tools it runs, so a missing one is reported as a missing tool rather than surfacing as a compiler error.

  2. It builds for the host only. A target the driver cannot run is a target it cannot test: macOS needs its SDK, Windows needs its API, and signing and notarization happen on the platform that will validate them. Cross-compilation is not refused on principle, it is simply not this decision.

  3. zig compiles the C on every platform. One toolchain decides what the C sources are built with, instead of each host's own idea of it, and it is one binary rather than one installation per platform. build -cc <compiler> names another one, which is what a Linux distribution packaging qtogo needs: the platform rules — the glibc target of decision 4 above all — describe zig and are skipped for a compiler this project did not choose. What it publishes stays what zig built.

    The C is compiled at -O2 -g, which is cgo's own default (DefaultCFlags), so no flag of ours sets it; the release linker's -s -w then discards the debug info, and a shipped binary carries no .debug_* section at all. The -g costs compile time and nothing else.

  4. On Linux the glibc to build against is named: 2.31. That is Debian oldoldstable, while Debian 13 is stable, so nothing the binary needs may be newer than the oldest distribution this project means to support. It is one number for every architecture: a per-architecture table would be a second thing to keep current for no stated gain.

    The number is not what makes the binary portable today, and the reason is not which compiler: the same sources built with the system gcc come out byte-identical, both statically linked, with no interpreter and no needed libraries. What makes them so is that nothing in the cgo objects references a libc symbol — the SDK's own allocator is unreachable and dropped, and the rest is satisfied by Go's runtime — so the linker has no shared library to record.

    The floor is therefore a bound on what the C side may require, and it becomes load-bearing the day some C source does reach for libc: a future SDK, or an option that calls one. (centos:7 stood in the first draft as the case that had to fail; it ran, which is what prompted looking.)

    What watches this is its own workflow rather than a job in the ordinary one. It builds a Linux binary and starts it in two containers — the slowest thing the repository does — and it guards one promise rather than the code in general, so running it on every change would trade minutes for nothing. It runs when a thing that could change the promise moves: the driver, the toolchain it installs, the vendored C. It runs on demand too, and that is what a release does before it publishes a Linux binary: the check is a gate in front of the risky step, not a tax on every commit.

  5. A race build is the one exception. -race drops the compiler it would otherwise use and leaves it to the host, because the detector needs tsan and zig does not carry it. On Windows that still means the race build runs in CI rather than locally.

  6. Packaging is manual and publishing is not wired. release writes a host archive — the binary, its licence, and a checksum — into dist/. Nothing publishes it, and the version is whatever the checkout says: what a release is called and when one happens are still open, and inventing a tag convention to serve a build driver would be backwards.

Consequences

  • Contributing needs Go and (for the C side) zig, on any platform, through one command documented in one place. The driver is ordinary code: it is linted, tested, and exits with the status of the tool it ran — when the driver is the process that runs. Invoked as go run ./bootstrap, which is how the README shows it, go run reports its own status instead, so a caller that needs the exact code builds the driver first.
  • The Linux floor is a constant to keep: when Debian's oldoldstable moves, the floor moves with it. Today it is inert — no cgo object references a libc symbol, so the binary is static — and the check that reads the binary's glibc requirement is what will say so when that stops being true.
  • A race build and a release build are compiled by different C compilers. That is deliberate and only affects what the detector sees, not what ships.
  • The floor is a promise about starting, not about behaviour: the binary still needs the Qt and X11 libraries the system happens to have, which is the SDK's business rather than the build's.

Alternatives considered

  • Keep the Makefile. Rejected: it needs a shell the project does not otherwise require, and on Windows it produced a second, differently written path to the same build.
  • A shell script per platform. Rejected: three scripts to keep in step, and the platform differences are exactly the parts worth having in one place.
  • Install a C compiler per platform and keep using it. Rejected: the compiler would decide the Linux glibc floor, which is the thing that needed deciding.
  • Cross-compile everything from one runner. Deferred rather than rejected: it is cheaper in runners and more expensive everywhere a platform's own SDK, signature or notary is involved, and none of that is settled yet.
  • Per-architecture glibc floors, as some projects do to reach further back. Rejected: this project ships a baseline binary for modern machines, so one number that names a distribution release is easier to explain and to keep current than a table.
⚠️ **GitHub.com Fallback** ⚠️