Skip to content
commission

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

targetrunnerartifact
darwin-arm64macos-15commission-darwin-arm64
darwin-x64macos-15-intelcommission-darwin-x64
linux-x64ubuntu-22.04commission-linux-x64
linux-arm64ubuntu-22.04-armcommission-linux-arm64
windows-x64windows-2022commission-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-b and commission-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 → done

around which sit the assertions a compiled binary can fail while bun test passes:

assertionwhat it catches
database created under a COMMISSION_DB path whose parents do not exista missing recursive mkdir
default database lands under homedir()/.commissionWindows resolves homedir() from USERPROFILE, POSIX from HOME — the test sets both
next --claim from three directories down finds .commission.jsonthe upward config walk, across platform separators
board from a directory with no config above it anywherea walk that fails to terminate at the filesystem root — a hang, not an error
done on unchecked criteria exits 2; unknown id exits 3exit codes surviving compilation, historically a Windows casualty
sync-github with PATH stripped exits 2, not 1a 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 2

Applied 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:

startterminates atsteps
C:\C:\0
C:\Users\a\projC:\3
C:/Users/a (forward slashes)C:/2
\\server\share (UNC root)\\server\share0
\\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.

targetsizegzip--version medianboard medianmeasured on
darwin-arm6462.1 MiB23.0 MiB37.5 ms42.1 msApple silicon, native
darwin-x6467.6 MiB25.4 MiBran under Rosetta 2; timings would be emulation, not the platform
linux-x6491.8 MiB34.4 MiBran in an emulated x64 container; same caveat
linux-arm6490.9 MiB34.1 MiB61.3 ms66.4 mscontainer, native arm64
windows-x6495.5 MiB36.7 MiBbuilt 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 (file reports not 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 --compile is 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 20

With 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/reports

which 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 an oven/bun container 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-x64 has never been run. The binary is a valid PE32+ console executable and the Windows-specific logic was audited by tracing path.win32 semantics — 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.7 plus shellcheck 0.10.0 over every run: 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.