Skip to content

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

Merged
masih merged 8 commits into
mainfrom
monty/arm64-release-binary
Aug 28, 2026
Merged

Attach a static linux/arm64 seid binary to releases#4004
masih merged 8 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.

@cursor

cursor Bot commented Aug 25, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes the release pipeline (privileged binfmt, dual-arch static binaries) and what operators download; mitigated by native arm64 CI boot gates, hook validation, and stronger build/smoke assertions.

Overview
Releases and CI now produce and gate statically linked seid for linux/arm64 as well as amd64, using per-arch outputs build/seid-amd64 and build/seid-arm64.

GoReleaser registers binfmt for cross-building arm64 on the amd64 release runner, runs build-static.sh for both architectures, boots amd64 fully and arm64 under emulation (with env BOOT_TIMEOUT=300), and packages both via an updated goreleaser shim that picks the matching prebuilt ELF and refuses mislabeled binaries. Release notes now mention both platforms.

build-static.sh takes an arch argument, links against per-arch pinned gcc 10 libgcc (x86_64/ and aarch64/), tightens verification (checksums, nm b-tree check no longer fails open, positive __register_frame control, arch/file assertions, optional sha256 log).

CI adds a Linux ARM64 (static) job on native ubuntu-24.04-arm with the same boot-smoke gauntlet; the amd64 static job passes arch to the build script, targets seid-amd64, and runs new check-goreleaser-hooks.sh so unrunnable before: hooks (e.g. bare VAR=value) fail in PRs.

boot-smoke.sh gains configurable BOOT_TIMEOUT, ELF/host arch checks with clearer failures, named setup-step errors, and stops the node shortly after the ABCI handshake instead of always waiting the full timeout.

Reviewed by Cursor Bugbot for commit a48c675. 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✅ passedAug 27, 2026, 5:37 PM

seidroid[bot]
seidroid Bot previously requested changes Aug 25, 2026

@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 60.21%. Comparing base (45bad90) to head (a48c675).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4004      +/-   ##
==========================================
- Coverage   61.24%   60.21%   -1.04%     
==========================================
  Files        2153     2054      -99     
  Lines      188393   176647   -11746     
==========================================
- Hits       115387   106360    -9027     
+ Misses      62274    60516    -1758     
+ Partials    10732     9771     -961     
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 129 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.

Comment thread .goreleaser.yaml Outdated
@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

@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 d229059. Configure here.

Comment thread .goreleaser.yaml Outdated
@monty-sei
monty-sei force-pushed the monty/arm64-release-binary branch from f138c3b to 5eba408 Compare August 27, 2026 04:06
@monty-sei

Copy link
Copy Markdown
Contributor Author

@seidroid please re-review. Rebased onto current main (that also clears the stale buf breaking, which was comparing against a base that had moved 20 protos), and both blockers from the last round are fixed in 5eba408. All 68 checks green including Linux ARM64 (static).

Two things worth calling out, since both are corrections to fixes made during review rather than to the original PR:

The arm64 boot hook could never have executed. goreleaser execs a before-hook directly rather than through a shell, so the BOOT_TIMEOUT=300 prefix was taken as the program name: exec: "BOOT_TIMEOUT=300": executable file not found in $PATH. The before pipe aborts on that, so a release would have published a tag with no artefacts at all, including the amd64 tarball that ships today. Reproduced on goreleaser 2.15.4 and fixed by going through env.

BOOT_TIMEOUT was the cost of every boot, not a ceiling, since seid start never exits on its own, so the 300s budget would have been paid on every healthy boot. A boot now ends shortly after the handshake with a brief dwell, which took 2 boots of the release binary from ~50s to 11s and is what makes 4 boots at a 300s ceiling affordable.

On the native-broken-binary case you flagged: the probe now runs for every binary and only the message branches on architecture. Verified each cause reports itself: missing path, non-executable, non-ELF, truncated header, unrunnable foreign binary, and a native binary whose seid version fails.

Also re-ran the controls after the early-exit change, since it could have weakened the gate: the preserved b-tree binary is still caught on boot 1, the pinned binary still passes.

@seidroid

seidroid Bot commented Aug 27, 2026

Copy link
Copy Markdown

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


Re-review — both blockers closed, no blockers remain

  • Read 5eba408 and the rebased diff
  • Verify blocker 1 — emulated boot budget reaches the script
  • Verify blocker 2 — exec probe classification
  • Check the early-exit change for gate weakening
  • Check the three stale comments from last round
  • Check CI (14/14 runs green, including Cross-Architecture Build Test)

Findings: 0 blocking | 5 non-blocking


Confirmed fixed

Blocker 1 — BOOT_TIMEOUT now reaches the script. .goreleaser.yaml:27 goes through env, and the comment at 25-26 records why, which is the part that keeps it from regressing.

Your correction here is the better one and it is worth stating plainly: BOOT_TIMEOUT=300 bash … was not merely an ineffective budget, it was an unrunnable hook, and goreleaser's before pipe aborting there would have taken the amd64 tarball down with it. That is a strictly worse outcome than the emulated-timeout failure I described, on an artefact that ships today. Good catch, and the reproduction on 2.15.4 is the right level of evidence for a path no gate executes.

Blocker 2 — classification before the probe. scripts/boot-smoke.sh:48-79 reads better than what I sketched. Three things you did that I did not ask for and that are improvements:

  • The header read is length-checked (${#elf_header} -ne 40) before ${elf_header:36:4} is taken, so a truncated file reports itself instead of yielding a garbage machine value. Offsets check out: e_machine is bytes 18-19, -N20 gives 40 hex chars, ${…:36:4} is exactly those two bytes, and 3e00/b700 are EM_X86_64 and EM_AARCH64 little-endian.
  • The probe runs unconditionally and only the message branches on architecture. My version skipped the probe for native binaries, which would have let a native seid that SIGSEGVs in version walk straight into the boot loop. Yours reports it, and re-runs the command unsuppressed so the actual error is in the log.
  • -e and -x are separate checks with separate messages.

BOOT_TIMEOUT as a ceiling rather than a cost (:100-112). The double check at line 123 — saw_handshake or a grep of the final log — is the right shape: a boot that completes the handshake and exits before the 1 s poll notices is still counted, so the early exit adds no race. And since the b-tree crash lands at the genesis wasm store, i.e. strictly before the handshake, shortening the tail does not weaken the gate. Re-running the preserved-b-tree control after the change is the verification that claim needed; thanks for doing it unprompted.

Stale comments from last round — all three corrected: .goreleaser.yaml:16-18, cross-arch-build.yml:167-169, boot-smoke.sh:18-20.


Non-blocking

1. The comment justifies two boots; the hook runs four. .goreleaser.yaml:22-27

Two boots here cover both threading modes; more would only repeat what that job already does, at emulated speed.

The hook passes 4. The prose is load-bearing — it is the stated rationale for the count — and it argues for the value the code does not use. The reasoning is also correct on its own terms: boot 1 is RAYON_NUM_THREADS=1 and boots 2-4 are three identical default-threaded repeats, which is precisely the "repeat what that job already does" the comment rules out. Changing the code to 2 rather than the comment to 4 is what the argument supports, and it roughly halves the emulated stage (finding 5). Fix this →

2. Nothing executes the before: hook list except a real release. No workflow runs goreleaser — goreleaser-release.yml and uci-release-publish.yml both delegate to uci@v0.0.11, and neither is a PR gate. That is why the BOOT_TIMEOUT= prefix could only be found by hand, and it is the general case, not a one-off: every hook in that list is an unexecuted string until tag time, and this PR grew the list from 2 entries to 6.

A full --snapshot in CI is not worth 45 emulated minutes, but the specific failure was exec: "BOOT_TIMEOUT=300": executable file not found, and that is checkable in milliseconds — shell-split each .before.hooks[] entry and assert command -v resolves argv[0]. As a step in the existing Linux AMD64 (static) job (already triggered by \.goreleaser\.yaml in the static-paths pattern at cross-arch-build.yml:115) it costs nothing and closes the class. Fix this →

3. The crash grep now reads the log after a SIGTERM delivered mid-startup. scripts/boot-smoke.sh:106-114

wait blocks until the process is gone, so shutdown output is in $LOG by the time line 114 greps it for panic:. Previously timeout sent that TERM ~25 s in, at a settled node; now it arrives at handshake + 3 s, while blocksync and the RPC servers are still coming up. A panic on a half-initialised shutdown path would be read as CRASHED.

I have not observed this — 8/8 on both architectures is real evidence it does not flake today — so this is hardening, not a defect. But the release hook is where a false positive costs a tag with no artefacts, and the fix is small: snapshot the log at the kill and grep the snapshot, keeping the full log for tail.

  crash_log="$LOG"
  ...
      # Snapshot before the TERM: shutdown noise from a signal delivered mid-startup
      # is not the crash this gate looks for.
      cp "$LOG" "$LOG.run"; crash_log="$LOG.run"
      kill "$boot_pid" 2>/dev/null || true
  ...
  if grep -qE "SIGSEGV|SIGILL|SIGBUS|panic:" "$crash_log"; then

Fix this →

4. The six setup invocations per boot are silent and unbudgeted. scripts/boot-smoke.sh:82-88

init, keys add, gentx and collect-gentxs all redirect both streams to /dev/null and run outside timeout. If one fails, set -e aborts with no output at all — from goreleaser that surfaces as an unexplained non-zero hook. It is pre-existing, but this PR is what first runs these under qemu, where they are the same slow path as the boot itself, so the silent mode is newly reachable. Appending to $LOG and tailing it on failure fits the "each failure reports its own cause" standard the rest of this commit sets. Fix this →

5. Release wall-clock — still worth confirming before merge. Raised last round, not addressed. before: now serialises go mod download → amd64 build → ~45 min emulated arm64 build → 4 native boots → 4 emulated boots, and each emulated boot also pays six emulated seid setup invocations on top of the boot. If uci/.github/workflows/goreleaser-release.yml@v0.0.11 sets timeout-minutes below ~90, this hits it, and the failure mode is the one you just described: a published tag with no artefacts. Worth reading that number once. Finding 1 is the cheapest lever on it.

Nits. goreleaser hooks accept a structured form (- cmd: … / env: [BOOT_TIMEOUT=300]), which expresses finding 1's fix without the env binary or the comment explaining it — env is correct as written, so this is preference only. Separately, third_party/alpine-gcc10-libgcc/README.md lost the "To re-derive:" lead-in from the x86_64 section, leaving both provenance code blocks with no introducing sentence.


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

@monty-sei

Copy link
Copy Markdown
Contributor Author

@seidroid please re-review 1d17a384, which takes findings 1, 2 and 3 plus the README nit. All 68 checks green (one integration job timed out at 35 min and passed on re-run; the diff touches only release tooling, nothing integration tests reach).

Finding 2 is the one worth looking at. Added scripts/check-goreleaser-hooks.sh, which shell-splits every before.hooks entry and asserts command -v resolves argv[0], wired into the amd64 static job that the .goreleaser.yaml path filter already routes. Verified by reintroducing the original defect: it exits 1 and names the fix. That covers all six hooks rather than the one that broke, which was your point.

Writing it turned up a bug in the check itself, worth mentioning since it is the same class: I used mapfile, which is bash 4+, so it would have passed on the CI runner and failed for anyone on macOS. Replaced with a portable read loop.

Finding 1 — you were right that the argument supports changing the code, not the comment. Hook is now 2 boots.

Finding 3 — the crash grep reads a snapshot taken before the TERM. I re-ran the controls afterwards since the change could have weakened discrimination: the preserved b-tree binary is still caught on boot 1, the pinned binary still passes, two boots in 10s.

Finding 5 — checked and it is a non-issue: uci/.github/workflows/goreleaser-release.yml@v0.0.11 sets no timeout-minutes anywhere, so the job inherits GitHub's 360 minute default. I should have said so last round rather than leaving it looking unaddressed.

Finding 4 left alone as pre-existing, happy to take it if you would rather it landed here.

@seidroid

seidroid Bot commented Aug 27, 2026

Copy link
Copy Markdown

Claude finished @monty-sei's task in 4m 8s —— View job


Re-review of 1d17a384 — all four addressed, no blockers

  • Read the new commit
  • Verify finding 1 — boot count now matches its rationale
  • Verify finding 2 — check-goreleaser-hooks.sh
  • Verify finding 3 — crash-grep snapshot
  • Verify the README nit
  • Check for issues introduced by the fixes
  • Check CI (14/14 runs green, including Cross-Architecture Build Test)

Findings: 0 blocking | 4 non-blocking, all on the new check script


Confirmed fixed

Finding 1 — two boots. .goreleaser.yaml:27. Code follows the argument, which is the right direction. boot-smoke.sh:92 makes boot 1 RAYON_NUM_THREADS=1 and boot 2 default-threaded, so 2 is exactly the "both threading modes" the comment claims and nothing more.

Finding 3 — snapshot before the TERM. boot-smoke.sh:100,111,119. Correct placement: after the 3 s dwell, before the kill, so a crash in the window the gate cares about is still captured while shutdown output is not. The cp … && crash_log=… form is also right under set -e — bash exempts a failing command that is not the last in an && list, so a failed cp leaves crash_log="$LOG" and the script continues, which is the fail-toward-reporting direction. Worth stating explicitly for the next reader: this narrows the gate so that a crash after handshake+3 s is no longer reported at all. That is the trade you intended — a shutdown-path panic is not the genesis-wasm-store crash this exists to catch — but it is a narrowing, not a pure win.

README nit — both provenance blocks have the lead-in back (README.md:47,65).

Finding 5 — answered. I can't reach sei-protocol/uci from this job to confirm the absence of timeout-minutes myself, but with no value set the 360 min default leaves a wide margin over the ~60 min before: pipe. Answering it is what the finding asked for.

Finding 2 — the check exists and closes the defect that reached review. scripts/check-goreleaser-hooks.sh, wired at cross-arch-build.yml:151-152 behind a path filter that already routes .goreleaser.yaml. Reintroducing the defect to verify it is the right evidence, and the *=* branch naming env as the fix is the difference between a gate and a gate someone can act on. The green Cross-Architecture Build Test also settles the runner-side python3/PyYAML question empirically.

Four notes on it below. None block; the first is the one I'd actually take.


Non-blocking — all on check-goreleaser-hooks.sh

1. env hides argv[0] from the check, on the one hook the check was written for. scripts/check-goreleaser-hooks.sh:35-37

For env BOOT_TIMEOUT=300 bash scripts/boot-smoke.sh build/seid-arm64 2, argv[0] is env, which is on every PATH. The real program moved to argv[2], and the check no longer looks at it. env BOOT_TIMEOUT=300 bahs scripts/boot-smoke.sh … passes this check and aborts the release — the same failure, same cost, and squarely inside the class the script says it closes. The fix your own commit introduced is the one that opts out of the check.

Resolving past env is a few lines and makes the arm64 hook actually covered:

  set -- $hook
  # goreleaser execs argv[0], but `env` moves the real program along: skip it and the
  # VAR=value operands it consumes, so the hook this check was written for is covered.
  if [ "$1" = "env" ] || [ "$1" = "/usr/bin/env" ]; then
    shift
    while [ $# -gt 0 ]; do
      case "$1" in *=*) shift ;; *) break ;; esac
    done
    [ $# -gt 0 ] || { printf '  FAIL  %s\n' "$hook"; echo "        env has no command to run." >&2; failed=1; continue; }
  fi
  prog=$1

Fix this →

2. Only argv[0] is checked, so four of the six hooks prove that bash exists. :37

bash scripts/build-static.sh amd64 passes on command -v bash. A renamed or typo'd script path is still an unexecuted string until tag time, with the same "tag published, no artefacts" outcome. The closing message — "all 6 before hooks resolve to an executable" — is literally true and reads stronger than what was proven. When argv[0] is bash/sh, asserting the first non-flag operand is a readable file costs two lines and takes the hooks from 1 of 6 meaningfully checked to 6 of 6. Fix this →

3. The portability comment and the dependency point opposite ways. :13-23

The comment drops mapfile because it is bash 4+ and "absent on the macOS bash a developer runs this with" — then the next statement is python3 -c 'import yaml'. Stock macOS /usr/bin/python3 has no PyYAML, so on the machine the comment is written for, this script is more likely to fail than the mapfile version was. CI is fine (green run proves it), so this only bites the local goreleaser release --snapshot path — the same path boot-smoke.sh:40-43 goes out of its way to keep working on macOS.

Either way, a missing import currently surfaces as a Python traceback and a bare non-zero exit, which is the failure mode this whole commit series has been eliminating everywhere else:

python3 -c 'import yaml' 2>/dev/null || {
  echo "check-goreleaser-hooks: needs python3 with PyYAML (pip install pyyaml)" >&2; exit 1; }

Fix this →

Nits. set -- $hook is not "exactly as goreleaser's shellwords does" (:34): it also performs pathname expansion and drops quoting. Neither changes a verdict on today's hooks, but set -f around the loop closes the glob half, and quoting starts to matter if the check ever looks past argv[0] per finding 2. Separately, builds[].tool: ./scripts/goreleaser-shim.sh (.goreleaser.yaml:40) is exec'd by goreleaser the same way and is not covered — it is 100755 in git today, so this is a completeness point rather than a live risk. And in cross-arch-build.yml, the new step landed inside the comment block that explains the Alpine static build (:144-152), so lines 144-148 now introduce the hook check rather than the build they describe; moving the step below Build statically, or the comment with it, keeps each step under its own rationale.

On finding 4 (silent init/keys add/gentx under set -e): your call, and not a merge condition. The argument for taking it here is that this PR is what first runs those six invocations under qemu, where they are the same slow path as the boot; the argument against is that it is genuinely pre-existing. I'd take it — it is four lines and it is the last place in this script where a failure does not name itself — but I would not hold the PR for it.


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

@monty-sei
monty-sei dismissed seidroid[bot]’s stale review August 27, 2026 11:42

Already addressed, re-review didn't dismiss

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.
…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.
boot-smoke bounded every boot at a fixed 25 seconds, sized for a native boot where
the handshake lands about a second in. The release hook now boots the arm64 binary
under emulation on an amd64 runner, where wasmer's JIT compile at the genesis wasm
store is the slow part and emulation multiplies it. A healthy binary could be
killed before reaching the handshake and fail the whole release.

Introduce BOOT_TIMEOUT, defaulting to 25 so every existing caller is unchanged,
and give the emulated arm64 hook 120 seconds.

The real case cannot be measured on an arm64 workstation: only amd64 can be
emulated here, possibly through Rosetta, while the release runner emulates arm64
under QEMU. The window is therefore set for headroom rather than tuned to a
number that would not transfer.
…lf-describing

The arm64 boot hook could never have run. GoReleaser execs a before-hook directly
rather than through a shell, so the BOOT_TIMEOUT=300 prefix was taken as the
program name: "exec: BOOT_TIMEOUT=300: executable file not found in $PATH". The
before pipe aborts on that, so a release would have published a tag with no
artefacts at all, including the amd64 tarball that ships today. Reproduced on
goreleaser 2.15.4 and fixed by invoking through env.

Gating the execution probe behind the foreign-architecture test left the case the
gate exists to catch with no diagnostic: a native binary that cannot run its own
subcommands skipped the probe, reached the unchecked seid init, and errexit killed
the script with zero output. Run the probe for every binary and branch only the
message, so a foreign binary is told to register binfmt and a native one reports
that seid version failed, with its output.

BOOT_TIMEOUT was the guaranteed cost of every boot rather than a ceiling, because
seid start never exits on its own. A budget large enough to be safe under
emulation was therefore also the price paid on every healthy boot. End a boot
shortly after the handshake appears, keeping a brief dwell so a crash just after
it still lands in the log. Two boots of the release binary now take 11s where
they took ~50s, which is what makes 4 boots at a 300s ceiling affordable.

Read the ELF header once and length-check it, so a truncated file reports itself
instead of dying inside od.

Verified: the preserved b-tree binary is still caught on boot 1, the pinned binary
still passes, and each of missing path, non-executable, non-ELF, truncated header,
unrunnable foreign binary and broken native binary reports its own cause.
…ot gate

Nothing in CI runs goreleaser, so every entry in before.hooks is an unexecuted
string until a tag is pushed, and this change grew that list from two entries to
six. The failure that reached review was argv[0] not resolving, which is
checkable in milliseconds: shell-split each entry and assert command -v finds the
program. Runs in the amd64 static job, which the .goreleaser.yaml path filter
already routes. Verified by reintroducing the original defect, which the check
rejects and names the fix for.

The arm64 hook ran four boots while the comment beside it argued for two. The
argument is the correct one: boot 1 is the single-threaded repro and the rest are
identical default-threaded repeats of what the native arm64 job already does, so
the code follows the comment rather than the other way round.

The crash grep now reads a snapshot taken before the boot is terminated. The
signal arrives seconds after the handshake now rather than at a settled node, so
shutdown output from a half-started node would otherwise be read as a crash. Not
observed, but a false positive here costs a tag with no artefacts.

Restores the "To re-derive:" lead-in lost from the libgcc provenance sections.

Verified after the change: the preserved b-tree binary is still caught on boot 1,
the pinned binary still passes, two boots in 10s.
The check resolved argv[0] and stopped, so `env BOOT_TIMEOUT=300 bash ...` passed on
`env` being present and the real program was never looked at. The one hook that
needed `env`, and the one the check was written for, was the one hook exempt from
it: `env BOOT_TIMEOUT=300 bahs scripts/boot-smoke.sh` would have passed and still
aborted the release. Resolve past `env` and the VAR=value operands it consumes.

Four of the six hooks only proved that bash exists. When argv[0] is an interpreter,
check that the script it runs is readable, so a renamed path fails here rather than
at tag time. builds[].tool is exec'd the same way and is now covered too, taking
the config from one hook meaningfully checked to seven.

The script dropped mapfile for the bash macOS ships, then depended on python3 with
PyYAML, which stock macOS python3 does not have: on the machine the comment named,
it was more likely to fail than what it replaced, as a bare traceback. Check for
the import and say what is missing. Globbing is disabled around the split so a hook
containing * is not expanded against the working directory.

The six per-boot setup invocations discarded both streams and run outside timeout,
so a failure aborted under set -e with no output, which from a goreleaser hook is
an unexplained non-zero exit. Each step now names itself and tails its log. This
PR is what first runs them under emulation, where they are the same slow path as
the boot.

Verified: dropping env, a typo after env, a renamed script, and env with no command
are each rejected with their own message; a failing setup step reports which one and
why; the preserved b-tree binary is still caught on boot 1 and the pinned binary
still passes.
@monty-sei
monty-sei force-pushed the monty/arm64-release-binary branch from 45ca1ee to 545d44e Compare August 27, 2026 11:55
@masih

masih commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

@seidroid review

@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.

Well-executed arm64 arch-split: the per-arch libgcc pin (checksums verified against the vendored files), the ELF-machine guards in build-static.sh / boot-smoke.sh / goreleaser-shim.sh, and the new hook-executability check all fail closed, and both prior review findings are genuinely fixed. Two non-blocking notes: the newly-added digest print can't support the cross-run comparison its comment claims, and the fail-fast hook check runs after the slow build.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • None at the file/PR level.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread scripts/build-static.sh
*) echo "build-static: ERROR: $OUT is not a linux/$ARCH binary" >&2; exit 1 ;;
esac

# Record the digest so a CI build and a later release build of the same commit can be

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The stated purpose — comparing "a CI build and a later release build of the same commit" — can't be met, because the digests differ for reasons unrelated to toolchain drift.

make build bakes version.Version=$(VERSION) and version.Commit=$(COMMIT) into the ldflags (Makefile:70-71). VERSION derives from the branch name and git describe --tags; under actions/checkout the workspace is a detached HEAD at fetch-depth 1, so BRANCH_NAME is HEAD and git describe --tags finds nothing, leaving VERSION empty in every CI static job. At release time goreleaser runs on a tag, so VERSION is the tag string. The two binaries therefore never match byte-for-byte, even on the same commit, and a mismatch tells an operator nothing about whether apk add build-base picked up a different gcc.

The line still has value for run-to-run comparison within one context (two CI runs on the same PR head, or a release rerun on the same tag), which is where apk drift would actually show. Suggest narrowing the comment to that, or — if the cross-context comparison is the goal — pinning the apk package versions instead, which is the only thing that makes the CI-vs-release link enforceable rather than observed.


# goreleaser is never run by CI, so its hooks are unexecuted strings until a tag is
# pushed. This catches an unrunnable hook in milliseconds instead.
- name: Verify goreleaser hooks are executable

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The step's own comment says it "catches an unrunnable hook in milliseconds instead", but it is placed after Build statically (Alpine / musl), which is a full Alpine apk add build-base plus a cgo make build — roughly ten minutes. A .goreleaser.yaml-only change (the exact case this gate exists for, and one the static-paths pattern deliberately matches) pays that cost before hearing about a typo'd hook.

Moving the step directly after Checkout code gets the advertised latency and costs nothing: check-goreleaser-hooks.sh only reads .goreleaser.yaml and probes PATH, with no dependency on the build output.

@masih
masih added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit fc586f4 Aug 28, 2026
85 checks passed
@masih
masih deleted the monty/arm64-release-binary branch August 28, 2026 07:40
@seidroid

seidroid Bot commented Aug 28, 2026

Copy link
Copy Markdown

Successfully created backport PR for release/v6.7:

masih added a commit that referenced this pull request Aug 28, 2026
…eleases (#4047)

Backport of #4004 to `release/v6.7`.

Co-authored-by: monty-sei <monty@seinetwork.io>
Co-authored-by: Masih H. Derkani <m@derkani.org>
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