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 pacman -Syu --noconfirm --needed archiso jq - name: Ensure /dev/stdin exists for mkinitcpio run: | set -euo pipefail # Docker/container environments may lack /dev/stdin; mkinitcpio needs it [ -e /dev/stdin ] || ln -sf /proc/self/fd/0 /dev/stdin [ -e /dev/stdout ] || ln -sf /proc/self/fd/1 /dev/stdout [ -e /dev/stderr ] || ln -sf /proc/self/fd/2 /dev/stderr - 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:-}" 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: 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 # 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 if curl -sf -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"; then if 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 returned no download URL (attempt $attempt/$MAX_ATTEMPTS)" >&2 else echo " ⚠️ Upload of $fname failed (attempt $attempt/$MAX_ATTEMPTS)" >&2 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 : 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: 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"