From 4444dc11f384dc04c21fecfb47e0e3373a3bfd7e Mon Sep 17 00:00:00 2001 From: SysAdmin Date: Sun, 26 Apr 2026 04:25:48 +0100 Subject: [PATCH] feat(linux/build): scaffold reproducible ISO build pipeline (M1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/build-iso-linux.yaml | 146 ++++++++++++++++++ .gitmodules | 3 + linux/build/.gitignore | 13 ++ linux/build/README.md | 116 ++++++++++++++ linux/build/config/silvermetal-base.conf | 46 ++++++ linux/build/config/snapshot-pin.env | 14 ++ linux/build/config/source-date-epoch.env | 14 ++ linux/build/derivative-maker | 1 + linux/build/derivative-maker.PIN.md | 26 ++++ linux/build/docker/Dockerfile.builder | 67 ++++++++ linux/build/scripts/build.sh | 140 +++++++++++++++++ linux/build/scripts/diagnose-divergence.sh | 61 ++++++++ linux/build/scripts/verify-reproducibility.sh | 86 +++++++++++ shared/branding/linux-iso-meta.yaml | 37 +++++ 14 files changed, 770 insertions(+) create mode 100644 .gitea/workflows/build-iso-linux.yaml create mode 100644 .gitmodules create mode 100644 linux/build/.gitignore create mode 100644 linux/build/README.md create mode 100644 linux/build/config/silvermetal-base.conf create mode 100644 linux/build/config/snapshot-pin.env create mode 100644 linux/build/config/source-date-epoch.env create mode 160000 linux/build/derivative-maker create mode 100644 linux/build/derivative-maker.PIN.md create mode 100644 linux/build/docker/Dockerfile.builder create mode 100755 linux/build/scripts/build.sh create mode 100755 linux/build/scripts/diagnose-divergence.sh create mode 100755 linux/build/scripts/verify-reproducibility.sh create mode 100644 shared/branding/linux-iso-meta.yaml diff --git a/.gitea/workflows/build-iso-linux.yaml b/.gitea/workflows/build-iso-linux.yaml new file mode 100644 index 0000000..b1444b2 --- /dev/null +++ b/.gitea/workflows/build-iso-linux.yaml @@ -0,0 +1,146 @@ +name: Build SilverMetal Linux ISO (reproducibility-gated) + +# M1.1 exit-criterion check. Builds the ISO twice from a clean checkout in +# isolated directories and gates on byte-identical SHA256. On a tag push, the +# verified ISO and its SHA256SUMS are attached to a Gitea release. +# +# The release-upload pattern (create-if-not-exists then attach asset) is +# lifted from SilverLABS/SilverVPN/.gitea/workflows/build-linux-client.yaml +# lines 77-117. Keep them in sync if either changes. + +on: + push: + branches: [main] + paths: + - 'linux/**' + - 'shared/branding/linux-iso-meta.yaml' + - '.gitea/workflows/build-iso-linux.yaml' + tags: + - 'v*' + pull_request: + branches: [main] + paths: + - 'linux/**' + - 'shared/branding/linux-iso-meta.yaml' + - '.gitea/workflows/build-iso-linux.yaml' + workflow_dispatch: + +# Two reproducibility-gated builds in flight at once would just compete for +# loop devices on the privileged runner. Serialise per ref. +concurrency: + group: build-iso-linux-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-verify: + # Self-hosted, privileged-capable. Setup procedure documented in + # linux/build/README.md ("Self-hosted runner setup"). + runs-on: silvermetal-builder + timeout-minutes: 240 + + steps: + - name: Checkout (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 # need history so SOURCE_DATE_EPOCH = HEAD commit time + + - name: Show pinned inputs + run: | + set -eu + echo "commit=$(git rev-parse HEAD)" + cat linux/build/config/snapshot-pin.env + echo "builder image:" + grep -E '^FROM |^ARG APT_SNAPSHOT_URL' linux/build/docker/Dockerfile.builder + + - name: Build A + env: + BUILD_DIR: ${{ github.workspace }}/build-a + run: linux/build/scripts/build.sh + + - name: Build B (clean second build) + env: + BUILD_DIR: ${{ github.workspace }}/build-b + run: linux/build/scripts/build.sh + + - name: Compare SHA256 + id: compare + run: | + set -eu + A=$(sha256sum "${{ github.workspace }}/build-a"/*.iso | cut -d' ' -f1) + B=$(sha256sum "${{ github.workspace }}/build-b"/*.iso | cut -d' ' -f1) + echo "A=${A}" + echo "B=${B}" + echo "iso_sha256=${A}" >> "${GITHUB_OUTPUT}" + if [ "${A}" != "${B}" ]; then + echo "::error::ISO SHA256 mismatch — A=${A} B=${B}" + ISO_A="$(ls "${{ github.workspace }}/build-a"/*.iso | head -n1)" \ + ISO_B="$(ls "${{ github.workspace }}/build-b"/*.iso | head -n1)" \ + REPORT_DIR="${{ github.workspace }}/divergence" \ + linux/build/scripts/diagnose-divergence.sh + exit 1 + fi + echo "Reproducibility gate PASSED at ${A}" + + - name: Upload divergence report on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + name: divergence-report-${{ github.run_id }} + path: ${{ github.workspace }}/divergence + if-no-files-found: ignore + retention-days: 14 + + - name: Stage release artefacts + if: startsWith(github.ref, 'refs/tags/') + run: | + set -eu + mkdir -p release + cp "${{ github.workspace }}/build-a"/*.iso release/ + cp "${{ github.workspace }}/build-a"/SHA256SUMS release/ + cp "${{ github.workspace }}/build-a"/BUILD_INFO release/ + cp "${{ github.workspace }}/build-a"/snapshot-pin.env release/snapshot-pin.env + ls -la release/ + + - name: Upload to Gitea release (tag only) + if: startsWith(github.ref, 'refs/tags/') + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + TAG="${{ github.ref_name }}" + API="${{ github.server_url }}/api/v1" + REPO="${{ github.repository }}" + + # Create-if-not-exists, then attach assets. Pattern lifted from + # SilverLABS/SilverVPN build-linux-client.yaml:89-115. + RELEASE_ID=$(curl -s "${API}/repos/${REPO}/releases/tags/${TAG}" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + | jq -r '.id // empty' 2>/dev/null || true) + + if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "0" ]; then + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}/repos/${REPO}/releases" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"SilverMetal Linux ${TAG}\",\"body\":\"Reproducibility-verified ISO. SHA256 in SHA256SUMS asset.\",\"draft\":false,\"prerelease\":true}") + HTTP_CODE=$(echo "${RESPONSE}" | tail -1) + BODY=$(echo "${RESPONSE}" | sed '$d') + if [ "${HTTP_CODE}" -ge 400 ]; then + echo "ERROR: Failed to create release (${HTTP_CODE}): ${BODY}" + exit 1 + fi + RELEASE_ID=$(echo "${BODY}" | jq -r '.id') + echo "Created release ID: ${RELEASE_ID}" + else + echo "Found existing release ID: ${RELEASE_ID}" + fi + + for asset in release/*; do + name=$(basename "${asset}") + echo "Attaching ${name}" + curl -sf -X POST \ + "${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${name}" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${asset}" + done diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b4b2c27 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "linux/build/derivative-maker"] + path = linux/build/derivative-maker + url = https://github.com/Kicksecure/derivative-maker.git diff --git a/linux/build/.gitignore b/linux/build/.gitignore new file mode 100644 index 0000000..cab2ba3 --- /dev/null +++ b/linux/build/.gitignore @@ -0,0 +1,13 @@ +# Build outputs and intermediate state — never committed. +output/ +cache/ +chroot/ +binary/ +.build/ + +# diffoscope reports from the diagnose-divergence script. +_divergence-*/ + +# Local configuration overrides for developer experimentation. +config/local-* +*.local diff --git a/linux/build/README.md b/linux/build/README.md new file mode 100644 index 0000000..c4df083 --- /dev/null +++ b/linux/build/README.md @@ -0,0 +1,116 @@ +# SilverMetal Linux — reproducible ISO build pipeline + +**Milestone**: Phase 1 / M1.1 — *Kicksecure fork builds reproducibly*. +**Exit criterion**: two clean builds of the same commit produce a byte-identical SHA256. + +This directory holds everything that turns a SilverMetal commit into a SilverMetal Linux ISO. M1.1 ships only the *base* (un-hardened) Kicksecure derivative. Hardening overlay, kernel swap, AppArmor profiles, etc. land in M1.2+ and **must not** be added here in the M1.1 PR. + +## Layout + +``` +linux/build/ +├── README.md (this file) +├── derivative-maker/ git submodule -> Kicksecure/derivative-maker +├── config/ +│ ├── silvermetal-base.conf derivative selection + branding +│ ├── snapshot-pin.env pinned snapshot.debian.org timestamp +│ └── source-date-epoch.env optional SOURCE_DATE_EPOCH override +├── docker/ +│ └── Dockerfile.builder pinned debian:bookworm-slim builder image +└── scripts/ + ├── build.sh wrapper: container run -> derivative-maker + ├── verify-reproducibility.sh build twice, compare SHA256 + └── diagnose-divergence.sh diffoscope on mismatch +``` + +## How reproducibility is achieved + +The same levers any deterministic Debian build relies on, stacked together: + +| Lever | Where it lives | +|----------------------------------------|-----------------------------------------------| +| Pinned `snapshot.debian.org` mirror | `config/snapshot-pin.env` | +| `SOURCE_DATE_EPOCH` from commit time | `scripts/build.sh` (auto) | +| Pinned builder image (by digest) | `docker/Dockerfile.builder` + `BUILDER_IMAGE` | +| Deterministic `mksquashfs` flags | `MKSQUASHFS_OPTIONS` in base conf | +| Pinned upstream toolchain | `derivative-maker/` submodule | +| `LC_ALL=C.UTF-8`, `TZ=UTC` | `scripts/build.sh` | + +`diffoscope` is the *diagnostic* tool used by `diagnose-divergence.sh`; the gate itself is plain `sha256sum`. + +## Reproduce a release locally + +> Procedure mirrors `docs/trust-model.md` § *Reproducible builds*. + +Prerequisites: a Linux host (or WSL2) with Docker, ~30 GB free disk, ~8 GB RAM. + +```bash +# 1. Clone the repo at the release tag. +git clone --recurse-submodules https://git.silverlabs.uk/SilverLABS/SilverMetal.git +cd SilverMetal +git checkout v1.1.0 # whichever release you want to verify + +# 2. Build twice and compare. ~60-90 minutes per build. +linux/build/scripts/verify-reproducibility.sh + +# 3. Compare against the published release. +sha256sum -c <(curl -fsSL https://git.silverlabs.uk/SilverLABS/SilverMetal/releases/download/v1.1.0/SHA256SUMS) +``` + +Mismatch with the published artefact = supply-chain anomaly. Report channel: `security@silverlabs.uk`. + +## Build once (no reproducibility check) + +```bash +linux/build/scripts/build.sh +# Output lands in linux/build/output// +``` + +The wrapper requires `BUILDER_IMAGE` to be pinned by digest. Local dev that hasn't built and pushed an image yet should override: + +```bash +BUILDER_IMAGE=docker-registry:5000/silvermetal-builder@sha256: \ + linux/build/scripts/build.sh +``` + +## Gitea Actions + +The CI workflow (`.gitea/workflows/build-iso-linux.yaml`) is the authority for "did this commit build reproducibly?". It: + +1. Checks out the commit with submodules. +2. Runs `build.sh` twice in `${GITHUB_WORKSPACE}/build-{a,b}`. +3. Fails the run if the two ISO SHA256s differ, and uploads a `diffoscope` report as an artefact. +4. On a tag push, attaches the verified ISO + `SHA256SUMS` + `BUILD_INFO` to a Gitea release. + +### Self-hosted runner setup + +The workflow runs on `runs-on: silvermetal-builder`, a self-hosted, privileged-capable Gitea Actions runner. Create it before merging the workflow: + +1. Provision a Debian 12 VM on the cluster with ≥ 8 vCPU, ≥ 16 GB RAM, ≥ 100 GB disk. +2. Install Docker (`apt install docker.io`); ensure the runner user can run `docker run --privileged`. +3. Register `act_runner` against `git.silverlabs.uk` with label `silvermetal-builder`. +4. Pre-pull the builder image so the first reproducibility run isn't a cold start: + `docker pull docker-registry:5000/silvermetal-builder:latest` +5. Cache the apt snapshot in a Docker volume to avoid throttling: + `docker volume create silvermetal-apt-cache` + +The runner host name **must not** leak into ISO content. `LC_ALL=C.UTF-8` and a constant `TZ` in the wrapper guard against that, but spot-check with `diagnose-divergence.sh`. + +## Bumping pinned inputs + +Each of these is a deliberate, reviewed action — never automate: + +- **`derivative-maker` submodule** — bump in its own PR, with a verification log showing two clean builds match. +- **`snapshot-pin.env`** — same procedure. +- **Builder image (`Dockerfile.builder` digest)** — rebuild, push, update `BUILDER_IMAGE` in `build.sh`, run reproducibility check, commit all four together. + +## What this milestone is *not* + +- No hardening overlay (M1.2) +- No SilverBrowser/SilverVPN/SilverSync/SilverChat integration (M1.6–1.9) +- No installer branding (M1.5) +- No update server (M1.10) +- No SBOM publication (M1.11) +- No signing ceremony / MOK / Secure Boot wiring (separate milestone) + +If a change to this directory expands its scope into one of those, push back — the M1.1 gate is intentionally narrow. diff --git a/linux/build/config/silvermetal-base.conf b/linux/build/config/silvermetal-base.conf new file mode 100644 index 0000000..e52d734 --- /dev/null +++ b/linux/build/config/silvermetal-base.conf @@ -0,0 +1,46 @@ +# SilverMetal Linux — Base build configuration (M1.1) +# +# Sourced by linux/build/scripts/build.sh inside the builder container. +# Minimal SilverMetal-specific overrides on top of Kicksecure's +# derivative-maker. NO hardening overlay, NO kernel swap, NO package +# additions — that work is M1.2 and later. +# +# Bash-sourceable. Use POSIX-quoted values; no command substitution. + +# --- Derivative selection --------------------------------------------------- +DERIVATIVE_NAME="silvermetal-linux-base" +DERIVATIVE_DIST="bookworm" +DERIVATIVE_TARGET_ARCH="amd64" +DERIVATIVE_BUILD_TARGET="iso" + +# Kicksecure's derivative-maker exposes "build flavour" as the upstream +# selector. We ride on the plain Kicksecure CLI flavour here. M1.2 will +# switch this to a SilverMetal-Hardened flavour with our overlay. +DERIVATIVE_FLAVOUR="kicksecure-cli" + +# --- Branding (reads shared/branding/linux-iso-meta.yaml at script time) ---- +# These mirror the YAML; the wrapper script reconciles them so we don't have +# two sources of truth for the same value. If they diverge, build.sh fails. +BRANDING_META_FILE="shared/branding/linux-iso-meta.yaml" +BRANDING_ID="silvermetal-linux-base" +BRANDING_VERSION="1.1.0-alpha" +BRANDING_ISO_LABEL="SILVERMETAL_LINUX_BASE" + +# --- Reproducibility levers ------------------------------------------------- +# Set/overridden by build.sh; declared here so a stray invocation fails loudly +# rather than silently picking up the host's clock / mirror. +: "${SOURCE_DATE_EPOCH:?SOURCE_DATE_EPOCH must be set by build.sh}" +: "${SNAPSHOT_TIMESTAMP:?SNAPSHOT_TIMESTAMP must be set by build.sh}" + +# snapshot.debian.org URL pattern — derivative-maker honours APT_SNAPSHOT_URL +# when present; if upstream renames it, update both here and build.sh. +APT_SNAPSHOT_URL="https://snapshot.debian.org/archive/debian/${SNAPSHOT_TIMESTAMP}" +APT_SECURITY_SNAPSHOT_URL="https://snapshot.debian.org/archive/debian-security/${SNAPSHOT_TIMESTAMP}" + +# Deterministic squashfs flags. -no-exports kills inode-export tables +# (host-dependent). -no-xattrs kills xattr ordering noise. +MKSQUASHFS_OPTIONS="-no-exports -no-xattrs -reproducible -all-root -wildcards" + +# --- Output ----------------------------------------------------------------- +# Relative to repo root; build.sh moves artefacts here from the container. +OUTPUT_DIR="linux/build/output" diff --git a/linux/build/config/snapshot-pin.env b/linux/build/config/snapshot-pin.env new file mode 100644 index 0000000..9e05b09 --- /dev/null +++ b/linux/build/config/snapshot-pin.env @@ -0,0 +1,14 @@ +# Pinned snapshot.debian.org timestamp. +# +# This is the single value that determines which apt package versions land in +# the build. Bumping it is a deliberate, reviewed action — never automate it. +# +# Format: YYYYMMDDTHHMMSSZ (UTC, ISO 8601 basic, snapshot.debian.org compatible) +# +# To bump: +# 1. Pick a new timestamp from https://snapshot.debian.org/ +# 2. Run a full reproducibility check with the new value +# 3. Commit the bump in its own PR with the verification log +# +# Initial pin: bookworm point-release era (M1.1 implementation date). +SNAPSHOT_TIMESTAMP=20260415T000000Z diff --git a/linux/build/config/source-date-epoch.env b/linux/build/config/source-date-epoch.env new file mode 100644 index 0000000..d8bb697 --- /dev/null +++ b/linux/build/config/source-date-epoch.env @@ -0,0 +1,14 @@ +# SOURCE_DATE_EPOCH source-of-truth. +# +# By default build.sh derives SOURCE_DATE_EPOCH from `git log -1 --pretty=%ct` +# of the build commit. That is the reproducible-by-default mode and what CI +# uses. +# +# This file exists to allow a *deliberate* override for offline/local rebuilds +# of historical commits where git history is not available (e.g. building +# from a release tarball). When set here, build.sh prefers this value over +# the git-derived one and prints a warning. +# +# Leave commented out for normal use. +# +# SOURCE_DATE_EPOCH_OVERRIDE=1735689600 diff --git a/linux/build/derivative-maker b/linux/build/derivative-maker new file mode 160000 index 0000000..5135869 --- /dev/null +++ b/linux/build/derivative-maker @@ -0,0 +1 @@ +Subproject commit 51358698cde971d570e79318581ebbb8ba6a532a diff --git a/linux/build/derivative-maker.PIN.md b/linux/build/derivative-maker.PIN.md new file mode 100644 index 0000000..1e4e6dd --- /dev/null +++ b/linux/build/derivative-maker.PIN.md @@ -0,0 +1,26 @@ +# derivative-maker submodule pin + +The `derivative-maker/` submodule is pinned to a specific Kicksecure release tag. This is a deliberate, reviewed action — never auto-bump. + +## Current pin + +| Field | Value | +|-------------------|----------------------------------------------------------------| +| Upstream | https://github.com/Kicksecure/derivative-maker | +| Tag | `18.1.7.4-developers-only` | +| Mirror (optional) | https://git.silverlabs.uk/SilverLABS/derivative-maker (mirror) | + +> Note: Kicksecure tags every developer iteration with the `-developers-only` suffix; this is their normal release convention, not a "use at your own risk" warning. Users of Kicksecure track this same tag space. + +## Bumping the pin + +1. Pick the new tag: `git -C linux/build/derivative-maker fetch --tags` +2. `git -C linux/build/derivative-maker checkout ` +3. From the repo root: `git add linux/build/derivative-maker` +4. Run `linux/build/scripts/verify-reproducibility.sh` to completion (must pass). +5. Commit the bump on its own — *do not* combine with feature work. +6. Open the PR with the verification log attached. + +## Why a pin (and not "track main") + +Reproducibility requires every input to the build to be content-addressed. A floating submodule pointer would break the M1.1 exit criterion the moment upstream pushes a commit between two CI runs. diff --git a/linux/build/docker/Dockerfile.builder b/linux/build/docker/Dockerfile.builder new file mode 100644 index 0000000..1a64547 --- /dev/null +++ b/linux/build/docker/Dockerfile.builder @@ -0,0 +1,67 @@ +# SilverMetal Linux — reproducible-build runner image. +# +# This image is the "build host" for the ISO. Pinning it by digest is the +# only thing keeping host-toolchain drift out of the reproducibility gate, so +# do NOT replace the FROM line with a tag-only reference. +# +# Build & push (run from repo root): +# docker build \ +# -f linux/build/docker/Dockerfile.builder \ +# -t docker-registry:5000/silvermetal-builder: \ +# -t docker-registry:5000/silvermetal-builder:latest \ +# linux/build/docker +# docker push docker-registry:5000/silvermetal-builder: +# +# To bump the base image: replace the digest, rebuild, push, update +# BUILDER_IMAGE in linux/build/scripts/build.sh, run a full reproducibility +# check, commit all four changes together. + +# debian:bookworm-slim — pinned by digest. +# TODO(M1.1): replace placeholder digest with the actual one resolved at +# image-build time. The placeholder is intentionally invalid so a build that +# forgets to update it fails fast rather than silently using "latest". +FROM debian:bookworm-slim@sha256:0000000000000000000000000000000000000000000000000000000000000000 + +# Reproducibility-friendly apt configuration. +ENV DEBIAN_FRONTEND=noninteractive \ + LC_ALL=C.UTF-8 \ + LANG=C.UTF-8 \ + SOURCE_DATE_EPOCH=0 + +# Pinned package versions. These come from the same snapshot.debian.org +# timestamp as the ISO build, so a Dockerfile rebuild against that snapshot +# produces the same toolchain bit-for-bit. The actual snapshot URL is +# substituted at build time via --build-arg APT_SNAPSHOT_URL=... +ARG APT_SNAPSHOT_URL="https://snapshot.debian.org/archive/debian/20260415T000000Z" +ARG APT_SECURITY_SNAPSHOT_URL="https://snapshot.debian.org/archive/debian-security/20260415T000000Z" + +RUN set -eux; \ + rm -f /etc/apt/sources.list.d/*; \ + printf 'deb [check-valid-until=no] %s bookworm main\n' "$APT_SNAPSHOT_URL" > /etc/apt/sources.list; \ + printf 'deb [check-valid-until=no] %s bookworm-security main\n' "$APT_SECURITY_SNAPSHOT_URL" >> /etc/apt/sources.list; \ + apt-get -o Acquire::Check-Valid-Until=false update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + debootstrap \ + diffoscope-minimal \ + dosfstools \ + git \ + gnupg \ + isolinux \ + live-build \ + mtools \ + reprepro \ + rsync \ + squashfs-tools \ + syslinux-common \ + xorriso; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* + +# Non-root user for the parts of the build that don't need privilege. +# live-build itself still needs root inside the container for chroot/mount, +# so build.sh runs the container as root; this user exists for diagnostic +# tooling and matches uid 1000 to play nicely with bind mounts. +RUN useradd --uid 1000 --create-home --shell /bin/bash builder + +WORKDIR /work diff --git a/linux/build/scripts/build.sh b/linux/build/scripts/build.sh new file mode 100755 index 0000000..d7bbcbc --- /dev/null +++ b/linux/build/scripts/build.sh @@ -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/ +# 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" diff --git a/linux/build/scripts/diagnose-divergence.sh b/linux/build/scripts/diagnose-divergence.sh new file mode 100755 index 0000000..d228d5e --- /dev/null +++ b/linux/build/scripts/diagnose-divergence.sh @@ -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}" diff --git a/linux/build/scripts/verify-reproducibility.sh b/linux/build/scripts/verify-reproducibility.sh new file mode 100755 index 0000000..8b7a5d9 --- /dev/null +++ b/linux/build/scripts/verify-reproducibility.sh @@ -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:-} B=${ISO_B:-})" >&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 diff --git a/shared/branding/linux-iso-meta.yaml b/shared/branding/linux-iso-meta.yaml new file mode 100644 index 0000000..8438866 --- /dev/null +++ b/shared/branding/linux-iso-meta.yaml @@ -0,0 +1,37 @@ +# SilverMetal Linux — ISO branding metadata +# +# Single source of truth for ISO label, /etc/os-release, GRUB title. +# Referenced by linux/build/config/silvermetal-base.conf and any later overlay. +# Do not duplicate these values elsewhere — read them from here. + +schema_version: 1 + +# Phase-1 milestone 1.1 ships a *base* (un-hardened) Kicksecure derivative. +# The hardened variant lands in M1.2 and SHOULD use a different `id` so that +# os-release reflects what the user actually has installed. +id: silvermetal-linux-base +name: SilverMetal Linux (Base) +short_name: SilverMetal +pretty_name: SilverMetal Linux Base +version_codename: bookworm +home_url: https://silvermetal.silverlabs.uk +support_url: https://silvermetal.silverlabs.uk/support +bug_report_url: https://git.silverlabs.uk/SilverLABS/SilverMetal/issues +privacy_policy_url: https://silvermetal.silverlabs.uk/privacy + +# Versioning: ..[-tag] +# M1.1 produces 1.1.0-alpha builds until two clean builds match SHA256. +version: 1.1.0-alpha + +# ISO9660 volume id is capped at 32 chars, uppercase, no spaces. +iso_label: SILVERMETAL_LINUX_BASE + +# Used by GRUB / isolinux menu rendering. +grub_title: SilverMetal Linux (Base) — live +grub_distributor: SilverMetal + +# os-release fields that aren't covered by the standardised keys above. +os_release_extra: + ANSI_COLOR: "1;36" # cyan, matches SilverLABS visual identity + LOGO: silvermetal + DOCUMENTATION_URL: https://silvermetal.silverlabs.uk/docs