feat(linux/build): scaffold reproducible ISO build pipeline (M1.1)

Vendors Kicksecure derivative-maker as a pinned submodule (18.1.7.4),
adds the wrapper + verify + diagnose scripts, the pinned builder image,
and the reproducibility-gated Gitea Actions workflow. Base flavour only —
no hardening overlay (that's M1.2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 04:25:48 +01:00
parent 810301908d
commit 4444dc11f3
14 changed files with 770 additions and 0 deletions

140
linux/build/scripts/build.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# SilverMetal Linux — ISO build wrapper.
#
# Runs the Kicksecure derivative-maker inside the pinned builder container
# with the reproducibility levers locked down. This script is the single
# entry point for both local developer builds and CI — there is no separate
# CI-only path. If you need to debug, run *this*, not lb directly.
#
# Usage:
# linux/build/scripts/build.sh # writes to linux/build/output/<commit>
# BUILD_DIR=/tmp/build-a linux/build/scripts/build.sh # override output root
#
# Exit codes:
# 0 ISO produced and SHA256SUMS written
# 1 argument / environment error
# 2 derivative-maker submodule missing
# 3 build failed
# 4 post-build hash/manifest step failed
set -euo pipefail
# --- Locate repo root -------------------------------------------------------
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
cd "${REPO_ROOT}"
# --- Pinned builder image ---------------------------------------------------
# Bumped together with linux/build/docker/Dockerfile.builder. The digest form
# is required; refusing the tag-only form is what stops a silent host drift.
BUILDER_IMAGE="${BUILDER_IMAGE:-docker-registry:5000/silvermetal-builder@sha256:REPLACE_WITH_PUSHED_DIGEST}"
if [[ "${BUILDER_IMAGE}" != *"@sha256:"* ]]; then
echo "build.sh: BUILDER_IMAGE must be pinned by digest, got: ${BUILDER_IMAGE}" >&2
exit 1
fi
# --- Sanity: submodule present ---------------------------------------------
if [[ ! -f "linux/build/derivative-maker/.git" && ! -d "linux/build/derivative-maker/.git" ]]; then
echo "build.sh: linux/build/derivative-maker submodule is not initialised." >&2
echo " Run: git submodule update --init --recursive" >&2
exit 2
fi
# --- Compute SOURCE_DATE_EPOCH ---------------------------------------------
# Order of preference:
# 1. Explicit env var passed in (CI may set it for cross-runner consistency)
# 2. config/source-date-epoch.env override (offline rebuilds)
# 3. git commit timestamp of HEAD (default)
# shellcheck disable=SC1091
source linux/build/config/source-date-epoch.env || true
if [[ -z "${SOURCE_DATE_EPOCH:-}" ]]; then
if [[ -n "${SOURCE_DATE_EPOCH_OVERRIDE:-}" ]]; then
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH_OVERRIDE}"
echo "build.sh: using SOURCE_DATE_EPOCH override = ${SOURCE_DATE_EPOCH}"
else
SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
fi
fi
export SOURCE_DATE_EPOCH
# --- Pinned snapshot timestamp ---------------------------------------------
# shellcheck disable=SC1091
source linux/build/config/snapshot-pin.env
export SNAPSHOT_TIMESTAMP
# --- Resolve commit & output dir -------------------------------------------
COMMIT_SHA="$(git rev-parse --short=12 HEAD)"
BUILD_DIR="${BUILD_DIR:-${REPO_ROOT}/linux/build/output/${COMMIT_SHA}}"
mkdir -p "${BUILD_DIR}"
echo "build.sh: commit=${COMMIT_SHA} epoch=${SOURCE_DATE_EPOCH} snapshot=${SNAPSHOT_TIMESTAMP}"
echo "build.sh: output -> ${BUILD_DIR}"
# --- Run the build inside the container ------------------------------------
# --privileged is required because live-build mounts loop devices and chroots.
# --network=host lets the container reach snapshot.debian.org without us
# fighting CI proxy config; tighten if/when that becomes a concern.
docker run --rm --privileged \
--network=host \
-e SOURCE_DATE_EPOCH \
-e SNAPSHOT_TIMESTAMP \
-e LC_ALL=C.UTF-8 \
-e LANG=C.UTF-8 \
-e TZ=UTC \
-v "${REPO_ROOT}:/work:rw" \
-v "${BUILD_DIR}:/out:rw" \
-w /work \
"${BUILDER_IMAGE}" \
bash -euo pipefail -c '
# shellcheck disable=SC1091
source /work/linux/build/config/silvermetal-base.conf
cd /work/linux/build/derivative-maker
./derivative-maker \
--build \
--target "${DERIVATIVE_BUILD_TARGET}" \
--flavour "${DERIVATIVE_FLAVOUR}" \
--arch "${DERIVATIVE_TARGET_ARCH}" \
--dist "${DERIVATIVE_DIST}" \
--config /work/linux/build/config/silvermetal-base.conf
# derivative-maker writes into its own build/ dir; collect into /out.
# Exact upstream output paths can shift between tags — keep this
# tolerant. Anything matching *.iso under the tree is what we want.
find . -maxdepth 6 -type f -name "*.iso" -print0 \
| xargs -0 -I{} cp -av "{}" /out/
# Manifest of file metadata that lives inside the ISO. Useful when
# diagnosing reproducibility regressions without re-extracting.
find . -maxdepth 6 -type f -name "*.manifest" -print0 \
| xargs -0 -I{} cp -av "{}" /out/ 2>/dev/null || true
' || { echo "build.sh: derivative-maker failed"; exit 3; }
# --- Hash artefacts ---------------------------------------------------------
# Run hashing on the host (not in the container) so a busted container image
# can't tamper with the digests we publish.
shopt -s nullglob
ISO_FILES=("${BUILD_DIR}"/*.iso)
shopt -u nullglob
if (( ${#ISO_FILES[@]} == 0 )); then
echo "build.sh: no ISO produced in ${BUILD_DIR}" >&2
exit 4
fi
(
cd "${BUILD_DIR}"
sha256sum -- *.iso > SHA256SUMS
cp -- "${REPO_ROOT}/linux/build/config/snapshot-pin.env" snapshot-pin.env
{
echo "commit=${COMMIT_SHA}"
echo "source_date_epoch=${SOURCE_DATE_EPOCH}"
echo "snapshot_timestamp=${SNAPSHOT_TIMESTAMP}"
echo "builder_image=${BUILDER_IMAGE}"
echo "host_uname=$(uname -srm)"
} > BUILD_INFO
)
echo "build.sh: SHA256SUMS:"
cat "${BUILD_DIR}/SHA256SUMS"

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# SilverMetal Linux — reproducibility-failure diagnostic.
#
# Invoked by verify-reproducibility.sh when two builds disagree, but also
# safe to run by hand against any two ISOs:
#
# ISO_A=/path/a.iso ISO_B=/path/b.iso linux/build/scripts/diagnose-divergence.sh
#
# Produces a diffoscope report. The output is intentionally verbose — when
# this script runs in anger we want everything we can get.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
: "${ISO_A:?ISO_A must point to the first ISO}"
: "${ISO_B:?ISO_B must point to the second ISO}"
if [[ ! -f "${ISO_A}" || ! -f "${ISO_B}" ]]; then
echo "diagnose: one of the ISOs is missing (A=${ISO_A} B=${ISO_B})" >&2
exit 1
fi
REPORT_DIR="${REPORT_DIR:-${REPO_ROOT}/linux/build/output/_divergence-$(date -u +%Y%m%dT%H%M%SZ)}"
mkdir -p "${REPORT_DIR}"
echo "diagnose: writing report to ${REPORT_DIR}"
# Quick wins first — these usually point straight at the culprit.
sha256sum "${ISO_A}" "${ISO_B}" > "${REPORT_DIR}/sha256.txt"
ls -la "${ISO_A}" "${ISO_B}" > "${REPORT_DIR}/sizes.txt" 2>&1 || true
# diffoscope — html if available (richer), text always.
if command -v diffoscope >/dev/null 2>&1; then
diffoscope --max-report-size 100000000 \
--html "${REPORT_DIR}/diff.html" \
--text "${REPORT_DIR}/diff.txt" \
"${ISO_A}" "${ISO_B}" \
|| true # non-zero exit just means "they differ"; that's why we're here
elif command -v cmp >/dev/null 2>&1; then
echo "diagnose: diffoscope not found, falling back to cmp" >&2
cmp -l "${ISO_A}" "${ISO_B}" > "${REPORT_DIR}/cmp.txt" || true
fi
# A first guess at the culprit, even when the diff is huge.
{
echo "## Likely-culprit checklist"
echo ""
echo "Walk these in order — most failures fall into the first two."
echo ""
echo " [ ] SOURCE_DATE_EPOCH was identical in both builds (compare BUILD_INFO files)"
echo " [ ] snapshot.debian.org timestamp matched (compare snapshot-pin.env files)"
echo " [ ] Same builder image digest (compare BUILD_INFO files)"
echo " [ ] mksquashfs reproducibility flags survived (-no-exports -no-xattrs -reproducible)"
echo " [ ] No build-id randomisation in kernel/initrd (look for differing .note.gnu.build-id)"
echo " [ ] No host hostname/username leakage (grep for the runner host name)"
echo " [ ] No locale drift (LC_ALL=C.UTF-8 enforced in container)"
} > "${REPORT_DIR}/checklist.md"
echo "diagnose: done. See ${REPORT_DIR}/checklist.md and ${REPORT_DIR}/diff.{html,txt}"

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# SilverMetal Linux — reproducibility gate.
#
# Builds the ISO twice from clean clones of HEAD and compares SHA256.
# This is the M1.1 exit-criterion check; CI runs it on every push.
#
# Usage:
# linux/build/scripts/verify-reproducibility.sh
# COMMIT=abc1234 linux/build/scripts/verify-reproducibility.sh # specific SHA
#
# Exit codes:
# 0 Both builds produced byte-identical ISOs
# 1 Mismatch — diagnose-divergence.sh has been invoked
# 2 Setup / clone error
# 3 One of the builds failed before producing an ISO
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
COMMIT="${COMMIT:-$(git -C "${REPO_ROOT}" rev-parse HEAD)}"
WORKROOT="${WORKROOT:-/tmp/silvermetal-repro-$$}"
DIR_A="${WORKROOT}/build-a"
DIR_B="${WORKROOT}/build-b"
cleanup() {
if [[ "${KEEP_BUILD_DIRS:-0}" != "1" ]]; then
rm -rf "${WORKROOT}"
else
echo "verify: kept ${WORKROOT} for inspection"
fi
}
trap cleanup EXIT
mkdir -p "${DIR_A}" "${DIR_B}"
clone_one() {
local dest="$1"
git clone --recurse-submodules "${REPO_ROOT}" "${dest}/repo" >/dev/null 2>&1 \
|| { echo "verify: clone -> ${dest} failed" >&2; exit 2; }
git -C "${dest}/repo" checkout --quiet "${COMMIT}"
git -C "${dest}/repo" submodule update --init --recursive --quiet
}
build_one() {
local dest="$1"
BUILD_DIR="${dest}/out" "${dest}/repo/linux/build/scripts/build.sh" \
|| { echo "verify: build in ${dest} failed" >&2; exit 3; }
}
echo "verify: cloning two copies at ${COMMIT}"
clone_one "${DIR_A}"
clone_one "${DIR_B}"
echo "verify: building copy A"
build_one "${DIR_A}"
echo "verify: building copy B"
build_one "${DIR_B}"
ISO_A="$(ls "${DIR_A}/out"/*.iso 2>/dev/null | head -n1 || true)"
ISO_B="$(ls "${DIR_B}/out"/*.iso 2>/dev/null | head -n1 || true)"
if [[ -z "${ISO_A}" || -z "${ISO_B}" ]]; then
echo "verify: missing ISO (A=${ISO_A:-<none>} B=${ISO_B:-<none>})" >&2
exit 3
fi
HASH_A="$(sha256sum "${ISO_A}" | cut -d' ' -f1)"
HASH_B="$(sha256sum "${ISO_B}" | cut -d' ' -f1)"
echo "verify: A = ${HASH_A} ${ISO_A}"
echo "verify: B = ${HASH_B} ${ISO_B}"
if [[ "${HASH_A}" == "${HASH_B}" ]]; then
echo "verify: PASS — reproducible at ${COMMIT}"
exit 0
fi
echo "verify: FAIL — running diffoscope"
KEEP_BUILD_DIRS=1
ISO_A="${ISO_A}" ISO_B="${ISO_B}" \
"${REPO_ROOT}/linux/build/scripts/diagnose-divergence.sh" \
|| true
exit 1