commit 4732be263077c6e3cf4b956bd9e963948c5ef797
parent 3ec247c67e856e6f3fd25889b2785a1d545b1aaf
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Fri, 17 Jul 2026 17:39:26 -0700
release: verify with two bootstrap runs
Diffstat:
6 files changed, 182 insertions(+), 135 deletions(-)
diff --git a/Makefile b/Makefile
@@ -74,7 +74,7 @@ help:
@echo ' make all build boot6 kernel'
@echo ' make src prep canonical src/ tree (incl. musl)'
@echo ' make package quick: package boot2-<arch>.tar.gz from current build'
- @echo ' make release validated: clean-rebuild x2 + verify, mint to dist/'
+ @echo ' make release validated: build + input/tar/output proofs, mint to dist/'
@echo ' make build/<arch>/<driver>/boot6/<kn> full chain (kn = Image | kernel.elf)'
@echo ' make build/<arch>/<driver>/bootN/<file> any single artifact'
@echo ' make test SUITE=<suite> test suite (NAMES=<filter> optional)'
@@ -95,10 +95,10 @@ package: build/$(ARCH)/$(DRIVER)/boot5/libc.a \
build/$(ARCH)/$(DRIVER)/boot6/$(KERNEL_NAME_$(ARCH))
DRIVER=$(DRIVER) tools/mkrelease.sh $(ARCH)
-# `release`: the validated path. Cleans, rebuilds + packages twice,
-# asserts both tarballs hash-match, extracts one and runs its
-# verify.sh, then promotes to dist/. Slow but paranoid; this is the
-# only way a tarball lands in dist/.
+# `release`: the validated path. Builds + packages once, regenerates
+# and compares canonical inputs, repackages the sealed payload to prove
+# stable tar bytes, then rebuilds from the archive and compares every
+# output hash. This is the only way a tarball lands in dist/.
release:
DRIVER=$(DRIVER) tools/release.sh $(ARCH)
diff --git a/README.md b/README.md
@@ -187,10 +187,11 @@ canonical generated source tree (used by every stage) is at
## Reproducible releases
-The validated release path performs two clean builds, asserts that their
-tarballs are byte-identical, extracts one into a fresh directory, reruns
-boot0 through boot6 from the bundled inputs, and compares every key output
-against its SHA-256 manifest:
+The validated release path performs one clean build, regenerates the
+canonical source tree and compares its input manifest, repackages the sealed
+payload and asserts byte-identical tar output, then rebuilds boot0 through
+boot6 from the extracted archive and compares every key output against its
+SHA-256 manifest:
```sh
ARCH=aarch64 # or amd64, riscv64
@@ -198,7 +199,8 @@ make release ARCH="$ARCH" DRIVER=podman
```
**Warning:** `make release` deliberately removes the entire `build/` tree
-before each of its two passes. The publishable results are:
+before the build and again before the canonical-input check. The publishable
+results are:
* `dist/boot2-$ARCH.tar.gz` — self-contained source and verification bundle
* `dist/boot2-$ARCH.tar.gz.sha256` — archive digest
diff --git a/tools/mkrelease.sh b/tools/mkrelease.sh
@@ -103,13 +103,7 @@ done
# ── (4) INPUT_MANIFEST.txt ────────────────────────────────────────────
echo "[mkrelease] input manifest"
INMAN=$STAGING/INPUT_MANIFEST.txt
-(
- cd "$STAGING"
- find src boot -type f | LC_ALL=C sort | while read -r rel; do
- h=$(sha256 "$rel")
- printf '%s %s\n' "$h" "$rel"
- done
-) > "$INMAN"
+tools/release/input-manifest.sh "$STAGING/src" "$STAGING/boot" "$INMAN"
n_in=$(wc -l < "$INMAN" | tr -d ' ')
# ── (5) OUTPUT_MANIFEST.txt ───────────────────────────────────────────
@@ -166,51 +160,9 @@ fi
n_out=$(wc -l < "$OUTMAN" | tr -d ' ')
# ── (6) tarball — deterministic ──────────────────────────────────────
-# Identical inputs must yield byte-identical tarballs. Sources of drift:
-# (a) gzip header embeds wall-clock mtime + original filename
-# → use `gzip -n` (no name, no timestamp).
-# (b) tar entries embed per-file mtime + uid/gid/uname/gname.
-# → normalize on-disk mtimes with `touch -t`; override ownership
-# in the tar headers via --uid/--gid/--uname/--gname.
-# (c) tar entry order = filesystem readdir order.
-# → sorted -T file list.
-# (d) a -T list containing both directories and their descendants makes
-# tar add descendants once per ancestor unless recursion is disabled.
-# → --no-recursion; every entry is already present in the find list.
-# Note: macOS bsdtar refuses `--mtime` together with `-T -`. We avoid
-# that combo by normalizing mtimes on disk first.
-# Also: a pipeline like `... | tar | gzip > out` silently produces an
-# empty gzip if tar fails. Build the uncompressed tar to a tempfile so
-# `set -e` catches tar's exit code, then gzip from disk.
echo "[mkrelease] tar -> $TARBALL"
-find "$STAGING" -exec touch -t 200001010000.00 {} +
-
-TAR_TMP=$PWD/$REL_DIR/$NAME.tar
TARBALL_ABS=$PWD/$TARBALL
-rm -f "$TAR_TMP" "$TARBALL"
-(
- cd "$REL_DIR"
- find "$NAME" -print0 | LC_ALL=C sort -z | \
- tar --null --no-recursion -T - \
- --uid 0 --gid 0 --uname '' --gname '' \
- --format ustar \
- -cf "$TAR_TMP"
-)
-gzip -n -9 < "$TAR_TMP" > "$TARBALL_ABS"
-rm -f "$TAR_TMP"
-
-# A deterministic archive must also contain each pathname exactly once.
-# Keep this as a packaging invariant so a future tar invocation cannot
-# silently reintroduce recursive duplicates.
-DUPES=$REL_DIR/$NAME.tar.entries.duplicate
-tar -tzf "$TARBALL" | LC_ALL=C sort | uniq -d > "$DUPES"
-if [ -s "$DUPES" ]; then
- echo "[mkrelease] FAIL: duplicate archive entries:" >&2
- sed 's/^/ /' "$DUPES" >&2
- rm -f "$DUPES"
- exit 1
-fi
-rm -f "$DUPES"
+tools/release/tar-payload.sh "$STAGING" "$TARBALL_ABS"
# Tarball digest (echoed for release notes; not embedded in the tar).
TAR_SHA=$(sha256 "$TARBALL")
diff --git a/tools/release.sh b/tools/release.sh
@@ -3,27 +3,21 @@
##
## Releases are critical, so the path to minting one is paranoid:
##
-## 1. `make clean` — wipe build/ entirely.
-## 2. `make package` — full prep-src → boot0..boot6 → mkrelease.sh chain.
-## Capture tarball A's sha256.
-## 3. `make clean` again — fresh state, no cached intermediates.
-## 4. `make package` again — full rebuild + repackage.
-## Capture tarball B's sha256.
-## 5. Assert A == B. Bit-for-bit reproducibility check; if this drifts,
-## something in the input set or build is nondeterministic and the
-## release is not safe to publish.
-## 6. Extract one tarball into a fresh dir under $HOME (so the macOS
-## podman VM can see it) and run the bundled verify.sh. verify.sh
-## re-runs boot0..boot6 off the bundled inputs and diffs each
-## artifact's sha256 against OUTPUT_MANIFEST.txt. End-to-end proof
-## that the shipped tarball reproduces the claimed outputs.
-## 7. Promote the validated tarball to dist/<name>.tar.gz with a
+## 1. Clean-build and package prep-src → boot0..boot6 once.
+## 2. Extract the archive into a fresh directory under $HOME.
+## 3. Clean-regenerate the checkout's canonical src/ tree and compare
+## its full boot/ + src/ hash manifest with the bundled
+## INPUT_MANIFEST.txt.
+## 4. Repackage the extracted sealed payload and assert that its tarball
+## is byte-identical to the original.
+## 5. Run the bundled verify.sh. It rebuilds boot0..boot6 from only the
+## sealed inputs and compares every artifact with OUTPUT_MANIFEST.txt,
+## proving both build determinism and archive completeness.
+## 6. Promote the validated tarball to dist/<name>.tar.gz with a
## sha256 sidecar. dist/ is the only directory mkrelease.sh /
## release.sh ever writes to that's intended for publication.
##
-## On any failure the dist/ output is NOT created; the operator gets a
-## clear error pointing at which pass failed. If the two passes differ,
-## both tarballs are copied to dist/.failed-passes/ for inspection.
+## On any failure no new dist/ output is promoted.
##
## Usage: tools/release.sh <arch>
## <arch> ∈ {aarch64, amd64, riscv64}
@@ -62,8 +56,7 @@ NAME=boot2-$ARCH
REL_DIR=build/$ARCH/release
DIST=dist
-# Pass-tarball vault — must live OUTSIDE build/ so it survives the
-# `make clean` between passes. mktemp under /tmp; cleaned on exit.
+# Validation vault — must live outside build/ so it survives make clean.
VAULT=$(mktemp -d -t boot2-release-XXXXXX)
trap 'rm -rf "$VAULT"' EXIT
@@ -79,78 +72,82 @@ hr() { printf '[release] ===================== %s =====================\n' "$*"
t0=$(date +%s)
-# Two passes from a clean state. Each pass: make clean + make package.
-# Save each tarball aside under a pass-specific name so we can compare
-# them even if the recipe is rerun by hand later.
-do_pass() {
- _label=$1; _outvar=$2
- hr "pass $_label"
- log "make clean"
- make clean >/dev/null
- log "make package ARCH=$ARCH DRIVER=$DRIVER"
- make package ARCH="$ARCH" DRIVER="$DRIVER"
- _src=$REL_DIR/$NAME.tar.gz
- _dst=$VAULT/$NAME.pass-$_label.tar.gz
- [ -f "$_src" ] || { log "FAIL: pass $_label produced no $_src"; exit 1; }
- cp "$_src" "$_dst"
- _h=$(sha256 "$_dst")
- log "pass $_label sha256: $_h"
- eval "$_outvar=\$_h"
-}
-
-do_pass A SHA_A
-do_pass B SHA_B
-
-hr "compare"
-if [ "$SHA_A" != "$SHA_B" ]; then
- log "FAIL: pass A and pass B produced DIFFERENT tarballs"
- log " A: $SHA_A ($VAULT/$NAME.pass-A.tar.gz)"
- log " B: $SHA_B ($VAULT/$NAME.pass-B.tar.gz)"
- log ""
- log "Copying tarballs to dist/.failed-passes/ for inspection (vault is auto-cleaned)."
- mkdir -p "$DIST/.failed-passes"
- cp "$VAULT/$NAME.pass-A.tar.gz" "$DIST/.failed-passes/"
- cp "$VAULT/$NAME.pass-B.tar.gz" "$DIST/.failed-passes/"
- log "To investigate, extract both and diff the trees:"
- log " mkdir /tmp/A /tmp/B"
- log " tar xzf $DIST/.failed-passes/$NAME.pass-A.tar.gz -C /tmp/A"
- log " tar xzf $DIST/.failed-passes/$NAME.pass-B.tar.gz -C /tmp/B"
- log " diff -r /tmp/A /tmp/B"
- log "(check INPUT_MANIFEST.txt and OUTPUT_MANIFEST.txt for hash drift)"
- exit 1
-fi
-log "OK: both passes produced identical tarballs"
-log " sha256 = $SHA_A"
-
-# End-to-end verify: extract the tarball, run its verify.sh in a fresh
-# tree, and hash-diff every artifact in OUTPUT_MANIFEST.txt. Always run
-# podman first: a seed verification needs the podman-built boot6 kernel
-# as its runtime. When the release itself was built with DRIVER=seed,
-# follow the podman proof with a seed proof against the same manifest.
-hr "verify"
+hr "build"
+log "make clean"
+make clean >/dev/null
+log "make package ARCH=$ARCH DRIVER=$DRIVER"
+make package ARCH="$ARCH" DRIVER="$DRIVER"
+ARCHIVE=$VAULT/$NAME.original.tar.gz
+[ -f "$REL_DIR/$NAME.tar.gz" ] || { log "FAIL: package produced no archive"; exit 1; }
+cp "$REL_DIR/$NAME.tar.gz" "$ARCHIVE"
+SHA_A=$(sha256 "$ARCHIVE")
+log "archive sha256: $SHA_A"
+
+# Extract before regenerating checkout inputs. The verify directory must
+# live under $HOME because the macOS podman VM only mounts /Users/.
+hr "extract"
# macOS podman VM only mounts /Users/, so the verify dir must live
# under $HOME. ~/.cache is a stable, throwaway-friendly location.
VERIFY_BASE=$HOME/.cache/boot2-release-verify/$ARCH
log "extract -> $VERIFY_BASE/$NAME"
rm -rf "$VERIFY_BASE"
mkdir -p "$VERIFY_BASE"
-tar xzf "$VAULT/$NAME.pass-A.tar.gz" -C "$VERIFY_BASE"
+tar xzf "$ARCHIVE" -C "$VERIFY_BASE"
+
+SEALED=$VERIFY_BASE/$NAME
+INPUT_MANIFEST=$SEALED/INPUT_MANIFEST.txt
+OUTPUT_MANIFEST=$SEALED/OUTPUT_MANIFEST.txt
+[ -f "$INPUT_MANIFEST" ] || { log "FAIL: archive has no INPUT_MANIFEST.txt"; exit 1; }
+[ -f "$OUTPUT_MANIFEST" ] || { log "FAIL: archive has no OUTPUT_MANIFEST.txt"; exit 1; }
+
+# Independently regenerate canonical inputs from the checkout. This is
+# cheap compared with boot3 and proves prep-src produces the sealed tree.
+hr "inputs"
+log "make clean"
+make clean >/dev/null
+log "make src ARCH=$ARCH DRIVER=$DRIVER"
+make src ARCH="$ARCH" DRIVER="$DRIVER"
+REGENERATED_INPUTS=$VAULT/$NAME.inputs.regenerated.txt
+tools/release/input-manifest.sh "build/$ARCH/src" boot "$REGENERATED_INPUTS"
+if ! cmp -s "$INPUT_MANIFEST" "$REGENERATED_INPUTS"; then
+ log "FAIL: regenerated canonical inputs differ from INPUT_MANIFEST.txt"
+ diff -u "$INPUT_MANIFEST" "$REGENERATED_INPUTS" || true
+ exit 1
+fi
+log "OK: regenerated canonical inputs match ($(wc -l < "$INPUT_MANIFEST" | tr -d ' ') files)"
+
+# Repackage the extracted payload with the same deterministic archiver.
+# This proves the published tar bytes are stable without rebuilding boot3.
+hr "repackage"
+REPACKED=$VAULT/$NAME.repacked.tar.gz
+tools/release/tar-payload.sh "$SEALED" "$REPACKED"
+SHA_REPACKED=$(sha256 "$REPACKED")
+if [ "$SHA_A" != "$SHA_REPACKED" ]; then
+ log "FAIL: repackaged payload differs from original archive"
+ log " original : $SHA_A"
+ log " repacked : $SHA_REPACKED"
+ exit 1
+fi
+log "OK: repackaged payload is byte-identical"
+log " sha256 = $SHA_A"
+
+# End-to-end build from the archive. Always run podman first: a seed
+# verification needs the podman-built boot6 kernel as its runtime.
+hr "verify"
log "running ./verify.sh (DRIVER=podman) — this rebuilds the chain"
-( cd "$VERIFY_BASE/$NAME" && DRIVER=podman ./verify.sh )
+( cd "$SEALED" && DRIVER=podman ./verify.sh )
if [ "$DRIVER" = seed ]; then
log "running ./verify.sh (DRIVER=seed) — this closes the kernel loop"
- ( cd "$VERIFY_BASE/$NAME" && DRIVER=seed ./verify.sh )
+ ( cd "$SEALED" && DRIVER=seed ./verify.sh )
fi
-OUTPUT_MANIFEST=$VERIFY_BASE/$NAME/OUTPUT_MANIFEST.txt
-[ -f "$OUTPUT_MANIFEST" ] || { log "FAIL: archive has no OUTPUT_MANIFEST.txt"; exit 1; }
OUTPUT_MANIFEST_SHA=$(sha256 "$OUTPUT_MANIFEST")
# Promote to dist/ — the only directory we treat as "publishable".
hr "mint"
mkdir -p "$DIST"
-cp "$VAULT/$NAME.pass-A.tar.gz" "$DIST/$NAME.tar.gz"
+cp "$ARCHIVE" "$DIST/$NAME.tar.gz"
printf '%s %s\n' "$SHA_A" "$NAME.tar.gz" > "$DIST/$NAME.tar.gz.sha256"
cp "$OUTPUT_MANIFEST" "$DIST/$NAME.outputs.sha256"
diff --git a/tools/release/input-manifest.sh b/tools/release/input-manifest.sh
@@ -0,0 +1,43 @@
+#!/bin/sh
+## input-manifest.sh — hash a sealed boot2 src/ + boot/ input pair.
+##
+## Usage: tools/release/input-manifest.sh <src-dir> <boot-dir> <output>
+##
+## Paths in the output are rooted at src/ and boot/ regardless of where
+## the two input directories live. This lets release.sh compare a freshly
+## regenerated checkout tree with the manifest bundled in a release.
+
+set -eu
+
+SRC_DIR=${1:-}
+BOOT_DIR=${2:-}
+OUTPUT=${3:-}
+[ -d "$SRC_DIR" ] || { echo "input-manifest: missing src dir: $SRC_DIR" >&2; exit 2; }
+[ -d "$BOOT_DIR" ] || { echo "input-manifest: missing boot dir: $BOOT_DIR" >&2; exit 2; }
+[ -n "$OUTPUT" ] || { echo "usage: $0 <src-dir> <boot-dir> <output>" >&2; exit 2; }
+
+if command -v sha256sum >/dev/null 2>&1; then
+ sha256() { sha256sum "$1" | awk '{print $1}'; }
+else
+ sha256() { shasum -a 256 "$1" | awk '{print $1}'; }
+fi
+
+emit_tree() {
+ _prefix=$1
+ _dir=$2
+ (
+ cd "$_dir"
+ find . -type f | LC_ALL=C sort | while read -r _path; do
+ _rel=${_path#./}
+ _hash=$(sha256 "$_path")
+ printf '%s %s/%s\n' "$_hash" "$_prefix" "$_rel"
+ done
+ )
+}
+
+# LC_ALL=C lexical order puts boot/ before src/, matching the bundled
+# INPUT_MANIFEST.txt contract.
+{
+ emit_tree boot "$BOOT_DIR"
+ emit_tree src "$SRC_DIR"
+} > "$OUTPUT"
diff --git a/tools/release/tar-payload.sh b/tools/release/tar-payload.sh
@@ -0,0 +1,53 @@
+#!/bin/sh
+## tar-payload.sh — create a deterministic boot2 release archive.
+##
+## Usage: tools/release/tar-payload.sh <payload-dir> <output.tar.gz>
+
+set -eu
+
+PAYLOAD_ARG=${1:-}
+OUTPUT_ARG=${2:-}
+[ -d "$PAYLOAD_ARG" ] || { echo "tar-payload: missing payload dir: $PAYLOAD_ARG" >&2; exit 2; }
+[ -n "$OUTPUT_ARG" ] || { echo "usage: $0 <payload-dir> <output.tar.gz>" >&2; exit 2; }
+
+PAYLOAD_PARENT=$(cd "$(dirname "$PAYLOAD_ARG")" && pwd)
+PAYLOAD_NAME=$(basename "$PAYLOAD_ARG")
+PAYLOAD=$PAYLOAD_PARENT/$PAYLOAD_NAME
+OUTPUT_PARENT=$(cd "$(dirname "$OUTPUT_ARG")" && pwd)
+OUTPUT=$OUTPUT_PARENT/$(basename "$OUTPUT_ARG")
+
+case "$OUTPUT" in
+ "$PAYLOAD"/*)
+ echo "tar-payload: output must not be inside payload: $OUTPUT" >&2
+ exit 2
+ ;;
+esac
+
+TAR_TMP=$OUTPUT_PARENT/.$(basename "$OUTPUT").tmp.tar
+GZIP_TMP=$OUTPUT_PARENT/.$(basename "$OUTPUT").tmp.gz
+DUPES=$OUTPUT_PARENT/.$(basename "$OUTPUT").entries.duplicate
+cleanup() { rm -f "$TAR_TMP" "$GZIP_TMP" "$DUPES"; }
+trap cleanup 0 1 2 15
+
+# Normalize all archive metadata and ordering. The input list already
+# contains every directory and file, so tar recursion must stay disabled.
+find "$PAYLOAD" -exec touch -t 200001010000.00 {} +
+(
+ cd "$PAYLOAD_PARENT"
+ find "$PAYLOAD_NAME" -print0 | LC_ALL=C sort -z | \
+ tar --null --no-recursion -T - \
+ --uid 0 --gid 0 --uname '' --gname '' \
+ --format ustar \
+ -cf "$TAR_TMP"
+)
+gzip -n -9 < "$TAR_TMP" > "$GZIP_TMP"
+mv "$GZIP_TMP" "$OUTPUT"
+
+# Reject recursive duplicates even if the tar command is changed later.
+tar -tzf "$OUTPUT" | LC_ALL=C sort | uniq -d > "$DUPES"
+if [ -s "$DUPES" ]; then
+ echo "tar-payload: duplicate archive entries:" >&2
+ sed 's/^/ /' "$DUPES" >&2
+ rm -f "$OUTPUT"
+ exit 1
+fi