From 4e7b18eb7d44c012fa6fe3daed4fbe8abe0c58ba Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 18:56:35 +0200 Subject: [PATCH 01/31] Scripts that turn an installed Omarchy disk into a downloadable VM image As they were tested by hand on the 4.0.4 ISO, before any of it is wired into a workflow: vm-install unattended install of an ISO into a qcow2, through cidata vm-seal makes the disk publishable: SSH takes keys only, no machine ID, host keys, build key or build logs left, compressed with zstd, with checksums and the kernel pulled out for direct boot vm-verify boots the sealed image once and checks all of that vm-boot boots a throwaway copy with a key made for that boot and runs a command in it On this machine: install 114 s, seal 27 s, a 4.2 GB image, SSH about 5 s after power-on, 17 of 17 verify checks, and 7 of them failing on an image sealed without these fixes. --- test/vm-image/vm-boot | 75 +++++++++++++++++++++ test/vm-image/vm-install | 90 +++++++++++++++++++++++++ test/vm-image/vm-seal | 142 +++++++++++++++++++++++++++++++++++++++ test/vm-image/vm-verify | 48 +++++++++++++ 4 files changed, 355 insertions(+) create mode 100755 test/vm-image/vm-boot create mode 100755 test/vm-image/vm-install create mode 100755 test/vm-image/vm-seal create mode 100755 test/vm-image/vm-verify diff --git a/test/vm-image/vm-boot b/test/vm-image/vm-boot new file mode 100755 index 0000000..86e059c --- /dev/null +++ b/test/vm-image/vm-boot @@ -0,0 +1,75 @@ +#!/bin/bash +# Boots a sealed Omarchy image (vm-seal) as a throwaway machine. +# +# vm-boot boot, print how to log in, run until Ctrl-C +# vm-boot boot, run the command over SSH, power off; +# exits with the command's status +# +# Nothing is written to sealed_dir: the machine runs on a copy-on-write overlay +# that is deleted on exit. Each boot gets a new SSH key and a new machine ID; +# the image itself holds no key and its SSH takes no password. +# +# Needs qemu-system-x86_64, qemu-img, ssh and a usable /dev/kvm. +# Env: SMP (4), MEM (4G), SSH_PORT (random), VM_UUID (random), APPEND (more +# kernel arguments), QEMU_EXTRA (more qemu arguments, split on spaces), +# VM_STATE_DIR (/var/tmp; holds the overlay, so it needs room to grow). +set -euo pipefail +[[ $# -ge 1 ]] || { sed -n '2,16p' "$0" >&2; exit 2; } +base=$(realpath "$1"); shift +for f in base.qcow2 vmlinuz initrd cmdline; do + [[ -s $base/$f ]] || { echo "vm-boot: $base/$f is missing" >&2; exit 1; } +done +[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "vm-boot: /dev/kvm is not usable" >&2; exit 1; } + +state=$(mktemp -d "${VM_STATE_DIR:-/var/tmp}/vm.XXXXXX") +cleanup() { + local status=$? pid + if [[ -f $state/qemu.pid ]]; then + pid=$(cat "$state/qemu.pid") + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done + fi + rm -rf "$state" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT TERM +port=${SSH_PORT:-$(( 20000 + RANDOM % 10000 ))} +uuid=${VM_UUID:-$(cat /proc/sys/kernel/random/uuid)} + +# A key made for this boot only, handed in through the firmware tables as a +# systemd credential that writes authorized_keys. +ssh-keygen -q -t ed25519 -N '' -C vm-boot -f "$state/id_ed25519" +keys=$(printf 'd /home/omarchy/.ssh 0700 omarchy omarchy -\nf+ /home/omarchy/.ssh/authorized_keys 0600 omarchy omarchy - %s\n' \ + "$(cat "$state/id_ed25519.pub")" | base64 -w0) +ssh_opts=(-i "$state/id_ed25519" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR) + +qemu-img create -qf qcow2 -b "$base/base.qcow2" -F qcow2 "$state/run.qcow2" +t0=$(date +%s%N) +# shellcheck disable=SC2086 +qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp "${SMP:-4}" -m "${MEM:-4G}" \ + -kernel "$base/vmlinuz" -initrd "$base/initrd" -append "$(cat "$base/cmdline")${APPEND:+ $APPEND}" \ + -uuid "$uuid" \ + -drive "file=$state/run.qcow2,format=qcow2,if=none,id=d0" -device virtio-blk-pci,drive=d0 \ + -smbios "type=11,value=io.systemd.credential.binary:tmpfiles.extra=$keys" \ + -device virtio-vga -device virtio-rng-pci -display none \ + -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \ + -qmp "unix:$state/qmp.sock,server,nowait" -pidfile "$state/qemu.pid" -daemonize ${QEMU_EXTRA:-} + +# /dev/null; do + kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null || { echo "vm-boot: qemu exited before ssh came up" >&2; exit 1; } + (( ($(date +%s%N) - t0) / 1000000 < 300000 )) || { echo "vm-boot: no ssh after 300s" >&2; exit 1; } + sleep 0.2 +done +echo "vm-boot: ssh up after $(( ($(date +%s%N) - t0) / 1000000 )) ms, machine ID ${uuid//-/}" >&2 + +if [[ $# -gt 0 ]]; then + # Stdin passes through, so `vm-boot dir bash -s /dev/null; do sleep 1; done +fi diff --git a/test/vm-image/vm-install b/test/vm-image/vm-install new file mode 100755 index 0000000..ef63cd9 --- /dev/null +++ b/test/vm-image/vm-install @@ -0,0 +1,90 @@ +#!/bin/bash +# Unattended install of an Omarchy ISO into a qcow2, through a cidata drive. +# +# vm-install +# +# out_dir gets base.qcow2 (the installed disk, powered off), OVMF_VARS.4m.fd +# and the key that can log in to it. vm-seal turns that into a reusable image. +# Same answer files and VM shape as omarchy-iso's test/integration.d/base-test.sh. +set -uo pipefail +[[ $# -eq 2 ]] || { echo "Usage: vm-install " >&2; exit 2; } +ISO=$(realpath "$1"); OUT=$(realpath -m "$2"); W=$OUT/work +# Arch and Ubuntu keep the firmware in different places. +for fw in /usr/share/edk2/x64/OVMF_CODE.4m.fd:/usr/share/edk2/x64/OVMF_VARS.4m.fd /usr/share/OVMF/OVMF_CODE_4M.fd:/usr/share/OVMF/OVMF_VARS_4M.fd; do + [[ -f ${fw%%:*} && -f ${fw##*:} ]] && { FW_CODE=${fw%%:*}; FW_VARS=${fw##*:}; break; } +done +: "${FW_CODE:?no OVMF firmware found}" +[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "vm-install: /dev/kvm is not usable" >&2; exit 1; } +GUEST_USER=omarchy; GUEST_PASSWORD=omarchy; GUEST_HOSTNAME=omarchy-vm; SSH_PORT=2422 +rm -rf "$W"; mkdir -p "$W/cidata" "$W/shots"; t0=$(date +%s); el() { echo $(( $(date +%s) - t0 )); } +ssh-keygen -q -t ed25519 -N '' -C omarchy-vm-build -f "$W/id_ed25519" +hash=$(openssl passwd -6 "$GUEST_PASSWORD") +disk_bytes=$((40*1024**3)); mib=$((1024**2)); boot_start=$mib; boot_size=$((2*1024**3)); main_start=$((boot_size+boot_start)); main_size=$((disk_bytes-main_start-mib)) +jq -n --arg h "$hash" --arg u "$GUEST_USER" '{root_enc_password:$h, users:[{enc_password:$h, groups:[], sudo:true, username:$u}]}' > "$W/cidata/user_credentials.json" +cat > "$W/cidata/user_configuration.json" </dev/null || { echo "bad json"; exit 1; } +echo "Omarchy Test" > "$W/cidata/user_full_name.txt"; echo "test@omarchy.org" > "$W/cidata/user_email_address.txt"; echo false > "$W/cidata/user_encrypt_installation.txt" +cp "$W/id_ed25519.pub" "$W/cidata/authorized_keys" +truncate -s 4M "$W/cidata.img"; mkfs.vfat -n CIDATA "$W/cidata.img" >/dev/null; mcopy -i "$W/cidata.img" "$W"/cidata/* ::/ +qemu-img create -f qcow2 "$W/disk.qcow2" 40G >/dev/null; cp "$FW_VARS" "$W/OVMF_VARS.4m.fd" +qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp 8 -m 8G \ + -drive if=pflash,format=raw,readonly=on,file="$FW_CODE" -drive if=pflash,format=raw,file="$W/OVMF_VARS.4m.fd" \ + -drive file="$W/disk.qcow2",format=qcow2,if=none,id=drive0 -device virtio-blk-pci,drive=drive0,bootindex=1 \ + -device virtio-vga -display none -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:$W/qmp.sock,server,nowait" -serial "file:$W/serial.log" -pidfile "$W/qemu.pid" -daemonize \ + -drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0,readonly=on" -device ide-cd,drive=cdrom0,bootindex=2 \ + -drive "file=$W/cidata.img,format=raw,if=none,id=cidata" -device usb-storage,drive=cidata || exit 1 +qmp() { printf '{"execute":"qmp_capabilities"}\n%s\n' "$1" | socat - "unix-connect:$W/qmp.sock" >/dev/null 2>&1; } +shot() { qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$W/shots/$1.png\",\"format\":\"png\"}}"; } +sshg() { ssh -i "$W/id_ed25519" -p $SSH_PORT -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 -o LogLevel=ERROR $GUEST_USER@127.0.0.1 "$@"; } +echo "install started"; n=0 +while :; do + sshg true 2>/dev/null && { echo "ssh_up $(el)s"; break; } + kill -0 "$(cat "$W/qemu.pid")" 2>/dev/null || { echo "qemu exited at $(el)s"; exit 1; } + (( $(el) > 2400 )) && { shot timeout; echo "timeout"; exit 1; } + n=$((n+1)); (( n % 6 == 0 )) && { shot "$(printf 'p-%04d' "$(el)")"; sleep 1 + txt=$(tesseract "$W/shots/$(printf 'p-%04d' "$(el)")".png - 2>/dev/null || true) + grep -qi "Reboot Now" <<<"$txt" && { echo "reboot prompt $(el)s"; qmp '{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}'; } + grep -qi "installation stopped" <<<"$txt" && { echo "install stopped $(el)s"; exit 1; } + echo "... $(el)s"; } + sleep 10 +done +sshg 'timeout 180 systemctl is-system-running --wait; systemd-analyze; cat /etc/os-release | head -3; pacman -Q omarchy linux-omarchy 2>&1; cat ~/.local/share/omarchy/version 2>/dev/null' > "$W/first-boot.txt" 2>&1 +sshg 'sudo -n cat /var/log/omarchy-install-timing.json' > "$W/install-timing.json" 2>/dev/null +shot first-boot; echo "first_boot_checked $(el)s"; cat "$W/first-boot.txt" +# Power off cleanly, so the disk is consistent, and leave the three files vm-seal reads. +printf '%s\n' "$GUEST_PASSWORD" | sshg "sudo -S -p '' systemctl poweroff" 2>/dev/null || true +pid=$(cat "$W/qemu.pid"); for i in $(seq 1 120); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done +kill -0 "$pid" 2>/dev/null && { echo "vm-install: the VM did not power off" >&2; exit 1; } +mv "$W/disk.qcow2" "$OUT/base.qcow2"; cp "$W/OVMF_VARS.4m.fd" "$W/id_ed25519" "$W/id_ed25519.pub" "$OUT/" +echo "installed: $OUT/base.qcow2 ($(du -sh "$OUT/base.qcow2" | cut -f1)) in $(el)s" diff --git a/test/vm-image/vm-seal b/test/vm-image/vm-seal new file mode 100755 index 0000000..683b27b --- /dev/null +++ b/test/vm-image/vm-seal @@ -0,0 +1,142 @@ +#!/bin/bash +# Turns an installed Omarchy disk (vm-install) into an image anyone can +# download and boot: every boot is its own machine, nothing of the build is +# left in it, and it boots straight into its kernel, so a boot takes seconds. +# +# vm-seal +# +# installed_dir holds base.qcow2, OVMF_VARS.4m.fd and id_ed25519. sealed_dir +# gets a standalone, zstd-compressed base.qcow2 and the UKI's vmlinuz, initrd +# and cmdline. It holds no key: each boot is handed one (see vm-boot). +# +# The image keeps the installer's user and password (GUEST_PASSWORD, default +# omarchy) for the desktop and sudo. SSH takes keys only. +set -euo pipefail +[[ $# -eq 2 ]] || { echo "Usage: vm-seal " >&2; exit 2; } +in=$(realpath "$1") out=$(realpath -m "$2") +pw=${GUEST_PASSWORD:-omarchy} +for fw in /usr/share/edk2/x64/OVMF_CODE.4m.fd /usr/share/OVMF/OVMF_CODE_4M.fd; do + [[ -f $fw ]] && { fw_code=$fw; break; } +done +: "${fw_code:?no OVMF firmware found}" + +state=$(mktemp -d /var/tmp/seal.XXXXXX) +cleanup() { + local status=$? pid + if [[ -f $state/qemu.pid ]]; then + pid=$(cat "$state/qemu.pid") + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done + fi + rm -rf "$state" + exit "$status" +} +trap cleanup EXIT +port=$(( 30000 + RANDOM % 5000 )) + +ssh_vm() { + ssh -i "$in/id_ed25519" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=2 -o LogLevel=ERROR omarchy@127.0.0.1 "$@" +} +# Runs a script as root in the guest and fails if any line of it fails. The +# password goes to sudo on stdin, never into a command line. +root() { + printf '%s\n' "$pw" | ssh_vm "sudo -S -p '' bash -ec $(printf %q "trap 'echo \"vm-seal: failed in the guest at: \$BASH_COMMAND\" >&2' ERR +$1")" +} + +qemu-img create -qf qcow2 -b "$in/base.qcow2" -F qcow2 "$state/edit.qcow2" +cp "$in/OVMF_VARS.4m.fd" "$state/vars.fd" +qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp 4 -m 4G \ + -drive if=pflash,format=raw,readonly=on,file="$fw_code" \ + -drive if=pflash,format=raw,file="$state/vars.fd" \ + -drive "file=$state/edit.qcow2,format=qcow2,if=virtio,discard=unmap" -display none \ + -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \ + -pidfile "$state/qemu.pid" -daemonize +for i in $(seq 1 150); do + ssh_vm true 2>/dev/null && break + (( i < 150 )) || { echo "vm-seal: no ssh on the installed disk" >&2; exit 1; } + sleep 2 +done +# Let first-boot work finish before taking it apart. +ssh_vm 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true' + +mkdir -p "$out" +root 'cat /boot/EFI/Linux/omarchy_linux-omarchy.efi' >"$state/uki.efi" + +root ' + # The ISO writes autologin only for encrypted installs; a test has nobody at + # SDDM, and a desktop check that skips for want of a session reads as a pass. + printf "[Autologin]\nUser=omarchy\nSession=omarchy.desktop\n" >/etc/sddm.conf.d/autologin.conf + + # The image is public and its password is too, so SSH takes keys only. sshd + # keeps the first value it reads, hence a name that sorts first. + printf "PasswordAuthentication no\nKbdInteractiveAuthentication no\nAuthenticationMethods publickey\n" \ + >/etc/ssh/sshd_config.d/00-omarchy-vm.conf + ssh-keygen -A >/dev/null # sshd -T needs host keys to parse; they go below + sshd -T | grep -qix "passwordauthentication no" + sshd -T | grep -qix "authenticationmethods publickey" + + # Empty, not "uninitialized": that string marks a first boot, and a first boot + # applies unit presets, which on Arch disable everything not listed. Empty lets + # systemd take the VM SMBIOS UUID (qemu -uuid) as this boot machine ID. + # Host keys come back from sshdgenkeys.service before sshd starts. + : >/etc/machine-id + rm -f /var/lib/dbus/machine-id /etc/ssh/ssh_host_* + rm -f /home/omarchy/.ssh/authorized_keys /home/omarchy/.bash_history /root/.bash_history + + # The factory snapshot the installer took carries the same machine ID. + mkdir -p /run/seal-top + mount -o subvolid=5 "$(findmnt -no SOURCE / | sed "s/\[.*//")" /run/seal-top + if [ -f /run/seal-top/@factory/etc/machine-id ]; then + ro=$(btrfs property get -ts /run/seal-top/@factory ro) + [ "$ro" = ro=false ] || btrfs property set -ts /run/seal-top/@factory ro false + : >/run/seal-top/@factory/etc/machine-id + [ "$ro" = ro=false ] || btrfs property set -ts /run/seal-top/@factory ro true + fi + umount /run/seal-top + + # Stopping the unit saves a seed; with it stopped, shutdown saves none. + systemctl stop systemd-random-seed.service + rm -f /var/lib/systemd/random-seed /var/lib/systemd/credential.secret + + # Leases and the key NetworkManager derives stable addresses from. + systemctl stop NetworkManager.service + rm -f /var/lib/NetworkManager/*.lease /var/lib/NetworkManager/secret_key \ + /var/lib/NetworkManager/seen-bssids /var/lib/NetworkManager/timestamps \ + /var/lib/NetworkManager/NetworkManager.state + + # Vacuuming leaves the active journal. Send journald to /run, then the + # build logs on disk can go, and shutdown writes nothing back. + journalctl --relinquish-var + rm -rf /var/log/journal/* + + test ! -s /etc/machine-id + test -z "$(ls /etc/ssh/ssh_host_* /var/lib/NetworkManager/*.lease /var/log/journal/* 2>/dev/null)" + test ! -e /home/omarchy/.ssh/authorized_keys + test ! -e /var/lib/systemd/random-seed + + sync + # Hand freed blocks back to the disk image, so the copy skips them. + fstrim -av >/dev/null + (sleep 1; systemctl poweroff) >/dev/null 2>&1 &' + +# The key that let us in is gone, so the poweroff above was the last word. +pid=$(cat "$state/qemu.pid") +for _ in $(seq 1 120); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done +if kill -0 "$pid" 2>/dev/null; then echo "vm-seal: the VM did not power off" >&2; exit 1; fi +rm -f "$state/qemu.pid" + +# Direct boot skips the firmware and Limine, which spend most of a boot reading +# and hashing the UKI. resume= points at the image's own swap, so drop it. +objcopy -O binary --only-section=.linux "$state/uki.efi" "$out/vmlinuz" +objcopy -O binary --only-section=.initrd "$state/uki.efi" "$out/initrd" +objcopy -O binary --only-section=.cmdline "$state/uki.efi" "$state/cmdline" +tr -d '\0' <"$state/cmdline" | tr ' ' '\n' | { grep -vE '^(resume|resume_offset)=|^$' || true; } | paste -sd' ' >"$out/cmdline" +[[ -s $out/vmlinuz && -s $out/initrd && -s $out/cmdline ]] || { echo "vm-seal: the UKI gave no kernel, initrd or cmdline" >&2; exit 1; } + +# zstd clusters: QEMU reads them as they are, so the download boots as it is. +qemu-img convert -c -O qcow2 -o compression_type=zstd -m 16 -W "$state/edit.qcow2" "$out/base.qcow2" +( cd "$out" && sha256sum base.qcow2 vmlinuz initrd cmdline >SHA256SUMS ) +echo "sealed: $out ($(du -sh "$out/base.qcow2" | cut -f1)); cmdline: $(cat "$out/cmdline")" diff --git a/test/vm-image/vm-verify b/test/vm-image/vm-verify new file mode 100755 index 0000000..dba443f --- /dev/null +++ b/test/vm-image/vm-verify @@ -0,0 +1,48 @@ +#!/bin/bash +# Boots a sealed image once, as a throwaway machine, and checks that it is +# what vm-seal promises: a desktop that comes up, nothing of the build left +# in it, and SSH that takes keys only. Exits non-zero on any failed check. +# +# vm-verify +set -euo pipefail +[[ $# -eq 1 ]] || { echo "Usage: vm-verify " >&2; exit 2; } +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +base=$(realpath "$1") +( cd "$base" && sha256sum --quiet -c SHA256SUMS ) +uuid=$(cat /proc/sys/kernel/random/uuid) +export VM_UUID=$uuid SSH_PORT=$(( 20000 + RANDOM % 10000 )) + +VM_PW=${GUEST_PASSWORD:-omarchy} "$here/vm-boot" "$base" "WANT_ID=${uuid//-/} PW=${GUEST_PASSWORD:-omarchy} bash -s" <<'GUEST' +fail=0 +check() { # description, then a command that must succeed + local what=$1; shift + if "$@" >/dev/null 2>&1; then echo "ok $what"; else echo "FAIL $what"; fail=1; fi +} +root() { printf '%s\n' "$PW" | sudo -S -p '' "$@"; } +booted=$(( $(date +%s) - $(cut -d. -f1 /proc/uptime) - 5 )) +newer() { [ "$(root stat -c %Y "$1")" -ge "$booted" ]; } # written by this boot + +check "the system reaches running or degraded" \ + sh -c 's=$(timeout 120 systemctl is-system-running --wait); [ "$s" = running ] || [ "$s" = degraded ]' +check "no unit failed" sh -c '[ -z "$(systemctl --failed --no-legend)" ]' +check "a desktop session is logged in" sh -c 'for i in $(seq 1 60); do pgrep -x Hyprland && exit 0; sleep 0.5; done; exit 1' +check "the machine ID is the one this boot was given" [ "$(cat /etc/machine-id)" = "$WANT_ID" ] +check "the journal holds this machine only" [ "$(root ls /var/log/journal | tr '\n' ' ')" = "$WANT_ID " ] +check "SSH refuses passwords" sh -c "printf '%s\n' '$PW' | sudo -S -p '' sshd -T | grep -qix 'passwordauthentication no'" +check "SSH requires a key" sh -c "printf '%s\n' '$PW' | sudo -S -p '' sshd -T | grep -qix 'authenticationmethods publickey'" +# Asking with no method makes sshd list the ones it would take. +check "sshd offers a key as the only way in" \ + sh -c 'ssh -o PreferredAuthentications=none -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null omarchy@127.0.0.1 true 2>&1 | grep -qF "Permission denied (publickey)."' +check "authorized_keys holds one key, this boot's" [ "$(grep -c . ~/.ssh/authorized_keys)" = 1 ] +check "SSH host keys were made by this boot" newer /etc/ssh/ssh_host_ed25519_key +check "NetworkManager's key was made by this boot" newer /var/lib/NetworkManager/secret_key +check "one DHCP lease, this boot's" [ "$(root sh -c 'ls /var/lib/NetworkManager/*.lease | wc -l')" = 1 ] +check "no shell history" sh -c '! [ -e ~/.bash_history ]' +check "the package cache is empty" [ -z "$(ls /var/cache/pacman/pkg)" ] +root mkdir -p /run/top && root mount -o subvolid=5,ro "$(findmnt -no SOURCE / | sed 's/\[.*//')" /run/top +check "the factory snapshot has no machine ID" sh -c "! printf '%s\n' '$PW' | sudo -S -p '' test -s /run/top/@factory/etc/machine-id" +check "the factory snapshot has no SSH host keys" sh -c "[ -z \"\$(printf '%s\n' '$PW' | sudo -S -p '' sh -c 'ls /run/top/@factory/etc/ssh/ssh_host_* 2>/dev/null')\" ]" +check "no snapshots besides the current system" [ -z "$(root ls /.snapshots)" ] +echo "omarchy $(pacman -Q omarchy 2>/dev/null | cut -d' ' -f2), kernel $(uname -r), $(systemd-analyze | head -1)" +exit $fail +GUEST From 466a7a8221d4e45a60f28c682545f4658fbd59d4 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:06:48 +0200 Subject: [PATCH 02/31] Integration harness: an edge ISO installs the -dev packages builder/build-iso.sh puts omarchy-dev and omarchy-settings-dev on an edge ISO, as it does on a dev or local one. The harness picked its package names from the ISO's file name and knew dev, local and pr, so for an edge ISO it asked the installer for the release packages. --- test/integration.d/base-test.sh | 5 +- test/vm-image/vm-install | 90 --------------------------------- 2 files changed, 3 insertions(+), 92 deletions(-) delete mode 100755 test/vm-image/vm-install diff --git a/test/integration.d/base-test.sh b/test/integration.d/base-test.sh index b9d4ab3..9b20e6b 100644 --- a/test/integration.d/base-test.sh +++ b/test/integration.d/base-test.sh @@ -503,11 +503,12 @@ EOF mcopy -i "$CIDATA_IMG" "$dir"/* ::/ } -# The dev/local ISO installs the -dev packages; a stable ISO the plain ones. +# The edge/dev/local ISO installs the -dev packages, as builder/build-iso.sh +# picks them; a stable ISO the plain ones. detect_packages() { RUNTIME_PACKAGE=omarchy-dev SETTINGS_PACKAGE=omarchy-settings-dev - if [[ $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then + if [[ $(basename "$ISO") != *edge* && $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then RUNTIME_PACKAGE=omarchy SETTINGS_PACKAGE=omarchy-settings fi diff --git a/test/vm-image/vm-install b/test/vm-image/vm-install deleted file mode 100755 index ef63cd9..0000000 --- a/test/vm-image/vm-install +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/bash -# Unattended install of an Omarchy ISO into a qcow2, through a cidata drive. -# -# vm-install -# -# out_dir gets base.qcow2 (the installed disk, powered off), OVMF_VARS.4m.fd -# and the key that can log in to it. vm-seal turns that into a reusable image. -# Same answer files and VM shape as omarchy-iso's test/integration.d/base-test.sh. -set -uo pipefail -[[ $# -eq 2 ]] || { echo "Usage: vm-install " >&2; exit 2; } -ISO=$(realpath "$1"); OUT=$(realpath -m "$2"); W=$OUT/work -# Arch and Ubuntu keep the firmware in different places. -for fw in /usr/share/edk2/x64/OVMF_CODE.4m.fd:/usr/share/edk2/x64/OVMF_VARS.4m.fd /usr/share/OVMF/OVMF_CODE_4M.fd:/usr/share/OVMF/OVMF_VARS_4M.fd; do - [[ -f ${fw%%:*} && -f ${fw##*:} ]] && { FW_CODE=${fw%%:*}; FW_VARS=${fw##*:}; break; } -done -: "${FW_CODE:?no OVMF firmware found}" -[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "vm-install: /dev/kvm is not usable" >&2; exit 1; } -GUEST_USER=omarchy; GUEST_PASSWORD=omarchy; GUEST_HOSTNAME=omarchy-vm; SSH_PORT=2422 -rm -rf "$W"; mkdir -p "$W/cidata" "$W/shots"; t0=$(date +%s); el() { echo $(( $(date +%s) - t0 )); } -ssh-keygen -q -t ed25519 -N '' -C omarchy-vm-build -f "$W/id_ed25519" -hash=$(openssl passwd -6 "$GUEST_PASSWORD") -disk_bytes=$((40*1024**3)); mib=$((1024**2)); boot_start=$mib; boot_size=$((2*1024**3)); main_start=$((boot_size+boot_start)); main_size=$((disk_bytes-main_start-mib)) -jq -n --arg h "$hash" --arg u "$GUEST_USER" '{root_enc_password:$h, users:[{enc_password:$h, groups:[], sudo:true, username:$u}]}' > "$W/cidata/user_credentials.json" -cat > "$W/cidata/user_configuration.json" </dev/null || { echo "bad json"; exit 1; } -echo "Omarchy Test" > "$W/cidata/user_full_name.txt"; echo "test@omarchy.org" > "$W/cidata/user_email_address.txt"; echo false > "$W/cidata/user_encrypt_installation.txt" -cp "$W/id_ed25519.pub" "$W/cidata/authorized_keys" -truncate -s 4M "$W/cidata.img"; mkfs.vfat -n CIDATA "$W/cidata.img" >/dev/null; mcopy -i "$W/cidata.img" "$W"/cidata/* ::/ -qemu-img create -f qcow2 "$W/disk.qcow2" 40G >/dev/null; cp "$FW_VARS" "$W/OVMF_VARS.4m.fd" -qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp 8 -m 8G \ - -drive if=pflash,format=raw,readonly=on,file="$FW_CODE" -drive if=pflash,format=raw,file="$W/OVMF_VARS.4m.fd" \ - -drive file="$W/disk.qcow2",format=qcow2,if=none,id=drive0 -device virtio-blk-pci,drive=drive0,bootindex=1 \ - -device virtio-vga -display none -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:$W/qmp.sock,server,nowait" -serial "file:$W/serial.log" -pidfile "$W/qemu.pid" -daemonize \ - -drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0,readonly=on" -device ide-cd,drive=cdrom0,bootindex=2 \ - -drive "file=$W/cidata.img,format=raw,if=none,id=cidata" -device usb-storage,drive=cidata || exit 1 -qmp() { printf '{"execute":"qmp_capabilities"}\n%s\n' "$1" | socat - "unix-connect:$W/qmp.sock" >/dev/null 2>&1; } -shot() { qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$W/shots/$1.png\",\"format\":\"png\"}}"; } -sshg() { ssh -i "$W/id_ed25519" -p $SSH_PORT -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 -o LogLevel=ERROR $GUEST_USER@127.0.0.1 "$@"; } -echo "install started"; n=0 -while :; do - sshg true 2>/dev/null && { echo "ssh_up $(el)s"; break; } - kill -0 "$(cat "$W/qemu.pid")" 2>/dev/null || { echo "qemu exited at $(el)s"; exit 1; } - (( $(el) > 2400 )) && { shot timeout; echo "timeout"; exit 1; } - n=$((n+1)); (( n % 6 == 0 )) && { shot "$(printf 'p-%04d' "$(el)")"; sleep 1 - txt=$(tesseract "$W/shots/$(printf 'p-%04d' "$(el)")".png - 2>/dev/null || true) - grep -qi "Reboot Now" <<<"$txt" && { echo "reboot prompt $(el)s"; qmp '{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}'; } - grep -qi "installation stopped" <<<"$txt" && { echo "install stopped $(el)s"; exit 1; } - echo "... $(el)s"; } - sleep 10 -done -sshg 'timeout 180 systemctl is-system-running --wait; systemd-analyze; cat /etc/os-release | head -3; pacman -Q omarchy linux-omarchy 2>&1; cat ~/.local/share/omarchy/version 2>/dev/null' > "$W/first-boot.txt" 2>&1 -sshg 'sudo -n cat /var/log/omarchy-install-timing.json' > "$W/install-timing.json" 2>/dev/null -shot first-boot; echo "first_boot_checked $(el)s"; cat "$W/first-boot.txt" -# Power off cleanly, so the disk is consistent, and leave the three files vm-seal reads. -printf '%s\n' "$GUEST_PASSWORD" | sshg "sudo -S -p '' systemctl poweroff" 2>/dev/null || true -pid=$(cat "$W/qemu.pid"); for i in $(seq 1 120); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done -kill -0 "$pid" 2>/dev/null && { echo "vm-install: the VM did not power off" >&2; exit 1; } -mv "$W/disk.qcow2" "$OUT/base.qcow2"; cp "$W/OVMF_VARS.4m.fd" "$W/id_ed25519" "$W/id_ed25519.pub" "$OUT/" -echo "installed: $OUT/base.qcow2 ($(du -sh "$OUT/base.qcow2" | cut -f1)) in $(el)s" From c0a0e787f30cdc6e1fdbc89ef9441ba25095c9c4 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:06:48 +0200 Subject: [PATCH 03/31] VM image: seal the install test's own base image test/integration already installs the ISO unattended and saves the installed disk as test-runs/-integration/base.qcow2, with its firmware variables and the key that logs in to it. That folder is what vm-seal takes, so the separate install script goes. The sealed image now also carries the firmware variables, which hold the boot entry the installer registered: a consumer that boots the disk through UEFI needs them, a direct kernel boot does not. --- test/vm-image/vm-seal | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/vm-image/vm-seal b/test/vm-image/vm-seal index 683b27b..322df27 100755 --- a/test/vm-image/vm-seal +++ b/test/vm-image/vm-seal @@ -1,13 +1,16 @@ #!/bin/bash -# Turns an installed Omarchy disk (vm-install) into an image anyone can +# Turns an installed Omarchy disk (test/integration's base image) into an image anyone can # download and boot: every boot is its own machine, nothing of the build is # left in it, and it boots straight into its kernel, so a boot takes seconds. # # vm-seal # -# installed_dir holds base.qcow2, OVMF_VARS.4m.fd and id_ed25519. sealed_dir -# gets a standalone, zstd-compressed base.qcow2 and the UKI's vmlinuz, initrd -# and cmdline. It holds no key: each boot is handed one (see vm-boot). +# installed_dir holds base.qcow2, OVMF_VARS.4m.fd and id_ed25519, which is +# what test/integration leaves in test-runs/-integration once its install +# has passed. sealed_dir gets a standalone, zstd-compressed base.qcow2, the +# firmware variables that boot it through UEFI, and the UKI's vmlinuz, initrd +# and cmdline for booting it directly. It holds no key: each boot is handed +# one (see vm-boot). # # The image keeps the installer's user and password (GUEST_PASSWORD, default # omarchy) for the desktop and sudo. SSH takes keys only. @@ -138,5 +141,8 @@ tr -d '\0' <"$state/cmdline" | tr ' ' '\n' | { grep -vE '^(resume|resume_offset) # zstd clusters: QEMU reads them as they are, so the download boots as it is. qemu-img convert -c -O qcow2 -o compression_type=zstd -m 16 -W "$state/edit.qcow2" "$out/base.qcow2" -( cd "$out" && sha256sum base.qcow2 vmlinuz initrd cmdline >SHA256SUMS ) +# The variables hold the boot entry the installer registered; a firmware boot +# of this disk needs them, a direct kernel boot does not. +cp "$in/OVMF_VARS.4m.fd" "$out/OVMF_VARS.4m.fd" +( cd "$out" && sha256sum base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline >SHA256SUMS ) echo "sealed: $out ($(du -sh "$out/base.qcow2" | cut -f1)); cmdline: $(cat "$out/cmdline")" From d90119f5d8f0a4f98c90d909a445312542b035c9 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:06:48 +0200 Subject: [PATCH 04/31] Nightly: build edge, test the install, publish the ISO and a VM image The nightly built the stable ISO and kept it as an artifact for a day. It now builds the edge ISO, runs the install test on it, seals the installed disk that test leaves behind into a VM image, checks the image, and publishes ISO and image under a version that is never reused: nightly.omarchy.org/edge/latest.json nightly.omarchy.org/edge//omarchy-edge-.iso nightly.omarchy.org/edge//vm/ latest.json gives the version, the ISO's URL and checksum, the omarchy-iso commit, the Omarchy version and commit inside, and where the image is. It is written last, after every file has been fetched back through the CDN and compared. Publishing is a second job, the only one with the bucket's key, and only on quattro. There is no pull_request trigger. A manual run on a branch builds and checks everything and publishes nothing. --- .github/workflows/nightly-build.yml | 236 +++++++++++++++++++++++++--- 1 file changed, 216 insertions(+), 20 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index caac03e..448db55 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -1,35 +1,231 @@ name: Nightly ISO Build +# Every night: builds the edge ISO, installs it unattended and boots what it +# installed (the install test), turns that installed disk into a VM image, and +# publishes all of it with a latest.json saying what is in it. +# +# https://nightly.omarchy.org/edge/latest.json +# https://nightly.omarchy.org/edge//omarchy-edge-.iso +# https://nightly.omarchy.org/edge//vm/ base.qcow2 and what boots it +# +# Nothing is published unless the ISO installed and the image passed its +# checks. A version's files are written once and never replaced: the CDN keeps +# them for a year. Only latest.json changes, and it is written last. +# +# No pull_request trigger: this workflow holds the key to the bucket, so it +# never runs for a fork. To try a change, run it by hand on a branch; that +# builds and checks everything and publishes nothing. + on: schedule: - cron: "1 0 * * *" # Daily at 00:01 UTC - workflow_dispatch: # Allow manual triggering + workflow_dispatch: + inputs: + publish: + description: "Publish to nightly.omarchy.org (only from quattro)" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: false + +env: + CHANNEL: edge + SITE: https://nightly.omarchy.org jobs: - build-iso: - runs-on: ubuntu-latest + build: + name: Build, install, seal + runs-on: ubuntu-24.04 + timeout-minutes: 90 + outputs: + version: ${{ steps.version.outputs.version }} steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + # The date says when, the run number keeps two builds of one day apart: + # a published name is never reused. + - id: version + run: echo "version=$(date -u +%Y.%m.%d).${{ github.run_number }}" | tee -a "$GITHUB_OUTPUT" - - name: Free Disk Space - uses: jlumbroso/free-disk-space@v1.3.1 + - name: Check disk space + run: | + df -h / + avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9) + [ "$avail_gb" -ge 50 ] || { echo "::error::only ${avail_gb} GB free, the build, the install test and the image need ~50 GB"; exit 1; } - - name: Build ISO - run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - - name: Delete previously uploaded ISO artifact - uses: philips-labs/action-delete-artifacts@v1.0.0 + - name: Build the edge ISO + run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make --edge + + # The same install test a pull request gets (install-test.yml), then the + # image: the installed disk that test leaves behind, sealed and checked. + - name: Install unattended, boot the installed system, seal it + run: | + sudo chmod 0666 /dev/kvm + docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \ + -e OMARCHY_INTEGRATION_DISK_CACHE=none \ + archlinux:latest bash -euo pipefail -c ' + pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \ + imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl binutils >/dev/null + # test/integration installs its tools through Omarchy'"'"'s helper; they are installed above. + printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add + chmod 0755 /usr/local/bin/omarchy-pkg-add + qemu-system-x86_64 --version | head -1 + ./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800 + + base=$(ls -d test-runs/*-integration) + ./test/vm-image/vm-seal "$base" sealed + ./test/vm-image/vm-verify sealed | tee vm-verify.txt + ./test/vm-image/vm-boot sealed "pacman -Q omarchy-dev omarchy-settings-dev linux-omarchy; uname -r" >vm-packages.txt + cat vm-packages.txt + ' + + # The container ran as root; what it wrote belongs to root until here. + - name: Hand the results to the runner's user + if: always() + run: | + mkdir -p test-runs sealed + sudo chown -R "$(id -u):$(id -g)" test-runs sealed release vm-*.txt 2>/dev/null || true + + - name: Lay out the release and describe it + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + dir=out/$CHANNEL/$VERSION + iso=omarchy-$CHANNEL-$VERSION.iso + mkdir -p "$dir/vm" + mv release/*.iso "$dir/$iso" + (cd "$dir" && sha256sum "$iso" >"$iso.sha256") + mv sealed/* "$dir/vm/" + cp test/vm-image/vm-boot "$dir/vm/vm-boot" + (cd "$dir/vm" && sha256sum base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline vm-boot >SHA256SUMS) + + omarchy=$(awk '$1 == "omarchy-dev" { print $2 }' vm-packages.txt) + jq -n \ + --arg version "$VERSION" --arg channel "$CHANNEL" --arg site "$SITE/$CHANNEL/$VERSION" --arg iso "$iso" \ + --arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg sha256 "$(cut -d" " -f1 "$dir/$iso.sha256")" --argjson size "$(stat -c %s "$dir/$iso")" \ + --arg iso_commit "$GITHUB_SHA" \ + --arg omarchy_version "$omarchy" --arg omarchy_commit "$(sed -nE "s/.*\.g([0-9a-f]+)-.*/\1/p" <<<"$omarchy")" \ + --arg kernel "$(tail -n 1 vm-packages.txt)" \ + --arg disk_sha256 "$(awk '$2 == "base.qcow2" { print $1 }' "$dir/vm/SHA256SUMS")" \ + --argjson disk_size "$(stat -c %s "$dir/vm/base.qcow2")" \ + '{ + version: $version, channel: $channel, built_at: $built_at, + url: "\($site)/\($iso)", sha256: $sha256, size: $size, + iso_commit: $iso_commit, omarchy_version: $omarchy_version, omarchy_commit: $omarchy_commit, + kernel: $kernel, + vm: { + url: "\($site)/vm/", disk: "\($site)/vm/base.qcow2", disk_sha256: $disk_sha256, disk_size: $disk_size, + files: ["base.qcow2", "OVMF_VARS.4m.fd", "vmlinuz", "initrd", "cmdline", "vm-boot", "SHA256SUMS"], + user: "omarchy", password: "omarchy", + ssh: "keys only; vm-boot makes one for each boot" + } + }' | tee "out/$CHANNEL/latest.json" + + { + echo "## Nightly $CHANNEL $VERSION" + echo + echo "| File | Size |" + echo "| --- | ---: |" + (cd "$dir" && find . -type f -printf '| `%P` | %s |\n' | sort) | awk -F'|' '{ printf "|%s| %.0f MiB |\n", $2, $3 / 1048576 }' + echo + echo '```' + cat vm-verify.txt + echo '```' + } >>"$GITHUB_STEP_SUMMARY" + + - name: Keep the release for the publish job + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - workflow: nightly-build.yml - continue-on-error: true + name: nightly-${{ steps.version.outputs.version }} + path: out/ + compression-level: 0 + retention-days: 2 + if-no-files-found: error - - name: Upload newly built ISO artifact - uses: actions/upload-artifact@v4 + - name: Keep the install test's logs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: omarchy-iso-${{ github.run_number }} - path: ./release/*.iso - retention-days: 1 + name: nightly-install-test-${{ github.run_id }}-${{ github.run_attempt }} + path: | + test-runs/*/omarchy-install-timing.json + test-runs/*/omarchy-install.log + test-runs/*/first-boot-systemd-analyze.txt + test-runs/*/runs/**/*.png + test-runs/*/runs/**/*.txt + test-runs/*/runs/**/*.json + test-runs/*/runs/**/*.log + vm-verify.txt + vm-packages.txt + if-no-files-found: ignore + retention-days: 14 + + # The only job that holds the bucket's key, and it builds nothing: it copies + # what the job above made, checks what the CDN then serves, and only then + # points latest.json at it. + publish: + name: Publish + needs: build + if: github.ref == 'refs/heads/quattro' && (github.event_name == 'schedule' || inputs.publish) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + environment: nightly + env: + VERSION: ${{ needs.build.outputs.version }} + RCLONE_CONFIG_R2_TYPE: s3 + RCLONE_CONFIG_R2_PROVIDER: Cloudflare + RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + BUCKET: omarchy-nightly + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: nightly-${{ needs.build.outputs.version }} + path: out + + - name: rclone + run: | + [ -n "$RCLONE_CONFIG_R2_ACCESS_KEY_ID" ] || { echo "::error::the nightly environment has no bucket key"; exit 1; } + sudo apt-get update -qq && sudo apt-get install -y -qq rclone >/dev/null + rclone version | head -1 + + # --immutable: a name that exists with other bytes is an error, not an + # overwrite. + - name: Upload this version + run: | + rclone copy "out/$CHANNEL/$VERSION" "r2:$BUCKET/$CHANNEL/$VERSION" \ + --immutable --s3-no-check-bucket --transfers 8 --s3-upload-concurrency 8 --s3-chunk-size 64M --stats 30s --stats-one-line + + # Fetched the way a user fetches them. It proves what is served, and it + # is the first request the CDN sees for each file. + - name: Check what the CDN serves + run: | + set -euo pipefail + dir=out/$CHANNEL/$VERSION + check() { # url, expected sha256 + got=$(curl -fsSL --retry 3 "$1" | sha256sum | cut -d" " -f1) + [ "$got" = "$2" ] || { echo "::error::$1 serves $got, expected $2"; exit 1; } + echo "ok $1" + } + iso=$(basename "$dir"/*.iso) + check "$SITE/$CHANNEL/$VERSION/$iso" "$(cut -d" " -f1 "$dir/$iso.sha256")" + while read -r sum name; do + check "$SITE/$CHANNEL/$VERSION/vm/$name" "$sum" + done <"$dir/vm/SHA256SUMS" + + - name: Point latest.json at it + run: | + rclone copyto "out/$CHANNEL/latest.json" "r2:$BUCKET/$CHANNEL/latest.json" --s3-no-check-bucket + served=$(curl -fsSL "$SITE/$CHANNEL/latest.json?run=$GITHUB_RUN_ID" | jq -r .version) + [ "$served" = "$VERSION" ] || { echo "::error::latest.json serves version $served, expected $VERSION"; exit 1; } + echo "Published [$VERSION]($SITE/$CHANNEL/latest.json)" >>"$GITHUB_STEP_SUMMARY" From c6b1d2dadc476b8d612d4112a0c177176fc4e262 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:07:58 +0200 Subject: [PATCH 05/31] Build without pacman's download sandbox where the kernel has no Landlock pacman 7 sandboxes its downloads with Landlock and fails every database sync on a kernel without it, which Namespace's AmpereOne arm64 machines run: "restricting filesystem access failed because Landlock is not supported by the kernel!". An ISO build there stops at its first pacman -Sy. Where /sys/kernel/security/lsm lists no landlock, or cannot be read, a wrapper in /usr/local/bin runs every pacman in the build container, pacstrap's included, with --disable-sandbox, the flag pacman provides for such kernels. Not DisableSandbox in a config: pacman-offline.conf ships on the ISO. --- builder/build-iso.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 86ffae4..8b444fe 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -23,6 +23,19 @@ esac : "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}" export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE +# pacman 7 sandboxes its downloads with Landlock and fails every sync on a +# kernel without it (Namespace's AmpereOne arm64 machines): "restricting +# filesystem access failed because Landlock is not supported by the kernel". +# There, every pacman in this container (pacstrap's too) runs with +# --disable-sandbox, which pacman provides for exactly this. A wrapper rather +# than DisableSandbox in a config: pacman-offline.conf ships on the ISO. +if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then + echo "No Landlock in this kernel: pacman runs with --disable-sandbox in this container" + printf '#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox "$@"\n' >/usr/local/bin/pacman + chmod +x /usr/local/bin/pacman + hash -r +fi + # Packages installed into the Arch container used to build the ISO. pacman-key --init pacman --noconfirm -Sy archlinux-keyring From 0bc00ff57216b8592cebb2b952c020901795e8f7 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:07:58 +0200 Subject: [PATCH 06/31] Nightly: build on Namespace's 16-core amd64 runner GitHub's hosted runner has 4 cores. The build job moves to namespace-profile-linux-amd64-16x32; the publish job, which only copies files and holds the bucket's key, stays where it is. Those runners' kernel has no Landlock, so pacman 7 fails its first sync in a container there. The ISO build already steps around that; the install test's container does the same now. The job also prints what it is running on: CPU, memory, LSMs and /dev/kvm. --- .github/workflows/nightly-build.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 448db55..d957cdd 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -40,7 +40,7 @@ env: jobs: build: name: Build, install, seal - runs-on: ubuntu-24.04 + runs-on: namespace-profile-linux-amd64-16x32 timeout-minutes: 90 outputs: version: ${{ steps.version.outputs.version }} @@ -52,6 +52,14 @@ jobs: - id: version run: echo "version=$(date -u +%Y.%m.%d).${{ github.run_number }}" | tee -a "$GITHUB_OUTPUT" + - name: Runner + run: | + uname -srm + echo "$(nproc) CPUs, $(free -h | awk 'NR==2 {print $2}') memory" + grep -m1 'model name' /proc/cpuinfo || true + echo "LSMs: $(cat /sys/kernel/security/lsm 2>/dev/null || echo unreadable)" + ls -l /dev/kvm + - name: Check disk space run: | df -h / @@ -71,6 +79,12 @@ jobs: docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \ -e OMARCHY_INTEGRATION_DISK_CACHE=none \ archlinux:latest bash -euo pipefail -c ' + # pacman 7 fails every sync on a kernel without Landlock, which + # these runners have; builder/build-iso.sh does the same. + if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then + printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman + chmod 0755 /usr/local/bin/pacman + fi pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \ imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl binutils >/dev/null # test/integration installs its tools through Omarchy'"'"'s helper; they are installed above. From ebfbf43aa03a4f5c8ae246ce6be89741b7f5582d Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:18:06 +0200 Subject: [PATCH 07/31] Index the offline mirror with one repo-add per CPU repo-add takes one package at a time: it hashes the archive and lists its files, which means decompressing all of it, for about a thousand packages one after another. A database is a tar of one directory per package. The packages are dealt out to one repo-add per CPU, each writing its own database, and the pieces are joined. On 160 packages on a 16-core machine the result is identical to one repo-add's, db and files, in 1.0 s against 6.3 s. Two versions of one package name are an error: one repo-add would keep the last, the pieces apart would keep both. --- builder/build-iso.sh | 5 +- builder/index-offline-mirror.sh | 67 ++++++++++++++++++++++++++ test/unit/offline-mirror-index-test.sh | 64 ++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100755 builder/index-offline-mirror.sh create mode 100644 test/unit/offline-mirror-index-test.sh diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 8b444fe..13a90d9 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -304,8 +304,9 @@ printf '%s\n' "${required_package_files[@]}" | # Rebuild the offline repo db from scratch so size/checksum/depends entries # always reflect only the package files selected for this build. -rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files* -repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst +# Every package archive in the mirror, indexed by one repo-add per CPU: one +# alone hashes and lists a thousand packages one after another. +bash /builder/index-offline-mirror.sh "$offline_mirror_dir" # mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the # container (the airootfs path); symlink rather than duplicate. diff --git a/builder/index-offline-mirror.sh b/builder/index-offline-mirror.sh new file mode 100755 index 0000000..ffa8600 --- /dev/null +++ b/builder/index-offline-mirror.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# +# Index the offline mirror: /offline.db.tar.gz and offline.files.tar.gz +# from every package in , the same databases one repo-add call writes. +# +# index-offline-mirror.sh [jobs] +# +# repo-add handles one package at a time, and for each it hashes the archive +# and lists its files, which means decompressing all of it. A database is only +# a tar of one directory per package, so the packages are dealt out to one +# repo-add per CPU, each writing its own database, and the pieces are put +# together afterwards. +set -euo pipefail + +dir=$(realpath "$1") +jobs=${2:-$(nproc)} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +mapfile -t packages < <(find "$dir" -maxdepth 1 -name '*.pkg.tar.*' ! -name '*.sig' | sort) +(( ${#packages[@]} > 0 )) || { echo "index-offline-mirror: no packages in $dir" >&2; exit 1; } +(( jobs > ${#packages[@]} )) && jobs=${#packages[@]} +(( jobs >= 1 )) || jobs=1 + +# Deal the sorted list out round-robin: big and small packages spread evenly. +for (( i = 0; i < jobs; i++ )); do mkdir "$work/$i"; done +for (( i = 0; i < ${#packages[@]}; i++ )); do + printf '%s\0' "${packages[i]}" >>"$work/$(( i % jobs ))/list" +done + +pids=() +for (( i = 0; i < jobs; i++ )); do + xargs -0 -a "$work/$i/list" repo-add -q "$work/$i/offline.db.tar.gz" >"$work/$i/log" 2>&1 & + pids+=($!) +done +failed=0 +for (( i = 0; i < jobs; i++ )); do + if ! wait "${pids[i]}"; then + failed=1 + echo "index-offline-mirror: repo-add failed on part $i:" >&2 + cat "$work/$i/log" >&2 + fi +done +(( failed == 0 )) || exit 1 + +mkdir "$work/db" "$work/files" +for (( i = 0; i < jobs; i++ )); do + bsdtar -xf "$work/$i/offline.db.tar.gz" -C "$work/db" + bsdtar -xf "$work/$i/offline.files.tar.gz" -C "$work/files" +done + +# One repo-add keeps a single entry per package name. Apart, two versions of +# one name would both survive, so that is an error here rather than a choice. +entries=$(find "$work/db" -mindepth 1 -maxdepth 1 -type d | wc -l) +names=$(cat "$work"/db/*/desc | awk '/^%NAME%$/ { getline; print }' | sort -u | wc -l) +if (( entries != ${#packages[@]} || names != entries )); then + echo "index-offline-mirror: ${#packages[@]} packages gave $entries entries for $names names; two versions of one package?" >&2 + exit 1 +fi + +rm -f "$dir"/offline.db* "$dir"/offline.files* +for kind in db files; do + find "$work/$kind" -mindepth 1 -maxdepth 1 -type d -printf '%P\n' | sort | + bsdtar -czf "$dir/offline.$kind.tar.gz" -C "$work/$kind" -T - + ln -s "offline.$kind.tar.gz" "$dir/offline.$kind" +done +echo "Indexed ${#packages[@]} packages with $jobs repo-add processes." diff --git a/test/unit/offline-mirror-index-test.sh b/test/unit/offline-mirror-index-test.sh new file mode 100644 index 0000000..bfc8b5a --- /dev/null +++ b/test/unit/offline-mirror-index-test.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# +# The offline mirror is indexed by several repo-add processes whose databases +# are then joined (builder/index-offline-mirror.sh). The result has to be the +# database one repo-add writes over the same packages, and two versions of one +# package have to be an error, since apart they would both be kept. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +index="$ROOT/builder/index-offline-mirror.sh" +bash -n "$index" +if ! command -v repo-add >/dev/null; then + echo "offline mirror index tests skipped: no repo-add here" + exit 0 +fi + +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } + +package() { # directory name version: the least repo-add accepts as a package + local stage="$fixture/stage" + rm -rf "$stage"; mkdir -p "$stage/usr/share/$2" + echo "$2 $3" >"$stage/usr/share/$2/version" + printf 'pkgname = %s\npkgver = %s\npkgdesc = fixture\narch = any\nsize = 1\n' "$2" "$3" >"$stage/.PKGINFO" + bsdtar -czf "$1/$2-$3-any.pkg.tar.gz" -C "$stage" .PKGINFO usr +} +contents() { # database file -> directory holding its entries + mkdir -p "$2"; bsdtar -xf "$1" -C "$2" +} + +mkdir "$fixture/one" "$fixture/many" +for name in alpha bravo charlie delta echo foxtrot golf; do + package "$fixture/one" "$name" 1.0-1 + cp "$fixture/one/$name-1.0-1-any.pkg.tar.gz" "$fixture/many/" +done +: >"$fixture/many/alpha-1.0-1-any.pkg.tar.gz.sig" # a signature is not a package + +repo-add -q "$fixture/one/offline.db.tar.gz" "$fixture"/one/*.pkg.tar.gz >/dev/null 2>&1 +bash "$index" "$fixture/many" 3 >/dev/null || fail "indexing seven packages with three processes failed" +for kind in db files; do + [[ -L $fixture/many/offline.$kind ]] || fail "offline.$kind, the name pacman fetches, is missing" + contents "$fixture/one/offline.$kind.tar.gz" "$fixture/x-one-$kind" + contents "$fixture/many/offline.$kind" "$fixture/x-many-$kind" + diff -r "$fixture/x-one-$kind" "$fixture/x-many-$kind" >/dev/null \ + || fail "the joined $kind database differs from one repo-add's" +done + +# More processes than packages, and a second run over an indexed mirror. +bash "$index" "$fixture/many" 64 >/dev/null || fail "more processes than packages failed" +contents "$fixture/many/offline.db" "$fixture/x-again" +diff -r "$fixture/x-one-db" "$fixture/x-again" >/dev/null || fail "indexing an already indexed mirror changed it" + +package "$fixture/many" alpha 2.0-1 +bash "$index" "$fixture/many" 3 >/dev/null 2>&1 && fail "two versions of one package were indexed" + +mkdir "$fixture/empty" +bash "$index" "$fixture/empty" >/dev/null 2>&1 && fail "an empty mirror was indexed" + +grep -Fq 'bash /builder/index-offline-mirror.sh "$offline_mirror_dir"' "$ROOT/builder/build-iso.sh" \ + || fail "the build does not use the parallel index" + +echo "offline mirror index tests passed" From d2677b30463971197ed2e579ebfd6e5f323b755a Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:18:28 +0200 Subject: [PATCH 08/31] Nightly: keep downloaded packages between runs A nightly downloads the packages of the ISO's offline mirror, a few gigabytes, and the test container's own, and would download most of them again the next night. On Namespace both directories live on the runner profile's cache volume. The build already prunes from its cache what an ISO no longer needs. The step is skipped, not failed, on a runner without a cache volume. --- .github/workflows/nightly-build.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index d957cdd..9ab69af 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -68,6 +68,18 @@ jobs: - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + # What a build downloads and would download again tomorrow: the packages + # the ISO's offline mirror is made of (the build prunes the ones it no + # longer needs) and the test container's own. Skipped, not failed, on a + # runner without a cache volume. + - name: Package cache (Namespace) + continue-on-error: true + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: | + ~/.cache/omarchy + ~/.cache/omarchy-ci + - name: Build the edge ISO run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make --edge @@ -76,7 +88,9 @@ jobs: - name: Install unattended, boot the installed system, seal it run: | sudo chmod 0666 /dev/kvm + mkdir -p ~/.cache/omarchy-ci/pacman docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \ + -v "$HOME/.cache/omarchy-ci/pacman:/var/cache/pacman/pkg" \ -e OMARCHY_INTEGRATION_DISK_CACHE=none \ archlinux:latest bash -euo pipefail -c ' # pacman 7 fails every sync on a kernel without Landlock, which From 9d4d03ff1ccacf3bb8ee8fc939a929529a95632d Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:29:17 +0200 Subject: [PATCH 09/31] T2 repository: ask GitHub first, the Seoul host second linux-t2, the kernel every x86 live ISO boots, comes from arch-mact2, which has two servers in these configs: the maintainer's host in Seoul and their GitHub release. Both carry the same database. An ISO build on a runner in Virginia waited on the Seoul host at 0.45 MB/s for a 92 MB package; the same file from GitHub came at 58 MB/s. GitHub goes first, which is also the order of arch-mact2's own mirror list. The Seoul host stays as the fallback. --- configs/pacman-online-edge.conf | 2 +- configs/pacman-online-rc.conf | 2 +- configs/pacman-online-stable.conf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/configs/pacman-online-edge.conf b/configs/pacman-online-edge.conf index 8f280f4..ee76be6 100644 --- a/configs/pacman-online-edge.conf +++ b/configs/pacman-online-edge.conf @@ -30,6 +30,6 @@ Server = https://mirror.omarchy.org/$repo/os/$arch Include = /etc/pacman.d/mirrorlist [arch-mact2] -Server = https://mirror.funami.tech/arch-mact2/os/x86_64 Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release +Server = https://mirror.funami.tech/arch-mact2/os/x86_64 SigLevel = Never diff --git a/configs/pacman-online-rc.conf b/configs/pacman-online-rc.conf index f9f9020..fe0ca9e 100644 --- a/configs/pacman-online-rc.conf +++ b/configs/pacman-online-rc.conf @@ -30,6 +30,6 @@ Server = https://rc-mirror.omarchy.org/$repo/os/$arch Include = /etc/pacman.d/mirrorlist [arch-mact2] -Server = https://mirror.funami.tech/arch-mact2/os/x86_64 Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release +Server = https://mirror.funami.tech/arch-mact2/os/x86_64 SigLevel = Never diff --git a/configs/pacman-online-stable.conf b/configs/pacman-online-stable.conf index 9589e62..825dc29 100644 --- a/configs/pacman-online-stable.conf +++ b/configs/pacman-online-stable.conf @@ -30,6 +30,6 @@ Server = https://stable-mirror.omarchy.org/$repo/os/$arch Include = /etc/pacman.d/mirrorlist [arch-mact2] -Server = https://mirror.funami.tech/arch-mact2/os/x86_64 Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release +Server = https://mirror.funami.tech/arch-mact2/os/x86_64 SigLevel = Never From 41fa32d6717cccc06a41ec4785584dacbf3dca00 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:42:34 +0200 Subject: [PATCH 10/31] VM image: seal whichever kernel the system runs The seal read /boot/EFI/Linux/omarchy_linux-omarchy.efi. An edge install has linux-omarchy-bore, so the first nightly run stopped there. It now asks the running system which kernel package it booted and takes that UKI, and takes the command line from /proc/cmdline, since the boot entry, not the UKI, may be what holds it. The nightly's package report names the kernel the same way. --- .github/workflows/nightly-build.yml | 2 +- test/vm-image/vm-seal | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 9ab69af..57544fd 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -110,7 +110,7 @@ jobs: base=$(ls -d test-runs/*-integration) ./test/vm-image/vm-seal "$base" sealed ./test/vm-image/vm-verify sealed | tee vm-verify.txt - ./test/vm-image/vm-boot sealed "pacman -Q omarchy-dev omarchy-settings-dev linux-omarchy; uname -r" >vm-packages.txt + ./test/vm-image/vm-boot sealed "pacman -Q omarchy-dev omarchy-settings-dev; pacman -Qo /usr/lib/modules/\$(uname -r)/pkgbase; uname -r" >vm-packages.txt cat vm-packages.txt ' diff --git a/test/vm-image/vm-seal b/test/vm-image/vm-seal index 322df27..0434a97 100755 --- a/test/vm-image/vm-seal +++ b/test/vm-image/vm-seal @@ -66,7 +66,12 @@ done ssh_vm 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true' mkdir -p "$out" -root 'cat /boot/EFI/Linux/omarchy_linux-omarchy.efi' >"$state/uki.efi" +# The kernel that is running: edge installs linux-omarchy-bore where stable +# installs linux-omarchy, and the boot entry, not the UKI, may hold the +# command line. +kernel=$(ssh_vm 'cat "/usr/lib/modules/$(uname -r)/pkgbase"') +root "cat /boot/EFI/Linux/omarchy_$kernel.efi" >"$state/uki.efi" +ssh_vm 'cat /proc/cmdline' >"$state/cmdline" root ' # The ISO writes autologin only for encrypted installs; a test has nobody at @@ -135,7 +140,6 @@ rm -f "$state/qemu.pid" # and hashing the UKI. resume= points at the image's own swap, so drop it. objcopy -O binary --only-section=.linux "$state/uki.efi" "$out/vmlinuz" objcopy -O binary --only-section=.initrd "$state/uki.efi" "$out/initrd" -objcopy -O binary --only-section=.cmdline "$state/uki.efi" "$state/cmdline" tr -d '\0' <"$state/cmdline" | tr ' ' '\n' | { grep -vE '^(resume|resume_offset)=|^$' || true; } | paste -sd' ' >"$out/cmdline" [[ -s $out/vmlinuz && -s $out/initrd && -s $out/cmdline ]] || { echo "vm-seal: the UKI gave no kernel, initrd or cmdline" >&2; exit 1; } From c97836e4f2f39b2f238d71b30d15b1ac55120ea5 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:45:40 +0200 Subject: [PATCH 11/31] VM image: check the factory snapshot holds no SSH login key The install test's key sits in the user's home, which is its own subvolume and not part of the factory snapshot, and the seal removes it there. The check says so of every image, instead of leaving it to the layout staying as it is. --- test/vm-image/vm-verify | 1 + 1 file changed, 1 insertion(+) diff --git a/test/vm-image/vm-verify b/test/vm-image/vm-verify index dba443f..bc10e41 100755 --- a/test/vm-image/vm-verify +++ b/test/vm-image/vm-verify @@ -41,6 +41,7 @@ check "no shell history" sh -c '! [ -e ~/.bash_history ]' check "the package cache is empty" [ -z "$(ls /var/cache/pacman/pkg)" ] root mkdir -p /run/top && root mount -o subvolid=5,ro "$(findmnt -no SOURCE / | sed 's/\[.*//')" /run/top check "the factory snapshot has no machine ID" sh -c "! printf '%s\n' '$PW' | sudo -S -p '' test -s /run/top/@factory/etc/machine-id" +check "the factory snapshot holds no SSH login key" sh -c "! printf '%s\n' '$PW' | sudo -S -p '' sh -c 'ls /run/top/@factory/root/.ssh/authorized_keys /run/top/@factory/home/*/.ssh/authorized_keys' 2>/dev/null | grep -q ." check "the factory snapshot has no SSH host keys" sh -c "[ -z \"\$(printf '%s\n' '$PW' | sudo -S -p '' sh -c 'ls /run/top/@factory/etc/ssh/ssh_host_* 2>/dev/null')\" ]" check "no snapshots besides the current system" [ -z "$(root ls /.snapshots)" ] echo "omarchy $(pacman -Q omarchy 2>/dev/null | cut -d' ' -f2), kernel $(uname -r), $(systemd-analyze | head -1)" From 98b20a682954e2dc65b43bb34d4f7c3c51893b5a Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:47:24 +0200 Subject: [PATCH 12/31] Nightly: a stable channel, made from the last release Next to edge, the nightly now makes a VM image of the last release. The latest release of omacom/omarchy names the version, iso.omarchy.org has the ISO, and it is used only if its signature is the release key's, the one in builder/omarchy.gpg. It gets the same install test, seal and checks, and is published under stable/ with its own latest.json, whose url is the ISO's existing place: the ISO is not uploaded again. A release is done once. A plan job decides what a run makes: edge always, stable unless a scheduled run finds that version already in stable/latest.json. Build and publish run once per channel, and one channel failing does not hold back the other. --- .github/workflows/nightly-build.yml | 146 +++++++++++++++++++++------- 1 file changed, 110 insertions(+), 36 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 57544fd..e2f34e8 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -1,12 +1,18 @@ name: Nightly ISO Build -# Every night: builds the edge ISO, installs it unattended and boots what it +# Every night, for two channels: installs an ISO unattended and boots what it # installed (the install test), turns that installed disk into a VM image, and -# publishes all of it with a latest.json saying what is in it. +# publishes it with a latest.json saying what is in it. # -# https://nightly.omarchy.org/edge/latest.json +# edge the ISO is built here, from quattro and the edge packages +# stable the ISO is the last release: omacom/omarchy's latest release names +# the version, iso.omarchy.org has the ISO, its signature is checked. +# Done once for each release, skipped on the nights after. +# +# https://nightly.omarchy.org//latest.json # https://nightly.omarchy.org/edge//omarchy-edge-.iso -# https://nightly.omarchy.org/edge//vm/ base.qcow2 and what boots it +# https://nightly.omarchy.org/edge//vm/ base.qcow2 and what boots it +# https://nightly.omarchy.org/stable///vm/ # # Nothing is published unless the ISO installed and the image passed its # checks. A version's files are written once and never replaced: the CDN keeps @@ -34,24 +40,57 @@ concurrency: cancel-in-progress: false env: - CHANNEL: edge SITE: https://nightly.omarchy.org jobs: + # Which channels this run makes, under which version, and where each goes. + # An edge version is the date and the run number. A stable version is the + # release's own, and a release already published is left out of a scheduled + # run. Two builds of one release are not the same bytes, so a stable image + # also has the run number in its path: a published path is never reused, and + # a publish that stopped halfway is simply made again. + plan: + name: Plan + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + channels: ${{ steps.plan.outputs.channels }} + steps: + - id: plan + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + edge=$(date -u +%Y.%m.%d).${{ github.run_number }} + stable=$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name) + stable=${stable#v} + [[ $stable =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "::error::omacom/omarchy's latest release is '$stable', not a version"; exit 1; } + published=$(curl -fsL "$SITE/stable/latest.json?run=$GITHUB_RUN_ID" | jq -r .version || true) + channels=$(jq -cn --arg edge "$edge" '[{channel: "edge", version: $edge, release: "edge/\($edge)"}]') + if [[ $GITHUB_EVENT_NAME == schedule && $published == "$stable" ]]; then + echo "stable $stable is already published" + else + channels=$(jq -c --arg stable "$stable" --arg run "${{ github.run_number }}" \ + '. + [{channel: "stable", version: $stable, release: "stable/\($stable)/\($run)"}]' <<<"$channels") + fi + echo "channels=$channels" | tee -a "$GITHUB_OUTPUT" + build: - name: Build, install, seal + name: ${{ matrix.channel }} ${{ matrix.version }} + needs: plan + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.channels) }} runs-on: namespace-profile-linux-amd64-16x32 timeout-minutes: 90 - outputs: - version: ${{ steps.version.outputs.version }} + env: + CHANNEL: ${{ matrix.channel }} + VERSION: ${{ matrix.version }} + RELEASE: ${{ matrix.release }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # The date says when, the run number keeps two builds of one day apart: - # a published name is never reused. - - id: version - run: echo "version=$(date -u +%Y.%m.%d).${{ github.run_number }}" | tee -a "$GITHUB_OUTPUT" - - name: Runner run: | uname -srm @@ -67,6 +106,7 @@ jobs: [ "$avail_gb" -ge 50 ] || { echo "::error::only ${avail_gb} GB free, the build, the install test and the image need ~50 GB"; exit 1; } - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + if: matrix.channel == 'edge' # What a build downloads and would download again tomorrow: the packages # the ISO's offline mirror is made of (the build prunes the ones it no @@ -81,8 +121,26 @@ jobs: ~/.cache/omarchy-ci - name: Build the edge ISO + if: matrix.channel == 'edge' run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make --edge + # Good only if the signature is the release key's, the one in this repo. + - name: Download the released ISO and check its signature + if: matrix.channel == 'stable' + run: | + set -euo pipefail + mkdir -p release + iso=omarchy-$VERSION.iso + curl -fSL --retry 3 -o "release/$iso" "https://iso.omarchy.org/$iso" + curl -fsSL --retry 3 -o "release/$iso.sig" "https://iso.omarchy.org/$iso.sig" + home=$(mktemp -d) + gpg --homedir "$home" --quiet --batch --import builder/omarchy.gpg + key=$(gpg --homedir "$home" --batch --with-colons --list-keys | awk -F: '$1 == "fpr" { print $10; exit }') + gpg --homedir "$home" --batch --status-fd 1 --verify "release/$iso.sig" "release/$iso" 2>/dev/null | + grep -q "^\[GNUPG:\] VALIDSIG $key " || { echo "::error::$iso is not signed by $key"; exit 1; } + echo "$iso is signed by $key" + rm -rf "$home" "release/$iso.sig" + # The same install test a pull request gets (install-test.yml), then the # image: the installed disk that test leaves behind, sealed and checked. - name: Install unattended, boot the installed system, seal it @@ -110,7 +168,7 @@ jobs: base=$(ls -d test-runs/*-integration) ./test/vm-image/vm-seal "$base" sealed ./test/vm-image/vm-verify sealed | tee vm-verify.txt - ./test/vm-image/vm-boot sealed "pacman -Q omarchy-dev omarchy-settings-dev; pacman -Qo /usr/lib/modules/\$(uname -r)/pkgbase; uname -r" >vm-packages.txt + ./test/vm-image/vm-boot sealed "pacman -Qq omarchy omarchy-dev 2>/dev/null | xargs pacman -Q; pacman -Qo /usr/lib/modules/\$(uname -r)/pkgbase; uname -r" >vm-packages.txt cat vm-packages.txt ' @@ -122,24 +180,30 @@ jobs: sudo chown -R "$(id -u):$(id -g)" test-runs sealed release vm-*.txt 2>/dev/null || true - name: Lay out the release and describe it - env: - VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - dir=out/$CHANNEL/$VERSION - iso=omarchy-$CHANNEL-$VERSION.iso + dir=out/$RELEASE mkdir -p "$dir/vm" - mv release/*.iso "$dir/$iso" - (cd "$dir" && sha256sum "$iso" >"$iso.sha256") + # The edge ISO is published here. The released one already has its + # place, so latest.json points there and it is not uploaded again. + if [[ $CHANNEL == edge ]]; then + iso=omarchy-$CHANNEL-$VERSION.iso iso_url=$SITE/$RELEASE/$iso + mv release/*.iso "$dir/$iso" + (cd "$dir" && sha256sum "$iso" >"$iso.sha256") + iso_sha256=$(cut -d" " -f1 "$dir/$iso.sha256") iso_size=$(stat -c %s "$dir/$iso") + else + iso=omarchy-$VERSION.iso iso_url=https://iso.omarchy.org/$iso + iso_sha256=$(sha256sum "release/$iso" | cut -d" " -f1) iso_size=$(stat -c %s "release/$iso") + fi mv sealed/* "$dir/vm/" cp test/vm-image/vm-boot "$dir/vm/vm-boot" (cd "$dir/vm" && sha256sum base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline vm-boot >SHA256SUMS) - omarchy=$(awk '$1 == "omarchy-dev" { print $2 }' vm-packages.txt) + omarchy=$(awk '$1 == "omarchy" || $1 == "omarchy-dev" { print $2 }' vm-packages.txt) jq -n \ - --arg version "$VERSION" --arg channel "$CHANNEL" --arg site "$SITE/$CHANNEL/$VERSION" --arg iso "$iso" \ + --arg version "$VERSION" --arg channel "$CHANNEL" --arg site "$SITE/$RELEASE" --arg iso_url "$iso_url" \ --arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --arg sha256 "$(cut -d" " -f1 "$dir/$iso.sha256")" --argjson size "$(stat -c %s "$dir/$iso")" \ + --arg sha256 "$iso_sha256" --argjson size "$iso_size" \ --arg iso_commit "$GITHUB_SHA" \ --arg omarchy_version "$omarchy" --arg omarchy_commit "$(sed -nE "s/.*\.g([0-9a-f]+)-.*/\1/p" <<<"$omarchy")" \ --arg kernel "$(tail -n 1 vm-packages.txt)" \ @@ -147,7 +211,7 @@ jobs: --argjson disk_size "$(stat -c %s "$dir/vm/base.qcow2")" \ '{ version: $version, channel: $channel, built_at: $built_at, - url: "\($site)/\($iso)", sha256: $sha256, size: $size, + url: $iso_url, sha256: $sha256, size: $size, iso_commit: $iso_commit, omarchy_version: $omarchy_version, omarchy_commit: $omarchy_commit, kernel: $kernel, vm: { @@ -173,7 +237,7 @@ jobs: - name: Keep the release for the publish job uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: nightly-${{ steps.version.outputs.version }} + name: nightly-${{ matrix.channel }}-${{ github.run_id }} path: out/ compression-level: 0 retention-days: 2 @@ -183,7 +247,7 @@ jobs: if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: nightly-install-test-${{ github.run_id }}-${{ github.run_attempt }} + name: nightly-install-test-${{ matrix.channel }}-${{ github.run_id }}-${{ github.run_attempt }} path: | test-runs/*/omarchy-install-timing.json test-runs/*/omarchy-install.log @@ -200,15 +264,23 @@ jobs: # The only job that holds the bucket's key, and it builds nothing: it copies # what the job above made, checks what the CDN then serves, and only then # points latest.json at it. + # One channel failing does not hold back the other: each is published if + # its own build left a release behind. publish: - name: Publish - needs: build - if: github.ref == 'refs/heads/quattro' && (github.event_name == 'schedule' || inputs.publish) + name: Publish ${{ matrix.channel }} + needs: [plan, build] + if: ${{ !cancelled() && github.ref == 'refs/heads/quattro' && (github.event_name == 'schedule' || inputs.publish) }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.channels) }} runs-on: ubuntu-24.04 timeout-minutes: 45 environment: nightly env: - VERSION: ${{ needs.build.outputs.version }} + CHANNEL: ${{ matrix.channel }} + VERSION: ${{ matrix.version }} + RELEASE: ${{ matrix.release }} RCLONE_CONFIG_R2_TYPE: s3 RCLONE_CONFIG_R2_PROVIDER: Cloudflare RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} @@ -218,7 +290,7 @@ jobs: steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: nightly-${{ needs.build.outputs.version }} + name: nightly-${{ matrix.channel }}-${{ github.run_id }} path: out - name: rclone @@ -231,7 +303,7 @@ jobs: # overwrite. - name: Upload this version run: | - rclone copy "out/$CHANNEL/$VERSION" "r2:$BUCKET/$CHANNEL/$VERSION" \ + rclone copy "out/$RELEASE" "r2:$BUCKET/$RELEASE" \ --immutable --s3-no-check-bucket --transfers 8 --s3-upload-concurrency 8 --s3-chunk-size 64M --stats 30s --stats-one-line # Fetched the way a user fetches them. It proves what is served, and it @@ -239,16 +311,18 @@ jobs: - name: Check what the CDN serves run: | set -euo pipefail - dir=out/$CHANNEL/$VERSION + dir=out/$RELEASE check() { # url, expected sha256 got=$(curl -fsSL --retry 3 "$1" | sha256sum | cut -d" " -f1) [ "$got" = "$2" ] || { echo "::error::$1 serves $got, expected $2"; exit 1; } echo "ok $1" } - iso=$(basename "$dir"/*.iso) - check "$SITE/$CHANNEL/$VERSION/$iso" "$(cut -d" " -f1 "$dir/$iso.sha256")" + for iso in "$dir"/*.iso; do + [[ -f $iso ]] || continue + check "$SITE/$RELEASE/${iso##*/}" "$(cut -d" " -f1 "$iso.sha256")" + done while read -r sum name; do - check "$SITE/$CHANNEL/$VERSION/vm/$name" "$sum" + check "$SITE/$RELEASE/vm/$name" "$sum" done <"$dir/vm/SHA256SUMS" - name: Point latest.json at it From 32f917fdffae264510d6ee205a2b47a5d6f24cec Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:56:45 +0200 Subject: [PATCH 13/31] Integration harness: say when the install is over and the first boot is awaited The wait loop printed "installing (Ns)" until SSH answered on the installed system. A nightly run sat on that line for twelve minutes while the install had finished after two: the first boot was what hung. The firmware names what it starts on the serial port, so the loop now notices the installed disk's Limine starting, logs how long the install took, and counts the first boot from there. --- test/integration.d/base-test.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/integration.d/base-test.sh b/test/integration.d/base-test.sh index 9b20e6b..0b5e157 100644 --- a/test/integration.d/base-test.sh +++ b/test/integration.d/base-test.sh @@ -535,7 +535,7 @@ install_phase() { -device usb-storage,drive=cidata log "Waiting for the unattended install to finish (timeout ${INSTALL_TIMEOUT}s)" - local waited=0 text progress_name + local waited=0 text progress_name first_boot_at="" while true; do # An unattended install reboots on its own; SSH answering means the # installed system is up (cidata's authorized_keys enables sshd). @@ -576,7 +576,21 @@ install_phase() { printf -v progress_name 'success-install-progress-%04ds' "$waited" capture_console "$progress_name" fi - ((waited % 30 == 0)) && echo " ... installing (${waited}s)" + # The firmware names what it starts on the serial port. Once that is the + # installed disk's Limine, the install is over and what is being waited + # for is the first boot: say so, or a hung first boot reads as a slow + # install. + if [[ -z $first_boot_at ]] && grep -aq 'starting Boot.*"Limine"' "$RUN_DIR/install-serial.log" 2>/dev/null; then + first_boot_at=$waited + log "Install finished after about ${waited}s. Waiting for the installed system's first boot." + fi + if ((waited % 30 == 0)); then + if [[ -n $first_boot_at ]]; then + echo " ... first boot ($((waited - first_boot_at))s, install took about ${first_boot_at}s)" + else + echo " ... installing (${waited}s)" + fi + fi sleep 10 ((waited += 10)) From f0216cf0c0aec7231bf7e6f31c46ceebc3ce7077 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 19:59:26 +0200 Subject: [PATCH 14/31] Install test: run on Namespace's 16-core amd64 runner The same runner the nightly uses. Its kernel has no Landlock, so the test's container steps around pacman's sandbox the way the ISO build does. It gets no cache volume: this workflow runs for pull requests, and a cache one of them wrote would be trusted by the next run. --- .github/workflows/install-test.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index ccec9b0..5e49b84 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -21,13 +21,14 @@ concurrency: jobs: install: - runs-on: ubuntu-24.04 + # No cache volume here on purpose: this runs for pull requests, and what + # one of them leaves in a cache the next run would trust. + runs-on: namespace-profile-linux-amd64-16x32 timeout-minutes: 75 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # The hosted runner has far more disk than the build needs (145 GB, - # ~86 GB free); fail early and loudly if that ever changes. + # Fail early and loudly if the runner ever has less disk than this needs. - name: Check disk space run: | df -h / @@ -48,6 +49,12 @@ jobs: docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \ -e OMARCHY_INTEGRATION_DISK_CACHE=none \ archlinux:latest bash -euo pipefail -c ' + # pacman 7 fails every sync on a kernel without Landlock, which + # these runners have; builder/build-iso.sh does the same. + if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then + printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman + chmod 0755 /usr/local/bin/pacman + fi pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \ imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null # test/integration installs its tools through Omarchy'"'"'s helper; they are installed above. From 4f7d4a9db3d4fde20593c6de757af0ce22c99963 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 20:01:54 +0200 Subject: [PATCH 15/31] Install test: a pull request from a fork stays on GitHub's runner Only our own branches, quattro and manual runs go to Namespace. --- .github/workflows/install-test.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 5e49b84..025b773 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -21,9 +21,11 @@ concurrency: jobs: install: - # No cache volume here on purpose: this runs for pull requests, and what - # one of them leaves in a cache the next run would trust. - runs-on: namespace-profile-linux-amd64-16x32 + # Our own branches run on Namespace. A pull request from a fork stays on + # GitHub's runner: it is somebody else's code, and it has no business on + # our account's machines. No cache volume either way: what one pull + # request leaves in a cache the next run would trust. + runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-24.04' || 'namespace-profile-linux-amd64-16x32' }} timeout-minutes: 75 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 From d054c00d24ae45794141e91bb44aa91d59d710e3 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 20:07:53 +0200 Subject: [PATCH 16/31] Nightly: a re-run publishes under its own number Starting a run again keeps its run number, so a second attempt would have built new images for the paths the first attempt had already partly filled, and the upload refuses to replace a file. From the second attempt on, the attempt is part of the number: 431, then 431.2. --- .github/workflows/nightly-build.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index e2f34e8..9f22171 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -61,7 +61,11 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - edge=$(date -u +%Y.%m.%d).${{ github.run_number }} + # A run that is started again makes its images again, and they are + # not the same bytes: it gets its own number too. + build=${{ github.run_number }} + [[ ${{ github.run_attempt }} == 1 ]] || build+=.${{ github.run_attempt }} + edge=$(date -u +%Y.%m.%d).$build stable=$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name) stable=${stable#v} [[ $stable =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "::error::omacom/omarchy's latest release is '$stable', not a version"; exit 1; } @@ -70,7 +74,7 @@ jobs: if [[ $GITHUB_EVENT_NAME == schedule && $published == "$stable" ]]; then echo "stable $stable is already published" else - channels=$(jq -c --arg stable "$stable" --arg run "${{ github.run_number }}" \ + channels=$(jq -c --arg stable "$stable" --arg run "$build" \ '. + [{channel: "stable", version: $stable, release: "stable/\($stable)/\($run)"}]' <<<"$channels") fi echo "channels=$channels" | tee -a "$GITHUB_OUTPUT" From ff30b7f5581ba0a20c92066a48b4474ca253e2a8 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 20:09:14 +0200 Subject: [PATCH 17/31] Nightly: one Omarchy version in latest.json On edge, pacman answers for both omarchy and omarchy-dev with the one installed package, so the version and the commit were each written twice, with a line break between. --- .github/workflows/nightly-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 9f22171..8e78109 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -203,7 +203,7 @@ jobs: cp test/vm-image/vm-boot "$dir/vm/vm-boot" (cd "$dir/vm" && sha256sum base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline vm-boot >SHA256SUMS) - omarchy=$(awk '$1 == "omarchy" || $1 == "omarchy-dev" { print $2 }' vm-packages.txt) + omarchy=$(awk '$1 == "omarchy" || $1 == "omarchy-dev" { print $2; exit }' vm-packages.txt) jq -n \ --arg version "$VERSION" --arg channel "$CHANNEL" --arg site "$SITE/$RELEASE" --arg iso_url "$iso_url" \ --arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ From 0abcbb0b97eb8184e280fda2b19f0690955cb18e Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 20:12:14 +0200 Subject: [PATCH 18/31] Nightly: its decisions are scripts, with tests Which channels a run makes and where they go, and what is laid out and what latest.json then says, were shell inside the workflow file, tried only by hand. A green run published a latest.json with the Omarchy version in it twice, and nothing noticed. Both are scripts now, test/vm-image/nightly-plan and nightly-layout, and test/unit/nightly-test.sh tries them without a runner: a first run, a run started again, a release already published, a tag that is not a version; every field of latest.json for each channel, the checksums against the files, the version a consumer reads from the ISO's name. nightly-layout also refuses to describe an image it cannot describe fully, and checks its own latest.json before anything is published. The same test covers two things the install test decides: which Omarchy packages an ISO installs, and when an install is over. --- .github/workflows/nightly-build.yml | 69 ++----------- test/integration.d/base-test.sh | 15 ++- test/unit/nightly-test.sh | 152 ++++++++++++++++++++++++++++ test/vm-image/nightly-layout | 98 ++++++++++++++++++ test/vm-image/nightly-plan | 42 ++++++++ 5 files changed, 312 insertions(+), 64 deletions(-) create mode 100644 test/unit/nightly-test.sh create mode 100755 test/vm-image/nightly-layout create mode 100755 test/vm-image/nightly-plan diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 8e78109..320f604 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -43,12 +43,8 @@ env: SITE: https://nightly.omarchy.org jobs: - # Which channels this run makes, under which version, and where each goes. - # An edge version is the date and the run number. A stable version is the - # release's own, and a release already published is left out of a scheduled - # run. Two builds of one release are not the same bytes, so a stable image - # also has the run number in its path: a published path is never reused, and - # a publish that stopped halfway is simply made again. + # Which channels this run makes, under which version, and where each goes: + # test/vm-image/nightly-plan decides, from what is fetched here. plan: name: Plan runs-on: ubuntu-24.04 @@ -56,27 +52,17 @@ jobs: outputs: channels: ${{ steps.plan.outputs.channels }} steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - id: plan env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - # A run that is started again makes its images again, and they are - # not the same bytes: it gets its own number too. - build=${{ github.run_number }} - [[ ${{ github.run_attempt }} == 1 ]] || build+=.${{ github.run_attempt }} - edge=$(date -u +%Y.%m.%d).$build - stable=$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name) - stable=${stable#v} - [[ $stable =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "::error::omacom/omarchy's latest release is '$stable', not a version"; exit 1; } + release=$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name) published=$(curl -fsL "$SITE/stable/latest.json?run=$GITHUB_RUN_ID" | jq -r .version || true) - channels=$(jq -cn --arg edge "$edge" '[{channel: "edge", version: $edge, release: "edge/\($edge)"}]') - if [[ $GITHUB_EVENT_NAME == schedule && $published == "$stable" ]]; then - echo "stable $stable is already published" - else - channels=$(jq -c --arg stable "$stable" --arg run "$build" \ - '. + [{channel: "stable", version: $stable, release: "stable/\($stable)/\($run)"}]' <<<"$channels") - fi + channels=$(./test/vm-image/nightly-plan "$(date -u +%Y.%m.%d)" "${{ github.run_number }}" "${{ github.run_attempt }}" \ + "$GITHUB_EVENT_NAME" "$release" "$published") echo "channels=$channels" | tee -a "$GITHUB_OUTPUT" build: @@ -184,47 +170,12 @@ jobs: sudo chown -R "$(id -u):$(id -g)" test-runs sealed release vm-*.txt 2>/dev/null || true - name: Lay out the release and describe it + env: + ISO_COMMIT: ${{ github.sha }} run: | set -euo pipefail + ./test/vm-image/nightly-layout "$CHANNEL" "$VERSION" "$RELEASE" release/*.iso sealed vm-packages.txt out dir=out/$RELEASE - mkdir -p "$dir/vm" - # The edge ISO is published here. The released one already has its - # place, so latest.json points there and it is not uploaded again. - if [[ $CHANNEL == edge ]]; then - iso=omarchy-$CHANNEL-$VERSION.iso iso_url=$SITE/$RELEASE/$iso - mv release/*.iso "$dir/$iso" - (cd "$dir" && sha256sum "$iso" >"$iso.sha256") - iso_sha256=$(cut -d" " -f1 "$dir/$iso.sha256") iso_size=$(stat -c %s "$dir/$iso") - else - iso=omarchy-$VERSION.iso iso_url=https://iso.omarchy.org/$iso - iso_sha256=$(sha256sum "release/$iso" | cut -d" " -f1) iso_size=$(stat -c %s "release/$iso") - fi - mv sealed/* "$dir/vm/" - cp test/vm-image/vm-boot "$dir/vm/vm-boot" - (cd "$dir/vm" && sha256sum base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline vm-boot >SHA256SUMS) - - omarchy=$(awk '$1 == "omarchy" || $1 == "omarchy-dev" { print $2; exit }' vm-packages.txt) - jq -n \ - --arg version "$VERSION" --arg channel "$CHANNEL" --arg site "$SITE/$RELEASE" --arg iso_url "$iso_url" \ - --arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --arg sha256 "$iso_sha256" --argjson size "$iso_size" \ - --arg iso_commit "$GITHUB_SHA" \ - --arg omarchy_version "$omarchy" --arg omarchy_commit "$(sed -nE "s/.*\.g([0-9a-f]+)-.*/\1/p" <<<"$omarchy")" \ - --arg kernel "$(tail -n 1 vm-packages.txt)" \ - --arg disk_sha256 "$(awk '$2 == "base.qcow2" { print $1 }' "$dir/vm/SHA256SUMS")" \ - --argjson disk_size "$(stat -c %s "$dir/vm/base.qcow2")" \ - '{ - version: $version, channel: $channel, built_at: $built_at, - url: $iso_url, sha256: $sha256, size: $size, - iso_commit: $iso_commit, omarchy_version: $omarchy_version, omarchy_commit: $omarchy_commit, - kernel: $kernel, - vm: { - url: "\($site)/vm/", disk: "\($site)/vm/base.qcow2", disk_sha256: $disk_sha256, disk_size: $disk_size, - files: ["base.qcow2", "OVMF_VARS.4m.fd", "vmlinuz", "initrd", "cmdline", "vm-boot", "SHA256SUMS"], - user: "omarchy", password: "omarchy", - ssh: "keys only; vm-boot makes one for each boot" - } - }' | tee "out/$CHANNEL/latest.json" { echo "## Nightly $CHANNEL $VERSION" diff --git a/test/integration.d/base-test.sh b/test/integration.d/base-test.sh index 0b5e157..e7fcc9f 100644 --- a/test/integration.d/base-test.sh +++ b/test/integration.d/base-test.sh @@ -514,6 +514,12 @@ detect_packages() { fi } +# The firmware names what it starts on the serial port: the ISO's medium while +# the installer runs, the installed disk's Limine once the install is over. +installed_system_started() { + grep -aq 'starting Boot.*"Limine"' "$1" 2>/dev/null +} + install_phase() { log "Installing $(basename "$ISO") unattended via cidata (headless)" @@ -576,11 +582,10 @@ install_phase() { printf -v progress_name 'success-install-progress-%04ds' "$waited" capture_console "$progress_name" fi - # The firmware names what it starts on the serial port. Once that is the - # installed disk's Limine, the install is over and what is being waited - # for is the first boot: say so, or a hung first boot reads as a slow - # install. - if [[ -z $first_boot_at ]] && grep -aq 'starting Boot.*"Limine"' "$RUN_DIR/install-serial.log" 2>/dev/null; then + # Once the installed system has started, the install is over and what is + # being waited for is the first boot: say so, or a hung first boot reads + # as a slow install. + if [[ -z $first_boot_at ]] && installed_system_started "$RUN_DIR/install-serial.log"; then first_boot_at=$waited log "Install finished after about ${waited}s. Waiting for the installed system's first boot." fi diff --git a/test/unit/nightly-test.sh b/test/unit/nightly-test.sh new file mode 100644 index 0000000..2c9a91d --- /dev/null +++ b/test/unit/nightly-test.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# +# The nightly's decisions, tried without a runner: which channels a run makes +# and where they go (test/vm-image/nightly-plan), what it lays out and what +# latest.json then says (test/vm-image/nightly-layout), and two things the +# install test decides from what it is given: which Omarchy packages an ISO +# installs, and when an install is over. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +plan="$ROOT/test/vm-image/nightly-plan" +layout="$ROOT/test/vm-image/nightly-layout" +harness="$ROOT/test/integration.d/base-test.sh" +bash -n "$plan" +bash -n "$layout" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +equal() { [[ $2 == "$3" ]] || fail "$1: got '$2', expected '$3'"; } +refused() { # description, command...: it has to fail and say why + local what=$1; shift + if "$@" >"$tmp/refused.out" 2>"$tmp/refused.err"; then fail "$what was accepted"; fi + [[ -s $tmp/refused.err ]] || fail "$what was refused without a word" +} + +# ---------------------------------------------------------------- nightly-plan +planned() { "$plan" "$@" 2>/dev/null; } + +first=$(planned 2026.10.09 431 1 schedule v4.0.4 "") +equal "a first run makes both channels" "$(jq -r 'map(.channel) | join(",")' <<<"$first")" "edge,stable" +equal "the edge version" "$(jq -r '.[0].version' <<<"$first")" "2026.10.09.431" +equal "the edge path" "$(jq -r '.[0].release' <<<"$first")" "edge/2026.10.09.431" +equal "the stable version is the release's" "$(jq -r '.[1].version' <<<"$first")" "4.0.4" +equal "the stable path has the run number" "$(jq -r '.[1].release' <<<"$first")" "stable/4.0.4/431" + +again=$(planned 2026.10.09 431 2 schedule v4.0.4 "") +equal "a run started again has its own edge version" "$(jq -r '.[0].version' <<<"$again")" "2026.10.09.431.2" +equal "a run started again has its own stable path" "$(jq -r '.[1].release' <<<"$again")" "stable/4.0.4/431.2" +[[ $(jq -r '.[].release' <<<"$first" | sort) != "$(jq -r '.[].release' <<<"$again" | sort)" ]] || + fail "two attempts of one run share a path" + +equal "a published release is left out of a scheduled run" \ + "$(planned 2026.10.09 432 1 schedule v4.0.4 4.0.4 | jq -r 'map(.channel) | join(",")')" "edge" +equal "a new release is made on the next scheduled run" \ + "$(planned 2026.10.09 432 1 schedule v4.0.5 4.0.4 | jq -r '.[1].version')" "4.0.5" +equal "a manual run makes a published release again" \ + "$(planned 2026.10.09 432 1 workflow_dispatch v4.0.4 4.0.4 | jq -r 'map(.channel) | join(",")')" "edge,stable" + +refused "a release tag that is not a version" "$plan" 2026.10.09 431 1 schedule nightly "" +refused "an empty release tag" "$plan" 2026.10.09 431 1 schedule "" "" +refused "a release tag with a path in it" "$plan" 2026.10.09 431 1 schedule "v4.0.4/../x" "" +refused "a run number that is not a number" "$plan" 2026.10.09 "" 1 schedule v4.0.4 "" +refused "a date in another form" "$plan" 2026-10-09 431 1 schedule v4.0.4 "" + +# -------------------------------------------------------------- nightly-layout +sealed() { # directory: what vm-seal leaves + rm -rf "$1"; mkdir -p "$1" + local file + for file in base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline; do echo "$file of ${1##*/}" >"$1/$file"; done + echo "sums of the seal" >"$1/SHA256SUMS" +} +export SITE=https://nightly.example ISO_SITE=https://iso.example ISO_COMMIT=0123456789abcdef0123456789abcdef01234567 + +# Edge. pacman answers for omarchy and omarchy-dev with the one installed +# package, so its line is there twice. +mkdir -p "$tmp/release" +echo "an edge iso" >"$tmp/release/omarchy-2026.10.09-x86_64-edge.iso" +sealed "$tmp/sealed-edge" +printf '%s\n' "omarchy-dev 4.0.0.r6818.gc352b62-2" "omarchy-dev 4.0.0.r6818.gc352b62-2" \ + "/usr/lib/modules/7.2.8-5-omarchy-bore/pkgbase is owned by linux-omarchy-bore 7.2.8-5" "7.2.8-5-omarchy-bore" >"$tmp/edge-packages" +"$layout" edge 2026.10.09.431 edge/2026.10.09.431 "$tmp/release/omarchy-2026.10.09-x86_64-edge.iso" \ + "$tmp/sealed-edge" "$tmp/edge-packages" "$tmp/out" >/dev/null +json=$tmp/out/edge/latest.json +dir=$tmp/out/edge/2026.10.09.431 +field() { jq -r "$1" "$json"; } + +equal "edge: files published" "$(cd "$tmp/out" && find . -type f | sort | paste -sd' ')" \ + "./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso.sha256 ./edge/2026.10.09.431/vm/OVMF_VARS.4m.fd ./edge/2026.10.09.431/vm/SHA256SUMS ./edge/2026.10.09.431/vm/base.qcow2 ./edge/2026.10.09.431/vm/cmdline ./edge/2026.10.09.431/vm/initrd ./edge/2026.10.09.431/vm/vm-boot ./edge/2026.10.09.431/vm/vmlinuz ./edge/latest.json" +(cd "$dir" && sha256sum -c --quiet ./*.sha256) || fail "edge: the ISO's checksum file does not match the ISO" +(cd "$dir/vm" && sha256sum -c --quiet SHA256SUMS) || fail "edge: SHA256SUMS does not match the image" +equal "edge: SHA256SUMS covers every file beside it" \ + "$(awk '{ print $2 }' "$dir/vm/SHA256SUMS" | sort | paste -sd' ')" "$(cd "$dir/vm" && ls | grep -v SHA256SUMS | sort | paste -sd' ')" +cmp -s "$dir/vm/vm-boot" "$ROOT/test/vm-image/vm-boot" || fail "edge: the published vm-boot is not the repository's" +equal "edge: version" "$(field .version)" "2026.10.09.431" +equal "edge: channel" "$(field .channel)" "edge" +equal "edge: url" "$(field .url)" "https://nightly.example/edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso" +equal "edge: sha256 is the ISO's" "$(field .sha256)" "$(sha256sum "$dir/omarchy-edge-2026.10.09.431.iso" | cut -d' ' -f1)" +equal "edge: size is the ISO's" "$(field .size)" "$(stat -c %s "$dir/omarchy-edge-2026.10.09.431.iso")" +equal "edge: iso_commit" "$(field .iso_commit)" "$ISO_COMMIT" +equal "edge: omarchy_version, once" "$(field .omarchy_version)" "4.0.0.r6818.gc352b62-2" +equal "edge: omarchy_commit" "$(field .omarchy_commit)" "c352b62" +equal "edge: kernel" "$(field .kernel)" "7.2.8-5-omarchy-bore" +equal "edge: image address" "$(field .vm.disk)" "https://nightly.example/edge/2026.10.09.431/vm/base.qcow2" +equal "edge: image checksum is the disk's" "$(field .vm.disk_sha256)" "$(sha256sum "$dir/vm/base.qcow2" | cut -d' ' -f1)" +equal "edge: every listed file is published" "$(field '.vm.files[]' | sort | paste -sd' ')" "$(cd "$dir/vm" && ls | sort | paste -sd' ')" +[[ $(field .built_at) =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$ ]] || fail "edge: built_at is '$(field .built_at)'" +# What a consumer does with the ISO's name: the first run of three or more +# dotted numbers is the version. +[[ $(basename "$(field .url)") =~ [0-9]+(\.[0-9]+){2,} ]] || fail "edge: the ISO's name carries no version" +equal "edge: the version read from the ISO's name" "${BASH_REMATCH[0]}" "2026.10.09.431" + +# Stable: the image is published, the released ISO stays where it is. +rm -rf "$tmp/out" +echo "a released iso" >"$tmp/release/omarchy-4.0.4.iso" +sealed "$tmp/sealed-stable" +printf '%s\n' "omarchy 4.0.4-1" "/usr/lib/modules/7.2.5-3-omarchy/pkgbase is owned by linux-omarchy 7.2.5-3" "7.2.5-3-omarchy" >"$tmp/stable-packages" +"$layout" stable 4.0.4 stable/4.0.4/431 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-stable" "$tmp/stable-packages" "$tmp/out" >/dev/null +json=$tmp/out/stable/latest.json +equal "stable: no ISO is published" "$(find "$tmp/out" -name '*.iso*' | wc -l)" "0" +[[ -f $tmp/release/omarchy-4.0.4.iso ]] || fail "stable: the released ISO was moved away" +equal "stable: url is the release's" "$(field .url)" "https://iso.example/omarchy-4.0.4.iso" +equal "stable: sha256 is the ISO's" "$(field .sha256)" "$(sha256sum "$tmp/release/omarchy-4.0.4.iso" | cut -d' ' -f1)" +equal "stable: version" "$(field .version)" "4.0.4" +equal "stable: omarchy_version" "$(field .omarchy_version)" "4.0.4-1" +equal "stable: a release names no commit" "$(field .omarchy_commit)" "" +equal "stable: image address has the run number" "$(field .vm.disk)" "https://nightly.example/stable/4.0.4/431/vm/base.qcow2" +(cd "$tmp/out/stable/4.0.4/431/vm" && sha256sum -c --quiet SHA256SUMS) || fail "stable: SHA256SUMS does not match the image" + +# Nothing half-made is described. +sealed "$tmp/sealed-short"; rm "$tmp/sealed-short/initrd" +refused "a sealed image without its initrd" "$layout" stable 4.0.4 stable/4.0.4/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-short" "$tmp/stable-packages" "$tmp/out2" +sealed "$tmp/sealed-x"; : >"$tmp/no-packages" +refused "an image that named no Omarchy package" "$layout" stable 4.0.4 stable/4.0.4/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-x" "$tmp/no-packages" "$tmp/out3" +sealed "$tmp/sealed-y" +refused "a released ISO under another version's name" "$layout" stable 4.0.5 stable/4.0.5/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-y" "$tmp/stable-packages" "$tmp/out4" +sealed "$tmp/sealed-z" +refused "a channel that does not exist" "$layout" rc 4.0.4 rc/4.0.4 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-z" "$tmp/stable-packages" "$tmp/out5" + +# ------------------------------------------------------------ the install test +# The harness is a script that starts a VM when it is read; take the two +# functions out of it. +eval "$(sed -n '/^detect_packages() {$/,/^}$/p; /^installed_system_started() {$/,/^}$/p' "$harness")" +declare -F detect_packages installed_system_started >/dev/null || fail "the harness no longer has detect_packages and installed_system_started" + +packages_of() { ISO=$1; detect_packages; echo "$RUNTIME_PACKAGE $SETTINGS_PACKAGE"; } +equal "an edge ISO installs the -dev packages" "$(packages_of release/omarchy-2026.10.09-x86_64-edge.iso)" "omarchy-dev omarchy-settings-dev" +equal "a dev ISO installs the -dev packages" "$(packages_of release/omarchy-2026.10.09-x86_64-dev.iso)" "omarchy-dev omarchy-settings-dev" +equal "a released ISO installs the release packages" "$(packages_of release/omarchy-4.0.4.iso)" "omarchy omarchy-settings" +equal "a stable build installs the release packages" "$(packages_of /x/edge/omarchy-2026.10.09-x86_64-stable.iso)" "omarchy omarchy-settings" + +printf '%s\r\n' 'BdsDxe: loading Boot0002 "UEFI QEMU DVD-ROM QM00011 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x5,0xFFFF,0x0)' \ + 'BdsDxe: starting Boot0002 "UEFI QEMU DVD-ROM QM00011 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x5,0xFFFF,0x0)' >"$tmp/serial" +if installed_system_started "$tmp/serial"; then fail "the installer's own boot counts as the installed system's"; fi +printf '%s\r\n' 'BdsDxe: loading Boot0005 "Limine" from HD(1,GPT,774175D2,0x800,0x400000)/\EFI\limine\limine_x64.efi' >>"$tmp/serial" +if installed_system_started "$tmp/serial"; then fail "loading Limine counts as having started it"; fi +printf '%s\r\n' 'BdsDxe: starting Boot0005 "Limine" from HD(1,GPT,774175D2,0x800,0x400000)/\EFI\limine\limine_x64.efi' >>"$tmp/serial" +installed_system_started "$tmp/serial" || fail "the installed system's Limine starting is not noticed" +if installed_system_started "$tmp/no-such-log"; then fail "a missing serial log counts as a started system"; fi + +echo "nightly tests passed" diff --git a/test/vm-image/nightly-layout b/test/vm-image/nightly-layout new file mode 100755 index 0000000..c58f60c --- /dev/null +++ b/test/vm-image/nightly-layout @@ -0,0 +1,98 @@ +#!/bin/bash +# Lays out what one channel of a nightly run publishes and writes the +# latest.json that describes it. +# +# nightly-layout +# +# release the path under the site, from nightly-plan +# iso the ISO that was installed +# sealed_dir what vm-seal made +# vm_packages what the sealed image answered to: pacman -Q of its Omarchy +# package, pacman -Qo of its kernel's pkgbase, uname -r +# +# Env: SITE (https://nightly.omarchy.org), ISO_SITE (https://iso.omarchy.org), +# ISO_COMMIT (the omarchy-iso commit, default: HEAD of this checkout). +# +# out_dir gets /vm/ with the image, its checksums and vm-boot, and +# /latest.json. The edge ISO is published too, next to the image. +# The released one already has its place: latest.json points there and the +# ISO stays where it is. +# +# The ISO and the image are moved, not copied: they are gigabytes. +set -euo pipefail +[[ $# -eq 7 ]] || { sed -n '2,13p' "$0" >&2; exit 2; } +channel=$1 version=$2 release=$3 iso_file=$4 sealed=$5 packages=$6 out=$7 +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +site=${SITE:-https://nightly.omarchy.org} +iso_site=${ISO_SITE:-https://iso.omarchy.org} +iso_commit=${ISO_COMMIT:-$(git -C "$here" rev-parse HEAD)} +fail() { echo "nightly-layout: $*" >&2; exit 1; } + +[[ $channel == edge || $channel == stable ]] || fail "'$channel' is not a channel" +[[ -f $iso_file ]] || fail "no ISO at $iso_file" +files=(base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline) +for file in "${files[@]}"; do + [[ -s $sealed/$file ]] || fail "the sealed image has no $file" +done + +omarchy=$(awk '$1 == "omarchy" || $1 == "omarchy-dev" { print $2; exit }' "$packages") +kernel=$(tail -n 1 "$packages") +[[ -n $omarchy ]] || fail "$packages names no omarchy package" +[[ $kernel =~ ^[0-9]+\.[0-9] ]] || fail "$packages does not end with a kernel release" +# An edge package's version carries the commit it was built from. +omarchy_commit=$(sed -nE 's/.*\.g([0-9a-f]+)-.*/\1/p' <<<"$omarchy") + +dir=$out/$release +mkdir -p "$dir/vm" "$out/$channel" +if [[ $channel == edge ]]; then + iso=omarchy-$channel-$version.iso + iso_url=$site/$release/$iso + mv "$iso_file" "$dir/$iso" + (cd "$dir" && sha256sum "$iso" >"$iso.sha256") + iso_sha256=$(cut -d' ' -f1 "$dir/$iso.sha256") iso_size=$(stat -c %s "$dir/$iso") +else + iso=omarchy-$version.iso + [[ ${iso_file##*/} == "$iso" ]] || fail "the released ISO is $iso, not ${iso_file##*/}" + iso_url=$iso_site/$iso + iso_sha256=$(sha256sum "$iso_file" | cut -d' ' -f1) iso_size=$(stat -c %s "$iso_file") +fi + +for file in "${files[@]}"; do mv "$sealed/$file" "$dir/vm/$file"; done +cp "$here/vm-boot" "$dir/vm/vm-boot" +files+=(vm-boot) +(cd "$dir/vm" && sha256sum "${files[@]}" >SHA256SUMS) + +latest=$out/$channel/latest.json +jq -n \ + --arg version "$version" --arg channel "$channel" --arg vm "$site/$release/vm" \ + --arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg url "$iso_url" --arg sha256 "$iso_sha256" --argjson size "$iso_size" \ + --arg iso_commit "$iso_commit" --arg omarchy_version "$omarchy" --arg omarchy_commit "$omarchy_commit" \ + --arg kernel "$kernel" \ + --arg disk_sha256 "$(awk '$2 == "base.qcow2" { print $1 }' "$dir/vm/SHA256SUMS")" \ + --argjson disk_size "$(stat -c %s "$dir/vm/base.qcow2")" \ + --args '{ + version: $version, channel: $channel, built_at: $built_at, + url: $url, sha256: $sha256, size: $size, + iso_commit: $iso_commit, omarchy_version: $omarchy_version, omarchy_commit: $omarchy_commit, + kernel: $kernel, + vm: { + url: "\($vm)/", disk: "\($vm)/base.qcow2", disk_sha256: $disk_sha256, disk_size: $disk_size, + files: ($ARGS.positional + ["SHA256SUMS"]), + user: "omarchy", password: "omarchy", + ssh: "keys only; vm-boot makes one for each boot" + } + }' "${files[@]}" >"$latest" + +# What is about to be published has to say what it should: one value in every +# field, and checksums that are checksums. +jq -e --arg channel "$channel" ' + def one_line: type == "string" and test("^[^\n]+$"); + def sha: type == "string" and test("^[0-9a-f]{64}$"); + (.version | one_line) and .channel == $channel + and (.url | test("^https://[^\n ]+\\.iso$")) and (.sha256 | sha) and .size > 0 + and (.iso_commit | test("^[0-9a-f]{40}$")) + and (.omarchy_version | one_line) and (.omarchy_commit | test("^[0-9a-f]*$")) and (.kernel | one_line) + and (.vm.disk | test("^https://[^\n ]+/vm/base\\.qcow2$")) and (.vm.disk_sha256 | sha) and .vm.disk_size > 0 +' "$latest" >/dev/null || { cat "$latest" >&2; fail "latest.json does not say what it should"; } +cat "$latest" diff --git a/test/vm-image/nightly-plan b/test/vm-image/nightly-plan new file mode 100755 index 0000000..5738781 --- /dev/null +++ b/test/vm-image/nightly-plan @@ -0,0 +1,42 @@ +#!/bin/bash +# Says which channels a nightly run makes, under which version, and where +# each one is published. It decides; the workflow fetches what it needs to +# know and passes it in, so every case can be tried without a runner. +# +# nightly-plan [published_stable] +# +# date today, as YYYY.MM.DD +# event what started the run: schedule, workflow_dispatch +# release_tag omacom/omarchy's latest release, such as v4.0.4 +# published_stable the version stable/latest.json names now, if any +# +# Prints a JSON list of {channel, version, release}; release is the path +# under the site. +# +# An edge version is the date and the run number. A stable version is the +# release's own, and on a scheduled run a release already published is left +# out: it is done once. Two builds are never the same bytes and a published +# file is never replaced, so no path is used twice: a stable image has the run +# number in its path, and a run started again adds its attempt to the number. +set -euo pipefail +[[ $# -eq 5 || $# -eq 6 ]] || { sed -n '2,12p' "$0" >&2; exit 2; } +date=$1 run=$2 attempt=$3 event=$4 tag=$5 published=${6:-} +fail() { echo "nightly-plan: $*" >&2; exit 1; } + +[[ $date =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || fail "'$date' is not a date like 2026.10.09" +[[ $run =~ ^[1-9][0-9]*$ ]] || fail "'$run' is not a run number" +[[ $attempt =~ ^[1-9][0-9]*$ ]] || fail "'$attempt' is not a run attempt" +stable=${tag#v} +[[ $stable =~ ^[0-9]+(\.[0-9]+)+$ ]] || fail "the latest release is '$tag', not a version" + +build=$run +[[ $attempt == 1 ]] || build+=.$attempt + +channels=$(jq -cn --arg v "$date.$build" '[{channel: "edge", version: $v, release: "edge/\($v)"}]') +if [[ $event == schedule && $published == "$stable" ]]; then + echo "nightly-plan: stable $stable is already published" >&2 +else + channels=$(jq -c --arg v "$stable" --arg build "$build" \ + '. + [{channel: "stable", version: $v, release: "stable/\($v)/\($build)"}]' <<<"$channels") +fi +echo "$channels" From 65589d21380140b36849d98a2b4977969a0a94a9 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 20:12:51 +0200 Subject: [PATCH 19/31] README: the nightly's badge Passing or failing for the last scheduled run on quattro. Runs started by hand, on a branch or not, do not change it. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1ec23ea..489917f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Omarchy ISO +[![Nightly ISO Build](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml/badge.svg?branch=quattro&event=schedule)](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml?query=branch%3Aquattro+event%3Aschedule) + The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user. ## Downloading the latest ISO From f99443dc4c1b393b4a78d90bd63ba8ce8544fd30 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 20:14:03 +0200 Subject: [PATCH 20/31] Nightly: no package cache The cache kept the ISO's packages between runs. A package that has gone missing from the mirror would then still be found on the runner, and the nightly would build and pass while a fresh machine could not. Every package is fetched again each night: with the T2 repository asked on GitHub first, the build is 3 to 4 minutes without a cache. --- .github/workflows/nightly-build.yml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 320f604..5fc41c7 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -98,18 +98,9 @@ jobs: - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 if: matrix.channel == 'edge' - # What a build downloads and would download again tomorrow: the packages - # the ISO's offline mirror is made of (the build prunes the ones it no - # longer needs) and the test container's own. Skipped, not failed, on a - # runner without a cache volume. - - name: Package cache (Namespace) - continue-on-error: true - uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 - with: - path: | - ~/.cache/omarchy - ~/.cache/omarchy-ci - + # No package cache, on purpose: every package the ISO is made of is + # fetched from the mirrors again each night, so one that has gone + # missing there fails this build instead of being served from a cache. - name: Build the edge ISO if: matrix.channel == 'edge' run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make --edge @@ -136,9 +127,7 @@ jobs: - name: Install unattended, boot the installed system, seal it run: | sudo chmod 0666 /dev/kvm - mkdir -p ~/.cache/omarchy-ci/pacman docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \ - -v "$HOME/.cache/omarchy-ci/pacman:/var/cache/pacman/pkg" \ -e OMARCHY_INTEGRATION_DISK_CACHE=none \ archlinux:latest bash -euo pipefail -c ' # pacman 7 fails every sync on a kernel without Landlock, which From 61b309cf9a7ef39586b5024afe2afd1e51c5795c Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 20:16:04 +0200 Subject: [PATCH 21/31] Unit tests: the container needs diff and jq The offline mirror index test compares databases with diff and the nightly test reads JSON with jq. Neither is in the Arch base image, so both tests failed in CI while passing on a workstation. --- .github/workflows/unit-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 2f11608..af8c1f2 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -46,7 +46,7 @@ jobs: sleep 1 done echo "systemd: $state" - pacman -Syu --noconfirm --needed python parted >/dev/null + pacman -Syu --noconfirm --needed python parted diffutils jq >/dev/null useradd -m tester cp -r /src /home/tester/omarchy-iso chown -R tester /home/tester/omarchy-iso From 18a968f467b2cef41623fab27e22b0923cf5a850 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:13:54 +0200 Subject: [PATCH 22/31] README: what is published, and where One table of the release ISO, the edge ISO and the two VM images with their addresses, then a part for each: the release ISO and its checksum, the nightly, every field of latest.json, and the files of a VM image with the commands that fetch, check and boot one. --- README.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 489917f..d85a440 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,85 @@ The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user. -## Downloading the latest ISO +## What is published, and where -See the ISO link on [omarchy.org](https://omarchy.org). +| What | Where | When | +| --- | --- | --- | +| Release ISO | `https://iso.omarchy.org/omarchy-.iso`, linked from [omarchy.org](https://omarchy.org) | Each release | +| Edge ISO | `https://nightly.omarchy.org/edge//omarchy-edge-.iso` | Every night | +| VM image of edge | `https://nightly.omarchy.org/edge//vm/` | Every night | +| VM image of the last release | `https://nightly.omarchy.org/stable///vm/` | Once per release | -Every published ISO has a `.sha256` beside it at the same URL. Download both into the same directory and check the ISO before writing it to a USB stick: +Nobody has to build those paths: each channel has a `latest.json` that names the newest version and gives the full URLs. -```bash -sha256sum -c omarchy-3.0.iso.sha256 +``` +https://nightly.omarchy.org/edge/latest.json +https://nightly.omarchy.org/stable/latest.json ``` -Corruption anywhere in the ISO is worth catching before the write, and corruption in the bundled package mirror is worth catching most: the mirror lives inside the ISO and the installer reads it straight off the medium, so those bytes surface minutes into the install as a pacman "invalid or corrupted package" error rather than as anything that names the download. Corruption elsewhere is louder and earlier — it stops the medium booting or mounting. There is a `.sig` beside the ISO too for anyone who wants to verify it against the Omarchy signing key. +A published file is never replaced. Only `latest.json` changes, and it is written last, after everything it points at has been fetched back and compared. + +### Release ISO + +Every release ISO has a `.sha256` and a `.sig` beside it at the same URL. Download the ISO and its `.sha256` into the same directory and check the ISO before writing it to a USB stick: + +```bash +sha256sum -c omarchy-4.0.4.iso.sha256 +``` + +Corruption anywhere in the ISO is worth catching before the write, and corruption in the bundled package mirror is worth catching most: the mirror lives inside the ISO and the installer reads it straight off the medium, so those bytes surface minutes into the install as a pacman "invalid or corrupted package" error rather than as anything that names the download. Corruption elsewhere is louder and earlier — it stops the medium booting or mounting. The `.sig` is for anyone who wants to verify the ISO against the Omarchy signing key. + +### Nightly builds + +The nightly (`.github/workflows/nightly-build.yml`) builds the edge ISO from `quattro` and the edge packages, installs it unattended, boots what it installed, and turns that installed disk into a VM image. It does the same once for each release, from the release ISO. Nothing is published unless the install and the image's checks passed. + +### latest.json + +| Field | What it is | +| --- | --- | +| `version` | Edge: the build's date and run number, `2026.10.09.431`. Stable: the release, `4.0.4`. | +| `channel` | `edge` or `stable`. | +| `built_at` | When the image was made, UTC, `2026-10-09T18:02:35Z`. | +| `url` | The ISO that was installed. Edge: on nightly.omarchy.org. Stable: the release ISO on iso.omarchy.org. | +| `sha256`, `size` | The ISO's SHA-256 and its size in bytes. | +| `iso_commit` | The omarchy-iso commit the nightly ran from. | +| `omarchy_version` | The version of the Omarchy package in the image. | +| `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty, the version names it. | +| `kernel` | The kernel release the image boots, as `uname -r` gives it. | +| `vm.url` | The folder the image's files are in, ending in `/`. | +| `vm.disk`, `vm.disk_sha256`, `vm.disk_size` | The disk image, `base.qcow2`: its URL, SHA-256 and size in bytes. | +| `vm.files` | Every file in `vm.url`. | +| `vm.user`, `vm.password` | The account for the desktop and `sudo`. | +| `vm.ssh` | How SSH gets in: keys only, never the password. | + +The version is also in the edge ISO's file name, for a consumer that reads it from there. + +### VM image + +A VM image is Omarchy already installed: it boots to the desktop in a few seconds, with no installer to go through. `vm.url` holds: + +| File | What it is | +| --- | --- | +| `base.qcow2` | The disk, a zstd-compressed qcow2. | +| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments, for booting it directly without firmware or boot loader. | +| `OVMF_VARS.4m.fd` | The firmware variables with the installer's boot entry, for booting it through UEFI instead. | +| `vm-boot` | A script that boots the image with QEMU. | +| `SHA256SUMS` | Checksums of the files above. | + +Every boot is its own machine: a new machine ID and new SSH host keys, and nothing of the build left in the image. To get one and run it, on a machine with QEMU and KVM: + +```bash +latest=https://nightly.omarchy.org/edge/latest.json +vm=$(curl -fsSL "$latest" | jq -r .vm.url) +for file in $(curl -fsSL "$latest" | jq -r '.vm.files[]'); do curl -fsSLO "$vm$file"; done +sha256sum -c SHA256SUMS +chmod +x vm-boot + +./vm-boot . # boots, prints how to log in, runs until Ctrl-C +./vm-boot . uname -r # boots, runs the command over SSH, powers off +``` + +`vm-boot` runs the machine on a throwaway overlay, so the downloaded image is never written to, and makes an SSH key for each boot. Its header lists what it needs and the settings it takes. ## Creating the ISO From 2a39b849c1c6ca5d5a8674f606c496e780957e16 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:18:54 +0200 Subject: [PATCH 23/31] README: a part for users, a part for maintainers The top is for someone who wants Omarchy: where to get the ISO and how to check it, how to try a VM image, and autoinstall. Everything about building, testing and publishing follows under "For maintainers", with what is published where, how the nightly works, the fields of latest.json and the files of a VM image. A read against the code corrected several statements, some of them older: the installer runs omarchy-provision-user; a defer-provisioning marker can stand in for the credentials file; an authorized_keys file fails the install when it holds no keys, not when its keys are bad; the stock firewall also allows Docker's DNS; built_at is when latest.json was written; vm-boot's needs are listed in full. The publish job now fetches the two checksum files back as well, so "every file of the version is compared" is exact. --- .github/workflows/nightly-build.yml | 5 + README.md | 172 +++++++++++++++------------- 2 files changed, 100 insertions(+), 77 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 5fc41c7..57e48aa 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -261,13 +261,18 @@ jobs: [ "$got" = "$2" ] || { echo "::error::$1 serves $got, expected $2"; exit 1; } echo "ok $1" } + # Every file of the version, the checksum files too. for iso in "$dir"/*.iso; do [[ -f $iso ]] || continue check "$SITE/$RELEASE/${iso##*/}" "$(cut -d" " -f1 "$iso.sha256")" + check "$SITE/$RELEASE/${iso##*/}.sha256" "$(sha256sum "$iso.sha256" | cut -d" " -f1)" done while read -r sum name; do check "$SITE/$RELEASE/vm/$name" "$sum" done <"$dir/vm/SHA256SUMS" + check "$SITE/$RELEASE/vm/SHA256SUMS" "$(sha256sum "$dir/vm/SHA256SUMS" | cut -d" " -f1)" + published=$(find "$dir" -type f | wc -l) + echo "$published files published and checked" - name: Point latest.json at it run: | diff --git a/README.md b/README.md index d85a440..9838d41 100644 --- a/README.md +++ b/README.md @@ -2,77 +2,28 @@ [![Nightly ISO Build](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml/badge.svg?branch=quattro&event=schedule)](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml?query=branch%3Aquattro+event%3Aschedule) -The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user. +The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-provision-user` for that user. -## What is published, and where +## Get Omarchy -| What | Where | When | -| --- | --- | --- | -| Release ISO | `https://iso.omarchy.org/omarchy-.iso`, linked from [omarchy.org](https://omarchy.org) | Each release | -| Edge ISO | `https://nightly.omarchy.org/edge//omarchy-edge-.iso` | Every night | -| VM image of edge | `https://nightly.omarchy.org/edge//vm/` | Every night | -| VM image of the last release | `https://nightly.omarchy.org/stable///vm/` | Once per release | +Download the ISO from [omarchy.org](https://omarchy.org), write it to a USB stick and boot from it. -Nobody has to build those paths: each channel has a `latest.json` that names the newest version and gives the full URLs. - -``` -https://nightly.omarchy.org/edge/latest.json -https://nightly.omarchy.org/stable/latest.json -``` - -A published file is never replaced. Only `latest.json` changes, and it is written last, after everything it points at has been fetched back and compared. - -### Release ISO - -Every release ISO has a `.sha256` and a `.sig` beside it at the same URL. Download the ISO and its `.sha256` into the same directory and check the ISO before writing it to a USB stick: +Check the download first. Every release ISO has a `.sha256` beside it at the same URL; put both in one directory: ```bash sha256sum -c omarchy-4.0.4.iso.sha256 ``` -Corruption anywhere in the ISO is worth catching before the write, and corruption in the bundled package mirror is worth catching most: the mirror lives inside the ISO and the installer reads it straight off the medium, so those bytes surface minutes into the install as a pacman "invalid or corrupted package" error rather than as anything that names the download. Corruption elsewhere is louder and earlier — it stops the medium booting or mounting. The `.sig` is for anyone who wants to verify the ISO against the Omarchy signing key. +A damaged download otherwise shows up minutes into the install, as a pacman "invalid or corrupted package" error. There is a `.sig` beside the ISO too, for verifying it against the Omarchy signing key. -### Nightly builds +## Try it in a VM -The nightly (`.github/workflows/nightly-build.yml`) builds the edge ISO from `quattro` and the edge packages, installs it unattended, boots what it installed, and turns that installed disk into a VM image. It does the same once for each release, from the release ISO. Nothing is published unless the install and the image's checks passed. +A VM image is Omarchy already installed: no installer to go through, and it boots in seconds. There is one of the last release and one of edge, which is rebuilt every night from what is about to become the next release and can be broken on any given day. -### latest.json - -| Field | What it is | -| --- | --- | -| `version` | Edge: the build's date and run number, `2026.10.09.431`. Stable: the release, `4.0.4`. | -| `channel` | `edge` or `stable`. | -| `built_at` | When the image was made, UTC, `2026-10-09T18:02:35Z`. | -| `url` | The ISO that was installed. Edge: on nightly.omarchy.org. Stable: the release ISO on iso.omarchy.org. | -| `sha256`, `size` | The ISO's SHA-256 and its size in bytes. | -| `iso_commit` | The omarchy-iso commit the nightly ran from. | -| `omarchy_version` | The version of the Omarchy package in the image. | -| `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty, the version names it. | -| `kernel` | The kernel release the image boots, as `uname -r` gives it. | -| `vm.url` | The folder the image's files are in, ending in `/`. | -| `vm.disk`, `vm.disk_sha256`, `vm.disk_size` | The disk image, `base.qcow2`: its URL, SHA-256 and size in bytes. | -| `vm.files` | Every file in `vm.url`. | -| `vm.user`, `vm.password` | The account for the desktop and `sudo`. | -| `vm.ssh` | How SSH gets in: keys only, never the password. | - -The version is also in the edge ISO's file name, for a consumer that reads it from there. - -### VM image - -A VM image is Omarchy already installed: it boots to the desktop in a few seconds, with no installer to go through. `vm.url` holds: - -| File | What it is | -| --- | --- | -| `base.qcow2` | The disk, a zstd-compressed qcow2. | -| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments, for booting it directly without firmware or boot loader. | -| `OVMF_VARS.4m.fd` | The firmware variables with the installer's boot entry, for booting it through UEFI instead. | -| `vm-boot` | A script that boots the image with QEMU. | -| `SHA256SUMS` | Checksums of the files above. | - -Every boot is its own machine: a new machine ID and new SSH host keys, and nothing of the build left in the image. To get one and run it, on a machine with QEMU and KVM: +On a Linux machine with QEMU (`qemu-system-x86_64` and `qemu-img`), a `/dev/kvm` you can read and write, `ssh`, `curl` and `jq`: ```bash -latest=https://nightly.omarchy.org/edge/latest.json +latest=https://nightly.omarchy.org/stable/latest.json # or .../edge/latest.json vm=$(curl -fsSL "$latest" | jq -r .vm.url) for file in $(curl -fsSL "$latest" | jq -r '.vm.files[]'); do curl -fsSLO "$vm$file"; done sha256sum -c SHA256SUMS @@ -82,22 +33,14 @@ chmod +x vm-boot ./vm-boot . uname -r # boots, runs the command over SSH, powers off ``` -`vm-boot` runs the machine on a throwaway overlay, so the downloaded image is never written to, and makes an SSH key for each boot. Its header lists what it needs and the settings it takes. +The user is `omarchy` with password `omarchy`. SSH takes keys only, and `vm-boot` makes one for each boot. Every boot is a new machine, and the downloaded image is never written to: the machine runs on an overlay under `/var/tmp` (`VM_STATE_DIR` moves it) that is deleted on exit. The disk is a download of 4 to 5 GB. -## Creating the ISO - -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. - -For local development, build the ISO from sibling checkouts: +The edge ISO itself is published every night as well: ```bash -./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs +curl -fsSL https://nightly.omarchy.org/edge/latest.json | jq -r .url ``` -Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo. - -Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. - ## Autoinstall The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry. @@ -118,7 +61,7 @@ These are the configurator's own output files, so the way to get a starting set | `authorized_keys` | No | SSH public keys in sshd's own format, one per line | | `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot | -Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. +Both required files must be present or the installer falls back to the configurator. (A `defer-provisioning` marker file can stand in for `user_credentials.json`: user creation is then deferred to first boot.) Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. @@ -128,7 +71,7 @@ Encryption itself is configured by the `disk_encryption` block inside `user_conf ssh-ed25519 AAAA... you@host ``` -When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach. +When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend and Docker's DNS. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no keys in it fails the install rather than producing a machine nobody can reach. When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs. @@ -160,7 +103,82 @@ Boot order is disk first: the empty disk falls through to the ISO on the first b Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot. -## Testing the ISO +## For maintainers + +Everything from here on is about building, testing and publishing the ISO. + +### What is published, and where + +| What | Where | When | +| --- | --- | --- | +| Release ISO, with `.sha256` and `.sig` | `https://iso.omarchy.org/omarchy-.iso` | Each release | +| Edge ISO, with `.sha256` | `https://nightly.omarchy.org/edge//omarchy-edge-.iso` | Every night | +| VM image of edge | `https://nightly.omarchy.org/edge//vm/` | Every night | +| VM image of the last release | `https://nightly.omarchy.org/stable///vm/` | Once per release | +| `latest.json` | `https://nightly.omarchy.org/edge/latest.json` and `.../stable/latest.json` | With each of the three above | + +On nightly.omarchy.org a published file is never replaced. Only `latest.json` changes, and it is written last, after every file of the version has been fetched back through the CDN and compared. + +### The nightly + +`.github/workflows/nightly-build.yml` runs every night and for two channels: + +- `edge`: builds the ISO from the branch the run is on, which is `quattro` for the nightly, with the edge packages. +- `stable`: takes the last release. The latest release of omacom/omarchy names the version, iso.omarchy.org has the ISO, and it is used only if its signature is the release key's. A release already published is skipped on the nightly run; a run started by hand makes it again. + +Each ISO is installed unattended and the installed system booted, by the same install test a pull request gets. `test/vm-image/vm-seal` then turns the installed disk into a VM image and `test/vm-image/vm-verify` boots that image and checks it. Nothing is published unless all of it passed. + +Publishing is a job of its own, the only one with the bucket's key, and it runs on `quattro` only. To try a change, run the workflow by hand on a branch: that builds and checks everything and publishes nothing. + +Which channels a run makes and where they go is decided by `test/vm-image/nightly-plan`; what is laid out and what `latest.json` says by `test/vm-image/nightly-layout`. Both are covered by `test/unit/nightly-test.sh`. + +### latest.json + +| Field | What it is | +| --- | --- | +| `version` | Edge: the build's date and run number, `2026.10.09.431`. Stable: the release, `4.0.4`. | +| `channel` | `edge` or `stable`. | +| `built_at` | When this file was written, once the image had passed its checks. UTC, `2026-10-09T18:02:35Z`. | +| `url` | The ISO that was installed. Edge: on nightly.omarchy.org. Stable: the release ISO on iso.omarchy.org. | +| `sha256`, `size` | The ISO's SHA-256 and its size in bytes. | +| `iso_commit` | The omarchy-iso commit the nightly ran from. | +| `omarchy_version` | The version of the Omarchy package in the image. | +| `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty, the version names it. | +| `kernel` | The kernel release the image boots, as `uname -r` gives it. | +| `vm.url` | The folder the image's files are in, ending in `/`. | +| `vm.disk`, `vm.disk_sha256`, `vm.disk_size` | The disk image, `base.qcow2`: its URL, SHA-256 and size in bytes. | +| `vm.files` | Every file in `vm.url`. | +| `vm.user`, `vm.password` | The account for the desktop and `sudo`. | +| `vm.ssh` | How SSH gets in: keys only, never the password. | + +The version is also in the edge ISO's file name. + +### A VM image's files + +| File | What it is | +| --- | --- | +| `base.qcow2` | The disk, a zstd-compressed qcow2. | +| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments, for booting it directly without firmware or boot loader. | +| `OVMF_VARS.4m.fd` | The firmware variables with the installer's boot entry, for booting it through UEFI instead. | +| `vm-boot` | A script that boots the image with QEMU. | +| `SHA256SUMS` | Checksums of the files above. | + + +### Creating the ISO + +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. + +For local development, build the ISO from sibling checkouts: + +```bash +./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs +``` + +Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo. + +Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. + +### Testing the ISO Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. @@ -172,7 +190,7 @@ synthetic disk in `/tmp` with an existing ESP and data partition plus ample unallocated space, then offers to start an interactive installation on it. The fixture exercises Windows partition preservation but does not contain Windows. -## Acceptance testing the ISO +### Acceptance testing the ISO Run `./bin/omarchy-iso-test [release/omarchy.iso]` to install the ISO into a headless VM by driving the real interactive install flow — the harness reads each screen via QMP screendumps + OCR and answers with virtual keystrokes, so the configurator wizard, install dashboard, reboot prompt, and SDDM login are all exercised exactly as a user would. It then boots the installed system, sends real VM keyboard shortcuts for the primary shell and window-management actions, and runs the in-guest acceptance suite (`test/acceptance` in the omarchy repo). The suite checks session and service health, the complete core-package manifest, user defaults, representative applications, menus, panels, live weather, launchers, visual selectors, notifications, clipboard, and other interactive shell behavior. @@ -188,7 +206,7 @@ The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available Pass `--encrypt` to drive the encrypted install flow (including typing the LUKS passphrase at boot) instead of the unencrypted one. Pass `--no-preview` to collect the same visual artifacts without opening them in `imv` when the run finishes. -## Integration testing the ISO +### Integration testing the ISO Scenarios under `test/integration.d/` boot a real ISO install in QEMU and assert on what the running system actually does. The runner installs the ISO once — unattended, from a generated cidata drive — and saves the result as a reusable base image; every scenario then boots a throwaway overlay of that base with its own copy of the firmware vars, so neither disk nor NVRAM state leaks between runs. Shared machinery (VM lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest SSH, the cidata build) lives in `test/integration.d/base-test.sh`, so a new scenario is one file. @@ -202,14 +220,14 @@ The first scenario is `factory-reset`: it proves `omarchy-system-factory-reset` Artifacts — screenshots, the fixtured/staged/final `limine.conf`, the reset typescript, and the factory-reset log — land under `test-runs/-integration/runs/-/`, and `--no-preview` skips the `imv` review just like the acceptance harness. -## Signing the ISO +### Signing the ISO Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI. -## Uploading the ISO +### Uploading the ISO Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). The `.sig` and `.sha256` sidecars go up with the ISO when they exist beside it. -## Full release of the ISO +### Full release of the ISO Run `./bin/omarchy-iso-release VERSION` to create, test, sign, and upload the ISO in one flow. Add `--rc` to release an RC build instead. From 469052b93eea644395902ff2985e3e6ec18618a1 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:23:37 +0200 Subject: [PATCH 24/31] README: the edge ISO is built from the edge channel Packages, mirrors and Omarchy itself (omarchy-dev) all come from edge. The line named the branch the workflow runs on as if it were a second source. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9838d41..c8194fd 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ On nightly.omarchy.org a published file is never replaced. Only `latest.json` ch `.github/workflows/nightly-build.yml` runs every night and for two channels: -- `edge`: builds the ISO from the branch the run is on, which is `quattro` for the nightly, with the edge packages. +- `edge`: builds the ISO from the edge channel: the edge packages and mirrors, and the edge builds of Omarchy (`omarchy-dev`). - `stable`: takes the last release. The latest release of omacom/omarchy names the version, iso.omarchy.org has the ISO, and it is used only if its signature is the release key's. A release already published is skipped on the nightly run; a run started by hand makes it again. Each ISO is installed unattended and the installed system booted, by the same install test a pull request gets. `test/vm-image/vm-seal` then turns the installed disk into a VM image and `test/vm-image/vm-verify` boots that image and checks it. Nothing is published unless all of it passed. From 82cec53728fa48f1d819633ccf387c71e33552e1 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:23:51 +0200 Subject: [PATCH 25/31] Nightly: the header says edge is built from the edge channel --- .github/workflows/nightly-build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 57e48aa..51bdb21 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -4,7 +4,8 @@ name: Nightly ISO Build # installed (the install test), turns that installed disk into a VM image, and # publishes it with a latest.json saying what is in it. # -# edge the ISO is built here, from quattro and the edge packages +# edge the ISO is built here, from the edge channel: its packages, its +# mirrors and the edge builds of Omarchy (omarchy-dev) # stable the ISO is the last release: omacom/omarchy's latest release names # the version, iso.omarchy.org has the ISO, its signature is checked. # Done once for each release, skipped on the nights after. From 04ff1f5ea1db503156564f75668fc1aeece6b4cb Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:26:31 +0200 Subject: [PATCH 26/31] README: the top follows the manual's Getting Started Download, write to a stick, turn off Secure Boot, boot, answer the questions: the manual's own words, shortened, then links to its pages on getting started, dual booting and unattended installs. The detailed autoinstall reference moves under "For maintainers" and points at the manual's page for users. --- README.md | 136 +++++++++++++++++++++++++++++------------------------- 1 file changed, 72 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index c8194fd..fc67207 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarc ## Get Omarchy -Download the ISO from [omarchy.org](https://omarchy.org), write it to a USB stick and boot from it. +[Download the ISO](https://omarchy.org) and put it on a USB stick, with [balenaEtcher](https://etcher.balena.io/) on Mac or Windows or [caligula](https://github.com/ifd3f/caligula) on Linux. Turn off Secure Boot and/or TPM in the BIOS, boot off the stick, answer the configuration questions and select a drive. The install can be done in under a minute on the fastest modern machines and shouldn't take more than 5 minutes even on an older computer. -Check the download first. Every release ISO has a `.sha256` beside it at the same URL; put both in one directory: +Check the download before writing it to the stick. Every release ISO has a `.sha256` beside it at the same URL, linked next to the download; put both in one directory: ```bash sha256sum -c omarchy-4.0.4.iso.sha256 @@ -16,6 +16,12 @@ sha256sum -c omarchy-4.0.4.iso.sha256 A damaged download otherwise shows up minutes into the install, as a pacman "invalid or corrupted package" error. There is a `.sig` beside the ISO too, for verifying it against the Omarchy signing key. +The manual has the rest: + +- [Getting started](https://omarchy.org/manual/getting-started/): the install step by step, installing for another owner, installing without encryption. +- [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): Omarchy in the free space of a drive, beside Windows or another OS. +- [Unattended installs](https://omarchy.org/manual/unattended-installs/): the ISO installs itself, with no keyboard and no wizard, from a configuration on a second drive. + ## Try it in a VM A VM image is Omarchy already installed: no installer to go through, and it boots in seconds. There is one of the last release and one of edge, which is rebuilt every night from what is about to become the next release and can be broken on any given day. @@ -41,68 +47,6 @@ The edge ISO itself is published every night as well: curl -fsSL https://nightly.omarchy.org/edge/latest.json | jq -r .url ``` -## Autoinstall - -The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry. - -`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one. - -### Configuration files - -These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`. - -| File | Required | Purpose | -|------|----------|---------| -| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard | -| `user_credentials.json` | Yes | Username and password hash | -| `user_full_name.txt` | No | Git full name | -| `user_email_address.txt` | No | Git email | -| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | -| `authorized_keys` | No | SSH public keys in sshd's own format, one per line | -| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot | - -Both required files must be present or the installer falls back to the configurator. (A `defer-provisioning` marker file can stand in for `user_credentials.json`: user creation is then deferred to first boot.) Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. - -Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. - -`authorized_keys` is the same file sshd reads — copy your own or write one key per line: - -``` -ssh-ed25519 AAAA... you@host -``` - -When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend and Docker's DNS. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no keys in it fails the install rather than producing a machine nobody can reach. - -When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs. - -### Building the drive - -```bash -mkdir cidata -cp user_configuration.json user_credentials.json authorized_keys cidata/ -genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/ -``` - -### Proxmox example - -```bash -qm create 101 --name my-omarchy \ - --bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \ - --ostype l26 --scsihw virtio-scsi-single \ - --efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \ - --scsi0 local-lvm:40,discard=on,iothread=1 \ - --net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \ - --ide2 local:iso/omarchy.iso,media=cdrom \ - --ide3 local:iso/cidata.iso,media=cdrom \ - --boot order='scsi0;ide2' - -qm start 101 -``` - -Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes. - -Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot. - ## For maintainers Everything from here on is about building, testing and publishing the ISO. @@ -164,6 +108,70 @@ The version is also in the edge ISO's file name. | `SHA256SUMS` | Checksums of the files above. | +### Autoinstall + +The [manual's page](https://omarchy.org/manual/unattended-installs/) is the one for users; this is the same feature in full. + +The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry. + +`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one. + +#### Configuration files + +These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`. + +| File | Required | Purpose | +|------|----------|---------| +| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard | +| `user_credentials.json` | Yes | Username and password hash | +| `user_full_name.txt` | No | Git full name | +| `user_email_address.txt` | No | Git email | +| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | +| `authorized_keys` | No | SSH public keys in sshd's own format, one per line | +| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot | + +Both required files must be present or the installer falls back to the configurator. (A `defer-provisioning` marker file can stand in for `user_credentials.json`: user creation is then deferred to first boot.) Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. + +Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. + +`authorized_keys` is the same file sshd reads — copy your own or write one key per line: + +``` +ssh-ed25519 AAAA... you@host +``` + +When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend and Docker's DNS. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no keys in it fails the install rather than producing a machine nobody can reach. + +When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs. + +#### Building the drive + +```bash +mkdir cidata +cp user_configuration.json user_credentials.json authorized_keys cidata/ +genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/ +``` + +#### Proxmox example + +```bash +qm create 101 --name my-omarchy \ + --bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \ + --ostype l26 --scsihw virtio-scsi-single \ + --efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \ + --scsi0 local-lvm:40,discard=on,iothread=1 \ + --net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \ + --ide2 local:iso/omarchy.iso,media=cdrom \ + --ide3 local:iso/cidata.iso,media=cdrom \ + --boot order='scsi0;ide2' + +qm start 101 +``` + +Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes. + +Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot. + ### Creating the ISO 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. From 97e06f025fa83f52cbe5471cc3559f16b03be281 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:27:31 +0200 Subject: [PATCH 27/31] README: users get the release ISO from iso.omarchy.org The part for users pointed at nightly.omarchy.org for a VM image. Users are sent to the release ISO only: the commands read the latest release of omacom/omarchy, fetch that ISO and its checksum from iso.omarchy.org and check it. The nightly site and the VM images are for testing, and everything about them is now under "For maintainers". --- README.md | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index fc67207..8d849b7 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,14 @@ The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarc [Download the ISO](https://omarchy.org) and put it on a USB stick, with [balenaEtcher](https://etcher.balena.io/) on Mac or Windows or [caligula](https://github.com/ifd3f/caligula) on Linux. Turn off Secure Boot and/or TPM in the BIOS, boot off the stick, answer the configuration questions and select a drive. The install can be done in under a minute on the fastest modern machines and shouldn't take more than 5 minutes even on an older computer. -Check the download before writing it to the stick. Every release ISO has a `.sha256` beside it at the same URL, linked next to the download; put both in one directory: +Check the download before writing it to the stick. Every release ISO has a `.sha256` beside it at the same URL, linked next to the download. From a terminal, this fetches the latest release and checks it; the version is the latest release of [omacom/omarchy](https://github.com/omacom/omarchy/releases): ```bash -sha256sum -c omarchy-4.0.4.iso.sha256 +version=$(curl -fsSL https://api.github.com/repos/omacom/omarchy/releases/latest | jq -r .tag_name) +version=${version#v} +curl -fLO "https://iso.omarchy.org/omarchy-$version.iso" +curl -fsSLO "https://iso.omarchy.org/omarchy-$version.iso.sha256" +sha256sum -c "omarchy-$version.iso.sha256" ``` A damaged download otherwise shows up minutes into the install, as a pacman "invalid or corrupted package" error. There is a `.sig` beside the ISO too, for verifying it against the Omarchy signing key. @@ -22,31 +26,6 @@ The manual has the rest: - [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): Omarchy in the free space of a drive, beside Windows or another OS. - [Unattended installs](https://omarchy.org/manual/unattended-installs/): the ISO installs itself, with no keyboard and no wizard, from a configuration on a second drive. -## Try it in a VM - -A VM image is Omarchy already installed: no installer to go through, and it boots in seconds. There is one of the last release and one of edge, which is rebuilt every night from what is about to become the next release and can be broken on any given day. - -On a Linux machine with QEMU (`qemu-system-x86_64` and `qemu-img`), a `/dev/kvm` you can read and write, `ssh`, `curl` and `jq`: - -```bash -latest=https://nightly.omarchy.org/stable/latest.json # or .../edge/latest.json -vm=$(curl -fsSL "$latest" | jq -r .vm.url) -for file in $(curl -fsSL "$latest" | jq -r '.vm.files[]'); do curl -fsSLO "$vm$file"; done -sha256sum -c SHA256SUMS -chmod +x vm-boot - -./vm-boot . # boots, prints how to log in, runs until Ctrl-C -./vm-boot . uname -r # boots, runs the command over SSH, powers off -``` - -The user is `omarchy` with password `omarchy`. SSH takes keys only, and `vm-boot` makes one for each boot. Every boot is a new machine, and the downloaded image is never written to: the machine runs on an overlay under `/var/tmp` (`VM_STATE_DIR` moves it) that is deleted on exit. The disk is a download of 4 to 5 GB. - -The edge ISO itself is published every night as well: - -```bash -curl -fsSL https://nightly.omarchy.org/edge/latest.json | jq -r .url -``` - ## For maintainers Everything from here on is about building, testing and publishing the ISO. @@ -108,6 +87,27 @@ The version is also in the edge ISO's file name. | `SHA256SUMS` | Checksums of the files above. | +### Using a VM image + +For testing, not for users: users get the release ISO from iso.omarchy.org. A VM image is Omarchy already installed, with no installer to go through, and it boots in seconds. There is one of edge, rebuilt every night, and one of the last release. + +On a Linux machine with QEMU (`qemu-system-x86_64` and `qemu-img`), a `/dev/kvm` you can read and write, `ssh`, `curl` and `jq`: + +```bash +latest=https://nightly.omarchy.org/edge/latest.json # or .../stable/latest.json +vm=$(curl -fsSL "$latest" | jq -r .vm.url) +for file in $(curl -fsSL "$latest" | jq -r '.vm.files[]'); do curl -fsSLO "$vm$file"; done +sha256sum -c SHA256SUMS +chmod +x vm-boot + +./vm-boot . # boots, prints how to log in, runs until Ctrl-C +./vm-boot . uname -r # boots, runs the command over SSH, powers off +``` + +The user is `omarchy` with password `omarchy`. SSH takes keys only, and `vm-boot` makes one for each boot. Every boot is a new machine, and the downloaded image is never written to: the machine runs on an overlay under `/var/tmp` (`VM_STATE_DIR` moves it) that is deleted on exit. The disk is a download of 4 to 5 GB. + +The nightly's edge ISO is `jq -r .url` of the same `latest.json`. + ### Autoinstall The [manual's page](https://omarchy.org/manual/unattended-installs/) is the one for users; this is the same feature in full. From 2b8d0126882aa860e2090299dda99476d9587fb3 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:32:53 +0200 Subject: [PATCH 28/31] README: plainer writing The install is a numbered list of steps, each starting with its verb. Verifying the download and the manual's pages have their own headings. Long sentences are split, compressed ones written out, and one thing has one name: "nightly build", "latest release". --- README.md | 66 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8d849b7..3734e9e 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,23 @@ [![Nightly ISO Build](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml/badge.svg?branch=quattro&event=schedule)](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml?query=branch%3Aquattro+event%3Aschedule) -The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-provision-user` for that user. +The Omarchy ISO is the only supported way to install Omarchy. It includes the Omarchy Configurator and installs Arch Linux and the Omarchy packages from the bundled mirror. It then runs target system setup in the chroot, creates the user, and runs `omarchy-provision-user` for that user. ## Get Omarchy -[Download the ISO](https://omarchy.org) and put it on a USB stick, with [balenaEtcher](https://etcher.balena.io/) on Mac or Windows or [caligula](https://github.com/ifd3f/caligula) on Linux. Turn off Secure Boot and/or TPM in the BIOS, boot off the stick, answer the configuration questions and select a drive. The install can be done in under a minute on the fastest modern machines and shouldn't take more than 5 minutes even on an older computer. +1. [Download the ISO](https://omarchy.org). +2. Write it to a USB stick. Use [balenaEtcher](https://etcher.balena.io/) on Mac or Windows, or [caligula](https://github.com/ifd3f/caligula) on Linux. +3. Turn off Secure Boot and/or TPM in the BIOS. +4. Boot from the stick. +5. Answer the configuration questions and select a drive. -Check the download before writing it to the stick. Every release ISO has a `.sha256` beside it at the same URL, linked next to the download. From a terminal, this fetches the latest release and checks it; the version is the latest release of [omacom/omarchy](https://github.com/omacom/omarchy/releases): +The install takes under a minute on the fastest machines and up to 5 minutes on an older computer. + +### Verify the download + +Verify the ISO before you write it to the stick. Otherwise, a corrupted download causes a pacman "invalid or corrupted package" error several minutes into the install. + +Every release ISO has a `.sha256` file and a `.sig` file next to it. Run these commands to download and verify the ISO of the latest release of [omacom/omarchy](https://github.com/omacom/omarchy/releases): ```bash version=$(curl -fsSL https://api.github.com/repos/omacom/omarchy/releases/latest | jq -r .tag_name) @@ -18,17 +28,17 @@ curl -fsSLO "https://iso.omarchy.org/omarchy-$version.iso.sha256" sha256sum -c "omarchy-$version.iso.sha256" ``` -A damaged download otherwise shows up minutes into the install, as a pacman "invalid or corrupted package" error. There is a `.sig` beside the ISO too, for verifying it against the Omarchy signing key. +Use the `.sig` file to verify the ISO against the Omarchy signing key. -The manual has the rest: +### More in the manual -- [Getting started](https://omarchy.org/manual/getting-started/): the install step by step, installing for another owner, installing without encryption. -- [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): Omarchy in the free space of a drive, beside Windows or another OS. -- [Unattended installs](https://omarchy.org/manual/unattended-installs/): the ISO installs itself, with no keyboard and no wizard, from a configuration on a second drive. +- [Getting started](https://omarchy.org/manual/getting-started/): the install step by step, installing for another owner, and installing without encryption. +- [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): install Omarchy in the free space of a drive, beside Windows or another OS. +- [Unattended installs](https://omarchy.org/manual/unattended-installs/): let the ISO install itself from a configuration on a second drive, with no keyboard and no wizard. ## For maintainers -Everything from here on is about building, testing and publishing the ISO. +The rest of this file covers building, testing and publishing the ISO. ### What is published, and where @@ -37,23 +47,23 @@ Everything from here on is about building, testing and publishing the ISO. | Release ISO, with `.sha256` and `.sig` | `https://iso.omarchy.org/omarchy-.iso` | Each release | | Edge ISO, with `.sha256` | `https://nightly.omarchy.org/edge//omarchy-edge-.iso` | Every night | | VM image of edge | `https://nightly.omarchy.org/edge//vm/` | Every night | -| VM image of the last release | `https://nightly.omarchy.org/stable///vm/` | Once per release | +| VM image of the latest release | `https://nightly.omarchy.org/stable///vm/` | Once per release | | `latest.json` | `https://nightly.omarchy.org/edge/latest.json` and `.../stable/latest.json` | With each of the three above | -On nightly.omarchy.org a published file is never replaced. Only `latest.json` changes, and it is written last, after every file of the version has been fetched back through the CDN and compared. +On nightly.omarchy.org, a published file is never replaced. Only `latest.json` changes. It is written last, after every file of the version has been fetched back through the CDN and compared. -### The nightly +### The nightly build -`.github/workflows/nightly-build.yml` runs every night and for two channels: +`.github/workflows/nightly-build.yml` runs every night for two channels: - `edge`: builds the ISO from the edge channel: the edge packages and mirrors, and the edge builds of Omarchy (`omarchy-dev`). -- `stable`: takes the last release. The latest release of omacom/omarchy names the version, iso.omarchy.org has the ISO, and it is used only if its signature is the release key's. A release already published is skipped on the nightly run; a run started by hand makes it again. +- `stable`: uses the latest release. The latest release of omacom/omarchy determines the version. The ISO comes from iso.omarchy.org and is used only if its signature matches the release key. A scheduled run skips a release that is already published. A manual run builds it again. -Each ISO is installed unattended and the installed system booted, by the same install test a pull request gets. `test/vm-image/vm-seal` then turns the installed disk into a VM image and `test/vm-image/vm-verify` boots that image and checks it. Nothing is published unless all of it passed. +Each ISO goes through the same install test that runs for pull requests: an unattended install, then a boot of the installed system. `test/vm-image/vm-seal` then turns the installed disk into a VM image, and `test/vm-image/vm-verify` boots that image and checks it. Nothing is published unless every check passes. -Publishing is a job of its own, the only one with the bucket's key, and it runs on `quattro` only. To try a change, run the workflow by hand on a branch: that builds and checks everything and publishes nothing. +A separate job publishes. It is the only job with the bucket's key, and it runs only on `quattro`. To try a change, run the workflow manually on a branch. That run builds and checks everything and publishes nothing. -Which channels a run makes and where they go is decided by `test/vm-image/nightly-plan`; what is laid out and what `latest.json` says by `test/vm-image/nightly-layout`. Both are covered by `test/unit/nightly-test.sh`. +`test/vm-image/nightly-plan` decides which channels a run builds and where they are published. `test/vm-image/nightly-layout` lays out the files and writes `latest.json`. `test/unit/nightly-test.sh` tests both. ### latest.json @@ -61,18 +71,18 @@ Which channels a run makes and where they go is decided by `test/vm-image/nightl | --- | --- | | `version` | Edge: the build's date and run number, `2026.10.09.431`. Stable: the release, `4.0.4`. | | `channel` | `edge` or `stable`. | -| `built_at` | When this file was written, once the image had passed its checks. UTC, `2026-10-09T18:02:35Z`. | +| `built_at` | When this file was written, after the image passed its checks. UTC, `2026-10-09T18:02:35Z`. | | `url` | The ISO that was installed. Edge: on nightly.omarchy.org. Stable: the release ISO on iso.omarchy.org. | | `sha256`, `size` | The ISO's SHA-256 and its size in bytes. | -| `iso_commit` | The omarchy-iso commit the nightly ran from. | +| `iso_commit` | The omarchy-iso commit the nightly build ran from. | | `omarchy_version` | The version of the Omarchy package in the image. | -| `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty, the version names it. | -| `kernel` | The kernel release the image boots, as `uname -r` gives it. | +| `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty. | +| `kernel` | The image's kernel release, as reported by `uname -r`. | | `vm.url` | The folder the image's files are in, ending in `/`. | | `vm.disk`, `vm.disk_sha256`, `vm.disk_size` | The disk image, `base.qcow2`: its URL, SHA-256 and size in bytes. | | `vm.files` | Every file in `vm.url`. | | `vm.user`, `vm.password` | The account for the desktop and `sudo`. | -| `vm.ssh` | How SSH gets in: keys only, never the password. | +| `vm.ssh` | SSH authentication: keys only. Password authentication is off. | The version is also in the edge ISO's file name. @@ -89,9 +99,11 @@ The version is also in the edge ISO's file name. ### Using a VM image -For testing, not for users: users get the release ISO from iso.omarchy.org. A VM image is Omarchy already installed, with no installer to go through, and it boots in seconds. There is one of edge, rebuilt every night, and one of the last release. +VM images are for testing. Users download the release ISO from iso.omarchy.org. -On a Linux machine with QEMU (`qemu-system-x86_64` and `qemu-img`), a `/dev/kvm` you can read and write, `ssh`, `curl` and `jq`: +A VM image is Omarchy already installed. It boots in seconds, with no installer to go through. There is an image of the edge channel, rebuilt every night, and an image of the latest release. + +Run these commands on a Linux machine with QEMU (`qemu-system-x86_64` and `qemu-img`), read and write access to `/dev/kvm`, and `ssh`, `curl` and `jq`: ```bash latest=https://nightly.omarchy.org/edge/latest.json # or .../stable/latest.json @@ -104,9 +116,11 @@ chmod +x vm-boot ./vm-boot . uname -r # boots, runs the command over SSH, powers off ``` -The user is `omarchy` with password `omarchy`. SSH takes keys only, and `vm-boot` makes one for each boot. Every boot is a new machine, and the downloaded image is never written to: the machine runs on an overlay under `/var/tmp` (`VM_STATE_DIR` moves it) that is deleted on exit. The disk is a download of 4 to 5 GB. +The user is `omarchy` and the password is `omarchy`. SSH takes keys only, and `vm-boot` makes a new key for each boot. -The nightly's edge ISO is `jq -r .url` of the same `latest.json`. +Each boot starts a new machine on an overlay under `/var/tmp`, so the downloaded image is never changed. The overlay is deleted on exit. Set `VM_STATE_DIR` to put it somewhere else. The disk image is a 4 to 5 GB download. + +To get the URL of the edge ISO, run `jq -r .url` on the same `latest.json`. ### Autoinstall From f9d184f2bcc6a3a25d0474a4c1b2ce9000befec3 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:40:01 +0200 Subject: [PATCH 29/31] VM image: one file, named like the ISO The image was a folder, vm/, holding base.qcow2 and what boots it. It is now one file next to the ISO, with the ISO's name and its own checksum file, the way Ubuntu, Debian, Arch, Fedora and CentOS publish theirs: edge//omarchy-edge-.qcow2 stable///omarchy-.qcow2 The image boots by itself through UEFI. What a direct boot needs, the kernel, initramfs, kernel arguments and vm-boot, is in an unpacked/ folder beside it, Ubuntu's name for the same thing. The firmware variables are no longer published: a boot with the firmware's defaults finds the disk. latest.json follows: vm.url, vm.sha256 and vm.size are the image, as url, sha256 and size are the ISO; vm.unpacked and vm.unpacked_files are the folder. vm-boot takes the image file. The publish job compares every file it uploaded, whatever the layout. README, tests and the workflow's header say the same. --- .github/workflows/nightly-build.yml | 23 ++++----- README.md | 79 ++++++++++++++++++----------- test/unit/nightly-test.sh | 29 +++++++---- test/vm-image/nightly-layout | 56 ++++++++++++-------- test/vm-image/vm-boot | 27 +++++++--- test/vm-image/vm-seal | 12 ++--- 6 files changed, 136 insertions(+), 90 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 51bdb21..d95a3bd 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -12,8 +12,10 @@ name: Nightly ISO Build # # https://nightly.omarchy.org//latest.json # https://nightly.omarchy.org/edge//omarchy-edge-.iso -# https://nightly.omarchy.org/edge//vm/ base.qcow2 and what boots it -# https://nightly.omarchy.org/stable///vm/ +# https://nightly.omarchy.org/edge//omarchy-edge-.qcow2 +# https://nightly.omarchy.org/edge//unpacked/ kernel, initramfs, vm-boot +# https://nightly.omarchy.org/stable///omarchy-.qcow2 +# https://nightly.omarchy.org/stable///unpacked/ # # Nothing is published unless the ISO installed and the image passed its # checks. A version's files are written once and never replaced: the CDN keeps @@ -263,17 +265,12 @@ jobs: echo "ok $1" } # Every file of the version, the checksum files too. - for iso in "$dir"/*.iso; do - [[ -f $iso ]] || continue - check "$SITE/$RELEASE/${iso##*/}" "$(cut -d" " -f1 "$iso.sha256")" - check "$SITE/$RELEASE/${iso##*/}.sha256" "$(sha256sum "$iso.sha256" | cut -d" " -f1)" - done - while read -r sum name; do - check "$SITE/$RELEASE/vm/$name" "$sum" - done <"$dir/vm/SHA256SUMS" - check "$SITE/$RELEASE/vm/SHA256SUMS" "$(sha256sum "$dir/vm/SHA256SUMS" | cut -d" " -f1)" - published=$(find "$dir" -type f | wc -l) - echo "$published files published and checked" + (cd "$dir" && find . -type f -printf '%P\n' | sort) >files.txt + [ -s files.txt ] || { echo "::error::nothing to check under $dir"; exit 1; } + while read -r file; do + check "$SITE/$RELEASE/$file" "$(sha256sum "$dir/$file" | cut -d" " -f1)" + done .iso` | Each release | -| Edge ISO, with `.sha256` | `https://nightly.omarchy.org/edge//omarchy-edge-.iso` | Every night | -| VM image of edge | `https://nightly.omarchy.org/edge//vm/` | Every night | -| VM image of the latest release | `https://nightly.omarchy.org/stable///vm/` | Once per release | -| `latest.json` | `https://nightly.omarchy.org/edge/latest.json` and `.../stable/latest.json` | With each of the three above | +| Release ISO | `https://iso.omarchy.org/omarchy-.iso` | Each release | +| Edge ISO | `https://nightly.omarchy.org/edge//omarchy-edge-.iso` | Every night | +| VM image of edge | `https://nightly.omarchy.org/edge//omarchy-edge-.qcow2` | Every night | +| VM image of the latest release | `https://nightly.omarchy.org/stable///omarchy-.qcow2` | Once per release | +| `latest.json` | `https://nightly.omarchy.org/edge/latest.json` and `.../stable/latest.json` | With each nightly build | + +Every ISO and every VM image has a `.sha256` file next to it. The release ISO also has a `.sig` file. Each VM image has an `unpacked/` folder next to it. On nightly.omarchy.org, a published file is never replaced. Only `latest.json` changes. It is written last, after every file of the version has been fetched back through the CDN and compared. @@ -78,47 +80,66 @@ A separate job publishes. It is the only job with the bucket's key, and it runs | `omarchy_version` | The version of the Omarchy package in the image. | | `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty. | | `kernel` | The image's kernel release, as reported by `uname -r`. | -| `vm.url` | The folder the image's files are in, ending in `/`. | -| `vm.disk`, `vm.disk_sha256`, `vm.disk_size` | The disk image, `base.qcow2`: its URL, SHA-256 and size in bytes. | -| `vm.files` | Every file in `vm.url`. | +| `vm.url`, `vm.sha256`, `vm.size` | The VM image: its URL, SHA-256 and size in bytes. | +| `vm.unpacked` | The URL of the image's `unpacked/` folder, ending in `/`. | +| `vm.unpacked_files` | Every file in that folder. | | `vm.user`, `vm.password` | The account for the desktop and `sudo`. | | `vm.ssh` | SSH authentication: keys only. Password authentication is off. | The version is also in the edge ISO's file name. -### A VM image's files - -| File | What it is | -| --- | --- | -| `base.qcow2` | The disk, a zstd-compressed qcow2. | -| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments, for booting it directly without firmware or boot loader. | -| `OVMF_VARS.4m.fd` | The firmware variables with the installer's boot entry, for booting it through UEFI instead. | -| `vm-boot` | A script that boots the image with QEMU. | -| `SHA256SUMS` | Checksums of the files above. | - - -### Using a VM image +### VM images VM images are for testing. Users download the release ISO from iso.omarchy.org. -A VM image is Omarchy already installed. It boots in seconds, with no installer to go through. There is an image of the edge channel, rebuilt every night, and an image of the latest release. +A VM image is Omarchy already installed, as one `.qcow2` file. There is an image of the edge channel, rebuilt every night, and an image of the latest release. Every boot is a new machine, with a new machine ID and new SSH host keys. -Run these commands on a Linux machine with QEMU (`qemu-system-x86_64` and `qemu-img`), read and write access to `/dev/kvm`, and `ssh`, `curl` and `jq`: +The user is `omarchy` and the password is `omarchy`. SSH takes keys only. + +#### Boot the image by itself + +The image boots through UEFI with no other file. Run these commands on a Linux machine with QEMU, read and write access to `/dev/kvm`, `curl` and `jq`: ```bash latest=https://nightly.omarchy.org/edge/latest.json # or .../stable/latest.json -vm=$(curl -fsSL "$latest" | jq -r .vm.url) -for file in $(curl -fsSL "$latest" | jq -r '.vm.files[]'); do curl -fsSLO "$vm$file"; done +image=$(curl -fsSL "$latest" | jq -r .vm.url) +curl -fLO "$image" +curl -fsSLO "$image.sha256" +sha256sum -c "${image##*/}.sha256" + +cp /usr/share/edk2/x64/OVMF_VARS.4m.fd vars.fd +qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 4G \ + -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \ + -drive if=pflash,format=raw,file=vars.fd \ + -drive file="${image##*/}",if=virtio,snapshot=on \ + -device virtio-vga +``` + +The firmware paths are Arch's. `snapshot=on` keeps the downloaded image unchanged. The image is a 4 to 5 GB download. + +#### Boot it faster, with SSH + +The `unpacked/` folder next to each image holds what a direct boot needs: + +| File | What it is | +| --- | --- | +| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments. | +| `vm-boot` | A script that boots the image directly into its kernel and gives it an SSH key. | +| `SHA256SUMS` | Checksums of the files above. | + +A direct boot skips the firmware and the boot loader: SSH answers after about 6 seconds instead of about 20. Download the folder next to the image, then run `vm-boot`. It also needs `qemu-img` and `ssh`: + +```bash +unpacked=$(curl -fsSL "$latest" | jq -r .vm.unpacked) +for file in $(curl -fsSL "$latest" | jq -r '.vm.unpacked_files[]'); do curl -fsSLO "$unpacked$file"; done sha256sum -c SHA256SUMS chmod +x vm-boot -./vm-boot . # boots, prints how to log in, runs until Ctrl-C -./vm-boot . uname -r # boots, runs the command over SSH, powers off +./vm-boot "${image##*/}" # boots, prints how to log in, runs until Ctrl-C +./vm-boot "${image##*/}" uname -r # boots, runs the command over SSH, powers off ``` -The user is `omarchy` and the password is `omarchy`. SSH takes keys only, and `vm-boot` makes a new key for each boot. - -Each boot starts a new machine on an overlay under `/var/tmp`, so the downloaded image is never changed. The overlay is deleted on exit. Set `VM_STATE_DIR` to put it somewhere else. The disk image is a 4 to 5 GB download. +`vm-boot` makes a new SSH key for each boot. It runs the machine on an overlay under `/var/tmp`, so the downloaded image is never changed, and deletes the overlay on exit. Set `VM_STATE_DIR` to put the overlay somewhere else. To get the URL of the edge ISO, run `jq -r .url` on the same `latest.json`. diff --git a/test/unit/nightly-test.sh b/test/unit/nightly-test.sh index 2c9a91d..9706696 100644 --- a/test/unit/nightly-test.sh +++ b/test/unit/nightly-test.sh @@ -58,7 +58,7 @@ refused "a date in another form" "$plan" 2026-10-09 431 1 schedule v4.0.4 "" sealed() { # directory: what vm-seal leaves rm -rf "$1"; mkdir -p "$1" local file - for file in base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline; do echo "$file of ${1##*/}" >"$1/$file"; done + for file in base.qcow2 vmlinuz initrd cmdline; do echo "$file of ${1##*/}" >"$1/$file"; done echo "sums of the seal" >"$1/SHA256SUMS" } export SITE=https://nightly.example ISO_SITE=https://iso.example ISO_COMMIT=0123456789abcdef0123456789abcdef01234567 @@ -77,12 +77,14 @@ dir=$tmp/out/edge/2026.10.09.431 field() { jq -r "$1" "$json"; } equal "edge: files published" "$(cd "$tmp/out" && find . -type f | sort | paste -sd' ')" \ - "./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso.sha256 ./edge/2026.10.09.431/vm/OVMF_VARS.4m.fd ./edge/2026.10.09.431/vm/SHA256SUMS ./edge/2026.10.09.431/vm/base.qcow2 ./edge/2026.10.09.431/vm/cmdline ./edge/2026.10.09.431/vm/initrd ./edge/2026.10.09.431/vm/vm-boot ./edge/2026.10.09.431/vm/vmlinuz ./edge/latest.json" -(cd "$dir" && sha256sum -c --quiet ./*.sha256) || fail "edge: the ISO's checksum file does not match the ISO" -(cd "$dir/vm" && sha256sum -c --quiet SHA256SUMS) || fail "edge: SHA256SUMS does not match the image" + "./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso.sha256 ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2 ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2.sha256 ./edge/2026.10.09.431/unpacked/SHA256SUMS ./edge/2026.10.09.431/unpacked/cmdline ./edge/2026.10.09.431/unpacked/initrd ./edge/2026.10.09.431/unpacked/vm-boot ./edge/2026.10.09.431/unpacked/vmlinuz ./edge/latest.json" +(cd "$dir" && sha256sum -c --quiet ./*.sha256) || fail "edge: a checksum file does not match the file it is for" +equal "edge: the ISO and the image each have a checksum file" "$(cd "$dir" && ls ./*.sha256 | wc -l)" "2" +equal "edge: the image is the sealed disk" "$(cat "$dir/omarchy-edge-2026.10.09.431.qcow2")" "base.qcow2 of sealed-edge" +(cd "$dir/unpacked" && sha256sum -c --quiet SHA256SUMS) || fail "edge: SHA256SUMS does not match the unpacked files" equal "edge: SHA256SUMS covers every file beside it" \ - "$(awk '{ print $2 }' "$dir/vm/SHA256SUMS" | sort | paste -sd' ')" "$(cd "$dir/vm" && ls | grep -v SHA256SUMS | sort | paste -sd' ')" -cmp -s "$dir/vm/vm-boot" "$ROOT/test/vm-image/vm-boot" || fail "edge: the published vm-boot is not the repository's" + "$(awk '{ print $2 }' "$dir/unpacked/SHA256SUMS" | sort | paste -sd' ')" "$(cd "$dir/unpacked" && ls | grep -v SHA256SUMS | sort | paste -sd' ')" +cmp -s "$dir/unpacked/vm-boot" "$ROOT/test/vm-image/vm-boot" || fail "edge: the published vm-boot is not the repository's" equal "edge: version" "$(field .version)" "2026.10.09.431" equal "edge: channel" "$(field .channel)" "edge" equal "edge: url" "$(field .url)" "https://nightly.example/edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso" @@ -92,9 +94,12 @@ equal "edge: iso_commit" "$(field .iso_commit)" "$ISO_COMMIT" equal "edge: omarchy_version, once" "$(field .omarchy_version)" "4.0.0.r6818.gc352b62-2" equal "edge: omarchy_commit" "$(field .omarchy_commit)" "c352b62" equal "edge: kernel" "$(field .kernel)" "7.2.8-5-omarchy-bore" -equal "edge: image address" "$(field .vm.disk)" "https://nightly.example/edge/2026.10.09.431/vm/base.qcow2" -equal "edge: image checksum is the disk's" "$(field .vm.disk_sha256)" "$(sha256sum "$dir/vm/base.qcow2" | cut -d' ' -f1)" -equal "edge: every listed file is published" "$(field '.vm.files[]' | sort | paste -sd' ')" "$(cd "$dir/vm" && ls | sort | paste -sd' ')" +equal "edge: image address" "$(field .vm.url)" "https://nightly.example/edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2" +equal "edge: image checksum is the image's" "$(field .vm.sha256)" "$(sha256sum "$dir/omarchy-edge-2026.10.09.431.qcow2" | cut -d' ' -f1)" +equal "edge: image size is the image's" "$(field .vm.size)" "$(stat -c %s "$dir/omarchy-edge-2026.10.09.431.qcow2")" +equal "edge: the ISO and the image differ only in their ending" "$(field .url | sed 's/\.iso$//')" "$(field .vm.url | sed 's/\.qcow2$//')" +equal "edge: unpacked folder" "$(field .vm.unpacked)" "https://nightly.example/edge/2026.10.09.431/unpacked/" +equal "edge: every unpacked file listed is published" "$(field '.vm.unpacked_files[]' | sort | paste -sd' ')" "$(cd "$dir/unpacked" && ls | sort | paste -sd' ')" [[ $(field .built_at) =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$ ]] || fail "edge: built_at is '$(field .built_at)'" # What a consumer does with the ISO's name: the first run of three or more # dotted numbers is the version. @@ -115,8 +120,10 @@ equal "stable: sha256 is the ISO's" "$(field .sha256)" "$(sha256sum "$tmp/releas equal "stable: version" "$(field .version)" "4.0.4" equal "stable: omarchy_version" "$(field .omarchy_version)" "4.0.4-1" equal "stable: a release names no commit" "$(field .omarchy_commit)" "" -equal "stable: image address has the run number" "$(field .vm.disk)" "https://nightly.example/stable/4.0.4/431/vm/base.qcow2" -(cd "$tmp/out/stable/4.0.4/431/vm" && sha256sum -c --quiet SHA256SUMS) || fail "stable: SHA256SUMS does not match the image" +equal "stable: image address has the run number" "$(field .vm.url)" "https://nightly.example/stable/4.0.4/431/omarchy-4.0.4.qcow2" +equal "stable: unpacked folder" "$(field .vm.unpacked)" "https://nightly.example/stable/4.0.4/431/unpacked/" +(cd "$tmp/out/stable/4.0.4/431" && sha256sum -c --quiet omarchy-4.0.4.qcow2.sha256) || fail "stable: the image's checksum file does not match it" +(cd "$tmp/out/stable/4.0.4/431/unpacked" && sha256sum -c --quiet SHA256SUMS) || fail "stable: SHA256SUMS does not match the unpacked files" # Nothing half-made is described. sealed "$tmp/sealed-short"; rm "$tmp/sealed-short/initrd" diff --git a/test/vm-image/nightly-layout b/test/vm-image/nightly-layout index c58f60c..44c8078 100755 --- a/test/vm-image/nightly-layout +++ b/test/vm-image/nightly-layout @@ -13,14 +13,21 @@ # Env: SITE (https://nightly.omarchy.org), ISO_SITE (https://iso.omarchy.org), # ISO_COMMIT (the omarchy-iso commit, default: HEAD of this checkout). # -# out_dir gets /vm/ with the image, its checksums and vm-boot, and -# /latest.json. The edge ISO is published too, next to the image. -# The released one already has its place: latest.json points there and the -# ISO stays where it is. +# out_dir gets, under /: +# +# omarchy-<...>.qcow2 and .qcow2.sha256 the VM image: one file, which boots +# by itself through UEFI +# unpacked/ its kernel, initramfs and kernel +# arguments for booting it directly, +# vm-boot, and their SHA256SUMS +# omarchy-edge-.iso and .sha256 edge only +# +# and /latest.json. The released ISO already has its place: +# latest.json points there and the ISO stays where it is. # # The ISO and the image are moved, not copied: they are gigabytes. set -euo pipefail -[[ $# -eq 7 ]] || { sed -n '2,13p' "$0" >&2; exit 2; } +[[ $# -eq 7 ]] || { sed -n '2,12p' "$0" >&2; exit 2; } channel=$1 version=$2 release=$3 iso_file=$4 sealed=$5 packages=$6 out=$7 here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) site=${SITE:-https://nightly.omarchy.org} @@ -30,8 +37,8 @@ fail() { echo "nightly-layout: $*" >&2; exit 1; } [[ $channel == edge || $channel == stable ]] || fail "'$channel' is not a channel" [[ -f $iso_file ]] || fail "no ISO at $iso_file" -files=(base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline) -for file in "${files[@]}"; do +unpacked=(vmlinuz initrd cmdline) +for file in base.qcow2 "${unpacked[@]}"; do [[ -s $sealed/$file ]] || fail "the sealed image has no $file" done @@ -43,46 +50,52 @@ kernel=$(tail -n 1 "$packages") omarchy_commit=$(sed -nE 's/.*\.g([0-9a-f]+)-.*/\1/p' <<<"$omarchy") dir=$out/$release -mkdir -p "$dir/vm" "$out/$channel" +mkdir -p "$dir/unpacked" "$out/$channel" if [[ $channel == edge ]]; then - iso=omarchy-$channel-$version.iso + name=omarchy-$channel-$version + iso=$name.iso iso_url=$site/$release/$iso mv "$iso_file" "$dir/$iso" (cd "$dir" && sha256sum "$iso" >"$iso.sha256") iso_sha256=$(cut -d' ' -f1 "$dir/$iso.sha256") iso_size=$(stat -c %s "$dir/$iso") else - iso=omarchy-$version.iso + name=omarchy-$version + iso=$name.iso [[ ${iso_file##*/} == "$iso" ]] || fail "the released ISO is $iso, not ${iso_file##*/}" iso_url=$iso_site/$iso iso_sha256=$(sha256sum "$iso_file" | cut -d' ' -f1) iso_size=$(stat -c %s "$iso_file") fi -for file in "${files[@]}"; do mv "$sealed/$file" "$dir/vm/$file"; done -cp "$here/vm-boot" "$dir/vm/vm-boot" -files+=(vm-boot) -(cd "$dir/vm" && sha256sum "${files[@]}" >SHA256SUMS) +# The image has the ISO's name, so the two are told apart by their endings. +image=$name.qcow2 +mv "$sealed/base.qcow2" "$dir/$image" +(cd "$dir" && sha256sum "$image" >"$image.sha256") + +for file in "${unpacked[@]}"; do mv "$sealed/$file" "$dir/unpacked/$file"; done +cp "$here/vm-boot" "$dir/unpacked/vm-boot" +unpacked+=(vm-boot) +(cd "$dir/unpacked" && sha256sum "${unpacked[@]}" >SHA256SUMS) latest=$out/$channel/latest.json jq -n \ - --arg version "$version" --arg channel "$channel" --arg vm "$site/$release/vm" \ + --arg version "$version" --arg channel "$channel" --arg release "$site/$release" --arg image "$image" \ --arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg url "$iso_url" --arg sha256 "$iso_sha256" --argjson size "$iso_size" \ --arg iso_commit "$iso_commit" --arg omarchy_version "$omarchy" --arg omarchy_commit "$omarchy_commit" \ --arg kernel "$kernel" \ - --arg disk_sha256 "$(awk '$2 == "base.qcow2" { print $1 }' "$dir/vm/SHA256SUMS")" \ - --argjson disk_size "$(stat -c %s "$dir/vm/base.qcow2")" \ + --arg vm_sha256 "$(cut -d' ' -f1 "$dir/$image.sha256")" --argjson vm_size "$(stat -c %s "$dir/$image")" \ --args '{ version: $version, channel: $channel, built_at: $built_at, url: $url, sha256: $sha256, size: $size, iso_commit: $iso_commit, omarchy_version: $omarchy_version, omarchy_commit: $omarchy_commit, kernel: $kernel, vm: { - url: "\($vm)/", disk: "\($vm)/base.qcow2", disk_sha256: $disk_sha256, disk_size: $disk_size, - files: ($ARGS.positional + ["SHA256SUMS"]), + url: "\($release)/\($image)", sha256: $vm_sha256, size: $vm_size, + unpacked: "\($release)/unpacked/", unpacked_files: ($ARGS.positional + ["SHA256SUMS"]), user: "omarchy", password: "omarchy", ssh: "keys only; vm-boot makes one for each boot" } - }' "${files[@]}" >"$latest" + }' "${unpacked[@]}" >"$latest" # What is about to be published has to say what it should: one value in every # field, and checksums that are checksums. @@ -93,6 +106,7 @@ jq -e --arg channel "$channel" ' and (.url | test("^https://[^\n ]+\\.iso$")) and (.sha256 | sha) and .size > 0 and (.iso_commit | test("^[0-9a-f]{40}$")) and (.omarchy_version | one_line) and (.omarchy_commit | test("^[0-9a-f]*$")) and (.kernel | one_line) - and (.vm.disk | test("^https://[^\n ]+/vm/base\\.qcow2$")) and (.vm.disk_sha256 | sha) and .vm.disk_size > 0 + and (.vm.url | test("^https://[^\n ]+\\.qcow2$")) and (.vm.sha256 | sha) and .vm.size > 0 + and (.vm.unpacked | test("^https://[^\n ]+/unpacked/$")) and (.vm.unpacked_files | length) == 5 ' "$latest" >/dev/null || { cat "$latest" >&2; fail "latest.json does not say what it should"; } cat "$latest" diff --git a/test/vm-image/vm-boot b/test/vm-image/vm-boot index 86e059c..b1f3a56 100755 --- a/test/vm-image/vm-boot +++ b/test/vm-image/vm-boot @@ -1,11 +1,16 @@ #!/bin/bash # Boots a sealed Omarchy image (vm-seal) as a throwaway machine. # -# vm-boot boot, print how to log in, run until Ctrl-C -# vm-boot boot, run the command over SSH, power off; -# exits with the command's status +# vm-boot boot, print how to log in, run until Ctrl-C +# vm-boot boot, run the command over SSH, power off; +# exits with the command's status # -# Nothing is written to sealed_dir: the machine runs on a copy-on-write overlay +# is a downloaded disk, omarchy-<...>.qcow2, with vmlinuz, initrd and +# cmdline in the same directory as this script, as in a published "unpacked" +# folder. Or it is a directory as vm-seal leaves it, holding base.qcow2 and +# those three files. +# +# Nothing is written to the image: the machine runs on a copy-on-write overlay # that is deleted on exit. Each boot gets a new SSH key and a new machine ID; # the image itself holds no key and its SSH takes no password. # @@ -14,9 +19,15 @@ # kernel arguments), QEMU_EXTRA (more qemu arguments, split on spaces), # VM_STATE_DIR (/var/tmp; holds the overlay, so it needs room to grow). set -euo pipefail -[[ $# -ge 1 ]] || { sed -n '2,16p' "$0" >&2; exit 2; } -base=$(realpath "$1"); shift -for f in base.qcow2 vmlinuz initrd cmdline; do +[[ $# -ge 1 ]] || { sed -n '2,21p' "$0" >&2; exit 2; } +image=$(realpath "$1"); shift +if [[ -d $image ]]; then + base=$image disk=$image/base.qcow2 +else + base=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) disk=$image +fi +[[ -s $disk ]] || { echo "vm-boot: there is no disk image at $disk" >&2; exit 1; } +for f in vmlinuz initrd cmdline; do [[ -s $base/$f ]] || { echo "vm-boot: $base/$f is missing" >&2; exit 1; } done [[ -r /dev/kvm && -w /dev/kvm ]] || { echo "vm-boot: /dev/kvm is not usable" >&2; exit 1; } @@ -45,7 +56,7 @@ keys=$(printf 'd /home/omarchy/.ssh 0700 omarchy omarchy -\nf+ /home/omarchy/.ss ssh_opts=(-i "$state/id_ed25519" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR) -qemu-img create -qf qcow2 -b "$base/base.qcow2" -F qcow2 "$state/run.qcow2" +qemu-img create -qf qcow2 -b "$disk" -F qcow2 "$state/run.qcow2" t0=$(date +%s%N) # shellcheck disable=SC2086 qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp "${SMP:-4}" -m "${MEM:-4G}" \ diff --git a/test/vm-image/vm-seal b/test/vm-image/vm-seal index 0434a97..1f279b0 100755 --- a/test/vm-image/vm-seal +++ b/test/vm-image/vm-seal @@ -7,10 +7,9 @@ # # installed_dir holds base.qcow2, OVMF_VARS.4m.fd and id_ed25519, which is # what test/integration leaves in test-runs/-integration once its install -# has passed. sealed_dir gets a standalone, zstd-compressed base.qcow2, the -# firmware variables that boot it through UEFI, and the UKI's vmlinuz, initrd -# and cmdline for booting it directly. It holds no key: each boot is handed -# one (see vm-boot). +# has passed. sealed_dir gets a standalone, zstd-compressed base.qcow2, which +# boots by itself through UEFI, and the UKI's vmlinuz, initrd and cmdline for +# booting it directly. It holds no key: each boot is handed one (see vm-boot). # # The image keeps the installer's user and password (GUEST_PASSWORD, default # omarchy) for the desktop and sudo. SSH takes keys only. @@ -145,8 +144,5 @@ tr -d '\0' <"$state/cmdline" | tr ' ' '\n' | { grep -vE '^(resume|resume_offset) # zstd clusters: QEMU reads them as they are, so the download boots as it is. qemu-img convert -c -O qcow2 -o compression_type=zstd -m 16 -W "$state/edit.qcow2" "$out/base.qcow2" -# The variables hold the boot entry the installer registered; a firmware boot -# of this disk needs them, a direct kernel boot does not. -cp "$in/OVMF_VARS.4m.fd" "$out/OVMF_VARS.4m.fd" -( cd "$out" && sha256sum base.qcow2 OVMF_VARS.4m.fd vmlinuz initrd cmdline >SHA256SUMS ) +( cd "$out" && sha256sum base.qcow2 vmlinuz initrd cmdline >SHA256SUMS ) echo "sealed: $out ($(du -sh "$out/base.qcow2" | cut -f1)); cmdline: $(cat "$out/cmdline")" From 20fd1dd00b6ea1e977d8cedc805d6f2ee7ebfc13 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:45:12 +0200 Subject: [PATCH 30/31] README: "For maintainers" is a top-level part Its sections move up one level with it, so creating, testing and releasing the ISO are second-level headings again. --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3b2242e..cc5fe51 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ Use the `.sig` file to verify the ISO against the Omarchy signing key. - [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): install Omarchy in the free space of a drive, beside Windows or another OS. - [Unattended installs](https://omarchy.org/manual/unattended-installs/): let the ISO install itself from a configuration on a second drive, with no keyboard and no wizard. -## For maintainers +# For maintainers The rest of this file covers building, testing and publishing the ISO. -### What is published, and where +## What is published, and where | What | Where | When | | --- | --- | --- | @@ -54,7 +54,7 @@ Every ISO and every VM image has a `.sha256` file next to it. The release ISO al On nightly.omarchy.org, a published file is never replaced. Only `latest.json` changes. It is written last, after every file of the version has been fetched back through the CDN and compared. -### The nightly build +## The nightly build `.github/workflows/nightly-build.yml` runs every night for two channels: @@ -67,7 +67,7 @@ A separate job publishes. It is the only job with the bucket's key, and it runs `test/vm-image/nightly-plan` decides which channels a run builds and where they are published. `test/vm-image/nightly-layout` lays out the files and writes `latest.json`. `test/unit/nightly-test.sh` tests both. -### latest.json +## latest.json | Field | What it is | | --- | --- | @@ -88,7 +88,7 @@ A separate job publishes. It is the only job with the bucket's key, and it runs The version is also in the edge ISO's file name. -### VM images +## VM images VM images are for testing. Users download the release ISO from iso.omarchy.org. @@ -96,7 +96,7 @@ A VM image is Omarchy already installed, as one `.qcow2` file. There is an image The user is `omarchy` and the password is `omarchy`. SSH takes keys only. -#### Boot the image by itself +### Boot the image by itself The image boots through UEFI with no other file. Run these commands on a Linux machine with QEMU, read and write access to `/dev/kvm`, `curl` and `jq`: @@ -117,7 +117,7 @@ qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 4G \ The firmware paths are Arch's. `snapshot=on` keeps the downloaded image unchanged. The image is a 4 to 5 GB download. -#### Boot it faster, with SSH +### Boot it faster, with SSH The `unpacked/` folder next to each image holds what a direct boot needs: @@ -143,7 +143,7 @@ chmod +x vm-boot To get the URL of the edge ISO, run `jq -r .url` on the same `latest.json`. -### Autoinstall +## Autoinstall The [manual's page](https://omarchy.org/manual/unattended-installs/) is the one for users; this is the same feature in full. @@ -151,7 +151,7 @@ The shipped ISO installs itself with no keyboard when it finds its configuration `cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one. -#### Configuration files +### Configuration files These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`. @@ -179,7 +179,7 @@ When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs. -#### Building the drive +### Building the drive ```bash mkdir cidata @@ -187,7 +187,7 @@ cp user_configuration.json user_credentials.json authorized_keys cidata/ genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/ ``` -#### Proxmox example +### Proxmox example ```bash qm create 101 --name my-omarchy \ @@ -207,7 +207,7 @@ Boot order is disk first: the empty disk falls through to the ISO on the first b Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot. -### Creating the ISO +## Creating the ISO 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. @@ -221,7 +221,7 @@ Despite the local folder name, the first argument is the Omarchy source checkout Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. -### Testing the ISO +## Testing the ISO Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. @@ -233,7 +233,7 @@ synthetic disk in `/tmp` with an existing ESP and data partition plus ample unallocated space, then offers to start an interactive installation on it. The fixture exercises Windows partition preservation but does not contain Windows. -### Acceptance testing the ISO +## Acceptance testing the ISO Run `./bin/omarchy-iso-test [release/omarchy.iso]` to install the ISO into a headless VM by driving the real interactive install flow — the harness reads each screen via QMP screendumps + OCR and answers with virtual keystrokes, so the configurator wizard, install dashboard, reboot prompt, and SDDM login are all exercised exactly as a user would. It then boots the installed system, sends real VM keyboard shortcuts for the primary shell and window-management actions, and runs the in-guest acceptance suite (`test/acceptance` in the omarchy repo). The suite checks session and service health, the complete core-package manifest, user defaults, representative applications, menus, panels, live weather, launchers, visual selectors, notifications, clipboard, and other interactive shell behavior. @@ -249,7 +249,7 @@ The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available Pass `--encrypt` to drive the encrypted install flow (including typing the LUKS passphrase at boot) instead of the unencrypted one. Pass `--no-preview` to collect the same visual artifacts without opening them in `imv` when the run finishes. -### Integration testing the ISO +## Integration testing the ISO Scenarios under `test/integration.d/` boot a real ISO install in QEMU and assert on what the running system actually does. The runner installs the ISO once — unattended, from a generated cidata drive — and saves the result as a reusable base image; every scenario then boots a throwaway overlay of that base with its own copy of the firmware vars, so neither disk nor NVRAM state leaks between runs. Shared machinery (VM lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest SSH, the cidata build) lives in `test/integration.d/base-test.sh`, so a new scenario is one file. @@ -263,14 +263,14 @@ The first scenario is `factory-reset`: it proves `omarchy-system-factory-reset` Artifacts — screenshots, the fixtured/staged/final `limine.conf`, the reset typescript, and the factory-reset log — land under `test-runs/-integration/runs/-/`, and `--no-preview` skips the `imv` review just like the acceptance harness. -### Signing the ISO +## Signing the ISO Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI. -### Uploading the ISO +## Uploading the ISO Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). The `.sig` and `.sha256` sidecars go up with the ISO when they exist beside it. -### Full release of the ISO +## Full release of the ISO Run `./bin/omarchy-iso-release VERSION` to create, test, sign, and upload the ISO in one flow. Add `--rc` to release an RC build instead. From 415831b1eb059e198b829b06bb99d9296ffc0c09 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 21:51:44 +0200 Subject: [PATCH 31/31] Tests: install the container's tools from our own mirror, and try again An install test failed 13 seconds in: a public Arch mirror dropped two signature files while the test container was installing QEMU. The three test containers now ask mirror.omarchy.org first, with the public mirrors after it, and try up to three times. --- .github/workflows/install-test.yml | 12 ++++++++++-- .github/workflows/nightly-build.yml | 12 ++++++++++-- .github/workflows/unit-test.yml | 9 ++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 025b773..3235746 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -57,8 +57,16 @@ jobs: printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman chmod 0755 /usr/local/bin/pacman fi - pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \ - imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null + # The tools come from our own Arch mirror first, the public ones + # after it. A mirror that drops one file must not fail the test: + # try again. + sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist + for attempt in 1 2 3; do + pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \ + imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null && break + [ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; } + echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15 + done # test/integration installs its tools through Omarchy'"'"'s helper; they are installed above. printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add chmod 0755 /usr/local/bin/omarchy-pkg-add diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index d95a3bd..188d856 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -139,8 +139,16 @@ jobs: printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman chmod 0755 /usr/local/bin/pacman fi - pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \ - imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl binutils >/dev/null + # The tools come from our own Arch mirror first, the public ones + # after it. A mirror that drops one file must not fail the test: + # try again. + sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist + for attempt in 1 2 3; do + pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \ + imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl binutils >/dev/null && break + [ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; } + echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15 + done # test/integration installs its tools through Omarchy'"'"'s helper; they are installed above. printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add chmod 0755 /usr/local/bin/omarchy-pkg-add diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index af8c1f2..6310014 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -46,7 +46,14 @@ jobs: sleep 1 done echo "systemd: $state" - pacman -Syu --noconfirm --needed python parted diffutils jq >/dev/null + # From our own Arch mirror first, and a second and third try: a + # mirror that drops one file must not fail the tests. + sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist + for attempt in 1 2 3; do + pacman -Syu --noconfirm --needed python parted diffutils jq >/dev/null && break + [ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; } + sleep 15 + done useradd -m tester cp -r /src /home/tester/omarchy-iso chown -R tester /home/tester/omarchy-iso