Skip to content

fix(build): make the vendored libghostty-vt archives reproducible - #668

Merged
DerekMarshall merged 2 commits into
mainfrom
fix/reproducible-ghostty-archives
Oct 14, 2026
Merged

DerekMarshall merged 2 commits into
mainfrom
fix/reproducible-ghostty-archives

Conversation

@DerekMarshall

@DerekMarshall DerekMarshall commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

The release gate that rebuilds spyc-vt-sys's five libghostty-vt.a archives from the pin and compares checksums has never passed. It failed on v2.2.0-rc.1 (run 37275888635), so Sign + publish release was skipped and that tag reached crates.io only: no GitHub release, no brew, no apt. SECURITY.md's "Reproducible by anyone" wasn't true. An rc.2 tagged before this would end the same way.

Root cause

Four findings, each checked member by member with a read-only ar parser:

  1. zig's archive container isn't reproducible. The objects inside are byte-identical across runs, build paths and caches, but:
    • A Linux archive names each member by its absolute path in the build cache (<work>/zc-<target>/o/<hash>/vt.o), and the work directory is a fresh mktemp every run.
    • A darwin archive's __.SYMDEF SORTED header records the build's wall-clock time and the builder's uid/gid.
  2. A shared global zig cache leaks objects from another zig build. zig keys its cache on the version string, so a stale ~/.cache/zig handed one build a compiler_rt.o that some other "0.16.0" had made. A fresh one is deterministic at any path.
  3. On a macOS host, darwin targets compile against the installed Xcode SDK. ghostty's pkg/apple-sdk uses xcrun there, and the build also links a dylib with Apple ld, so the SDK can't be hidden. From Linux it uses zig's bundled Darwin headers, which the pinned zig fixes. A Mac build therefore follows whatever Xcode is installed.
  4. The committed archives match no fresh build of the recipe. Every object differs in content and size, so they can only be replaced, not reproduced.

Fix

  • Linux is the canonical build host. make vendor-ghostty and the script refuse to run anywhere else.
  • make vendor-ghostty-container runs the recipe in an ubuntu:24.04 container (podman or docker) with ziglang.org's zig, checked against its published sha256. This is the Mac path.
  • Deterministic re-archive. Each build is repacked as the same members, in the same order, under their basenames, with this zig's own ar rcsD (--format=darwin or gnu). It refuses two members that share a basename.
  • A private ZIG_GLOBAL_CACHE_DIR under the script's work directory.
  • All five archives and CHECKSUMS regenerated. The pin and the bindings are unchanged.
  • The verify job moves to .github/workflows/vendored-archives.yml:
    • release.yml still calls it before publish.
    • It also runs on PRs touching the archives, the pin, REQUIRED_ZIG, either script or the Makefile, plus workflow_dispatch.
    • It uploads the archives it built, so a mismatch hands over the bytes to commit.
    • It sets use-cache: false on setup-zig, since the recipe uses its own cache.
  • Docs: the crate README explains the recipe and why, and SECURITY.md's claim names Linux, the container target and the gate.

Evidence

The same five checksums came out of four independent builds:

Build Host
This PR's first CI run, plus v2.2.0-rc.1's run in October (darwin bytes identical across the two) x86_64 Linux
A Linux container arm64
make vendor-ghostty-container from empty vendor outputs arm64
Two native Mac runs (Linux targets only) macOS

The two native Mac runs matched each other and CI for every Linux target, but not for the darwin targets, which is what exposed cause 3.

Testing against the new archives:

  • make check-ci: === GATE: PASS ===, 3,176 library tests on macOS (Linux-built darwin archive), including the ghostty engine suite.
  • The 229 pane and engine tests pass for x86_64-apple-darwin under Rosetta.
  • make lint-linux, make package-check and make lint-workflows pass.
  • The spike harness (BUMP_POLICY step 3) wasn't re-run. It builds its own ghostty from source rather than consuming these archives, so it couldn't evaluate them, and the pin didn't move.

A trap caught along the way: the container script first fed itself to bash -s on stdin, apt-get swallowed the rest of it, and it "passed" having built nothing. The archives' modification times gave it away. It now passes the script as an argument, and the re-test started from deleted outputs, so it couldn't pass by doing nothing.

Not in this PR

spyc-vt-sys 0.1.0 is already on crates.io with the old archives, and the crates job skips a version that's already published. crates.io consumers keep those bytes until the crate is bumped to 0.1.1, which needs the trusted-publisher entry for spyc-vt-sys first.

Generated with Claude Code

DerekMarshall and others added 2 commits October 14, 2026 08:37
The release gate that rebuilds spyc-vt-sys's five archives from the pin
and compares checksums has never passed: it failed on v2.2.0-rc.1, so
that tag's GitHub release (and brew/apt) never published. Two causes:

- zig's archives are not reproducible as built. A Linux one names each
  member by its absolute path in the build cache, which changes every
  run; a darwin one's `__.SYMDEF SORTED` header records the build time
  and the builder's uid/gid. The objects inside are byte-identical
  across runs, build paths and caches (checked member by member), so
  the script now re-archives the same members, in the same order, under
  their basenames, with this zig's own `ar` in deterministic mode.
- A shared global zig cache leaks objects from another zig build of the
  same version (zig keys its cache on the version string): compiler_rt
  came out different until the script used a private global cache.

The committed archives match no fresh build of the documented recipe at
all (every object differs in content and size), so they are regenerated
here with ziglang.org's zig 0.16.0, the build CI installs. Two full runs
from fresh clones at different paths, with fresh caches, produce the
same five checksums.

The rebuild-and-compare moves into its own reusable workflow, called by
release.yml as before and also run on a PR that touches the archives,
the pin or the recipe, so a change proves itself before a tag does.

Co-Authored-By: Claude <noreply@anthropic.com>
CI's first run of the rebuild-and-compare on this PR reproduced all
three Linux archives from the Mac-built bytes, but not the two darwin
ones. On a macOS host ghostty's build compiles darwin targets against
the installed Xcode SDK (pkg/apple-sdk uses xcrun, and the build also
links a dylib with Apple ld, so the SDK can't be hidden); from Linux it
uses zig's bundled Darwin headers, which the pinned zig fixes. A Linux
arm64 container produced exactly CI's x86_64 bytes for all five, and
CI's darwin bytes were already identical between v2.2.0-rc.1's run and
this one, so Linux is the canonical host.

- The two darwin archives are replaced with the Linux-built ones. The
  full gate passes on macOS against them, and the pane and engine tests
  pass for x86_64-apple-darwin under Rosetta.
- `make vendor-ghostty` and the script refuse to run off Linux.
- `make vendor-ghostty-container` runs the recipe in an ubuntu:24.04
  container with ziglang.org's zig (checked against its published
  sha256), for a Mac. Run from empty vendor outputs, it rebuilt all five
  to CI's checksums.
- The workflow uploads the archives it built, so a mismatch hands over
  the bytes to commit.

Co-Authored-By: Claude <noreply@anthropic.com>
@DerekMarshall
DerekMarshall merged commit 38acc24 into main Oct 14, 2026
5 checks passed
@DerekMarshall
DerekMarshall deleted the fix/reproducible-ghostty-archives branch October 14, 2026 07:19
DerekMarshall added a commit that referenced this pull request Oct 14, 2026
Sets the version to `2.2.0-rc.2`, made with `make release-prep
VERSION=2.2.0-rc.2`. Only `Cargo.toml` and `Cargo.lock` change: an rc
gets no CHANGELOG section, because the 2.2.0 section will cover the
whole cycle.

**rc.1 never reached GitHub, brew or apt.** Its release run's
archive-reproducibility gate failed, so `Sign + publish release` was
skipped. Only crates.io got `spyc 2.2.0-rc.1`. #668 fixed the cause: CI
now reproduces the committed `libghostty-vt.a` archives byte for byte,
which is the first time that gate has passed. rc.2 is the first rc to go
out through every channel. It also carries #667, the two flaky
statusline tests that had main's CI red.

`main` stays at `2.2.0-rc.2` through the soak.

**After merge, on `main`:**
1. `make release-tag VERSION=2.2.0-rc.2`
2. Push the tag. `release.yml` verifies the archives reproduce,
publishes a GitHub pre-release, and dispatches the Homebrew tap and apt.
The crates job publishes `spyc 2.2.0-rc.2` and skips `spyc-vt-sys
0.1.0`, which is already on crates.io with the older archives (bumping
it needs its trusted-publisher entry).

**Checks:** `make check-ci` passes, and the binary reports `spyc
2.2.0-rc.2 (<sha>)`.

Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant