Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Unit tests

on:
pull_request:
push:

jobs:
unit:
runs-on: ubuntu-latest
# The unit tests exercise scripts that ship on the ISO, so they run against
# the same userland: Arch's parted, util-linux script, libarchive bsdtar
# and systemd localectl. Ubuntu carries most of these too, but not the
# same versions, and a difference there is a test failure nobody asked for.
container: archlinux:latest
steps:
- name: Install test dependencies
run: |
pacman -Syu --noconfirm --needed \
bash git jq libarchive parted python systemd util-linux

- name: Checkout repository
uses: actions/checkout@v4

- name: Run unit tests
run: ./test/all

- name: Upload test result ledger
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: test-runs/unit-test-results.json
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Corruption anywhere in the ISO is worth catching before the write, and corruptio

Run `./bin/omarchy-iso-make`; output goes into `./release`. By default the ISO uses the Omarchy packages and tracks the `quattro` branch, from the stable mirror. Pass `--edge` to use `omarchy-dev` and `omarchy-settings-dev` from the edge mirror.

The build defaults to `x86_64`. Pass `--arch aarch64` for the generic UEFI ARM64 ISO (Ampere, Graviton, Snapdragon X, dev kits — anything with vanilla UEFI + ACPI; Apple Silicon is not a target). It builds in an Arch Linux ARM container, so an arm64 host is strongly preferred; on x86_64 it runs under QEMU binfmt emulation and takes several times longer. The aarch64 ISO is named `omarchy-<date>-aarch64-<ref>.iso` and can share `release/` with the x86_64 one. See `plans/aarch64-support.md` for what is covered and what still blocks a bootable image.

For local development, build the ISO from sibling checkouts:

```bash
Expand Down
48 changes: 43 additions & 5 deletions bin/omarchy-iso-make
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ NO_CACHE=""
KEEP_PKG_CACHE=""
LOCAL_OMARCHY_PATH=""
LOCAL_PKGS_PATH=""
OMARCHY_ARCH="x86_64"
while [[ $# -gt 0 ]]; do
case $1 in
--arch)
OMARCHY_ARCH="${2:-}"
shift 2
;;
--arch=*)
OMARCHY_ARCH="${1#*=}"
shift
;;
--no-cache)
NO_CACHE=1
shift
Expand Down Expand Up @@ -57,12 +66,34 @@ while [[ $# -gt 0 ]]; do
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
echo "Usage: $0 [--arch x86_64|aarch64] [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
exit 1
;;
esac
done

# The build container must match the ISO's architecture: pacman resolves
# $arch from the running system and mkarchiso pacstraps with the host's
# pacman, so an aarch64 ISO needs an arm64 container. On an x86_64 host that
# means QEMU binfmt emulation (slow, 30-60 min); a native arm64 host is
# preferred. archlinux/archlinux publishes no arm64 image, and Arch Linux ARM
# is the aarch64 base distribution anyway, so use an ALARM image there.
case "$OMARCHY_ARCH" in
x86_64)
BUILD_IMAGE="archlinux/archlinux:latest"
BUILD_PLATFORM_ARGS=()
;;
aarch64)
BUILD_IMAGE="menci/archlinuxarm:latest"
BUILD_PLATFORM_ARGS=(--platform linux/arm64)
;;
*)
echo "Unsupported architecture: $OMARCHY_ARCH" >&2
echo "Supported architectures: x86_64, aarch64" >&2
exit 1
;;
esac

if [[ -n $LOCAL_OMARCHY_PATH ]]; then
[[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; }
[[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; }
Expand Down Expand Up @@ -125,6 +156,7 @@ DOCKER_ARGS=(
--privileged
-e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF"
-e "OMARCHY_MIRROR=$OMARCHY_MIRROR"
-e "OMARCHY_ARCH=$OMARCHY_ARCH"
-e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}"
-e "HOST_UID=$(id -u)"
-e "HOST_GID=$(id -g)"
Expand All @@ -147,9 +179,13 @@ if [[ -d /var/cache/pacman/pkg ]]; then
fi

# Mount the build cache directory where packages are downloaded. Channels use
# different package snapshots and must never share an offline mirror cache.
# different package snapshots and must never share an offline mirror cache,
# and neither can architectures: the offline repo db and every package in it
# are per-arch. x86_64 keeps its existing directory name.
if [[ -z "$NO_CACHE" ]]; then
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
cache_scope="iso_${OMARCHY_MIRROR}"
[[ $OMARCHY_ARCH == "x86_64" ]] || cache_scope+="_${OMARCHY_ARCH}"
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/$cache_scope/airootfs/var/cache/omarchy"
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
else
Expand All @@ -166,9 +202,11 @@ if ! docker version &>/dev/null; then
DOCKER=(sudo docker)
fi

"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
"${DOCKER[@]}" run "${BUILD_PLATFORM_ARGS[@]}" "${DOCKER_ARGS[@]}" "$BUILD_IMAGE" /$BUILD_SCRIPT

latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
# mkarchiso names the ISO after profiledef's arch, so two architectures can
# share release/ and each build picks up only its own output.
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*-"$OMARCHY_ARCH".iso | head -n1)
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
mv -f "$latest_iso" "$iso_ref"

Expand Down
133 changes: 114 additions & 19 deletions bin/omarchy-iso-test
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@
# and also run the CLI and shell suites there
# --port PORT Host port forwarded to guest SSH (default 2222)
# --memory MB VM memory (default 8192)
# --cpus COUNT Virtual CPUs (default: all host CPUs)
# --timeout SECS Install timeout (default 2400)
# --keep-running Leave the acceptance VM running after the suite finishes
# --no-preview Do not open the collected screenshots in imv
#
# The guest architecture follows the ISO name (omarchy-*-aarch64-*.iso boots
# qemu-system-aarch64 with the ARM virt machine). Hosts: Linux with KVM, or
# macOS on Apple Silicon (Homebrew qemu, HVF) for aarch64 ISOs only.

set -euo pipefail

Expand All @@ -36,6 +41,11 @@ REPO_DIR="$(dirname "$SCRIPT_DIR")"
ISO=""
SSH_PORT=2222
MEMORY=8192
if command -v nproc >/dev/null 2>&1; then
CPUS=$(nproc)
else
CPUS=$(sysctl -n hw.logicalcpu)
fi
INSTALL_TIMEOUT=2400
BOOT_TIMEOUT=600
ENCRYPT=false
Expand Down Expand Up @@ -63,6 +73,7 @@ while (($#)); do
--sync-all) SYNC_DIR="$2"; SYNC_ALL=true; shift ;;
--port) SSH_PORT="$2"; shift ;;
--memory) MEMORY="$2"; shift ;;
--cpus) CPUS="$2"; shift ;;
--timeout) INSTALL_TIMEOUT="$2"; shift ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*) ISO="$1" ;;
Expand All @@ -84,18 +95,64 @@ if [[ ! -f ${ISO:-} ]]; then
exit 1
fi

omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng
case "$CPUS" in
''|*[!0-9]*) echo "CPU count must be a positive integer" >&2; exit 1 ;;
esac
(( CPUS > 0 )) || { echo "CPU count must be a positive integer" >&2; exit 1; }

HOST_OS=$(uname -s)
GUEST_ARCH=x86_64
[[ $(basename "$ISO") == *-aarch64*.iso ]] && GUEST_ARCH=aarch64

# Firmware and tooling per host. Linux gets the Arch packages; macOS relies
# on Homebrew's qemu (which bundles edk2 for aarch64) and coreutils' gtimeout,
# and can only run aarch64 guests natively under HVF.
if [[ $HOST_OS == "Darwin" ]]; then
if [[ $GUEST_ARCH != "aarch64" || $(uname -m) != "arm64" ]]; then
echo "macOS acceptance currently supports native aarch64 ISOs on Apple Silicon only" >&2
exit 1
fi

export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
for command in qemu-system-aarch64 qemu-img socat magick tesseract ssh ssh-keygen python3; do
command -v "$command" >/dev/null 2>&1 || {
echo "Missing macOS acceptance dependency: $command" >&2
exit 1
}
done

QEMU_SHARE="$(brew --prefix qemu)/share/qemu"
OVMF_CODE="$QEMU_SHARE/edk2-aarch64-code.fd"
OVMF_VARS_TEMPLATE="$QEMU_SHARE/edk2-arm-vars.fd"
ACCEL=hvf
TIMEOUT_COMMAND=()
command -v gtimeout >/dev/null 2>&1 && TIMEOUT_COMMAND=(gtimeout 5)
else
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng
if [[ $GUEST_ARCH == "aarch64" ]]; then
omarchy-pkg-add edk2-armvirt
OVMF_CODE="/usr/share/edk2/aarch64/QEMU_CODE.fd"
OVMF_VARS_TEMPLATE="/usr/share/edk2/aarch64/QEMU_VARS.fd"
else
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
fi
ACCEL=kvm
TIMEOUT_COMMAND=(timeout 5)
fi

OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
[[ -f $OVMF_CODE && -f $OVMF_VARS_TEMPLATE ]] || {
echo "Missing UEFI firmware for $GUEST_ARCH: $OVMF_CODE" >&2
exit 1
}

BASE_NAME="$(basename "$ISO" .iso)"
$ENCRYPT && BASE_NAME+="-encrypted"
$PROVISION && BASE_NAME+="-provision"
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
BASE_DISK="$BASE_DIR/base.qcow2"
BASE_OVMF="$BASE_DIR/OVMF_VARS.4m.fd"
BASE_OVMF="$BASE_DIR/OVMF_VARS.fd"
SSH_KEY="$BASE_DIR/id_ed25519"
HTTP_PORT=$((SSH_PORT + 1))
HTTP_PID=""
Expand All @@ -104,7 +161,9 @@ mkdir -p "$BASE_DIR" "$RUN_DIR"

# Unix socket paths are capped at ~108 bytes, which a run directory nested in
# test-runs/<iso-name>/runs/<timestamp> can exceed. Keep the socket in /tmp.
QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-iso-test-qmp.XXXXXX.sock")
# macOS mktemp only substitutes a trailing run of X's, so the suffix is
# appended after the template.
QMP_SOCK="$(mktemp -u "${TMPDIR:-/tmp}/omarchy-iso-test-qmp.XXXXXX").sock"
PIDFILE="$RUN_DIR/qemu.pid"

log() {
Expand All @@ -119,7 +178,7 @@ vm_running() {

qmp() {
printf '{"execute":"qmp_capabilities"}\n{"execute":%s}\n' "$1" |
timeout 5 socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true
"${TIMEOUT_COMMAND[@]}" socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true
}

screendump() {
Expand Down Expand Up @@ -164,11 +223,18 @@ open_screenshots() {
local entry
local -a screenshots=()

# Ordered by capture time. BSD find has no -printf; stat -f gives the same
# "<mtime> <path>" pairs there.
while IFS= read -r -d '' entry; do
screenshots+=("${entry#* }")
done < <(
find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -printf '%T@ %p\0' |
sort -zn
if [[ $HOST_OS == "Darwin" ]]; then
find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -exec stat -f '%m %N' {} + |
sort -n | tr '\n' '\0'
else
find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -printf '%T@ %p\0' |
sort -zn
fi
)

(( ${#screenshots[@]} > 0 )) || return 0
Expand All @@ -191,7 +257,7 @@ cleanup() {
stop_vm
fi

rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png"
rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png" "$QMP_SOCK"
open_screenshots

return $status
Expand All @@ -207,18 +273,36 @@ start_vm() {
# virtio-gpu before the system comes up.
log "Watch (do not resize): vncviewer -RemoteResize=0 127.0.0.1:5905"

qemu-system-x86_64 \
-cpu host -enable-kvm -machine q35,accel=kvm \
-smp "$(nproc)" \
# The two machines differ in what they can emulate, not in how the harness
# drives them: the ARM virt machine has no q35 legacy USB or VGA, so it gets
# virtio-gpu and an xHCI controller with a USB keyboard for send-key.
local -a machine_args
if [[ $GUEST_ARCH == "aarch64" ]]; then
machine_args=(
qemu-system-aarch64
-cpu host -machine "virt,accel=$ACCEL"
-device virtio-gpu-pci
-device qemu-xhci,id=xhci
-device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0
)
else
machine_args=(
qemu-system-x86_64
-cpu host -enable-kvm -machine "q35,accel=$ACCEL"
-device virtio-vga
-usb -device usb-tablet
)
fi

"${machine_args[@]}" \
-smp "$CPUS" \
-m "$MEMORY" \
-drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \
-drive if=pflash,format=raw,file="$BASE_OVMF" \
-drive file="$disk",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0,bootindex=1 \
-device virtio-vga \
-display none \
-vnc 127.0.0.1:5 \
-usb -device usb-tablet \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \
-device virtio-net-pci,netdev=net0 \
-qmp "unix:$QMP_SOCK,server,nowait" \
Expand Down Expand Up @@ -255,7 +339,7 @@ wait_for_screen() {
fi

if ((waited >= timeout)); then
slug=${text,,}
slug=$(printf '%s' "$text" | tr '[:upper:]' '[:lower:]') # macOS bash 3 has no ${var,,}
slug=${slug// /-}
slug=${slug//[^a-z0-9-]/}
capture_console "failure-installer-waiting-for-$slug"
Expand Down Expand Up @@ -290,7 +374,7 @@ type_text() {
ch=${text:i:1}
case "$ch" in
[a-z0-9]) press "$ch" ;;
[A-Z]) press "shift-${ch,,}" ;;
[A-Z]) press "shift-$(printf '%s' "$ch" | tr '[:upper:]' '[:lower:]')" ;;
" ") press spc ;;
.) press dot ;;
,) press comma ;;
Expand Down Expand Up @@ -990,9 +1074,20 @@ install_phase() {
qemu-img create -f qcow2 "$BASE_DISK" 40G >/dev/null
cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF"

start_vm "$BASE_DISK" "$RUN_DIR/install-serial.log" \
-drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \
-device ide-cd,drive=cdrom0,bootindex=2
# The ARM virt machine has no IDE; the ISO rides a virtio-scsi CD there.
local -a iso_boot_args=(
-drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0"
)
if [[ $GUEST_ARCH == "aarch64" ]]; then
iso_boot_args+=(
-device virtio-scsi-pci,id=scsi0
-device scsi-cd,drive=cdrom0,bus=scsi0.0,bootindex=2
)
else
iso_boot_args+=(-device ide-cd,drive=cdrom0,bootindex=2)
fi

start_vm "$BASE_DISK" "$RUN_DIR/install-serial.log" "${iso_boot_args[@]}"

drive_configurator
wait_for_install
Expand Down
Loading