veilor-os/.github/workflows/build-iso.yml
obsidian-ai 6b0828d692 ci: pin sbom/cosign/attest actions to node20-safe versions
forgejo-runner v6.4.0 ships node20; floating tags @v0/@v3/@v2 now
resolve to actions whose runs.using=node24, which the runner cannot
exec. Pin to last node20-shipping release of each:

- anchore/sbom-action@v0.17.2
- sigstore/cosign-installer@v3.7.0
- actions/attest-build-provenance@v2.2.3
2026-05-06 16:10:03 +01:00

377 lines
17 KiB
YAML

# TODO: SHA-pin all uses: tags to commit SHAs (Agent 8 audit recommendation).
# Tracked separately so this PR can land without long web lookups.
name: Build veilor-os ISO
on:
push:
branches: [main]
paths:
- 'kickstart/**'
- 'overlay/**'
- 'scripts/**'
- 'assets/**'
- 'build/**'
- '.github/workflows/build-iso.yml'
workflow_dispatch:
inputs:
releasever:
description: 'Fedora release version'
required: false
default: '43'
release:
types: [published]
permissions:
contents: write # needed for action-gh-release to create+update ci-latest
id-token: write # cosign keyless OIDC + attest-build-provenance
attestations: write # attest-build-provenance writes the attestation
jobs:
build:
name: Build live ISO
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- name: Checkout
# Pinned to last v4 tag confirmed to ship on node20. v4.2+ ships
# node24 which forgejo-runner v6.4.0 (node20) cannot exec.
uses: actions/checkout@v4.1.7
- name: Free up disk
run: |
sudo rm -rf /opt/hostedtoolcache /usr/share/dotnet /usr/local/lib/android /usr/local/share/boost
sudo apt-get clean
df -h
- name: Run build inside Fedora 43 container
# v3 is composite/docker-based — no node runtime in the action
# itself. Safe under node20 forgejo-runner. TODO(infra): consider
# SHA pinning in a follow-up sweep.
uses: addnab/docker-run-action@v3
with:
# Pinned to digest from `skopeo inspect --raw` on 2026-05-06.
# Refresh by re-running skopeo against fedora:43 and bumping.
image: registry.fedoraproject.org/fedora:43@sha256:72e874e771b953c6357c7a5823c6fc1e3e3253b90121e795febe01380e32269b
options: |
--privileged
-v ${{ github.workspace }}:/work
-v /dev:/dev
--tmpfs /tmp:rw,nosuid,nodev,exec,size=16G
run: |
set -euxo pipefail
# Update Fedora image to latest packages — guarantees pcre2 +
# libselinux + selinux-policy are matched (the local build's
# core problem). CI runners always start fresh, no version skew.
dnf -y upgrade --refresh
# Install build tooling
dnf -y install \
lorax \
livecd-tools \
pykickstart \
python3-imgcreate \
anaconda-tui \
squashfs-tools \
xorriso \
createrepo_c \
git \
which \
shadow-utils \
syslinux \
tar \
curl
# Vendor gum binary onto the ISO so the TTY1 installer can use
# Charm.sh TUI primitives. gum is not packaged in Fedora repos,
# so pull the upstream release tarball pinned by sha256.
GUM_VERSION="0.17.0"
GUM_URL="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/gum_${GUM_VERSION}_Linux_x86_64.tar.gz"
GUM_SHA256="69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb"
mkdir -p /work/overlay/usr/local/bin
curl -fsSL "$GUM_URL" -o /tmp/gum.tgz
echo "$GUM_SHA256 /tmp/gum.tgz" | sha256sum -c -
tar -xzf /tmp/gum.tgz -C /tmp/
install -m 0755 "/tmp/gum_${GUM_VERSION}_Linux_x86_64/gum" /work/overlay/usr/local/bin/gum
/work/overlay/usr/local/bin/gum --version
echo "[OK] gum ${GUM_VERSION} vendored into overlay/usr/local/bin/"
cd /work
# PATCH: livecd-creator bug — __get_efi_image_stanza writes
# `root=live:LABEL=...` instead of `live:CDLABEL=...` for dracut.
# Result: dracut hangs on parse-livenet looking for non-CD label.
# Fix in-place before running build.
LIVE_PY=$(python3 -c 'import imgcreate, os; print(os.path.dirname(imgcreate.__file__))')/live.py
sed -i 's|"live:LABEL=%(fslabel)s"|"live:CDLABEL=%(fslabel)s"|g' "$LIVE_PY"
grep -n 'CDLABEL=%(fslabel)s' "$LIVE_PY" || { echo "[ERR] patch failed"; exit 1; }
echo "[OK] livecd-creator patched: LABEL= → CDLABEL= for EFI dracut stanza"
# CI uses ks-ci.ks (no local fix-repo line). Generated from main ks.
# Also strip flags livecd-creator doesn't recognize.
# Drop `updates` repo: 3 consecutive builds 404'd on its
# repodata zchunk file across all mirrors — Fedora infra issue
# mid-push window. Original reason for `updates` (selinux-policy
# 43.7 pcre2 fix) is no longer needed; base 43 ships fixed.
sed -e '/veilor-fix/d' \
-e '/^shutdown$/d' \
-e '/repo --name=updates/d' \
kickstart/veilor-os.ks > kickstart/veilor-os-ci.ks
ksvalidator kickstart/veilor-os-ci.ks
mkdir -p build/out
# livecd-creator (livecd-tools) — purpose-built for live ISOs.
# Handles EFI/BOOT + isohybrid + grafting that livemedia-creator
# --make-iso --no-virt does not. Produces UEFI+BIOS bootable ISO.
# --tmpdir /var/lmc to avoid GitHub Actions /tmp tmpfs constraints.
# /var on the runner is the host's ext4 (~80GB free post-disk-cleanup).
mkdir -p /var/lmc /var/lmc-cache
livecd-creator \
--verbose \
--config kickstart/veilor-os-ci.ks \
--fslabel "veilor-os-43" \
--title "veilor-os" \
--product "veilor-os" \
--releasever "${{ github.event.inputs.releasever || '43' }}" \
--tmpdir /var/lmc \
--cache /var/lmc-cache 2>&1 | tee build/out/build.log
# Graft veilor source tree onto the ISO so the installer-generated
# kickstart's `%post --nochroot` can find SRC at
# /run/install/repo/veilor/{overlay,scripts,assets}/ when the user
# promotes the live ISO into a real install.
ISO_FILE=$(ls /work/*.iso 2>/dev/null | head -1)
[ -n "$ISO_FILE" ] || { echo "[ERR] no ISO produced by livecd-creator"; exit 1; }
echo "[INFO] grafting /veilor/ onto $ISO_FILE"
# Extract original ISO's exact boot stanza so the rebuild matches
# livecd-creator's layout byte-for-byte. This is immune to upstream
# Fedora layout changes (e.g. images/ vs isolinux/ for efiboot.img,
# partition geometry flags, hybrid MBR/GPT options).
xorriso -indev "$ISO_FILE" -report_el_torito as_mkisofs 2>&1 | tee /tmp/iso-boot.txt || true
ORIG_FLAGS=$(xorriso -indev "$ISO_FILE" -report_el_torito as_mkisofs 2>/dev/null | \
grep -v '^xorriso :' | grep -E '^-' | tr '\n' ' ')
[ -n "$ORIG_FLAGS" ] || { echo "[ERR] could not extract boot stanza from $ISO_FILE"; exit 1; }
echo "[INFO] re-pack flags from original ISO: $ORIG_FLAGS"
mkdir -p /tmp/iso-mod
xorriso -osirrox on -indev "$ISO_FILE" -extract / /tmp/iso-mod
chmod -R u+w /tmp/iso-mod
mkdir -p /tmp/iso-mod/veilor
cp -a /work/overlay /work/scripts /work/assets /tmp/iso-mod/veilor/
# Replay the exact stanza captured above. eval is needed because
# ORIG_FLAGS contains multiple flag/value pairs that must word-split.
eval xorriso -as mkisofs \
-volid "veilor-os-43" \
$ORIG_FLAGS \
-o "${ISO_FILE}.tmp" /tmp/iso-mod
mv "${ISO_FILE}.tmp" "$ISO_FILE"
rm -rf /tmp/iso-mod
echo "[OK] /veilor/ grafted onto $ISO_FILE"
# Move output ISO to expected dir
mv ./veilor-os-43.iso build/out/ 2>/dev/null || mv ./*.iso build/out/ 2>/dev/null || true
# Rename + checksum
ISO_NAME="veilor-os-${{ github.event.inputs.releasever || '43' }}-$(date +%Y%m%d-%H%M%S).iso"
cd build/out
for f in *.iso; do
[[ -f $f && $f != $ISO_NAME ]] && mv "$f" "$ISO_NAME"
done
sha256sum "$ISO_NAME" > "$ISO_NAME.sha256"
ls -lh "$ISO_NAME"
# ── ISO publish ────────────────────────────────────────────────────
# GH Release asset size limit = 2 GiB. Our ISO ~2.8 GiB. Split into
# chunks before upload. Reassemble client-side via `cat *.part-* > x.iso`.
# Squashfs is already near-incompressible (zstd -19 → 96%) so split,
# not compress.
- name: Split ISO into 2GiB chunks
if: success() && github.ref == 'refs/heads/main'
run: |
# ISO + sidecars created by Fedora container as root. Reclaim
# ownership so this step (running as runner user) can write.
sudo chown -R "$(id -u):$(id -g)" build/out
cd build/out
ISO=$(ls *.iso | head -1)
[ -n "$ISO" ] || { echo "[ERR] no ISO"; exit 1; }
# Split with 1900M chunks (under 2 GiB safe). Suffix .part-aa, .part-ab, ...
split -b 1900M -d --suffix-length=2 "$ISO" "${ISO}.part-"
ls -lh
# Drop the original ISO so it doesn't try to upload (over limit)
rm -f "$ISO"
# Generate sha256 of all parts so reassembly is verifiable
sha256sum *.part-* > "${ISO}.parts.sha256"
echo "[OK] split into:"
ls "${ISO}".part-*
- name: Install cosign
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
# Pinned to last v3 release confirmed node20.
uses: sigstore/cosign-installer@v3.7.0
- name: Sign ISO parts (keyless)
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: |
cd build/out
for f in *.part-*; do
cosign sign-blob --yes "$f" \
--output-signature "$f.sig" \
--output-certificate "$f.pem"
done
- name: Generate SBOM (SPDX)
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
# Pinned to last v0.17 release that ships node20.
uses: anchore/sbom-action@v0.17.2
with:
path: build/out
format: spdx-json
output-file: build/out/veilor-os.spdx.json
- name: Build provenance attestation
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
# Pinned to last v2.2 release that ships node20.
uses: actions/attest-build-provenance@v2.2.3
with:
subject-path: 'build/out/*.iso.part-*'
# GitHub-only: softprops/action-gh-release uses the GitHub REST API
# which Forgejo doesn't expose at the same endpoints. When this
# workflow runs on git.s8n.ru the step below (Forgejo) handles
# publishing instead.
- name: Publish to ci-latest rolling prerelease (GitHub)
if: success() && github.ref == 'refs/heads/main' && github.server_url == 'https://github.com'
# Pinned to last v2 tag confirmed to ship on node20.
uses: softprops/action-gh-release@v2.0.4
with:
tag_name: ci-latest
name: "ci-latest (auto)"
body: |
Rolling auto-build from `main`. Latest commit: ${{ github.sha }}.
**ISO is split into chunks (GH release 2 GiB asset limit).**
Reassemble:
```
cat veilor-os-*.iso.part-* > veilor-os.iso
sha256sum -c veilor-os-*.iso.parts.sha256
```
Or use `test/auto-install.sh` which handles reassembly automatically.
Not a stable release — for testing only.
prerelease: true
make_latest: false
files: |
build/out/*.iso.part-*
build/out/*.sha256
build/out/*.sig
build/out/*.pem
build/out/*.spdx.json
# Forgejo equivalent: drop+recreate ci-latest release via the
# Forgejo REST API, then upload chunks. Only runs when not on GitHub.
# All ${{ }} interpolations are vetted (repo coords + signed SHA).
- name: Publish to ci-latest rolling prerelease (Forgejo)
if: success() && github.ref == 'refs/heads/main' && github.server_url != 'https://github.com'
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORGEJO_API: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
GIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
TAG="ci-latest"
REL_JSON=$(curl -fsSL -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "")
if [ -n "$REL_JSON" ]; then
REL_ID=$(echo "$REL_JSON" | grep -oE '"id":\s*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -n "$REL_ID" ]; then
echo "[INFO] deleting existing ci-latest release id=$REL_ID"
curl -fsSL -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/releases/${REL_ID}" || true
curl -fsSL -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/git/refs/tags/${TAG}" || true
fi
fi
BODY="Rolling auto-build from main. Latest commit: ${GIT_SHA}.
ISO is split into chunks. Reassemble:
cat veilor-os-*.iso.part-* > veilor-os.iso
sha256sum -c veilor-os-*.iso.parts.sha256
Or use test/auto-install.sh (handles reassembly automatically).
Not a stable release — for testing only."
PAYLOAD=$(BODY="$BODY" TAG="$TAG" python3 -c "
import json,os
print(json.dumps({
'tag_name': os.environ['TAG'],
'target_commitish': 'main',
'name': 'ci-latest (auto)',
'body': os.environ['BODY'],
'prerelease': True,
'draft': False,
}))")
REL_ID=$(curl -fsSL -X POST -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${FORGEJO_API}/repos/${REPO}/releases" | \
grep -oE '"id":\s*[0-9]+' | head -1 | grep -oE '[0-9]+')
[ -n "$REL_ID" ] || { echo "[ERR] failed to create Forgejo release"; exit 1; }
echo "[OK] Forgejo release id=$REL_ID created"
cd build/out
for f in *.iso.part-* *.sha256; do
[ -f "$f" ] || continue
echo "[INFO] uploading $f"
curl -fsSL -X POST -H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${f}" \
"${FORGEJO_API}/repos/${REPO}/releases/${REL_ID}/assets?name=${f}"
done
echo "[OK] all assets uploaded to Forgejo ci-latest"
# Build log on failure: print inline + skip artifact upload to avoid
# quota wall. Job log retains everything anyway.
- name: Print build log on failure
if: failure()
run: |
echo "─── build/out/build.log ───"
tail -200 build/out/build.log 2>/dev/null || echo "(no build.log)"
echo "─── anaconda program.log ───"
find build/out/build/anaconda -name 'program.log' -exec tail -100 {} \; 2>/dev/null || echo "(no anaconda log)"
# GitHub-only: same restriction as ci-latest publish.
- name: Attach to release on tag (GitHub)
if: github.event_name == 'release' && github.server_url == 'https://github.com'
# Pinned to last v2 tag confirmed to ship on node20.
uses: softprops/action-gh-release@v2.0.4
with:
files: |
build/out/*.iso
build/out/*.sha256
# Forgejo equivalent for tag-driven release uploads. The release
# is assumed to already exist (Forgejo creates it from the tag);
# we only attach assets here.
- name: Attach to release on tag (Forgejo)
if: github.event_name == 'release' && github.server_url != 'https://github.com'
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORGEJO_API: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
REL_JSON=$(curl -fsSL -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_API}/repos/${REPO}/releases/tags/${REF_NAME}")
REL_ID=$(echo "$REL_JSON" | grep -oE '"id":\s*[0-9]+' | head -1 | grep -oE '[0-9]+')
[ -n "$REL_ID" ] || { echo "[ERR] no Forgejo release for tag ${REF_NAME}"; exit 1; }
cd build/out
for f in *.iso *.sha256; do
[ -f "$f" ] || continue
curl -fsSL -X POST -H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@${f}" \
"${FORGEJO_API}/repos/${REPO}/releases/${REL_ID}/assets?name=${f}"
done