Skip to content

0012. Native arm64 Release Builds on Hosted arm64 Runners

Status

Accepted. Amends ADR 0011 (replaces its "arm64 via QEMU" decision and the multi-arch :buildcache tag; revisits its rejection of push-by-digest).

Context

ADR 0011 chose QEMU emulation for the arm64 half of release builds because GitHub-hosted arm64 runners were paid-only for private repositories, and "slow but rare" seemed acceptable at release cadence.

Two facts invalidated that decision during the v1.0.0-rc.1 rehearsal (2026-06-10):

  1. QEMU cannot build this image at all. The Dockerfile compiles Ruby from source via mise/ruby-build (no precompiled Ruby exists for musl/aarch64). Under QEMU the build ran make -j 2 for ~115 minutes and then failed (BUILD FAILED (Alpine Linux 3.23.4 on aarch64 using ruby-build)). Ruby's build executes its own freshly compiled miniruby mid-build, a known QEMU-user failure mode. Python 3.14, a plain C compile, succeeded under the same emulation. Native arm64 (the Apple Silicon dev host) builds the same stage without issue.
  2. The pricing constraint expired. Since 2026-01-29, arm64 standard runners (ubuntu-24.04-arm) are available in private repositories as standard GitHub-hosted runners, billed against the included minutes at a lower per-minute rate than x64.

Decision

The release push job becomes a two-leg matrix of native builds:

  • linux/amd64 on ubuntu-latest, linux/arm64 on ubuntu-24.04-arm.
  • Each leg builds with provenance: false and pushes by digest (no tags), exporting layer cache to a per-arch registry tag (:buildcache-amd64, :buildcache-arm64).
  • A merge job downloads both digests and runs docker buildx imagetools create to publish a single manifest list under the semver tags (X.Y.Z/X.Y/X/latest, prereleases exact-only).

The multi-arch :buildcache tag from ADR 0011 is retired: per-arch jobs writing per-arch cache tags cannot clobber each other, which was the only reason the second tag existed. (ci.yml keeps reading/writing :buildcache-amd64 unchanged. The orphaned :buildcache tag on Docker Hub can be deleted manually.)

Alternatives considered:

  • Keep QEMU, install Ruby from apk -- avoids the compile entirely, but unpins Ruby from mise/Renovate and ties its version to Alpine's package tree; image contents would diverge from the mise-managed convention for one runtime only.
  • Self-hosted arm64 runner (e.g. the Apple Silicon host) -- free and native, but an always-on maintenance and security liability for a personal repo.
  • Make the repo public for free arm64 runners -- out of scope as a side effect of a build fix.

Consequences

Easier:

  • Release arm64 builds are native: minutes-scale instead of hours-scale, and immune to QEMU emulation bugs.
  • ADR 0011's "QEMU release builds take hours" accepted cost is deleted.
  • The released bytes are exactly the pushed digests (ADR 0011 accepted a cache-identical rebuild instead; push-by-digest also removes that caveat).

Harder / accepted costs:

  • arm64 leg consumes paid-tier (but discounted) standard minutes from the monthly quota; at release cadence this is small against the ADR 0011 minutes budget.
  • One more registry cache tag (:buildcache-arm64) and the digest-merge plumbing (artifact handoff between jobs) in release.yml.
  • The amd64 image that ships is built in the push leg, not the build-test job; it resolves from the same :buildcache-amd64 the test job just wrote, so the cache-identical caveat from ADR 0011 still applies to the relationship between tested and shipped amd64 bytes.