Files
SilverMetal/.gitea/workflows/build-iso-linux.yaml
SysAdmin 34bc442dd8
Some checks failed
Build SilverMetal Linux ISO (reproducibility-gated) / builder-image (push) Successful in 1s
Build SilverMetal Linux ISO (reproducibility-gated) / build-and-verify (push) Failing after 33m40s
fix(linux/build): cover all ISO9660 dates + locate residual byte drift (M1.1 iter34)
Run #4281 cleared every layer above the ISO9660 wrapper:

    SHA256 (squashfs payload)
    caed117ca72c6c1d9204c49dd749d5f7b372f3a19cac1b2a7e66bee452a8d501  /tmp/.../a.squashfs
    caed117ca72c6c1d9204c49dd749d5f7b372f3a19cac1b2a7e66bee452a8d501  /tmp/.../b.squashfs

…squashfs is now byte-identical, ISO TOC is identical, file listing
diff is empty, but ISO SHA still differs. The remaining drift is in
the ISO9660 metadata region between the system area (first 32 KiB)
and the file payload start.

Two complementary changes:

1. xorriso post-process now sets *every* date field xorriso writes,
   not just the obvious two:

     -alter_date_r all     — atime + mtime + btime on all nodes,
                             not just mtime. ISO9660 directory
                             records carry creation+modification
                             timestamps.
     -volume_date c m x f u s — every volume-descriptor date:
       c=creation  m=modification  x=expiration  f=effective
       u=system area  s=path table
     Default for any unset volume_date is "now", which is what was
     leaking through despite us setting c+m.

2. diagnose-divergence.sh now does whole-file cmp -l (capped at 200
   lines so 1 GiB of all-different doesn't drown the report) and on
   any divergence, dumps a 128-byte xxd window from each ISO around
   the first differing byte plus a unified diff between the two
   windows. This tells us in the next failure log "first byte differs
   at offset N (LBA M), bytes around it look like X" — pinpoints the
   ISO9660 region without needing artifact download.

Workflow tail-into-log step wired up the two new files
(iso-cmp-first-200.txt, iso-around-first-diff.diff).

If iter34 still fails the gate, the new diagnostic tells us exactly
which structure (volume descriptor, path table, directory record,
boot catalog…) is still drifting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:29:37 +01:00

289 lines
12 KiB
YAML

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.
#
# Two-stage:
# 1. builder-image — rebuilds linux/build/docker/Dockerfile.builder, pushes
# to docker-registry.silverlabs.uk/silvermetal-builder:m1.1-<sha>, and
# surfaces the resulting digest as a job output. This is what previously
# had to be done by hand on 10.0.0.51 between every iter.
# 2. build-and-verify — reproducibility-gated double build, using the
# digest from step 1 via the BUILDER_IMAGE env override that build.sh
# already supports (and validates is digest-form).
#
# 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:
builder-image:
# Build + push the silvermetal-builder image on the runner host's docker
# daemon (DooD via /var/run/docker.sock, mounted into the act_runner job
# container by linux/build/runner/docker-compose.yml). The runner host
# is also already authenticated to docker-registry.silverlabs.uk via
# /root/.docker (mounted read-only into the runner) so `docker push`
# works without an explicit login step here.
runs-on: silvermetal-builder
timeout-minutes: 30
outputs:
digest: ${{ steps.push.outputs.digest }}
image: ${{ steps.push.outputs.image }}
steps:
- name: Checkout
uses: actions/checkout@v4
# No submodules needed for the builder image — its build context is
# only linux/build/docker/.
- name: Build & push silvermetal-builder
id: push
env:
REGISTRY: docker-registry.silverlabs.uk
REPO: silvermetal-builder
run: |
set -eu
TAG="m1.1-${GITHUB_SHA::12}"
IMAGE="${REGISTRY}/${REPO}:${TAG}"
LATEST="${REGISTRY}/${REPO}:latest"
echo "Building ${IMAGE}"
docker build \
-f linux/build/docker/Dockerfile.builder \
-t "${IMAGE}" \
-t "${LATEST}" \
linux/build/docker
echo "Pushing ${IMAGE}"
docker push "${IMAGE}"
docker push "${LATEST}"
# docker inspect's RepoDigests is "repo@sha256:...". Take the
# entry that matches the registry/repo we just pushed to (there
# may be multiple if the image has been pushed elsewhere too).
DIGEST=$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${IMAGE}" \
| grep "^${REGISTRY}/${REPO}@" \
| head -n1 \
| sed 's/.*@//')
if [ -z "${DIGEST}" ]; then
echo "::error::failed to resolve digest for ${IMAGE}" >&2
docker inspect "${IMAGE}" >&2 || true
exit 1
fi
echo "Pushed digest: ${DIGEST}"
{
echo "digest=${DIGEST}"
echo "image=${REGISTRY}/${REPO}@${DIGEST}"
} >> "${GITHUB_OUTPUT}"
build-and-verify:
# Self-hosted, privileged-capable. Setup procedure documented in
# linux/build/README.md ("Self-hosted runner setup").
needs: builder-image
runs-on: silvermetal-builder
timeout-minutes: 240
env:
# Override build.sh's compiled-in pin with the digest we just built &
# pushed. build.sh validates the @sha256: form on line ~37 — the
# composed value below satisfies that.
BUILDER_IMAGE: ${{ needs.builder-image.outputs.image }}
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 (this run): ${BUILDER_IMAGE}"
echo "Dockerfile.builder FROM/snapshot:"
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}"
# The catthehacker job container has cmp but not diffoscope.
# We do have the silvermetal-builder image (with
# diffoscope-minimal baked in via Dockerfile.builder) on the
# host docker daemon — built fresh at the top of this run by
# the builder-image job. Run diagnose-divergence inside that
# image so we get the rich, package-aware diff. Mount the
# workspace at the same path so REPO_ROOT-relative resolution
# in diagnose-divergence.sh works.
ISO_A="$(ls "${{ github.workspace }}/build-a"/*.iso | head -n1)"
ISO_B="$(ls "${{ github.workspace }}/build-b"/*.iso | head -n1)"
mkdir -p "${{ github.workspace }}/divergence"
SELF_CID=""
for cid in $(docker ps -q --no-trunc 2>/dev/null); do
if docker inspect "$cid" --format \
'{{range .Mounts}}{{if eq .Destination "/workspace/SilverLABS/SilverMetal"}}match{{end}}{{end}}' \
2>/dev/null | grep -q match; then
SELF_CID="$cid"; break
fi
done
if [ -n "${SELF_CID}" ]; then
docker run --rm \
--volumes-from "${SELF_CID}" \
-e ISO_A="${ISO_A}" \
-e ISO_B="${ISO_B}" \
-e REPORT_DIR="${{ github.workspace }}/divergence" \
--entrypoint /bin/bash \
"${BUILDER_IMAGE}" \
"${{ github.workspace }}/linux/build/scripts/diagnose-divergence.sh" \
|| true
else
echo "::warning::Could not find self container, falling back to host cmp"
ISO_A="${ISO_A}" ISO_B="${ISO_B}" \
REPORT_DIR="${{ github.workspace }}/divergence" \
linux/build/scripts/diagnose-divergence.sh || true
fi
# Tail key signal directly into the workflow log so we see it
# without needing artifact download (Gitea 1.25's API doesn't
# surface upload-artifact@v3 payloads through any v1 endpoint
# we've found). Print sizes, sha, checklist, and the new
# staged outputs from diagnose-divergence.sh: ISO TOC diff
# and squashfs file listing diff first (small, high signal),
# then the targeted diffoscope output on the squashfs payload.
DIVDIR="${{ github.workspace }}/divergence"
print_section() {
local title="$1" path="$2" head_lines="${3:-0}"
[ -e "${path}" ] || return 0
echo ""
echo "=== ${title} ==="
if [ "${head_lines}" -gt 0 ]; then
head -n "${head_lines}" "${path}" 2>/dev/null || true
else
cat "${path}" 2>/dev/null || true
fi
}
print_section "ISO sizes" "${DIVDIR}/sizes.txt"
print_section "SHA256 (ISO)" "${DIVDIR}/sha256.txt"
print_section "SHA256 (squashfs payload)" "${DIVDIR}/squashfs-sha256.txt"
print_section "checklist" "${DIVDIR}/checklist.md"
print_section "ISO TOC diff (xorriso lsdl)" "${DIVDIR}/toc-diff.txt" 400
print_section "squashfs file listing diff" "${DIVDIR}/sqfs-ls-diff.txt" 600
print_section "diffoscope (squashfs)" "${DIVDIR}/sqfs-diff.txt" 600
print_section "ISO cmp first 200 differing bytes" "${DIVDIR}/iso-cmp-first-200.txt" 200
print_section "Hex around first ISO divergence (diff)" "${DIVDIR}/iso-around-first-diff.diff" 50
print_section "ISO header cmp -l (first 8KB)" "${DIVDIR}/iso-header-cmp.txt" 100
echo ""
echo "(Full report uploaded as divergence-report-${{ github.run_id }})"
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