ADR-0009 — The pre-release macOS-VM E2E is a structural gate, and VERSION (not a tag) is the release's source of truth
- Status: Accepted.
- Date: 2026-07-22.
- Applies to: How augur cuts a release — the protected
releasebranch,VERSION,scripts/release-gate.sh,.github/workflows/release.yml, andaugur version/installversion reporting (README "Cutting a release"). Supersedes the prior trust-based "remember to runmake e2ebefore tagging" convention. Landed on PR #108.
augur's heavy pre-release gate — the macOS-VM E2E (make e2e: boot a VM, xcodebuild test,
virtiofs + testmanagerd + VM-mode egress fail-closed) — is enforced by the server and executed
on a Mac, instead of trusting a human to remember it before tagging. Concretely:
VERSION(repo root) is the single source of truth for the version number.augur versionandinstallread it; git is used only to append a dev suffix (<v>-dev+<sha>) when HEAD is not exactly the release tag. A tag is the OUTPUT of a release, never its input.- A protected
releasebranch is the gate. Branch protection requires a greene2e/macos-vmstatus on any commit that lands there (the status may be carried over from a merged-in parent — see "tested vs tagged SHA" in the rationale), and applies to admins too (enforce_admins: true).mainstays the everyday branch. scripts/release-gate.shrunsmake e2elocally and posts the status (successonly on exit 0)..github/workflows/release.ymlthen fires on push torelease, readsVERSION, verifies thee2e/macos-vmstatus on the tag-target commit itself (refusing otherwise), and creates the annotated tagv<VERSION>+ a GitHub Release — idempotently (a collision guard makes an un-bumped or follow-up push a no-op). It boots no VM.
augur deliberately does not build, for this:
- a self-hosted runner or any always-on release infrastructure;
- the macOS-VM E2E as a GitHub-hosted CI job (it cannot exist — see Rationale);
- a tag-push trigger for releases (that would let a tag drive the release);
- a second place the version number lives (it is never read back from
git describeas the source of truth); - a tag-protection ruleset to block manual tags (see Rationale + Consequences);
- a packaging layer — a self-updater, a Homebrew tap, signed binaries — deferred below.
The pre-release macOS-VM E2E is load-bearing but cannot run in CI: GitHub's arm64 macOS
runners are themselves Virtualization.framework guests with no nested virtualization, so anything
that boots a VM/microVM fails on every hosted runner (documented in the README "Continuous
integration" and the Makefile header). So the E2E was a local step (make e2e) that a human
was trusted to run before cutting a vX.Y.Z tag by hand.
That trust is the weak link: "remember to run the heavy test before tagging" is exactly the kind
of step that gets skipped under time pressure, and nothing structural caught a skip. Separately,
augur version derived the version from git describe --tags, which made the tag both the
trigger for and the name of a release — a circular dependency (you tag to make a version, and the
version is read back from the tag).
The design brief was explicit: keep execution local, move enforcement to the server, and build no standing infrastructure. The open decision was the branch model, resolved to B (below).
Execution must be local, so enforcement moves to the server. Because no hosted runner can
nest a VM, "run the E2E in the cloud" is off the table — the only lever left is to gate on a
result the local run produces. scripts/release-gate.sh turns the E2E's exit code into an
e2e/macos-vm commit status; branch protection on release makes that status mandatory. This is
why there is deliberately no self-hosted runner: the goal is not to run the E2E somewhere central,
it is to make a locally-produced proof unskippable on the path to a tag.
The version number belongs in a committed file; the tag is what the gate emits. Moving the
number into VERSION breaks the circular "tag drives version" dependency, lets augur version
answer instantly with no tags present, and — most importantly — makes the tag a certificate the
pipeline issues after the gate, not a string a human types. release.yml computes v$(cat VERSION) and creates the tag itself, so a tag can only come into existence as the output of a
push that already cleared the gate.
Branch model B (add a release branch) over model A (repurpose main). B keeps main the
everyday branch, so every existing CI job and PR flow is untouched and the default branch stays
the one people actually branch from. Model A — make main the gated branch and add a develop
for daily work — would invert the default branch to a rarely-moving one and force re-pointing
every existing CI trigger onto develop, buying nothing at augur's scale. The cost of B is one
extra branch; the cost of A is churn across the whole existing setup.
required_linear_history is deliberately OFF. main is integrated with merge commits, so a
linear-history rule on release would reject the fast-forward release push every cycle — the
pushed range carries merge commits. Enforcement comes from the required status check instead: a
commit cannot become the release tip unless a green e2e/macos-vm status is satisfied for it
(empirically verified — a no-status commit is rejected with GH006: Required status check "e2e/macos-vm" is expected). Linear history would break releases while adding nothing here. (An
adversarial review of PR #108 caught this before it shipped — the first draft copied a
linear-history rule that would have dead-locked every release.)
"Tested SHA == tagged SHA" holds only if the gate runs on the exact commit that ships.
GitHub carries required-check satisfaction through merge commits: a merge commit whose
merged-in parent has the green status is accepted even though the merge commit itself has none
(empirically verified — and it is how v0.10.1 shipped: scripts/release-gate.sh ran on the bump
commit, which became the parent of the merge commit that got tagged). So the tagged SHA can
differ from the tested SHA. For augur's clean (conflict-free) release merges the two have
identical trees, so the shipped content is still exactly what was tested. release.yml
enforces the literal guarantee: it re-checks the e2e/macos-vm status on the tag-target commit
itself and refuses to tag when it is absent — so release-gate.sh must run on the post-merge
main tip (git checkout main && git pull). This makes tested==tagged machine-checked and closes
the theoretical case of a merge that resolves conflicts with new, untested changes.
release doubles as the stable install channel; packaging is deferred, not rejected. augur
is built from source (Swift/Go), so a git ref is the version selector: git clone -b release
installs the latest gated build (bare X.Y.Z from augur version), main installs dev
(X.Y.Z-dev+<sha>), and the suffix makes the two distinguishable at a glance. A real packaging
layer — an augur upgrade self-updater (the augur update name is already taken by the image
rebuild), a Homebrew tap, or signed/notarized binaries — only earns its ongoing maintenance if
non-developers install augur, which today they do not (the audience already has Xcode/Swift/Go and
clones the repo). This is deferred with a clear trigger, not declined on principle.
The gate stops an accidental skip, not a determined admin. An admin can still hand-push a
tag (release.yml does not fire on tags, and branch protection does not cover tag refs) or simply
disable the protection rule. This is accepted, the same shape as ADR-0008 and ADR-0001: the
structural guarantee is that the paved path cannot skip the E2E, converting a silent omission
into a deliberate, visible, auditable override. Forcing even the admin through the gate would need
a tag-protection ruleset that allows release.yml's bot while blocking humans — fiddly (the
GITHUB_TOKEN actor must be bypass-listed or the auto-release itself breaks), still admin-bypassable
via ruleset edits, and disproportionate for a solo/small-team repo — so it is documented-and-
declined rather than built. A hand-cut tag is additionally a footgun: it shadows the automated
path (the collision guard sees the tag and no-ops), so that version can never be cut properly.
That is why the README carries a "never hand-cut tags" warning instead of a machine block.
- The README "Cutting a release" section documents the operator flow (
VERSIONbump onmain→scripts/release-gate.shon a Mac → fast-forwardrelease→ automatic tag/Release) and points here for the reasoning — the same README-points-to-ADR pattern ADR-0008 established. - Run the gate on the post-merge
maintip, not a pre-merge branch commit.release.ymlrequires thee2e/macos-vmstatus on the tag-target commit itself and refuses to tag otherwise, so a pre-merge gate run (which leaves the status only on the merge's parent) fails the release loudly — GitHub's branch protection alone would let it through via merge carry-over. - One-time human setup that cannot be automated: a fine-grained PAT scoped to only "Commit
statuses: write", stored in the login Keychain as
augur-release-gate. Thereleasebranch and its protection (required checke2e/macos-vm,enforce_admins,required_linear_history: false, no force-push/deletion) are applied out-of-band by an admin. augur version/installno longer stamp an install date or readgit describeas the source; they reportVERSION(plus a-dev+<sha>suffix off-tag). No repository code parses the version string, so the format change is display-only.- A future proposal to put the E2E in CI, add a tag-push trigger, re-derive the version from tags, turn on linear history, block manual tags with a ruleset, or ship a brew/binary distribution should treat these as settled and bring a new ADR arguing the calculus changed, rather than implementing directly.
- README — "Cutting a release (structural gate)", "Continuous integration", "Pre-release gate".
- ADR-0001 and ADR-0008 — the same shape of decision: a residual (here, admin bypass) is documented and accepted rather than closed, because closing it would cost more than it is worth at augur's scale.
scripts/release-gate.sh(header) and.github/workflows/release.yml— the mechanism.- PR #108 — where this landed.