diff --git a/.gitea/workflows/build-iso.yaml b/.gitea/workflows/build-iso.yaml index 8af6c95..ec56cb5 100644 --- a/.gitea/workflows/build-iso.yaml +++ b/.gitea/workflows/build-iso.yaml @@ -42,6 +42,14 @@ jobs: - name: Build Arch ISO run: | set -euo pipefail + # Pin one timestamp for the whole job: mkarchiso derives the ISO label, + # volume UUID, file mtimes, and ext4 hash seed from SOURCE_DATE_EPOCH. + # Exporting it explicitly makes every timestamp-derived value traceable + # (and lets later steps log the same value via $GITHUB_ENV). + SOURCE_DATE_EPOCH="$(date -u +%s)" + export SOURCE_DATE_EPOCH + echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" >> "$GITHUB_ENV" + echo "Building with SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" # Clean up immediately even if this step is interrupted (cancel/exit): # unmount leftovers first, then remove the multi-GB work dir. trap 'umount -R "$PWD/work" 2>/dev/null || true; rm -rf "$PWD/work"' EXIT HUP INT TERM @@ -51,6 +59,80 @@ jobs: mkdir -p "$PWD/out" mkarchiso -v -w "$PWD/work" -o "$PWD/out" -- "$PWD" + - name: Verify ISO search metadata + run: | + set -euo pipefail + shopt -s nullglob + isos=(out/*.iso) + if (( ${#isos[@]} != 1 )); then + echo "❌ Expected exactly 1 ISO in out/, found ${#isos[@]}" >&2 + exit 1 + fi + iso=${isos[0]} + + # blkid exits non-zero when a field is missing; keep going so the + # explicit guard below can report a useful error. + vol_uuid=$(blkid -o value -s UUID "$iso" || true) + vol_label=$(blkid -o value -s LABEL "$iso" || true) + if [ -z "$vol_uuid" ]; then + echo "❌ blkid found no UUID for $iso — volume date chain broken?" >&2 + exit 1 + fi + echo "Volume UUID: $vol_uuid" + echo "Volume label: ${vol_label:-}" + echo "SOURCE_DATE_EPOCH: ${SOURCE_DATE_EPOCH:-}" + + fail() { + echo "❌ $1" >&2 + echo " If you changed boot configs (efiboot/, syslinux/, grub/) on purpose," >&2 + echo " update this 'Verify ISO search metadata' step to match." >&2 + exit 1 + } + + # 1) /boot/.uuid (the archisosearchfilename fallback key) must + # exist inside the ISO and its stem must equal the volume UUID. + uuid_refs=0 + while IFS= read -r member; do + [ -z "$member" ] && continue + member=${member#/} + stem=$(basename "$member" .uuid) + if [ "$stem" != "$vol_uuid" ]; then + fail "$member: stem '$stem' != volume UUID '$vol_uuid'" + fi + echo " ✅ $member matches volume UUID" + uuid_refs=$((uuid_refs + 1)) + done < <(bsdtar -tf "$iso" | grep -E '(^|/)boot/[0-9A-Za-z-]+\.uuid$' || true) + if (( uuid_refs == 0 )); then + fail "no /boot/.uuid file found inside the ISO" + fi + + # 2) Every archisosearchuuid= in shipped boot configs must equal the + # volume UUID; every %ARCHISO% placeholder must have been substituted. + search_refs=0 + while IFS= read -r member; do + [ -z "$member" ] && continue + member=${member#/} + if ! content=$(bsdtar -xOf "$iso" "$member" 2>/dev/null); then + fail "could not extract $member from the ISO" + fi + if printf '%s\n' "$content" | grep -q '%ARCHISO'; then + fail "$member contains an unsubstituted %ARCHISO% placeholder" + fi + while IFS= read -r val; do + [ -z "$val" ] && continue + search_refs=$((search_refs + 1)) + if [ "$val" != "$vol_uuid" ]; then + fail "$member: archisosearchuuid=$val != volume UUID '$vol_uuid'" + fi + echo " ✅ $member: archisosearchuuid=$val matches volume UUID" + done < <(printf '%s\n' "$content" | grep -o 'archisosearchuuid=[^[:space:]]*' | cut -d= -f2- || true) + done < <(bsdtar -tf "$iso" | grep -E '(^|/)(loader/entries/.*\.conf|boot/.*\.(cfg|conf))$' || true) + if (( search_refs == 0 )); then + fail "no archisosearchuuid= references found in shipped boot configs (params removed without updating this check?)" + fi + + echo "✅ ISO search metadata consistent: $uuid_refs uuid file(s), $search_refs search reference(s)" + - name: Generate Unique Dynamic Tag id: tag env: @@ -82,6 +164,7 @@ jobs: echo "release_name=Arch Linux ISO (${BASE_DATE} Rev ${REVISION})" >> "$GITHUB_OUTPUT" - name: Create Release and Upload ISO + id: release env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} # Use internal Gitea URL to bypass traefik proxy timeout on large uploads @@ -141,6 +224,8 @@ jobs: # Build the release body with SHA-256 checksums printf -v body 'Arch Linux live ISO %s\n\nChecksums (SHA-256):\n' "$RELEASE_NAME" for i in "${!isos[@]}"; do + # Release body: markdown backticks are literal (printf format below) + # shellcheck disable=SC2016 printf -v line -- '- `%s`: sha256 `%s`\n' "$(basename "${isos[$i]}")" "${sha256sums[$i]}" body+="$line" done @@ -160,6 +245,7 @@ jobs: echo "❌ Release response contained no id" >&2 exit 1 fi + echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT" echo " ✅ Created release $TAG_NAME (ID: $RELEASE_ID)" # Upload each ISO as a release asset, up to 3 attempts per file @@ -224,6 +310,124 @@ jobs: echo "✅ All ISOs uploaded to release $TAG_NAME" exit 0 + - name: Verify uploaded ISO checksums + if: success() + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + # Internal origin bypasses traefik (and its Anubis bot wall) — the + # same host the upload step already uses. + GITEA_API: http://192.168.1.168:3000/api/v1 + GITEA_ORIGIN: http://192.168.1.168:3000 + REPOSITORY: ${{ github.repository }} + TAG_NAME: ${{ steps.tag.outputs.tag_name }} + RELEASE_ID: ${{ steps.release.outputs.release_id }} + run: | + set -euo pipefail + shopt -s nullglob + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + for var in GITEA_TOKEN TAG_NAME RELEASE_ID; do + if [ -z "${!var:-}" ]; then + echo "❌ $var is empty" >&2 + exit 1 + fi + done + case "$RELEASE_ID" in + ''|*[!0-9]*) echo "❌ RELEASE_ID is not numeric: $RELEASE_ID" >&2; exit 1 ;; + esac + + # fail_verify : job fails but the release is kept — used + # for transport/API errors where the upload itself may be fine. + fail_verify() { + echo "❌ $1" >&2 + echo " Release $TAG_NAME (ID $RELEASE_ID) left in place (not proven corrupt)." >&2 + exit 1 + } + + # abort_release : the asset is provably wrong — delete the + # release so a corrupt asset can never be published, then fail. + abort_release() { + echo "❌ $1" >&2 + echo "Deleting release $TAG_NAME (ID $RELEASE_ID) so the bad upload cannot be used..." >&2 + if ! curl -sf -X DELETE -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API/repos/$REPOSITORY/releases/$RELEASE_ID" -o /dev/null; then + echo "⚠️ Could not delete release $RELEASE_ID — delete it manually!" >&2 + fi + exit 1 + } + + isos=(out/*.iso) + if (( ${#isos[@]} == 0 )); then + echo "❌ No ISO in out/ to verify" >&2 + exit 1 + fi + + # Recompute local checksums + declare -A local_sha local_size + for iso in "${isos[@]}"; do + fname=$(basename "$iso") + local_sha["$fname"]=$(sha256sum "$iso" | awk '{print $1}') + local_size["$fname"]=$(stat -c %s "$iso") + if [ -z "${local_sha[$fname]}" ]; then + echo "❌ Failed to compute SHA-256 for '$iso'" >&2 + exit 1 + fi + done + + # List the release's assets as uploaded (API error = transient, no delete) + if ! curl -sf -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API/repos/$REPOSITORY/releases/$RELEASE_ID/assets" \ + -o "$tmpdir/assets.json"; then + fail_verify "Failed to list assets of release $RELEASE_ID (API/transport error)" + fi + + for iso in "${isos[@]}"; do + fname=$(basename "$iso") + api_size=$(jq -er --arg name "$fname" \ + '.[] | select(.name == $name) | .size' "$tmpdir/assets.json" 2>/dev/null) \ + || abort_release "Asset '$fname' missing from release $RELEASE_ID" + if [ "$api_size" != "${local_size[$fname]}" ]; then + abort_release "Asset '$fname' size mismatch: server=$api_size local=${local_size[$fname]}" + fi + + # Download from the internal origin (traefik/Anubis bypassed). + # iso_name contains a space ("Alvin archlinux"), so the filename + # must be percent-encoded or curl rejects the URL outright. + enc_fname=$(jq -rn --arg v "$fname" '$v|@uri') + url="$GITEA_ORIGIN/$REPOSITORY/releases/download/$TAG_NAME/$enc_fname" + downloaded=0 + for attempt in 1 2 3; do + # Try header auth, then ?token=, then anonymous (public repo). + if curl -sfL --max-time 600 -H "Authorization: token $GITEA_TOKEN" \ + "$url" -o "$tmpdir/$fname"; then + downloaded=1 + elif curl -sfL --max-time 600 "$url?token=$GITEA_TOKEN" \ + -o "$tmpdir/$fname"; then + downloaded=1 + elif curl -sfL --max-time 600 "$url" -o "$tmpdir/$fname"; then + downloaded=1 + fi + [ "$downloaded" -eq 1 ] && break + echo " ⚠️ Download of $fname failed (attempt $attempt/3)" >&2 + [ "$attempt" -lt 3 ] && sleep $(( attempt * 10 )) + done + if [ "$downloaded" -ne 1 ]; then + fail_verify "Could not download asset '$fname' back from release $RELEASE_ID (auth/transport error, not proven corrupt)" + fi + + actual=$(sha256sum "$tmpdir/$fname" | awk '{print $1}') + expected=${local_sha[$fname]} + if [ "$actual" != "$expected" ]; then + abort_release "SHA-256 mismatch for '$fname': server=$actual local=$expected" + fi + echo " ✅ $fname: size and SHA-256 match" + rm -f "$tmpdir/$fname" + done + + echo "✅ All uploaded assets verified against local ISO checksums" + exit 0 + - name: Keep only latest 2 releases if: success() env: