Files
AlvinArch/.gitea/workflows/build-iso.yaml
T
alvinlollo 1bb6f8dddd
Tests / tests (push) Successful in 4m8s
ci: install archiso via private pacman config, not /etc/pacman.conf
Writing stock core/extra over the host /etc/pacman.conf raced with the
tests workflow, which appends [cachyos]/[chaotic-aur] to the same file —
runs 132 and 133 ran concurrently on the runner, so either job could
corrupt the other's config mid-pacman.

Use 'pacman --config $(mktemp)' with stock core/extra instead: same
DNS-lookup reduction (only core/extra sync, no cachyos/chaotic), zero
writes to shared state. Verified on the runner: exit 0, only core/extra
synced, host /etc/pacman.conf mtime unchanged.
2026-10-07 11:22:12 +11:00

586 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
# Install with a *private* config instead of /etc/pacman.conf.
# The host config is shared mutable state: the tests workflow
# rewrites it and jobs overlap on this runner (runs 132 and 133
# ran concurrently), so writing it here could corrupt a concurrent
# tests run. It also carries [cachyos] and [chaotic-aur] leftovers
# from tests.yaml, and syncing those two repos means extra DNS
# lookups against a gateway that intermittently drops UDP/53 —
# that is what failed run 131 at this very step. The build only
# needs archiso/jq from extra; mkarchiso reads the *profile's*
# pacman.conf for everything that goes into the ISO.
BUILD_CONF=$(mktemp)
trap 'rm -f "$BUILD_CONF"' EXIT
cat > "$BUILD_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/^ //' "$BUILD_CONF"
# 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 --config "$BUILD_CONF" -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"