Tests / tests (push) Successful in 5m7s
Run 131 failed at 'pacman -Syu archiso jq' because the gateway (192.168.1.1) intermittently drops UDP/53: cachyos.db and chaotic-aur.db lookups hit libalpm's 10s resolve timeout while core/extra survived on resolved's cache. - build-iso: reset the host /etc/pacman.conf to stock core/extra first (tests.yaml leaves [cachyos]/[chaotic-aur] behind, and mkarchiso reads the profile's pacman.conf anyway), pre-warm mirror hostnames in the resolver cache, then retry pacman -Syu 5x with backoff. - tests: wrap all three pacman -Syu calls in the same retry loop. DNS-side redundancy was added on the runner separately (networkd drop-in adds 1.1.1.1/8.8.8.8 next to the gateway).
583 lines
25 KiB
YAML
583 lines
25 KiB
YAML
name: Automated Arch ISO Build
|
|
|
|
on:
|
|
schedule:
|
|
- cron: "0 0 1 * *" # Runs monthly on the 1st day of the month
|
|
workflow_dispatch: # Allows manual triggers (multiple times a month)
|
|
|
|
concurrency:
|
|
group: iso-build
|
|
cancel-in-progress: false # Never kill a build that is mid-flight
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: archlinux
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Setup local AUR repo
|
|
run: |
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
mkdir -p /tmp/localrepo
|
|
cp localrepo/*.pkg.tar.zst localrepo/local-aur.db* localrepo/local-aur.files* /tmp/localrepo/
|
|
sed -i 's|file:///home/alvin/AlvinArch/localrepo|file:///tmp/localrepo|' pacman.conf
|
|
# The medium needs its own copy: the ISO's /etc/pacman.conf points
|
|
# [local-aur] at file:///opt/localrepo, which pacstrap reads at
|
|
# install time to resolve packages that are not in the official repos
|
|
# (built during the ISO build and harvested there by
|
|
# airootfs/root/customize_airootfs.sh).
|
|
mkdir -p airootfs/opt/localrepo
|
|
shipped=(localrepo/*.pkg.tar.zst localrepo/local-aur.db* localrepo/local-aur.files*)
|
|
if (( ${#shipped[@]} > 0 )); then
|
|
cp -L "${shipped[@]}" airootfs/opt/localrepo/
|
|
fi
|
|
echo "airootfs/opt/localrepo: $(ls airootfs/opt/localrepo | wc -l) file(s)"
|
|
|
|
- name: Install archiso and dependencies
|
|
run: |
|
|
set -euo pipefail
|
|
# The host's /etc/pacman.conf is shared, mutable state: the tests
|
|
# workflow appends [cachyos] and [chaotic-aur] to it, so without this
|
|
# reset the build would sync two repos the host never needs (it only
|
|
# installs archiso/jq from extra — mkarchiso reads the *profile's*
|
|
# pacman.conf). Every extra repo is another DNS lookup, and the
|
|
# gateway intermittently drops UDP/53 (that is what failed run 131
|
|
# at this very step). Reset to stock core/extra.
|
|
cat > /etc/pacman.conf << 'STOCKCONF'
|
|
[options]
|
|
DisableSandbox
|
|
HoldPkg = pacman glibc
|
|
Architecture = auto
|
|
CheckSpace
|
|
ParallelDownloads = 5
|
|
SigLevel = Required DatabaseOptional
|
|
LocalFileSigLevel = Optional
|
|
|
|
[core]
|
|
Include = /etc/pacman.d/mirrorlist
|
|
|
|
[extra]
|
|
Include = /etc/pacman.d/mirrorlist
|
|
STOCKCONF
|
|
sed -i 's/^ //' /etc/pacman.conf
|
|
# Drop sync databases of the custom repos so they cannot linger
|
|
rm -f /var/lib/pacman/sync/cachyos* /var/lib/pacman/sync/chaotic*
|
|
|
|
# Warm the resolver cache for the mirrors we are about to query.
|
|
# systemd-resolved serves later lookups from cache, so a gateway
|
|
# UDP/53 burst cannot hit them again. Failures here are not fatal —
|
|
# the retry loop below is the real safety net.
|
|
for host in mirror.aarnet.edu.au gsl-syd.mm.fcix.net au.arch.niranjan.co; do
|
|
for try in 1 2 3 4 5; do
|
|
if getent hosts "$host" >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
echo " ⏳ DNS for $host failed (try $try/5), retrying in $(( try * 3 ))s..."
|
|
sleep $(( try * 3 ))
|
|
done
|
|
done
|
|
|
|
# The gateway drops DNS queries in bursts; one burst must not fail
|
|
# the whole job, so retry with backoff before giving up.
|
|
synced=0
|
|
for try in 1 2 3 4 5; do
|
|
if pacman -Syu --noconfirm --needed archiso jq; then
|
|
synced=1
|
|
break
|
|
fi
|
|
echo " ⚠️ pacman -Syu failed (attempt $try/5), retrying in $(( try * 15 ))s..." >&2
|
|
sleep $(( try * 15 ))
|
|
done
|
|
if [ "$synced" -ne 1 ]; then
|
|
echo "❌ pacman -Syu failed after 5 attempts — giving up" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Ensure /dev/fd symlinks exist for mkinitcpio and procsub
|
|
run: |
|
|
set -euo pipefail
|
|
# mkinitcpio's preset step pipes through /dev/stdin and bash process
|
|
# substitution (< <(...)) uses /dev/fd; both break if these symlinks
|
|
# are missing (they are normally created once at boot by mkinitcpio's
|
|
# init). Two places need them, so cover both:
|
|
# 1. the executor's own /dev: mkarchiso reads /dev/stdin on the host
|
|
# (machine-id, version, pkglist) and the workflow's own scripts use
|
|
# process substitution here. A no-op if the links already exist.
|
|
ln -sfn /proc/self/fd /dev/fd
|
|
ln -sfn /proc/self/fd/0 /dev/stdin
|
|
ln -sfn /proc/self/fd/1 /dev/stdout
|
|
ln -sfn /proc/self/fd/2 /dev/stderr
|
|
# 2. the chroot's /dev: devtmpfs is single-instance, so mounting it
|
|
# here yields the same tree that pacstrap/arch-chroot expose as
|
|
# <chroot>/dev. When the executor's /dev is itself devtmpfs (1)
|
|
# already fixed that tree; this pass is what makes it work when
|
|
# the executor's /dev is a plain tmpfs or directory instead.
|
|
d="$(mktemp -d)"
|
|
trap 'umount "$d" 2>/dev/null || true; rmdir "$d" 2>/dev/null || true' EXIT
|
|
mount -t devtmpfs devtmpfs "$d"
|
|
ln -sfn /proc/self/fd "$d/fd"
|
|
ln -sfn /proc/self/fd/0 "$d/stdin"
|
|
ln -sfn /proc/self/fd/1 "$d/stdout"
|
|
ln -sfn /proc/self/fd/2 "$d/stderr"
|
|
ls -l "$d/fd" "$d/stdin" "$d/stdout" "$d/stderr"
|
|
umount "$d"
|
|
rmdir "$d"
|
|
trap - EXIT
|
|
|
|
- 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
|
|
rm -rf "$PWD/work"
|
|
# Always start from an empty out dir so stale ISOs are never re-uploaded
|
|
rm -rf "$PWD/out"
|
|
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:-<none>}"
|
|
echo "SOURCE_DATE_EPOCH: ${SOURCE_DATE_EPOCH:-<unset>}"
|
|
|
|
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>.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>.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:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
GITEA_API: http://192.168.1.168:3000/api/v1
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
test -n "$GITEA_TOKEN"
|
|
|
|
# Pre-flight: confirm the internal Gitea API is reachable so failures are clear
|
|
if ! curl -sfS -o /dev/null "$GITEA_API/version"; then
|
|
echo "❌ Cannot reach Gitea API at $GITEA_API" >&2
|
|
exit 1
|
|
fi
|
|
|
|
BASE_DATE=$(date -u +'%Y.%m.%d')
|
|
REVISION=1
|
|
TAG_NAME="iso-${BASE_DATE}-r${REVISION}"
|
|
|
|
# Check if the tag already exists via Gitea API, increment revision loop if it does
|
|
while curl -sf -o /dev/null -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPOSITORY/releases/tags/$TAG_NAME"; do
|
|
REVISION=$((REVISION + 1))
|
|
TAG_NAME="iso-${BASE_DATE}-r${REVISION}"
|
|
done
|
|
|
|
echo "tag_name=${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
|
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
|
|
GITEA_API: http://192.168.1.168:3000/api/v1
|
|
REPOSITORY: ${{ github.repository }}
|
|
TAG_NAME: ${{ steps.tag.outputs.tag_name }}
|
|
RELEASE_NAME: ${{ steps.tag.outputs.release_name }}
|
|
run: |
|
|
set -eu
|
|
shopt -s nullglob
|
|
tmpdir=$(mktemp -d)
|
|
trap 'rm -rf "$tmpdir"' EXIT
|
|
if [ -z "$GITEA_TOKEN" ]; then
|
|
echo "❌ GITEA_TOKEN is empty" >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "$TAG_NAME" ]; then
|
|
echo "❌ TAG_NAME is empty" >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "$RELEASE_NAME" ]; then
|
|
echo "❌ RELEASE_NAME is empty" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Pre-flight: confirm the internal Gitea API is reachable so failures are clear
|
|
if ! curl -sfS -o /dev/null "$GITEA_API/version"; then
|
|
echo "❌ Cannot reach Gitea API at $GITEA_API" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Collect the ISOs produced by the build and record SHA-256
|
|
isos=(out/*.iso)
|
|
if (( ${#isos[@]} == 0 )); then
|
|
echo "❌ No ISO found in out/ — nothing to upload, aborting." >&2
|
|
exit 1
|
|
fi
|
|
|
|
sha256sums=()
|
|
for iso in "${isos[@]}"; do
|
|
fname=$(basename "$iso")
|
|
size=$(stat -c %s "$iso")
|
|
# Sanity check: a real ISO is multi-GB; anything tiny is the wrong file
|
|
if (( size < 100000000 )); then
|
|
echo "❌ '$iso' is only $size bytes — refusing to upload (not an ISO)." >&2
|
|
exit 1
|
|
fi
|
|
sha=$(sha256sum "$iso" | awk '{print $1}')
|
|
if [ -z "$sha" ]; then
|
|
echo "❌ Failed to compute SHA-256 for '$iso'" >&2
|
|
exit 1
|
|
fi
|
|
echo " ✅ $fname: $(( size / 1024 / 1024 )) MiB, sha256=$sha"
|
|
sha256sums+=("$sha")
|
|
done
|
|
|
|
# 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
|
|
|
|
# Create the release (curl result written to a file, checked with if)
|
|
payload=$(jq -nc --arg tag "$TAG_NAME" --arg name "$RELEASE_NAME" --arg body "$body" \
|
|
'{tag_name:$tag,name:$name,body:$body}')
|
|
if ! curl -sf -X POST \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
"$GITEA_API/repos/$REPOSITORY/releases" \
|
|
-d "$payload" -o "$tmpdir/release.json"; then
|
|
echo "❌ Failed to create release $TAG_NAME" >&2
|
|
exit 1
|
|
fi
|
|
if ! RELEASE_ID=$(jq -er '.id' "$tmpdir/release.json"); then
|
|
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
|
|
MAX_ATTEMPTS=3
|
|
for i in "${!isos[@]}"; do
|
|
iso=${isos[$i]}
|
|
fname=$(basename "$iso")
|
|
size=$(stat -c %s "$iso")
|
|
echo "Uploading $fname ($(( size / 1024 / 1024 )) MiB) to release $TAG_NAME..."
|
|
uploaded=0
|
|
attempt=1
|
|
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
|
|
# Fresh output file each attempt: curl does not truncate -o on
|
|
# transport failure, so a stale body must not be misreported.
|
|
rm -f "$tmpdir/upload.json"
|
|
# A previous attempt may have saved the asset but lost the
|
|
# response; Gitea rejects duplicate names, so only after the
|
|
# first attempt check for and remove same-name leftovers
|
|
if [ "$attempt" -gt 1 ]; then
|
|
if curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPOSITORY/releases/$RELEASE_ID/assets" \
|
|
-o "$tmpdir/assets.json"; then
|
|
asset_ids=$(jq -r --arg name "$fname" \
|
|
'.[] | select(.name == $name) | .id' "$tmpdir/assets.json")
|
|
while read -r asset_id; do
|
|
if [ -n "$asset_id" ]; then
|
|
echo " ↻ Removing leftover asset id=$asset_id from a previous attempt"
|
|
if ! curl -sf -X DELETE -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPOSITORY/releases/$RELEASE_ID/assets/$asset_id" \
|
|
-o /dev/null; then
|
|
echo " ⚠️ Could not remove leftover asset id=$asset_id (continuing)" >&2
|
|
fi
|
|
fi
|
|
done <<< "$asset_ids"
|
|
fi
|
|
fi
|
|
|
|
# Capture HTTP status explicitly: `-f` would swallow the Gitea
|
|
# error body (e.g. attachment size-limit messages) and leave the
|
|
# failure reason unknown.
|
|
if http_code=$(curl -sS -X POST --max-time 600 \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-F "attachment=@$iso" \
|
|
"$GITEA_API/repos/$REPOSITORY/releases/$RELEASE_ID/assets" \
|
|
-o "$tmpdir/upload.json" -w '%{http_code}'); then
|
|
if [ "${http_code:0:1}" = "2" ] \
|
|
&& download_url=$(jq -er '.browser_download_url' "$tmpdir/upload.json"); then
|
|
echo " ✅ Uploaded $fname (attempt $attempt/$MAX_ATTEMPTS): $download_url"
|
|
uploaded=1
|
|
break
|
|
fi
|
|
echo " ⚠️ Upload of $fname: HTTP $http_code (attempt $attempt/$MAX_ATTEMPTS)" >&2
|
|
echo " Response: $(head -c 2000 "$tmpdir/upload.json" 2>/dev/null)" >&2
|
|
else
|
|
rc=$?
|
|
echo " ⚠️ Upload of $fname failed (attempt $attempt/$MAX_ATTEMPTS): curl exit $rc, HTTP ${http_code:-000}" >&2
|
|
if [ -s "$tmpdir/upload.json" ]; then
|
|
echo " Response: $(head -c 2000 "$tmpdir/upload.json")" >&2
|
|
fi
|
|
fi
|
|
|
|
if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
|
|
sleep $(( attempt * 10 ))
|
|
fi
|
|
attempt=$(( attempt + 1 ))
|
|
done
|
|
|
|
if [ "$uploaded" -ne 1 ]; then
|
|
echo "❌ Failed to upload $fname after $MAX_ATTEMPTS attempts — aborting." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
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 <reason...>: 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 <reason...>: 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:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
GITEA_API: http://192.168.1.168:3000/api/v1
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
test -n "$GITEA_TOKEN"
|
|
|
|
echo "Pruning old releases in $REPOSITORY..."
|
|
readarray -t ids < <(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPOSITORY/releases" \
|
|
| jq -er 'sort_by(.created_at) | .[].id')
|
|
|
|
to_delete=$(( ${#ids[@]} - 2 ))
|
|
if (( to_delete > 0 )); then
|
|
for ((i = 0; i < to_delete; i++)); do
|
|
id=${ids[$i]}
|
|
echo "Deleting old release ID: $id"
|
|
curl -sf -X DELETE -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPOSITORY/releases/$id"
|
|
done
|
|
fi
|
|
|
|
- name: Cleanup mkarchiso mounts and workdirs
|
|
if: always() # Runs on success, failure, or cancellation
|
|
run: |
|
|
set -eu
|
|
# Steps run as root (pacman is used directly); fall back to sudo if not
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
PRIV=""
|
|
else
|
|
PRIV="sudo"
|
|
fi
|
|
|
|
# Unmount any leftover loop devices or bind mounts in the work directory
|
|
$PRIV umount -R "$PWD/work" 2>/dev/null || true
|
|
|
|
# Clean up loop devices if any remain attached to the work dir
|
|
$PRIV losetup -D || true
|
|
|
|
# Remove working directories with proper permissions
|
|
$PRIV rm -rf "$PWD/work"
|
|
|
|
- name: Remove built ISO
|
|
if: always()
|
|
run: |
|
|
set -eu
|
|
rm -rf "$PWD/out"
|