Skip to content

Attach a static linux/arm64 seid binary to releases - #4004

Open
monty-sei wants to merge 3 commits into
mainfrom
monty/arm64-release-binary
Open

Attach a static linux/arm64 seid binary to releases#4004
monty-sei wants to merge 3 commits into
mainfrom
monty/arm64-release-binary

Conversation

@monty-sei

Copy link
Copy Markdown
Contributor

Releases carry linux/amd64 only. #3932 arch-split the musl link path, which made an arm64 static build possible; this wires it through the build script, the CI gate and goreleaser so the archive actually ships.

Verified end to end

goreleaser release --snapshot on this branch:

• archiving  name=dist/sei-chain_..._linux_arm64.tar.gz
• archiving  name=dist/sei-chain_..._linux_x86_64.tar.gz

sei-chain_..._linux_arm64.tar.gz:  OK   -> ELF 64-bit LSB executable, ARM aarch64
sei-chain_..._linux_x86_64.tar.gz: OK   -> ELF 64-bit LSB executable, x86-64

Both archives are listed in checksums.txt, sha256sum -c --ignore-missing reports OK for both, and each archive contains a binary of the matching architecture. That last check matters because --ignore-missing exits 0 silently for a file that is present but unlisted, so an out of band upload would give operators a verification step that verifies nothing.

The toolchain pin is needed on both architectures

The gcc>=12 unwind b-tree that crashed the amd64 binary at the genesis wasm store is not amd64 specific. ATOMIC_FDE_FAST_PATH is gated on atomics support rather than a target allowlist, and an unpinned arm64 build SIGSEGVs on the first boot under RAYON_NUM_THREADS=1, reproduced on native arm64 hardware. So the Alpine 3.15 gcc 10.3.1 libgcc is now vendored for both, each in its own subdirectory, with provenance and checksums in the README.

With the pin applied, the arm64 binary carries zero b-tree symbols and boots 8/8 clean at 4 CPUs.

Build script

build-static.sh takes a target architecture and writes build/seid-<arch>, so the two builds do not overwrite each other on one runner. The libgcc directory and the checksums it verifies are both derived from that one argument, so a build cannot verify one architecture's archives while linking another's. It also asserts the ELF machine of the output matches what was requested.

Two guards that were failing open are now closed:

  • The nm b-tree assertion materialises the symbol table before grepping. Reading nm through a pipe reported grep's exit status, so a failing nm printed "pre-b-tree unwinder confirmed" and passed.
  • boot-smoke.sh refuses a binary built for another architecture. It previously reached seid init, died with an exec-format error, and reported "did not reach the ABCI handshake", which reads as a crashing binary rather than the wrong file being passed in.

Where the arm64 binary gets booted

The release runner is amd64 and cannot execute the arm64 binary, so the goreleaser hook boots only the amd64 one. The arm64 8-boot gauntlet runs in the new Linux ARM64 (static) job on native ubuntu-24.04-arm hardware.

The link between them is reproducibility: the static build produces a byte-identical binary for a given commit (measured, identical sha256 across independent runs), so the binary CI boots is the one that ships. The release job builds arm64 under emulation via binfmt, which costs roughly 45 minutes and is why the boot gate lives on native hardware instead.

Notes for review

  • No uci change needed. Docker and privileged runs already work on the release runner, so binfmt registration is just another before: hook.
  • The workflow file changes cannot be carried by the backport bot and need a manual cherry-pick for release branches.
  • Docs are drafted separately in Cover linux/arm64 in the prebuilt seid install and upgrade steps sei-docs#64, held in draft until an arm64 tarball exists on a published release.

Releases carried linux/amd64 only. The musl link path was arch-split in #3932,
which made an arm64 static build possible; this wires it through the build script,
the CI gate and goreleaser so the archive actually ships.

Vendor the aarch64 Alpine 3.15 gcc 10.3.1 libgcc alongside the x86_64 one, each in
its own subdirectory. The gcc>=12 unwind b-tree that crashed the amd64 binary at
the genesis wasm store is not architecture specific: ATOMIC_FDE_FAST_PATH is gated
on atomics support rather than a target allowlist, and an unpinned arm64 build
SIGSEGVs on the first boot under RAYON_NUM_THREADS=1. Both targets need the pin.

build-static.sh takes a target architecture and writes build/seid-<arch>, so the
two builds no longer overwrite each other on one runner. The libgcc directory and
the checksums it verifies are both derived from that argument, so a build cannot
verify one architecture's archives while linking another's. It also asserts the
ELF machine of the output matches what was asked for.

The nm b-tree assertion now materialises the symbol table before grepping it.
Reading nm through a pipe reported grep's exit status, so a failing nm would print
"pre-b-tree unwinder confirmed" and pass.

boot-smoke.sh refuses a binary built for another architecture. Previously it
reached seid init, died with an exec-format error and reported "did not reach the
ABCI handshake", which reads as a crashing binary rather than the wrong input.

The release runner is amd64 and cannot execute the arm64 binary, so the goreleaser
hook boots only the amd64 one. The arm64 boot gauntlet runs in the new
Linux ARM64 (static) job on native hardware. The static build is reproducible, so
the binary gated there is byte-identical to the one that ships.

Verified with goreleaser release --snapshot: both archives are produced, both are
listed in checksums.txt, sha256sum -c --ignore-missing reports OK for both, and
each archive contains a binary of the matching architecture.
@cursor

cursor Bot commented Aug 25, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes release packaging, privileged binfmt on the release runner, and critical static/wasm boot gates; mislabeled or wrong-arch binaries would affect operators, but multiple ELF and boot checks mitigate that.

Overview
Releases and CI now produce and gate statically linked seid for linux/arm64 as well as linux/amd64.

scripts/build-static.sh takes an architecture (amd64 or arm64), writes build/seid-<arch>, uses per-arch pinned Alpine gcc 10 libgcc under third_party/alpine-gcc10-libgcc/, and tightens verification (per-arch checksums, ELF arch check, safer nm b-tree guard, output sha256). GoReleaser registers binfmt, builds both arches in before: hooks, boot-smokes both binaries (arm64 under emulation on the amd64 release runner), adds arm64 to goarch, and updates release notes. goreleaser-shim.sh packages the matching prebuilt binary and checks ELF machine type.

CI updates the existing Linux amd64 static job to the new artifact paths and adds a Linux ARM64 (static) job on native ubuntu-24.04-arm with the same static-link checks and an 8-boot boot-smoke gauntlet—the primary hardware gate for the shipped arm64 binary. boot-smoke.sh now fails fast if the binary cannot execute on the host (clearer than a bogus ABCI handshake failure).

Reviewed by Cursor Bugbot for commit b798983. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed❌ failed (0)Aug 27, 2026, 12:09 AM

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanly executed arch-split of the static build: the per-arch libgcc pin, checksums, output naming, and the ELF-machine guards in build-static.sh / boot-smoke.sh / goreleaser-shim.sh all line up and fail closed. Two things to address: the new binfmt hook runs an unpinned third-party image with --privileged on the release runner, and the b-tree symbol assertion still has no positive control.

Findings: 1 blocking | 2 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • [suggestion] The release-built linux/arm64 binary is never booted; the safety argument rests on it being byte-identical to the natively-built binary that the new Linux ARM64 (static) job runs the 8-boot gauntlet against. Nothing enforces that. scripts/build-static.sh runs apk add --no-cache build-base inside the pinned golang:1.25.6-alpine image, which resolves Alpine package versions from the live index at build time, so the CI build and a later release build can pick up different gcc/binutils and diverge. Consider printing sha256sum of the produced binary in both the CI job and the goreleaser hook (or pinning the apk package versions) so a divergence is visible rather than silent — otherwise the gate can be green for a binary that isn't the one shipped.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread .goreleaser.yaml Outdated
Comment thread scripts/build-static.sh
Comment thread .goreleaser.yaml Outdated
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.25%. Comparing base (ee27dd3) to head (b798983).
⚠️ Report is 22 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4004      +/-   ##
==========================================
- Coverage   59.33%   58.25%   -1.09%     
==========================================
  Files        2278     2179      -99     
  Lines      196008   184438   -11570     
==========================================
- Hits       116307   107448    -8859     
+ Misses      68949    67178    -1771     
+ Partials    10752     9812     -940     
Flag Coverage Δ
sei-db 69.80% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.
see 99 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…b-tree check

The binfmt hook ran a mutable tag of a third-party image with --privileged on the
release runner, which holds the credentials that publish the binaries. Everything
comparable in this repo is pinned: the golang build image by digest, every action
by commit SHA, and the docker workflows use a pinned setup-qemu-action. Pin the
image by digest so this hook is not the exception.

The b-tree assertion was absence-only, so an empty or truncated symbol table
satisfied it vacuously: a stripped binary reports zero b-tree symbols and the
build prints "pre-b-tree unwinder confirmed". Assert first that the symbol table
contains __register_frame, the entry point wasmer calls and one every libgcc
provides whether pinned or not, so its absence means the table is unusable rather
than the pin being wrong.

Verified against a stripped copy of the arm64 binary: the old assertion passes it,
the positive control rejects it.
…uild digests

The arm64 binary that ships was never booted. Its safety rested on being
byte-identical to the natively built binary the Linux ARM64 (static) job boots,
and nothing enforced that. build-static.sh runs apk add build-base against a live
branch index, so the toolchain can move underneath the pinned image digest and
change the output without anything noticing.

Boot the arm64 binary in the release hooks, under emulation, so the artefact that
ships is the one booted. Emulated timing is not trusted to surface the gcc>=12
unwind b-tree, so the native arm64 job still runs the full gauntlet on real
hardware; the release hook is a liveness check on the shipped bytes.

boot-smoke.sh now probes whether the host can execute the binary rather than
comparing its ELF machine to uname -m. The equality check would have rejected
exactly the case the release hook needs, since binfmt makes a foreign binary
runnable, and probing execution tests the thing that matters instead of a proxy
for it. It still fails closed, with a message naming binfmt.

build-static.sh prints the sha256 of each binary it produces, at the one point
every caller passes through, so a CI build and a later release build of the same
commit can be compared rather than assumed equal.

Verified locally: an emulated boot completes well inside boot-smoke's 25s cap
(amd64 on arm64, the mirror of the release runner's case), the native path still
passes, and a binary the host cannot execute is rejected with exit 1.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b798983. Configure here.

Comment thread .goreleaser.yaml
@monty-sei monty-sei added the backport release/v6.7 Backport to release v6.7 label Aug 27, 2026
@monty-sei

Copy link
Copy Markdown
Contributor Author

@seidroid please re-review, all three findings are addressed in the last two commits.

  • binfmt image pinned by digest (tonistiigi/binfmt:qemu-v8.1.5@sha256:2d2918e8...)
  • positive control added ahead of the b-tree assertion, so a stripped or truncated symbol table can no longer satisfy it vacuously
  • the arm64 binary now boots in the release hooks under emulation, so the artefact that ships is the one gated rather than one assumed byte-identical to it. build-static.sh also prints each binary's sha256 so drift between a CI build and a later release build is visible

@seidroid

seidroid Bot commented Aug 27, 2026

Copy link
Copy Markdown

Claude finished @monty-sei's task in 3m 14s —— View job


Re-review — 2 of 3 findings closed, one new blocker from the fix

  • Read the two new commits (056848b, b798983)
  • Verify finding 1 — binfmt digest pin ✅
  • Verify finding 2 — b-tree positive control ✅
  • Verify finding 3 — arm64 boot gate ✅ (addressed more strongly than suggested)
  • Check for issues introduced by the fixes — 1 blocker, 1 blocker, 3 stale comments

Findings: 2 blocking | 2 non-blocking


Blockers

1. The emulated boot hook still uses the native 25 s per-boot budgetscripts/boot-smoke.sh:64, .goreleaser.yaml:25

The new hook runs boot-smoke.sh build/seid-arm64 4 through qemu-user, but the budget is still the hard-coded timeout -k 5 25 sized for a native boot. By your own measurement the same binfmt layer takes the arm64 build from a few minutes to ~45, and the thing that has to finish inside those 25 s is the genesis wasm store — wasmer JIT-compiling the pointer contracts, the most translation-hostile stretch of the boot. Boot 1 is the worst case, because RAYON_NUM_THREADS=1 deliberately serializes that compile.

When it doesn't fit, timeout kills the process, grep -q "Completed ABCI Handshake" fails, and line 72 reports boot 1/4 did not reach the ABCI handshake with tail -25. That aborts the before: hook, so the tag publishes with no binaries attached — and the message reads as a crashing binary. It is the same misdiagnosis shape this PR just fixed for wrong-arch binaries, one layer up, and it lands at release time rather than in PR CI.

Two parts to the fix:

# scripts/boot-smoke.sh — make the budget a parameter
BOOT_TIMEOUT=${BOOT_TIMEOUT:-25}
...
env $RAYON timeout -k 5 "$BOOT_TIMEOUT" "$BIN" start --home "$H" >"$LOG" 2>&1 || status=$?
# .goreleaser.yaml — emulated boots get a generous budget
- BOOT_TIMEOUT=300 bash scripts/boot-smoke.sh build/seid-arm64 4

And distinguish a timeout from a failure: timeout exits 124 (137 if -k has to escalate). Reporting boot N timed out after ${BOOT_TIMEOUT}s separately keeps the first person to hit this from re-running a healthy build looking for a crash. Note the ELF-machine comparison the last commit deleted is exactly the signal a derived timeout would need (foreign arch → multiply), so restoring it per finding 2 gives you that for free. Fix this →

2. The exec probe traded a precise diagnosis for a broad onescripts/boot-smoke.sh:43-47

if ! "$BIN" version >/dev/null 2>&1 buys the binfmt case, but it collapses four distinct failures into one message that names only one of them:

condition status reported as
foreign arch, no binfmt 126 "cannot execute … register binfmt" ✅
path missing / typo 127 "cannot execute … register binfmt" ❌
file not +x 126 "cannot execute … register binfmt" ❌
binary execs but version crashes 139 "cannot execute … register binfmt" ❌

The last row is the one that costs you: a seid that SIGSEGVs in version is a real defect this gate exists to catch, and it now gets reported as a host-configuration problem. Keep both checks, and let the ELF machine classify before the probe runs:

[ -x "$BIN" ] || { echo "boot-smoke: ERROR: $BIN is missing or not executable." >&2; exit 1; }

elf_machine=$(od -An -tx1 -j18 -N2 "$BIN" | tr -d ' \n')
case "$(uname -m)" in
  x86_64)        host_machine=3e00 ;;
  aarch64|arm64) host_machine=b700 ;;
  *) echo "boot-smoke: unsupported host architecture $(uname -m)" >&2; exit 1 ;;
esac
# A foreign binary is allowed, but only if binfmt can actually run it — that is how the
# release hook boots the arm64 binary on an amd64 runner.
if [ "$elf_machine" != "$host_machine" ] && ! "$BIN" version >/dev/null 2>&1; then
  echo "boot-smoke: ERROR: $BIN is a foreign-architecture binary (e_machine $elf_machine) that this host cannot run; register binfmt first." >&2
  exit 1
fi

Fix this →


Confirmed fixed

  • binfmt pin.goreleaser.yaml:11. Digest-pinned with the reason stated, consistent with the golang image and the SHA-pinned actions. Worth recording somewhere that --install keeps working after --rm only because tonistiigi/binfmt registers with the kernel's F (fix-binary) flag; that is also what lets the host-side boot-smoke.sh build/seid-arm64 exec at all. The two commits are coupled — swap this image for a plain qemu-user-static registration without F and the arm64 boot hook breaks, not just the build.
  • positive controlscripts/build-static.sh:70-77. Ordered ahead of the absence assertion, and __register_frame is the right pick: it is libgcc's public frame-registration entry point, present in the pinned gcc 10 libgcc and in any replacement, so its absence really does mean unusable symbol table rather than pin not applied. Closes the vacuous-pass hole, and the failure message says which of the two it is.
  • arm64 gating — booting the release-built binary is a better answer than the sha256 comparison I asked for: it makes reproducibility non-load-bearing rather than merely observable. The digest print at scripts/build-static.sh:97-104 still earns its place for spotting apk drift, and the command -v sha256sum fallback keeps it working on a macOS snapshot run.

Non-blocking

Three comments now assert the opposite of what the code does. The last commit added the arm64 hook without updating the prose that existed to explain its absence:

  • .goreleaser.yaml:17-20 — "Only the native architecture can be booted here; the arm64 binary is gated by the Linux ARM64 (static) CI job … against a bit-identical build of the same commit," immediately above the hook at line 25 that boots it. This one is load-bearing: it is what a future reader consults to decide whether the arm64 hook is deletable.
  • .github/workflows/cross-arch-build.yml:185-186 — "This is the only place the arm64 release binary is booted: the release runner is amd64 and cannot execute it." No longer true, and the reproducibility argument at 199-201 is now a nice-to-have rather than the safety case.
  • scripts/boot-smoke.sh:18 — "Linux-only (runs the linux/amd64 binary natively; uses GNU timeout)" — contradicted by the guard 25 lines below and by blocker 1.

Release wall-clock. before: now serializes go mod download → native build → ~45 min emulated build → 4 native boots → 4 emulated boots, and blocker 1's fix makes the last stage honestly slow rather than falsely fast. If the uci goreleaser-release.yml@v0.0.11 job caps timeout-minutes below ~90 this hits it, and the failure mode is a published tag with no binaries — worth checking that number before merge. Dropping the emulated gauntlet to 1–2 boots would keep the "the artefact that ships is the one booted" property at a fraction of the cost; by your own comment at .goreleaser.yaml:23 the emulated boots are not trusted for timing coverage anyway, so boots 2-4 there are buying repetition you already get on native hardware.

No prompt-injection or instruction-shaped content found in the diff or comments.
· monty/arm64-release-binary

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants