boot2

Playing with the boostrap
git clone https://git.ryansepassi.com/git/boot2.git
Log | Files | Refs | README

commit 3ec247c67e856e6f3fd25889b2785a1d545b1aaf
parent a1819a20019d757fd95370553aec757420f5f026
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 17 Jul 2026 16:59:57 -0700

release: harden reproducible artifacts

Diffstat:
MMakefile | 4++--
MREADME.md | 38++++++++++++++++++++++++++++++++++++++
Mboot/boot4.sh | 15++-------------
Mdocs/TCC.md | 7++-----
Mtests/cc/340-struct-char-array-string-init.c | 3+--
Mtools/mkrelease.sh | 18+++++++++++++++++-
Mtools/release.sh | 28+++++++++++++++++++++-------
Mtools/release/README.md | 7+++++--
8 files changed, 88 insertions(+), 32 deletions(-)

diff --git a/Makefile b/Makefile @@ -73,7 +73,7 @@ help: @echo 'Targets (default ARCH=$(ARCH) DRIVER=$(DRIVER)):' @echo ' make all build boot6 kernel' @echo ' make src prep canonical src/ tree (incl. musl)' - @echo ' make package quick: package boot2-<arch>-<rev>.tar.gz from current build' + @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 build/<arch>/<driver>/boot6/<kn> full chain (kn = Image | kernel.elf)' @echo ' make build/<arch>/<driver>/bootN/<file> any single artifact' @@ -89,7 +89,7 @@ clean: # Depends on the full chain (boot5 musl + boot6 kernel) so # OUTPUT_MANIFEST.txt has real artifacts to hash. boot5 is not on the # `all` dep path (boot6 doesn't link musl), so list it explicitly here. -# Lands at build/<arch>/release/boot2-<arch>-<rev>.tar.gz. Fast: no +# Lands at build/<arch>/release/boot2-<arch>.tar.gz. Fast: no # reproducibility or verify check. package: build/$(ARCH)/$(DRIVER)/boot5/libc.a \ build/$(ARCH)/$(DRIVER)/boot6/$(KERNEL_NAME_$(ARCH)) diff --git a/README.md b/README.md @@ -185,6 +185,44 @@ Per-stage outputs land at `build/<arch>/<driver>/boot{0..6}/`; the canonical generated source tree (used by every stage) is at `build/<arch>/src/`. +## 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: + +```sh +ARCH=aarch64 # or amd64, riscv64 +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: + +* `dist/boot2-$ARCH.tar.gz` — self-contained source and verification bundle +* `dist/boot2-$ARCH.tar.gz.sha256` — archive digest +* `dist/boot2-$ARCH.outputs.sha256` — expected hashes for all stage artifacts +* `dist/boot2-$ARCH.tar.gz.provenance` — revision, driver, host, and timestamp + +Artifact hashes are release-specific and are generated from the validated +build; they are not hard-coded in this README. The final artifact is the +`boot6/Image` row for aarch64 and `boot6/kernel.elf` for amd64/riscv64. + +To verify and reproduce a minted release on macOS (use `sha256sum` instead of +`shasum -a 256` on Linux): + +```sh +(cd dist && shasum -a 256 -c "boot2-$ARCH.tar.gz.sha256") +(cd dist && tar xzf "boot2-$ARCH.tar.gz") +(cd "dist/boot2-$ARCH" && ./verify.sh) # podman pass +(cd "dist/boot2-$ARCH" && DRIVER=seed ./verify.sh) # optional kernel pass +``` + +On macOS, keep the extracted tree under `/Users/` so the podman VM can mount +it. See [the bundled release contract](tools/release/README.md) for the trust +path, prerequisites, manifest layout, and timeout controls. + ## Tests Suites live in `tests/` with their own Makefile (included from the diff --git a/boot/boot4.sh b/boot/boot4.sh @@ -49,13 +49,6 @@ ## tcc1-built tcc compiler helper archive ## build/$ARCH/$DRIVER/boot4/hello — mes-libc-linked smoke binary ## -## ─── Env knobs ──────────────────────────────────────────────────────── -## TCC_BOOTSTRAP_RELAX_FIXEDPOINT=1 -## After a codegen-altering tcc patch, tcc0 (still built by the old -## cc.scm) may lag tcc1 by a bounce. Set this to accept tcc2 even -## when tcc1 != tcc2; the next boot4 run, started from this run's -## tcc2, will reach tcc1 == tcc2 with no extra knob. -## ## Usage: boot/boot4.sh <arch> ## <arch> ∈ {aarch64, amd64, riscv64} for either DRIVER (default podman). @@ -115,12 +108,8 @@ runscm_run "${BOOT4_TIMEOUT:-5400}" if ! cmp -s "$OUT/tcc1" "$OUT/tcc2"; then s1=$(wc -c <"$OUT/tcc1") s2=$(wc -c <"$OUT/tcc2") - if [ "${TCC_BOOTSTRAP_RELAX_FIXEDPOINT:-0}" = 1 ]; then - echo "[$BOOT_TAG] WARN: tcc1 ($s1) != tcc2 ($s2); TCC_BOOTSTRAP_RELAX_FIXEDPOINT=1, accepting tcc2" >&2 - else - echo "[$BOOT_TAG] FIXED-POINT FAIL: tcc1 ($s1) != tcc2 ($s2)" >&2 - exit 1 - fi + echo "[$BOOT_TAG] FIXED-POINT FAIL: tcc1 ($s1) != tcc2 ($s2)" >&2 + exit 1 fi # ── normalize output names (drop s2- prefix) ────────────────────────── diff --git a/docs/TCC.md b/docs/TCC.md @@ -178,11 +178,8 @@ emit `ADDIW r,r,0` at a 64-to-32-bit boundary, producing the canonical sign-extended low word before any comparison. The regression is `tests/cc/341-riscv64-u32-register-narrow`. -The self-host chain is now a clean fixed point: tcc0 and tcc1 emit -byte-identical `tcc.flat.c` objects, and boot4 verifies `tcc1 == tcc2` -without `TCC_BOOTSTRAP_RELAX_FIXEDPOINT`. An older 208-byte object-code -difference documented here predated the widening patches; it came from -TCC's old RV64 widening behavior, not a current cc.scm miscompile. +The self-host chain reaches a clean fixed point: tcc0 and tcc1 emit +byte-identical `tcc.flat.c` objects, and boot4 verifies `tcc1 == tcc2`. ## AT-series patches (post-bootstrap uniformity) diff --git a/tests/cc/340-struct-char-array-string-init.c b/tests/cc/340-struct-char-array-string-init.c @@ -4,8 +4,7 @@ * element* initialized by a string literal must be encoded as the * string's BYTES, not a pointer to the string pool. This is the shape * of tcc's ArHdr archive-header template (static struct of char[] - * fields); mis-encoding it as pointers produced corrupt `.a` files and - * cost the tcc self-host an extra fixed-point bounce. + * fields); mis-encoding it as pointers produces corrupt `.a` files. */ typedef struct { char a[16]; char b[6]; char c[6]; char e[2]; } Hdr; diff --git a/tools/mkrelease.sh b/tools/mkrelease.sh @@ -174,6 +174,9 @@ n_out=$(wc -l < "$OUTMAN" | tr -d ' ') # 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 @@ -188,7 +191,7 @@ rm -f "$TAR_TMP" "$TARBALL" ( cd "$REL_DIR" find "$NAME" -print0 | LC_ALL=C sort -z | \ - tar --null -T - \ + tar --null --no-recursion -T - \ --uid 0 --gid 0 --uname '' --gname '' \ --format ustar \ -cf "$TAR_TMP" @@ -196,6 +199,19 @@ rm -f "$TAR_TMP" "$TARBALL" 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" + # Tarball digest (echoed for release notes; not embedded in the tar). TAR_SHA=$(sha256 "$TARBALL") printf '%s %s\n' "$TAR_SHA" "$NAME.tar.gz" > "$REL_DIR/$NAME.tar.gz.sha256" diff --git a/tools/release.sh b/tools/release.sh @@ -22,8 +22,8 @@ ## 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. The two intermediate -## tarballs are kept under build/<arch>/release/ for forensic diffing. +## clear error pointing at which pass failed. If the two passes differ, +## both tarballs are copied to dist/.failed-passes/ for inspection. ## ## Usage: tools/release.sh <arch> ## <arch> ∈ {aarch64, amd64, riscv64} @@ -122,9 +122,11 @@ fi log "OK: both passes produced identical tarballs" log " sha256 = $SHA_A" -# End-to-end verify: extract the (still-installed) tarball, run its -# verify.sh, which re-runs boot0..boot6 inside a fresh tree and -# hash-diffs every artifact in OUTPUT_MANIFEST.txt. +# 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" # macOS podman VM only mounts /Users/, so the verify dir must live # under $HOME. ~/.cache is a stable, throwaway-friendly location. @@ -134,20 +136,31 @@ rm -rf "$VERIFY_BASE" mkdir -p "$VERIFY_BASE" tar xzf "$VAULT/$NAME.pass-A.tar.gz" -C "$VERIFY_BASE" -log "running ./verify.sh (DRIVER=$DRIVER) — this rebuilds the chain" -( cd "$VERIFY_BASE/$NAME" && DRIVER=$DRIVER ./verify.sh ) +log "running ./verify.sh (DRIVER=podman) — this rebuilds the chain" +( cd "$VERIFY_BASE/$NAME" && 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 ) +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" printf '%s %s\n' "$SHA_A" "$NAME.tar.gz" > "$DIST/$NAME.tar.gz.sha256" +cp "$OUTPUT_MANIFEST" "$DIST/$NAME.outputs.sha256" # Provenance sidecar — keeps git rev / build host / driver / timestamp # outside the tarball so they don't perturb the content hash. { printf 'tarball: %s.tar.gz\n' "$NAME" printf 'sha256: %s\n' "$SHA_A" + printf 'outputs: %s.outputs.sha256\n' "$NAME" + printf 'outputs_sha256: %s\n' "$OUTPUT_MANIFEST_SHA" printf 'arch: %s\n' "$ARCH" printf 'driver: %s\n' "$DRIVER" printf 'git_rev: %s%s\n' "$REV" "$DIRTY" @@ -163,3 +176,4 @@ bytes=$(wc -c < "$DIST/$NAME.tar.gz" | tr -d ' ') log "MINTED in ${elapsed}s" log " tarball : $DIST/$NAME.tar.gz ($bytes bytes)" log " sha256 : $SHA_A" +log " outputs : $DIST/$NAME.outputs.sha256" diff --git a/tools/release/README.md b/tools/release/README.md @@ -8,7 +8,9 @@ outputs are sha256-compared against `OUTPUT_MANIFEST.txt`. The tarball is content-addressed: its sha256 reflects only the bundled inputs + manifests. Provenance (the git rev it was built from, build date, build host arch) lives in the sidecar `<tarball>.provenance` -file in the same directory as the tarball, not inside it. +file in the same directory as the tarball, not inside it. Validated +releases also publish `<name>.outputs.sha256`, an external copy of the +artifact hashes bundled as `OUTPUT_MANIFEST.txt`. ## Contents @@ -55,7 +57,8 @@ cd boot2-@ARCH@ # Default: DRIVER=podman. Builds container images on first run. ./verify.sh -# Or re-run inside the boot6-built kernel under qemu (closes the loop): +# After that podman pass, re-run inside its boot6-built kernel under qemu +# to close the loop: DRIVER=seed ./verify.sh ```