Skip to content

Build disk images

Build disk images #128

Workflow file for this run

---
# Margine ISO + qcow2 builder.
#
# Pipeline shape (2026-06-02 split, see
# the project's CI split rule):
#
# build_disk (matrix: anaconda-iso, qcow2) ──┐
# │ uploads each artifact
# │ as GHA workflow artifact
# ▼
# build_iso_titanoboa ──► install_gate (stable only; skipped for nvidia)
# │ │
# └────────┬───────────┘ gate green — or skipped (nvidia)
# ▼
# publish_ia (ISO only) ──► bump_site ──┐
# ▼
# notify (if: always())
#
# build_disk does the heavy work — bootc-image-builder (~15-17 min) +
# checksums + upload-artifact. publish_ia downloads the ISO artifact
# and pushes it to Internet Archive (the unstable, slow, multi-GB step
# that for the previous monolithic shape forced a full BIB rerun
# whenever the upload failed). On a failed publish_ia,
# `gh run rerun --failed <run-id>` re-runs only the upload step in
# minutes instead of redoing the whole 15-17 min BIB pipeline.
#
# The qcow2 matrix entry doesn't publish anywhere today — its artifact
# is just retained as a GHA workflow artifact for daniel to grab
# manually when testing in QEMU/virt-manager. publish_ia downloads
# only the anaconda-iso artifact.
#
# Runs on GitHub-hosted ubuntu-24.04. The self-hosted PVE builder
# VM (margine-builder, VM 170) was decommissioned on 2026-06-01
# after two freezes — the second one took the entire PVE host down
# with ZFS spacemap corruption (see proxmox-pve1/docs/operations/
# zfs-spacemap-corruption-recovery.md).
#
# Cadence: manual trigger only (workflow_dispatch). ISOs are heavy
# (~5 GB each), so we generate one only when there's an actual
# reason — typically after a notable upstream change or a Margine
# release event, not on every push.
name: Build disk images
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to build the ISO from: 'stable' = the normal ISO; 'nvidia' = the experimental NVIDIA variant (run build-nvidia.yml first to push :nvidia). NVIDIA ISO publishes as margine-nvidia-iso-<date> and does NOT bump the site's stable date."
default: stable
type: string
# Auto-refresh the experimental NVIDIA ISO whenever the NVIDIA image is
# (re)built (build-nvidia.yml: weekly Sun 05:00 + on demand). On its success
# this rebuilds + publishes the nvidia ISO so testers always get the latest
# Margine. Every decision point below resolves a workflow_run event to
# image_tag 'nvidia' (inputs are EMPTY on workflow_run — a bare
# `inputs.image_tag || 'stable'` would silently build the STABLE ISO and
# bump the site): the qcow2 job and the GPU-less QEMU boot-test are
# skipped, publish goes to margine-nvidia-iso-<date>, the site's stable
# date is NOT bumped.
workflow_run:
workflows: ["Build Margine NVIDIA variant (experimental)"]
types: [completed]
branches: [main]
pull_request:
# `labeled` included so adding the iso-test label triggers a run
# immediately (without it, the label did nothing until the next
# push — unlike build.yml's documented label-then-build flow).
types: [labeled, synchronize, opened, reopened]
branches:
- main
paths:
# NB: GitHub path filters do NOT match a leading './' — the old
# './disk_config/...' globs never fired. Plain repo-relative globs.
- 'disk_config/disk.toml'
- '.github/workflows/build-disk.yml'
- 'live-env/**'
env:
# Titanoboa pin — the official ISO builder since 2026-06-11
# (ADR-0008 Phase 5). Pinned to OUR fork branch margine-pins =
# upstream post-#138 HEAD (5c457c3d) + THREE carried patches:
# 1. mksquashfs -e ordering (upstream PR ublue-os/titanoboa#147)
# — without it the squashfs silently falls back to gzip
# 2. grub2.extra_cfg raw fragment (upstream PR ublue-os/titanoboa#148)
# — the Secure Boot "Enroll key (MokManager)" menu entry
# 3. grub.cfg trailing-slash: write grub.cfg only into EFI
# SUBdirectories (cce73fc, NO upstream PR — fork-permanent).
# Load-bearing for our plain-file EFI/MOK.der placement; without it
# upstream's `for dir in /work/EFI/*` glob hits the MOK.der FILE and
# hard-fails the ISO build with "Not a directory".
# Reverting to an upstream pin is impossible until all three land.
# Renovate disabled.
TITANOBOA_REF: "daniel-g-carrasco/titanoboa@cce73fc476e97fed626283afb6c518e0882a12d7"
IMAGE_REGISTRY: "ghcr.io/${{ github.repository_owner }}"
DEFAULT_TAG: "stable"
BIB_IMAGE: "quay.io/centos-bootc/bootc-image-builder@sha256:7ae88b8d6f2cabfa971d7836b96d6cac19cd1384e658031bd154f9687e929905"
# Originally added for osbuild/bootc-image-builder-action (Node 20,
# deprecated). That action was replaced with a direct podman run on
# 2026-07-08; the opt-in stays as a general guard so any remaining
# Node-20 action breaks while it is still our choice to roll back.
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Least privilege (Scorecard Token-Permissions): nothing at the workflow
# level; every job below declares exactly what it needs.
permissions: {}
concurrency:
# One group per (event, variant): a stable dispatch and a nvidia
# dispatch never collide, and a NEW dispatch of the same variant
# QUEUES behind the running one instead of cancelling a multi-hour
# ISO build mid-publish (cancelled publishes are how half-finished
# IA items happen). Only PR runs cancel their superseded selves.
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('dispatch-{0}', inputs.image_tag || 'stable') || (github.ref || github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build_disk:
name: Build disk (${{ matrix.image }} / ${{ matrix.disk-type }})
# The weekly NVIDIA auto-refresh (workflow_run) only needs the nvidia
# ISO — don't burn 45 runner-minutes rebuilding the stable qcow2.
if: github.event_name != 'workflow_run'
runs-on: ubuntu-24.04
# BIB is ~15-17 min, sha256 + artifact upload ~1-3 min. 45 is a
# comfortable cap for the build half of the pipeline.
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# Margine ships a single base ISO. Users who want the gaming
# variant install the base, then `ujust margine-gaming` from a
# terminal (rpm-ostree install gamescope + vkBasalt, flatpak
# install Steam/Lutris/Heroic/Bottles/ProtonUp-Qt/etc.). That
# decision (2026-06-06) replaces the previous two-flavour ISO
# matrix — fewer 60-min ISO builds, fewer IA uploads, one less
# surface area for branding/asset drift. Gaming OCI variant
# also retired (build-gaming.yml deleted same PR).
image: ["margine"]
# 2026-06-11 (ADR-0008 Phase 5): the BIB anaconda-iso build is
# retired — the Titanoboa live ISO below is the official and
# only ISO. qcow2 (BIB) stays for VM/disk images.
disk-type: ["qcow2"]
permissions:
contents: read
# packages: read only — the installer-image push step this job
# carried write for was retired with the BIB ISO path (ADR-0008
# Phase 7); this job now only pulls. id-token dropped: nothing
# here is OIDC/keyless. Least privilege (review P2.6).
packages: read
steps:
- name: Maximize build space
# ubuntu-24.04 has ~14 GiB free by default; bootc-image-builder
# needs ~30 GiB scratch for qcow2/ISO build + buildah cache.
uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9
with:
remove-codeql: true
- name: Mount container storage on a BTRFS loopback
# Same fix the Titanoboa ISO job below has carried since 2026-06:
# the ~15 GB base image in rootful storage + osbuild's working
# tree (the BIB container's overlay) + the raw->qcow2 conversion
# peak no longer fit the runner's / even after the reclaim step.
# First hit 2026-07-07 (run 28900160440: qemu-img convert died
# ENOSPC mid-qcow2) once the stable image grew past the margin
# that had held all of 2026-07-06. Back rootful podman storage
# with a compressed 80G BTRFS loopback on the ~70 GB ephemeral
# /mnt SSD instead of hoping the margin holds.
run: |
set -euo pipefail
sudo truncate -s 80G /mnt/podman-storage.img
sudo mkfs.btrfs -f /mnt/podman-storage.img
sudo podman system reset --force || true
sudo systemctl stop podman.service podman.socket 2>/dev/null || true
sudo mount -o compress-force=zstd:2 /mnt/podman-storage.img /var/lib/containers/storage
sudo systemctl start podman.socket 2>/dev/null || true
sudo df -h / /var/lib/containers/storage
- name: Prepare environment
run: |
USER_UID=$(id -u)
USER_GID=$(id -g)
DISK_TYPE=$(echo "${{ matrix.disk-type }}" | tr ' ' '-')
# IMAGE_NAME comes from the matrix (single-entry: margine).
IMAGE_NAME="${{ matrix.image }}"
echo "IMAGE_REGISTRY=${IMAGE_REGISTRY,,}" >> ${GITHUB_ENV}
echo "IMAGE_NAME=${IMAGE_NAME,,}" >> ${GITHUB_ENV}
echo "DISK_TYPE=${DISK_TYPE}" >> ${GITHUB_ENV}
echo "USER_UID=${USER_UID}" >> ${GITHUB_ENV}
echo "USER_GID=${USER_GID}" >> ${GITHUB_ENV}
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Pre-build login to GHCR (for base-image pull)
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | timeout 30 podman login ghcr.io \
-u "${{ github.actor }}" --password-stdin
- name: Pre-pull bootc-image-builder (resilient to quay.io 5xx)
# quay.io occasionally returns 5xx for minutes at a time
# (observed 2026-06-01: a Cloudflare/quay outage that the
# downstream action surfaced only as a single failed pull).
# Retry explicitly with exponential backoff so the image is
# in the local store by the time the action runs; the action
# then finds it cached and proceeds.
run: |
set -uo pipefail
for attempt in 1 2 3 4 5 6 7 8; do
if sudo podman pull "${{ env.BIB_IMAGE }}"; then
echo "bootc-image-builder pulled on attempt $attempt"
exit 0
fi
backoff=$((attempt * 30))
echo "pull attempt $attempt failed; sleeping ${backoff}s before retry"
sleep $backoff
done
echo "::error::Unable to pull ${{ env.BIB_IMAGE }} after 8 attempts; quay.io may be down"
exit 1
# (The Bazzite installer-image pattern that lived here served the
# retired BIB anaconda-iso path — ADR-0008 Phase 7. qcow2 builds
# straight from the base image.)
- name: Build disk image
id: build
# Direct `podman run` of bootc-image-builder, replacing
# osbuild/bootc-image-builder-action (2026-07-08). Two reasons:
# 1. The action's first move is `sudo rm -rf
# /var/lib/containers/storage`, which is incompatible with the
# BTRFS-loopback-backed storage mounted above (EBUSY on the
# mountpoint failed the step even though the build itself ran
# on: run 28902818755) AND silently wiped the resilient
# pre-pull of BIB_IMAGE two steps up, re-pulling from quay
# with no retry logic — tonight quay flaked exactly there.
# 2. The action is pinned to v0.0.2 (last release), declares
# Node 20 (deprecated) and only ever ran this same podman
# command anyway. smoke-boot.yml has always invoked BIB
# directly; this mirrors it.
# The command below is byte-for-byte what the action ran (see any
# pre-2026-07-08 green run), with the same output contract:
# artifacts under ./output, `output-directory` step output.
run: |
set -euo pipefail
mkdir -p output
# The action also pulled the TARGET image into root's storage
# before invoking BIB; BIB inspects it in LOCAL storage and
# errors "image not known" otherwise (run 28905057914). The
# image is public, so no auth needed for root's podman.
for attempt in 1 2 3; do
sudo podman pull "${IMAGE_REGISTRY}/${IMAGE_NAME}:${DEFAULT_TAG}" && break
[ "$attempt" = 3 ] && { echo "::error::cannot pull ${IMAGE_REGISTRY}/${IMAGE_NAME}:${DEFAULT_TAG}"; exit 1; }
sleep $((attempt * 30))
done
# --rootfs btrfs: Margine's :stable inherits from Bluefin DX,
# which doesn't set the `containers.bootc.rootfs` OCI label;
# without an explicit rootfs bootc-image-builder errors out
# with "DefaultRootFs missing". Bluefin's own ISO uses btrfs
# and so does Margine's Anaconda installer config.
sudo podman run --rm --privileged \
--security-opt label=type:unconfined_t \
--volume /var/lib/containers/storage:/var/lib/containers/storage \
--volume "$(pwd)/output:/output" \
--volume "$(pwd)/disk_config/disk.toml:/config.toml:ro" \
"${BIB_IMAGE}" \
build --output /output \
--chown "${USER_UID}:${USER_GID}" \
--rootfs btrfs \
--use-librepo=True \
--type "${DISK_TYPE}" \
"${IMAGE_REGISTRY}/${IMAGE_NAME}:${DEFAULT_TAG}"
echo "output-directory=$(pwd)/output" >> "$GITHUB_OUTPUT"
- name: List + sha256 outputs
# Step id was `outputs` previously — that collides with GHA's
# reserved `outputs` expression context, and `${{ steps.outputs.outdir }}`
# evaluated to empty downstream (see run #26782508735, ia upload step
# received OUTDIR=""). Renamed to `disk` to avoid the shadow.
id: disk
run: |
set -euo pipefail
OUTDIR="${{ steps.build.outputs.output-directory }}"
if [[ -z "$OUTDIR" || ! -d "$OUTDIR" ]]; then
echo "::error::bootc-image-builder did not produce an output directory ($OUTDIR)"
exit 1
fi
echo "outdir=$OUTDIR" >> "$GITHUB_OUTPUT"
ls -la "$OUTDIR"
# bootc-image-builder puts artifacts under subdirs
# (bootiso/install.iso, qcow2/disk.qcow2). Walk recursively
# for sha256 and capture artifact paths so downstream steps
# have the actual file location, not a glob.
#
# CRITICAL: write SHA256SUMS with paths RELATIVE TO $OUTDIR
# (e.g. "bootiso/install.iso", not "./output/bootiso/install.iso").
# Run #26789024483's publish_ia failed verification because the
# download-artifact unpacks the dir at ./disk, not ./output —
# absolute or build-side-relative paths in SHA256SUMS become
# unreadable downstream. Relative paths verify correctly from
# any CWD that contains the same internal directory layout.
ARTIFACT_REL=""
(cd "$OUTDIR" && find . -type f \( -name '*.iso' -o -name '*.qcow2' \) -print | sort) > /tmp/disk-files.txt
( cd "$OUTDIR" && while read -r rel; do
rel="${rel#./}"
sha256sum "$rel"
done < /tmp/disk-files.txt
) > "$OUTDIR/SHA256SUMS"
cat "$OUTDIR/SHA256SUMS"
ARTIFACT_REL="$(head -1 /tmp/disk-files.txt | sed 's|^\./||')"
if [[ -z "$ARTIFACT_REL" ]]; then
echo "::error::no .iso or .qcow2 produced under $OUTDIR"
exit 1
fi
ARTIFACT="$OUTDIR/$ARTIFACT_REL"
SIZE_BYTES="$(stat -c %s "$ARTIFACT")"
echo "artifact=$ARTIFACT" >> "$GITHUB_OUTPUT"
echo "artifact_basename=$(basename "$ARTIFACT")" >> "$GITHUB_OUTPUT"
echo "size_bytes=$SIZE_BYTES" >> "$GITHUB_OUTPUT"
echo "Artifact: $ARTIFACT ($((SIZE_BYTES / 1024 / 1024)) MB / $SIZE_BYTES bytes)"
- name: Upload disk artifact + SHA256SUMS
# Pass the disk image to publish_ia via GHA workflow artifacts.
# ~3-5 GB transfer takes ~30-90 sec each direction on GHA's
# internal CAS — the net cost vs the old monolithic shape is
# ~2-3 min, vs ~15-17 min saved on every IA-upload failure.
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.image }}-disk-${{ matrix.disk-type }}
# Pack the *whole* output dir (artifact + SHA256SUMS); the
# publish_ia step needs both.
path: ${{ steps.disk.outputs.outdir }}
# ISOs are timestamped daily; one day of retention is plenty
# for the publish_ia handoff. Bump to 7 if you want to retry
# publish manually a few days later.
retention-days: 1
if-no-files-found: error
compression-level: 0 # ISOs/qcow2s are not further compressible
build_iso_titanoboa:
name: Build Live ISO (Titanoboa)
runs-on: ubuntu-24.04
# squashfs at zstd-19 over a ~14 GB rootfs dominates wall-clock.
timeout-minutes: 90
# Default ISO path since 2026-06-11 (ADR-0008 Phase 5): installation
# validated end-to-end in a VM (live GNOME session + Anaconda WebUI).
# On PRs it still only runs with the iso-test label (the build is
# ~40 min); on dispatch it always runs and publish_ia picks it up.
# On workflow_run (weekly NVIDIA auto-refresh) it runs only when the
# upstream build-nvidia run actually succeeded.
if: >-
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'iso-test'))
|| (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
permissions:
contents: read
# write: pushes the run-scoped margine-live tag + prunes old ones.
# id-token dropped — nothing OIDC/keyless here (review P2.6).
packages: write
steps:
- name: Maximize build space
uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9
with:
remove-codeql: true
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare environment
run: |
# ISO_TAG selects which published image the live ISO is built from.
# Defaults to 'stable'; 'nvidia' builds the experimental NVIDIA
# variant. inputs are EMPTY on workflow_run (the weekly NVIDIA
# auto-refresh), which must resolve to 'nvidia', never 'stable'.
ISO_TAG="${{ github.event_name == 'workflow_run' && 'nvidia' || inputs.image_tag || 'stable' }}"
echo "IMAGE_REGISTRY=${IMAGE_REGISTRY,,}" >> "${GITHUB_ENV}"
echo "ISO_TAG=${ISO_TAG}" >> "${GITHUB_ENV}"
echo "LIVE_TAG=${IMAGE_REGISTRY,,}/margine-live:ci-run-${{ github.run_id }}" >> "${GITHUB_ENV}"
- name: Mount container storage on a BTRFS loopback
# Titanoboa's squashfs (zstd-19) of a ~14 GB rootfs + the ~14 GB
# base image in storage + the output ISO blow past ubuntu-24.04's
# ~14 GB free on /. The ephemeral /mnt SSD has ~70 GB; back
# rootful podman storage with a compressed BTRFS loopback there.
# Mirrors Bazzite's Titanoboa workflow.
run: |
set -euo pipefail
sudo truncate -s 80G /mnt/podman-storage.img
sudo mkfs.btrfs -f /mnt/podman-storage.img
sudo podman system reset --force || true
sudo systemctl stop podman.service podman.socket 2>/dev/null || true
sudo mount -o compress-force=zstd:2 /mnt/podman-storage.img /var/lib/containers/storage
sudo systemctl start podman.socket 2>/dev/null || true
- name: Login to GHCR
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | sudo podman login ghcr.io \
-u "${{ github.actor }}" --password-stdin
- name: Pull base image (margine:${{ env.ISO_TAG }})
run: |
sudo podman pull "${{ env.IMAGE_REGISTRY }}/margine:${{ env.ISO_TAG }}"
- name: Build margine-live image
id: live
run: |
set -euo pipefail
BASE_IMAGE="${{ env.IMAGE_REGISTRY }}/margine:${{ env.ISO_TAG }}"
echo "Building ${LIVE_TAG} from ${BASE_IMAGE}"
# --cap-add sys_admin + label=disable: build.sh regenerates the
# initramfs (dracut) and, from Phase 2 on, installs Flatpaks via
# bwrap which needs user namespaces. (Flags inherited from the
# retired BIB installer-image build.)
sudo podman build \
--cap-add sys_admin \
--security-opt label=disable \
--build-arg "BASE_IMAGE=${BASE_IMAGE}" \
-t "${LIVE_TAG}" \
-f live-env/Containerfile \
live-env/
# Push the run-scoped tag so Titanoboa can pull it. Titanoboa
# issue #141 hardcodes `podman pull` of image-ref, so a local
# tag isn't enough — the image must be on a registry.
sudo podman push "${LIVE_TAG}"
echo "live_tag=${LIVE_TAG}" >> "$GITHUB_OUTPUT"
- name: Build Live ISO (Titanoboa)
id: titanoboa
# Pinned to the margine-pins fork branch (see env.TITANOBOA_REF
# comment for the exact carried patches + revert condition).
uses: daniel-g-carrasco/titanoboa@cce73fc476e97fed626283afb6c518e0882a12d7
with:
image-ref: ${{ steps.live.outputs.live_tag }}
iso-dest: ${{ github.workspace }}/margine-live.iso
- name: ISO info + sha256
id: iso
run: |
set -euo pipefail
ISO="${{ steps.titanoboa.outputs.iso-dest }}"
if [[ -z "$ISO" || ! -f "$ISO" ]]; then
echo "::error::Titanoboa did not produce an ISO ($ISO)"
exit 1
fi
OUTDIR="${{ github.workspace }}/titanoboa-out"
mkdir -p "$OUTDIR"
mv "$ISO" "$OUTDIR/"
( cd "$OUTDIR" && sha256sum ./*.iso | tee SHA256SUMS )
SIZE_BYTES="$(stat -c %s "$OUTDIR"/*.iso)"
echo "outdir=$OUTDIR" >> "$GITHUB_OUTPUT"
echo "size_mb=$((SIZE_BYTES / 1024 / 1024))" >> "$GITHUB_OUTPUT"
echo "Live ISO: $((SIZE_BYTES / 1024 / 1024)) MB"
- name: Assert squashfs is zstd (guards the mksquashfs -e patch)
# The mksquashfs -e ordering patch on our titanoboa fork keeps the
# squashfs at zstd-19; an accidental revert to an upstream pin
# would silently produce a gzip squashfs (slow boot, bigger ISO)
# that the boot-test below would NOT catch. Assert it explicitly.
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq squashfs-tools >/dev/null
ISO="$(ls "${{ steps.iso.outputs.outdir }}"/*.iso | head -1)"
MNT="$(mktemp -d)"
sudo mount -o loop,ro "$ISO" "$MNT"
SQ="$(sudo find "$MNT" -name 'squashfs.img' | head -1)"
if [[ -z "$SQ" ]]; then sudo umount "$MNT"; echo "::error::no squashfs.img in ISO"; exit 1; fi
COMP="$(sudo unsquashfs -s "$SQ" | grep -i 'Compression' || true)"
echo "squashfs: $COMP"
sudo umount "$MNT"
echo "$COMP" | grep -qi 'zstd' \
|| { echo "::error::squashfs is NOT zstd (mksquashfs -e patch regressed?)"; exit 1; }
# Gate (2026-06-12, review item #9): boot the freshly built ISO
# in UEFI QEMU and require the live system to reach a usable
# state. Catches Titanoboa/dracut/livesys regressions that static
# checks can't see. ~3-6 min on KVM runners; generous 15 min cap.
- name: Boot-test the ISO in QEMU (UEFI)
# The NVIDIA variant can't be QEMU-boot-tested (no GPU → the nvidia
# driver black-screens / never reaches a graphical target), and ADR 0009
# marks it experimental, validated on real hardware. Skipping the gate
# for it lets the ISO publish for bare-metal testing instead of the job
# timing out in the boot wait (run 27753036929). Stable keeps the gate.
# workflow_run == the weekly NVIDIA auto-refresh (inputs empty there,
# so the tag check alone would wrongly boot-test it as 'stable').
if: github.event_name != 'workflow_run' && (inputs.image_tag == '' || inputs.image_tag == 'stable')
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq qemu-system-x86 ovmf xorriso >/dev/null
ISO="$(ls "${{ steps.iso.outputs.outdir }}"/*.iso | head -1)"
echo "Boot-testing: $ISO"
# The shipped ISO no longer bakes console=ttyS0 (it stalls
# phantom-UART mini-PCs like the Beelink SER8 — see
# live-env/src/iso.yaml). To keep serial observability in CI,
# extract the ISO's kernel+initrd and direct-boot them with
# console=ttyS0 + systemd.show_status=1 injected via --append;
# the -cdrom still serves the live squashfs (root=live:CDLABEL).
# Extract with xorriso (NOT a sudo loop-mount): the mount left the
# files root-owned and the non-sudo cp hit EACCES (run 28220889854).
xorriso -osirrox on -indev "$ISO" \
-extract /images/pxeboot/vmlinuz ./ci-vmlinuz \
-extract /images/pxeboot/initrd.img ./ci-initrd.img
chmod a+r ./ci-vmlinuz ./ci-initrd.img
APPEND="quiet rhgb console=tty0 console=ttyS0,115200n8 systemd.show_status=1 root=live:CDLABEL=Margine-Live enforcing=0 rd.live.image rd.live.overlay.size=4096"
# Shared watcher (same script gates the qcow2 in smoke-boot).
# Live-healthy markers: GDM session (live autologin) or livesys
# completing; dracut emergency = instant fail.
sudo -E .github/scripts/qemu-boot-wait.sh \
--cdrom "$ISO" \
--kernel ./ci-vmlinuz --initrd ./ci-initrd.img --append "$APPEND" \
--log iso-serial.log \
--timeout 900 \
--ok-regex 'Started.*gdm\.service|Reached target [Gg]raphical|livesys.*Finished|[Mm]argine login:' \
--fail-regex 'dracut-emergency|Failed to start.*dmsquash|emergency shell'
# Constrained-RAM pass (2 GiB), WARN-ONLY: surfaces a live-overlay
# Overflow / read-only remount on low usable RAM (large iGPU UMA
# carveouts) without blocking the ship. Flip to gating once proven.
echo "=== constrained-RAM (2 GiB) boot pass (warn-only) ==="
sudo -E .github/scripts/qemu-boot-wait.sh \
--cdrom "$ISO" \
--kernel ./ci-vmlinuz --initrd ./ci-initrd.img --append "$APPEND" \
--mem 2048 \
--log iso-serial-2g.log \
--timeout 900 \
--ok-regex 'Started.*gdm\.service|Reached target [Gg]raphical|livesys.*Finished|[Mm]argine login:' \
--fail-regex 'Overflow|Remounting.*read-only|dracut-emergency|emergency shell' \
|| echo "::warning::constrained-RAM (2 GiB) boot did not reach a usable state — see iso-serial-2g.log (live-overlay overflow on low usable RAM?)"
- name: Upload ISO serial log
# Always — a failed boot-test is debugged from this log (the
# qcow2 gate has had the same artifact from day one).
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: iso-boot-serial-log
path: iso-serial*.log
retention-days: 7
if-no-files-found: ignore
- name: Upload Live ISO artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: margine-live-iso
path: ${{ steps.iso.outputs.outdir }}
retention-days: 1
if-no-files-found: error
compression-level: 0
- name: Prune stale margine-live GHCR tags (keep newest 3)
if: always()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
PKG="margine-live"
OWNER="${{ github.repository_owner }}"
echo "Pruning $OWNER/$PKG GHCR tags, keeping newest 3"
# --slurp: --paginate emits ONE JSON ARRAY PER PAGE, so the
# downstream `jq length` would count only the first page past
# 100 versions. Slurp flattens all pages into a single array.
VERSIONS_JSON="$(gh api --paginate --slurp \
"/users/${OWNER}/packages/container/${PKG}/versions" \
--jq '[.[][] | {id, name: (.metadata.container.tags[0] // "<untagged>"), updated_at}]' \
2>/dev/null || echo '[]')"
COUNT=$(echo "$VERSIONS_JSON" | jq 'length')
echo "Found $COUNT version(s) total"
if (( COUNT <= 3 )); then echo "Nothing to prune."; exit 0; fi
TO_DELETE=$(echo "$VERSIONS_JSON" | jq -r '.[3:] | .[].id')
PRUNED=0
for vid in $TO_DELETE; do
if gh api --method DELETE \
"/users/${OWNER}/packages/container/${PKG}/versions/${vid}" \
>/dev/null 2>&1; then
PRUNED=$((PRUNED+1))
fi
done
echo "Pruned $PRUNED stale version(s)."
install_gate:
name: Automated install gate (Flatpak bake)
# Reproduces a real fresh ISO install + verifies /var/lib/flatpak — the
# path that broke on the Beelink SER8 (forum 12247) and that no other CI
# job exercises (smoke-boot uses a qcow2 from the OCI image; the boot-test
# only boots the live session). Runs whenever the live ISO is built for
# stable. BLOCKS publish_ia since 2026-07-03 (originally surface-only —
# "tighten to blocking once proven" — and it proved itself on the #232
# flatpak-bake fix and every stable run since): a stable ISO that fails a
# real install must reach neither Internet Archive nor the site date bump.
needs: build_iso_titanoboa
if: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'iso-test'))) && (inputs.image_tag == '' || inputs.image_tag == 'stable') }}
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: read
steps:
- name: Maximize build space
uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9
with:
remove-codeql: true
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download Live ISO artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: margine-live-iso
path: ./iso
- name: Install QEMU + tools
run: |
sudo apt-get update -qq
# qemu-utils=qemu-nbd, parted+util-linux=nbd partition exposure,
# xorriso=ISO extract (no sudo mount), attr=getfattr (SELinux check).
sudo apt-get install -y -qq qemu-system-x86 qemu-utils ovmf xorriso parted util-linux attr
- name: Extract kernel + initrd from the ISO
run: |
set -euo pipefail
ISO="$(ls ./iso/*.iso | head -1)"
echo "ISO=$ISO"
xorriso -osirrox on -indev "$ISO" \
-extract /images/pxeboot/vmlinuz ./ci-vmlinuz \
-extract /images/pxeboot/initrd.img ./ci-initrd.img
chmod a+r ./ci-vmlinuz ./ci-initrd.img
- name: Phase 1 — headless automated install
run: |
set -euo pipefail
ISO="$(ls ./iso/*.iso | head -1)"
qemu-img create -f qcow2 target.qcow2 20G
sudo -E .github/scripts/qemu-install-wait.sh \
--cdrom "$ISO" --disk target.qcow2 \
--kernel ./ci-vmlinuz --initrd ./ci-initrd.img \
--log install-serial.log --timeout 2400
- name: Phase 2 — offline-verify the installed disk (qemu-nbd)
run: sudo -E .github/scripts/verify-install-disk.sh target.qcow2
- name: Upload install-gate logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: install-gate-logs
path: install-serial.log
retention-days: 7
if-no-files-found: ignore
publish_ia:
name: Publish ISO (${{ matrix.image }}) to Internet Archive
# 2026-06-11: publishes the Titanoboa live ISO (ADR-0008 Phase 5).
# 2026-07-03: gated on install_gate for stable — an ISO that fails the
# real-install Flatpak gate must not reach the public mirror (nor bump the
# site's ISO date downstream). install_gate doesn't run for nvidia, so its
# 'skipped' result still publishes; `always()` is required because a plain
# needs would ALSO skip publish on a skipped gate.
needs: [build_iso_titanoboa, install_gate]
if: >-
always() && github.event_name != 'pull_request'
&& needs.build_iso_titanoboa.result == 'success'
&& (needs.install_gate.result == 'success' || needs.install_gate.result == 'skipped')
runs-on: ubuntu-24.04
# 350 = IA upload headroom for a SLOW-BUT-MOVING ingest (2026-06-09:
# a ~9 GB ISO legitimately took >3 h). A STALLED transfer no longer
# burns this budget: ia-upload-iso.sh aborts an attempt when the
# rate sits under 10 KB/s for 5 min and retries on a fresh
# connection (run 28758416337 sat 5h50m on a dead socket before
# this). If the job still times out, IA ingest is degraded across
# attempts — re-run just this job (the build_disk split exists
# precisely for that; the verify short-circuit makes the re-run
# skip anything already ingested).
timeout-minutes: 350
strategy:
fail-fast: false
matrix:
# Only the base Margine ISO is published — gaming variant was
# retired 2026-06-06 in favour of `ujust margine-gaming`.
image: ["margine"]
permissions:
contents: read
outputs:
identifier: ${{ steps.ia_upload.outputs.identifier }}
iso_file: ${{ steps.disk.outputs.artifact_basename }}
steps:
- name: Checkout repo (for the hardened upload script)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- name: Download ISO artifact (${{ matrix.image }})
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: margine-live-iso
path: ./disk
- name: Locate ISO + verify integrity
id: disk
run: |
set -euo pipefail
OUTDIR="$(pwd)/disk"
ARTIFACT="$(find "$OUTDIR" -type f -name '*.iso' | head -1)"
if [[ -z "$ARTIFACT" || ! -f "$ARTIFACT" ]]; then
echo "::error::no .iso found in downloaded artifact at $OUTDIR"
ls -la "$OUTDIR" || true
exit 1
fi
# Verify the downloaded artifact against the BUILD-side SHA256SUMS
# first (its paths are relative to OUTDIR, e.g. bootiso/<name>) —
# before renaming anything.
(cd "$OUTDIR" && sha256sum -c SHA256SUMS)
# Version the published filename so the download itself says which
# build it is: margine-<date>.iso (stable) / margine-<variant>-<date>.iso
# — instead of the generic margine-live.iso. Same UTC day as the IA
# identifier (both derived in this job).
DATE_TAG="$(date -u +%Y%m%d)"
# workflow_run = weekly NVIDIA auto-refresh (inputs empty there).
ISO_TAG="${{ github.event_name == 'workflow_run' && 'nvidia' || inputs.image_tag || 'stable' }}"
VFILE="margine"
[[ "$ISO_TAG" != "stable" ]] && VFILE="margine-${ISO_TAG}"
NEWBASE="${VFILE}-${DATE_TAG}.iso"
# Weekly auto-refresh replaces ONE fixed file in ONE fixed IA item
# (see the identifier step below) instead of minting a dated item
# per week: 52 multi-GB items/year with only a MANUAL prune
# workflow would just pile up. --no-backup makes the re-upload a
# clean replace, and testers get one stable download URL.
[[ "${{ github.event_name }}" == "workflow_run" ]] && NEWBASE="margine-nvidia-latest.iso"
if [[ "$(basename "$ARTIFACT")" != "$NEWBASE" ]]; then
mv -- "$ARTIFACT" "$(dirname "$ARTIFACT")/$NEWBASE"
ARTIFACT="$(dirname "$ARTIFACT")/$NEWBASE"
fi
BASE="$(basename "$ARTIFACT")"
SIZE_BYTES="$(stat -c %s "$ARTIFACT")"
echo "outdir=$OUTDIR" >> "$GITHUB_OUTPUT"
echo "artifact=$ARTIFACT" >> "$GITHUB_OUTPUT"
echo "artifact_basename=$BASE" >> "$GITHUB_OUTPUT"
echo "date_tag=$DATE_TAG" >> "$GITHUB_OUTPUT"
echo "Will upload $BASE — $((SIZE_BYTES / 1024 / 1024)) MB ($SIZE_BYTES bytes)"
# Regenerate SHA256SUMS for the PUBLISHED (flat, renamed) layout IA
# serves: archive.org/download/<id>/<BASE>. End users download the ISO
# + SHA256SUMS as siblings and run `sha256sum -c SHA256SUMS`.
( cd "$(dirname "$ARTIFACT")" && sha256sum "$BASE" ) > "$OUTDIR/SHA256SUMS"
echo "SHA256SUMS rewritten for IA layout:"
cat "$OUTDIR/SHA256SUMS"
- name: Install Internet Archive CLI (ia)
run: |
set -euo pipefail
if ! command -v ia >/dev/null; then
# Pin: this job exports the IA S3 keys, so a trojaned
# `internetarchive` release must not be auto-pulled. 5.9.0 is
# the version this pipeline's CLI-surface notes were verified
# against.
python3 -m pip install --user --quiet internetarchive==5.9.0
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
fi
~/.local/bin/ia --version || ia --version
- name: Create IA item (metadata + SHA256SUMS)
# IA auto-generates a BitTorrent .torrent + magnet + 3 HTTP
# mirrors for everything we upload, and seeds it forever. This
# is the same pattern Bluefin/Bazzite use to avoid Cloudflare
# TOS (no large binary content served from origin) and to keep
# our home-server upload bandwidth free.
#
# SPLIT (2026-07-06): this step moves only the TINY pieces —
# item creation, metadata, SHA256SUMS — through the ia CLI,
# whose --retries handles S3 503s fine at this size and whose
# --metadata escaping is battle-tested. The multi-GB ISO goes
# through ia-upload-iso.sh in the NEXT step: ia's --retries
# only fires on error RESPONSES, so a hung connection blocked
# a 5.5 GB upload for 5h50m (run 28758416337) with no retry.
#
# ia 5.x CLI surface (verified against the installed pip
# internetarchive==5.9.0, run #26789968571):
# - `--verbose` does NOT exist as an `ia upload` subcommand
# flag (run #26787945599 failed: "unrecognized arguments:
# --verbose").
# - `-l` is a *flag* (no value) at the top level that means
# "also write logs to a file". Passing `-l info` (run
# #26789968571) makes argparse interpret 'info' as the
# positional command and reject:
# "invalid choice: 'info' (choose from 'upload', …)".
# - `-d` / `--debug` at the top level enables verbose stderr
# logging — that's the actual knob for "show progress".
# --checksum: a re-run (or same-day re-publish) skips files IA
# already has with the same md5 instead of clobbering.
env:
IA_S3_ACCESS: ${{ secrets.IA_ACCESS_KEY }}
IA_S3_SECRET: ${{ secrets.IA_SECRET_KEY }}
run: |
set -euo pipefail
OUTDIR="${{ steps.disk.outputs.outdir }}"
# Reuse the SAME date the ISO was renamed with (the IA upload between
# can be long enough to cross UTC midnight — review blocker).
DATE_TAG="${{ steps.disk.outputs.date_tag }}"
# Variant-aware identifier: stable -> margine-live-iso-<date> (unchanged);
# nvidia dispatch -> margine-nvidia-iso-<date>, titled "Margine NVIDIA".
# workflow_run (weekly NVIDIA auto-refresh; inputs empty there) -> ONE
# fixed item, margine-nvidia-iso-weekly, whose margine-nvidia-latest.iso
# is replaced in place each week (--no-backup): a stable URL for
# testers and no pile of dated multi-GB items on IA.
ISO_TAG="${{ github.event_name == 'workflow_run' && 'nvidia' || inputs.image_tag || 'stable' }}"
if [[ "$ISO_TAG" == "stable" ]]; then VSLUG="live"; VLABEL=""; else VSLUG="$ISO_TAG"; VLABEL=" ${ISO_TAG^^}"; fi
IDENTIFIER="${{ matrix.image }}-${VSLUG}-iso-${DATE_TAG}"
TITLE="Margine OS${VLABEL} Linux ISO ${DATE_TAG}"
DESCRIPTION="Margine OS${VLABEL} live ISO (Titanoboa): bootable GNOME live session with the Anaconda installer. Bluefin DX + CachyOS signed kernel + Margine deltas. Built on ${DATE_TAG} from commit ${{ github.sha }}. Website: https://margine.dev. Source: https://github.com/${{ github.repository }}"
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
IDENTIFIER="${{ matrix.image }}-nvidia-iso-weekly"
TITLE="Margine OS NVIDIA Linux ISO (weekly auto-refresh)"
DESCRIPTION="Margine OS NVIDIA live ISO (Titanoboa), experimental: refreshed automatically every week; margine-nvidia-latest.iso is replaced in place, check SHA256SUMS for the current build. Bootable GNOME live session with the Anaconda installer. Bluefin DX + CachyOS signed kernel + NVIDIA driver + Margine deltas. Website: https://margine.dev. Source: https://github.com/${{ github.repository }}"
fi
SUBJECT="linux;fedora;bootc;bluefin;cachyos;margine"
export IA_CONFIG_FILE=/tmp/ia.ini
cat > "$IA_CONFIG_FILE" <<EOF
[s3]
access = ${IA_S3_ACCESS}
secret = ${IA_S3_SECRET}
EOF
chmod 0600 "$IA_CONFIG_FILE"
# --no-backup: when an upload CLOBBERS an existing file (e.g. a
# same-day re-publish of a fixed ISO reuses identifier
# margine-live-iso-<date> AND filename margine-<date>.iso), IA
# would otherwise move the old copy to history/files/<name>.~N~
# and KEEP it. For a multi-GB ISO that silently DOUBLES the item
# size and bloats the auto-generated torrent (observed 2026-06-20:
# a re-published ISO left an 8.5 GB stale copy in history/, torrent
# ~17.9 GB). Our ISOs are reproducible from a tagged commit, so we
# don't need IA's clobber-history — replace cleanly. Prune existing
# history with the ia-prune-history.yml workflow.
ia --config-file "$IA_CONFIG_FILE" --debug upload "$IDENTIFIER" \
"$OUTDIR/SHA256SUMS" \
--no-backup \
--checksum \
--retries 5 \
--sleep 60 \
--metadata="mediatype:software" \
--metadata="collection:opensource" \
--metadata="title:${TITLE}" \
--metadata="description:${DESCRIPTION}" \
--metadata="subject:${SUBJECT}" \
--metadata="creator:daniel-g-carrasco" \
--metadata="licenseurl:https://www.apache.org/licenses/LICENSE-2.0"
echo "identifier=$IDENTIFIER" >> "$GITHUB_OUTPUT"
id: ia_upload
- name: Upload ISO (stall-aware, verify short-circuit)
# The fragile transfer, isolated: curl PUT with a real stall
# detector (<10 KB/s for 5 min aborts the attempt), bounded
# retries on fresh connections, and a HEAD size+md5 verify that
# makes retries and re-runs skip already-ingested files. See
# .github/scripts/ia-upload-iso.sh for the full failure-mode
# rationale; tested against a mock S3 (stall, happy, preloaded).
env:
IA_S3_ACCESS: ${{ secrets.IA_ACCESS_KEY }}
IA_S3_SECRET: ${{ secrets.IA_SECRET_KEY }}
IDENTIFIER: ${{ steps.ia_upload.outputs.identifier }}
run: |
set -euo pipefail
.github/scripts/ia-upload-iso.sh "$IDENTIFIER" "${{ steps.disk.outputs.artifact }}"
echo "Uploaded as https://archive.org/details/$IDENTIFIER"
- name: Wait for IA to derive (gen torrent + mirrors)
timeout-minutes: 25
env:
IDENTIFIER: ${{ steps.ia_upload.outputs.identifier }}
run: |
set -euo pipefail
# IA needs a few minutes to derive a .torrent + magnet for
# the upload. Poll until the *_archive.torrent file shows up.
for i in $(seq 1 60); do
sleep 20
if ia --config-file /tmp/ia.ini list "$IDENTIFIER" 2>/dev/null | grep -q '_archive\.torrent$'; then
echo "Torrent ready after ~$((i*20))s"
ia --config-file /tmp/ia.ini list "$IDENTIFIER"
exit 0
fi
echo " (poll $i) torrent not yet derived, retrying…"
done
echo "::warning::IA torrent not derived within 20 min — index will use HTTP mirrors only"
- name: Generate index.html for the IA upload page
env:
IDENTIFIER: ${{ steps.ia_upload.outputs.identifier }}
run: |
set -euo pipefail
OUTDIR="${{ steps.disk.outputs.outdir }}"
DATE_TAG="${{ steps.disk.outputs.date_tag }}"
ISO_NAME="${{ steps.disk.outputs.artifact_basename }}"
HEADER="Margine OS ${DATE_TAG}"
SUBHEAD="Margine OS: Bluefin DX + CachyOS signed kernel."
TORRENT_URL="https://archive.org/download/${IDENTIFIER}/${IDENTIFIER}_archive.torrent"
MIRROR_HTTP="https://archive.org/download/${IDENTIFIER}/${ISO_NAME}"
cat > "$OUTDIR/index.html" <<HTML
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<title>${HEADER} — downloads</title>
<style>
body { font: 15px/1.5 ui-sans-serif, system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; color: #1a1a1a; }
h1 { font-size: 22px; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.pkg { background: #fafafa; border: 1px solid #ddd; border-radius: 8px; padding: 16px 20px; margin-top: 12px; }
a { color: #1e5fb6; }
footer { margin-top: 32px; color: #888; font-size: 13px; }
</style>
</head><body>
<h1>${HEADER}</h1>
<p>${SUBHEAD}
Built from commit <code>${{ github.sha }}</code>.</p>
<div class="pkg">
<strong>Live installer ISO</strong> · <code>${ISO_NAME}</code><br>
<p>
⤓ <a href="${TORRENT_URL}">Download .torrent</a> (recommended)<br>
⤓ <a href="${MIRROR_HTTP}">Direct HTTP download</a> (IA mirror)<br>
🗂 <a href="https://archive.org/details/${IDENTIFIER}">Internet Archive page</a> (all mirrors)
</p>
</div>
<p>Verify hashes: <a href="SHA256SUMS">SHA256SUMS</a></p>
<footer>
Published $(date -u +%Y-%m-%dT%H:%MZ).
ISOs are hosted by the <a href="https://archive.org">Internet Archive</a>;
this page only serves torrent/HTML links.
Source: <a href="https://github.com/${{ github.repository }}">${{ github.repository }}</a>.
</footer>
</body></html>
HTML
- name: Upload index.html + SHA256SUMS as workflow artifact
# The big binary lives authoritatively on Internet Archive
# (uploaded above). The lightweight pieces — HTML index page
# with magnet links and the SHA256SUMS file — are kept here
# as a downloadable GHA artifact. Anyone who wants to publish
# them under a friendly URL can grab the artifact and rsync it
# to a webserver of their choice.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.image }}-iso-index-${{ steps.ia_upload.outputs.identifier }}
path: |
${{ steps.disk.outputs.outdir }}/index.html
${{ steps.disk.outputs.outdir }}/SHA256SUMS
retention-days: 30
bump_site:
# After publish_ia succeeds for the (single, post-gaming-kill)
# Margine ISO, auto-open a PR on margine-os-1084ca72 that bumps
# the LATEST_ISO_DATE constant in src/routes/index.tsx. That
# single constant drives 4 hardcoded direct-link URLs on the
# site (IA details / .torrent / direct HTTP / SHA256SUMS).
# Without this, the site's Hero CTAs and Install Option A direct
# links would have to be bumped by hand after every release.
#
# SETUP REQUIREMENT — repo secret `SITE_BUMP_TOKEN`:
# A fine-grained GitHub PAT scoped to repo
# daniel-g-carrasco/margine-os-1084ca72, with permissions:
# Contents: Read and write
# Pull requests: Read and write
# Stored as Actions secret SITE_BUMP_TOKEN in this repo
# (margine-image). Without it the job no-ops with a clear message
# instead of failing the workflow.
name: Auto-bump LATEST_ISO_DATE on margine.dev repo
needs: publish_ia
# PR events skip this step entirely. Only the stable ISO bumps the site's
# LATEST_ISO_DATE — the experimental NVIDIA ISO must not move the default
# download date (it gets its own constant when it's validated + wired).
# workflow_run == the weekly NVIDIA auto-refresh: inputs are empty there,
# so WITHOUT the explicit event guard the tag check would pass and the
# nvidia refresh would bump the stable date. Never bump on workflow_run.
if: success() && github.event_name != 'pull_request' && github.event_name != 'workflow_run' && (inputs.image_tag == '' || inputs.image_tag == 'stable')
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Check SITE_BUMP_TOKEN availability
id: secret_check
env:
T: ${{ secrets.SITE_BUMP_TOKEN }}
run: |
if [[ -z "${T:-}" ]]; then
echo "::warning::SITE_BUMP_TOKEN secret not set; skipping auto-bump."
echo "::warning::Create a fine-grained PAT scoped to margine-os-1084ca72 (Contents+PR write) and save as SITE_BUMP_TOKEN to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout this repo (for the bump script)
if: steps.secret_check.outputs.skip != 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- name: Checkout site repo
if: steps.secret_check.outputs.skip != 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: daniel-g-carrasco/margine-os-1084ca72
token: ${{ secrets.SITE_BUMP_TOKEN }}
path: site
fetch-depth: 1
- name: Bump LATEST_ISO_DATE + open PR
# Logic extracted to a shellcheck-gated script (2026-06-12).
if: steps.secret_check.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.SITE_BUMP_TOKEN }}
RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
NEW_FILE: ${{ needs.publish_ia.outputs.iso_file }}
run: |
set -euo pipefail
cd site
../.github/scripts/bump-site-iso-date.sh
notify:
name: ntfy notification (aggregated)
needs: [build_disk, build_iso_titanoboa, install_gate, publish_ia, bump_site]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
# Sends one curl; needs nothing from the token.
permissions: {}
steps:
- name: Notify ntfy
env:
NTFY_URL: ${{ secrets.NTFY_TOPIC_URL }}
BUILD_RESULT: ${{ needs.build_disk.result }}
ISO_RESULT: ${{ needs.build_iso_titanoboa.result }}
PUBLISH_RESULT: ${{ needs.publish_ia.result }}
BUMP_RESULT: ${{ needs.bump_site.result }}
run: |
if [[ -z "${NTFY_URL:-}" ]]; then
echo "NTFY_TOPIC_URL secret not set, skipping notification."
exit 0
fi
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# Weekly NVIDIA auto-refresh where the upstream build failed:
# every job skipped, nothing happened — don't ping about it
# (build-nvidia's own run is the signal for that failure).
if [[ "$BUILD_RESULT" == "skipped" && "$ISO_RESULT" == "skipped" ]]; then
echo "Nothing ran (upstream workflow_run not a success) — no notification."
exit 0
fi
# Decision matrix — the partial-success cases are the
# interesting ones: each names the exact cheap retry. Until
# 2026-06-12 the live-ISO job's result wasn't inspected at
# all, so a release-ISO failure arrived as a generic shrug.
# build_disk 'skipped' counts as OK: it is skipped by design on
# the weekly NVIDIA auto-refresh (workflow_run).
if [[ ("$BUILD_RESULT" == "success" || "$BUILD_RESULT" == "skipped") && "$ISO_RESULT" == "success" && "$PUBLISH_RESULT" == "success" ]]; then
TITLE="Disk images built + ISO on Internet Archive"
PRIO="default"
TAGS="cd,white_check_mark"
MSG="Run #${{ github.run_number }} done. qcow2 + live ISO built; ISO + torrent on archive.org. Site bump: ${BUMP_RESULT}."
elif [[ "$BUILD_RESULT" == "failure" ]]; then
TITLE="Disk build FAILED (BIB qcow2)"
PRIO="high"
TAGS="x,cd"
MSG="bootc-image-builder failed. See logs: ${RUN_URL}"
elif [[ "$ISO_RESULT" == "failure" ]]; then
TITLE="Live ISO build FAILED (Titanoboa)"
PRIO="high"
TAGS="x,optical_disk"
MSG="qcow2 OK but the Titanoboa live ISO failed (build or QEMU boot-test). Retry: gh run rerun --failed ${{ github.run_id }} — see iso-boot-serial-log artifact if the boot-test tripped."
elif [[ "$PUBLISH_RESULT" == "failure" ]]; then
TITLE="ISO built but IA upload FAILED — retry only the publish job"
PRIO="high"
TAGS="cd,closed_lock_with_key"
MSG="The ISO + qcow2 are built and saved as GHA artifacts. Retry just the publish_ia job: gh run rerun --failed ${{ github.run_id }}"
elif [[ "$BUILD_RESULT" == "cancelled" || "$PUBLISH_RESULT" == "cancelled" ]]; then
TITLE="Disk pipeline cancelled"
PRIO="low"
TAGS="warning"
MSG=""
else
TITLE="Disk pipeline status: build=${BUILD_RESULT}, iso=${ISO_RESULT}, publish=${PUBLISH_RESULT}, bump=${BUMP_RESULT}"
PRIO="default"
TAGS="warning"
MSG=""
fi
curl --silent --show-error --fail \
-H "Title: ${TITLE}" \
-H "Priority: ${PRIO}" \
-H "Tags: ${TAGS}" \
-H "Click: ${RUN_URL}" \
-d "${MSG}" \
"$NTFY_URL" || echo "ntfy notify failed (non-fatal)"