commit 3ec247c67e856e6f3fd25889b2785a1d545b1aaf
parent a1819a20019d757fd95370553aec757420f5f026
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Fri, 17 Jul 2026 16:59:57 -0700
release: harden reproducible artifacts
Diffstat:
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
```