commit 2c5b3f5a1c54ba790d00a2d17dea3685e8206e01
parent 2fd574b3dbb533dc05f32f8aaa52812579e193c8
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 17 Jun 2026 13:42:07 -0700
selfdist: hermetic test/dist suite (62 cases: install/flip/rollback/prune/verify-key/tamper/bootstrap/networked) + scripts/release.sh matrix driver; exempt update from --version banner shortcut
Diffstat:
4 files changed, 360 insertions(+), 16 deletions(-)
diff --git a/driver/main.c b/driver/main.c
@@ -243,8 +243,12 @@ static int dispatch(const char* name, int argc, char** argv,
* own argument parser runs, so it works for both the symlink form
* (argv[0] == "cc ... --version") and the subcommand form
* ("kit cc ... --version", which also routes through dispatch). The scan
- * stops at "--", so a flag forwarded to a JITed program / guest is safe. */
- if (driver_argv_wants_version(argc, argv)) {
+ * stops at "--", so a flag forwarded to a JITed program / guest is safe.
+ * `update` is exempt: there `--version <ver>` is a value-bearing flag
+ * (flip to an installed version), not a banner query, so the uniform
+ * shortcut would hijack it. */
+ if (!driver_streq(tool->name, "update") &&
+ driver_argv_wants_version(argc, argv)) {
driver_print_version(tool->name);
return 0;
}
diff --git a/mk/test.mk b/mk/test.mk
@@ -75,6 +75,7 @@ TEST_TARGETS = \
test-driver-objdump \
test-driver-pkg \
test-dist \
+ test-selfdist \
test-driver-strings \
test-driver-tools \
test-driver-wasm \
@@ -325,6 +326,15 @@ test-driver-objdump: bin
test-driver-pkg: bin
@KIT=$(abspath $(BIN)) sh test/pkg/run.sh
+# test-selfdist: hermetic self-distribution suite — drives `kit update` over
+# packages built with `kit pkg create` (the same operation `make dist` runs),
+# entirely under a sandbox $KIT_HOME so it touches no real install. Covers
+# offline install/flip/rollback/prune, explicit-key + wrong-key + tamper
+# rejection, stock-minisign bootstrap verification, and the networked +
+# monotonic channel-index path over file:// (self-skips if curl is absent).
+test-selfdist: bin
+ @KIT=$(abspath $(BIN)) sh test/dist/run.sh
+
# test-extlink: prove kit-emitted objects link with the *system* linkers
# (LLVM lld ELF/COFF/Mach-O personalities + Apple ld), not just kit's own
# `ld`. Lanes self-skip when a linker / SDK is absent, so it is safe in the
diff --git a/scripts/release.sh b/scripts/release.sh
@@ -0,0 +1,324 @@
+#!/usr/bin/env bash
+# scripts/release.sh — the multi-target kit release driver.
+#
+# This is the matrix counterpart to `make dist`: where `make dist` stages and
+# signs the toolchain for the NATIVE host triple only (and falls back to the
+# in-tree NON-RELEASE test key), release.sh does the same staging + packaging
+# for EVERY hosted (runnable) triple in scripts/hosted.sh's support set, signs
+# each artifact with the REAL release key, and finally emits + signs one
+# `kit-release 1` channel index covering all of them. See doc/plan/SELFDIST.md
+# ("Release artifacts", "Channel index format", and the scripts/release.sh work
+# item) and mk/dist.mk for the per-target staging/packaging contract this
+# mirrors.
+#
+# For each hosted target it:
+# 1. cross-builds the kit binary for the target (scripts/kit_cross.sh)
+# 2. builds the per-target rt archive(s) (make rt-<variant>, target codegen)
+# 3. stages the same self-contained tree `make dist` stages
+# 4. packages a signed fat .kpkg, a signed portable .tar.gz, and a detached
+# .tar.gz.minisig (the stock-minisign bootstrap path)
+# then assembles one signed channel index over the whole matrix.
+#
+# Freestanding targets have no OS to run on, so they produce NO kit release
+# artifact (SELFDIST.md: "the release set is the hosted triples only"); they are
+# excluded by design.
+#
+# Prerequisites
+# -------------
+# * A REAL signing key in $KIT_SIGN_KEY (see below). Unlike `make dist`, this
+# script never falls back to the in-tree test key — a release is signed with
+# the real release secret or not at all.
+# * Cross-build sysroots for every foreign-libc target, provisioned once with
+# `make provision TARGET=<selector>` (doc/plan/SYSROOTS.md). kit_cross.sh
+# errors (does not skip) when a requested target's sysroot is missing.
+# * For the VM-validated targets (freebsd/windows) the usual VM/exec infra —
+# drop them with KIT_VM=0 (below) on a VM-less CI runner.
+#
+# Environment knobs
+# -----------------
+# KIT_SIGN_KEY (required) path to the real release minisign secret key.
+# KIT_RELEASE_URL_BASE (optional) one or more space-separated mirror base URLs
+# for the channel index `url` lines. Each artifact's URL
+# is "<base>/<filename>". Defaults to a GitHub-Releases-
+# style base: https://github.com/<org>/kit/releases/
+# download/v<VERSION>. Repeat (space-separated) for an
+# ordered mirror list.
+# KIT_RELEASE_TARGETS (optional) hosted.sh selector for the target set
+# (default: all). Freestanding tokens are always dropped.
+# KIT_VM (optional, default 1) 0 drops the VM-only targets
+# (freebsd/windows) for a VM-less CI runner, same meaning
+# as in scripts/hosted.sh.
+# KIT_CHANNEL (optional, default "stable") channel name for the index
+# and the index file basename (<channel>.index).
+# KIT (optional) path to a prebuilt native kit to use as the
+# packaging tool (default: the build/release/kit produced
+# by `make RELEASE=1 bin`).
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+HOSTED="$ROOT/scripts/hosted.sh"
+
+die() { printf 'release: %s\n' "$*" >&2; exit 1; }
+log() { printf 'release: %s\n' "$*" >&2; }
+
+# ---- preconditions ---------------------------------------------------------
+# A real signing key is mandatory; releasing with the test key is never valid.
+[ -n "${KIT_SIGN_KEY:-}" ] || die \
+ "KIT_SIGN_KEY is unset — set it to the real release minisign secret key.
+ (Unlike 'make dist', release.sh does NOT fall back to the in-tree test key.)"
+[ -f "$KIT_SIGN_KEY" ] || die "KIT_SIGN_KEY=$KIT_SIGN_KEY is not a file"
+
+[ -f "$ROOT/VERSION" ] || die "no VERSION file at repo root"
+VERSION="$(tr -d ' \t\r\n' < "$ROOT/VERSION")"
+[ -n "$VERSION" ] || die "VERSION file is empty"
+
+CHANNEL="${KIT_CHANNEL:-stable}"
+
+# Default mirror base: GitHub-Releases-style download URL for this version. Can
+# be overridden / extended (space-separated) via KIT_RELEASE_URL_BASE.
+DEFAULT_URL_BASE="https://github.com/kit/kit/releases/download/v$VERSION"
+URL_BASES="${KIT_RELEASE_URL_BASE:-$DEFAULT_URL_BASE}"
+
+OUT_DIR="$ROOT/build/release-dist"
+INDEX_FILE="$OUT_DIR/$CHANNEL.index"
+
+# ---- the native packaging tool ---------------------------------------------
+# The packaging steps (kit pkg create / sign) run on the HOST, so they need a
+# host-runnable kit. Build the release native binary first and use it as the
+# packaging tool. (Mirrors mk/dist.mk, which packages with build/release/kit.)
+log "building native bootstrap kit (make RELEASE=1 bin)"
+make RELEASE=1 bin
+KIT="${KIT:-$ROOT/build/release/kit}"
+[ -x "$KIT" ] || die "native kit not found at $KIT after 'make RELEASE=1 bin'"
+
+# ---- target set ------------------------------------------------------------
+# The release set is the hosted (runnable) triples: every target in the support
+# set that has an OS to run on. scripts/hosted.sh's `expand --mode=cross` is the
+# cross-buildable set (includes freestanding + non-host-arch macOS + android);
+# we drop the freestanding tokens, which by design ship no kit artifact. KIT_VM
+# is honored by hosted.sh itself (drops freebsd/windows when 0).
+SELECTOR="${KIT_RELEASE_TARGETS:-all}"
+TARGETS=()
+while IFS= read -r tok; do
+ [ -n "$tok" ] || continue
+ case "$tok" in
+ freestanding-*) continue ;; # no host to run on → no release artifact
+ esac
+ TARGETS+=("$tok")
+done < <(KIT_VM="${KIT_VM:-1}" bash "$HOSTED" expand "$SELECTOR" --mode=cross)
+
+[ "${#TARGETS[@]}" -gt 0 ] || die "no hosted targets selected (selector='$SELECTOR', KIT_VM=${KIT_VM:-1})"
+
+log "version=$VERSION channel=$CHANNEL targets=${TARGETS[*]}"
+
+# ---- per-target rt variant mapping -----------------------------------------
+# Map a hosted token to the mk/rt.mk RT_VARIANTS entry whose target triple
+# matches. The rt variant set is keyed by (arch,os) and is libc-agnostic on
+# Linux (musl and glibc share the *-linux rt), so both linux libcs collapse to
+# the same variant. Windows x64's rt variant is the msvc-ABI `x86_64-pc-windows`
+# (the only x86_64 windows rt in the table); Android maps to the dedicated
+# `aarch64-linux-android` variant. (Source of truth: mk/rt.mk RT_VARIANTS.)
+rt_variant_of() {
+ case "$1" in
+ linux-glibc-aa64|linux-musl-aa64) echo aarch64-linux ;;
+ linux-glibc-x64|linux-musl-x64) echo x86_64-linux ;;
+ linux-glibc-rv64|linux-musl-rv64) echo riscv64-linux ;;
+ freebsd-aa64) echo aarch64-freebsd ;;
+ freebsd-x64) echo x86_64-freebsd ;;
+ freebsd-rv64) echo riscv64-freebsd ;;
+ windows-aa64) echo aarch64-windows ;;
+ windows-x64) echo x86_64-pc-windows ;;
+ android-aa64) echo aarch64-linux-android ;;
+ macos-aa64) echo aarch64-apple-darwin ;;
+ macos-x64) echo x86_64-apple-darwin ;;
+ *) die "no rt variant mapping for token '$1' (extend rt_variant_of + mk/rt.mk)" ;;
+ esac
+}
+
+# ---- output staging --------------------------------------------------------
+rm -rf "$OUT_DIR"
+mkdir -p "$OUT_DIR"
+
+# Accumulate one index "[host]" block per target into a temp dir, one file per
+# target named by triple, so the final index can be assembled in sorted (i.e.
+# canonical, strictly-ascending-by-target) order regardless of build order.
+BLOCK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kit-release-blocks.XXXXXX")"
+cleanup() { rm -rf "$BLOCK_DIR"; }
+trap cleanup EXIT
+
+# Track artifacts produced, for the closing summary.
+SUMMARY=()
+
+# ---- per-target build/stage/package ----------------------------------------
+build_target() {
+ local token="$1"
+ local triple sysroot rt_variant
+ triple="$(bash "$HOSTED" triple "$token")" || die "bad target '$token'"
+ sysroot="$(bash "$HOSTED" path "$token" 2>/dev/null || true)"
+ rt_variant="$(rt_variant_of "$token")"
+
+ # macOS has no cross sysroot — compile against the host SDK.
+ local os="${token%%-*}"
+ if [ "$os" = macos ] && [ -z "$sysroot" ]; then
+ sysroot="$(xcrun --show-sdk-path 2>/dev/null || true)"
+ fi
+
+ log "=== $token (triple=$triple rt=$rt_variant) ==="
+
+ # 1) Cross-build the kit binary for the target. kit_cross.sh wipes its own
+ # build dir per run and writes the binary to build/kit-cross/kit/<token>/
+ # (kit.exe for windows), so capture it immediately into the per-target
+ # staging tree before any later run can clobber it.
+ log "cross-building kit binary"
+ bash "$ROOT/scripts/kit_cross.sh" "$token" --cc=kit
+ local cross_dir="$ROOT/build/kit-cross/kit/$token"
+ local cross_bin bin_name
+ if [ "$os" = windows ]; then
+ cross_bin="$cross_dir/kit.exe"; bin_name="kit.exe"
+ else
+ cross_bin="$cross_dir/kit"; bin_name="kit"
+ fi
+ [ -x "$cross_bin" ] || die "$token: cross-built kit missing at $cross_bin"
+
+ # 2) Build the per-target rt archive(s). These are TARGET objects, so they
+ # must be produced by a HOST-runnable kit emitting target code — i.e. the
+ # native packaging kit driven with -target/--sysroot — NOT the just-built
+ # foreign binary (which cannot execute on the build host). This matches
+ # mk/dist.mk, where the rt is built by the native `$(BIN) cc`. The cross
+ # binary is the SHIPPED compiler; this host kit is only the BUILD tool.
+ # Each target gets an isolated RT_BUILD_DIR so per-arch objects never mix.
+ log "building rt variant $rt_variant"
+ local rt_build_dir="$ROOT/build/release-rt/$token"
+ rm -rf "$rt_build_dir"
+ local rt_cc="$KIT cc -target $triple"
+ [ -n "$sysroot" ] && rt_cc="$rt_cc --sysroot $sysroot"
+ make "rt-$rt_variant" \
+ BIN="$KIT" \
+ RT_BUILD_DIR="$rt_build_dir" \
+ RT_CC="$rt_cc" \
+ RT_AR="$KIT ar" \
+ RT_AS="$KIT as"
+ local rt_archive="$rt_build_dir/$rt_variant/libkit_rt.a"
+ [ -f "$rt_archive" ] || die "$token: rt archive missing at $rt_archive"
+
+ # 3) Stage the self-contained tree, exactly as mk/dist.mk does: bin/kit[.exe],
+ # lib/libkit.a, include/, support/rt/{include,lib}, VERSION, README/license.
+ # libkit.a is the TARGET-arch static lib that kit_cross.sh produced next to
+ # the cross binary (its `make bin BUILD_DIR=$cross_dir` leaves both there),
+ # so the shipped lib matches the shipped binary. The rt archive is staged
+ # under support/rt/lib/ next to the rt sources, named for the target so a
+ # multi-arch tree stays unambiguous.
+ log "staging tree"
+ local stage="$OUT_DIR/stage/$triple"
+ local cross_lib="$cross_dir/libkit.a"
+ [ -f "$cross_lib" ] || die "$token: cross-built libkit.a missing at $cross_lib"
+ rm -rf "$stage"
+ mkdir -p "$stage/bin" "$stage/lib" "$stage/support/rt"
+ cp "$cross_bin" "$stage/bin/$bin_name"
+ cp "$cross_lib" "$stage/lib/libkit.a"
+ cp -r "$ROOT/include" "$stage/include"
+ cp -r "$ROOT/rt/include" "$stage/support/rt/include"
+ cp -r "$ROOT/rt/lib" "$stage/support/rt/lib"
+ cp "$ROOT/VERSION" "$stage/VERSION"
+ cp "$rt_archive" "$stage/support/rt/lib/libkit_rt-$rt_variant.a"
+ cp "$ROOT/README.md" "$stage/README.txt" 2>/dev/null || true
+ local f
+ for f in LICENSE LICENSE.txt LICENSE.md NOTICE NOTICE.txt; do
+ [ -f "$ROOT/$f" ] && cp "$ROOT/$f" "$stage/$f"
+ done
+ : # keep set -e happy after the optional copies
+
+ # 4) Package + sign, mirroring mk/dist.mk's invocations (real key, no test-key
+ # fallback). Capture the package-id (`id <64hex>`) printed by pkg create.
+ local base="kit-$VERSION-$triple"
+ local kpkg="$OUT_DIR/$base.kpkg"
+ local targz="$OUT_DIR/$base.tar.gz"
+
+ log "packaging $base.kpkg (fat)"
+ local kpkg_out kpkg_id
+ kpkg_out="$("$KIT" pkg create --name kit --version "$VERSION" \
+ --format kpkg --native-shape fat -s "$KIT_SIGN_KEY" \
+ --root "$stage" -o "$kpkg")"
+ printf '%s\n' "$kpkg_out" >&2
+ kpkg_id="$(printf '%s\n' "$kpkg_out" | sed -n 's/.*id \([0-9a-f]\{64\}\).*/\1/p')"
+ [ -n "$kpkg_id" ] || die "$token: could not parse kpkg package-id from pkg create output"
+
+ log "packaging $base.tar.gz (portable)"
+ local targz_out targz_id
+ targz_out="$("$KIT" pkg create --name kit --version "$VERSION" \
+ --format tar.gz -s "$KIT_SIGN_KEY" \
+ --root "$stage" -o "$targz")"
+ printf '%s\n' "$targz_out" >&2
+ targz_id="$(printf '%s\n' "$targz_out" | sed -n 's/.*id \([0-9a-f]\{64\}\).*/\1/p')"
+ [ -n "$targz_id" ] || die "$token: could not parse tar.gz package-id from pkg create output"
+
+ log "signing $base.tar.gz.minisig (detached, bootstrap path)"
+ "$KIT" pkg sign -s "$KIT_SIGN_KEY" \
+ -o "$targz.minisig" --comment "kit $VERSION $triple" \
+ "$targz"
+
+ # kpkg byte size, for the index `size` field.
+ local kpkg_size
+ kpkg_size="$(wc -c < "$kpkg" | tr -d ' ')"
+
+ # 5) Emit this target's index [host] block into the sort accumulator. The
+ # block file is named by triple so the final assembly sorts canonically.
+ # Index field order matches test/api/release_index_test.c's golden:
+ # target, kpkg, targz, size, then one url line per mirror base.
+ {
+ printf '[host]\n'
+ printf 'target = %s\n' "$triple"
+ printf 'kpkg = %s\n' "$kpkg_id"
+ printf 'targz = %s\n' "$targz_id"
+ printf 'size = %s\n' "$kpkg_size"
+ for base_url in $URL_BASES; do
+ printf 'url = %s/%s.kpkg\n' "${base_url%/}" "$base"
+ done
+ } > "$BLOCK_DIR/$triple"
+
+ SUMMARY+=("$triple: kpkg=$kpkg ($kpkg_size bytes, id ${kpkg_id:0:12}…) targz=$targz minisig=$targz.minisig")
+}
+
+for token in "${TARGETS[@]}"; do
+ build_target "$token"
+done
+
+# ---- channel index ---------------------------------------------------------
+# Assemble the canonical `kit-release 1` index: a fixed header, a blank line,
+# then the [host] blocks sorted ASCENDING by target triple (so the file is
+# byte-stable and parseable by kit_release_index_parse, which requires strictly
+# ascending unique targets). Blocks are separated by a blank line. The block
+# files are named by triple, so `sort` over the directory listing yields the
+# canonical order.
+log "emitting channel index $INDEX_FILE"
+{
+ printf 'kit-release 1\n'
+ printf 'channel = %s\n' "$CHANNEL"
+ printf 'version = %s\n' "$VERSION"
+ printf 'hash = blake2b-256\n'
+ first=1
+ while IFS= read -r triple; do
+ [ -n "$triple" ] || continue
+ printf '\n' # blank line before each [host] block
+ cat "$BLOCK_DIR/$triple"
+ first=0
+ done < <(find "$BLOCK_DIR" -type f -exec basename {} \; | LC_ALL=C sort)
+ : "$first"
+} > "$INDEX_FILE"
+
+log "signing channel index $INDEX_FILE.minisig"
+"$KIT" pkg sign -s "$KIT_SIGN_KEY" \
+ -o "$INDEX_FILE.minisig" --comment "kit-release $CHANNEL $VERSION" \
+ "$INDEX_FILE"
+
+# ---- summary ---------------------------------------------------------------
+printf '\nrelease: %s channel %s, version %s — %d target(s)\n' \
+ "DONE" "$CHANNEL" "$VERSION" "${#TARGETS[@]}" >&2
+for line in "${SUMMARY[@]}"; do
+ printf 'release: %s\n' "$line" >&2
+done
+printf 'release: index=%s\n' "$INDEX_FILE" >&2
+printf 'release: index.sig=%s\n' "$INDEX_FILE.minisig" >&2
+printf 'release: all artifacts under %s\n' "$OUT_DIR" >&2
diff --git a/test/dist/run.sh b/test/dist/run.sh
@@ -48,22 +48,21 @@ kh=
kit_home() { kh="$work/$1"; export KIT_HOME="$kh"; }
# stage_kpkg VER : build a signed fat kit kpkg containing bin/kit (= $KIT), a
-# VERSION file, and a small lib/ payload. Echoes the kpkg path; records the
-# package id in $LAST_KPKG_ID for the channel-index cases.
-LAST_KPKG_ID=
+# VERSION file, and a small lib/ payload. Records a pass/fail through run_ok and
+# leaves the kpkg path in $LAST_KPKG (NOT echoed — calling this in a command
+# substitution would swallow the run_ok PASS line into the captured value).
+LAST_KPKG=
stage_kpkg() {
ver=$1
sdir="$work/stage/$ver"
- out="$work/pkg/kit-$ver.kpkg"
+ LAST_KPKG="$work/pkg/kit-$ver.kpkg"
mkdir -p "$sdir/bin" "$sdir/lib"
cp "$KIT" "$sdir/bin/kit"
printf '%s\n' "$ver" > "$sdir/VERSION"
printf 'self-distribution payload for %s\n' "$ver" > "$sdir/lib/data.txt"
run_ok "create-$ver" "$KIT" pkg create \
--name kit --version "$ver" --format kpkg --native-shape fat \
- -s "$SECKEY" --root "$sdir" -o "$out"
- LAST_KPKG_ID=$(first_hex_id "$work/create-$ver.out")
- printf '%s\n' "$out"
+ -s "$SECKEY" --root "$sdir" -o "$LAST_KPKG"
}
# current_is NAME KH_DIR WANT : assert $KH_DIR/current resolves to versions/WANT.
@@ -77,9 +76,12 @@ current_is() {
fi
}
-# version_runs NAME EXE WANT_VER : run EXE --version and assert WANT_VER appears.
-version_runs() {
- name=$1; exe=$2; wantver=$3
+# runs_kit NAME EXE [WANTVER] : run EXE --version; assert it executes and prints
+# a "kit ..." banner. WANTVER (optional) additionally requires that string. (All
+# staged packages bundle the SAME test binary, so per-version banner text is
+# only asserted where that binary's own version is known to match.)
+runs_kit() {
+ name=$1; exe=$2; wantver=${3:-kit }
if "$exe" --version > "$work/$name.out" 2>&1 &&
grep -F "$wantver" "$work/$name.out" >/dev/null 2>&1; then
ok "$name"
@@ -102,7 +104,8 @@ host_triple() {
# (a) offline install of 2026.6.0
# =========================================================================== #
kit_home kh
-kpkg_60=$(stage_kpkg 2026.6.0)
+stage_kpkg 2026.6.0
+kpkg_60="$LAST_KPKG"
run_ok "a-install-60" "$KIT" update "$kpkg_60"
contains "a-install-60-msg" "$work/a-install-60.out" "installed kit 2026.6.0"
@@ -112,15 +115,18 @@ assert_file_exists "a-link-kit" "$kh/bin/kit"
assert_file_exists "a-link-cc" "$kh/bin/cc"
is_executable "a-link-kit-exec" "$kh/bin/kit"
is_executable "a-link-cc-exec" "$kh/bin/cc"
-version_runs "a-link-kit-runs" "$kh/bin/kit" 2026.6.0
+runs_kit "a-link-kit-runs" "$kh/bin/kit" "kit 2026.6.0"
+contains "a-version-file" "$kh/versions/2026.6.0/VERSION" "2026.6.0"
# =========================================================================== #
# (b) second install + atomic flip; --list marks the new current
# =========================================================================== #
-kpkg_61=$(stage_kpkg 2026.6.1)
+stage_kpkg 2026.6.1
+kpkg_61="$LAST_KPKG"
run_ok "b-install-61" "$KIT" update "$kpkg_61"
current_is "b-current-61" "$kh" 2026.6.1
-version_runs "b-link-kit-runs-61" "$kh/bin/kit" 2026.6.1
+runs_kit "b-link-kit-runs-61" "$kh/bin/kit"
+contains "b-version-file-61" "$kh/versions/2026.6.1/VERSION" "2026.6.1"
run_ok "b-list" "$KIT" update --list
contains "b-list-has-60" "$work/b-list.out" "2026.6.0"
contains "b-list-star-61" "$work/b-list.out" "* 2026.6.1"