Compare commits

..
Author SHA1 Message Date
ZacharyZhang-NY 24f188a4be Merge upstream quattro
# Conflicts:
#	README.md
2026-10-12 02:58:01 -04:00
David Heinemeier HanssonandClaude Opus 5.5 53b80d406c Accept the Lao desktop layout from the setup form (#257)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:46:30 +09:00
Emir Beganović 4f4b3e3da9 Merge pull request #252 from omacom/fix/nightly-rclone-version-variable
Nightly: do not name the rclone pin RCLONE_VERSION
2026-10-10 09:08:00 +02:00
Emir Beganovic bddcb7bfc8 Nightly: do not name the rclone pin RCLONE_VERSION
rclone reads every RCLONE_<FLAG> variable as that flag. The step that
installs it set RCLONE_VERSION=v1.75.2 for the download, so its own
"rclone version" ran as "--version=v1.75.2" and failed:

  Invalid value when setting --version from environment variable
  RCLONE_VERSION="v1.75.2": invalid argument for "--version" flag

Both publish jobs stopped there, before uploading, in every run since the
pin was added. Name the two variables PINNED_RCLONE and
PINNED_RCLONE_SHA256, and have the nightly test refuse any RCLONE_ variable
in a workflow other than the remote's RCLONE_CONFIG_ settings.
2026-10-10 08:59:41 +02:00
Emir Beganović dcfd3e9917 Merge pull request #249 from omacom/ci/upgrade-test-rc
Upgrade test: work against rc, and stop early when there is nothing to upgrade to
2026-10-10 01:05:45 +02:00
Emir Beganovic 3e21939b9e Upgrade test: a channel that cannot be asked is a failure of its own
The question to the channel had no time limit, and a download that
failed gave an empty answer, which the test read as nothing on offer. A
server that held the connection open could keep the test until the job's
limit, and one that was down made it report "Nothing to upgrade to".

The request now has a deadline, a failure anywhere in it counts, and the
test stops with its own message and the last lines of what went wrong.
2026-10-10 00:57:46 +02:00
Emir Beganovic 40c5fb8e98 Upgrade test: an older offer is nothing to upgrade to either, and show a stuck reboot by itself
The early stop compared the offered version with the installed one for
equality. Started from a release newer than the channel holds, the test
went on to the updater with nothing to install. It now asks pacman's
vercmp and stops when the offer is the same or older.

The picture of a reboot that stuck was inside the condition that wants
both the before and the after picture, so it was left out exactly when
there was no after to show. It now has its own condition.
2026-10-10 00:46:10 +02:00
Emir Beganovic d4ad44c9f6 Upgrade test: work against rc, and say at once when there is nothing to upgrade to
Run against rc, the test stopped at a password prompt and then at a
question, each until its 30 minute limit.

sudo asked for a password although the test had written a rule that
lets it through: sudo takes the last rule that matches and reads its
folder in name order, and the rule's name sorted before the install's
own. The release's updater also runs "sudo -v", which asks unless every
rule for the user lets it through. The rule now sorts last, turns the
question off for the user, and the test checks both before going on.

rc holds what stable holds today. The release's updater, run with
nothing to do, ends at a prompt nobody answers. The test now asks the
channel what it offers before it starts, and with nothing newer on offer
it stops in seconds and says so.

The comment shows the two pictures only when both exist.
2026-10-10 00:41:07 +02:00
Emir Beganović df10011064 Merge pull request #248 from omacom/ci/upgrade-test
Upgrade test: update the last release to the next one, reboot, and check
2026-10-10 00:33:31 +02:00
Emir Beganovic d6a32f0dcf Upgrade test: fail when nothing was upgraded, and retry a lost push
A channel that holds the version the machine already has upgrades
nothing, and every other check passes on a machine that was only
rebooted: rc is in that state today. A twelfth check requires the
Omarchy package to have changed.

Two runs on different branches both push pictures to one branch. The
second to push was refused and its comment never written. The push is
now tried again on top of whatever got there first; each run's folder is
its own, so there is nothing to merge.
2026-10-10 00:20:27 +02:00
Emir Beganovic b5936a6cbe Upgrade test: started by hand only, and two checks that prove what they say
The workflow no longer runs for pull requests. It is started by hand,
and still posts to the pull request of the branch it was started on.
The fork fallback and the fork guard go with the trigger.

Two checks could pass without the thing they named being true:

The wallpaper check asked only whether the file the link points at
exists, so an update that swapped the user's picture for another would
pass. It now compares before and after: the same picture, whatever
format it is kept in, and present.

The failed-units check read an empty answer as nothing failed, which is
also what a service manager that cannot be asked gives. A query that
fails is now recorded as that, and fails the check.
2026-10-10 00:09:56 +02:00
Emir Beganovic 7f0ae48661 Upgrade test: what an existing user gets from the next release
Every test so far installs fresh. Nothing took a machine on the last
release and updated it, which is what most users of a new release do.

test/upgrade-test takes an installed system, moves it to edge or rc with
omarchy-channel-set in a login shell, as a user would, reboots it
through the firmware so the new kernel is the one that starts, and
checks eleven things: the update's exit status, skipped migrations, that
the reboot comes back by itself, into a new boot and an installed
kernel, the channel, the system's state, failed units, the desktop, the
wallpaper the user had, and the boot menu. It writes a report and takes
a picture of the unlocked desktop before and after. A reboot that only
comes back after a key press is a failure, and is said to be that.

The Upgrade test workflow installs a release from its signed ISO with
the install test, then runs it. It is started by hand, and runs for a
pull request that changes the test. A second job, the only one that can
write, commits the pictures to the upgrade-test-screenshots branch and
posts the report as one comment on the branch's pull request, updated in
place on later runs.

Run by hand from 4.0.4 to today's edge it found two things: the
wallpaper link left pointing at a file the update renamed
(omacom/omarchy#14596), and one reboot in three stuck on a black screen
until a key was pressed (omacom/omarchy#14771).
2026-10-09 23:52:50 +02:00
Emir Beganović 522b92a8d3 Merge pull request #246 from omacom/ci/current-rclone
Nightly: a current rclone for the upload, and Ubuntu 26.04 runners
2026-10-09 23:26:32 +02:00
Emir Beganovic 21a44ee4b4 Pull the Arch image from GitHub's registry
A unit test run stopped before it began: Docker Hub refused to hand out
archlinux:latest, "You have reached your unauthenticated pull rate
limit". GitHub's runners share addresses, so the limit is not ours to
stay under.

Arch publishes the same image as ghcr.io/archlinux/archlinux, which has
no such limit from a runner. The three workflow containers and the ISO
build's own container pull it from there.
2026-10-09 22:58:33 +02:00
Emir Beganovic ed0dfc5052 Nightly: the retry warning covers latest.json and counts files
The warning was written for the bulk upload only, and counted lines:
latest.json is uploaded by a second rclone call, which could still pass
on a retry unseen, and a file that failed on two attempts was counted as
two uploads.

Both calls go through test/vm-image/nightly-rclone, which passes
rclone's output and exit status through and names each file once. A
stand-in rclone tests it.
2026-10-09 22:36:31 +02:00
Emir Beganovic 22ef6e12c5 CI: GitHub's runners on Ubuntu 26.04
The four jobs on GitHub's own runners move from ubuntu-24.04 and
ubuntu-latest to ubuntu-26.04.

On 26.04 /tmp is held in memory, so the publish job's artifact, 11 GB for
edge, goes to the runner's temp directory on disk instead. nightly-fetch
now checks there is room before it starts and says how much is missing.
2026-10-09 22:35:17 +02:00
Emir Beganovic f9caa25766 Nightly: a current rclone for the upload, and a warning when it retries
Ubuntu's rclone is 1.60.1, from 2022. In the first publish R2 answered
every upload of the first attempt "501 Not Implemented", having stored
the file each time, and the job passed because rclone's second attempt
found everything in place.

Run against a stand-in server, 1.60.1 and the current 1.75.2 make the
same requests but for one header: 1.60.1 sends "x-amz-acl: private"
with each upload and 1.75.2 sends none to Cloudflare. The publish job
now installs 1.75.2, by version and checksum.

Should an upload pass only on a retry again, the run says so in a
warning.
2026-10-09 22:32:58 +02:00
Emir Beganović cabc8d1ea9 Merge pull request #245 from omacom/ci/bump-actions-dependabot
CI: latest actions, Dependabot weekly, and a fast fetch for the publish job
2026-10-09 22:31:57 +02:00
Emir Beganovic b01a396e62 Nightly: fetch the release over 16 connections
The first publish spent 9 min 42 s in the download action for the 4.6 GB
stable release; the 11.5 GB edge release would not have fitted in the
job's 45 minutes. GitHub's artifact storage limits each connection to a
few megabytes a second, and the action uses few.

An artifact is one zip at a signed address. test/vm-image/nightly-fetch
asks the API for that address and takes it with aria2c over 16
connections, checks the size and unpacks it. The same 4.6 GB artifact
came down in 123 s that way. The publish job gets actions: read for it,
and checks out test/vm-image for the script.

test/unit/nightly-test.sh plays the network with stand-ins: the newest
artifact of a name is the one fetched, a failed download is tried again
with a new address, a wrong size is refused and not unpacked.
2026-10-09 22:24:05 +02:00
Emir Beganovic bb1ad28887 CI: the four actions at their latest releases, and Dependabot weekly
actions/checkout             v5.0.0 -> v7.0.1
  actions/upload-artifact      v6.0.0 -> v7.0.2
  actions/download-artifact    v4.3.0 -> v8.0.2
  docker/setup-buildx-action   v4.3.0 -> v4.4.1

Each stays pinned to a commit, with the version in its comment.

From the release notes, what the new majors change and why none of it
touches these workflows: download-artifact v5 moved the path of an
artifact fetched by ID, and ours is fetched by name; v8 fails on a
checksum mismatch where v4 said nothing, which is what a publish job
wants. checkout v7 refuses a fork's code under pull_request_target and
workflow_run, and the one workflow_run here checks nothing out. The
Node 24 releases need runner 2.327.1; ours run 2.337.0.

Pinned actions do not update themselves, so .github/dependabot.yml asks
for one grouped pull request a week.
2026-10-09 22:14:54 +02:00
Emir Beganović 20aeec8364 Merge pull request #243 from omacom/vm-image
Nightly build workflow
2026-10-09 22:00:13 +02:00
Emir Beganovic 415831b1eb 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.
2026-10-09 21:51:44 +02:00
Emir Beganovic 20fd1dd00b 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.
2026-10-09 21:45:12 +02:00
Emir Beganovic f9d184f2bc 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/<version>/omarchy-edge-<version>.qcow2
  stable/<version>/<run>/omarchy-<version>.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.
2026-10-09 21:40:01 +02:00
Emir Beganovic 2b8d012688 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".
2026-10-09 21:32:53 +02:00
Emir Beganovic 97e06f025f 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".
2026-10-09 21:27:31 +02:00
Emir Beganovic 04ff1f5ea1 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.
2026-10-09 21:26:31 +02:00
Emir Beganovic 82cec53728 Nightly: the header says edge is built from the edge channel 2026-10-09 21:23:51 +02:00
Emir Beganovic 469052b93e 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.
2026-10-09 21:23:37 +02:00
Emir Beganovic 2a39b849c1 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.
2026-10-09 21:18:54 +02:00
Emir Beganović 7cce6045fa Merge pull request #244 from omacom/iso-build/require-package-signatures
ISO build: require signatures on Omarchy's own packages
2026-10-09 21:14:00 +02:00
Emir Beganovic 18a968f467 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.
2026-10-09 21:13:54 +02:00
Emir Beganovic f6377d2f87 ISO build: require signatures on Omarchy's own packages
The three configs the build downloads the ISO's packages with had
"SigLevel = Optional TrustAll" on [omarchy]. Optional checks a signature
when there is one and accepts a package that has none; TrustAll accepts
any key in the keyring. So a package whose .sig was missing went onto the
ISO unchecked.

Without the line, [omarchy] follows the configs' own default, Required
DatabaseOptional, which is what an installed system uses. Every package
in the repository is signed, and the build adds and signs the release
key before its first download, so nothing else changes. [arch-mact2]
keeps its own SigLevel = Never: those packages are unsigned upstream.
2026-10-09 20:20:30 +02:00
Emir Beganovic 61b309cf9a 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.
2026-10-09 20:16:04 +02:00
Emir Beganovic f99443dc4c 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.
2026-10-09 20:14:03 +02:00
Emir Beganovic 65589d2138 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.
2026-10-09 20:12:51 +02:00
Emir Beganovic 0abcbb0b97 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.
2026-10-09 20:12:14 +02:00
Emir Beganovic ff30b7f558 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.
2026-10-09 20:09:14 +02:00
Emir Beganovic d054c00d24 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.
2026-10-09 20:07:53 +02:00
Emir Beganovic 4f7d4a9db3 Install test: a pull request from a fork stays on GitHub's runner
Only our own branches, quattro and manual runs go to Namespace.
2026-10-09 20:01:54 +02:00
Emir Beganovic f0216cf0c0 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.
2026-10-09 19:59:26 +02:00
Emir Beganovic 32f917fdff 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.
2026-10-09 19:56:45 +02:00
Emir Beganovic 98b20a6829 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.
2026-10-09 19:47:24 +02:00
Emir Beganovic c97836e4f2 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.
2026-10-09 19:45:40 +02:00
Emir Beganovic 41fa32d671 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.
2026-10-09 19:42:34 +02:00
Emir Beganovic 9d4d03ff1c 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.
2026-10-09 19:29:17 +02:00
Emir Beganovic d2677b3046 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.
2026-10-09 19:18:28 +02:00
Emir Beganovic ebfbf43aa0 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.
2026-10-09 19:18:06 +02:00
Emir Beganovic 0bc00ff572 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.
2026-10-09 19:07:58 +02:00
Emir Beganovic c6b1d2dadc 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.
2026-10-09 19:07:58 +02:00
Emir Beganovic d90119f5d8 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/<version>/omarchy-edge-<version>.iso
  nightly.omarchy.org/edge/<version>/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.
2026-10-09 19:06:48 +02:00
Emir Beganovic c0a0e787f3 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/<iso>-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.
2026-10-09 19:06:48 +02:00
Emir Beganovic 466a7a8221 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.
2026-10-09 19:06:48 +02:00
Emir Beganovic 4e7b18eb7d 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.
2026-10-09 18:56:35 +02:00
26 changed files with 1955 additions and 66 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# Every action is pinned to a commit, so nothing updates by itself. Once a
# week Dependabot proposes the newer releases in one pull request, moving each
# commit and the version in its comment together.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
+1 -1
View File
@@ -18,7 +18,7 @@ permissions:
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
+27 -10
View File
@@ -21,35 +21,52 @@ concurrency:
jobs:
install:
runs-on: ubuntu-24.04
# 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-26.04' || 'namespace-profile-linux-amd64-16x32' }}
timeout-minutes: 75
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# 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 /
avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
[ "$avail_gb" -ge 40 ] || { echo "::error::only ${avail_gb} GB free, the ISO build and install test need ~40 GB"; exit 1; }
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build the ISO
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
# The harness runs in an Arch container: Arch's current QEMU and OVMF
# (QEMU 11) instead of Ubuntu 24.04's QEMU 8.2, at the paths the
# (QEMU 11) instead of the runner's older one, at the paths the
# harness expects, the same as on an Omarchy workstation.
- name: Install unattended, then boot the installed system
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 >/dev/null
ghcr.io/archlinux/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
# 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
@@ -101,7 +118,7 @@ jobs:
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: install-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
+307 -20
View File
@@ -1,35 +1,322 @@
name: Nightly ISO Build
# 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 it with a latest.json saying what is in it.
#
# 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.
#
# https://nightly.omarchy.org/<channel>/latest.json
# https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.iso
# https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.qcow2
# https://nightly.omarchy.org/edge/<version>/unpacked/ kernel, initramfs, vm-boot
# https://nightly.omarchy.org/stable/<version>/<run>/omarchy-<version>.qcow2
# https://nightly.omarchy.org/stable/<version>/<run>/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
# 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:
SITE: https://nightly.omarchy.org
jobs:
build-iso:
runs-on: ubuntu-latest
# 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-26.04
timeout-minutes: 5
outputs:
channels: ${{ steps.plan.outputs.channels }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- id: plan
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
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=$(./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"
- name: Free Disk Space
uses: jlumbroso/free-disk-space@v1.3.1
build:
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
env:
CHANNEL: ${{ matrix.channel }}
VERSION: ${{ matrix.version }}
RELEASE: ${{ matrix.release }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build ISO
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
- 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: Delete previously uploaded ISO artifact
uses: philips-labs/action-delete-artifacts@v1.0.0
- 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; }
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
if: matrix.channel == 'edge'
# 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
# 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
run: |
sudo chmod 0666 /dev/kvm
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
-e OMARCHY_INTEGRATION_DISK_CACHE=none \
ghcr.io/archlinux/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
# 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
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 -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
'
# 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:
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
{
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
workflow: nightly-build.yml
continue-on-error: true
name: nightly-${{ matrix.channel }}-${{ github.run_id }}
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: omarchy-iso-${{ github.run_number }}
path: ./release/*.iso
retention-days: 1
name: nightly-install-test-${{ matrix.channel }}-${{ 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.
# One channel failing does not hold back the other: each is published if
# its own build left a release behind.
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-26.04
timeout-minutes: 45
environment: nightly
# actions: read is for finding and fetching the build job's artifact.
permissions:
contents: read
actions: read
env:
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 }}
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
BUCKET: omarchy-nightly
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: test/vm-image
persist-credentials: false
# Not the download action: GitHub's storage limits each connection to a
# few megabytes a second, and the action took 10 minutes over 4.6 GB.
# nightly-fetch takes the same artifact over 16 connections.
- name: Fetch the release from the build job
env:
GH_TOKEN: ${{ github.token }}
# Not /tmp: on Ubuntu 26.04 it is held in memory, and the zip is
# gigabytes.
TMPDIR: ${{ runner.temp }}
run: |
df -h "$TMPDIR" .
command -v aria2c unzip >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq aria2 unzip >/dev/null; }
./test/vm-image/nightly-fetch "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "nightly-$CHANNEL-$GITHUB_RUN_ID" out
# A current rclone, by version and checksum. Ubuntu's is 1.60 from 2022:
# with it R2 answered every first upload "501 Not Implemented", though
# it had stored the file, and the publish only passed on rclone's retry.
# 1.60 sends "x-amz-acl: private" with each upload; current rclone does
# not send it to Cloudflare.
# Not RCLONE_VERSION: rclone reads every RCLONE_<FLAG> variable as that
# flag, and "--version=v1.75.2" stops it before it does anything.
- name: rclone
env:
PINNED_RCLONE: v1.75.2
PINNED_RCLONE_SHA256: efbfe852181f7191eb3c9043ed1ab49c9b2d0ba045c61c926a5da111c939ec5c
run: |
set -euo pipefail
[ -n "$RCLONE_CONFIG_R2_ACCESS_KEY_ID" ] || { echo "::error::the nightly environment has no bucket key"; exit 1; }
curl -fsSL --retry 3 -o rclone.deb "https://github.com/rclone/rclone/releases/download/$PINNED_RCLONE/rclone-$PINNED_RCLONE-linux-amd64.deb"
echo "$PINNED_RCLONE_SHA256 rclone.deb" | sha256sum -c -
sudo dpkg -i rclone.deb >/dev/null
rclone version | head -1
# --immutable: a name that exists with other bytes is an error, not an
# overwrite.
# nightly-rclone is rclone, with a warning on the run's page when an
# upload only passed on a retry: the files are compared afterwards either
# way, but it should not go unnoticed again.
- name: Upload this version
run: |
./test/vm-image/nightly-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
# is the first request the CDN sees for each file.
- name: Check what the CDN serves
run: |
set -euo pipefail
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"
}
# Every file of the version, the checksum files too.
(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 <files.txt
echo "$(wc -l <files.txt) files published and checked"
- name: Point latest.json at it
run: |
./test/vm-image/nightly-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"
+11 -4
View File
@@ -15,10 +15,10 @@ permissions:
jobs:
unit:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 10
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The tests run where the code they cover runs: on Arch, the same as
# the live ISO and an Omarchy workstation. The container boots systemd
@@ -33,7 +33,7 @@ jobs:
run: |
docker run -d --name unit --privileged --cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw --tmpfs /run --tmpfs /tmp:exec \
-v "$PWD:/src:ro" archlinux:latest bash -c '
-v "$PWD:/src:ro" ghcr.io/archlinux/archlinux:latest bash -c '
systemctl mask getty.target console-getty.service \
systemd-udev-trigger.service systemd-udevd.service \
systemd-vconsole-setup.service
@@ -46,7 +46,14 @@ jobs:
sleep 1
done
echo "systemd: $state"
pacman -Syu --noconfirm --needed python parted >/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 zip unzip >/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
+228
View File
@@ -0,0 +1,228 @@
name: Upgrade test
# What an existing user gets: installs a release from its ISO, moves it to
# another channel the way a user would (omarchy-channel-set), reboots it, and
# checks what came back. test/upgrade-test does the work and says what it
# checks.
#
# Started by hand only: from the latest release to edge unless told otherwise.
#
# When there is a pull request for the branch it ran on, the result goes there
# as one comment, updated in place on later runs, with a picture of the desktop
# before and after. The pictures are committed to the upgrade-test-screenshots
# branch, which is what lets a comment show them.
on:
workflow_dispatch:
inputs:
to:
description: "Channel to move to"
type: choice
options: [edge, rc]
default: edge
from:
description: "Release to start from, like 4.0.4 (default: the latest)"
type: string
default: ""
permissions:
contents: read
concurrency:
group: upgrade-test-${{ github.ref }}
cancel-in-progress: true
env:
TO: ${{ inputs.to || 'edge' }}
jobs:
upgrade:
name: Install, upgrade, reboot
runs-on: namespace-profile-linux-amd64-16x32
timeout-minutes: 60
outputs:
from: ${{ steps.release.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: release
env:
GH_TOKEN: ${{ github.token }}
FROM: ${{ inputs.from }}
run: |
set -euo pipefail
version=${FROM:-$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name)}
version=${version#v}
[[ $version =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "::error::'$version' is not a release version"; exit 1; }
echo "version=$version" | tee -a "$GITHUB_OUTPUT"
# Good only if the signature is the release key's, the one in this repo.
- name: Download the released ISO and check its signature
env:
VERSION: ${{ steps.release.outputs.version }}
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 install test's own install, which leaves the installed disk behind;
# the upgrade then starts from that disk. A failed upgrade does not stop
# here: the evidence is kept first, and the last step fails the job.
- name: Install the release, then upgrade it
run: |
sudo chmod 0666 /dev/kvm
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
-e OMARCHY_INTEGRATION_DISK_CACHE=none -e TO \
ghcr.io/archlinux/archlinux:latest bash -euo pipefail -c '
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
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
printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add
chmod 0755 /usr/local/bin/omarchy-pkg-add
./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800
status=0
VM_STATE_DIR=/w ./test/upgrade-test "$(ls -d test-runs/*-integration)" "$TO" upgrade-evidence || status=$?
echo "$status" >upgrade-evidence/status
'
- name: Hand the results to the runner's user
if: always()
run: sudo chown -R "$(id -u):$(id -g)" upgrade-evidence test-runs 2>/dev/null || true
- name: Keep the evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
path: upgrade-evidence/
if-no-files-found: ignore
retention-days: 14
- name: The upgrade's result
run: |
cat upgrade-evidence/report.md >>"$GITHUB_STEP_SUMMARY"
[ "$(cat upgrade-evidence/status)" = 0 ] || { echo "::error::the upgrade test failed, see the summary"; exit 1; }
# The only job that can write: it commits the two pictures and writes the
# comment. It runs nothing from the test; it handles two PNG files and a
# Markdown report.
report:
name: Report
needs: upgrade
if: ${{ !cancelled() }}
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
env:
GH_TOKEN: ${{ github.token }}
FROM: ${{ needs.upgrade.outputs.from }}
BRANCH: ${{ github.ref_name }}
SHOTS_BRANCH: upgrade-test-screenshots
steps:
# No evidence at all still gets a comment saying so.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
continue-on-error: true
with:
name: upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
path: evidence
# A comment can only show a picture that has a public address. A commit
# on a branch of this repository gives it one, and the address of a
# commit never changes.
- name: Commit the pictures
id: pictures
run: |
set -euo pipefail
[ -s evidence/before.png ] || [ -s evidence/after.png ] || { echo "no pictures to commit"; exit 0; }
url=https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git
dir=$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
git init -q shots && cd shots
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Runs on other branches push here too. A push that loses the race
# is made again on top of whatever won: the folder is this run's own,
# so there is never anything to merge.
for attempt in 1 2 3 4 5; do
if git fetch -q --depth 1 "$url" "$SHOTS_BRANCH" 2>/dev/null; then
git checkout -q -B "$SHOTS_BRANCH" FETCH_HEAD
else
git checkout -q --orphan "$SHOTS_BRANCH" 2>/dev/null || true
fi
mkdir -p "$dir"
cp ../evidence/*.png "$dir/"
git add "$dir"
git commit -q -m "Upgrade test $GITHUB_RUN_ID, attempt $GITHUB_RUN_ATTEMPT: $FROM to $TO"
if git push -q "$url" "HEAD:refs/heads/$SHOTS_BRANCH"; then break; fi
[ "$attempt" -lt 5 ] || { echo "::error::could not push the pictures after 5 tries"; exit 1; }
echo "The push lost a race, trying again"
sleep $((attempt * 2))
done
echo "base=https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$(git rev-parse HEAD)/$dir" >>"$GITHUB_OUTPUT"
- name: Write the comment
env:
PICTURES: ${{ steps.pictures.outputs.base }}
RESULT: ${{ needs.upgrade.result }}
run: |
set -euo pipefail
marker="<!-- upgrade-test -->"
{
echo "$marker"
echo "## Upgrade test: $FROM → $TO"
echo
if [ -s evidence/report.md ]; then
cat evidence/report.md
else
echo "**The test did not get as far as a report** (job result: $RESULT)."
fi
if [ -n "$PICTURES" ] && [ -s evidence/before.png ] && [ -s evidence/after.png ]; then
echo
echo "| Before, $FROM | After, on $TO |"
echo "| --- | --- |"
echo "| ![before]($PICTURES/before.png) | ![after]($PICTURES/after.png) |"
fi
# The picture of a reboot that stuck stands by itself: it is the
# one worth having when there is no "after" to show.
if [ -n "$PICTURES" ] && [ -s evidence/stuck.png ]; then
echo
echo "The reboot did not come back by itself. This is what the screen showed:"
echo
echo "![stuck]($PICTURES/stuck.png)"
fi
echo
echo "[Run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID), attempt $GITHUB_RUN_ATTEMPT, at \`${GITHUB_SHA:0:7}\`."
} >comment.md
cat comment.md >>"$GITHUB_STEP_SUMMARY"
# The open pull request for the branch this was started on, if any.
pr=$(gh pr list -R "$GITHUB_REPOSITORY" --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
[ -n "$pr" ] || { echo "No pull request for $BRANCH: the result is on this run's page."; exit 0; }
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate \
--jq "[.[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\")))][0].id // empty")
if [ -n "$existing" ]; then
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -F body=@comment.md >/dev/null
echo "Updated the comment on #$pr"
else
gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -F body=@comment.md >/dev/null
echo "Commented on #$pr"
fi
+165 -15
View File
@@ -1,37 +1,152 @@
# Omarchy ISO
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.
[![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)
## Downloading the latest ISO
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.
See the ISO link on [omarchy.org](https://omarchy.org).
## Get Omarchy
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:
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.
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
sha256sum -c omarchy-3.0.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"
```
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.
Use the `.sig` file to verify the ISO against the Omarchy signing key.
## Creating the ISO
### More in the manual
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.
- [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 local development, build the ISO from sibling checkouts:
# For maintainers
The rest of this file covers building, testing and publishing the ISO.
## What is published, and where
| What | Where | When |
| --- | --- | --- |
| Release ISO | `https://iso.omarchy.org/omarchy-<version>.iso` | Each release |
| Edge ISO | `https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.iso` | Every night |
| VM image of edge | `https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.qcow2` | Every night |
| VM image of the latest release | `https://nightly.omarchy.org/stable/<version>/<run>/omarchy-<version>.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.
## The nightly build
`.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`: 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 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.
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.
`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
| 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, 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 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. |
| `kernel` | The image's kernel release, as reported by `uname -r`. |
| `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.
## VM images
VM images are for testing. Users download the release ISO from iso.omarchy.org.
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.
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
./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs
latest=https://nightly.omarchy.org/edge/latest.json # or .../stable/latest.json
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
```
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.
The firmware paths are Arch's. `snapshot=on` keeps the downloaded image unchanged. The image is a 4 to 5 GB download.
The configurator asks for the language on its first screen: English or Simplified Chinese. Chinese reruns the installer in cage + foot, a console that draws Chinese, with Noto Sans Mono CJK unpacked from the offline mirror's `noto-fonts-cjk`. It opens on the keyboard screen, offers only keyboards with a US keymap (cage keeps a US layout) and preselects Chinese (Simplified, Pinyin); every later screen and the install progress read in Chinese. Esc there goes back to a bilingual language screen, where English returns to the VT, which applies the chosen keymap. Without a working display or the font, Chinese continues in English on the VT. Either way a Chinese install sets the target's `LANG` to `zh_CN.UTF-8` through archinstall's `locale_config`. The language never decides the region: that follows the timezone. The Chinese text lives in `OMARCHY_ZH` tables beside the English it replaces.
### Boot it faster, with SSH
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
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 "${image##*/}" # boots, prints how to log in, runs until Ctrl-C
./vm-boot "${image##*/}" uname -r # boots, runs the command over SSH, powers off
```
`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`.
## 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.
@@ -50,7 +165,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.
@@ -60,7 +175,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.
@@ -94,6 +209,22 @@ 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
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.
The configurator asks for the language on its first screen: English or Simplified Chinese. Chinese reruns the installer in cage + foot, a console that draws Chinese, with Noto Sans Mono CJK unpacked from the offline mirror's `noto-fonts-cjk`. It opens on the keyboard screen, offers only keyboards with a US keymap (cage keeps a US layout) and preselects Chinese (Simplified, Pinyin); every later screen and the install progress read in Chinese. Esc there goes back to a bilingual language screen, where English returns to the VT, which applies the chosen keymap. Without a working display or the font, Chinese continues in English on the VT. Either way a Chinese install sets the target's `LANG` to `zh_CN.UTF-8` through archinstall's `locale_config`. The language never decides the region: that follows the timezone. The Chinese text lives in `OMARCHY_ZH` tables beside the English it replaces.
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]`.
@@ -136,6 +267,25 @@ 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/<iso>-integration/runs/<timestamp>-<scenario>/`, and `--no-preview` skips the `imv` review just like the acceptance harness.
## Upgrade testing
`test/upgrade-test` checks what an existing user gets. It takes an installed system, moves it to another channel with `omarchy-channel-set`, reboots it, and checks what came back: the update's exit status, skipped migrations, that the reboot comes back by itself into an installed kernel, failed units, the desktop, the user's wallpaper and the boot menu.
```bash
./test/integration release/omarchy.iso boot # installs once, leaves the installed disk
./test/upgrade-test test-runs/omarchy-integration edge upgrade-evidence # edge or rc
```
The evidence folder gets `report.md`, the update's output, and a picture of the desktop before and after.
The "Upgrade test" workflow does both steps from a release's ISO. Start it by hand, from the latest release to edge by default:
```bash
gh workflow run upgrade-test.yml -f to=edge
```
It only runs when started. When the branch it ran on has a pull request, the result is posted there as one comment and updated in place on later runs.
## 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.
+1 -1
View File
@@ -166,7 +166,7 @@ if ! docker version &>/dev/null; then
DOCKER=(sudo docker)
fi
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" ghcr.io/archlinux/archlinux:latest /$BUILD_SCRIPT
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
+16 -2
View File
@@ -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
@@ -325,8 +338,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.
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
#
# Index the offline mirror: <dir>/offline.db.tar.gz and offline.files.tar.gz
# from every package in <dir>, the same databases one repo-add call writes.
#
# index-offline-mirror.sh <dir> [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."
@@ -71,5 +71,5 @@ def configure_keyboard(target: Path, language: str, input_method: str | None = N
def validate_input_selection(input_method: str | None, xkb_layout: str) -> None:
if input_method is not None and input_method not in {"none", "mozc", "hangul", "pinyin", "chewing"}:
raise ValueError(f"Unknown input method: {input_method}")
if xkb_layout not in {"", "kr"}:
if xkb_layout not in {"", "kr", "la"}:
raise ValueError(f"Unknown input keyboard layout: {xkb_layout}")
+1 -2
View File
@@ -14,7 +14,6 @@ SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
[core]
@@ -30,6 +29,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
+1 -2
View File
@@ -14,7 +14,6 @@ SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/rc/$arch
[core]
@@ -30,6 +29,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
+1 -2
View File
@@ -14,7 +14,6 @@ SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/stable/$arch
[core]
@@ -30,6 +29,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
+24 -4
View File
@@ -503,16 +503,23 @@ 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
}
# 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)"
@@ -534,7 +541,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).
@@ -575,7 +582,20 @@ install_phase() {
printf -v progress_name 'success-install-progress-%04ds' "$waited"
capture_console "$progress_name"
fi
((waited % 30 == 0)) && echo " ... installing (${waited}s)"
# 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
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))
+244
View File
@@ -0,0 +1,244 @@
#!/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 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/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/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"
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.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.
[[ $(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.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"
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"
# --------------------------------------------------------------- nightly-fetch
# The network is played by three stand-ins: gh lists the artifacts, curl gives
# the signed address, aria2c "downloads" a zip made here.
fetch="$ROOT/test/vm-image/nightly-fetch"
bash -n "$fetch"
if command -v zip >/dev/null && command -v unzip >/dev/null; then
stubs=$tmp/stubs; mkdir -p "$stubs" "$tmp/payload/edge/1"
echo "an image" >"$tmp/payload/edge/1/omarchy-edge-1.qcow2"
echo '{"version": "1"}' >"$tmp/payload/edge/latest.json"
(cd "$tmp/payload" && zip -qr "$tmp/artifact.zip" .)
zip_size=$(stat -c %s "$tmp/artifact.zip")
cat >"$stubs/gh" <<STUB
#!/bin/bash
case "\$*" in
"auth token") echo token ;;
*"/runs/7/artifacts"*) printf '%s\\n' \${FETCH_IDS:-11 12} ;;
*"/artifacts/"*) echo \${FETCH_SIZE:-$zip_size} ;;
esac
STUB
cat >"$stubs/curl" <<'STUB'
#!/bin/bash
echo "${@: -1}" >>"$FETCH_LOG"; printf 'https://storage.example/signed'
STUB
cat >"$stubs/aria2c" <<STUB
#!/bin/bash
echo aria2c >>"\$FETCH_LOG"
if [[ -n \${FETCH_FAIL_ONCE:-} && ! -e $tmp/failed ]]; then touch $tmp/failed; exit 1; fi
for arg in "\$@"; do case \$arg in --dir=*) dir=\${arg#--dir=} ;; --out=*) out=\${arg#--out=} ;; esac; done
cp "$tmp/artifact.zip" "\$dir/\$out"
STUB
chmod +x "$stubs"/*
fetched() { PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$@"; }
: >"$tmp/fetch.log"
fetched "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/fetched" >/dev/null
equal "fetch: the files of the artifact" "$(cd "$tmp/fetched" && find . -type f | sort | paste -sd' ')" "./edge/1/omarchy-edge-1.qcow2 ./edge/latest.json"
grep -q "/artifacts/12/zip" "$tmp/fetch.log" || fail "fetch: of two artifacts with one name, the newer was not the one fetched"
equal "fetch: nothing left behind" "$(ls "$tmp" | grep -c 'nightly-fetch\.')" "0"
: >"$tmp/fetch.log"; rm -f "$tmp/failed"
FETCH_FAIL_ONCE=1 fetched "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/fetched-again" >/dev/null 2>&1
equal "fetch: a failed download is tried again with a new address" "$(grep -c zip "$tmp/fetch.log")" "2"
refused "an artifact that does not exist" env FETCH_IDS=" " PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f3"
refused "a download of the wrong size" env FETCH_SIZE=1 PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f4"
[[ ! -e $tmp/f4/edge ]] || fail "fetch: a download of the wrong size was unpacked"
refused "an artifact larger than the free space" env FETCH_SIZE=9000000000000000 PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f5"
grep -q "MiB free" "$tmp/refused.err" || fail "fetch: too little room is not what was reported"
else
echo "nightly-fetch tests skipped: no zip or unzip here"
fi
# -------------------------------------------------------------- nightly-rclone
wrapped="$ROOT/test/vm-image/nightly-rclone"
bash -n "$wrapped"
mkdir -p "$tmp/rc"
rclone_says() { # exit status, output lines...: a stand-in rclone
local status=$1; shift
{ echo '#!/bin/bash'; printf 'echo %q >&2\n' "$@"; echo "exit $status"; } >"$tmp/rc/rclone"
chmod +x "$tmp/rc/rclone"
}
rclone_says 0 "Transferred: 3 / 3, 100%"
out=$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b)
[[ $out != *::warning::* ]] || fail "rclone: a clean upload was reported as a retry"
[[ $out == *"Transferred: 3 / 3"* ]] || fail "rclone: its output was not passed through"
rclone_says 0 "<3>ERROR : a.qcow2: Failed to copy: NotImplemented: Not Implemented" \
"<3>ERROR : a.qcow2: Failed to copy: NotImplemented: Not Implemented" \
"<3>ERROR : unpacked/vmlinuz: Failed to copy: NotImplemented: Not Implemented" \
"<3>ERROR : Attempt 2/3 failed with 2 errors" "<3>ERROR : Attempt 3/3 succeeded"
out=$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b)
[[ $out == *"::warning::rclone needed a retry for 2 file(s): a.qcow2 unpacked/vmlinuz"* ]] ||
fail "rclone: a file that failed twice is not counted once: $(grep warning <<<"$out")"
rclone_says 3 "<3>ERROR : a.qcow2: Failed to copy: AccessDenied"
if PATH="$tmp/rc:$PATH" "$wrapped" copy a b >"$tmp/rc/out" 2>&1; then fail "rclone: a failed upload passed"; fi
equal "rclone: its exit status is kept" "$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b >/dev/null 2>&1; echo $?)" "3"
if grep -q '::warning::' "$tmp/rc/out"; then fail "rclone: a failed upload was reported as a retry"; fi
# rclone reads every RCLONE_<FLAG> variable as that flag: RCLONE_VERSION=v1.75.2
# was "--version=v1.75.2" and stopped the publish before it uploaded anything.
# Only its remote's settings, RCLONE_CONFIG_*, belong in a workflow's environment.
flags=$(sed -nE 's/^[[:space:]]+(RCLONE_[A-Z0-9_]+):.*/\1/p' "$ROOT"/.github/workflows/*.yml | grep -v '^RCLONE_CONFIG_' | sort -u | paste -sd' ' || true)
[[ -z $flags ]] || fail "rclone: a workflow sets $flags, which rclone would read as flags"
# ------------------------------------------------------------ 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"
+64
View File
@@ -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"
+2 -2
View File
@@ -82,7 +82,7 @@ class KeyboardConfigurationTest(unittest.TestCase):
)
def test_input_choice_survives_while_console_keymap_stays_real(self):
for method, keymap, layout in (("mozc", "jp106", ""), ("mozc", "us", ""), ("hangul", "us", "kr"), ("pinyin", "us", ""), ("chewing", "us", "")):
for method, keymap, layout in (("mozc", "jp106", ""), ("mozc", "us", ""), ("hangul", "us", "kr"), ("none", "us", "la"), ("pinyin", "us", ""), ("chewing", "us", "")):
with self.subTest(method=method), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap, method, layout))
@@ -104,7 +104,7 @@ class KeyboardConfigurationTest(unittest.TestCase):
KEYBOARD.configure_keyboard(target, "unknown", "pinyin")
def test_invalid_input_preferences_are_rejected_without_writes(self):
for method, layout in (("unknown", ""), ("hangul", "kr\nKEYMAP=ru")):
for method, layout in (("unknown", ""), ("hangul", "kr\nKEYMAP=ru"), ("none", "az")):
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
with self.assertRaises(ValueError):
+270
View File
@@ -0,0 +1,270 @@
#!/bin/bash
#
# Takes an installed Omarchy, as a user's machine holds it, moves it to another
# channel the way a user would, reboots it, and checks what came back.
#
# test/upgrade-test <installed_dir> <channel> <evidence_dir>
#
# installed_dir is what test/integration leaves once its install has passed,
# test-runs/<iso>-integration: base.qcow2, OVMF_VARS.4m.fd and id_ed25519. It is
# not written to; the machine runs on an overlay. channel is edge or rc.
#
# The machine boots through the firmware and the boot loader, so the reboot
# after the update starts whatever kernel the update installed. Commands run
# in a login shell, as a user's terminal does: Omarchy's commands need its
# environment.
#
# evidence_dir gets before.png and after.png (the desktop, unlocked), the
# update's output, the facts before and after, and report.md; stuck.png too if
# the reboot did not come back by itself. The exit status is 0 only if every
# check passed.
#
# Needs qemu-system-x86_64, qemu-img, ssh, python3 and a usable /dev/kvm.
# Env: SMP (4), MEM (8G), SSH_PORT (2422), GUEST_PASSWORD (omarchy),
# UPDATE_TIMEOUT (seconds, 1800), EXTRA_MIGRATIONS (migration files to put
# in place before the update, to try a fix that is not released yet).
set -uo pipefail
[[ $# -eq 3 ]] || { sed -n '3,24p' "$0" >&2; exit 2; }
in=$(realpath "$1") channel=$2 out=$(realpath -m "$3")
pw=${GUEST_PASSWORD:-omarchy} port=${SSH_PORT:-2422}
[[ $channel == edge || $channel == rc ]] || { echo "upgrade-test: channel is edge or rc" >&2; exit 2; }
for f in base.qcow2 OVMF_VARS.4m.fd id_ed25519; do
[[ -s $in/$f ]] || { echo "upgrade-test: $in/$f is missing" >&2; exit 1; }
done
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "upgrade-test: /dev/kvm is not usable" >&2; exit 1; }
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 UEFI firmware found}"
mkdir -p "$out"
state=$(mktemp -d "${VM_STATE_DIR:-/var/tmp}/upgrade-test.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
log() { printf '==> %s\n' "$*"; }
install -m600 "$in/id_ed25519" "$state/key"
ssh_opts=(-i "$state/key" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=3)
guest() { ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "bash -lc $(printf %q "$1")" </dev/null; }
wait_ssh() { # seconds
local start=$SECONDS
until ssh "${ssh_opts[@]}" omarchy@127.0.0.1 true </dev/null 2>/dev/null; do
kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null || { echo "upgrade-test: qemu exited" >&2; return 1; }
(( SECONDS - start < $1 )) || return 1
sleep 1
done
}
qmp() { # shot <file> | key <qcode>...
python3 - "$state/qmp.sock" "$@" <<'PY'
import json, socket, sys
s = socket.socket(socket.AF_UNIX); s.settimeout(20); s.connect(sys.argv[1]); f = s.makefile("rw"); f.readline()
def run(command):
f.write(json.dumps(command) + "\n"); f.flush()
while "return" not in (reply := json.loads(f.readline())):
if "error" in reply: sys.exit(reply["error"]["desc"])
run({"execute": "qmp_capabilities"})
if sys.argv[2] == "shot":
run({"execute": "screendump", "arguments": {"filename": sys.argv[3], "format": "png"}})
else:
for key in sys.argv[3:]:
run({"execute": "send-key", "arguments": {"keys": [{"type": "qcode", "data": key}]}})
PY
}
# The session starts locked. Type the password, as its owner would, and wait
# for the desktop to draw.
desktop_shot() { # file
local key keys=()
for (( key = 0; key < ${#pw}; key++ )); do keys+=("${pw:key:1}"); done
qmp key "${keys[@]}" ret || true
sleep 8
qmp shot "$1" || echo "upgrade-test: no screenshot" >&2
}
# One fact per line, name=value, the same names before and after.
facts() { # file
guest '
link=~/.local/state/omarchy/current/background; target=$(readlink -f "$link" 2>/dev/null)
echo "omarchy=$(pacman -Qq omarchy omarchy-dev 2>/dev/null | sort -u | xargs -r pacman -Q 2>/dev/null | paste -sd, -)"
echo "channel=$(omarchy-channel-current 2>/dev/null)"
echo "kernel=$(uname -r)"
echo "kernel_is_installed=$([[ -d /usr/lib/modules/$(uname -r)/kernel ]] && echo yes || echo no)"
echo "systemd=$(systemctl --version | sed -n "1s/^systemd \([0-9.]*\).*/\1/p")"
echo "hyprland=$(pacman -Q hyprland 2>/dev/null | cut -d" " -f2)"
echo "system_state=$(systemctl is-system-running 2>/dev/null)"
# A manager that cannot be asked is not a manager with nothing failed.
units=$(systemctl --failed --no-legend --plain 2>/dev/null) && units=$(awk "{print \$1}" <<<"$units" | paste -sd, -) || units="could not ask systemd"
user_units=$(systemctl --user --failed --no-legend --plain 2>/dev/null) && user_units=$(awk "{print \$1}" <<<"$user_units" | paste -sd, -) || user_units="could not ask the user manager"
echo "failed_units=$units"
echo "failed_user_units=$user_units"
echo "hyprland_running=$(pgrep -c -x Hyprland)"
echo "migrations_run=$(find ~/.local/state/omarchy/migrations -maxdepth 1 -name "*.sh" | wc -l)"
echo "migrations_skipped=$(find ~/.local/state/omarchy/migrations/skipped -name "*.sh" 2>/dev/null | wc -l)"
echo "wallpaper=${target##*/}"
echo "wallpaper_exists=$([[ -n $target && -e $target ]] && echo yes || echo no)"
echo "boot_entry_groups=$(sudo grep -c "^/+" /boot/limine.conf 2>/dev/null)"
echo "boot_id=$(cut -c1-8 /proc/sys/kernel/random/boot_id)"
' 2>/dev/null | grep -E '^[a-z_]+=' >"$1"
}
fact() { sed -n "s/^$2=//p" "$1" | head -n 1; }
qemu-img create -qf qcow2 -b "$in/base.qcow2" -F qcow2 "$state/run.qcow2"
cp "$in/OVMF_VARS.4m.fd" "$state/vars.fd"
: >"$out/serial.log"
qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp "${SMP:-4}" -m "${MEM:-8G}" \
-drive "if=pflash,format=raw,readonly=on,file=$fw_code" -drive "if=pflash,format=raw,file=$state/vars.fd" \
-drive "file=$state/run.qcow2,format=qcow2,if=none,id=d0" -device virtio-blk-pci,drive=d0 \
-device virtio-vga -device virtio-rng-pci -display none -usb -device usb-kbd -serial "file:$out/serial.log" \
-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
log "Booting the installed system"
wait_ssh 300 || { echo "upgrade-test: the installed system did not come up" >&2; exit 1; }
# The overlay is thrown away: let sudo through without asking, so a long
# update does not stop at a password prompt nobody is there to answer.
# sudo takes the last rule that matches, and reads this folder in name order:
# the name has to sort after the install's own rule for the wheel group.
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "echo $(printf %q "$pw") | sudo -S -p '' sh -c 'printf \"%s\\n\" \"omarchy ALL=(ALL:ALL) NOPASSWD: ALL\" \"Defaults:omarchy !authenticate\" >/etc/sudoers.d/zzz-upgrade-test'" </dev/null
# "sudo -v" is the hard one: it asks unless every rule for the user lets it
# through, which is why the rule alone is not enough.
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 'sudo -k; sudo -n true && sudo -n -v' </dev/null ||
{ echo "upgrade-test: sudo still asks for a password in the guest" >&2; exit 1; }
guest 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
facts "$out/before.txt"
desktop_shot "$out/before.png"
log "Before: $(fact "$out/before.txt" omarchy), kernel $(fact "$out/before.txt" kernel), channel $(fact "$out/before.txt" channel)"
for migration in ${EXTRA_MIGRATIONS:-}; do
log "Putting $(basename "$migration") in place before the update"
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "sudo tee /usr/share/omarchy/migrations/$(basename "$migration") >/dev/null" <"$migration"
done
# A channel that offers what the machine already has, or something older, is
# no upgrade. Say so now: the release's own updater, run with nothing to do,
# ends at a prompt nobody is there to answer.
case $channel in edge) package=omarchy-dev ;; *) package=omarchy ;; esac
# A server that cannot be reached, or does not answer in time, is not a channel
# with nothing on offer: that is a failure of its own, said as one.
if ! offered=$(guest "set -o pipefail; curl -fsSL --connect-timeout 20 --max-time 120 --retry 2 https://pkgs.omarchy.org/$channel/\$(uname -m)/omarchy.db | bsdtar -xOf - '$package-[0-9]*/desc' | sed -n '/^%VERSION%\$/{n;p;}' | head -n 1" 2>"$out/probe.log") || [[ -z $offered ]]; then
{
echo "**The $channel channel could not be asked what it offers.** The test did not run."
echo
echo '```'
tail -n 5 "$out/probe.log"
echo '```'
} >"$out/report.md"
cat "$out/report.md"
exit 1
fi
installed=$(guest "pacman -Q $package 2>/dev/null | cut -d' ' -f2" 2>/dev/null)
log "The $channel channel offers $package $offered; installed: ${installed:-none}"
# pacman's own ordering: 0 for the same version, below 0 for an older offer.
newer=1
[[ -z $installed ]] || newer=$(guest "vercmp $(printf %q "$offered") $(printf %q "$installed")" 2>/dev/null)
if (( ${newer:-0} <= 0 )); then
{
echo "**Nothing to upgrade to.** The $channel channel offers \`$package $offered\`, and the machine has \`$(fact "$out/before.txt" omarchy)\`."
echo
echo "The test did not run: point it at a channel that holds a newer Omarchy."
} >"$out/report.md"
cat "$out/report.md"
exit 1
fi
log "omarchy-channel-set $channel"
update_start=$SECONDS
guest "timeout ${UPDATE_TIMEOUT:-1800} omarchy-channel-set $channel" >"$out/update.log" 2>&1
update_status=$?
update_seconds=$(( SECONDS - update_start ))
sed -i 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\r/\n/g' "$out/update.log"
migrations_in_update=$(grep -c '^Running migration' "$out/update.log" || true)
log "The update exited $update_status after $update_seconds s, $migrations_in_update migrations"
log "Rebooting"
guest 'sudo systemctl reboot' >/dev/null 2>&1 || true
# Wait for the old boot to go away first: its sshd answers for a moment yet.
for _ in $(seq 1 90); do
ssh "${ssh_opts[@]}" -o ConnectTimeout=2 omarchy@127.0.0.1 true </dev/null 2>/dev/null || break
sleep 1
done
rebooted=no
if wait_ssh 150; then
rebooted=yes
else
# A boot that sits there until a key is pressed is a failure, and a known
# kind: keep what the screen showed, then press Enter to find out whether
# that was it, and to have a system to collect the rest from.
qmp shot "$out/stuck.png" || true
qmp key ret || true
if wait_ssh 150; then rebooted="only after a key was pressed"; fi
fi
if [[ $rebooted != no ]]; then
guest 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
facts "$out/after.txt"
desktop_shot "$out/after.png"
else
: >"$out/after.txt"
qmp shot "$out/after.png" || true
fi
log "After: $(fact "$out/after.txt" omarchy), kernel $(fact "$out/after.txt" kernel), channel $(fact "$out/after.txt" channel)"
failures=0
rows=()
check() { # description, command...
local what=$1; shift
if "$@"; then rows+=("| ✅ | $what |"); else rows+=("| ❌ | $what |"); failures=$((failures + 1)); fi
}
b() { fact "$out/before.txt" "$1"; }
a() { fact "$out/after.txt" "$1"; }
check "The update finished without an error (exit $update_status)" test "$update_status" = 0
check "No migration was skipped ($(a migrations_skipped) skipped)" test "$(a migrations_skipped)" = 0
check "The system came back from the reboot by itself (came back: $rebooted)" test "$rebooted" = yes
check "It is a new boot (boot ID $(b boot_id) → $(a boot_id))" test -n "$(a boot_id)" -a "$(a boot_id)" != "$(b boot_id)"
check "It is on the $channel channel (got: $(a channel))" test "$(a channel)" = "$channel"
# A channel that holds what the machine already had upgrades nothing, and
# every other check would pass on a machine that was only rebooted.
check "Omarchy itself changed ($(b omarchy) → $(a omarchy))" test -n "$(a omarchy)" -a "$(a omarchy)" != "$(b omarchy)"
check "The running kernel is an installed one ($(a kernel))" test "$(a kernel_is_installed)" = yes
check "systemd reports the system running (got: $(a system_state))" test "$(a system_state)" = running
failed="$(a failed_units) $(a failed_user_units)"
failed=$(xargs <<<"$failed")
check "No failed units (${failed:-none})" test -z "$failed"
check "The desktop is up (Hyprland processes: $(a hyprland_running))" test "$(a hyprland_running)" = 1
# The same picture, whatever format it is kept in now: an update may convert
# the bundled images, and may not swap the one the user chose for another.
before_wallpaper=$(b wallpaper) after_wallpaper=$(a wallpaper)
check "The wallpaper is the one the user had, and exists ($before_wallpaper → $after_wallpaper)" \
test "$(a wallpaper_exists)" = yes -a -n "$after_wallpaper" -a "${after_wallpaper%.*}" = "${before_wallpaper%.*}"
check "The boot menu has one Omarchy group ($(a boot_entry_groups))" test "$(a boot_entry_groups)" = 1
row() { printf '| %s | %s | %s |\n' "$1" "$(b "$2")" "$(a "$2")"; }
{
if (( failures )); then echo "**$failures of ${#rows[@]} checks failed.**"; else echo "**All ${#rows[@]} checks passed.**"; fi
echo
echo "| | Before | After the update and a reboot |"
echo "| --- | --- | --- |"
row "Omarchy" omarchy
row "Channel" channel
row "Kernel running" kernel
row "systemd" systemd
row "Hyprland" hyprland
row "Migrations run" migrations_run
row "Wallpaper" wallpaper
echo
echo "The update took $update_seconds s and ran $migrations_in_update migrations."
echo
echo "| | Check |"
echo "| --- | --- |"
printf '%s\n' "${rows[@]}"
} >"$out/report.md"
cat "$out/report.md"
(( failures == 0 ))
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Fetches a run's artifact for the publish job, and fast.
#
# nightly-fetch <owner/repo> <run_id> <artifact_name> <out_dir>
#
# The download action takes a multi-gigabyte artifact over a few connections,
# and GitHub's storage limits each one to a few megabytes a second: an 11 GB
# release would outlast the job. An artifact is one zip at a signed address,
# so this asks the API for that address and takes it over 16 connections.
#
# The zip goes to TMPDIR and is deleted once unpacked, so there has to be room
# for the artifact twice: point TMPDIR at a disk, not at a /tmp held in memory.
#
# Needs gh (with GH_TOKEN allowed to read the run's artifacts), curl, aria2c
# and unzip. A run started again keeps the artifacts of its earlier attempts
# under the same name: the newest is the one fetched.
set -euo pipefail
[[ $# -eq 4 ]] || { sed -n '2,16p' "$0" >&2; exit 2; }
repo=$1 run=$2 name=$3 out=$4
fail() { echo "nightly-fetch: $*" >&2; exit 1; }
id=$(gh api "repos/$repo/actions/runs/$run/artifacts" --paginate \
--jq ".artifacts[] | select(.name == \"$name\" and .expired == false) | .id" | sort -n | tail -n 1)
[[ -n $id ]] || fail "run $run has no artifact named $name"
size=$(gh api "repos/$repo/actions/artifacts/$id" --jq .size_in_bytes)
echo "nightly-fetch: artifact $id, $((size / 1048576)) MiB"
zip=$(mktemp -u "${TMPDIR:-/tmp}/nightly-fetch.XXXXXX.zip")
mkdir -p "$out"
for place in "$(dirname "$zip")" "$out"; do
free=$(( $(df --output=avail -B1 "$place" | tail -n 1) ))
(( free > size )) || fail "$place has $((free / 1048576)) MiB free, the artifact is $((size / 1048576)) MiB"
done
trap 'rm -f "$zip" "$zip.aria2"' EXIT
started=$(date +%s)
# The address is signed for a few minutes. A try that fails asks for a new one
# and carries on from what is already there.
for attempt in 1 2 3; do
url=$(curl -fsS -o /dev/null -w '%{redirect_url}' \
-H "Authorization: Bearer $(gh auth token)" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$repo/actions/artifacts/$id/zip")
[[ $url == https://* ]] || fail "the API gave no download address for artifact $id"
if aria2c --quiet --continue --max-connection-per-server=16 --split=16 --min-split-size=16M \
--file-allocation=none --dir="$(dirname "$zip")" --out="$(basename "$zip")" "$url"; then
break
fi
(( attempt < 3 )) || fail "the download failed three times"
echo "nightly-fetch: attempt $attempt failed, trying again" >&2
sleep 10
done
[[ $(stat -c %s "$zip") == "$size" ]] || fail "downloaded $(stat -c %s "$zip") bytes, the artifact is $size"
seconds=$(( $(date +%s) - started ))
echo "nightly-fetch: downloaded in $seconds s, $(( size / 1048576 / (seconds > 0 ? seconds : 1) )) MiB/s"
unzip -q "$zip" -d "$out"
echo "nightly-fetch: $(find "$out" -type f | wc -l) files in $out"
+112
View File
@@ -0,0 +1,112 @@
#!/bin/bash
# Lays out what one channel of a nightly run publishes and writes the
# latest.json that describes it.
#
# nightly-layout <channel> <version> <release> <iso> <sealed_dir> <vm_packages> <out_dir>
#
# 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, under <release>/:
#
# 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-<version>.iso and .sha256 edge only
#
# and <channel>/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,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}
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"
unpacked=(vmlinuz initrd cmdline)
for file in base.qcow2 "${unpacked[@]}"; 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/unpacked" "$out/$channel"
if [[ $channel == edge ]]; then
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
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
# 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 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 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: "\($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"
}
}' "${unpacked[@]}" >"$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.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"
+42
View File
@@ -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 <date> <run_number> <run_attempt> <event> <release_tag> [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"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Runs rclone for the publish job and says when it only got there on a retry.
#
# nightly-rclone <rclone arguments...>
#
# rclone tries a failed transfer again by itself, and a run that passes on the
# second attempt looks like any other. This passes rclone's output and exit
# status through, and adds a warning for the run's page naming the files that
# failed on an earlier attempt.
set -uo pipefail
log=$(mktemp)
trap 'rm -f "$log"' EXIT
rclone "$@" 2>&1 | tee "$log"
status=${PIPESTATUS[0]}
files=$(sed -nE 's/^.*ERROR : (.*): Failed to copy: .*$/\1/p' "$log" | sort -u)
if [[ $status -eq 0 && -n $files ]]; then
echo "::warning::rclone needed a retry for $(wc -l <<<"$files") file(s): $(paste -sd' ' <<<"$files")"
fi
exit "$status"
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# Boots a sealed Omarchy image (vm-seal) as a throwaway machine.
#
# vm-boot <image> boot, print how to log in, run until Ctrl-C
# vm-boot <image> <command...> boot, run the command over SSH, power off;
# exits with the command's status
#
# <image> 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.
#
# 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,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; }
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 "$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}" \
-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: the probe must not eat a script meant for the command below.
until ssh "${ssh_opts[@]}" -o ConnectTimeout=1 omarchy@127.0.0.1 true </dev/null 2>/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 <script` works.
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "$@"
else
echo "ssh ${ssh_opts[*]} omarchy@127.0.0.1"
echo "Desktop and sudo password: omarchy. QMP socket: $state/qmp.sock. Ctrl-C powers it off."
while kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null; do sleep 1; done
fi
+148
View File
@@ -0,0 +1,148 @@
#!/bin/bash
# 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> <sealed_dir>
#
# installed_dir holds base.qcow2, OVMF_VARS.4m.fd and id_ed25519, which is
# what test/integration leaves in test-runs/<iso>-integration once its install
# 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.
set -euo pipefail
[[ $# -eq 2 ]] || { echo "Usage: vm-seal <installed_dir> <sealed_dir>" >&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"
# 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
# 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"
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")"
+49
View File
@@ -0,0 +1,49 @@
#!/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 <sealed_dir>
set -euo pipefail
[[ $# -eq 1 ]] || { echo "Usage: vm-verify <sealed_dir>" >&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 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)"
exit $fail
GUEST