sre: release pipeline w/ ISO split, GPG sig, MOK signing scaffold

- build-iso.yml: on tag push (v*.*.*), split ISO into 1.9G parts, GPG-sign
  the sha256 with GPG_PRIVATE_KEY secret, and auto-create release with
  softprops/action-gh-release@v2 attaching part files + sig + reassembly
  instructions. Falls back to legacy release.published path.
- build-iso.yml: optional EFI Secure Boot signing step. If MOK_PRIVATE_KEY
  + MOK_CERT secrets are present, sbsign each .efi inside the ISO and
  repack with xorriso; otherwise warn and ship unsigned. Refresh sha256.
- release-checksums.yml: new PR-time gate. Validates source + generated
  CI kickstart, shellchecks scripts, parses every workflow YAML, and
  asserts the split size stays under GitHub'''s 2 GiB asset cap.
- scripts/gen-mok-key.sh: idempotent MOK keypair generator (RSA-4096,
  10y), outputs to gitignored build/keys/. Header documents mokutil
  enrollment and gh secret upload. exec bit set in index.
- .gitignore: add build/keys/, *.priv, *.der.

User must add GitHub secrets before the next tagged release:
  GPG_PRIVATE_KEY  — armored private key for sha256 signing
  MOK_PRIVATE_KEY  — sbsign EFI signing key (PEM)
  MOK_CERT         — public cert (DER) for sbsign + mokutil enrollment
This commit is contained in:
veilor-org 2026-05-01 23:39:19 +01:00
parent 8515bdbe38
commit 2782b72ead
4 changed files with 404 additions and 5 deletions

View file

@ -10,6 +10,8 @@ on:
- 'assets/**' - 'assets/**'
- 'build/**' - 'build/**'
- '.github/workflows/build-iso.yml' - '.github/workflows/build-iso.yml'
tags:
- 'v*.*.*'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
releasever: releasever:
@ -24,6 +26,8 @@ jobs:
name: Build live ISO name: Build live ISO
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 90 timeout-minutes: 90
permissions:
contents: write
steps: steps:
- name: Checkout - name: Checkout
@ -52,7 +56,9 @@ jobs:
# core problem). CI runners always start fresh, no version skew. # core problem). CI runners always start fresh, no version skew.
dnf -y upgrade --refresh dnf -y upgrade --refresh
# Install build tooling # Install build tooling. sbsigntools provides sbsign for EFI
# binary signing; pesign is the alternate path. xorriso is
# required for ISO repack after EFI signing.
dnf -y install \ dnf -y install \
lorax \ lorax \
livecd-tools \ livecd-tools \
@ -64,7 +70,11 @@ jobs:
createrepo_c \ createrepo_c \
git \ git \
which \ which \
shadow-utils shadow-utils \
sbsigntools \
openssl \
dosfstools \
mtools
cd /work cd /work
@ -105,8 +115,16 @@ jobs:
# Move output ISO to expected dir # Move output ISO to expected dir
mv ./veilor-os-43.iso build/out/ 2>/dev/null || mv ./*.iso build/out/ 2>/dev/null || true mv ./veilor-os-43.iso build/out/ 2>/dev/null || mv ./*.iso build/out/ 2>/dev/null || true
# Rename + checksum # Rename. For tag builds use the tag as the version stamp so
ISO_NAME="veilor-os-${{ github.event.inputs.releasever || '43' }}-$(date +%Y%m%d-%H%M%S).iso" # release artifacts have a stable, predictable name. For
# branch / dispatch builds use a timestamp.
REF_NAME="${{ github.ref_name }}"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
VERSION_TAG="$REF_NAME"
else
VERSION_TAG="$(date +%Y%m%d-%H%M%S)"
fi
ISO_NAME="veilor-os-${{ github.event.inputs.releasever || '43' }}-${VERSION_TAG}.iso"
cd build/out cd build/out
for f in *.iso; do for f in *.iso; do
[[ -f $f && $f != $ISO_NAME ]] && mv "$f" "$ISO_NAME" [[ -f $f && $f != $ISO_NAME ]] && mv "$f" "$ISO_NAME"
@ -114,6 +132,95 @@ jobs:
sha256sum "$ISO_NAME" > "$ISO_NAME.sha256" sha256sum "$ISO_NAME" > "$ISO_NAME.sha256"
ls -lh "$ISO_NAME" ls -lh "$ISO_NAME"
# Stash final ISO name for later steps that run *outside* the
# container (signing, splitting, release). The runner sees
# /work/build/out, host sees ${{ github.workspace }}/build/out.
echo "$ISO_NAME" > /work/build/out/.iso-name
- name: Resolve ISO filename
id: iso
run: |
ISO_NAME=$(cat build/out/.iso-name)
echo "name=$ISO_NAME" >> "$GITHUB_OUTPUT"
echo "path=build/out/$ISO_NAME" >> "$GITHUB_OUTPUT"
echo "[INFO] ISO resolved: $ISO_NAME"
# ─── EFI signing (Secure Boot) ──────────────────────────────────────
# Only runs if MOK_PRIVATE_KEY + MOK_CERT secrets are present.
# Repacks the ISO in-place with sbsign'd BOOTX64.EFI / grubx64.efi.
# Without the secrets, ISO is shipped unsigned (testing builds).
- name: Sign EFI binaries (if MOK secrets present)
id: sbsign
env:
MOK_PRIVATE_KEY: ${{ secrets.MOK_PRIVATE_KEY }}
MOK_CERT: ${{ secrets.MOK_CERT }}
run: |
set -euo pipefail
if [[ -z "${MOK_PRIVATE_KEY:-}" || -z "${MOK_CERT:-}" ]]; then
echo "::warning::[INFO] MOK secrets absent — ISO unsigned for testing"
echo "signed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
sudo apt-get update
sudo apt-get install -y sbsigntool xorriso mtools dosfstools
# Materialise key + cert from secrets to a tmp dir wiped on exit.
KEYDIR=$(mktemp -d)
trap 'rm -rf "$KEYDIR"' EXIT
printf '%s' "$MOK_PRIVATE_KEY" > "$KEYDIR/MOK.priv"
printf '%s' "$MOK_CERT" | base64 -d > "$KEYDIR/MOK.der" 2>/dev/null \
|| printf '%s' "$MOK_CERT" > "$KEYDIR/MOK.der"
# sbsign prefers PEM cert input — derive on the fly.
openssl x509 -inform DER -in "$KEYDIR/MOK.der" -outform PEM -out "$KEYDIR/MOK.pem" \
|| cp "$KEYDIR/MOK.der" "$KEYDIR/MOK.pem"
chmod 600 "$KEYDIR/MOK.priv"
ISO_PATH="${{ steps.iso.outputs.path }}"
WORKDIR=$(mktemp -d)
trap 'rm -rf "$KEYDIR" "$WORKDIR"' EXIT
# Extract EFI ESP image from ISO. xorriso prints embedded image
# locations; we pull the EFI/BOOT directory contents into a
# writable scratch dir, sign each .efi, and graft back in.
mkdir -p "$WORKDIR/iso-extract"
xorriso -osirrox on -indev "$ISO_PATH" \
-extract /EFI "$WORKDIR/iso-extract/EFI" 2>&1 | tail -20
SIGNED_ANY=0
for efi in $(find "$WORKDIR/iso-extract/EFI" -type f -iname '*.efi' 2>/dev/null); do
echo "[*] Signing $efi"
sbsign \
--key "$KEYDIR/MOK.priv" \
--cert "$KEYDIR/MOK.pem" \
--output "${efi}.signed" \
"$efi"
mv "${efi}.signed" "$efi"
SIGNED_ANY=1
done
if [[ $SIGNED_ANY -eq 0 ]]; then
echo "::warning::No EFI binaries found in ISO — skipping repack"
echo "signed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Repack: graft the signed EFI tree back into the ISO. Use
# xorriso's update mode so other ISO contents (squashfs, isolinux)
# remain untouched.
xorriso \
-indev "$ISO_PATH" \
-outdev "${ISO_PATH}.signed" \
-boot_image any keep \
-map "$WORKDIR/iso-extract/EFI" /EFI \
-commit
mv "${ISO_PATH}.signed" "$ISO_PATH"
# Refresh sha256 — content changed.
(cd build/out && sha256sum "${{ steps.iso.outputs.name }}" > "${{ steps.iso.outputs.name }}.sha256")
echo "[OK] EFI binaries signed; ISO repacked + checksum refreshed."
echo "signed=true" >> "$GITHUB_OUTPUT"
- name: Upload ISO artifact - name: Upload ISO artifact
if: success() if: success()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -133,7 +240,102 @@ jobs:
build/out/build.log build/out/build.log
build/out/build/anaconda/ build/out/build/anaconda/
- name: Attach to release # ─── Release artefact pipeline ──────────────────────────────────────
# Triggered on tag push (v*.*.*). The 2.86 GB ISO exceeds GitHub's
# 2 GB single-asset cap, so we split into 1.9 GB parts and let users
# reassemble with `cat`. Checksum is GPG-signed so users can verify
# the assembled ISO end-to-end.
- name: Split ISO for release
if: github.event_name == 'push' && github.ref_type == 'tag'
id: split
run: |
set -euo pipefail
cd build/out
ISO="${{ steps.iso.outputs.name }}"
# 1900M keeps each part safely below GitHub's 2 GB asset limit
# (limit is 2 GiB = 2147483648 bytes; 1900 MiB = 1992294400).
split -b 1900M -d -a 2 "$ISO" "${ISO}.part-"
# Rename suffix to letter form so it matches the documented
# convention .part-aa, .part-ab. -d uses 00/01; we want aa/ab
# to make the cat glob unambiguous (no leading zero confusion).
mv "${ISO}.part-00" "${ISO}.part-aa"
mv "${ISO}.part-01" "${ISO}.part-ab" 2>/dev/null || true
# In rare 3-part case (>3.8 GB ISO), handle .part-02 → .part-ac
[[ -f "${ISO}.part-02" ]] && mv "${ISO}.part-02" "${ISO}.part-ac" || true
ls -lh "${ISO}".part-*
echo "iso=$ISO" >> "$GITHUB_OUTPUT"
- name: GPG-sign sha256 checksum
if: github.event_name == 'push' && github.ref_type == 'tag'
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
set -euo pipefail
if [[ -z "${GPG_PRIVATE_KEY:-}" ]]; then
echo "::warning::GPG_PRIVATE_KEY secret missing — checksum will not be signed"
exit 0
fi
GNUPGHOME=$(mktemp -d)
export GNUPGHOME
chmod 700 "$GNUPGHOME"
# Trust the imported key automatically — CI is non-interactive.
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
KEYID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ {print $5; exit}')
echo "[*] Signing with key $KEYID"
cd build/out
ISO="${{ steps.iso.outputs.name }}"
gpg --batch --yes --pinentry-mode loopback \
--local-user "$KEYID" \
--armor --detach-sign \
--output "${ISO}.sha256.asc" \
"${ISO}.sha256"
ls -lh "${ISO}.sha256" "${ISO}.sha256.asc"
- name: Create release and attach assets
if: github.event_name == 'push' && github.ref_type == 'tag'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: veilor-os ${{ github.ref_name }}
draft: false
prerelease: false
generate_release_notes: true
fail_on_unmatched_files: false
files: |
build/out/*.iso.part-*
build/out/*.iso.sha256
build/out/*.iso.sha256.asc
body: |
## veilor-os ${{ github.ref_name }}
ISO is split into 1.9 GB parts to fit within GitHub's 2 GB
per-asset upload cap. Reassemble after download:
```bash
cat ${{ steps.iso.outputs.name }}.part-* > ${{ steps.iso.outputs.name }}
sha256sum -c ${{ steps.iso.outputs.name }}.sha256
```
### Verifying the checksum signature
```bash
# Import the veilor-os release signing key (one-time):
gpg --keyserver keys.openpgp.org --recv-keys <KEY-ID-FROM-RELEASE-NOTES>
# Verify:
gpg --verify ${{ steps.iso.outputs.name }}.sha256.asc \
${{ steps.iso.outputs.name }}.sha256
```
### Secure Boot
EFI binaries are signed with the veilor-os MOK if signing
secrets were configured at build time. To enroll the public
cert post-install, see `scripts/gen-mok-key.sh` header.
# Legacy hook: keeps the manual `release.published` workflow path
# working. Tag-driven flow above is the new canonical entry point.
- name: Attach to release (legacy release event)
if: github.event_name == 'release' if: github.event_name == 'release'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:

100
.github/workflows/release-checksums.yml vendored Normal file
View file

@ -0,0 +1,100 @@
name: Release Checksums
# PR-time validation gate for release-affecting files. Independent of
# lint.yml — meant to harden the brittle parts (ksvalidator on the
# generated CI kickstart, shellcheck across all maintained scripts,
# YAML sanity on every workflow).
#
# This workflow does NOT replace lint.yml; it runs alongside.
on:
pull_request:
paths:
- 'kickstart/**'
- 'scripts/**'
- '.github/workflows/**'
push:
branches: [main]
paths:
- 'kickstart/**'
- 'scripts/**'
- '.github/workflows/**'
jobs:
ksvalidate:
name: ksvalidator (CI-flavour kickstart)
runs-on: ubuntu-24.04
container:
image: registry.fedoraproject.org/fedora:43
steps:
- uses: actions/checkout@v4
- name: Install pykickstart
run: dnf -y install pykickstart sed
- name: Generate CI kickstart and validate
run: |
set -euxo pipefail
# Mirror what build-iso.yml does so we're validating the file
# the actual builder consumes, not just the source kickstart.
sed -e '/veilor-fix/d' \
-e '/^shutdown$/d' \
kickstart/veilor-os.ks > kickstart/veilor-os-ci.ks
ksvalidator kickstart/veilor-os.ks
ksvalidator kickstart/veilor-os-ci.ks
shellcheck:
name: shellcheck (release scripts)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: shellcheck repo scripts
uses: ludeeus/action-shellcheck@master
with:
severity: warning
# Same exclusions as lint.yml so behaviour is consistent.
ignore_paths: build/cache .github
workflow-yaml:
name: workflow YAML sanity
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Validate every workflow parses as YAML
run: |
set -euo pipefail
python3 - <<'PY'
import sys, pathlib, yaml
ok = True
for p in pathlib.Path(".github/workflows").glob("*.y*ml"):
try:
yaml.safe_load(p.read_text())
print(f"[OK] {p}")
except yaml.YAMLError as e:
print(f"[ERR] {p}: {e}", file=sys.stderr)
ok = False
sys.exit(0 if ok else 1)
PY
release-asset-budget:
name: Release asset size budget
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Confirm split threshold is below GitHub's 2 GiB asset cap
run: |
set -euo pipefail
# GitHub per-asset upload limit is 2 GiB = 2147483648 bytes.
# split is invoked with -b 1900M = 1900 * 2^20 = 1992294400 bytes.
# Hard-fail if anyone bumps the split size beyond the cap.
if grep -E 'split -b [0-9]+M' .github/workflows/build-iso.yml >/dev/null; then
SIZE_M=$(grep -oE 'split -b [0-9]+M' .github/workflows/build-iso.yml | head -1 | grep -oE '[0-9]+')
if [[ "$SIZE_M" -gt 2047 ]]; then
echo "::error::split -b ${SIZE_M}M exceeds GitHub's 2 GiB per-asset cap"
exit 1
fi
echo "[OK] split size ${SIZE_M}M is under the 2 GiB asset limit."
else
echo "::warning::No split -b NM directive found — release pipeline may have changed"
fi

3
.gitignore vendored
View file

@ -1,5 +1,6 @@
build/out/ build/out/
build/cache/ build/cache/
build/keys/
*.iso *.iso
*.img *.img
*.log *.log
@ -11,5 +12,7 @@ build/cache/
secrets/ secrets/
*.key *.key
*.pem *.pem
*.priv
*.der
test/veilor-vm.qcow2 test/veilor-vm.qcow2
test/veilor-vm.nvram* test/veilor-vm.nvram*

94
scripts/gen-mok-key.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# gen-mok-key.sh — Generate a Machine Owner Key (MOK) pair for Secure Boot
#
# Purpose:
# Produces an RSA-4096 key pair used to sign EFI binaries (BOOTX64.EFI,
# shim, grub) and out-of-tree kernel modules so they pass UEFI Secure Boot
# verification once the public cert is enrolled in the firmware.
#
# Output (gitignored):
# build/keys/MOK.priv — PEM private key (sbsign / sign-file input)
# build/keys/MOK.der — DER public certificate (mokutil enrollment input)
# build/keys/MOK.pem — PEM public certificate (sbsign --cert input)
#
# Idempotent: if MOK.priv already exists, exits 0 without regenerating.
# Re-running with existing keys is safe — won't clobber a key already used
# to sign released ISOs.
#
# ─── User enrollment workflow (post-install) ─────────────────────────────
# 1. Copy build/keys/MOK.der to the installed system (USB / scp / etc.)
# 2. On the booted veilor-os system, as root:
# mokutil --import /path/to/MOK.der
# Set a one-time password when prompted.
# 3. Reboot. The shim's MokManager will appear on next boot — choose
# "Enroll MOK", confirm with the password from step 2, then continue
# boot. The cert is now in the kernel's .platform keyring.
# 4. Verify enrollment:
# mokutil --list-enrolled | grep -A2 'veilor'
#
# ─── Uploading to GitHub Actions secrets ─────────────────────────────────
# After running this script, populate the CI signing secrets with:
# gh secret set MOK_PRIVATE_KEY < build/keys/MOK.priv
# gh secret set MOK_CERT < build/keys/MOK.der
#
# Keep build/keys/ off-disk-backup-medium-of-record offline. Anyone with
# MOK.priv can sign code that boots on enrolled machines.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
KEY_DIR="$REPO_ROOT/build/keys"
PRIV_KEY="$KEY_DIR/MOK.priv"
DER_CERT="$KEY_DIR/MOK.der"
PEM_CERT="$KEY_DIR/MOK.pem"
# Subject for the X.509 cert. Tweak CN if you fork the project.
SUBJ="/CN=veilor-os Machine Owner Key/O=veilor-org/OU=secure-boot/"
# Cert validity — 10 years. Long enough that we don't churn re-enrollment;
# short enough that a leaked key has a hard expiry.
DAYS=3650
if [[ -f "$PRIV_KEY" ]]; then
echo "[INFO] $PRIV_KEY already exists — skipping (idempotent)."
echo "[INFO] To regenerate, delete $KEY_DIR/ first."
exit 0
fi
mkdir -p "$KEY_DIR"
chmod 700 "$KEY_DIR"
echo "[*] Generating RSA-4096 MOK keypair → $KEY_DIR/"
# Single openssl invocation produces PEM private key + DER public cert.
# -nodes = no passphrase on the key (CI must use it non-interactively).
# Protect the resulting MOK.priv with filesystem perms only.
openssl req \
-new \
-x509 \
-newkey rsa:4096 \
-nodes \
-sha256 \
-days "$DAYS" \
-subj "$SUBJ" \
-keyout "$PRIV_KEY" \
-outform DER \
-out "$DER_CERT"
# Also emit a PEM-encoded copy of the cert — sbsign accepts PEM more
# reliably than DER in some distros' build of the tool.
openssl x509 -inform DER -in "$DER_CERT" -outform PEM -out "$PEM_CERT"
chmod 600 "$PRIV_KEY"
chmod 644 "$DER_CERT" "$PEM_CERT"
echo "[OK] MOK keypair written:"
echo " private : $PRIV_KEY (mode 600)"
echo " cert DER: $DER_CERT (enroll via mokutil --import)"
echo " cert PEM: $PEM_CERT (sbsign --cert input)"
echo ""
echo "[next] Upload to CI:"
echo " gh secret set MOK_PRIVATE_KEY < $PRIV_KEY"
echo " gh secret set MOK_CERT < $DER_CERT"