Release engineering: the binary matrix
How commission’s five platform binaries are built, proven, and checksummed. The human decisions around a release are release-checklist.md; what a version number promises is compatibility.md. This page is the machinery underneath both.
Everything here is executed by
.github/workflows/release.yml .github/workflows/release.yml and four
scripts, each of which also runs standalone on a laptop.
The five targets
| target | runner | artifact |
|---|---|---|
darwin-arm64 | macos-15 | commission-darwin-arm64 |
darwin-x64 | macos-15-intel | commission-darwin-x64 |
linux-x64 | ubuntu-22.04 | commission-linux-x64 |
linux-arm64 | ubuntu-22.04-arm | commission-linux-arm64 |
windows-x64 | windows-2022 | commission-windows-x64.exe |
Two deliberate choices in that table:
Each target is built on its own runner, not cross-compiled from one. Bun can
cross-compile all five from a single machine — the artifacts are valid, and the
scripts support it with --cross. But a cross-compiled binary is a binary nobody
has run. The workflow builds natively so the same runner can immediately execute
the artifact it just produced.
Linux builds on 22.04, not ubuntu-latest. The binaries are dynamically
linked against glibc (interpreter /lib64/ld-linux-x86-64.so.2), so the build
host’s glibc becomes the floor for every user. 22.04 ships glibc 2.35; building
on 24.04 would raise the floor to 2.39 and break Debian 12, RHEL 9, and Amazon
Linux 2023 users for no gain.
Runner labels move. macos-13 — the obvious choice for Intel macOS — has been
retired, and macos-14 is deprecated. If Intel macOS runners disappear
altogether, the fallback is to build darwin-x64 on the arm runner with
--target=bun-darwin-x64 and smoke-test it under Rosetta 2; that path is
verified to work, with the caveat noted under Known
sharp edges.
Reproducibility
The published SHA256SUMS claims something specific: the same source tree,
built with the same bun version for the same target, produces the same bytes.
That claim needs one piece of care. bun build --compile embeds the outfile’s
basename into the binary as the root of its virtual filesystem
(/$bunfs/root/<basename>). Measured on bun 1.3.14:
- three builds of identical source to the same outfile → one identical sha256
- the same source built to
commission-bandcommission-c→ two different sha256s, identical in size, differing only in that embedded string
So scripts/build-binary.ts always compiles to a constant staging basename
(commission / commission.exe) and renames afterwards. Building straight to
dist/commission-darwin-arm64 would have made each target’s checksum a function of
its own filename, and unverifiable by anyone rebuilding to a different path.
--verify-reproducible compiles twice through that identical staging path and
fails the build if the checksums disagree. The release workflow always passes it.
The claim does not extend to: two different bun versions (the runtime is
embedded, so the workflow pins BUN_VERSION), or a cross-compiled artifact
matching a native one.
The smoke test is the gate
A binary that compiles is not a release artifact. scripts/smoke-binary.ts runs
against the compiled executable — never against src/ — on the platform it was
built for, and the release stops if it fails.
The core of it is the loop commission exists for:
init → apply → next --claim → note → check → donearound which sit the assertions a compiled binary can fail while bun test
passes:
| assertion | what it catches |
|---|---|
database created under a COMMISSION_DB path whose parents do not exist | a missing recursive mkdir |
default database lands under homedir()/.commission | Windows resolves homedir() from USERPROFILE, POSIX from HOME — the test sets both |
next --claim from three directories down finds .commission.json | the upward config walk, across platform separators |
board from a directory with no config above it anywhere | a walk that fails to terminate at the filesystem root — a hang, not an error |
done on unchecked criteria exits 2; unknown id exits 3 | exit codes surviving compilation, historically a Windows casualty |
sync-github with PATH stripped exits 2, not 1 | a missing external tool crashing instead of refusing |
It is written in TypeScript rather than bash plus a PowerShell translation on purpose: one script that all five runners execute identically is the only way the Windows leg proves the same things as the Linux legs.
Windows: what the audit actually found
The brief for this work said Windows needed scrutiny rather than assumption. Here is what scrutiny produced, honestly separated into bugs that were fixed, things that turned out to be fine, and things that remain risks.
Fixed
Bun.spawnSync throws on a missing executable — it does not return a non-zero
exit code. This was the real bug, and it was not Windows-specific, though
Windows is where it bites hardest: neither gh nor a POSIX curl is a safe
assumption there.
Error: Executable not found in $PATH: "gh"Every adapter had written the graceful path as if (proc.exitCode !== 0) { … degrade … }, which is unreachable when the throw happens first. Concretely,
commission sync-github on a machine without gh produced a stack trace and exit 1
from detectRepo, whose entire contract is to return null when detection
fails. commission sync skipped nothing and crashed. src/adapters/spawn.ts now
wraps the call and turns “not installed” back into an ordinary failed result with
a missing flag, so the call sites keep one code path and can say which of the
two happened:
$ PATH= commission sync-github --project commission --repo example/nope
gh pr list failed: gh is not installed or not on PATH
hint: install the GitHub CLI (https://cli.github.com) and run 'gh auth login'
exit 2Applied in src/adapters/observe.ts, src/adapters/github.ts, and
src/adapters/jira-push.ts. The smoke test asserts it on every platform.
writeServeState wrote beside the database without creating the directory.
It worked only because openStore happened to mkdir first. commission serve
against a fresh COMMISSION_DB in a not-yet-existing directory should not depend on
that ordering; src/core/serve-state.ts now creates it.
Verified fine
The database location. join(homedir(), ".commission", "commission.db") is correct on
Windows — Node and Bun resolve homedir() from USERPROFILE — and node:path
produces C:\Users\me\.commission\commission.db. The recursive mkdir in openStore
covers the first run. The smoke test asserts the file actually appears.
The .commission.json upward walk. discover() terminates on dirname(dir) === dir, which is a POSIX-shaped test; the question was whether it terminates on
Windows path forms. Traced through path.win32 for every shape:
| start | terminates at | steps |
|---|---|---|
C:\ | C:\ | 0 |
C:\Users\a\proj | C:\ | 3 |
C:/Users/a (forward slashes) | C:/ | 2 |
\\server\share (UNC root) | \\server\share | 0 |
\\server\share\p | \\server\share\ | 1 |
\\?\C:\x (extended-length) | \\?\C:\ | 1 |
No cycles, no non-termination. The walk is correct as written.
Worktree paths. There are none yet. workspace.strategy and
workspace.dir exist as config keys, and src/workspace/ currently registers
only the in-place provider, which by design creates nothing. When a worktree
provider lands, the Windows questions it will have to answer are the 260-character
MAX_PATH limit on nested .worktrees/<task-id> paths and whether the provider
shells out to git worktree — neither of which can be tested against code that
does not exist. src/workspace/state.ts already writes its state file correctly
(recursive mkdir, atomic rename).
Exit codes. 0 / 2 / 3 asserted per platform in the smoke test rather than assumed.
Remaining risks
commission branch --checkout still crashes if git is absent. The same
Bun.spawnSync throw, at src/cli/commands/interop.ts:69. It is left unfixed
here only because that file was being edited concurrently by another task; the
fix is to route it through spawnTool exactly as the adapters now do. Worth its
own task.
curl on Windows. observe.ts and jira-push.ts shell out to curl to
keep the sync path synchronous end to end. Windows 10 1803+ ships curl.exe in
System32 so it usually resolves — but a PowerShell curl is an alias for
Invoke-WebRequest, not curl, and will never be what gets spawned here. The
failure is now a refusal that says so rather than a crash. Replacing curl with
fetch would mean making the sync path async, which is a larger change than a
release task should smuggle in.
WAL on network home directories. SQLite’s WAL mode requires shared memory,
which is unavailable on SMB/network shares — and roaming or redirected profiles
are common on managed Windows. PRAGMA journal_mode = WAL fails soft there:
SQLite silently stays in rollback-journal mode rather than erroring, so the only
symptom is degraded concurrency. commission doctor reports the actual
journal_mode, and its output is the thing to check on a Windows machine that
feels slow with several agents running. Setting COMMISSION_DB to a local path is the
fix.
Nothing on this page has been executed on real Windows. See What is verified.
Measured numbers
Recorded by scripts/measure-binary.ts and reproduced into the release body by
scripts/release-notes.ts. These are from local builds on bun 1.3.14 at the time
this pipeline was written; CI regenerates them per release, and the release notes
carry the CI numbers, not these.
| target | size | gzip | --version median | board median | measured on |
|---|---|---|---|---|---|
darwin-arm64 | 62.1 MiB | 23.0 MiB | 37.5 ms | 42.1 ms | Apple silicon, native |
darwin-x64 | 67.6 MiB | 25.4 MiB | — | — | ran under Rosetta 2; timings would be emulation, not the platform |
linux-x64 | 91.8 MiB | 34.4 MiB | — | — | ran in an emulated x64 container; same caveat |
linux-arm64 | 90.9 MiB | 34.1 MiB | 61.3 ms | 66.4 ms | container, native arm64 |
windows-x64 | 95.5 MiB | 36.7 MiB | — | — | built only; never executed |
Sizes and gzip sizes are exact for every target — they are properties of the bytes, not of where the bytes ran. Timings are only filled in where the binary executed on hardware it was built for; the two emulated runs proved the binaries work, but their latency numbers would measure the emulator. CI fills the rest of that column in on the first real run.
--version loads and initialises the whole bundle and exits. board
additionally opens SQLite, runs migrations, and renders — the pair separates
“how expensive is the runtime” from “how expensive is the first useful command”.
The gap between them is 5 ms, so essentially all of commission’s startup cost is the
embedded bun runtime, and none of it is commission.
Two facts worth keeping in view when these numbers are quoted:
- The Linux binaries are ~50% larger than the macOS ones because they retain
their debug sections (
filereportsnot stripped). That is a bun packaging characteristic, not something in commission’s control today. - Size is the weak number, not startup. ~60–95 MiB per artifact is entirely
the embedded runtime; a hello-world
bun build --compileis the same order. The gzip column is what a download actually costs, and is roughly a third.
Running it by hand
bun run scripts/build-binary.ts --target linux-x64 --verify-reproducible
bun run scripts/smoke-binary.ts dist/commission-linux-x64
bun run scripts/measure-binary.ts dist/commission-linux-x64 --runs 20With no --target, the host target is inferred. Adding --report <path> to the
build and measure scripts writes their JSON where scripts/release-notes.ts
expects to find it:
bun run scripts/release-notes.ts --dist dist --reports dist/reportswhich re-hashes every artifact, compares against what the building runner
recorded, and writes SHA256SUMS and ARTIFACTS.md. The re-hash is not
ceremony: artifacts travel through upload and download between CI jobs, and a
mismatch there means the bytes being published are not the bytes that were
smoke-tested — the one failure a checksum file cannot detect about itself.
What is verified
Stated plainly, because a release pipeline that overstates its own testing is worse than one that admits gaps.
Executed and passing, locally:
darwin-arm64— built natively, all 16 smoke steps pass, measured.linux-arm64— cross-compiled, all 16 smoke steps pass in anoven/buncontainer on arm64, measured.linux-x64— cross-compiled, all 16 smoke steps pass in an emulated x64 container.darwin-x64— cross-compiled, all 16 smoke steps pass under Rosetta 2.- Build reproducibility — three builds to a fixed path agree; two builds to differing paths do not. Both directions confirmed.
- All five targets produce valid executables for their architecture
(
Mach-O x86_64,ELF aarch64,ELF x86-64,PE32+ x86-64).
Not executed:
windows-x64has never been run. The binary is a valid PE32+ console executable and the Windows-specific logic was audited by tracingpath.win32semantics — but no commission command has executed on Windows. The first CI run is the first real test, and until it goes green the Windows artifact should be treated as unproven.- The workflow has never run. GitHub Actions cannot be executed from a
laptop. It is statically validated with
actionlint 1.7.7plusshellcheck 0.10.0over everyrun:block — clean, including runner-label validation (confirmed to be real by checking that a deliberately bogus label is rejected) — and every step it invokes has been run by hand. That is a substitute for a CI run, not the same thing. - No release has been published. No tag has been pushed and no GitHub release exists.
Known sharp edges
Rosetta 2 and AVX. Running commission-darwin-x64 on Apple silicon works, but
prints to stderr on every invocation:
warn: CPU lacks AVX support, strange crashes may occur.That is Rosetta not emulating AVX. The same warning appears on genuinely old
x64 hardware without AVX2 — pre-Haswell Intel Macs and older Linux servers —
where it indicates a real crash risk rather than an emulation artifact. Bun
publishes -baseline variants for exactly this, and
bun build --compile --target=bun-linux-x64-baseline builds successfully today.
If users on pre-2013 hardware appear, publishing *-baseline x64 artifacts
alongside the standard ones is a one-line change to the target table.
Release assets do not carry the executable bit. Neither GitHub release assets
nor actions/upload-artifact preserve it. ARTIFACTS.md tells users to
chmod +x; any install script must do the same.
This page is docs/release-engineering.md in the Commission
repository, rendered in place — the site keeps no copy of it. The repository is private, so there is no edit link to follow.