Compare commits

...
288 Commits
Author SHA1 Message Date
ZacharyZhang-NY c407101cef Merge chinese-installer (with omarchy-iso #241)
# Conflicts:
#	README.md
2026-10-12 01:21:51 -04:00
ZacharyZhang-NY e22a13dfeb Ask for the language first, and install in Chinese in a CJK console
The first screen offers English or Simplified Chinese. Chinese reruns
the configurator in cage + foot, which draw Chinese, with Noto Sans
Mono CJK unpacked from the offline mirror's noto-fonts-cjk; no font
joins the live image. There the keyboard list keeps US-keymap layouts
and preselects Chinese (Simplified, Pinyin), every later screen and
the install dashboard read in Chinese, and the target gets
LANG=zh_CN.UTF-8 through locale_config. Without a display or the
font, Chinese continues in English on the VT. The summary shows the
keyboard by its label instead of its keymap.

Needs omarchy's omarchy_prompt_language and omarchy_text in
install/provisioning/setup-form.sh.
2026-10-12 01:21:41 -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
Kros DaiandClaude Opus 5.5 31cca48c14 🐛 fix(build): refuse profiles the runtime cannot apply
- 🐛 profiles and the finalizer ship in separate packages, so a skewed pair would give regional targets a keyring without the repositories

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 16:08:59 -04:00
Kros DaiandClaude Opus 5.5 abc1a7716d 🐛 fix(install): reject non-string region overrides
- 🐛 a boolean or number in user_configuration.json crashed with a traceback instead of a configuration error
- ✅ pin regional packages inside the offline mirror mount window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:32:46 -04:00
Kros DaiandClaude Opus 5.5 27b7e76c5a 🐛 fix(build): read region profiles from settings package
- 🐛 default/ ships in omarchy-settings, so published builds silently bundled no regions
- ✅ cover the published package path, a package without profiles, and an unreadable one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:32:46 -04:00
Kros DaiandClaude Opus 5.5 9dcfdb035a 📝 docs(autoinstall): explain region selection
- 📝 unattended installs need to know how to pick a region without a timezone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:03:31 -04:00
Kros DaiandClaude Opus 5.5 e121561851 ✨ feat(install): pick the region from the timezone
- ✨ the timezone the user already chose selects the region, so no new prompt is needed
- 🔒 populate only the selected region's keys so global installs never trust them
- ✅ cover the mapping, autoinstall override, marker, keyring setup, and builder staging

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:03:31 -04:00
Kros DaiandClaude Opus 5.5 1c912658aa ✨ feat(build): bundle every region profile offline
- ✨ one iso serves every region, so regional packages ride in the offline repo without being installed by default
- 🔒 verify each regional keyring through arch's trust chain before downloading its packages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:03:31 -04:00
David Heinemeier Hansson 8b17699997 Prioritize OPR in online installer repositories (#240) 2026-10-08 16:57:28 +02:00
David Heinemeier Hansson 595d38cb04 Carry installer input-method selection into the target (#237)
Install test / install (push) Failing after 13s
Unit tests / unit (push) Failing after 17s
* Carry the installer input-method choice into the target

* Support Archinstall 4.5 sanity checks

* Constrain VM keyboard choices to supported credential layouts

* Cover VM keyboard offsets and reject absent choices
2026-10-07 21:21:55 +02:00
Emir Beganović 073f7b2fc0 Merge pull request #214 from omacom/ci/unit-tests
ci: run the VM-free unit tests on every pull request
2026-10-05 09:24:35 +02:00
Emir Beganovic d02dee7e73 ci: run the VM-free unit tests on every pull request
./test/all runs the cidata and orchestrator tests under test/unit/ in a couple of seconds, with no ISO. It runs in an Arch container, where the code it covers runs: the container boots systemd for the tests that ask localectl for its keymaps, and the tests run as an ordinary user. The install test keeps covering everything that needs a VM.
2026-10-05 03:17:37 +02:00
Emir Beganović 36b072ce76 Merge pull request #213 from omacom/ci/install-test
CI: install test on every pull request
2026-10-05 02:58:17 +02:00
Emir Beganovic fe133bd419 ci: hand root-owned test results to the runner; install progress every 30 s
The harness runs as root in its container, so test-runs and the serial
logs QEMU writes there belong to root. The runner's user could then
neither record the pull request number beside them nor read
install-serial.log into the artifact (EACCES at zip time), which failed
the job after a passing install and boot. A chown -R step after the
container step, run even on failure, fixes both. The install loop now
prints a line every 30 s and still screenshots every 2 min.
2026-10-05 01:45:19 +02:00
Emir Beganovic 89a34f7885 ci: comment survives a missing baseline, picks the right attempt
Review fixes for the comment workflow. Artifacts are downloaded by name
for the exact attempt that finished, and for the baseline run's latest
attempt, since a rerun keeps earlier attempts' artifacts under the same
run. Every lookup prints nothing instead of failing, so no quattro
baseline yet, an expired one, or a missing boot file shows as "–"
rather than ending the script under set -e before any comment. The
install test's own marker step takes ownership of test-runs first,
which the harness created as root inside its container.
2026-10-05 01:42:36 +02:00
Emir Beganovic 37f71a0efb ci: run the install test with QEMU 11 in an Arch container
The harness now runs in archlinux:latest with /dev/kvm passed through,
so CI uses Arch's current QEMU and OVMF (QEMU 11.1, edk2 202608) at the
paths the harness expects, the same as an Omarchy workstation, instead
of Ubuntu 24.04's QEMU 8.2 with symlinks and shims. Arch splits QEMU's
devices into modules: virtio-vga needs both qemu-hw-display-virtio-gpu
and qemu-hw-display-virtio-vga, and QEMU 11.1 segfaults on
-device virtio-vga when only the latter is installed.
2026-10-05 01:30:01 +02:00
Emir Beganovic 84149ef33b test/integration: wall-clock SSH wait with progress
wait_for_ssh counted only its sleeps toward the timeout. Under QEMU user
networking each attempt can also spend its whole ConnectTimeout waiting
for a banner, so a "300 s" wait ran for ten minutes or more without a
line of output. It now uses a wall-clock deadline, polls every 2 s, and
prints a progress line with a console screenshot every 30 s. When the
boot scenario times out it prints the serial console's last lines too.
2026-10-05 01:20:00 +02:00
Emir Beganovic 57df34ddfc test/integration: wait for startup before timing, keep failures visible
Review fixes. The first-boot systemd-analyze now waits (bounded) for
startup to finish, since SSH can answer first and systemd-analyze
refuses until it has. In the boot scenario, a non-zero exit from
is-system-running (as for "degraded") no longer ends the script under
set -e before the diagnostics are saved, and a failed query for failed
units writes a marker instead of an empty file that read as "none".
2026-10-05 01:10:30 +02:00
Emir Beganovic 6aef3008c1 ci: post install-test numbers on the pull request
A second workflow, triggered when the install test completes, posts one
comment on the pull request and updates it on every run: install, first
boot and everyday boot next to quattro's latest numbers, with the change,
and the install phases in a collapsed table. Pushes to quattro now run
the install test too, so there is always a baseline.

It runs from quattro's copy with write access, which works for pull
requests from forks, so it never checks out or runs pull request code.
It reads only the finished run's artifact, trusts the pull request
number in it only when that pull request's head is the commit the run
tested, and keeps phase names to plain text.
2026-10-05 00:59:00 +02:00
Emir Beganovic 1ad8872146 ci: install test on every pull request
Builds the ISO a pull request produces, installs it unattended in a KVM
guest on a GitHub-hosted runner, boots the installed system again, and
writes the install time per phase plus first-boot and everyday-boot
systemd-analyze to the job summary. Every later installer change is
measured against these numbers in its own PR.

Locally against the 2026-10-04 quattro ISO: install 51.3 s (42.9 s of
it pacstrap), first boot 12.1 s, everyday boot 11.7 s with ~6 s in the
loader, systemd running, no failed units.

A disk check replaces a cleanup step: the runner has ~86 GB free, so
the 2-minute "Free Disk Space" action the nightly uses is not needed.
2026-10-05 00:54:55 +02:00
Emir Beganovic e7774eeeaf test/integration: boot scenario for the everyday boot
Boots the installed system a second time, records the time to SSH and
systemd-analyze, and asserts systemd reaches running with no failed
units. It gives CI a scenario that passes on today's installer and a
number for every change that touches boot.
2026-10-05 00:54:55 +02:00
Emir Beganovic 82d5693395 test/integration: keep the install timing and first-boot numbers
After an unattended install the harness now copies the installer's
timing file and log off the installed system, with systemd-analyze from
its first boot, next to the base image, so CI can report them without
attaching the disk. OMARCHY_INTEGRATION_DISK_CACHE sets the target
disk's QEMU cache mode; cache=none keeps the host page cache out of the
guest's writes, so install times measure the installer rather than the
host.
2026-10-05 00:54:55 +02:00
Emir Beganović 1ca5e5a7a3 Merge pull request #196 from narze/quattro-nightly-build-fix
Fix failing Nightly ISO build since ~Sep 17 due to missing apple-bcm-firmware package
2026-10-05 00:23:24 +02:00
Ryan Hughes 86c07785cb Merge pull request #198 from omacom/feat/install-icon-progress
Draw the Omarchy icon as installation progresses
2026-09-23 23:21:56 -05:00
Ryan Hughes 9373d0e139 Draw the Omarchy icon as installation progresses 2026-09-23 23:18:10 -04:00
Manassarn ManoonchaiandClaude Opus 5 555c364221 Follow the T2 firmware package through its rename
arch-mact2 dropped apple-bcm-firmware on 2026-09-16 and published
apple-bcm-firmware-fetcher in its place. The new package only conflicts
with the old name instead of replacing it, so pacman cannot follow the
rename and every nightly build has aborted with "target not found"
since 2026-09-17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRnZrQDE6jowz4KxmzusL8
2026-09-21 18:27:34 +07:00
Ryan Hughes 7cfb7111a0 Merge pull request #184 from omacom/fix/iso-kernel-headers
Install and validate matching kernel headers in the ISO
2026-09-14 22:35:33 -07:00
Ryan Hughes 1daf1202d4 Install and validate matching kernel headers in the ISO 2026-09-15 00:06:40 -04:00
Ryan Hughes 47426693de Merge pull request #182 from omacom/fix/iso-linux-omarchy-default
Install linux-omarchy by default except on T2 Macs
2026-09-14 18:39:34 -07:00
Ryan Hughes b507a20b7e Install linux-omarchy by default except on T2 Macs 2026-09-14 18:45:34 -04:00
Ryan Hughes 447f6d2e58 Merge pull request #168 from drmikecrowe/greeter-matcher
Match the greeter on the part of the tagline that is not the pitch
2026-09-14 13:14:37 -07:00
Ryan Hughes a23f8d464d Merge pull request #163 from omacom/fix/broadcom-wl-dkms
Map broadcom-wl to broadcom-wl-dkms in the offline mirror list
2026-09-07 23:03:57 -04:00
Ryan Hughes 6f5ab0bcd1 Map broadcom-wl to broadcom-wl-dkms in the offline mirror list
Arch dropped the prebuilt broadcom-wl on 2026-09-02 and rebuilt broadcom-wl-dkms with replaces=(broadcom-wl). That only helps upgrades of an installed package; as an explicit pacman -Syw target the old name fails with "target not found", which broke the offline mirror download. Published runtime packages that predate the rename still list it in omarchy-other.packages, so rewrite it at build time until every channel ships a runtime naming broadcom-wl-dkms itself.
2026-09-07 23:03:16 -04:00
Mike Crowe 2f5af06895 Match the greeter on the part of the tagline that is not the pitch
The acceptance harness waits for the installer greeter by looking for
"Opinionated", which the tagline no longer contains: it now reads "Beautiful,
Fun & Agentic Linux by DHH". Every scenario the harness drives fails at the
first screen with

  Timed out after 300s waiting for screen: Opinionated

five minutes into a run that never gets further.

Matching "Linux by DHH" instead. That is the half of the tagline naming the
product rather than describing it, it is common to the old wording and the new,
and OCR reads it cleanly off the greeter. A tagline is marketing copy and will
be rewritten again; the product name is what the test actually means.
2026-09-03 07:49:55 -04:00
David Heinemeier Hansson 2673c613d9 Update the tagline to Beautiful, Fun & Agentic (#144)
Matches omarchy.org and the omarchy repo. The centring math keys off the string length, so the shorter line stays centred.
2026-09-01 11:54:27 +02:00
Ryan Hughes d6e4060d56 Add omarchy-iso-test-stop to shut down the kept-running VM
Graceful guest poweroff over SSH first, escalating to TERM then KILL;
--kill skips straight to force. Finds the newest live run by its pidfile
and confirms before acting when run from a terminal, since a harness
still driving the VM dies with it.
2026-08-30 18:33:17 -04:00
Ryan Hughes 100a41f7fd Type the console bootstrap blind and gate it on SSH
A VNC viewer that resizes the guest framebuffer makes the console font
unreadable to OCR, which stranded the bootstrap at its login-prompt gate.
Getty is reliably on the spare TTY and SSH coming up is the real success
signal, so type the login blind and retry the whole sequence up to three
times; a failed pass just leaves getty re-prompting.

Also print the resize-safe viewer invocation whenever a VM starts: a
resize during early boot can wedge virtio-gpu before the system is up.
2026-08-30 18:33:17 -04:00
Ryan Hughes f9be60b24b Name the released ISO after its full candidate version
--rc appended a bare -rc suffix regardless of the version passed, so
every candidate of a train uploaded over the same omarchy-X.Y.Z-rc.iso
and nobody could tell rc1 from rc2 by name or URL. A version that
already numbers its candidate (4.0.2rc2) is the complete artifact name;
the suffix remains for unnumbered candidate builds.
2026-08-30 17:15:42 -04:00
Ryan Hughes 222a742daf Merge pull request #136 from omacom/tagline-beautiful-fun
Change the greeter tagline to "Beautiful, Fun & Opinionated"
2026-08-30 17:03:17 -04:00
Ryan Hughes 9900e63bd4 Merge pull request #139 from omacom/fix/iso-test-ocr-bindings-vnc
Fix omarchy-iso-test staleness against 4.0.2-rc and add VNC visibility
2026-08-30 16:57:08 -04:00
Ryan Hughes 37fd3c4305 Sync the whole test suite and run it in the guest
--sync-omarchy now pushes all of test/ (the security coverage lives in
shell.d as well as acceptance.d), and the acceptance run gets
OMARCHY_ACCEPTANCE_SUDO_PASSWORD so the suite's opt-in sshd hardening
exercise can run against the throwaway VM.

--sync-all additionally runs the CLI and shell suites inside the guest
from the synced tree. The acceptance suite always runs against
/usr/share/omarchy either way: VM runs verify the finished product, and
qs matches shell instances by config path, so pointing the suite at the
synced tree just reads the session shell as "not running".
2026-08-30 16:45:35 -04:00
Ryan Hughes e89c0796f7 Serve a localhost VNC display from test VMs
Runs stay headless, but a viewer on 127.0.0.1:5905 can watch the driven
install live or inspect the acceptance VM left up by --keep-running.
2026-08-30 15:30:37 -04:00
Ryan Hughes 0b2739a55d Match the shortcut smoke to the menu-first bindings
Omarchy now binds Super+Space to the Omarchy menu and Super+Alt+Space to
the apps menu, and the omarchy.launcher plugin is gone (the apps menu
replaced it). The smoke table still expected the old launcher binding, so
both chords failed against current builds. Both surfaces open under the
omarchy-menu layer namespace.
2026-08-30 15:30:31 -04:00
Ryan Hughes 9a7325e3a6 Stop OCR-waiting on the unencrypted install confirm
Tesseract cannot read the highlighted "Yes, install without encryption"
button under any preprocessing, so waiting for that text always timed out
and killed unencrypted installs at the disk warning. Capture the screen and
continue instead; a Ctrl+C toggle that failed to apply still surfaces when
the unexpectedly encrypted install stalls at the LUKS prompt on first boot.
2026-08-30 15:30:21 -04:00
Ryan Hughes bba81a6ec5 End the release by printing the URL, checksum, and local path
The artifacts a release exists to hand out — the download URL and the
checksum to verify it with — were buried in rclone transfer noise or
absent entirely. Print them together as the final output, after the
upload has actually succeeded (set -e means reaching this line is the
success path).
2026-08-30 14:20:08 -04:00
Ryan Hughes 6b5ce68f25 Run docker through sudo when the socket refuses the user
The docker group is root-equivalent, so Omarchy operators stay out of
it on purpose — which left omarchy-iso-make unable to reach the daemon
at all. Fall back to sudo docker when a plain client call cannot reach
the socket; the script already prompts for interactive sudo elsewhere.
DOCKER_ARGS (including HOST_UID/HOST_GID) are assembled before the
escalation, so output ownership is unchanged.
2026-08-30 14:19:21 -04:00
Ryan Hughes 02f8d1cf50 Stop the ISO release when a step fails
omarchy-iso-release ran every step unconditionally: when docker denied
the build, it fell through to signing and uploading the newest stale
*-rc.iso from release/ under the new release's name — a six-month-old
ISO shipped as omarchy-4.0.2-rc.iso. set -e ends the release at the
first failed step, and also aborts when release/ holds no ISO for the
ref at all.
2026-08-30 14:15:01 -04:00
David Heinemeier Hansson 3559b2bc9d Change the greeter tagline to "Beautiful, Fun & Opinionated"
The same frame opens omarchy's first boot from its own copy of the string, so both repositories change together or an install and the deferred first boot after it bookend the setup with different words. Nothing generates one copy from the other: the vendoring between the repositories covers the setup form, not the greeter.

The line is centred from its own length, so the three characters it loses move it one column and nothing else.
2026-08-30 14:33:48 +02:00
Ryan Hughes d2e764d317 RC ISO installs from the rc package channel
pacman-online-rc.conf's [omarchy] repo moves from pkgs.omarchy.org/edge to
pkgs.omarchy.org/rc: an RC ISO now bundles and installs the release-candidate
packages against the package set stable users will get, instead of edge
churn.
2026-08-26 01:37:17 -04:00
OmarchybotandCodex XHigh 268bac16d3 Name the install medium when pacstrap rejects a package off it (#120)
* Name the install medium when pacstrap rejects a package off it

A failed install showed "Omarchy installation stopped", the pacstrap error, a log tail, and a link to Discord. Somewhere in that tail was the whole answer -- pacman rejecting a package as corrupt -- and nothing on the screen connected it to the USB stick it came off.

Nothing else can put it there. The offline mirror is bind-mounted onto the target's package cache for the duration of pacstrap, so a package rejected under that path was read straight off the medium; nothing else ever writes there. That is worth saying in the words that name the fix rather than leaving it as a package name nobody recognises.

Which way the medium failed is a separate question, and usually unanswerable after the fact: pacstrap runs with --noconfirm, which answers yes to pacman's "delete it?", so the evidence is unlinked through the bind mount before anything can weigh it. The usual verdict therefore names the medium and gives both remedies in the order worth trying -- verify the download first, because it is cheap and rules out the common case. When the package does survive, the sha256 repo-add recorded at build time separates a bad write from a bad read, and the wording follows.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Keep the media diagnosis whole and print it once

Two things only visible by looking at the rendered screen.

The diagnosis was recorded in the support log before the failure screen was drawn, and the log tail on that screen reads the same file -- so the verdict appeared twice, once as the banner and again eight lines below it. The same finding printed twice reads as two findings. It is recorded after the render now, and the tail carries the installer's own output the way it did before.

The package name shared a line with the sentence explaining it, and the screen is only as wide as the logo. npth's name already pushed the mismatch wording past the edge, and a firmware package would have taken the whole sentence with it. Every verdict puts the name on a line of its own, and the test walks all three with a sixty-three character package name to keep them there.

Co-Authored-By: Codex XHigh <codex@openai.com>

* Break the media diagnosis between what happened and what to do

The package name and the remedy beneath it ran together as one block, so the line telling someone to verify their download read as another clause about the package rather than the first thing to go and do. A blank line separates them in all three verdicts.

The renderer dropped blank lines, on the assumption that any it saw were slack rather than shape, so the break is drawn now instead of skipped.

* Give the log tail back the rows the diagnosis spends

The tail sizes itself from the terminal height less a constant for everything drawn above it, and the diagnosis is seven rows that constant never knew about. On a forty-row console the failure screen came to forty-one and scrolled its own logo off the top. The tail is the part that should give way, so it is told what was spent above it.

The test that came with this first asserted the logo was still on screen, which it cannot: it reads the output stream, and the stream keeps every row ever emitted whether or not it stayed visible. It passed with the fix reverted. It checks the mechanism instead -- a tail that reaches fewer lines of the log when a diagnosis is present -- against two logs identical line for line but for the one path that decides whether there is a diagnosis at all.

---------

Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-23 21:20:13 +02:00
OmarchybotandCodex XHigh 6d02625f7f Publish a checksum beside every released ISO (#119)
The release script computed the ISO's sha256 and echoed it to the releaser's terminal, where nobody downloading the ISO could ever see it, and the uploader shipped the ISO and its signature but nothing to check the download against. So "verify your download" was not advice anyone could act on.

That matters more for this ISO than for most. The offline pacman mirror lives inside the airootfs and is stored uncompressed, and the installer bind-mounts it onto the target's package cache, so pacstrap reads packages straight off the medium. A flipped block there meets no decompressor and no squashfs block checksum on the way; pacman's integrity check is the first thing in the chain to notice, minutes into the install, as "invalid or corrupted package" against a package name that says nothing about the download.

The sidecar names the ISO alone rather than the build path, so it verifies from whatever directory it was downloaded into. A release that cannot write it stops, because the sidecar left over from the previous release reads as a checksum for this one. The uploader now tallies every copy instead of letting the last one decide the exit status, and warns when there is no checksum to publish.

Co-authored-by: Codex XHigh <codex@openai.com>
2026-08-23 12:59:32 +02:00
OmarchybotandCodex XHigh df80b320cb Stop the install dying on non-UTF-8 command output (#118)
* Stop the install dying on non-UTF-8 command output

efibootmgr prints what firmware gave it, and an HP ProBook's NVRAM held a legacy BBS entry whose device path was raw binary. `_read_efibootmgr` captured that with `text=True`, so Python decoded it strictly and a byte in a boot entry nothing here reads killed the whole "Installing Arch + Omarchy" phase. Two machines have hit it now, on 0x80 and 0xe0.

Every text capture in the orchestrator had the same exposure — partition labels reach lsblk and blkid the same way firmware strings reach efibootmgr — so they all go through one `capture()` that replaces what it cannot decode. The file reads here already did this; the command reads were the gap.

Replacing bytes has its own failure mode, and it is worse than the crash: a mangled UUID would be written into fstab and the kernel cmdline, and `_validate_pre_mounted_filesystems` reads it back from the same command, agrees with itself, and ships a machine that cannot find its root. So `require_text()` refuses a value the installed system depends on — UUIDs, device names, the btrfs device handed to mount — while the boot-entry labels stay tolerant on purpose: the decisions taken from those are advisory, and refusing a junk one would abort the install this change exists to let finish.

The regression test runs a stand-in efibootmgr emitting the reported byte sequence, so it exercises the decode rather than a mock. test_keyboard.py loads the module through the package now that keyboard.py imports a sibling.

Closes #116

🤖 Generated by Opus 5 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Codex XHigh <noreply@openai.com>

* Say accurately why the boot-entry parsing stays tolerant

The previous commit called the decisions taken from a firmware label "advisory". They are not: a label match selects entries for deletion and rewrites the boot order, and a missing one aborts validation. Staying tolerant is still right, for a reason the comment did not give — an entry's text is its label and its device path together, the junk lives in the path, and nothing mangled reaches efibootmgr from there anyway, since only four-digit ASCII boot numbers become arguments and a boot order is filtered through the entries that parsed. A label that loses a byte costs a stale entry left behind, not a broken system.

The test docstring credited one machine with a fixture that carries the bytes from two.

Co-Authored-By: Codex XHigh <noreply@openai.com>

---------

Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-23 12:57:03 +02:00
David Heinemeier HanssonandClaude Fable 5 0c1333837f Add an integration test suite that boots ISO scenarios in QEMU (#109)
* Move the unit tests under test/unit

test/ is growing an integration suite; the fast, VM-free tests get their
own directory so the two kinds cannot be confused. test/all keeps its
role as the aggregate runner for everything that needs no built ISO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add an integration suite that boots ISO scenarios in QEMU

test/integration installs an ISO once — unattended, from a generated
cidata drive — and saves a reusable base image; each scenario in
test/integration.d/ then boots a throwaway overlay with its own copy of
the firmware vars. Shared machinery (VM lifecycle, QMP screendump + OCR
console driving, virtual keystrokes, guest SSH, the cidata build) lives
in base-test.sh, so a new scenario is one file.

The first scenario proves omarchy-system-factory-reset hands a machine
on without destroying a shared ESP: a Windows entry and a foreign Linux
with its own machine-id, boot directory, and UKIs must survive both the
staged reset and the first-boot provisioning that follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Let omarchy-iso-make keep the host package cache

Purging /var/cache/pacman/pkg needs interactive sudo, which blocks
unattended builds. --keep-pkg-cache skips the purge so test rigs and
agents can build an ISO without a prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 18:35:24 +02:00
David Heinemeier HanssonandClaude Fable 5 10bd6632d3 True up the secure boot plan and drop implemented plans
The consumer-secure-boot plan now reflects the current repo: installs
already boot mkinitcpio-built UKIs via Limine with pacman hooks
regenerating them, so the remaining work is signing, not UKI plumbing.
The Microsoft shim submission is called out as the calendar-bound
critical path, and DKMS/NVIDIA module signing is promoted to a v1
blocker. BitLocker guidance matches the shipped decrypt-not-suspend
policy from #105.

Removed plans that have shipped: tailscale (#97), autoinstall,
oem-install (#98/#107), dual-boot, and protected-partition-install
(dual-boot half shipped, restore half superseded by factory snapshots).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1HofkzWQwCMZSYAxm1tGe
2026-08-15 16:47:31 +02:00
David Heinemeier HanssonandClaude Opus 5 174dd82b15 Install the published Omarchy packages in the default build
The quattro ref is what a bare omarchy-iso-make builds, so pinning it to
omarchy-dev meant every default ISO shipped the dev runtime. Only the edge,
dev, and local-source refs need those recipes now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:59:53 -07:00
Ryan Hughes 7d3b01eae9 Fix partition issue 2026-08-13 23:57:22 -04:00
David Heinemeier HanssonandClaude Opus 5 7c50d72f40 Keep offering the deferred install after backing out (#107)
* Keep offering the deferred install after backing out

The keyboard screen only advertised Ctrl+C on its first showing. Backing out
of the user step — Esc from the account form, or "No, change it" on the review
table — returned to the same screen with the hint gone and Ctrl+C back to
aborting the install, so an operator who started a direct install could no
longer switch to preparing the machine for another owner.

Offer it on every visit instead. Nothing has touched the disk at that point in
either path, and the confirmation screen still catches a stray Ctrl+C. The user
step now returns as soon as the keyboard screen arms deferral, and the main
flow re-checks the flag before choosing a branch, so a late deferral clears the
half-entered credentials and goes straight to disk selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Trim the comments around the deferral check

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 20:55:07 +02:00
David Heinemeier HanssonandClaude Opus 5 d6cd2d30a6 Call the renamed omarchy-apply-system finalizer
The runtime moved its install-time plumbing out of the setup namespace, where
it sat among the interactive wizards a user browses to. The ISO installs the
runtime from the mirror it ships with, so the two move together and no
compatibility route is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:15:24 -07:00
David Heinemeier HanssonandClaude Opus 5 be618cc73b Send the installer off with laseretch again
Smoke read as a puff of glyphs rather than a finish. Go back to laseretch at the
260fps it ran at before the ttfx swap, where it takes about four seconds on this
logo — still well inside the 8s cap, which is a stuck-process guard rather than
a trim.

Hold it to the 256-index palette with --xterm-colors, the way the greeter holds
its own: the framebuffer console reduces indexed codes well and truecolor ones
badly, and the laser and sparks carry their hot palette through the reduction
intact. Settle on indexed 2 so the effect ends on the same green gum drew the
static logo in a few lines above, instead of recoloring a logo that is already
on screen.

Log a failing effect to the support log rather than dropping it. ttfx is in the
ISO's package list so this should not fire there, but a silent `|| true` is why
a missing binary looks like a still logo and no reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 13:51:31 -07:00
David Heinemeier HanssonandClaude Opus 5 a6a442b9a0 Center the installer logo in a locale without a charset
logo_width measured the logo with awk's `length`, which counts bytes unless the
locale is UTF-8. The logo is drawn in box-drawing glyphs, three bytes each, so
the answer was 197 columns rather than 81 anywhere LANG went unset — a console
booting before locale.conf applies, a shell that never inherited one.

A width wider than the terminal makes left_padding clamp its pad to zero, so the
logo sat flush left while the title, bar, and tip centered against a phantom
197-wide box and drifted right of it. The 81 this function already falls back to
when logo.txt is missing is the character count, so the intent was never in
question, only the measurement.

Count bytes deliberately under LC_ALL=C and drop the UTF-8 continuation bytes
before measuring. That is the same answer in every locale rather than the right
one only in those that have a charset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 13:51:24 -07:00
David Heinemeier HanssonandClaude Opus 5 e5f2b466ef Center the installer screens and move the effects to ttfx
The progress screen and the finish screen now center vertically the way the
greeter does, instead of stacking from row one. Both derive their block height
from the logo, so the frame the installer sits on for several minutes is
composed rather than top-aligned. The progress screen redraws itself if the
console resizes, which the framebuffer still does late in VMs.

Centering the finish screen turned up a spacing bug: gum confirm paints
prompt/blank/button from the cursor and clears all three rows, so anchoring one
row below the title left three blank rows under it, not one. Anchoring a row
higher closes that to the two rows gum allows, one more than the greeter's frame.

Swap python-terminaltexteffects for ttfx. The flags carry over, but two things
do not. ttfx resolves even indexed stops to truecolor, so the greeter needs
--xterm-colors to keep its palette indexed: the console reduces 256-colour codes
well and 24-bit ones badly, and the settle colour has to match the logo drawn
underneath with gum style --foreground 2. And ttfx centres its text two columns
right of plain centering, so both screens hand it a canvas two columns narrower
than the console or the logo jumps sideways when the effect starts.

The finish screen uses smoke rather than laseretch, at 60fps rather than 260 —
smoke finishes in under a second at the rate laseretch was tuned for, which
reads as a flicker instead of a send-off. Smoke also survives the console's
16-colour collapse better, carrying its motion in glyphs rather than gradients.

ttfx is in the ISO's package list and timeout is coreutils, so neither needs an
availability check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 09:19:44 -07:00
a12bfea7a8 Stop the live installer crashing the kernel on pre-T2 Macs
The ISO boots linux-t2 on every machine. On a MacBookPro11,4 applesmc finds the
SMC and then takes a NULL pointer dereference registering the keyboard-backlight
LED, in led_trigger_set_default -> queue_work_on.

The udev worker dies mid-init holding locks and the damage spreads: reading the
half-registered LED blocks systemd-backlight, opening /dev/rfkill blocks iwd
while it holds the global misc_mtx, and every later misc device open blocks
behind that mutex. LVM opens one, so the install stops at "Cleaning up existing
holders on install disk" and never resumes — no error, no progress, nothing to
see.

Nothing in the installer needs the SMC. It reads sensors and drives the fans and
keyboard backlight, and the firmware manages the fans by itself; the installed
system still loads applesmc normally. Blacklisting it in the live environment
only, alongside the Panther Lake audio blacklist that exists for the same class
of problem.

Extracted from #102, which also carried an unrelated rework of the disk probes.

Co-Authored-By: cupatea <vladyslav@hey.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:49:50 -07:00
David Heinemeier HanssonandClaude Opus 5 8b3dcc68b2 Tell BitLocker users to decrypt, not suspend (#105)
The free-space install detects BitLocker by the -FVE-FS- signature at
offset 3, and suspending BitLocker leaves that signature in place: the
volume stays encrypted, it just gains a clear-key protector. So the old
message sent users to do something that could never let the install
proceed. Aborting on a suspended volume is deliberate, so only the
wording needed fixing.

Fixes #99

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 13:48:20 +02:00
David Heinemeier HanssonandClaude Opus 5 22eb81ebf5 Correct two misleading messages from the setup-form split
The build's missing-form error only ever described the published-package
case, so a --local-source build against a checkout without the form told
you to publish a runtime or pass --local-source — advice you had already
taken. Branch on /omarchy-source and name the checkout instead.

The password comment has claimed yescrypt since 880d8c5 swapped the
command to `openssl passwd -6`, which is SHA-512 crypt. openssl passwd
cannot produce yescrypt at all, so the comment, not the command, is what
was wrong.

Both spotted by Copilot's review on #104.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 02:23:30 -07:00
David Heinemeier HanssonandClaude Opus 5 8858ac2544 Source the setup form from the Omarchy runtime (#104)
The keyboard layout list, the account and hostname validation rules, and the
gum prompts that ask for them existed here and again in the runtime's
first-boot owner setup, with nothing keeping the copies honest. They had
already drifted: dropping two layouts from this list moved English (US) from
the top of one gum choose page to the bottom of the previous one, leaving the
preselected default pinned under a screenful of layouts.

The form now lives in the runtime at install/provisioning/setup-form.sh, and
build-iso.sh vendors it out of the very runtime package this ISO bundles —
the same two-branch machinery already used for omarchy-base.packages, so a
--local-source build takes it from the mounted checkout and everything else
extracts it from the downloaded package. An ISO therefore ships the form
belonging to the runtime it installs.

That file is extracted on its own rather than alongside the package lists:
bsdtar exits non-zero for a member it cannot find, so bundling the request
would abort the build under set -e with a bare "Not found in archive" instead
of the actionable error about publishing a runtime that carries the form.

Cancel handling is unified with first-boot setup, which is what made the
prompts shareable. Every prompt reports 0 (answered), 1 (Esc), or 130
(Ctrl+C). Esc now unwinds to the start of the form instead of ending the
install — it previously hit the same `|| abort` as Ctrl+C. Ctrl+C keeps both
of its hidden affordances: arming deferred provisioning on the first keyboard
screen, and toggling unencrypted on the disk screens.

test_keyboard.py reads the layout list from a runtime checkout now, and skips
its localectl coverage rather than failing when there is none to read.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:20:48 +02:00
David Heinemeier HanssonandClaude 6b2f8a6ac7 Add deferred-provisioning install mode and factory snapshots (#98)
* Add OEM install mode and factory snapshots

OEM mode installs the entire system but defers user creation to first boot,
where the runtime's omarchy-oem-setup asks for the owner and lands them in the
already-installed system — and the same machinery arms omarchy-reset-computer.

The configurator's install-mode step gains an OEM choice, which required
moving the user step after mode selection (OEM skips it entirely). Autoinstall
rigs select OEM with an `oem` marker file on the cidata drive, which also
replaces the user_credentials.json requirement.

In the orchestrator, OEM swaps the user phases for a stage_oem_state phase:
it stashes the bundled Node tarball (every install, for offline factory
resets), arms omarchy-oem-setup.service in the target, and on encrypted
installs stages the throwaway LUKS passphrase — generated by InstallContext
when the input carries none — with a keyfile embedded in the UKI so boot
auto-unlocks until first-boot setup re-keys to the owner's password. The
install fails with a clear error if the bundled runtime lacks OEM support.

Every install now also ends with a read-only @factory snapshot of @ at the
btrfs top level — outside snapper's reach — which is what makes
omarchy-reset-computer a true factory reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Offer OEM mode only when the bundled runtime supports it

The configurator now peeks into the offline mirror's runtime package for
omarchy-oem-setup before offering the OEM install mode, so an ISO built
against a pre-OEM runtime can't start an install doomed to fail at the
staging phase (which still hard-checks as the backstop). Blank disks with no
OEM choice to offer skip the mode picker entirely, like the pre-OEM flow.

Also keep the test harness QMP socket in /tmp — unix socket paths cap at
~108 bytes, which nested test-run directories can exceed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Isolate the test harness SSH identity from the local ssh-agent

A loaded agent offers its keys before the harness identity and can exhaust
the server's MaxAuthTries before the right key is ever tried; IdentitiesOnly
pins authentication to the per-run key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make OEM state world-readable for user-context finalization

The Node tarball in /var/lib/omarchy/oem/packages is read by
omarchy-finalize-user running as the freshly created user; a 0700 directory
forced the offline install onto its network fallback. The only secret inside
(luks-key) is itself 0600 root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep provisioning credentials out of OEM installs and factory snapshots

Review findings from codex and Copilot:

- A rig-supplied credentials file in OEM mode now contributes only its LUKS
  passphrase: preserved users/root_enc_password fields would have created a
  hidden provisioning account despite OEM promising no user until first boot.
- The factory snapshot is taken writable, scrubbed of deployment credentials
  (staged authorized_keys, tailscale auth key and join unit, the dead OEM
  LUKS key and its initramfs drop-ins), then set read-only — a reset years
  later must not authorize the original deployment key or rejoin its tailnet.
- The missing-Node-tarball case is a hard install error on every install,
  not just OEM: the stash is what lets a factory reset finalize offline.
- The configurator's OEM gate checks the runtime package for the service
  unit as well as the setup binary.
- The test harness handles the deliberately skipped mode picker on blank
  disks when the bundled runtime lacks OEM support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Second review pass: strip account fields, clear stale cidata inputs, await OEM finalization

Codex xhigh round 2:

- Strip account authentication fields (!users, !root-password,
  root_enc_password, auth_config account entries) from the archinstall config
  in OEM mode, not just user_credentials — a combined/legacy config could
  otherwise have archinstall create a provisioning account before OOBE.
- Clear stale OEM inputs (oem marker, credentials, optional files) from /root
  before copying a cidata drive, so a retried install in the same live session
  can't force a fresh drive back into OEM mode or reuse an old LUKS passphrase.
- The test harness waits for the OEM pending flag to clear before saving the
  base image, so it never snapshots a half-finalized first boot; it also
  handles the deliberately skipped mode picker on blank disks built against a
  pre-OEM runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reorder configurator: disk before keyboard, skip keyboard for OEM

The wizard now opens on disk selection; keyboard moves to after the install
mode is chosen and stays before the user step (so the LUKS passphrase is
still typed under the chosen layout). OEM installs skip keyboard and user
entirely — the machine's owner picks both at first boot — so the emitted
config just carries a default 'us' keymap as a placeholder.

The QEMU harness drives the new order and the OEM first-boot keyboard step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Harness: prove the OEM keyboard step applies a non-default layout

Drive the OEM first-boot keyboard picker to English (UK) and assert the
installed system ends up with KEYMAP=uk and XKBLAYOUT=gb — proving the
deferred keyboard step actually applied a layout, not just rendered. UK keeps
every letter identical to US so the raw-keycode password still matches, and
the email's '@' (Shift+' under UK, not Shift+2) is typed with the UK keycode.
The console bootstrap now fetches-then-runs instead of piping, so it survives
the non-US layout the keyboard step installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update OEM comments to the renamed factory-reset command

Reference omarchy-system-factory-reset (was omarchy-reset-computer) in the
orchestrator comments and the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the configurator's runtime-support gate for OEM mode

Remove oem_supported() and its branches: the configurator always offers OEM
install now, rather than scanning the offline mirror's runtime package to
decide. Building an ISO against a runtime that ships OEM support is the
build's responsibility; the orchestrator still fails an OEM install with a
clear error if the staged target somehow lacks omarchy-oem-setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* OEM mode via Ctrl+C on the first screen, not a mode-picker entry

Restore the original screen order (keyboard first, then disk) and make Ctrl+C
on the keyboard screen the entry into OEM mode — the same hidden-Ctrl+C idiom
the disk-overwrite screen already uses for unencrypted installs. A confirm
guards against an accidental Ctrl+C; once confirmed, OEM jumps straight to
disk selection and the overwrite confirmation (no keyboard, no user), since
OEM is always a full-disk install. The 'OEM install' entry leaves the mode
picker. The harness drives the new path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restore keyboard→user→disk order; drop OEM wording from the configurator UI

The normal install flow returns to its original order: keyboard, then the
user step, then disk selection and mode. The Ctrl+C shortcut on the first
screen and its confirmation now speak only of preparing the machine for
another owner — no 'OEM' in any user-facing string (the omarchy_install.oem
data contract is unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Only show the mode picker when free space is also an option

Restore the pre-OEM behavior: a disk with no unallocated free space has just
one viable mode, so the wizard skips the mode picker and goes straight to the
overwrite confirmation. The picker appears only when a free-space install is
also on offer.

Also give the harness's first-boot SSH bootstrap more headroom (360s): an
encrypted OEM first boot re-keys LUKS and rebuilds the UKI before sshd
settles, which can exceed 180s under parallel VM load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tighten the prepare-for-another-owner confirm wording

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keyboard screen: Ctrl+C hint directly under the header, no blank line

Match the disk-overwrite screen, where the unencrypted-install hint sits on
the line right below its message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rename OEM vocabulary to provisioning (ISO)

Matches the runtime rename. The deferred-provisioning install mode replaces
'OEM' everywhere:
  omarchy_install.oem            → omarchy_install.defer_provisioning
  --oem / --oem-file             → --defer-provisioning / --defer-provisioning-file
  OMARCHY_INSTALL_OEM_FILE       → OMARCHY_INSTALL_DEFER_PROVISIONING_FILE
  cidata 'oem' marker            → 'defer-provisioning'
  /var/lib/omarchy/oem/          → /var/lib/omarchy/provisioning/
  /etc/omarchy/oem.key           → /etc/omarchy/provisioning.key
  install/oem/omarchy-oem-setup.service → install/provisioning/omarchy-provision-owner.service
  stage_oem_state phase          → stage_provisioning_state ('Staging provisioning')

Orchestrator internals (OEM_STATE_DIR, _validate_oem_state, ctx.oem, …),
the configurator, cidata-load, the dashboard phase band, the harness
(--oem → --provision), and the tests all move together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix orchestrator caller of the renamed user-provisioning command

run_chroot_finalizer still invoked /usr/bin/omarchy-finalize-user, which the
rename moved to omarchy-provision-user — so a normal install died with 'No
such file'. (Deferred-provisioning installs skip this phase, so only normal
installs were affected.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Wait for the live VT to settle before drawing the first screen

tty1 boots as an 80-column console and only widens once the kernel
framebuffer/KMS takes over, which lands late — especially in VMs. The
configurator's logo is wider than 80 columns, so the very first draw
measured against that transient narrow console: the logo wrapped
("smushed") and, with left padding clamped to zero, hugged the left
edge. Every later screen re-measures once the console has grown, which
is why only the opening keyboard screen looked wrong.

Hold the first draw until the width settles at or above the logo width,
capped so a console that never widens still proceeds. Terminal emulators
(dry runs) are already at final size and are skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* Confirm the Start Omarchy Now prompt after provisioning setup (#100)

* Confirm the Start Omarchy Now prompt after provisioning setup

First-boot account setup now ends on a celebration screen whose Start
Omarchy Now button gates the SDDM handoff, so the provisioning test
must press it — same as the installer's Reboot Now prompt — once the
pending flag clears.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a centered greeter before the installer keyboard step

Open the ISO configurator with the same greeter the deferred-provisioning
first boot uses — vertically centered logo, the Omarchy tagline, and a
single button — so install and first boot bookend setup identically. The
installer's button reads "Start install"; first boot's reads "Start setup".

Co-Authored-By: Claude <noreply@anthropic.com>

* Animate the installer greeter logo with a skippable ColorShift

Match the first-boot greeter: a looping tte ColorShift over the centered
logo with a "Press Return to Start Install" hint instead of a button.
Return begins the install at any time.

Co-Authored-By: Claude <noreply@anthropic.com>

* Theme the installer greeter ColorShift to the Omarchy palette

Match the first-boot greeter: sweep the logo ColorShift through the
Tokyo Night accent colors instead of tte's default rainbow.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix installer greeter ColorShift: green base, no flash, clean handoff

Mirror the first-boot greeter fixes: indexed ANSI colors (green base with
a cyan accent) because the framebuffer console can't render tte truecolor;
one long tte invocation so the loop never flashes; stty sane + clear after
Return so the keyboard step starts clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* Dismiss the greeter in the QEMU test drivers

The installer and first-boot flows now open with a greeter (animated logo
+ tagline). Wait for the tagline and press Return before the keyboard
step, in both drive paths, so the harness gets past the greeter.

Co-Authored-By: Claude <noreply@anthropic.com>

* Kill the installer greeter animation cleanly

Run tte directly instead of inside a backgrounded `while` subshell, so
killing the tracked PID actually stops the effect. Killing the subshell
orphaned tte, which kept painting the logo over the keyboard step.

Co-Authored-By: Claude <noreply@anthropic.com>

* Guard the greeter wait against set -e in the configurator too

Harmless today (no set -e), but keeps the greeter's animation teardown
robust if that changes.

Co-Authored-By: Claude <noreply@anthropic.com>

* Also settle the terminal in emulators, not just real VTs

wait_for_stable_terminal skipped terminal emulators on the assumption
they're already at final size, but a fresh terminal (or sudo's pty)
reports a stale 24x80 for a moment at startup too. Drop the VT-only gate
so the greeter centers there as well.

Co-Authored-By: Claude <noreply@anthropic.com>

* Test harness: drive the first-boot timezone step and direct SDDM handoff

Match the deferred-provisioning first-boot flow changes: the owner now picks
a timezone after the email step, and setup hands straight off to SDDM instead
of pausing on a celebration screen with a Start Omarchy Now button. Drive the
new Timezone screen (accept the geo-guessed default) and drop the button
confirmation.

Co-Authored-By: Claude <noreply@anthropic.com>

* Don't bake hostname/timezone into deferred installs; set them at first boot

Deferred provisioning defers the user step to first boot, so the machine's
owner should name the machine and pick the timezone there — not inherit values
chosen at install time. Stop setting hostname/timezone in the configurator's
deferred branch: the orchestrator already skips an empty timezone (the system
stays UTC until first boot), and it now falls back to a neutral "archlinux"
hostname for the install so archinstall still succeeds. The first-boot
omarchy-provision-owner form collects and applies both.

Harness: drive the new Hostname screen and assert both hostname and timezone
actually landed on the running system after first boot.

Co-Authored-By: Claude <noreply@anthropic.com>

* Match the installer greeter: solid green logo, no color animation

Same fix as the first-boot greeter — tte's ColorShift judders on the 16-color
framebuffer console (truecolor crush). Show the logo in solid green and drop
the animation so both greeters look identical and flash-free.

Co-Authored-By: Claude <noreply@anthropic.com>

* Harden the provision harness: SSH-blip wait, timezone timeout, hostname assert

- The pending-flag wait treated an SSH connection failure (rc 255) the same as
  "flag absent" (rc 1), so a transient blip during finalization would exit early
  and snapshot the base mid-finalization. Only break on a clean rc 1; keep
  waiting on 0 or 255.
- Bump the first-boot Timezone screen wait 60s -> 180s (tzupdate geo-guess over
  first-boot networking can lag; matches the installer's headroom).
- Make the applied-hostname check a hard failure (it's deterministic — we typed
  it), so a first-boot apply regression can't pass green; timezone stays a
  report since it's geo-guessed.

Co-Authored-By: Claude <noreply@anthropic.com>

* Seed hostname=omarchy/timezone=UTC in deferred install instead of empties

Write neutral valid defaults in the configurator's deferred branch rather than
empty strings, and drop the orchestrator's `config.hostname or "archlinux"`
fallback (hostname is always non-empty now). The first-boot wizard overwrites
both. This keeps the "owner sets them at first boot" behavior without depending
on archinstall tolerating empty hostname/timezone at config-load time.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix set -e regression in the provision pending-wait loop

The previous hardening moved `ssh_guest "test -f pending"` out of the `while`
condition into a standalone command, but the harness runs `set -euo pipefail` —
so the moment the flag cleared (ssh returns 1, the intended "done" signal) it
tripped errexit and aborted the whole run right after provisioning, before the
session/acceptance phase. Capture the result with `|| rc=$?` so the non-zero is
the signal, not a fatal error. Also guard the follow-on hostname/timezone
substitutions with `|| got=""` so a transient SSH failure there can't abort
either (an empty value then correctly fails the hostname assertion).

Co-Authored-By: Claude <noreply@anthropic.com>

* Bring back the installer greeter ColorShift animation

Match the first-boot greeter: restore the static green logo + looping tte
ColorShift overlay. The framebuffer truecolor crush gives it a quick flash,
which is acceptable — both greeters keep the animation.

Co-Authored-By: Claude <noreply@anthropic.com>

* Skip the install celebration on deferred installs; reboot directly

A deferred-provisioning install has nothing to celebrate yet — the owner
finishes setup (and gets the send-off) at first boot. So the installer's
"Installed Omarchy in ..." screen and the Reboot Now prompt now belong to a
direct install only: for a deferred install the dashboard clears and reboots on
its own. .automated_script.sh detects deferred mode (the config's
defer_provisioning flag, or the cidata defer-provisioning marker) and passes
OMARCHY_UI_DEFER_PROVISIONING to the dashboard.

Harness: a deferred install no longer shows Reboot Now, so wait_for_install
recognizes the auto-reboot by the first-boot greeter tagline appearing.

Co-Authored-By: Claude <noreply@anthropic.com>

* Detect deferred mode with jq, not a line regex

The dashboard's deferred detection grepped user_configuration.json for the
defer_provisioning boolean on one line; a reformatted config would read as a
direct install and wrongly show the celebration. Parse it with jq using the
same path the orchestrator reads (.omarchy_install.defer_provisioning).

Co-Authored-By: Claude <noreply@anthropic.com>

* Drop Bosnian and Khmer from the installer keyboard picker

Match the first-boot picker: neither has a console keymap, so both silently
fell back to US. Remove them rather than offer a layout that can't apply.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:56:55 +02:00
David Heinemeier Hansson 6004e80485 New plan 2026-08-08 00:08:46 +02:00
David Heinemeier HanssonandClaude Fable 5 0339b13d12 Join the tailnet from a cidata tailscale_authkey (#97)
* Join the tailnet from a cidata tailscale_authkey

A tailscale_authkey file on the autoinstall drive gets the installed
machine onto the tailnet on its own: the tailscale package is bundled in
the offline mirror and installed only when the key is present, the key is
staged root-only at /etc/tailscale/authkey, and a oneshot first-boot unit
runs tailscale up once the network is really there. On success the key is
deleted and the unit disables itself; on failure both survive to retry on
the next boot, so an offline install joins whenever it first gets
connectivity. ufw allows traffic in on tailscale0 via the same
write-the-rule-then-ignore-the-chroot-exit-code dance as the SSH phase.

Bundling rather than fetching at first boot is deliberate: pacman -Sy
tailscale on a fresh machine is a partial upgrade, and pacman -Syu means
an unattended full system upgrade racing the user's first login. The
offline mirror is a coherent snapshot; installing from it keeps the
target consistent and the install offline-capable. See plans/tailscale.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q

* Do not hold boot hostage for the tailnet join

Review caught that the join unit was Type=oneshot wanted by
multi-user.target: target units implicitly gain After= for their Wants=,
so an offline machine's boot -- SDDM included -- stalled for the full
10-minute timeout, and once timed out the join never retried within that
boot. Type=simple counts as started the moment it forks, so boot proceeds
while the join retries in the background for as long as the boot lasts.
Cleanup moves inside the script, sequenced after tailscale up succeeds,
which preserves the retry-across-boots behavior: key and unit survive a
boot with no connectivity, and the key never outlives a successful join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:10:30 +02:00
Axel Fontaine 4d10233371 Update boot command to use full path (#95) 2026-08-04 13:40:21 -05:00
David Heinemeier Hansson c9d445cf33 Merge branch 'autoinstall' into quattro 2026-08-04 10:00:30 -07:00
David Heinemeier HanssonandClaude Fable 5 f11a70c6ba Take authorized_keys on the cidata drive instead of ssh.json
The JSON wrapper was the one piece of autoinstall input that wasn't an
existing format: the other files are the configurator's own output, and
the keys end up in sshd's authorized_keys anyway. So take that file
directly -- one key per line, comments and key options work as they do
everywhere else -- and drop the parsing in between. The only thing JSON
bought was parse-error detection, and the phase still fails loudly when
the file carries no usable keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:08:33 -07:00
David Heinemeier HanssonandClaude Fable 5 be68a76a44 True up the autoinstall docs
user_encrypt_installation.txt does not encrypt anything: encryption is
driven by the disk_encryption block inside user_configuration.json,
which carries the passphrase in plaintext. The flag must match that
block, since it drives the encrypted install's autologin and the final
boot validation. The plan also claimed sshd ships enabled -- it ships
disabled, which is the whole reason the SSH phase exists -- and now
reflects the extracted loader and the unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
David Heinemeier HanssonandClaude Fable 5 69ec1b4b8e Add unit tests for cidata loading and the SSH access phase
./test/all runs every suite under test/: the shell tests exercise
omarchy-cidata-load against a sandbox with mount/umount/udevadm stubbed,
and test_configure_ssh_access.py runs the orchestrator phase against a
temp target with subprocess.run recorded and ufw's
write-the-rule-then-fail chroot behavior simulated. The Python tests go
through unittest discovery, which also picks up the repo's existing
test_*.py suites that previously had no runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
David Heinemeier HanssonandClaude Fable 5 dd5c53cd3c Trust the target to have ufw when opening SSH access
ufw is part of Omarchy's default package set, so the presence check was
guarding a machine that cannot exist -- and if it somehow did, skipping
the firewall would produce an unreachable install, which is exactly what
this phase fails loudly to prevent. The user.rules assertion already
raises in that case. With the check gone, the helper is small enough to
live inline in the phase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
David Heinemeier HanssonandClaude Fable 5 e8a7e3cfce Extract cidata loading into a standalone omarchy-cidata-load command
The loader now settles udev before probing, so a fast boot cannot miss
the drive and silently drop a headless install into the wizard. All the
files come over in a single cp, and a failed copy removes anything it
already copied and falls back to the wizard: the wizard cleans up only
what it writes itself, so a half-copied ssh.json would otherwise leak
into the interactive install that follows. The command takes an optional
path prefix so tests can run it against a sandbox instead of the live
system.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
Jeffrey Guenther 7e782bb7cf Document autoinstall
Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther 5f601f2c54 Make the installed machine reachable over SSH
An autoinstall drive can carry an ssh.json of public keys. Install them
for the user, enable sshd, and open port 22.

All three are needed: Omarchy ships openssh with the service disabled,
and install/config/firewall.sh opens LocalSend and docker DNS but not
22, so keys alone leave the machine pingable with SSH timing out.

The ufw call exits non-zero from a chroot -- it cannot reach netfilter
-- but writes the rule to user.rules before failing, and that file is
what ufw.service loads on first boot. Check the file rather than the
exit status, since the file is the thing that has to be right.

Unusable key files fail the phase. A machine that installs
"successfully" and then can't be logged into is worse than one that
stops with the reason on screen.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther b323a4cf08 Skip the dashboard's prompts when nobody is at the keyboard
The dashboard owns the two prompts that outlive an install -- the reboot
confirm and the failure menu -- and both read from the TTY, so both hang
forever on a headless VM. Gate them on OMARCHY_UI_INTERACTIVE here, in
the one process that owns the visible UI, rather than patching prompts
out of the scripts they live in.

The existing OMARCHY_UI_AUTO_REBOOT check still guards the reboot
itself, so setting both stops on the finish screen without a keyboard.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther a4ec97d174 Autoinstall from a cidata drive instead of the configurator
Look for a drive labeled cidata at startup. If it carries the
configurator's own output files, copy them into /root and skip the
wizard -- everything downstream then runs the ordinary path against
ordinary inputs. No drive, no change.

The required pair matches what the orchestrator treats as mandatory, so
a drive with less than that falls back to the wizard rather than failing
the boot.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther f5aa88b06c Plan autoinstall against the quattro install pipeline
PR #72 targeted the pre-quattro tree and can't be rebased -- the bash
seams it hooked into are gone. This plans the same feature against the
configurator/dashboard/orchestrator split, and drops the three parts of
that PR that were flagged as questionable.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
David Heinemeier Hansson f66b6fc28e Prune unneeded packages from the ISO mirror cache 2026-08-03 12:22:49 -07:00
17532793fd [INSTALL SPEED] Configure target keyboard without temporary boot (#93)
* Configure target keyboard without temporary boot

* Slim the offline keyboard configuration to what Omarchy consumes

Drop the Xorg 00-keyboard.conf writer: the installed system only reads
the XKB* settings in vconsole.conf (via detect-keyboard-layout.sh, which
copies XKBLAYOUT/XKBVARIANT into Hyprland's kb_layout), and nothing on
an Omarchy system reads xorg.conf.d. systemd-firstboot already writes
those vconsole settings.

Drop the set_keyboard_language monkey-patch: passing an empty kb_layout
to minimal_installation takes archinstall's documented skip path (no
nspawn boot), and the keymap is configured offline right after instead.

Append the preserved FONT= line instead of reproducing localectl's exact
file layout, and slim the tests to the behavior that matters: XKB
settings present, font preserved, every configurator keymap valid, and
archinstall-compatible invalid/empty handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 20:00:56 -05:00
N1R4 0b4d74f6af Filter phantom entries when rewriting EFI BootOrder (#94)
The Limine registration helper rebuilds the UEFI BootOrder from the
pre-state read, but passes every entry through verbatim. Some firmware
(listed in the BootOrder but with no matching BootXXXX variable) causes
efibootmgr --bootorder to fail with exit status 8, aborting the whole
install at the bootloader step.

Only carry forward entries that still exist as boot variables.
2026-08-01 13:07:09 -05:00
David Heinemeier HanssonandClaude Opus 5 a76f599eaa Drop the Qt5 stack from the recorded manifests
basecamp/omarchy#6431 takes kvantum-qt5 and qt5-wayland out of
omarchy-base.packages, and the rest of the stack follows them out of a
fresh install: qt5-base, qt5-declarative, qt5-svg, qt5-translations and
qt5-x11extras. kvantum stays put, moving from dependency to explicit now
that the package list names it directly instead of reaching it through
kvantum-qt5.

Hand-applied rather than re-recorded, so everything else in both files
still reflects the 2026-05-25 capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:14:40 -07:00
Ryan Hughes 64152dda3f Always makes sure the docker container is updated 2026-07-29 12:25:53 -04:00
Ryan Hughes 56eb451b92 Merge pull request #86 from omacom-io/fix-t2-kernel
Boot the T2 kernel the ISO says it boots
2026-07-28 15:52:12 -04:00
d64ffb0e00 Fix the nightly ISO build
The workflow has been running ./build_iso.sh, which has not existed for a long
time — the build moved to bin/omarchy-iso-make. Every scheduled run in the last
100 has failed.

Run the real build driver instead, with NO_BOOT_OFFER=1 so it skips the
interactive "Boot this ISO?" prompt at the end, and collect from ./release/
where the ISO actually lands. Drop the outer archlinux container: omarchy-iso-make
starts its own privileged container, so the job only needs Docker on the runner.
Add a disk cleanup step, since a ~6.5GB ISO plus a ~4.5GB offline mirror does not
fit in a stock runner's free space.

Taken from #67 by @karthikkalidas, which was opened against main. main is 143
commits behind and is no longer the default branch, and scheduled workflows only
run from the default branch, so merging it there would have fixed nothing.

Co-Authored-By: Karthik Kalidas <karthik.kalidas@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:50:02 -07:00
5b626d2a92 Update the copyright year
From #61 by @LukaMarinkic, applied to quattro; it was opened against main.

Co-Authored-By: LukaMarinkic <147918748+LukaMarinkic@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:43:43 -07:00
3efdb5c94b Reject reserved system usernames in the configurator
The username regex accepted names that already exist on the target, so
useradd failed inside arch_install_system — after the disk had been
partitioned and formatted, which is the worst point to discover it.

From #59 by @wolfgangw, applied to quattro; it was opened against main.

Co-Authored-By: Wolfgang Woehl <tito@online.de>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:43:43 -07:00
b3cf0c6f29 Run the live CPUs at the performance governor during install (#89)
archiso boots on whatever governor the kernel defaults to, which on most
laptops ramps conservatively. Package extraction and the UKI build are both
CPU-bound, so pin performance for the whole install and put it back afterwards.

Reduced from #87 by @davydotcom, which also disabled every non-POLL cpuidle
state. That half is left out deliberately: C1 wake latency is 1us on current
hardware, and disabling it leaves POLL as the only idle state, so idle cores
busy-spin instead of halting. Package extraction is single-threaded, so on an
8-core laptop that is 7 cores burning power for no work — on a thermally
limited machine that costs the working core its turbo headroom, which is a
plausible way to make the install slower. #87 measured the two tweaks together
with a page-cache prefetch and an updatedb deferral, so the C-state
contribution was never isolated.

Scoped to the whole install rather than just the pacstrap window, which #87
used: finalize_limine_boot runs mkinitcpio and compresses a UKI, and is the
most purely CPU-bound phase there is.

Restoring only matters when an install fails and the user keeps using the live
environment; a successful one reboots out of it.

Co-authored-by: David Estes <davydotcom@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:24:13 -07:00
David Heinemeier HanssonandClaude Opus 5 4f60a18c87 Drive the install bar from packages, not from a stopwatch
install_progress() mapped each phase to a start%, an end% and a hard-coded
"expected seconds", then interpolated on elapsed time. Those seconds were
calibrated on one machine, so the bar was only ever honest on that machine.
Measured against a real 69s install on an XPS 13, the table expected 145s: 95s
for a phase that took 58.6s, 10s for one that took 2.6s. On slow media it is
wrong the other way — the bar reaches each phase's ceiling in a fraction of the
real time and then sits there. Today's clamp turns that into a 386-second freeze
at 85% on a USB2 install.

Phases do not even scale together: package installation is bound by media read
and disk write, the UKI rebuild is CPU and compression bound. No single seconds
table can be right for both.

So stop predicting durations. Position is now max(floor, work), both
non-decreasing:

  floor  an asymptote over the phase's band, on time since this process first
         saw the phase. tau is a shape constant, not a prediction — wrong by 3x
         it changes the pace of a crawl, never the correctness.
  work   only for "Installing Arch + Omarchy", which is ~85% of the install.
         libalpm creates one directory per package under
         <target>/var/lib/pacman/local, so counting them counts finished work.
         The denominator is resolved at ISO build time by pacman's own resolver
         against the offline mirror, so there is no second package list to
         drift. phases.py records expected and actual in the timing JSON on
         every install, so drift shows up in acceptance runs.

There is deliberately no give-up predicate: two discontinuous curves selected by
a toggle, behind a monotonic clamp, is a freeze generator. If the signal never
appears — no denominator, wrong target, implausible count — the floor alone
still walks the band to its end.

Position moves to per-mille. That is load-bearing, not precision theatre: these
are asymptotes, and in whole percent an asymptote over a 2-point band has two
reachable values and stops moving within seconds.

Time now comes from /proc/uptime. The live ISO runs tzupdate and
systemd-timesyncd, and the old code read EPOCHSECONDS, so a clock step
mid-install jumped every band straight to its ceiling.

Also fixes drift the old table had accumulated: it still weighted five phases
that no longer exist and had no entry at all for "Preparing install target",
which fell through to a 1->99 sweep. Unknown names now derive an even band from
the index the orchestrator already publishes.

bin/omarchy-iso-installer fakes a pacman database so the preview exercises the
package path instead of the fallback; PREVIEW_PACKAGES tunes the count.

Verified by simulation against the real logic: a 69s NVMe install, a 6-minute
USB2 install with a 70s pre-extraction stall, an implausible denominator, an
unknown phase name, and a target that already holds packages. All monotonic, all
reach 100%. Not yet run on real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:54:05 -07:00
David Heinemeier HanssonandClaude Opus 5 0631c0530a Stop shipping a second kernel nobody boots
The live ISO boots one kernel for every machine, and after the preceding commit
that kernel is linux-t2. Stock linux is then a second kernel nobody boots, plus
its own archiso initramfs, copied into both the ISO tree and the
size-constrained FAT EFI image — about 147MB.

It could not simply be deleted: releng's broadcom-wl hard-depends on linux and
is the only releng package that does, so pacman would pull the kernel back in.

broadcom-wl is a prebuilt module for stock linux and cannot load on linux-t2.
It has kept working until now only because of the bug the preceding commit
fixes — the ISO has been booting stock all along — so it is booting T2 that
takes it away, not this commit. Dropping the package only stops shipping
something that can no longer load. Nothing in the offline install needs Wi-Fi
in the live environment.

Both packages stay in the offline mirror and this changes nothing about
installed systems: linux comes from builder/archinstall.packages and remains
what configurator's detect_kernel picks for every machine that is not a T2 Mac,
and broadcom-wl comes from omarchy-other.packages, so install/hardware/
fix-bcm43xx.sh can still install it offline on BCM4360/BCM4331 MacBooks.

The removal is anchored so linux-t2, linux-firmware, linux-firmware-marvell and
linux-atm are untouched; verified against releng's real list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:27:53 -07:00
David Heinemeier HanssonandClaude Opus 5 ed710a3b41 Boot the T2 kernel the ISO says it boots
Every boot entry has named vmlinuz-linux-t2 since 0638855, but the file has
contained stock Arch. Verified against the shipped ISO:

  arch/boot/x86_64/vmlinuz-linux-t2:
    bzImage, version 7.1.5-arch1-1 (linux@archlinux)

while the mirror carries linux-t2-7.1.4-2. T2 MacBooks have been installing
with no keyboard or trackpad in the live environment.

Kernel packages ship nothing in /boot — only usr/lib/modules/<kver>/vmlinuz.
install_kernel() in /usr/share/libalpm/scripts/mkinitcpio sources
/etc/mkinitcpio.d/<pkgbase>.preset and copies THAT package's kernel to whatever
ALL_kver names, whenever is_kernelcopy() sees an unowned absolute path. Our file
was named linux.preset, so it overrode releng's copy of the same name and
pointed the STOCK kernel at the T2 filenames. linux-t2's own generated preset
writes those same two paths, and stock won.

c23e0e7 introduced it a week after 0638855 added T2 support, so the feature
worked for exactly seven days: before the preset existed, releng's linux.preset
kept the stock kernel on its own filenames and linux-t2's generated preset
produced the real thing.

The fix is the filename. releng's linux.preset takes over the stock kernel
again, which now writes /boot/vmlinuz-linux and initramfs-linux.img instead of
squatting on the T2 names — so the ISO grows by one kernel and one archiso
initramfs. That cost is not free and is worth revisiting: the stock kernel is
in the live root only because releng's broadcom-wl hard-depends on it, and
broadcom-wl is a prebuilt module for that kernel which cannot load on the T2
one we now boot. Dropping both is ~147MB and old Broadcom Macs lose live Wi-Fi.

Boot the ISO on a T2 Mac before trusting this; the version string is only
evidence that the right kernel ships, not that it works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:20:15 -07:00
David Heinemeier HanssonandClaude Opus 5 89245f44ec Warm the offline mirror while the user answers the wizard
The install reads ~3GB out of the offline mirror, and pacman consumes it at
only ~33MB/s (3.14GB of packages across the 95s the package phase is calibrated
at), so on any medium slower than that the install is read-bound. The ISO medium
is idle for the minutes the wizard takes, and the target disk written to later
is a different device, so reading ahead costs nothing that is otherwise in use.

Warms largest-first under a MemAvailable/2 budget. Deliberately no dependency
resolution: asking pacman exactly which files the install will read would copy
the package-set knowledge in phases_impl.py — the early list, the runtime
target, the kernel from the wizard's JSON — into a shell script that would drift
silently. The install reads roughly 69% of the mirror, so warming blindly is
close enough for a cache hint.

The page cache this fills is clean and reclaimable, so the failure mode under
memory pressure is wasted bandwidth rather than OOM. The budget stops small
machines evicting what was just warmed. The job ends when that budget is spent
rather than when the wizard exits, because the partitioning and format window
afterwards is also free medium time.

OMARCHY_NO_PREFETCH=1 turns it off so a single ISO can measure both arms against
the Installing Arch + Omarchy entry in /var/log/omarchy-install-timing.json.
Not yet measured on real USB media, and expected to be worth nothing in the VM
harness, where the ISO is backed by the host's page cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:16:16 -07:00
David Heinemeier HanssonandClaude Opus 5 e47ce379ad Drop gum from the orchestrator's progress lines
The dashboard owns the visible UI: it captures the orchestrator's stdout into
the support log and strips CSI sequences on the way in, so the colouring was
never seen by anyone. gum charged a Go process start per line for it, on every
phase and every package group. Plain writes keep the same indented shape.

Flushing stays: pacstrap and arch-chroot inherit this fd, so buffering would
reorder our lines against theirs in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:24:35 -07:00
David Heinemeier HanssonandClaude Opus 5 a8c3a671ea Compress the live root with zstd instead of xz
Squashfs decompresses on the page-fault path through a single stream
(CONFIG_SQUASHFS_DECOMP_SINGLE), where xz manages roughly a tenth of zstd's
throughput, and the live root is read cold on every boot: kernel, plymouth,
systemd, python, archinstall, gum. The offline mirror is already stored
uncompressed, so this only covers the part that still gets decompressed.

Costs well under a percent of ISO size. Dropping the x86 BCJ filter also
removes one of the blockers listed in plans/aarch64-support.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:24:35 -07:00
David Heinemeier HanssonandClaude Opus 5 c2b93afe01 Defer the target's UKI rebuild until the final Limine build
The existing hook mask only covers pacstrap. pacman.conf(5) HookDir is
absolute, so it never applied to the pacman runs inside arch-chroot, and during
omarchy-setup-system the target's own hooks are live. limine-entry-tool's
90-mkinitcpio-install.hook triggers on usr/lib/firmware/*, usr/src/*/dkms.conf
and usr/lib/modules/*/pkgbase, and collect_pkgbase_files sets rebuild_all for
any trigger outside usr/lib/modules — so it rebuilds the initramfs and UKI for
every installed kernel. The hardware scripts hit that routinely: sof-firmware
on Intel audio, nvidia-open-dkms, linux-ptl on Panther Lake, linux-t2 on Macs.

Every one of those builds is discarded. finalize_limine_boot runs limine-update
immediately after, which pipes "rebuild" into the same script and rebuilds
unconditionally. They are stale as well as redundant: hardware/nvidia.sh writes
its mkinitcpio drop-in after installing the driver.

Mask that one hook inside the target for the duration of the phase. The
kernel-removal hooks stay live — they only prune Limine entries, which is what
ptl-kernel.sh's "pacman -Rdd linux" needs.

A mask that survived would be invisible until a later kernel update shipped a
UKI-less boot, so it is restored in a finally, again in main()'s finally, and
validate_boot repairs and then insists the real hook is back before reboot.
limine-mkinitcpio-hook is a hard dependency of the Omarchy runtime package, so
that file is always on disk before the mask goes up.

Worth several seconds per avoided build on real hardware. It is invisible in
the VM harness, which installs no hardware packages, so measure it on an Intel
laptop rather than in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:24:26 -07:00
David Heinemeier HanssonandClaude Opus 5 436f37f926 Explain why archinstall's zram-generator.conf gets dropped
omarchy-settings now ships the tuning as a vendor drop-in that outranks the
main config file, so setup_swap's /etc copy decides nothing and is removed only
to keep /etc free for the local admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjeyQZsNBxqyYy9z8KaKm7
2026-07-25 14:00:52 -07:00
David Heinemeier HanssonandClaude Opus 5 529001d566 Validate the UKI that was actually built
Hardware packages like omarchy-hw-intel-ptl swap the kernel mid-install,
so the UKI lands under a name the configurator never asked for and boot
validation halted a perfectly good install. Read the kernels back off
/usr/lib/modules/*/pkgbase instead of trusting the requested name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:05:22 -07:00
David Heinemeier HanssonandClaude Opus 4.8 78824fe084 Skip archinstall's pre-format countdown
archinstall's FilesystemHandler.perform_filesystem_operations defaults to
show_countdown=True, which runs a ~4.5s "5...4...3...2...1" warning
(_final_warning) before wiping the disk. That prompt is meant for
archinstall's interactive TUI; in our orchestrated/dashboard install it
renders nowhere and just sleeps silently on every full-disk install.

Pass show_countdown=False, guarded by an introspection check so it stays
safe on newer archinstall releases that dropped both the countdown and the
parameter. Generalize _method_accepts_users into _method_accepts for the
check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:41:45 -07:00
David Heinemeier HanssonandClaude Opus 4.8 8653d07e06 Drop archinstall's zram-generator.conf before pacstrap
archinstall's setup_swap writes /etc/systemd/zram-generator.conf directly
into the target, but omarchy-settings now ships that file as a packaged
config. pacstrap then aborts the transaction on a file conflict
("zram-generator.conf exists in filesystem"), failing the install.

Delete archinstall's copy after setup_swap so omarchy-settings' packaged
config lands cleanly. We still keep the zram-generator package and service
that setup_swap installs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 07:54:04 -07:00
David Heinemeier HanssonandClaude Fable 5 5e80c917ea Skip signature checks for the offline mirror
Fresh installs on real hardware failed pacstrap with "keyring is not
writable" and "required key missing from keyring". The offline repo's
SigLevel = Optional TrustAll was not the bypass it appeared to be:
Optional verifies whenever a .sig file exists — and every package in the
offline mirror ships one — while pacstrap's pacman -r relocates the
install root but not GpgDir, so verification runs against the LIVE
keyring. That silently made every install depend on archiso's boot-time
pacman-init.service having finished: fast in QEMU, minutes-or-never on
real hardware, where pacstrap then raced it and lost.

The mirror ships inside the ISO, whose integrity is already covered by
the airootfs sha512 and the ISO signature, so pacman-level signature
verification adds nothing. Set SigLevel = Never so installs never touch
the live keyring. The target still gets its own keyring via pacstrap -K
plus the archlinux-keyring/omarchy-keyring package hooks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:49:29 -07:00
David Heinemeier HanssonandClaude Fable 5 f47785a196 Drop the emulated serial port from omarchy-iso-boot
QEMU's default COM1 is advertised to the guest via ACPI SPCR, the boot
stub appends console=uart,io,0x3f8 to the kernel cmdline, and Plymouth
deliberately forces its plain-text details mode whenever a serial console
is active. That made every omarchy-iso-boot test show an unthemed LUKS
prompt that real hardware (no serial port) never shows. Remove the serial
device so manual QEMU tests match real-hardware appearance.

omarchy-iso-test keeps its -serial file: logging on purpose — the harness
reads the console log and types blindly, so details mode is harmless there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:18:39 -07:00
David Heinemeier HanssonandClaude Fable 5 6ac95226af Stop blocking installs on the live pacman keyring
prepare_live waited on archiso's pacman-init.service, stalling real-hardware
installs at 5% for minutes: the boot-time keyring init (gpg key generation
plus populating every keyring) is slow when reading from USB, and as a
Type=oneshot unit with no start timeout it can block systemctl start
indefinitely.

Nothing in the install consults the live keyring. The offline repo is
SigLevel = Optional TrustAll and archinstall bootstraps with pacstrap -K,
which gives the target its own independently initialized keyring. Drop the
wait and let pacman-init finish in the background for anyone using the live
environment interactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:41:23 -07:00
David Heinemeier HanssonandClaude Fable 5 f97a664ea3 Unpack the live initramfs synchronously
Kernel 7.1 unpacks the initramfs asynchronously, which races /init: the
early /proc, /sys, /dev, and /run mounts fail with EPERM while unpacking
settles. In the live environment that kills Plymouth and leaves early
archiso mounts to luck. Force synchronous unpacking on every boot entry,
matching the fix shipped for installed systems in omarchy-defaults.conf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:30:55 -07:00
David Heinemeier Hansson 338e687f32 Explain Super 2026-07-23 19:56:35 -07:00
David Heinemeier Hansson 4e8ee7da9b Switch to using tips 2026-07-23 19:39:54 -07:00
David Heinemeier Hansson ca79d0f4dc Prefer local packages in local-source ISOs 2026-07-23 17:41:15 -07:00
David Heinemeier Hansson 463f862b6c Speed up offline ISO installs 2026-07-23 17:21:13 -07:00
David Heinemeier Hansson 3544261e8c Retry stale offline package downloads 2026-07-23 16:20:36 -07:00
David Heinemeier Hansson a274a2c56e More whimsy 2026-07-23 16:15:29 -07:00
David Heinemeier Hansson fa54a8b2dc Expand ISO acceptance coverage 2026-07-22 15:31:05 -07:00
David Heinemeier Hansson a27385483f Replace stale local package signatures 2026-07-22 15:31:05 -07:00
David Heinemeier HanssonandClaude Fable 5 01a5203009 Pick the test ISO like omarchy-iso-boot does
Offer the same gum chooser over release/*.iso when no ISO is passed,
and fail plainly when there is no existing ISO to test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 09:47:46 -07:00
David Heinemeier Hansson a72a109e7a Add fallback mirror for arch-mact2 2026-07-22 09:46:08 -07:00
David Heinemeier HanssonandClaude Fable 5 3c98b0e10c Add omarchy-iso-test acceptance harness
Installs an ISO into a headless QEMU VM by driving the real interactive
install flow: each screen is read via QMP screendumps + OCR and answered
with virtual keystrokes, so the configurator wizard, install dashboard,
reboot prompt, and SDDM login are exercised exactly as a user would.
SSH access is bootstrapped by logging into a console TTY, the installed
disk is snapshotted for fast reruns, and a throwaway overlay then runs
the omarchy repo's in-guest acceptance suite over SSH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 18:29:49 -07:00
David Heinemeier Hansson 9d4b155e22 Language tweaks 2026-07-19 21:04:27 -07:00
David Heinemeier HanssonandClaude Fable 5 04aafcb781 Retry the partition commit when udev holds the disk
Full-disk installs flaked with "Partition(s) 1, 2 on /dev/vda have been
written, but we have been unable to inform the kernel of the change":
archinstall's disk wipe and parted's own partition registrations fire
udev probes, and when one is still holding the disk mid-commit, the
BLKPG sync fails and the install halts. The table is written correctly
when this happens — only the kernel's view is stale — so settle udev
before partitioning and redo the filesystem operations (wipe, partition,
format) when parted throws exactly this error. Recoveries are logged so
they show up in the support log.

Reproduced headlessly by booting the ISO on a serial console and driving
the real installer: stock failed 1 in 5 full-disk cycles at this exact
phase, with an audit watch confirming nothing else writes the disk. With
the fix, 13 of 13 cycles got cleanly through partitioning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:58:39 -07:00
David Heinemeier Hansson c3f9743847 Don't like the first one 2026-07-18 18:27:10 -07:00
David Heinemeier HanssonandClaude Fable 5 434db24b81 Keep the installer centered when console geometry shifts
Remeasure the terminal at every screen redraw instead of once at boot:
the framebuffer can settle after the configurator starts (VM windows
especially), which left stale padding for the whole flow. Pad the
confirm-screen warnings with say so every line shares the same left
edge. Boot the test VM with SDL instead of GTK, whose GL display path
crops the guest framebuffer on HiDPI Wayland desktops.

Verified in a live VM by driving the whole flow over QMP and reading
each screen back from /dev/vcs1: all screens center on the real console
width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:41:23 -07:00
David Heinemeier HanssonandClaude Fable 5 969d461eb9 Parse the free-space fields with a single read
Three echo-through-awk substitutions to split one line into three
variables is what read was made for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 10:55:49 -07:00
David Heinemeier HanssonandClaude Fable 5 4583a0e5e6 Extract the helpers shared by both install paths
The free-space and full-disk paths each carried verbatim copies of the
T2 Mac kernel detection, the credentials JSON heredoc, the identity/
encryption answer files, the dry-run print-and-cleanup block, and the
udev settle loops. Pull those into detect_kernel, write_user_files,
print_dry_run_files, and wait_for_device, which also removes a subtle
rm vs rm -f drift between the two dry-run cleanups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 10:55:32 -07:00
David Heinemeier HanssonandClaude Fable 5 f79578f2db Remove the dead shared-ESP path from the free-space install
We decided Omarchy always creates its own dedicated ESP and never adopts
a Windows one, so shared_esp was declared empty and never assigned —
every conditional on it was guaranteed to take the dedicated-ESP branch.
Collapse the whole dual-path structure into straight-line code and keep
detect_windows_esp only for the "leaving it untouched" message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 10:54:38 -07:00
David Heinemeier HanssonandClaude Fable 5 4e3e82922d Trust that tzupdate is on the ISO
build-iso.sh bakes tzupdate into the live environment, so the runtime
pacman fallback was dead there — and in a dry run on a dev machine it
would install a package as a side effect. tzupdate -p already fails
gracefully into the manual timezone filter when the tool is missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 10:53:57 -07:00
David Heinemeier HanssonandClaude Fable 5 59ffc3321e Simplify the configurator preamble
Drop the dead LOGO_PATH override, the redundant package-targets sourcing,
the unused TERM_HEIGHT and OMARCHY_NVIM_PACKAGE, the non-gum exports, and
the logo fallback branches. Trust that logo.txt is where OMARCHY_PATH says.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 10:40:33 -07:00
David Heinemeier Hansson 1463936de3 Simplify the free-space install selection 2026-07-16 14:41:18 -07:00
David Heinemeier Hansson f6775344f6 Offer easy way to test partitioned install 2026-07-16 09:48:21 -07:00
David Heinemeier Hansson 49dcd808aa Whimsy tweaks 2026-07-15 18:07:07 -07:00
David Heinemeier Hansson c2a58526f4 Replace installer preview Python with jq 2026-07-15 18:05:29 -07:00
David Heinemeier HanssonandClaude Fable 5 a5e605af4c Replace dashboard Python heredocs with jq + bash
The install dashboard shelled out to a full Python interpreter to read
four keys from the installer state JSON — including inside the render
loop, twice a second. jq is already on the ISO and does the same job
with plain bash arithmetic for the phase-weight interpolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 17:52:47 -07:00
David Heinemeier Hansson 8c1e143686 Hand write the whimsy 2026-07-15 17:40:24 -07:00
David Heinemeier Hansson 4ad8a22abe We don't need more than 8 cores for this 2026-07-15 17:35:37 -07:00
David Heinemeier Hansson 764a65f7ee Bring back gl acceleration 2026-07-15 17:35:19 -07:00
David Heinemeier Hansson c167bf9c1d Cleanup the look 2026-07-14 18:09:48 -07:00
David Heinemeier Hansson f87b08bba7 Merge branch 'main' into omarchy-4 2026-07-14 17:13:26 -07:00
David Heinemeier Hansson 588973b0cd Only offer a choice of installing alongside existing data if it's actually possible 2026-07-14 17:05:45 -07:00
David Heinemeier Hansson c77a7b555b Drop the tool offering during this initial install 2026-07-14 17:03:06 -07:00
David Heinemeier Hansson 08b7460900 Explain purpose 2026-07-14 16:53:10 -07:00
David Heinemeier Hansson 341bb84093 Use same naming as omarchy-iso-configurator 2026-07-14 16:53:06 -07:00
David Heinemeier Hansson c303a9f746 More aesthetic tweaks 2026-07-13 17:06:43 -07:00
David Heinemeier Hansson c8a0072403 Tweak the installer aesthetics 2026-07-13 15:55:08 -07:00
David Heinemeier Hansson ff6c9582ca Use the real flow during simulation so they don't drift 2026-07-13 14:44:42 -07:00
David Heinemeier Hansson d44bbc5e5f Fix archinstall 4.4 compatibility 2026-07-12 18:43:51 -07:00
David Heinemeier Hansson d51a280636 Let's go quattro 2026-07-12 18:31:10 -07:00
Ryan Hughes 8c77631f68 Delegate Snapper setup to Omarchy packages 2026-06-21 02:29:09 -04:00
Ryan Hughes 58221e5ed6 Fix bootstrap pacman database setup 2026-06-08 19:25:25 -04:00
Ryan Hughes b1c09fa1a8 Use dev Omarchy packages in dev ISO builds 2026-06-08 12:53:01 -04:00
Ryan Hughes ef3b0e4b61 Always create our own ESP 2026-05-30 01:38:24 -04:00
Ryan Hughes b280cbcc42 Patch t2 mirror for now 2026-05-27 23:06:03 -04:00
Ryan Hughes ce9e648ade Drop omarchy-limine package; settings now ships limine configs
Pairs with omarchy-pkgs' fold of omarchy-limine into omarchy-settings.

Changes:
- EARLY_BOOTSTRAP_PACKAGES in phases_impl.py drops omarchy-limine.
  The limine configs it shipped (mkinitcpio hooks, entry-tool drop-ins,
  snapper template, default/limine/, default/snapper/) now come with
  omarchy-settings, which is already in the early bootstrap.
- builder/build-iso.sh's offline-mirror seed list and post-mirror filter
  no longer reference omarchy-limine.
- builder/build-omarchy-packages.sh's build queue drops omarchy-limine.
- Test manifests (fresh-4.json, fresh-4-semantic.json) no longer expect
  omarchy-limine in the installed-packages list. The /etc/pacman.d/hooks/
  99-omarchy-limine.hook entry stays — that hook file is generated by the
  orchestrator regardless, and the filename is descriptive, not tied to
  the package name.
2026-05-27 19:09:05 -04:00
Ryan Hughes 239c8e7f64 Use omarchy-finalize-user in target chroot setup
Pairs with omarchy-installer's rename of bin/omarchy-setup-user to
bin/omarchy-finalize-user. The orchestrator now invokes
omarchy-finalize-user --force --first-install in the run_chroot_finalizer
phase, and the phase-ordering docstring is updated to match.
2026-05-27 19:05:38 -04:00
Ryan Hughes afa8293b2b Improve ISO installer flow and VM tooling 2026-05-25 19:54:12 -04:00
Ryan Hughes c50d0bc299 Unify ISO install orchestration
Drive installs through the common ISO pipeline, carry install intent in configurator JSON, and finalize Limine boot setup from the ISO.
2026-05-25 12:50:31 -04:00
Ryan Hughes 660edcb3af installer: defer boot hooks during pacstrap 2026-05-23 14:39:52 -04:00
Ryan Hughes 3a51e875b6 installer: move hibernation into orchestrator 2026-05-23 14:05:15 -04:00
Ryan Hughes c7b8d4b584 builder: prune stale offline package versions 2026-05-23 13:08:15 -04:00
Ryan Hughes 38f56103e1 builder: add debug install instrumentation 2026-05-23 12:45:12 -04:00
Ryan Hughes b16303f294 installer: always clean up finalizer log bind 2026-05-23 10:30:53 -04:00
Ryan Hughes 6232620806 installer: unify finalizer logging 2026-05-23 10:21:38 -04:00
Ryan Hughes 8d1d3c906d installer: harden dashboard finish transition 2026-05-23 10:02:36 -04:00
Ryan Hughes fd12ace8bc installer: tolerate missing duration on finish 2026-05-23 05:29:53 -04:00
Ryan Hughes 1d656af51d installer: make dashboard own install flow 2026-05-23 04:50:32 -04:00
Ryan Hughes af4679b153 installer: clear stale dashboard state 2026-05-23 03:43:24 -04:00
Ryan Hughes c43bad26b5 installer: stop dashboard before finish prompt 2026-05-23 03:35:55 -04:00
Ryan Hughes 7d7f6451a3 installer: track real dashboard pid 2026-05-23 02:55:44 -04:00
Ryan Hughes 0890472c9b installer: pass dashboard tty explicitly 2026-05-23 02:14:13 -04:00
Ryan Hughes 1162d111bd installer: force dashboard shutdown before finish 2026-05-22 23:10:44 -04:00
Ryan Hughes ca55321621 installer: animate dashboard and finish screens 2026-05-22 19:20:03 -04:00
Ryan Hughes 0734e982ca installer: harden dashboard handoff 2026-05-22 19:15:06 -04:00
Ryan Hughes 61f84a95cf installer: smooth dashboard progress 2026-05-22 18:09:00 -04:00
Ryan Hughes 63ba457034 builder: mark install dashboard executable 2026-05-22 17:46:55 -04:00
Ryan Hughes 337ba2e590 installer: show dashboard during ISO install 2026-05-22 17:34:01 -04:00
Ryan Hughes de0f013b5e orchestrator: converge free-space boot on finalizer 2026-05-22 16:33:11 -04:00
Ryan Hughes 948a53cc0a orchestrator: recover missing ESP mount in free-space installs 2026-05-22 15:00:19 -04:00
Ryan Hughes cab315bb8a configurator: count EFI inside free-space size 2026-05-22 14:47:57 -04:00
Ryan Hughes 2178c26a92 configurator: let free-space installs choose size 2026-05-22 14:05:28 -04:00
Ryan Hughes f3973abb4c configurator: use valid bootloader value for free-space installs 2026-05-22 13:51:40 -04:00
Ryan Hughes 3e0cd31c2b configurator: clarify free-space install semantics 2026-05-22 13:51:02 -04:00
Ryan Hughes 7e79355c71 configurator: add partition tool and recoverable partition flow 2026-05-22 13:29:10 -04:00
Ryan Hughes d7e4233042 orchestrator: defer full-disk mkinitcpio to final UKI build 2026-05-22 13:26:06 -04:00
Ryan Hughes 5ad90afbae orchestrator: use epoch for finalizer timing 2026-05-22 13:04:34 -04:00
Ryan Hughes c2396fb447 orchestrator: preserve offline finalizer timing 2026-05-22 12:26:51 -04:00
Ryan Hughes 9ad189ef82 orchestrator: configure snapper before offline finalizer 2026-05-22 12:13:00 -04:00
Ryan Hughes 1e26d18031 orchestrator: point finalizer at installed runtime assets 2026-05-22 11:23:46 -04:00
Ryan Hughes 6decb2f683 orchestrator: install omarchy-nvim before user creation 2026-05-22 10:58:26 -04:00
Ryan Hughes 3420bc9aa8 builder: include omarchy-nvim in local package builds 2026-05-22 10:46:56 -04:00
Ryan Hughes a21ab8bcb1 orchestrator: prepare writable offline finalizer log 2026-05-22 02:10:18 -04:00
Ryan Hughes c5baf2c791 orchestrator: keep finalizer payload out of tmp 2026-05-22 01:39:49 -04:00
Ryan Hughes ec218bade6 orchestrator: chown finalizer tooling inside target 2026-05-22 01:17:45 -04:00
Ryan Hughes c179d78814 configurator: emit explicit bootloader_config with removable=false
archinstall 4.3 maps the deprecated top-level 'bootloader': 'Limine'
to BootloaderConfiguration(bootloader=Limine, removable=True) (per
lib/args.py:185-190 and lib/models/bootloader.py:56-69). Our new
_install_limine_omarchy mirrors archinstall's removable handling: it
would copy BOOTX64.EFI to EFI/BOOT (fallback path) and SKIP
efibootmgr, neither of which is what we want.

Switch to the explicit bootloader_config form with removable=false
so we take the EFI/limine path + register the 'Limine' NVRAM entry
as intended.
2026-05-22 00:15:04 -04:00
Ryan Hughes 3a91cf780c orchestrator: install Limine ourselves; stop creating arch-limine entries to delete later
archinstall's add_bootloader creates EFI/arch-limine/{BOOTX64.EFI, BOOTIA32.EFI}, writes a limine.conf with an Arch Linux entry, and registers an efibootmgr label "Arch Linux Limine Bootloader" — only for limine-snapper.sh to delete it all and rebuild with Omarchy branding via limine-mkinitcpio-hook.

Replace the add_bootloader call with _install_limine_omarchy, which does the same work archinstall's _add_limine_bootloader does (UEFI EFI binary copy + efibootmgr register, or BIOS limine-bios.sys + limine bios-install) but with Omarchy-owned paths and labels from the start:

  - EFI subdir:    EFI/limine          (not EFI/arch-limine)

  - efibootmgr:    label "Limine"      (not "Arch Linux Limine Bootloader")

  - loader path:   \EFI\limine\BOOTX64.EFI

Compute the kernel cmdline directly from disk_config instead of harvesting it from a file archinstall wrote (whose path keeps drifting across versions). Then write /etc/default/limine and /etc/kernel/cmdline, and stage /boot/limine.conf from our styling template — limine-mkinitcpio-hook will add the actual boot entries when it deploys.

Adds limine + efibootmgr to EARLY_PACKAGES (archinstall used to pacstrap these as a side effect of add_bootloader).

Does not modify limine-snapper.sh — its cleanup code will simply no-op because there are no arch-limine paths or "Arch Linux Limine Bootloader" entries to find. That code can be pruned in a follow-up.
2026-05-21 21:57:50 -04:00
Ryan Hughes 39113a2d2b orchestrator: probe multiple paths for limine.conf after add_bootloader
archinstall 4.3 writes limine.conf to <target>/<esp_mount>/EFI/arch-limine/
limine.conf for UEFI non-removable installs (lib/installer.py:1487), not
to /mnt/boot/limine.conf as our code assumed. Older archinstall versions
used different paths.

Probe the 4.3 paths (arch-limine, BOOT, limine subdir) and the legacy
/boot/limine.conf in order; pick whichever exists. Error message now
lists everywhere it looked so future drift is easy to diagnose.
2026-05-21 20:56:58 -04:00
Ryan Hughes 16cc1058be automated_script: restore proven gum stream/env contract
Restore the env + redirect setup that the previously-working ISO
installer used (matching /home/ryan/Downloads/ISO scripts/.automated_script.sh):

  export COLUMNS=$(tput cols)
  export LINES=$(tput lines)
  exec > >(tee >(sed CSI-strip >> log) 2>/dev/null) 2>/dev/tty
  export CLICOLOR_FORCE=1
  export FORCE_COLOR=1

The cutover commit (064b5e2) silently dropped these. Subsequent
attempts to fix the TTY/gum rendering by reshaping the redirect were
all symptom-chasing — the proven shape was correct. The key insight:

  - gum draws its TUI on stderr, not stdout. 2>/dev/tty keeps that
    rendering on the real terminal.
  - stdout-as-pipe (via tee) is *desired*: it lets gum detect a
    non-TTY stdout and skip cursor-position probes (CSI 6n). Without
    that detection, the DSR replies leak as visible ^[[13;1R bytes.
  - CLICOLOR_FORCE/FORCE_COLOR force gum to still emit colour despite
    stdout being a pipe.

Also drops the configurator's _drain_tty workaround — unnecessary
once gum stops sending DSR queries in the first place.
2026-05-21 20:54:05 -04:00
Ryan Hughes 8734136908 configurator/shim: drain tty without hanging (use -t 0.05, not -t 0)
bash's `read -t 0` only TESTS whether input is available; it does not
consume the bytes. So the drain loop `while read -t 0 ...; do :; done`
spun forever the moment any bytes were pending (e.g., the DSR reply
sitting in the tty input buffer after gum style sent CSI 6n).

That hung the configurator at the first step() title screen — blank
prompt, blinking cursor, no keyboard form.

Use `read -t 0.05 -N 9999` once, which consumes up to 9999 bytes with
a 50ms timeout. Always returns after 50ms whether there's data or not.
Single call replaces the while loop.
2026-05-21 20:46:50 -04:00
Ryan Hughes a18d4651a3 orchestrator: pacstrap limine+efibootmgr in protected base install
configure_protected_boot used to arch-chroot pacman -S limine
efibootmgr, but at that phase the live ISO's offline mirror is not
bind-mounted into the target yet (run_chroot_finalizer does that
later) and the target's pacman.conf points at network mirrors that
aren't reachable during an offline install.

Pacstrap these packages alongside the runtime package list in
arch_install_base, where archinstall is still pacstrapping against
the live ISO's offline mirror. Drop the now-redundant arch-chroot
pacman call from configure_protected_boot.
2026-05-21 20:38:03 -04:00
Ryan Hughes a5a7110bea configurator: drain tty input after each step() title
gum's TUI startup queries the terminal (e.g., cursor position via
CSI 6n) and doesn't always read the reply. The reply bytes
("^[[13;1R") then surface visibly next to the next step title.
A one-time drain at script start (in .automated_script.sh) catches
the boot-time replies but not the gum-per-call ones, since the
artifact came back at every new step().

Add a small _drain_tty helper and call it from step() after the
title gum-style invocation so any pending DSR replies are consumed
before the next prompt runs.
2026-05-21 20:34:21 -04:00
Ryan Hughes 34c0a14766 archinstall_adapter: pass Path (not str) to Installer
archinstall 4.3's Installer.__init__ types target as Path and uses it
directly (self.target / 'boot', etc.). Passing str(mountpoint) makes
self.target a str, so every internal path operation raises
'unsupported operand type(s) for /: str and str' — most visibly during
set_mirrors, which constructs target / MIRRORLIST.relative_to_root().

Drop the str() cast; pass the Path through as-is.
2026-05-21 20:34:21 -04:00
Ryan Hughes be721e17f4 installer: route kernel/initramfs to ESP for encrypted protected installs (Limine can't read LUKS) 2026-05-21 20:09:50 -04:00
Ryan Hughes 21dbbcb917 ui: attach gum confirm to /dev/tty so it doesn't render through the install log tee 2026-05-21 20:08:38 -04:00
Ryan Hughes 260edc13c5 orchestrator: tear down protected-mode mounts and LUKS mapper on failure 2026-05-21 20:08:29 -04:00
Ryan Hughes e049f28bfa orchestrator: record bind mounts immediately to prevent leaks on partial failure 2026-05-21 20:08:06 -04:00
Ryan Hughes ed7c62febe omarchy-iso: drop obsolete archinstall patch helper (4.3 has the fixes upstream) 2026-05-21 20:07:55 -04:00
Ryan Hughes b568b0b796 archinstall_adapter: pass --config/--creds via synthetic sys.argv (env vars don't work)
ArchConfigHandler reads --config and --creds from sys.argv via argparse at
construction time; it ignores ARCHINSTALL_CONFIG/ARCHINSTALL_CREDS env
vars (confirmed in archinstall 4.3 lib/args.py:_parse_args). Setting those
env vars was a no-op, so handler.config.disk_config was always None when
the wrapper stripped our args off the real argv.

Fix: build a synthetic argv [prog, --config <path>, --creds <path>] and
swap it into sys.argv just for the ArchConfigHandler() call, then
restore. Drops the unused 'import os' now that the env var lines are
gone.
2026-05-21 19:53:27 -04:00
Ryan Hughes c1828b13ad automated_script: drain stale tty input + drop redundant stdin redirect
Fix the ^[[13;1R cursor-position-report artifacts visible next to the
first configurator step. archiso's boot init leaves DSR responses in the
kernel tty input buffer; the first gum invocation reads them as stray
input and echoes them as visible escape sequences.

- Drain stdin (with a short timeout) from /dev/tty before configurator
  runs so any pending replies are consumed.
- Drop the explicit </dev/tty stdin redirect — the parent script's
  stdin is already tty1 (inherited from archiso autostart), and the
  explicit redirect opened a new fd with subtly different termios.
2026-05-21 19:51:35 -04:00
Ryan Hughes 22f94e6da6 orchestrator: switch wrapper interface from CLI args to env vars to avoid archinstall argparse collision 2026-05-21 19:40:31 -04:00
Ryan Hughes 8dad5a7436 omarchy-iso-make: lint that executable airootfs files have file_permissions entries 2026-05-21 18:44:09 -04:00
Ryan Hughes 0477275d08 configurator: shorten install-mode labels to fit 80-col terminals 2026-05-21 18:44:09 -04:00
Ryan Hughes 13e0201e9b automated_script: keep configurator on real TTY; scope logging to install phase 2026-05-21 18:44:09 -04:00
Ryan Hughes 9c6d14a80b iso: add file_permissions entries for new omarchy-iso-* helpers (fix Permission denied) 2026-05-21 18:44:09 -04:00
Ryan 05a1c6f271 orchestrator: implement protected-mode boot (Limine in EFI/Omarchy, fstab/crypttab/mkinitcpio, efibootmgr with BootOrder preservation) 2026-05-21 18:18:50 -04:00
Ryan Hughes 26e6a78d3a configurator: add partition install mode with BitLocker/ESP guards and pre_mounted_config output 2026-05-21 18:12:55 -04:00
Ryan Hughes 7a56a9c1e6 orchestrator: mode-aware phase selection for full-disk vs protected 2026-05-21 18:06:55 -04:00
Ryan Hughes b36280f1a6 omarchy-iso-cleanup-disk: add --protected mode; adapter: doc pre-mount mount semantics 2026-05-21 18:06:52 -04:00
Ryan Hughes 064b5e2393 automated_script: collapse to configurator + omarchy-iso-install wrapper
All install-phase logic now lives in the Python orchestrator and the
omarchy-iso-* bash helpers. .automated_script.sh becomes a thin shim that:

  - sources install helpers (env for configurator)
  - sets the Tokyo Night palette on the live VT
  - tees stripped stdout/stderr to /var/log/omarchy-install.log
  - runs the configurator wizard
  - exec's omarchy-iso-install with absolute config paths

Absolute paths are required because the wrapper cd's into
/usr/share/omarchy-iso before invoking the orchestrator.

Removed install_base_system, install_omarchy, install_arch, install_disk,
cleanup_install_disk, configure_login_for_unencrypted_install, and
chroot_run_as_user — all replaced by Python phases or bash helpers.
2026-05-21 17:53:05 -04:00
Ryan Hughes 6d6b516221 orchestrator: always unwind bind mounts on exit
Bind mounts set up in run_chroot_finalizer used to be torn down in finish,
but finish doesn't run when a phase between the two raises. Move the unmount
loop into a dedicated cleanup_bind_mounts function and call it from main()'s
finally so it runs on success, failure, and KeyboardInterrupt.
2026-05-21 17:52:12 -04:00
Ryan Hughes 26f87f6789 orchestrator: shell out to bash helpers; patch archinstall before Python imports 2026-05-21 17:48:37 -04:00
Ryan Hughes 1742e6d997 Add omarchy-iso disk-cleanup and archinstall-patch bash helpers 2026-05-21 17:48:37 -04:00
Ryan Hughes 1daa755b70 gitignore python bytecode (orchestrator cache dirs) 2026-05-21 16:54:18 -04:00
Ryan Hughes 89cc55d559 Add Python install orchestrator (moved from omarchy-installer)
The orchestrator drives an offline install on the live ISO using
archinstall as a library subsystem. Lands under configs/airootfs/ so
mkarchiso bakes it into the live squashfs directly:

  /usr/share/omarchy-iso/orchestrator/   (the Python package)
  /usr/local/bin/omarchy-iso-install     (thin shell wrapper that
                                          execs python -m orchestrator.main)

Two corrections vs the previous omarchy-installer location:

1. omarchy-installer dropped from EARLY_PACKAGES \u2014 it's live-ISO-only
   install tooling, must never land on the target. /etc/skel is still
   populated pre-useradd via omarchy-settings.

2. run_chroot_finalizer now copies /usr/share/omarchy from the live
   ISO into /mnt/tmp/omarchy-install/ before arch-chroot. finalize.sh
   + install/ scripts run from /tmp inside the chroot; /tmp is wiped
   on first reboot, so the target boots with zero install detritus.
2026-05-21 16:53:13 -04:00
Ryan Hughes 0c63044066 chroot_run_as_user: unset XDG_RUNTIME_DIR
The live ISO runs as root with XDG_RUNTIME_DIR=/run/user/0; arch-chroot
-u ryan switches the UID but doesn't reset the env, so tools running as
ryan tried to write to root's runtime dir (dconf failed to commit,
omarchy-theme-set's flock target hit Permission denied). Drop the var
so tools fall back to no-runtime-dir behaviour, which works inside the
chroot.
2026-05-21 00:39:39 -04:00
Ryan Hughes f8010a355a Rebuild offline repo db from scratch each run
repo-add --new skips updating entries that are already in the db, so when
the omarchy-* packages are rebuilt in place (new content, same filename,
because pkgver is constant) the persistent cache's offline.db.tar.gz
kept the OLD size/checksum entries. Pacman then read the stale db size
and rejected the new file with 'Exceeded the maximum allowed file size'.

Delete the db files and let repo-add regenerate them fresh — slower but
deterministic, and 'offline.db.tar.gz' is small to rebuild even for the
full ~600 package set.
2026-05-21 00:28:32 -04:00
Ryan Hughes ca2b58fa4d Install omarchy-installer + omarchy-settings into the live ISO
Two motivations:

1. Plymouth: the omarchy plymouth theme + /etc/plymouth/plymouthd.conf
   ship from omarchy-settings (theme at /usr/share/plymouth/themes/omarchy,
   plymouthd.conf via the package's post_install etc-overrides hook).
   Without omarchy-settings on the live ISO, mkarchiso builds the initrd
   with plymouth pointing at the default theme instead of Omarchy's. Now
   it picks up the Omarchy theme on boot like the installed system does.

2. Drops the .pkg.tar.zst extraction hack in .automated_script.sh:
   OMARCHY_PATH=/usr/share/omarchy is the real package install path, so
   use_omarchy_helpers no longer needs to bsdtar pieces out of the
   offline mirror into /tmp/omarchy-iso.
2026-05-21 00:13:05 -04:00
Ryan Hughes 53bc2550f9 .automated_script.sh: seed user home from /etc/skel after archinstall
archinstall creates the user from user_credentials.json BEFORE installing
the 'additional packages' list, so when omarchy-settings populates
/etc/skel/.config/... later in the pacstrap, the user's home has already
been created from an empty skel and missed the seed (user boots into a
terminal with no Hyprland configs).

Copy /etc/skel into /home/$OMARCHY_USER after archinstall finishes (by
which point omarchy-settings has populated skel) and chown to the user.
cp -rT keeps it idempotent: existing files get overwritten, dirs merge.
2026-05-21 00:08:33 -04:00
Ryan Hughes 874e6cd24d Cache ISO mirror by channel instead of by date
The previous per-day cache (iso_YYYY-MM-DD) forced a fresh ~6GB download
on the first build of every day. Different channels (stable/edge/rc)
HAVE different package sets and shouldn't share a cache, but different
DAYS within a channel don't, so keying by channel gives us a stable
cache that persists indefinitely.

omarchy* packages always rebuild fresh inside the container regardless:
build-omarchy-packages.sh nukes /tmp/omarchy-pkg-build at the start
and the final mv overwrites the cached .pkg.tar.zst (pkgver is constant).
--no-cache still bypasses the mount entirely for a clean-slate run.
2026-05-20 23:43:37 -04:00
Ryan Hughes cf9f2745f3 .automated_script.sh: arch-chroot pacman -S omarchy-base.packages after pacstrap
omarchy's hard depends are now just the bricking set, not the full default
install. After archinstall pacstraps the meta packages (which populates
/etc/skel before user creation), install the rest of omarchy-base.packages
in the chroot before running omarchy-install.
2026-05-20 23:10:30 -04:00
Ryan Hughes 01b932023a configurator: pacstrap omarchy + omarchy-installer (not just installer)
With the cleaner dep graph, omarchy-installer no longer transitively
pulls omarchy + omarchy-settings + omarchy-limine. Pacstrap both
explicitly so /etc/skel (via omarchy -> omarchy-settings) is populated
before archinstall creates the user.
2026-05-20 22:27:38 -04:00
Ryan Hughes a4f5c1bf14 Pacstrap omarchy-installer via archinstall so /etc/skel is set up first
archinstall creates the user AFTER pacstrap. If omarchy-settings (which
ships /etc/skel/.config/**) isn't pacstrap'd, the user is created with
an empty skel and never gets the Omarchy default configs in their HOME.

Add omarchy-installer to the configurator's archinstall package list
(its depends pull omarchy + omarchy-settings + omarchy-limine), so all
the package files are in place before the user is created.

install_omarchy no longer needs the explicit 'pacman -S omarchy-installer'
step; it just runs 'omarchy-install' which is already at /usr/bin/.
2026-05-20 22:18:14 -04:00
Ryan Hughes 8982984f40 .automated_script.sh: also extract omarchy-settings into the helpers tree
presentation.sh reads $OMARCHY_PATH/logo.txt to compute LOGO_WIDTH and
LOGO_HEIGHT, which then drive max_line_width in logging.sh / errors.sh.
We were only extracting omarchy-installer into /tmp/, so logo.txt was
missing, LOGO_WIDTH=0, max_line_width=-4, and bash's substring expression
errored with 'substring expression < 0'.

Extract from omarchy-settings into the same /tmp tree so all the
presentation/logging helpers find what they expect. Renamed the dir
from omarchy-installer-iso to plain omarchy-iso since it now blends
content from two packages.
2026-05-20 22:16:34 -04:00
Ryan Hughes a74d20f8fb build-omarchy-packages.sh: pass --nodeps to makepkg
We're not installing the deps in the build container — just producing
.pkg.tar.zst files that pacman will resolve against the offline mirror
on the target. Installing limine-mkinitcpio-hook into the build
container also fires its pacman hook that errors about a missing ESP.
2026-05-20 21:42:22 -04:00
Ryan Hughes 1224531ce1 Add the [omarchy] repo to the build container's pacman.conf
makepkg in build-omarchy-packages.sh installs runtime build deps via
pacman, which uses the container's /etc/pacman.conf. That config only
had core/extra/multilib by default, so deps that live in the omarchy
repo (limine-snapper-sync, limine-mkinitcpio-hook) failed to resolve.

Append the [omarchy] block from the channel-specific online config to
/etc/pacman.conf right after omarchy-keyring is installed.
2026-05-20 21:39:58 -04:00
Ryan Hughes 8b0fd93011 chown the package build dir to the builder user
makepkg runs as the builder user and needs to write PKGDEST. /tmp/omarchy-pkg-build
is created by root, so without an explicit chown the build aborts with
'You do not have write permission for the directory $PKGDEST'.
2026-05-20 21:38:09 -04:00
Ryan Hughes b34f53629d Validate --local-source paths before any sudo work
Also includes the build-iso.sh cleanup that I prepared earlier but
forgot to stage (replacing the inline ugly $(...) conditional pipe
with a normal if/then-fi block).
2026-05-20 21:36:31 -04:00
Ryan Hughes e427304f29 Make --local-source require explicit checkout paths
Dropped the sibling-dir convention ($BUILD_ROOT/../omarchy-installer
etc.). Callers now pass paths explicitly:

  omarchy-iso-make --local-source <installer-checkout> <pkgs-checkout>

Errors clearly if either path is missing or doesn't exist.
2026-05-20 21:35:46 -04:00
Ryan Hughes b8df9d3f50 Make --local-source the opt-in; default to network omarchy mirror
The previous version made the local build the only path. Convention is
the opposite: omarchy-iso-make pulls released omarchy* packages from
the omacom-pkgs.org mirror by default, and only builds from local
checkouts when explicitly asked via --local-source.

- bin/omarchy-iso-make: only mount /omarchy-installer + /omarchy-pkgs
  when --local-source is set. Default has neither mount.
- builder/build-iso.sh: skip build-omarchy-packages.sh when no local
  source. Don't filter the omarchy* names out of the pacman -Syw list
  in network mode (so they get downloaded from the mirror). With
  --local-source, the four package names are stripped after assembly
  so we don't re-fetch what we just built.
- For the package-list source: with /omarchy-installer mounted, read
  omarchy-base.packages from the checkout. Without it, briefly pull
  the published omarchy package, extract the .packages files from it
  to /tmp, and use those.
2026-05-20 21:34:49 -04:00
Ryan Hughes 9f4e5feef0 Refactor ISO build for the package-backed install flow
The ISO no longer carries a cp -r'd copy of the omarchy git checkout
into /root/omarchy and the target /home/$user/.local/share/omarchy.
Instead it builds omarchy-installer + omarchy-settings + omarchy +
omarchy-limine from mounted source trees (${OMARCHY_INSTALLER_PATH:-
../omarchy-installer} and ${OMARCHY_PKGS_PATH:-../omarchy-pkgs}),
drops them in the offline mirror, and pacstraps omarchy-installer
during the target install — which deps-in the other three.

- builder/build-omarchy-packages.sh: new. Builds the four packages
  inside the ISO build container using each PKGBUILD's OMARCHY_SRC
  override (introduced in Chunk 2). Creates a 'builder' user because
  makepkg refuses to run as root. Drops the resulting .pkg.tar.zst
  files into the offline-mirror directory.

- builder/build-iso.sh: drop the git clone / -v omarchy mount path;
  call build-omarchy-packages.sh first; filter out the locally-built
  package names from the rest-of-mirror download so we don't try to
  fetch them from the network mirror.

- configs/airootfs/root/.automated_script.sh: replace the install_omarchy
  cp-and-source dance with 'arch-chroot pacman -S omarchy-installer'
  followed by 'OMARCHY_INSTALL_MODE=offline omarchy-install'. Drop the
  big chown/chmod block that was setting up the cloned tree.
  use_omarchy_helpers extracts the installer's helpers/ tree directly
  from the offline-mirror's omarchy-installer-*.pkg.tar.zst so the live
  ISO has access to gum-styled logging before pacstrap.

- bin/omarchy-iso-make: always mount omarchy-installer and omarchy-pkgs
  (paths configurable via OMARCHY_INSTALLER_PATH / OMARCHY_PKGS_PATH;
  default to ../omarchy-installer and ../omarchy-pkgs). --local-source
  is now a back-compat no-op.
2026-05-20 21:29:40 -04:00
96 changed files with 27808 additions and 1763 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: ["*"]
+118
View File
@@ -0,0 +1,118 @@
name: Install test comment
# Posts the install test's numbers on the pull request as one comment, kept
# up to date on every run, next to quattro's latest numbers. GitHub runs
# workflow_run workflows from the default branch, with write access even for
# pull requests from forks, so this job never checks out or runs pull request
# code: it only reads the finished run's artifact, as data.
on:
workflow_run:
workflows: ["Install test"]
types: [completed]
permissions:
actions: read
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-26.04
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
steps:
# A rerun keeps earlier attempts' artifacts under the same run, so take
# exactly the attempt that finished (and the baseline's latest attempt).
- name: Fetch this run's numbers and quattro's latest
env:
ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
run: |
gh run download "$RUN_ID" -R "$REPO" -n "install-test-$RUN_ID-$ATTEMPT" -D pr || true
base=$(gh run list -R "$REPO" --workflow install-test.yml --branch quattro --event push \
--status success -L 1 --json databaseId -q '.[0].databaseId')
if [ -n "$base" ]; then
base_attempt=$(gh api "repos/$REPO/actions/runs/$base" -q .run_attempt)
gh run download "$base" -R "$REPO" -n "install-test-$base-$base_attempt" -D base || true
fi
- name: Comment on the pull request
run: |
set -euo pipefail
marker='<!-- omarchy-install-test -->'
# The number comes from the pull request's own run, so trust it only
# when that pull request's head is the commit this run tested.
pr=$({ find pr -name pr-number -exec cat {} + 2>/dev/null || true; } | head -1 | tr -dc 0-9)
[ -n "$pr" ] || { echo "No pull request number in the artifact"; exit 0; }
[ "$(gh api "repos/$REPO/pulls/$pr" -q .head.sha)" = "$HEAD_SHA" ] ||
{ echo "PR #$pr has moved past $HEAD_SHA; not commenting"; exit 0; }
# Every lookup prints nothing rather than failing, so a missing baseline (none
# yet, or expired) shows as "–" instead of ending the script under set -e.
timing() { { find "$1" -name omarchy-install-timing.json 2>/dev/null || true; } | head -1; }
install_s() { [ -n "$1" ] || return 0; jq -r '.finished_at - .started_at' "$1" 2>/dev/null || true; }
# "Startup finished in … = 1min 2.3s" or "= 12.104s" -> seconds
boot_s() {
local f
f=$({ find "$1" -path "$2" 2>/dev/null || true; } | head -1)
[ -n "$f" ] || return 0
head -1 "$f" | sed -n 's/.*= *//p' |
awk '{t=0; for (i=1;i<=NF;i++) { if ($i ~ /min$/) t+=60*$i; else if ($i ~ /ms$/) t+=$i/1000; else t+=$i } printf "%.3f", t}'
}
cell() { [ -n "$1" ] && awk -v x="$1" 'BEGIN{printf "%.1f s", x}' || echo "–"; }
change() {
[ -n "$1" ] && [ -n "$2" ] || { echo "–"; return; }
awk -v a="$1" -v b="$2" 'BEGIN{d=a-b; printf "%s%.1f s", (d>=0?"+":"−"), (d<0?-d:d)}'
}
pt=$(timing pr); bt=$(timing base)
rows=(
"Install|$(install_s "$pt")|$(install_s "$bt")"
"First boot|$(boot_s pr '*/first-boot-systemd-analyze.txt')|$(boot_s base '*/first-boot-systemd-analyze.txt')"
"Everyday boot|$(boot_s pr '*-boot/systemd-analyze.txt')|$(boot_s base '*-boot/systemd-analyze.txt')"
)
status="passed"; [ "$CONCLUSION" = success ] || status="**${CONCLUSION}**"
{
echo "$marker"
echo "### Install test $status"
echo
echo "| | This PR | quattro | Change |"
echo "| --- | ---: | ---: | ---: |"
for row in "${rows[@]}"; do
IFS='|' read -r name this base_v <<<"$row"
echo "| $name | $(cell "$this") | $(cell "$base_v") | $(change "$this" "$base_v") |"
done
if [ -n "$pt" ]; then
echo
echo "<details><summary>Install phases</summary>"
echo
echo "| Phase | This PR | quattro |"
echo "| --- | ---: | ---: |"
# Phase names come from the pull request's installer: keep them to plain text.
jq -r --slurpfile b <( [ -n "$bt" ] && cat "$bt" || echo '{"phases":[]}' ) '
($b[0].phases | map({key: .name, value: .elapsed}) | from_entries) as $base
| .phases[]
| "| \(.name | gsub("[^A-Za-z0-9 +&/().-]"; "") | .[0:60]) | \(.elapsed * 10 | round / 10) s | \(($base[.name] // null) | if . == null then "–" else "\(. * 10 | round / 10) s" end) |"
' "$pt"
echo
echo "</details>"
fi
echo
echo "[Run]($RUN_URL) · ${HEAD_SHA:0:8}"
} > comment.md
existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \
-q ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -1)
if [ -n "$existing" ]; then
gh api -X PATCH "repos/$REPO/issues/comments/$existing" -F body=@comment.md >/dev/null
else
gh api "repos/$REPO/issues/$pr/comments" -F body=@comment.md >/dev/null
fi
cat comment.md
+134
View File
@@ -0,0 +1,134 @@
name: Install test
# Builds the ISO a pull request produces, installs it unattended in a KVM
# guest, boots the installed system again, and reports how long the install
# and both boots took. Every later change to the installer is measured
# against these numbers in its own PR.
on:
pull_request:
# Each merge records quattro's numbers, which PR comments compare against.
push:
branches: [quattro]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: install-test-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
install:
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# 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@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 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 \
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
qemu-system-x86_64 --version | head -1
./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800
'
# The harness ran as root in its container: test-runs and what QEMU
# wrote there (the serial logs) belong to root until this step.
- name: Hand the results to the runner's user
if: always()
run: |
mkdir -p test-runs
sudo chown -R "$(id -u):$(id -g)" test-runs
- name: Report times
if: always()
run: |
base=$(ls -d test-runs/*-integration 2>/dev/null | head -1)
[ -n "$base" ] || { echo "No test run to report" >> "$GITHUB_STEP_SUMMARY"; exit 0; }
{
echo "## Install test"
echo
if [ -f "$base/omarchy-install-timing.json" ]; then
jq -r '"**Install:** \((.finished_at - .started_at) * 10 | round / 10) s across \(.phases | length) phases"' "$base/omarchy-install-timing.json"
echo
echo "| Phase | Seconds |"
echo "| --- | ---: |"
jq -r '.phases[] | "| \(.name) | \(.elapsed * 10 | round / 10) |"' "$base/omarchy-install-timing.json"
echo
fi
if [ -f "$base/first-boot-systemd-analyze.txt" ]; then
echo "**First boot:** $(head -1 "$base/first-boot-systemd-analyze.txt")"
echo
fi
boot_dir=$(ls -d "$base"/runs/*-boot 2>/dev/null | tail -1)
if [ -n "$boot_dir" ] && [ -f "$boot_dir/systemd-analyze.txt" ]; then
echo "**Everyday boot:** $(head -1 "$boot_dir/systemd-analyze.txt")"
echo
echo "System state: $(jq -r .system_state "$boot_dir/boot-timing.json" 2>/dev/null)"
fi
} >> "$GITHUB_STEP_SUMMARY"
# The comment workflow runs from quattro and reads only this artifact;
# it needs the PR number, which a workflow_run event omits for forks.
- name: Record the pull request
if: always() && github.event_name == 'pull_request'
run: echo "${{ github.event.pull_request.number }}" > test-runs/pr-number
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: install-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
test-runs/pr-number
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
if-no-files-found: ignore
retention-days: 14
+315 -34
View File
@@ -1,41 +1,322 @@
name: Nightly ISO Build 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: on:
schedule: schedule:
- cron: '1 0 * * *' # Daily at 00:01 UTC - 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: jobs:
build-iso: # Which channels this run makes, under which version, and where each goes:
runs-on: ubuntu-latest # test/vm-image/nightly-plan decides, from what is fetched here.
plan:
container: name: Plan
image: archlinux/archlinux:latest runs-on: ubuntu-26.04
options: --privileged timeout-minutes: 5
outputs:
channels: ${{ steps.plan.outputs.channels }}
steps: steps:
- name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v4
- id: plan
- name: Set up Docker Buildx env:
uses: docker/setup-buildx-action@v3 GH_TOKEN: ${{ github.token }}
run: |
- name: Build ISO in Arch Linux container set -euo pipefail
run: | release=$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name)
docker run --rm \ published=$(curl -fsL "$SITE/stable/latest.json?run=$GITHUB_RUN_ID" | jq -r .version || true)
--privileged \ channels=$(./test/vm-image/nightly-plan "$(date -u +%Y.%m.%d)" "${{ github.run_number }}" "${{ github.run_attempt }}" \
-v "${{ github.workspace }}:/workspace" \ "$GITHUB_EVENT_NAME" "$release" "$published")
-w /workspace \ echo "channels=$channels" | tee -a "$GITHUB_OUTPUT"
archlinux:latest \
bash -c " build:
pacman -Syu --noconfirm && \ name: ${{ matrix.channel }} ${{ matrix.version }}
chmod +x build_iso.sh && \ needs: plan
./build_iso.sh strategy:
" fail-fast: false
matrix:
- name: Upload ISO artifact include: ${{ fromJSON(needs.plan.outputs.channels) }}
uses: actions/upload-artifact@v4 runs-on: namespace-profile-linux-amd64-16x32
with: timeout-minutes: 90
name: omarchy-iso-${{ github.run_number }} env:
path: out/*.iso CHANNEL: ${{ matrix.channel }}
retention-days: 30 VERSION: ${{ matrix.version }}
RELEASE: ${{ matrix.release }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Runner
run: |
uname -srm
echo "$(nproc) CPUs, $(free -h | awk 'NR==2 {print $2}') memory"
grep -m1 'model name' /proc/cpuinfo || true
echo "LSMs: $(cat /sys/kernel/security/lsm 2>/dev/null || echo unreadable)"
ls -l /dev/kvm
- name: Check disk space
run: |
df -h /
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:
name: nightly-${{ matrix.channel }}-${{ github.run_id }}
path: out/
compression-level: 0
retention-days: 2
if-no-files-found: error
- name: Keep the install test's logs
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
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"
+62
View File
@@ -0,0 +1,62 @@
name: Unit tests
# The fast, VM-free tests under test/unit/: cidata autoinstall loading and the
# orchestrator's phases. Seconds per run, no ISO build; the install test
# covers the rest.
on:
pull_request:
push:
branches: [quattro]
workflow_dispatch:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-26.04
timeout-minutes: 10
steps:
- 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
# because the keyboard tests ask the real localectl for its keymaps, and
# the tests run as an ordinary user because some of them check what
# happens when a file cannot be written.
#
# A privileged container shares the host's consoles and devices, so the
# units that would claim them are masked before systemd starts: without
# that, its getty, udev and vconsole setup take over the host's.
- name: Run ./test/all in an Arch container
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" 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
exec /usr/lib/systemd/systemd
'
docker exec unit bash -euo pipefail -c '
for i in $(seq 1 60); do
state=$(systemctl is-system-running 2>/dev/null || true)
case $state in running | degraded) break ;; esac
sleep 1
done
echo "systemd: $state"
# 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
cd /home/tester/omarchy-iso
setpriv --reuid=tester --regid=tester --init-groups ./test/all
'
+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
+7
View File
@@ -1,2 +1,9 @@
release/** release/**
vm-saves/** vm-saves/**
# Python bytecode (orchestrator)
__pycache__/
*.pyc
# Acceptance test harness artifacts
test-runs/**
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Anton Hvornum Copyright (c) 2026 Anton Hvornum
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+273 -13
View File
@@ -1,38 +1,298 @@
# Omarchy ISO # Omarchy ISO
The Omarchy ISO streamlines [the installation of Omarchy](https://learn.omacom.io/2/the-omarchy-manual/50/getting-started). It includes the Omarchy Configurator as a front-end to archinstall and automatically launches the [Omarchy Installer](https://github.com/basecamp/omarchy) after base arch has been setup. [![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
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
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"
```
Use the `.sig` file to verify the ISO against the Omarchy signing key.
### More in the manual
- [Getting started](https://omarchy.org/manual/getting-started/): the install step by step, installing for another owner, and installing without encryption.
- [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): install Omarchy in the free space of a drive, beside Windows or another OS.
- [Unattended installs](https://omarchy.org/manual/unattended-installs/): let the ISO install itself from a configuration on a second drive, with no keyboard and no wizard.
# For maintainers
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
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
```
The firmware paths are Arch's. `snapshot=on` keeps the downloaded image unchanged. The image is a 4 to 5 GB download.
### Boot it faster, with SSH
The `unpacked/` folder next to each image holds what a direct boot needs:
| File | What it is |
| --- | --- |
| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments. |
| `vm-boot` | A script that boots the image directly into its kernel and gives it an SSH key. |
| `SHA256SUMS` | Checksums of the files above. |
A direct boot skips the firmware and the boot loader: SSH answers after about 6 seconds instead of about 20. Download the folder next to the image, then run `vm-boot`. It also needs `qemu-img` and `ssh`:
```bash
unpacked=$(curl -fsSL "$latest" | jq -r .vm.unpacked)
for file in $(curl -fsSL "$latest" | jq -r '.vm.unpacked_files[]'); do curl -fsSLO "$unpacked$file"; done
sha256sum -c SHA256SUMS
chmod +x vm-boot
./vm-boot "${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.
### Configuration files
These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`.
| File | Required | Purpose |
|------|----------|---------|
| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard |
| `user_credentials.json` | Yes | Username and password hash |
| `user_full_name.txt` | No | Git full name |
| `user_email_address.txt` | No | Git email |
| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false |
| `authorized_keys` | No | SSH public keys in sshd's own format, one per line |
| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot |
Both required files must be present or the installer falls back to the configurator. (A `defer-provisioning` marker file can stand in for `user_credentials.json`: user creation is then deferred to first boot.) Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption.
`authorized_keys` is the same file sshd reads — copy your own or write one key per line:
```
ssh-ed25519 AAAA... you@host
```
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend and Docker's DNS. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no keys in it fails the install rather than producing a machine nobody can reach.
When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs.
The region follows `timezone`, exactly as in an interactive install: a timezone listed in one of the runtime's region profiles (`default/regions/*/timezones`, such as `Asia/Shanghai` for `cn`) selects that profile's mirrors and repositories, and anything else is global. Set `omarchy_install.region` in `user_configuration.json` to pick one explicitly, for example `"cn"` on a deferred-provisioning install, which carries `UTC` until first boot. Every ISO bundles every profile's packages, so a regional install still needs no network.
### Building the drive
```bash
mkdir cidata
cp user_configuration.json user_credentials.json authorized_keys cidata/
genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/
```
### Proxmox example
```bash
qm create 101 --name my-omarchy \
--bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \
--ostype l26 --scsihw virtio-scsi-single \
--efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \
--scsi0 local-lvm:40,discard=on,iothread=1 \
--net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \
--ide2 local:iso/omarchy.iso,media=cdrom \
--ide3 local:iso/cidata.iso,media=cdrom \
--boot order='scsi0;ide2'
qm start 101
```
Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes.
Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot.
## Creating the ISO ## Creating the ISO
Run `./bin/omarchy-iso-make` and the output goes into `./release`. You can build from your local `$OMARCHY_PATH` for testing with `--local-source`, from the dev branch with `--dev`, or from the quattro branch with `--quattro`. The dev and quattro builds use the edge package mirror. 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.
### Environment Variables For local development, build the ISO from sibling checkouts:
You can customize the repositories used during the build process by passing in variables:
- `OMARCHY_INSTALLER_REPO` - GitHub repository for the installer (default: `basecamp/omarchy`)
- `OMARCHY_INSTALLER_REF` - Git ref (branch/tag) for the installer (default: `master`)
Example usage:
```bash ```bash
OMARCHY_INSTALLER_REPO="myuser/omarchy-fork" OMARCHY_INSTALLER_REF="some-feature" ./bin/omarchy-iso-make ./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 ## Testing the ISO
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
Run `./test/all` for the fast, VM-free tests under `test/unit/`, which cover cidata autoinstall loading and the orchestrator's phases without needing a built ISO.
To exercise installation alongside existing Windows-style partitions, run
`./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a
synthetic disk in `/tmp` with an existing ESP and data partition plus ample
unallocated space, then offers to start an interactive installation on it. The
fixture exercises Windows partition preservation but does not contain Windows.
## Acceptance testing the ISO
Run `./bin/omarchy-iso-test [release/omarchy.iso]` to install the ISO into a headless VM by driving the real interactive install flow — the harness reads each screen via QMP screendumps + OCR and answers with virtual keystrokes, so the configurator wizard, install dashboard, reboot prompt, and SDDM login are all exercised exactly as a user would. It then boots the installed system, sends real VM keyboard shortcuts for the primary shell and window-management actions, and runs the in-guest acceptance suite (`test/acceptance` in the omarchy repo). The suite checks session and service health, the complete core-package manifest, user defaults, representative applications, menus, panels, live weather, launchers, visual selectors, notifications, clipboard, and other interactive shell behavior.
Visual checkpoints are saved as `success-<step>.png` or `failure-<step>.png` alongside the serial and install logs in `test-runs/<iso>/runs/<timestamp>/`. Independent test files and applications continue after a failure so one broken surface does not hide the rest of the report. The harness then stops the VM and opens the ordered screenshots in `imv` for quick visual review.
The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available. The install phase produces a reusable base image, so iterating against another checkout is fast:
```bash
./bin/omarchy-iso-test release/omarchy.iso --install-only # once per ISO
./bin/omarchy-iso-test release/omarchy.iso --reuse-base \
--sync-omarchy ../omarchy # fast loop against local tests
```
Pass `--encrypt` to drive the encrypted install flow (including typing the LUKS passphrase at boot) instead of the unencrypted one. Pass `--no-preview` to collect the same visual artifacts without opening them in `imv` when the run finishes.
## Integration testing the ISO
Scenarios under `test/integration.d/` boot a real ISO install in QEMU and assert on what the running system actually does. The runner installs the ISO once — unattended, from a generated cidata drive — and saves the result as a reusable base image; every scenario then boots a throwaway overlay of that base with its own copy of the firmware vars, so neither disk nor NVRAM state leaks between runs. Shared machinery (VM lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest SSH, the cidata build) lives in `test/integration.d/base-test.sh`, so a new scenario is one file.
```bash
./test/integration release/omarchy.iso # install once, run all scenarios
./test/integration release/omarchy.iso --reuse-base # fast loop against the saved base
./test/integration release/omarchy.iso factory-reset # a single named scenario
```
The first scenario is `factory-reset`: it proves `omarchy-system-factory-reset` hands a machine on without destroying a shared ESP. The installed ESP gets a Windows entry with payload plus a second Linux cloned under a foreign machine-id with its own boot directory and UKIs; a real factory reset is then driven through a guest pty, and the harness asserts the foreign entries survive both the staged reset and first-boot provisioning, that the old Omarchy identity is fully retired, and that the machine reaches first-boot setup unattended.
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 ## 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. Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI.
## Uploading the ISO ## Uploading the ISO
Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires you've configured rclone (use `rclone config`). Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). The `.sig` and `.sha256` sidecars go up with the ISO when they exist beside it.
## Full release of the ISO ## Full release of the ISO
+123 -15
View File
@@ -2,19 +2,113 @@
set -e set -e
ISO="$1" ISO=""
REUSE="$2" REUSE=""
SSH_HOST="${OMARCHY_VM_SSH_HOST:-127.0.0.1}"
SSH_PORT="${OMARCHY_VM_SSH_PORT:-2222}"
NETWORK="${OMARCHY_VM_NETWORK:-1}"
DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
OVMF_VARS="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
EXTRA_QEMU_ARGS=()
if [ $# -eq 0 ]; then usage() {
cat <<USAGE
Usage: omarchy-iso-boot [options] [release/omarchy.iso] [reuse]
Options:
--reuse Reuse the existing VM disk
--ssh-port PORT Forward host PORT to guest port 22 (default: $SSH_PORT)
--ssh-host HOST Host address for SSH forwarding (default: $SSH_HOST)
--no-network Boot without a virtual NIC
--disk PATH VM disk path (default: $DISK)
--ovmf-vars PATH OVMF vars path (default: $OVMF_VARS)
-h, --help Show this help
-- Pass the remaining arguments directly to QEMU
SSH access after starting sshd in the guest:
ssh -p $SSH_PORT root@localhost
scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/
USAGE
}
while (( $# > 0 )); do
case "$1" in
--reuse|reuse)
REUSE="reuse"
shift
;;
--ssh-port)
SSH_PORT="${2:-}"
shift 2
;;
--ssh-port=*)
SSH_PORT="${1#*=}"
shift
;;
--ssh-host)
SSH_HOST="${2:-}"
shift 2
;;
--ssh-host=*)
SSH_HOST="${1#*=}"
shift
;;
--no-network|--no-net)
NETWORK=0
shift
;;
--disk)
DISK="${2:-}"
shift 2
;;
--disk=*)
DISK="${1#*=}"
shift
;;
--ovmf-vars)
OVMF_VARS="${2:-}"
shift 2
;;
--ovmf-vars=*)
OVMF_VARS="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
EXTRA_QEMU_ARGS+=("$@")
break
;;
*)
if [[ -z $ISO ]]; then
ISO="$1"
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 1
fi
shift
;;
esac
done
if [[ -z $ISO && -t 0 ]]; then
ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to boot") ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to boot")
if gum confirm --default=false "Reuse existing disk?"; then if gum confirm --default=false "Reuse existing disk?"; then
REUSE="reuse" REUSE="reuse"
fi fi
fi fi
if [ -z "$ISO" ]; then if [[ -z $ISO ]]; then
echo "Usage: omarchy-iso-boot [release/omarchy.iso] [reuse]" usage
echo " SSH available at: ssh -p 2222 root@localhost (after installing openssh)" exit 1
fi
if [[ -z $DISK || -z $OVMF_VARS ]]; then
echo "Disk and OVMF vars paths must not be empty" >&2
exit 1 exit 1
fi fi
@@ -36,26 +130,40 @@ if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
exit 1 exit 1
fi fi
DISK="/tmp/omarchy-iso-boot.qcow2"
if [[ ! -f $DISK || $REUSE != "reuse" ]]; then if [[ ! -f $DISK || $REUSE != "reuse" ]]; then
qemu-img create -f qcow2 "$DISK" 40G qemu-img create -f qcow2 "$DISK" 40G
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd /tmp/OVMF_VARS.4m.fd cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$OVMF_VARS"
fi
NET_ARGS=()
if [[ $NETWORK != "0" && $NETWORK != "false" && $NETWORK != "no" ]]; then
if [[ -n $SSH_PORT && $SSH_PORT != "0" ]]; then
NET_ARGS=(-netdev "user,id=net0,hostfwd=tcp:${SSH_HOST}:${SSH_PORT}-:22" -device virtio-net-pci,netdev=net0)
echo "SSH forwarding enabled: ssh -p $SSH_PORT root@localhost"
echo "SCP example: scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/"
else
NET_ARGS=(-netdev user,id=net0 -device virtio-net-pci,netdev=net0)
echo "Networking enabled without SSH port forwarding."
fi
else
echo "Networking disabled."
fi fi
# Boot from ISO before we've installed, then boot from drive after # Boot from ISO before we've installed, then boot from drive after
qemu-system-x86_64 \ qemu-system-x86_64 \
-cpu host -enable-kvm -machine q35,accel=kvm \ -cpu host -enable-kvm -machine q35,accel=kvm \
-smp "$(nproc)" \ -smp "$(($(nproc) < 8 ? $(nproc) : 8))" \
-m 8192 \ -m 8192 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \ -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
-drive if=pflash,format=raw,file=/tmp/OVMF_VARS.4m.fd \ -drive if=pflash,format=raw,file="$OVMF_VARS" \
-drive file="$DISK",format=qcow2,if=none,id=drive0 \ -drive file="$DISK",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0,bootindex=1 \ -device virtio-blk-pci,drive=drive0,bootindex=1 \
-drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \ -drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \
-device ide-cd,drive=cdrom0,bootindex=2 \ -device ide-cd,drive=cdrom0,bootindex=2 \
-device virtio-vga \ -device virtio-vga-gl \
-display gtk \ -display sdl,gl=on \
-usb -device usb-tablet \ -usb -device usb-tablet \
-netdev user,id=net0,hostfwd=tcp::2222-:22 \ -serial none \
-device virtio-net-pci,netdev=net0 \ "${NET_ARGS[@]}" \
-boot menu=on -boot menu=on \
"${EXTRA_QEMU_ARGS[@]}"
+8 -19
View File
@@ -3,25 +3,14 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cleanup() { if [[ -z "${OMARCHY_PATH:-}" ]]; then
[[ -n "${mock_omarchy_install:-}" ]] && rm -rf "$(dirname "$mock_omarchy_install")" for candidate in "$repo_root/../omarchy-installer" "$repo_root/../omarchy" /usr/share/omarchy; do
} if [[ -f "$candidate/logo.txt" ]]; then
trap cleanup EXIT export OMARCHY_PATH="$candidate"
break
if [[ -z "${OMARCHY_INSTALL:-}" ]]; then fi
if [[ -r /root/omarchy/install/helpers/all.sh ]]; then done
export OMARCHY_INSTALL=/root/omarchy/install
else
mock_root="$(mktemp -d)"
mock_omarchy_install="$mock_root/install"
mkdir -p "$mock_omarchy_install/helpers"
cat >"$mock_omarchy_install/helpers/all.sh" <<'EOF'
PADDING_LEFT=0
PADDING_LEFT_SPACES=""
clear_logo() { clear; }
EOF
export OMARCHY_INSTALL="$mock_omarchy_install"
fi
fi fi
# Do a dry-run of the configurator to show the flow without having to do a real installation
exec bash "$repo_root/configs/airootfs/root/configurator" dry "$@" exec bash "$repo_root/configs/airootfs/root/configurator" dry "$@"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Preview the shipped Omarchy installer dashboard without building an ISO.
set -euo pipefail
# The dashboard drives the package phase from the target's pacman database, so
# fake one here; without it the preview falls back to its time curve and the bar
# barely moves, which reads as a bug.
PREVIEW_PACKAGES="${PREVIEW_PACKAGES:-925}"
write_simulation_state() {
local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4"
local target="$5" index="$6" total="$7"
local finished_at="${8:-null}" temporary="${state_file}.tmp"
jq -n \
--argjson started_at "$started_at" \
--argjson phase_started_at "$phase_started_at" \
--arg current_phase "$current_phase" \
--arg target "$target" \
--argjson current_index "$index" \
--argjson total_phases "$total" \
--argjson finished_at "$finished_at" \
'{
started_at: $started_at,
phase_started_at: $phase_started_at,
current_phase: $current_phase,
target: $target,
current_index: $current_index,
total_phases: $total_phases
} + if $finished_at == null then {} else {finished_at: $finished_at} end' \
>"$temporary"
mv -f -- "$temporary" "$state_file"
}
# Fill the fake database over the phase's duration, so the bar is driven by the
# same count it uses on a real install.
simulate_packages() {
local db="$1" duration="$2" total="$3" steps=25 i n
for ((i = 1; i <= steps; i++)); do
n=$(( total * i / steps ))
while (( $(ls -1 "$db" 2>/dev/null | wc -l) < n )); do
mkdir -p "$db/preview-$(( $(ls -1 "$db" 2>/dev/null | wc -l) ))-1.0-1"
done
sleep "$(awk -v d="$duration" -v s="$steps" 'BEGIN { printf "%.3f", d / s }')"
done
}
simulate_install() {
local state_file="$1"
local started_at="$EPOCHREALTIME"
local phase phase_started_at finished_at duration i index total
local target db
target="$(dirname -- "$state_file")/target"
db="$target/var/lib/pacman/local"
mkdir -p "$db"
local -a phases=(
"Starting installation" 0.5
"Preparing live environment" 0.8
"Preparing install target" 0.3
"Installing Arch + Omarchy" 5.0
"Configuring hibernation" 0.4
"Configuring system" 1.2
"Finalizing Limine boot" 1.0
"Finalizing user" 1.0
"Configuring login" 0.6
"Configuring DNS resolver" 0.3
"Validating boot setup" 0.8
)
total=$(( ${#phases[@]} / 2 ))
for ((i = 0; i < ${#phases[@]}; i += 2)); do
phase="${phases[i]}"
duration="${phases[i + 1]}"
index=$(( i / 2 ))
phase_started_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$phase_started_at" \
"$phase" "$target" "$index" "$total"
if [[ $phase == "Installing Arch + Omarchy" ]]; then
simulate_packages "$db" "$duration" "$PREVIEW_PACKAGES"
else
sleep "$duration"
fi
done
finished_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$finished_at" \
"Installation complete" "$target" "$(( total - 1 ))" "$total" "$finished_at"
sleep 0.3
}
if [[ ${1:-} == "--simulate-install" ]]; then
(( $# == 2 )) || exit 2
simulate_install "$2"
exit 0
fi
if (( $# > 0 )); then
echo "Usage: $0" >&2
exit 2
fi
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
dashboard="$repo_root/configs/airootfs/usr/local/bin/omarchy-install-dashboard"
if [[ ! -x $dashboard ]]; then
echo "Installer dashboard is not executable: $dashboard" >&2
exit 1
fi
if [[ -z ${OMARCHY_PATH:-} ]]; then
for candidate in "$repo_root/../omarchy" /usr/share/omarchy; do
if [[ -f $candidate/logo.txt ]]; then
export OMARCHY_PATH="$candidate"
break
fi
done
fi
if [[ -z ${OMARCHY_PATH:-} || ! -f $OMARCHY_PATH/logo.txt ]]; then
echo "Unable to find Omarchy logo.txt; set OMARCHY_PATH to an Omarchy checkout." >&2
exit 1
fi
preview_dir="$(mktemp -d)"
cleanup() {
rm -rf "$preview_dir"
}
trap cleanup EXIT
log_file="$preview_dir/install.log"
state_file="$preview_dir/state.json"
touch "$log_file"
# Match what the simulated database will reach, so the preview shows the
# package-driven bar rather than the missing-signal fallback.
printf '%s\n' "$PREVIEW_PACKAGES" >"$preview_dir/expected-packages"
OMARCHY_UI_AUTO_REBOOT=no \
OMARCHY_EXPECTED_PACKAGES_FILE="$preview_dir/expected-packages" \
PREVIEW_PACKAGES="$PREVIEW_PACKAGES" \
"$dashboard" "$log_file" "$state_file" -- \
"$0" --simulate-install "$state_file"
+98 -28
View File
@@ -1,27 +1,47 @@
#!/bin/bash #!/bin/bash
# Abort if anything fails
set -e set -e
# Parse command line arguments
NO_CACHE="" NO_CACHE=""
KEEP_PKG_CACHE=""
LOCAL_OMARCHY_PATH=""
LOCAL_PKGS_PATH=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--no-cache) --no-cache)
NO_CACHE=1 NO_CACHE=1
shift shift
;; ;;
--keep-pkg-cache)
KEEP_PKG_CACHE=1
shift
;;
--no-boot-offer) --no-boot-offer)
NO_BOOT_OFFER=1 NO_BOOT_OFFER=1
shift shift
;; ;;
--local-source) --debug)
LOCAL_SOURCE=1 OMARCHY_INSTALL_DEBUG=1
shift
;;
--edge)
OMARCHY_ISO_REF=edge
OMARCHY_MIRROR=edge OMARCHY_MIRROR=edge
shift shift
;; ;;
--local-source)
LOCAL_OMARCHY_PATH="${2:-}"
LOCAL_PKGS_PATH="${3:-}"
if [[ -z $LOCAL_OMARCHY_PATH || -z $LOCAL_PKGS_PATH ]]; then
echo "Error: --local-source requires two args: <omarchy-checkout> <pkgs-checkout>" >&2
exit 1
fi
OMARCHY_MIRROR=edge
OMARCHY_ISO_REF=local
shift 3
;;
--dev) --dev)
OMARCHY_INSTALLER_REF=dev OMARCHY_ISO_REF=dev
OMARCHY_MIRROR=edge OMARCHY_MIRROR=edge
shift shift
;; ;;
@@ -31,40 +51,81 @@ while [[ $# -gt 0 ]]; do
shift shift
;; ;;
--rc) --rc)
OMARCHY_ISO_REF=rc
OMARCHY_MIRROR=rc OMARCHY_MIRROR=rc
OMARCHY_INSTALLER_REF=rc
shift shift
;; ;;
*) *)
echo "Unknown option: $1" echo "Unknown option: $1"
echo "Usage: $0 [--no-cache] [--no-boot-offer] [--local-source] [--dev] [--quattro] [--rc]" echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
exit 1 exit 1
;; ;;
esac esac
done done
# Clear out old packages if [[ -n $LOCAL_OMARCHY_PATH ]]; then
sudo rm -rf /var/cache/pacman/pkg/* [[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; }
[[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; }
LOCAL_OMARCHY_PATH=$(realpath "$LOCAL_OMARCHY_PATH")
LOCAL_PKGS_PATH=$(realpath "$LOCAL_PKGS_PATH")
fi
# Ensure we have the submodule checked out lint_file_permissions() {
local profiledef="configs/profiledef.sh"
local -A declared=()
local missing=()
while IFS= read -r path; do
[[ -n $path ]] && declared["$path"]=1
done < <(grep -oP '^\s*\["\K[^"]+(?="\])' "$profiledef" || true)
while IFS= read -r f; do
[[ -f $f ]] || continue
local rel="${f#configs/airootfs}"
local mode
mode=$(git ls-files --stage -- "$f" | awk '{print $1}')
if [[ $mode != 100755 && $(head -c 2 "$f") != "#!" ]]; then
continue
fi
if [[ -z ${declared[$rel]+x} ]]; then
missing+=("$rel")
fi
done < <(find configs/airootfs/usr/local/bin configs/airootfs/root -type f 2>/dev/null)
if (( ${#missing[@]} > 0 )); then
echo "ERROR: the following executable files are missing from $profiledef file_permissions:" >&2
printf ' %s\n' "${missing[@]}" >&2
echo "Add entries like [\"<path>\"]=\"0:0:755\" to profiledef.sh and retry." >&2
exit 1
fi
}
lint_file_permissions
# Purging the host package cache needs interactive sudo; --keep-pkg-cache
# skips it so unattended builds (test rigs, agents) can run without a prompt.
if [[ -z $KEEP_PKG_CACHE ]]; then
sudo rm -rf /var/cache/pacman/pkg/*
fi
git submodule update --init --recursive --jobs=8 git submodule update --init --recursive --jobs=8
# Prepare build repo
BUILD_SCRIPT="builder/build-iso.sh" BUILD_SCRIPT="builder/build-iso.sh"
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
BUILD_RELEASE_PATH="$BUILD_ROOT/release" BUILD_RELEASE_PATH="$BUILD_ROOT/release"
mkdir -p "$BUILD_RELEASE_PATH" mkdir -p "$BUILD_RELEASE_PATH"
OMARCHY_INSTALLER_REPO="${OMARCHY_INSTALLER_REPO:-basecamp/omarchy}" OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_INSTALLER_REF="${OMARCHY_INSTALLER_REF:-master}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
DOCKER_ARGS=( DOCKER_ARGS=(
--rm --rm
--privileged --privileged
-e "OMARCHY_INSTALLER_REPO=$OMARCHY_INSTALLER_REPO" -e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF"
-e "OMARCHY_INSTALLER_REF=$OMARCHY_INSTALLER_REF"
-e "OMARCHY_MIRROR=$OMARCHY_MIRROR" -e "OMARCHY_MIRROR=$OMARCHY_MIRROR"
-e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}"
-e "HOST_UID=$(id -u)" -e "HOST_UID=$(id -u)"
-e "HOST_GID=$(id -g)" -e "HOST_GID=$(id -g)"
-v "$BUILD_RELEASE_PATH/:/out/" -v "$BUILD_RELEASE_PATH/:/out/"
@@ -74,13 +135,14 @@ DOCKER_ARGS=(
-v "$BUILD_ROOT/configs:/configs:ro" -v "$BUILD_ROOT/configs:/configs:ro"
) )
# Use local source of Omarchy from $OMARCHY_PATH instead of git checkout if [[ -n $LOCAL_OMARCHY_PATH ]]; then
if [[ -n "${LOCAL_SOURCE-}" ]]; then DOCKER_ARGS+=(
DOCKER_ARGS+=(-v "$OMARCHY_PATH:/omarchy:ro") -v "$LOCAL_OMARCHY_PATH:/omarchy-source:ro"
-v "$LOCAL_PKGS_PATH:/omarchy-pkgs:ro"
)
fi fi
# Use local pacman cache if you already have one on host to speed up repeat runs if [[ -d /var/cache/pacman/pkg ]]; then
if [ -d "/var/cache/pacman/pkg" ]; then
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg") DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
fi fi
@@ -89,20 +151,28 @@ fi
if [[ -z "$NO_CACHE" ]]; then if [[ -z "$NO_CACHE" ]]; then
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy" OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR" mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
# Mount to /var/cache/airootfs/var/cache/omarchy since that's where repo packages go in the ISO
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy") DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
else
# The locally cached :latest image is a cache too — docker run never
# re-checks the registry for it, so refresh it when building cold.
DOCKER_ARGS+=(--pull=always)
fi fi
# Run the builder with assembled args # The docker group is root-equivalent, so Omarchy operators stay out of it on
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT # purpose. When the socket refuses the calling user, run the client through
# sudo instead of requiring group membership.
DOCKER=(docker)
if ! docker version &>/dev/null; then
DOCKER=(sudo docker)
fi
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" ghcr.io/archlinux/archlinux:latest /$BUILD_SCRIPT
# Move as install ref
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1) latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
iso_ref="${latest_iso%.*}-$OMARCHY_INSTALLER_REF.iso" iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
mv -f "$latest_iso" "$iso_ref" mv -f "$latest_iso" "$iso_ref"
# Offer to boot the new build if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
if [[ -z "$NO_BOOT_OFFER" ]]; then
echo echo
gum confirm "Boot $iso_ref?" && omarchy-iso-boot "$iso_ref" gum confirm "Boot $iso_ref?" && "$BUILD_ROOT/bin/omarchy-iso-boot" "$iso_ref"
fi fi
+35 -6
View File
@@ -1,7 +1,12 @@
#!/bin/bash #!/bin/bash
# A failed step must stop the release: without this, a make that dies (say,
# docker permission denied) falls through to signing and uploading whatever
# stale ISO release/ already holds, under the new release's name.
set -e
MAKE_ARGS=() MAKE_ARGS=()
INSTALLER_REF=master ISO_REF=quattro
RELEASE_SUFFIX="" RELEASE_SUFFIX=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -12,7 +17,7 @@ while [[ $# -gt 0 ]]; do
;; ;;
--rc) --rc)
MAKE_ARGS+=("$1") MAKE_ARGS+=("$1")
INSTALLER_REF=rc ISO_REF=rc
RELEASE_SUFFIX="-rc" RELEASE_SUFFIX="-rc"
shift shift
;; ;;
@@ -37,26 +42,50 @@ if [[ -z "${BUILD_VERSION-}" ]]; then
exit 1 exit 1
fi fi
# A version that already numbers its candidate (4.0.2rc2) is the complete
# artifact name; the bare -rc suffix exists only for an unnumbered candidate.
# Without this, every candidate of a train uploads over the same
# omarchy-X.Y.Z-rc.iso and testers cannot tell which rc they are holding.
if [[ $BUILD_VERSION == *rc* ]]; then
RELEASE_SUFFIX=""
fi
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
BUILD_RELEASE_PATH="$BUILD_ROOT/release" BUILD_RELEASE_PATH="$BUILD_ROOT/release"
# Make ISOs # Make ISOs
if [[ ! " ${MAKE_ARGS[*]} " =~ " --no-make " ]]; then if [[ ! " ${MAKE_ARGS[*]} " =~ " --no-make " ]]; then
echo -e "\e[32mMaking $INSTALLER_REF ISO\e[0m" echo -e "\e[32mMaking $ISO_REF ISO\e[0m"
omarchy-iso-make --no-cache --no-boot-offer "${MAKE_ARGS[@]}" omarchy-iso-make --no-cache --no-boot-offer "${MAKE_ARGS[@]}"
else else
echo -e "\e[31mSkipping remaking ISOs\e[0m" echo -e "\e[31mSkipping remaking ISOs\e[0m"
fi fi
# Sign and upload ISO # Sign and upload ISO
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$INSTALLER_REF".iso | head -n1) latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$ISO_REF".iso | head -n1)
release_iso="$BUILD_RELEASE_PATH/omarchy-$BUILD_VERSION$RELEASE_SUFFIX.iso" release_iso="$BUILD_RELEASE_PATH/omarchy-$BUILD_VERSION$RELEASE_SUFFIX.iso"
cp "$latest_iso" "$release_iso" cp "$latest_iso" "$release_iso"
echo -e "\e[32mSigning $INSTALLER_REF ISO\e[0m" echo -e "\e[32mSigning $ISO_REF ISO\e[0m"
omarchy-iso-sign "$release_iso" omarchy-iso-sign "$release_iso"
# The checksum sidecar names the ISO alone, never the build path: it is
# downloaded next to the ISO and has to verify with `sha256sum -c` from
# whatever directory the person who downloaded it happens to be in. A release
# that cannot write it stops here, because the sidecar left over from the last
# release reads as a checksum for this one.
iso_sha=$(sha256sum "$release_iso") iso_sha=$(sha256sum "$release_iso")
echo -e "\e[32mSHA256 for $INSTALLER_REF ISO: $iso_sha\e[0m" iso_sha=${iso_sha%% *}
if [[ -z $iso_sha ]] || ! printf '%s %s\n' "$iso_sha" "${release_iso##*/}" >"$release_iso.sha256"; then
echo -e "\e[31mCould not write the checksum for $release_iso\e[0m" >&2
exit 1
fi
echo -e "\e[32mSHA256 for $ISO_REF ISO: $iso_sha\e[0m"
omarchy-iso-upload "$release_iso" omarchy-iso-upload "$release_iso"
# The whole point of a release is handing someone this pair.
echo -e "\e[32mReleased $ISO_REF ISO $BUILD_VERSION\e[0m"
echo " URL: https://iso.omarchy.org/${release_iso##*/}"
echo " SHA256: $iso_sha"
echo " Local: $release_iso"
+1236
View File
File diff suppressed because it is too large. Load diff
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
#
# Stop the VM a `omarchy-iso-test --keep-running` run left behind: graceful
# guest poweroff over SSH first, escalating to TERM then KILL. The base image
# is untouched either way; only the throwaway run overlay is affected.
#
# Usage: omarchy-iso-test-stop [--kill] [--port PORT]
#
# --kill Skip the graceful poweroff and kill the VM immediately
# --port PORT Host port forwarded to guest SSH (default 2222)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
GUEST_USER="omarchy"
GUEST_PASSWORD="omarchy"
SSH_PORT=2222
GRACEFUL=true
while (($#)); do
case "$1" in
--kill) GRACEFUL=false ;;
--port) SSH_PORT="$2"; shift ;;
-h | --help)
sed -n '3,10p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
shift
done
# The newest run dir whose qemu is still alive is the VM to stop.
PID=""
PIDFILE=""
while IFS= read -r candidate; do
pid=$(<"$candidate")
if kill -0 "$pid" 2>/dev/null; then
PID="$pid"
PIDFILE="$candidate"
break
fi
done < <(ls -t "$REPO_DIR"/test-runs/*/runs/*/qemu.pid 2>/dev/null)
if [[ -z $PID ]]; then
echo "No running omarchy-iso-test VM found." >&2
exit 1
fi
BASE_DIR="$(dirname "$(dirname "$(dirname "$PIDFILE")")")"
SSH_KEY="$BASE_DIR/id_ed25519"
# A harness that is still driving this VM dies with it; make sure that is
# what the caller wants before pulling anything down.
if [[ -t 0 ]]; then
if pgrep -f "bin/omarchy-iso-test .*\.iso" >/dev/null; then
echo "Warning: an omarchy-iso-test run appears to be in progress."
fi
if ! gum confirm "Stop test VM (pid $PID, ${PIDFILE#$REPO_DIR/})?"; then
exit 0
fi
fi
echo "Stopping test VM (pid $PID, ${PIDFILE#$REPO_DIR/})"
if $GRACEFUL && [[ -f $SSH_KEY ]]; then
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
-o LogLevel=ERROR \
"$GUEST_USER@127.0.0.1" \
"echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1 || true
waited=0
while kill -0 "$PID" 2>/dev/null && ((waited < 15)); do
sleep 1
((waited += 1))
done
fi
if kill -0 "$PID" 2>/dev/null; then
kill "$PID" 2>/dev/null || true
sleep 1
fi
if kill -0 "$PID" 2>/dev/null; then
kill -9 "$PID" 2>/dev/null || true
sleep 1
fi
if kill -0 "$PID" 2>/dev/null; then
echo "VM (pid $PID) refused to die." >&2
exit 1
fi
echo "VM stopped; port $SSH_PORT is free."
+338
View File
@@ -0,0 +1,338 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
DISK_SIZE_GIB=96
MIN_FREE_GIB=64
RECREATE=0
GAP=0
ISO=""
ACTIVE_NBD=""
MOUNT_DIR=""
MOUNTED=0
TEMPORARY_DISK=""
usage() {
cat <<USAGE
Usage: omarchy-iso-test-windows-disk [--recreate] [--gap] [release/omarchy.iso]
Creates or validates a synthetic Windows-style test disk with:
- a 512MiB EFI partition containing EFI/Microsoft
- an existing ext4 data partition with a preservation marker
- more than 64GiB of contiguous unallocated space for Omarchy
This exercises Windows partition detection and preservation; it is not a
bootable Windows installation.
--gap builds the same fixture with a hole in the partition numbering: a third
partition is created and then deleted, leaving slot 3 free while slot 4 stays
in use. parted fills the lowest free slot, so the installer must create its
partitions as 3 and 5 and read those numbers back. The contiguous fixture
cannot catch that class of bug, because there "lowest free" and "highest + 1"
are the same number.
Options:
--recreate Replace the existing test fixture
--gap Build the numbering-hole variant (separate disk image)
-h, --help Show this help
Paths can be overridden with:
OMARCHY_ISO_TEST_WINDOWS_DISK
OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS
USAGE
}
while (( $# > 0 )); do
case "$1" in
--recreate)
RECREATE=1
shift
;;
--gap)
GAP=1
shift
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
*)
if [[ -n $ISO ]]; then
echo "Only one ISO may be specified." >&2
usage >&2
exit 2
fi
ISO="$1"
shift
;;
esac
done
# Separate images per layout so the two fixtures can coexist and neither has
# to be rebuilt to run the other.
if (( GAP )); then
TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows-gap.qcow2}"
TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows-gap.fd}"
LAYOUT_LABEL="512MiB Windows ESP + data + deleted slot 3 + data + 64GiB+ free"
else
TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows.qcow2}"
TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows.fd}"
LAYOUT_LABEL="512MiB Windows ESP + existing data + 64GiB+ free"
fi
cleanup_mount() {
if (( MOUNTED )); then
sudo umount "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNTED=0
fi
if [[ -n $MOUNT_DIR ]]; then
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNT_DIR=""
fi
}
disconnect_disk() {
cleanup_mount
if [[ -n $ACTIVE_NBD ]]; then
sudo qemu-nbd --disconnect "$ACTIVE_NBD" >/dev/null 2>&1 || true
ACTIVE_NBD=""
fi
}
cleanup() {
disconnect_disk
[[ -z $TEMPORARY_DISK ]] || rm -f "$TEMPORARY_DISK"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
find_free_nbd() {
local device
for device in /sys/class/block/nbd*; do
[[ -e $device ]] || continue
[[ ! -e $device/pid ]] || continue
printf '/dev/%s\n' "${device##*/}"
return 0
done
return 1
}
connect_disk() {
local disk="$1" mode="${2:-read-write}"
local -a options=(--format=qcow2)
ACTIVE_NBD="$(find_free_nbd)" || {
echo "No unused NBD device is available." >&2
return 1
}
[[ $mode == read-only ]] && options+=(--read-only)
if ! sudo qemu-nbd "${options[@]}" --connect="$ACTIVE_NBD" "$disk"; then
ACTIVE_NBD=""
return 1
fi
if ! sudo udevadm settle; then
disconnect_disk
return 1
fi
}
mount_partition() {
local partition="$1"
MOUNT_DIR="$(mktemp -d)" || return 1
if ! sudo mount -o "${2:-defaults}" "$partition" "$MOUNT_DIR"; then
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNT_DIR=""
return 1
fi
MOUNTED=1
}
largest_free_region() {
sudo parted -ms "$1" unit B print free </dev/null 2>/dev/null |
awk -F: '
$NF ~ /free/ {
size=$4
gsub(/B/, "", size)
if (size+0 > max+0) max=size
}
END { print max+0 }
'
}
validate_test_disk() {
local part_count pttype free_space_b min_free_b want_parts numbers
local esp data valid=1
[[ -f $TEST_DISK ]] || return 1
qemu-img check -q "$TEST_DISK" >/dev/null 2>&1 || return 1
connect_disk "$TEST_DISK" read-only || return 1
esp="${ACTIVE_NBD}p1"
data="${ACTIVE_NBD}p2"
part_count=$(lsblk -nro TYPE "$ACTIVE_NBD" | awk '$1 == "part" { count++ } END { print count+0 }')
pttype=$(lsblk -dnro PTTYPE "$ACTIVE_NBD" 2>/dev/null || true)
free_space_b=$(largest_free_region "$ACTIVE_NBD")
min_free_b=$((MIN_FREE_GIB * 1024 * 1024 * 1024))
want_parts=2
(( GAP )) && want_parts=3
[[ $pttype == gpt ]] || valid=0
(( part_count == want_parts )) || valid=0
# The whole point of the gap fixture: slot 3 free, slot 4 in use.
if (( GAP )); then
numbers=$(sudo parted -ms "$ACTIVE_NBD" unit B print 2>/dev/null | tail -n +3 | cut -d: -f1 | sort | tr '\n' ' ')
[[ $numbers == "1 2 4 " ]] || valid=0
fi
(( free_space_b >= min_free_b )) || valid=0
[[ $(sudo blkid -s TYPE -o value "$esp" 2>/dev/null || true) == vfat ]] || valid=0
[[ $(sudo blkid -s LABEL -o value "$esp" 2>/dev/null || true) == WINDOWS_ESP ]] || valid=0
[[ $(sudo blkid -s TYPE -o value "$data" 2>/dev/null || true) == ext4 ]] || valid=0
[[ $(sudo blkid -s LABEL -o value "$data" 2>/dev/null || true) == WINDOWS_DATA ]] || valid=0
if (( valid )); then
mount_partition "$esp" ro || valid=0
if (( valid )); then
sudo test -d "$MOUNT_DIR/EFI/Microsoft" || valid=0
fi
cleanup_mount
fi
if (( valid )); then
mount_partition "$data" ro || valid=0
if (( valid )); then
sudo test -f "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" || valid=0
fi
cleanup_mount
fi
disconnect_disk
(( valid ))
}
create_test_disk() {
local esp data
mkdir -p "$(dirname "$TEST_DISK")"
TEMPORARY_DISK="${TEST_DISK}.creating.$$"
rm -f "$TEMPORARY_DISK"
gum style --foreground 6 "Creating synthetic Windows test disk at $TEST_DISK"
qemu-img create -f qcow2 "$TEMPORARY_DISK" "${DISK_SIZE_GIB}G"
connect_disk "$TEMPORARY_DISK"
sudo parted --script "$ACTIVE_NBD" mklabel gpt
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_ESP fat32 1MiB 513MiB
sudo parted --script "$ACTIVE_NBD" set 1 esp on
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_DATA ext4 513MiB 20GiB
if (( GAP )); then
# Slot 3 is created and deleted, slot 4 stays. parted then fills 3 before
# 5, so an installer that assumes "highest + 1" (which would be 5 and 6)
# writes to device nodes that do not exist. The deleted region is
# deliberately smaller than the trailing free space so the installer still
# picks the tail as its largest free region.
sudo parted --script "$ACTIVE_NBD" mkpart SCRATCH ext4 20GiB 28GiB
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_MORE ext4 28GiB 30GiB
sudo parted --script "$ACTIVE_NBD" rm 3
fi
sudo partprobe "$ACTIVE_NBD"
sudo udevadm settle
esp="${ACTIVE_NBD}p1"
data="${ACTIVE_NBD}p2"
sudo mkfs.fat -F32 -n WINDOWS_ESP "$esp" >/dev/null
sudo mkfs.ext4 -F -L WINDOWS_DATA "$data" >/dev/null 2>&1
mount_partition "$esp"
sudo mkdir -p "$MOUNT_DIR/EFI/Microsoft/Boot"
printf '%s\n' 'Synthetic Windows ESP for Omarchy ISO testing.' |
sudo tee "$MOUNT_DIR/EFI/Microsoft/README.txt" >/dev/null
cleanup_mount
mount_partition "$data"
printf '%s\n' 'This existing partition must survive the Omarchy installation.' |
sudo tee "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" >/dev/null
cleanup_mount
sync
disconnect_disk
mv -f "$TEMPORARY_DISK" "$TEST_DISK"
TEMPORARY_DISK=""
}
select_iso() {
local -a isos=()
if [[ -n $ISO ]]; then
[[ -f $ISO ]] || {
echo "ISO not found: $ISO" >&2
return 1
}
realpath "$ISO"
return 0
fi
mapfile -t isos < <(
find "$REPO_ROOT/release" -maxdepth 1 -type f -name '*.iso' -printf '%T@ %p\n' 2>/dev/null |
sort -nr |
cut -d' ' -f2-
)
(( ${#isos[@]} > 0 )) || {
echo "No ISO found under $REPO_ROOT/release." >&2
return 1
}
printf '%s\n' "${isos[@]}" | gum choose --header "Select ISO to install"
}
omarchy-pkg-add qemu-full edk2-ovmf parted dosfstools e2fsprogs
sudo modprobe nbd max_part=16
if [[ -f $TEST_DISK ]] && (( ! RECREATE )); then
gum style --foreground 6 "Verifying existing test disk at $TEST_DISK"
if validate_test_disk; then
gum style --foreground 2 "✓ Synthetic Windows test disk is ready"
elif gum confirm --default=false "The existing test disk is not a pristine fixture. Recreate it?"; then
create_test_disk
else
gum style "Left the existing disk untouched."
exit 0
fi
else
create_test_disk
fi
# The synthetic fixture has no real Windows bootloader, so reset OVMF state for
# every run. The ISO still sees EFI/Microsoft and must leave that ESP untouched.
mkdir -p "$(dirname "$TEST_OVMF_VARS")"
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$TEST_OVMF_VARS"
gum style --border rounded --padding "0 1" --border-foreground 6 \
"Synthetic Windows disk ready" \
"Disk: $TEST_DISK" \
"Layout: $LAYOUT_LABEL"
if gum confirm "Start installation on this disk?"; then
ISO="$(select_iso)"
trap - EXIT INT TERM
exec "$SCRIPT_DIR/omarchy-iso-boot" \
--reuse \
--disk "$TEST_DISK" \
--ovmf-vars "$TEST_OVMF_VARS" \
"$ISO" \
-- -boot once=d,menu=on
fi
+19 -3
View File
@@ -10,8 +10,24 @@ if [[ ! -f ~/.config/rclone/rclone.conf ]]; then
exit 1 exit 1
fi fi
rclone copy $1 Omarchy:omarchy/ -P # Every copy reports, and one failure fails the upload. Without the tally the
# last copy decides the exit status, so a signature that never made it is
# hidden by a checksum that did.
status=0
if [[ -f $1.sig ]]; then rclone copy "$1" Omarchy:omarchy/ -P || status=1
rclone copy $1.sig Omarchy:omarchy/ -P
# The signature and the checksum are only worth anything to someone who can
# reach them beside the ISO, so they go up in the same run rather than a step
# a release can finish without.
for sidecar in "$1.sig" "$1.sha256"; do
if [[ -f $sidecar ]]; then
rclone copy "$sidecar" Omarchy:omarchy/ -P || status=1
fi
done
if [[ ! -f $1.sha256 ]]; then
echo "Warning: no $1.sha256 to publish, so nobody can verify this download" >&2
fi fi
exit $status
+68 -15
View File
@@ -4,8 +4,8 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves" VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves"
TMP_DISK="/tmp/omarchy-iso-boot.qcow2" TMP_DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
TMP_OVMF="/tmp/OVMF_VARS.4m.fd" TMP_OVMF="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
mkdir -p "$VM_DIR" mkdir -p "$VM_DIR"
@@ -18,7 +18,7 @@ save_vm() {
fi fi
if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then
echo "Error: No active VM found in /tmp" echo "Error: No active VM found at $TMP_DISK / $TMP_OVMF"
echo "Boot a VM first with: omarchy-iso-boot [iso]" echo "Boot a VM first with: omarchy-iso-boot [iso]"
exit 1 exit 1
fi fi
@@ -53,7 +53,44 @@ list_vms() {
} }
boot_vm() { boot_vm() {
local name="$1" local name=""
local boot_args=()
local active_disk="$TMP_DISK"
local active_ovmf="$TMP_OVMF"
if [[ $# -gt 0 && ${1:-} != --* ]]; then
name="$1"
shift
fi
while (( $# > 0 )); do
case "$1" in
--disk)
active_disk="${2:-}"
boot_args+=("$1" "${2:-}")
shift 2
;;
--disk=*)
active_disk="${1#*=}"
boot_args+=("$1")
shift
;;
--ovmf-vars)
active_ovmf="${2:-}"
boot_args+=("$1" "${2:-}")
shift 2
;;
--ovmf-vars=*)
active_ovmf="${1#*=}"
boot_args+=("$1")
shift
;;
*)
boot_args+=("$1")
shift
;;
esac
done
if [[ -z "$(ls -A "$VM_DIR" 2>/dev/null)" ]]; then if [[ -z "$(ls -A "$VM_DIR" 2>/dev/null)" ]]; then
echo "No VM snapshots available" echo "No VM snapshots available"
@@ -73,25 +110,34 @@ boot_vm() {
exit 1 exit 1
fi fi
if [[ -f "$TMP_DISK" ]] && ! gum confirm "Override existing /tmp VM?"; then if [[ -z $active_disk || -z $active_ovmf ]]; then
echo "Error: active disk and OVMF vars paths must not be empty"
exit 1
fi
if [[ -f $active_disk ]] && ! gum confirm "Override existing active VM disk at $active_disk?"; then
exit 0 exit 0
fi fi
echo "Copying VM '$name' to /tmp..." echo "Copying VM '$name' to $active_disk..."
cp "$vm_path/omarchy-iso-boot.qcow2" "$TMP_DISK" mkdir -p "$(dirname "$active_disk")" "$(dirname "$active_ovmf")"
cp "$vm_path/OVMF_VARS.4m.fd" "$TMP_OVMF" cp "$vm_path/omarchy-iso-boot.qcow2" "$active_disk"
cp "$vm_path/OVMF_VARS.4m.fd" "$active_ovmf"
gum style --foreground 212 "✓ VM ready. Booting..." gum style --foreground 212 "✓ VM ready. Booting..."
exec omarchy-iso-boot /dev/null reuse exec omarchy-iso-boot "${boot_args[@]}" /dev/null reuse
} }
case "${1:-}" in command="${1:-}"
[[ $# -gt 0 ]] && shift
case "$command" in
save) save)
save_vm "$2" save_vm "${1:-}"
;; ;;
boot) boot)
boot_vm "$2" boot_vm "$@"
;; ;;
list) list)
list_vms list_vms
@@ -101,9 +147,16 @@ case "${1:-}" in
"$(gum style --bold 'omarchy-vm') - VM snapshot manager" \ "$(gum style --bold 'omarchy-vm') - VM snapshot manager" \
"" \ "" \
"$(gum style --foreground 245 'Commands:')" \ "$(gum style --foreground 245 'Commands:')" \
" save [name] Save current VM from /tmp" \ " save [name] Save current active VM" \
" boot [name] Boot saved VM (interactive if no name)" \ " boot [name] [boot-options] Boot saved VM (interactive if no name)" \
" list List available VMs" \ " list List available VMs" \
"" \
"$(gum style --foreground 245 'Boot options:')" \
" --ssh-port PORT Forward host PORT to guest SSH (default: 2222)" \
" --ssh-host HOST Host address for SSH forwarding (default: 127.0.0.1)" \
" --disk PATH Active disk path, useful for parallel VMs" \
" --ovmf-vars PATH Active OVMF vars path, useful for parallel VMs" \
" --no-network Boot without a virtual NIC" \
"" \ "" \
"$(gum style --foreground 245 'Current VMs:')" "$(gum style --foreground 245 'Current VMs:')"
list_vms 2>/dev/null || echo " None" list_vms 2>/dev/null || echo " None"
+1
View File
@@ -12,3 +12,4 @@ openssh
pipewire pipewire
snapper snapper
sof-firmware sof-firmware
tailscale
+381 -64
View File
@@ -2,105 +2,422 @@
set -e set -e
# Note that these are packages installed to the Arch container used to build the ISO. OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
# Edge, dev, and local-source ISOs install the dev packages explicitly. Those
# package recipes track the quattro branch. This avoids relying on pacman's
# provides=omarchy resolution and shows the real package names being tested in
# the offline mirror and target install. Every other ref, the default quattro
# build included, installs the published omarchy packages.
case "$OMARCHY_ISO_REF" in
edge|dev|local)
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
;;
*)
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings}"
;;
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-key --init
pacman --noconfirm -Sy archlinux-keyring pacman --noconfirm -Sy archlinux-keyring
pacman --noconfirm -Sy archiso git sudo base-devel jq grub # Full upgrade, not just -Sy: docker never re-pulls :latest once it's cached,
# so this container can be months behind the mirror it installs from. A plain
# -Sy install is then a partial upgrade — new packages linked against a glibc
# the container doesn't have yet.
pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli
# Pre-import the omarchy signing key so pacman can verify packages without a keyserver lookup # Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo
# during the build without keyserver lookups).
pacman-key --add /builder/omarchy.gpg pacman-key --add /builder/omarchy.gpg
pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571 pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571
# Install omarchy-keyring for package verification during build # omarchy-keyring is needed inside the offline mirror too.
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring
pacman-key --populate omarchy pacman-key --populate omarchy
# Setup build locations # Put OPR first in the container too so makepkg resolves the same overrides
build_cache_dir="/var/cache" # as the online installer configuration.
offline_mirror_dir="$build_cache_dir/airootfs/var/cache/omarchy/mirror/offline" if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then
mkdir -p $build_cache_dir/ opr_section=$(awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf)
mkdir -p $offline_mirror_dir/ awk -v opr="$opr_section" '/^\[core\]/ { print opr; print "" } { print }' \
/etc/pacman.conf >/tmp/pacman-opr-first.conf
install -m 644 /tmp/pacman-opr-first.conf /etc/pacman.conf
rm -f /tmp/pacman-opr-first.conf
fi
# We base our ISO on the official arch ISO (releng) config # Build locations
cp -r /archiso/configs/releng/* $build_cache_dir/ build_cache_dir=/var/cache
offline_mirror_dir="$build_cache_dir/airootfs/var/cache/omarchy/mirror/offline"
mkdir -p "$build_cache_dir" "$offline_mirror_dir"
# Seed from the official Arch releng profile.
cp -r /archiso/configs/releng/* "$build_cache_dir/"
rm "$build_cache_dir/airootfs/etc/motd" rm "$build_cache_dir/airootfs/etc/motd"
# Avoid using reflector for mirror identification as we are relying on the global CDN # We rely on the global CDN; drop reflector.
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service" rm -rf "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service"
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d" rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d"
rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector" rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector"
# Bring in our configs # Bring in our archiso profile additions.
cp -r /configs/* $build_cache_dir/ cp -r /configs/* "$build_cache_dir/"
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
# Persist OMARCHY_MIRROR so it's available at install time
echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror" echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror"
echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref"
cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <<EOF
OMARCHY_RUNTIME_PACKAGE=$OMARCHY_RUNTIME_PACKAGE
OMARCHY_SETTINGS_PACKAGE=$OMARCHY_SETTINGS_PACKAGE
OMARCHY_NVIM_PACKAGE=$OMARCHY_NVIM_PACKAGE
EOF
# Setup Omarchy itself if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
if [[ -d /omarchy ]]; then touch "$build_cache_dir/airootfs/usr/share/omarchy-iso/install-debug"
cp -rp /omarchy "$build_cache_dir/airootfs/root/omarchy" {
else echo "debug=1"
git clone -b $OMARCHY_INSTALLER_REF https://github.com/$OMARCHY_INSTALLER_REPO.git "$build_cache_dir/airootfs/root/omarchy" echo "built_at=$(date -Is)"
echo "ref=$OMARCHY_ISO_REF"
echo "mirror=$OMARCHY_MIRROR"
echo "runtime_package=$OMARCHY_RUNTIME_PACKAGE"
echo "settings_package=$OMARCHY_SETTINGS_PACKAGE"
echo "nvim_package=$OMARCHY_NVIM_PACKAGE"
if [[ -d /omarchy-source ]]; then
echo "omarchy_source=/omarchy-source"
git -c safe.directory=/omarchy-source -C /omarchy-source rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_commit=/' || true
git -c safe.directory=/omarchy-source -C /omarchy-source status --short 2>/dev/null | sed 's/^/omarchy_status=/' || true
fi
if [[ -d /omarchy-pkgs ]]; then
echo "omarchy_pkgs_source=/omarchy-pkgs"
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_pkgs_commit=/' || true
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs status --short 2>/dev/null | sed 's/^/omarchy_pkgs_status=/' || true
fi
} > "$build_cache_dir/airootfs/usr/share/omarchy-iso/build-info"
fi fi
# Make log uploader available in the ISO too # When --local-source is in effect, build omarchy* from the mounted source
mkdir -p "$build_cache_dir/airootfs/usr/local/bin/" # trees and drop them in the offline mirror. Otherwise pacman -Syw below
cp "$build_cache_dir/airootfs/root/omarchy/bin/omarchy-upload-log" "$build_cache_dir/airootfs/usr/local/bin/omarchy-upload-log" # downloads the published versions from the omarchy network mirror.
if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then
bash /builder/build-omarchy-packages.sh "$offline_mirror_dir"
LOCAL_OMARCHY_BUILD=1
fi
# Copy the Omarchy Plymouth theme to the ISO # Node.js binary for offline mise install.
mkdir -p "$build_cache_dir/airootfs/usr/share/plymouth/themes/omarchy"
cp -r "$build_cache_dir/airootfs/root/omarchy/default/plymouth/"* "$build_cache_dir/airootfs/usr/share/plymouth/themes/omarchy/"
# Download and verify Node.js binary for offline installation
NODE_DIST_URL="https://nodejs.org/dist/latest" NODE_DIST_URL="https://nodejs.org/dist/latest"
# Get checksums and parse filename and SHA
NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt") NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}') NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}')
NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}') NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}')
# Download the tarball
curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME" curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME"
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c -
# Verify SHA256 checksum
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c - || {
echo "ERROR: Node.js checksum verification failed!"
exit 1
}
# Copy to ISO
mkdir -p "$build_cache_dir/airootfs/opt/packages/" mkdir -p "$build_cache_dir/airootfs/opt/packages/"
cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/" cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
# Add our additional packages to packages.x86_64 # Packages installed into the live ISO environment itself (NOT the target system).
arch_packages=(linux-t2 git gum jq openssl plymouth tzupdate omarchy-keyring lvm2 cryptsetup parted) # The selected omarchy-settings package is needed here so its post_install hook
printf '%s\n' "${arch_packages[@]}" >>"$build_cache_dir/packages.x86_64" # drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the
# live initramfs.
# cage and foot give Chinese installs a console that draws Chinese.
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted cage foot)
printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64"
# Build list of all the packages needed for the offline mirror # The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so
all_packages=($(cat "$build_cache_dir/packages.x86_64")) # stock linux is a second kernel nobody boots: ~147MB of ISO, plus its own archiso
all_packages+=($(grep -v '^#' "$build_cache_dir/airootfs/root/omarchy/install/omarchy-base.packages" | grep -v '^$')) # initramfs, copied into both the ISO tree and the size-constrained FAT EFI image.
all_packages+=($(grep -v '^#' "$build_cache_dir/airootfs/root/omarchy/install/omarchy-other.packages" | grep -v '^$')) #
all_packages+=($(grep -v '^#' /builder/archinstall.packages | grep -v '^$')) # It cannot just be deleted — releng's broadcom-wl hard-depends on it, and it is
# the only releng package that does, so pacman would drag the kernel straight back
# in. broadcom-wl is a prebuilt module for stock linux and cannot load on the
# kernel we boot, so it has done nothing since we started booting T2 anyway. The
# install is entirely offline and the live environment needs no Wi-Fi driver.
#
# Anchored so linux-t2 and linux-firmware are untouched.
sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# Download all the packages to the offline mirror inside the ISO # Build the offline mirror: everything pacstrap might want during the target
# install. With --local-source, the omarchy* packages we just built are
# already in the mirror and we filter them out below. Without it, pacman -Syw
# pulls the published omarchy* from the network mirror like any other package.
if [[ -d /omarchy-source ]]; then
regions_source=/omarchy-source/default/regions
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
setup_form=/omarchy-source/install/provisioning/setup-form.sh
else
# Pull the same package lists out of the freshly-downloaded Omarchy runtime
# package so we don't need a local checkout in the non-local-source path.
bootstrap_cache_dir=/tmp/omarchy-pkg-bootstrap
rm -rf "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap /tmp/omarchy-pkglists
mkdir -p "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_RUNTIME_PACKAGE" --cachedir "$bootstrap_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
omarchy_pkg=$(find "$bootstrap_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_RUNTIME_PACKAGE-*.pkg.tar.zst" | sort | head -1)
if [[ -z $omarchy_pkg ]]; then
echo "ERROR: downloaded package for $OMARCHY_RUNTIME_PACKAGE not found in $bootstrap_cache_dir" >&2
exit 1
fi
mkdir -p /tmp/omarchy-pkglists
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages)
# Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member
# it can't find, so asking for this alongside the package lists would abort the
# build here (set -e) with a bare "Not found in archive" instead of the
# actionable error below.
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true
# Region profiles live under default/, which the settings package ships,
# not the runtime. Its own cache dir keeps the runtime lookup above from
# ever matching it.
settings_cache_dir="$bootstrap_cache_dir/settings"
mkdir -p "$settings_cache_dir"
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_SETTINGS_PACKAGE" --cachedir "$settings_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
regions_source=$(find "$settings_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_SETTINGS_PACKAGE-*.pkg.tar.zst" | sort | head -1)
if [[ -z $regions_source ]]; then
echo "ERROR: downloaded package for $OMARCHY_SETTINGS_PACKAGE not found in $settings_cache_dir" >&2
exit 1
fi
setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh
fi
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
# The configurator's setup form comes from the runtime this ISO bundles, so the
# installer and the first-boot setup that finishes a deferred install can never
# disagree. A runtime predating the split ships no such file, which would leave
# the configurator with no prompts at all.
if [[ ! -f $setup_form ]]; then
if [[ -d /omarchy-source ]]; then
echo "ERROR: the --local-source checkout ships no install/provisioning/setup-form.sh" >&2
remedy="Update the checkout to a revision carrying the shared setup form."
else
echo "ERROR: $OMARCHY_RUNTIME_PACKAGE does not ship install/provisioning/setup-form.sh" >&2
remedy="Publish a runtime carrying the shared setup form, or build with --local-source against a checkout that has it."
fi
echo " The configurator sources its prompts from that file, so this ISO" >&2
echo " would boot into an installer with no questions to ask." >&2
echo " $remedy" >&2
exit 1
fi
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
# Every region's packages ride in the offline mirror, and its community
# repositories join a writable copy of the online config to download them.
online_config=/tmp/omarchy-pacman-online.conf
cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config"
bash /builder/prepare-regions.sh "$regions_source" \
"$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config"
# Profiles ship in the settings package, but the finalizer that applies them
# ships in the runtime. A runtime without it would ignore the region marker,
# leaving regional targets with the keyring but none of the repositories.
if [[ -n ${omarchy_pkg:-} && -d $build_cache_dir/airootfs/usr/share/omarchy-iso/regions ]]; then
runtime_listing=$(bsdtar -tf "$omarchy_pkg")
if ! grep -qx 'usr/bin/omarchy-apply-pacman' <<<"$runtime_listing"; then
echo "ERROR: $OMARCHY_SETTINGS_PACKAGE ships region profiles, but $OMARCHY_RUNTIME_PACKAGE lacks omarchy-apply-pacman to apply them." >&2
exit 1
fi
fi
# Collect every package we want available in the offline mirror.
declare -a all_packages
mapfile -t all_packages < <(
{
cat "$build_cache_dir/packages.x86_64"
grep -hv '^#\|^$' "${base_pkg_lists[@]}"
grep -hv '^#\|^$' /builder/archinstall.packages
# Downloaded but not installed: the installer adds a region's packages
# only to targets in that region.
grep -hsv '^#\|^$' "$build_cache_dir"/airootfs/usr/share/omarchy-iso/regions/*/packages || true
# Always include the selected Omarchy packages so the target install can
# find the runtime and companion packages in the offline mirror.
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"
} | sort -u
)
# Arch dropped the prebuilt broadcom-wl on 2026-09-02 and rebuilt broadcom-wl-dkms
# with replaces=(broadcom-wl). A replaces entry only helps upgrades of an already
# installed package; as an explicit pacman target the old name now fails with
# "target not found". Published Omarchy runtime packages that predate the rename
# still list it in omarchy-other.packages, so map it here until every channel
# ships a runtime that names broadcom-wl-dkms itself.
# arch-mact2 dropped apple-bcm-firmware on 2026-09-16 in favour of
# apple-bcm-firmware-fetcher, which does the same job (pull the T2 Wi-Fi and
# Bluetooth firmware off the macOS volume) but only conflicts with the old name
# rather than replacing it, so pacman cannot follow the rename on its own.
mapfile -t all_packages < <(
printf '%s\n' "${all_packages[@]}" |
sed -e 's/^broadcom-wl$/broadcom-wl-dkms/' \
-e 's/^apple-bcm-firmware$/apple-bcm-firmware-fetcher/' |
sort -u
)
# With --local-source we already built these omarchy* packages directly into
# the mirror; strip them from the pacman -Syw list so it doesn't try to fetch
# the published versions on top.
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
mapfile -t all_packages < <(
printf '%s\n' "${all_packages[@]}" |
grep -Fxv \
-e "$OMARCHY_RUNTIME_PACKAGE" \
-e "$OMARCHY_SETTINGS_PACKAGE" \
-e "$OMARCHY_NVIM_PACKAGE" || true
)
fi
mkdir -p /tmp/offlinedb mkdir -p /tmp/offlinedb
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "${all_packages[@]}" --cachedir $offline_mirror_dir/ --dbpath /tmp/offlinedb download_offline_packages() {
repo-add --new "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst pacman --config "$online_config" --noconfirm -Syw \
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
}
# A repository may occasionally republish a package without changing its
# filename. Pacman detects that the persistent cached copy no longer matches
# the refreshed repository checksum and deletes it, but still fails the
# transaction. Retry once so the now-missing package is downloaded.
if ! download_offline_packages; then
echo "Offline package download failed; retrying after pacman cleaned invalid cached files..." >&2
download_offline_packages
fi
# Resolve the exact filenames chosen by the same synced package databases used
# for the download. Pruning by this transaction (rather than merely keeping the
# newest version of every cached package name) removes packages that have left
# the lists or dependency closure, such as an old Electron major version.
if ! resolved_package_files="$(
pacman --config "$online_config" --noconfirm \
--dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}"
)"; then
echo "ERROR: could not resolve the package files required by the offline mirror" >&2
exit 1
fi
mapfile -t required_package_files <<< "$resolved_package_files"
# Create a symlink to the offline mirror instead of duplicating it. # The online transaction intentionally excludes packages built from the local
# mkarchiso needs packages at /var/cache/omarchy/mirror/offline in the container, # checkouts. Add those exact artifacts back to the keep-set after verifying
# but they're actually in $build_cache_dir/airootfs/var/cache/omarchy/mirror/offline # that the local build left exactly one file for each selected package name.
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
for local_package_name in \
"$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do
local_package_file=""
for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do
[[ -f $candidate && $candidate != *.sig ]] || continue
read -r candidate_name _ < <(pacman -Qp "$candidate" 2>/dev/null) || continue
[[ $candidate_name == "$local_package_name" ]] || continue
if [[ -n $local_package_file ]]; then
echo "ERROR: multiple local builds found for $local_package_name" >&2
exit 1
fi
local_package_file="${candidate##*/}"
done
if [[ -z $local_package_file ]]; then
echo "ERROR: local build not found for $local_package_name" >&2
exit 1
fi
required_package_files+=("$local_package_file")
done
fi
printf '%s\n' "${required_package_files[@]}" |
bash /builder/prune-offline-mirror.sh "$offline_mirror_dir"
# Rebuild the offline repo db from scratch so size/checksum/depends entries
# always reflect only the package files selected for this build.
# 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.
mkdir -p /var/cache/omarchy/mirror mkdir -p /var/cache/omarchy/mirror
ln -s "$offline_mirror_dir" "/var/cache/omarchy/mirror/offline" ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline
# Denominator for the install dashboard's progress bar. Resolving the mirror's
# own package lists against the mirror we just indexed, with an empty local db,
# is the question pacstrap asks at install time — same resolver, same repo, same
# lists — so no hand-kept constant can drift.
#
# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and
# intel-ucode because the mirror must contain either. phases.py records expected
# and actual in the timing JSON, so growing drift shows up in acceptance runs.
# The early-bootstrap set is already inside this closure, so restating it would
# only add a second list to drift.
resolve_expected_packages() {
local resolve_root=/tmp/omarchy-expected-packages
local resolved
local -a targets
rm -rf "$resolve_root"
mkdir -p "$resolve_root/var/lib/pacman"
mapfile -t targets < <(
{
grep -hv '^#\|^$' /builder/archinstall.packages
# Read the shipped copy, which is what _runtime_package_list reads at
# install time, not the build-time source it came from.
grep -hv '^#\|^$' \
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \
"$OMARCHY_NVIM_PACKAGE"
} | sort -u
)
pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -Sy >/dev/null || return 1
# Capture before counting: no pipefail here, so a pacman failure inside a
# pipeline would become a plausible partial count, which never trips the
# dashboard's fallback.
resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1
printf '%s\n' "$resolved" | sort -u | grep -c .
}
# Worth failing the build over: -S --print only aborts when a target is missing
# from the offline repo, which would fail pacstrap the same way. A count that
# merely looks wrong is not — the dashboard falls back without the file.
if ! expected_packages="$(resolve_expected_packages)"; then
echo "ERROR: could not resolve the target package count from the offline mirror." >&2
echo " pacman -S --print aborts the whole transaction if any single target" >&2
echo " is missing, so this almost certainly means pacstrap would fail the" >&2
echo " same way at install time." >&2
exit 1
fi
if (( expected_packages < 600 || expected_packages > 2000 )); then
echo "WARNING: resolved target package count $expected_packages is outside the" >&2
echo " expected 600-2000 range; shipping no denominator so the install" >&2
echo " dashboard falls back to its time-based curve." >&2
else
printf '%s\n' "$expected_packages" \
>"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages"
echo "Target install resolves to $expected_packages packages."
fi
# Copy the offline pacman.conf to the ISO's /etc directory so the live environment uses our # Live ISO uses the same offline pacman.conf.
# same config when booted. cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf"
cp $build_cache_dir/pacman-offline.conf "$build_cache_dir/airootfs/etc/pacman.conf"
# Finally, we assemble the entire ISO # Build the ISO.
mkarchiso -v -w "$build_cache_dir/work/" -o "/out/" "$build_cache_dir/" mkarchiso -v -w "$build_cache_dir/work/" -o /out/ "$build_cache_dir/"
# Fix ownership of output files to match host user # Match host UID/GID on output.
if [ -n "$HOST_UID" ] && [ -n "$HOST_GID" ]; then if [[ -n $HOST_UID && -n $HOST_GID ]]; then
chown -R "$HOST_UID:$HOST_GID" /out/ chown -R "$HOST_UID:$HOST_GID" /out/
fi fi
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# Build Omarchy packages from mounted source (/omarchy-source + /omarchy-pkgs)
# and place the resulting .pkg.tar.zst files in the offline mirror.
set -e
offline_mirror_dir="$1"
if [[ -z $offline_mirror_dir ]]; then
echo "Usage: build-omarchy-packages.sh <offline-mirror-dir>" >&2
exit 1
fi
if [[ ! -d /omarchy-source ]]; then
echo "ERROR: /omarchy-source not mounted (pass --local-source or set OMARCHY_SOURCE_PATH)" >&2
exit 1
fi
if [[ ! -d /omarchy-pkgs ]]; then
echo "ERROR: /omarchy-pkgs not mounted (set OMARCHY_PKGS_PATH or place ../omarchy-pkgs)" >&2
exit 1
fi
work_dir=/tmp/omarchy-pkg-build
rm -rf "$work_dir"
mkdir -p "$work_dir"
if ! id builder &>/dev/null; then
useradd -m -s /bin/bash builder
fi
echo 'builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/99-omarchy-pkg-builder
chmod 440 /etc/sudoers.d/99-omarchy-pkg-builder
chown builder:builder "$work_dir"
pacman -Sy --noconfirm
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
packages=(
"$OMARCHY_SETTINGS_PACKAGE"
"$OMARCHY_RUNTIME_PACKAGE"
"$OMARCHY_NVIM_PACKAGE"
)
# Local-source packages must replace every cached build of the same package,
# even when the checkout's generated pkgver sorts below a published build.
# Otherwise the later generic cache pruning can silently keep edge instead.
for pkg in "${packages[@]}"; do
rm -f "$offline_mirror_dir/$pkg-"*.pkg.tar.*
done
for pkg in "${packages[@]}"; do
echo "----------------------------------------"
echo "Building $pkg"
echo "----------------------------------------"
pkg_work="$work_dir/$pkg"
if [[ ! -d "/omarchy-pkgs/pkgbuilds/$pkg" ]]; then
echo "ERROR: package source not found: /omarchy-pkgs/pkgbuilds/$pkg" >&2
exit 1
fi
cp -a "/omarchy-pkgs/pkgbuilds/$pkg" "$pkg_work"
chown -R builder:builder "$pkg_work"
su builder -c "
cd '$pkg_work' &&
PKGDEST='$work_dir' \
OMARCHY_SRC=/omarchy-source \
makepkg --noconfirm --skippgpcheck --skipchecksums --nodeps -f
"
done
mkdir -p "$offline_mirror_dir"
for package_file in "$work_dir"/*.pkg.tar.zst; do
destination="$offline_mirror_dir/$(basename "$package_file")"
# A cached signature belongs to the previously downloaded or locally built
# package. Keeping it beside a newly built package makes pacman reject the
# otherwise valid local-source build.
rm -f "$destination" "$destination.sig"
mv "$package_file" "$destination"
done
echo
echo "Built Omarchy packages, placed in $offline_mirror_dir:"
ls "$offline_mirror_dir"/omarchy*.pkg.tar.zst | sed 's|^| |'
+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."
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Ship every region profile from the bundled runtime, so one ISO serves every
# region. The installer picks a profile from the target's timezone; this only
# stages the profiles and makes their packages downloadable. Repository
# defaults stay in the runtime, not in a second set of ISO-specific templates.
set -euo pipefail
regions_source="$1"
iso_payload="$2"
online_config="$3"
# The source is a checkout's default/regions, or the published settings
# package that ships default/. One predating region profiles ships none; the
# ISO then installs global targets only, exactly as before. Anything else that
# fails here, such as an unreadable package, aborts the build.
regions="$regions_source"
if [[ -f $regions_source ]]; then
member=usr/share/omarchy/default/regions
listing=$(bsdtar -tf "$regions_source")
if grep -q "^$member/" <<<"$listing"; then
extracted=$(mktemp -d)
trap 'rm -rf "$extracted"' EXIT
bsdtar -xf "$regions_source" -C "$extracted" "$member"
regions="$extracted/$member"
fi
fi
if [[ ! -d $regions ]]; then
echo "Runtime ships no region profiles; every install will be global."
exit 0
fi
cp -a "$regions" "$iso_payload/regions"
for profile in "$regions"/*/; do
# Only the community repositories are needed to download regional packages.
# Keep the channel's build mirrors intact; target mirrors are set later by
# the runtime's own pacman finalizer.
printf '\n' >> "$online_config"
cat "$profile/pacman/pacman.conf.append" >> "$online_config"
# Each keyring package must be signed by a key Arch already trusts. Do not
# bootstrap trust from an unchecked keyserver response or disable signatures.
# https://github.com/archlinuxcn/repo#usage
mapfile -t keyring_packages < <(grep -hv '^#\|^$' "$profile/packages" | grep -- '-keyring$' || true)
if (( ${#keyring_packages[@]} > 0 )); then
pacman --config "$online_config" --noconfirm -Sy --needed "${keyring_packages[@]}"
pacman-key --populate "${keyring_packages[@]%-keyring}"
fi
done
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
set -euo pipefail
offline_mirror_dir="${1:-}"
if [[ -z $offline_mirror_dir ]]; then
echo "Usage: prune-offline-mirror.sh <offline-mirror-dir>" >&2
exit 1
fi
if [[ ! -d $offline_mirror_dir ]]; then
echo "ERROR: offline mirror directory not found: $offline_mirror_dir" >&2
exit 1
fi
# Read the exact package filenames selected by pacman on stdin. Validate the
# complete selection before deleting anything: an empty/partial resolution must
# never turn a warm-cache build into a broken mirror.
declare -A required=()
while IFS= read -r filename; do
[[ -n $filename ]] || continue
if [[ $filename == */* || $filename != *.pkg.tar.* || $filename == *.sig ]]; then
echo "ERROR: invalid required package filename: $filename" >&2
exit 1
fi
required["$filename"]=1
done
if (( ${#required[@]} == 0 )); then
echo "ERROR: refusing to prune the offline mirror with an empty package selection" >&2
exit 1
fi
missing=()
for filename in "${!required[@]}"; do
[[ -f "$offline_mirror_dir/$filename" ]] || missing+=("$filename")
done
if (( ${#missing[@]} > 0 )); then
echo "ERROR: refusing to prune; selected packages are missing from the mirror:" >&2
printf ' %s\n' "${missing[@]}" >&2
exit 1
fi
stale=()
while IFS= read -r -d '' package_file; do
filename="${package_file##*/}"
[[ -n ${required[$filename]+x} ]] || stale+=("$package_file")
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*' ! -name '*.sig' -print0
)
if (( ${#stale[@]} > 0 )); then
echo "Pruning package archives not selected for the offline mirror:"
for package_file in "${stale[@]}"; do
echo " $(basename "$package_file")"
rm -f -- "$package_file" "$package_file.sig"
done
fi
# A failed or interrupted download can leave a signature without its archive.
# It cannot be indexed and only wastes space in the ISO.
while IFS= read -r -d '' signature_file; do
[[ -f ${signature_file%.sig} ]] || rm -f -- "$signature_file"
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*.sig' -print0
)
@@ -0,0 +1,18 @@
# mkinitcpio preset file for the 'linux-t2' package on archiso
#
# The filename is the pkgbase, and that is load-bearing. Kernel packages ship
# nothing in /boot — they only install usr/lib/modules/<kver>/vmlinuz — so
# mkinitcpio's alpm hook sources /etc/mkinitcpio.d/<pkgbase>.preset and copies
# THAT package's kernel to whatever ALL_kver names (is_kernelcopy in
# /usr/share/libalpm/scripts/mkinitcpio). Named linux.preset, this pointed the
# stock kernel at the T2 filenames, so the ISO booted stock Arch while every
# boot entry said linux-t2 and T2 Macs got no keyboard or trackpad.
#
# releng ships its own linux.preset for the stock kernel; leave it alone.
PRESETS=('archiso')
ALL_kver='/boot/vmlinuz-linux-t2'
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
archiso_image="/boot/initramfs-linux-t2.img"
@@ -1,9 +0,0 @@
# mkinitcpio preset file for the 'linux' package on archiso
PRESETS=('archiso')
ALL_kver='/boot/vmlinuz-linux-t2'
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
archiso_image="/boot/initramfs-linux-t2.img"
@@ -0,0 +1,3 @@
# linux-t2 ships for every machine, and on a pre-T2 Mac its applesmc oopses
# registering the keyboard-backlight LED, wedging udev. Nothing here needs the SMC.
blacklist applesmc
@@ -0,0 +1,21 @@
# remove from airootfs!
#
# Mirror omarchy-settings' /etc/skel shell defaults into /root so the live ISO
# console has the same bashrc, starship prompt, and aliases as an installed
# Omarchy system. Fires during airootfs pacstrap (when omarchy-settings is
# installed); the "remove from airootfs" magic string causes
# zzzz99-remove-custom-hooks-from-airootfs.hook to delete this file before
# the squashfs is built, so no runtime hook remains.
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = omarchy-settings
[Action]
Description = Mirroring /etc/skel shell defaults into /root for the live ISO...
When = PostTransaction
Depends = coreutils
Depends = omarchy-settings
Exec = /bin/sh -c 'install -d -m 0700 /root/.config && for f in .bashrc .bash_profile; do [ -f "/etc/skel/$f" ] && install -m 0644 "/etc/skel/$f" "/root/$f"; done; [ -f /etc/skel/.config/starship.toml ] && install -m 0644 /etc/skel/.config/starship.toml /root/.config/starship.toml; true'
+182 -260
View File
@@ -1,264 +1,186 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#
# Live ISO entry point on tty1: set up the live VT, run the configurator
# wizard, then hand off to the Python install orchestrator. Mirrors the
# stream/env contract from the previously-working installer:
# - stdout teed to /var/log/omarchy-install.log (CSI-stripped) AND to tty
# - stderr direct to /dev/tty so gum (which draws its TUI on stderr)
# renders correctly
# - CLICOLOR_FORCE/FORCE_COLOR so gum emits ANSI even with stdout piped
# - COLUMNS/LINES so gum picks up real terminal size
set -euo pipefail set -euo pipefail
use_omarchy_helpers() { [[ $(tty) == /dev/tty1 || -n ${OMARCHY_CJK_CONSOLE:-} ]] || exit 0
export OMARCHY_PATH="/root/omarchy" if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
export OMARCHY_INSTALL="/root/omarchy/install" touch "$XDG_RUNTIME_DIR/cjk-console-started"
export OMARCHY_INSTALL_LOG_FILE="/var/log/omarchy-install.log"
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
source /root/omarchy/install/helpers/all.sh
}
run_configurator() {
set_tokyo_night_colors
./configurator
export OMARCHY_USER="$(jq -r '.users[0].username' user_credentials.json)"
}
install_arch() {
clear_logo
gum style --foreground 3 --padding "1 0 0 $PADDING_LEFT" "Installing..."
echo
touch /var/log/omarchy-install.log
start_log_output
# Set CURRENT_SCRIPT for the trap to display better when nothing is returned for some reason
CURRENT_SCRIPT="install_base_system"
install_base_system > >(sed -u 's/\x1b\[[0-9;]*[a-zA-Z]//g' >>/var/log/omarchy-install.log) 2>&1
unset CURRENT_SCRIPT
stop_log_output
}
install_omarchy() {
chroot_bash -lc "sudo pacman -S --noconfirm --needed gum" >/dev/null
chroot_bash -lc "source /home/$OMARCHY_USER/.local/share/omarchy/install.sh"
configure_login_for_unencrypted_install
# Reboot if requested by installer
if [[ -f /mnt/var/tmp/omarchy-install-completed ]]; then
reboot
fi
}
# Set Tokyo Night color scheme for the terminal
set_tokyo_night_colors() {
if [[ $(tty) == "/dev/tty"* ]]; then
# Tokyo Night color palette
echo -en "\e]P01a1b26" # black (background)
echo -en "\e]P1f7768e" # red
echo -en "\e]P29ece6a" # green
echo -en "\e]P3e0af68" # yellow
echo -en "\e]P47aa2f7" # blue
echo -en "\e]P5bb9af7" # magenta
echo -en "\e]P67dcfff" # cyan
echo -en "\e]P7a9b1d6" # white
echo -en "\e]P8414868" # bright black
echo -en "\e]P9f7768e" # bright red
echo -en "\e]PA9ece6a" # bright green
echo -en "\e]PBe0af68" # bright yellow
echo -en "\e]PC7aa2f7" # bright blue
echo -en "\e]PDbb9af7" # bright magenta
echo -en "\e]PE7dcfff" # bright cyan
echo -en "\e]PFc0caf5" # bright white (foreground)
# Set default foreground and background
echo -en "\033[0m"
clear
fi
}
install_disk() {
jq -er 'first(.disk_config.device_modifications[]? | select(.wipe == true) | .device)' user_configuration.json
}
cleanup_install_disk() {
local disk="$1"
if [[ -z "$disk" || ! -b "$disk" ]]; then
echo "Could not determine install disk for cleanup" >&2
return 1
fi
echo "Cleaning up existing holders on install disk: $disk"
# Ensure that no mounts exist from past install attempts.
findmnt -R /mnt >/dev/null && umount -R /mnt || true
# Turn off swap and unmount anything backed by the selected disk, including
# device-mapper children from a previous install. Active LVM/swap holders can
# prevent the kernel from re-reading the partition table after archinstall
# wipes and recreates it.
while read -r dev; do
[[ -b "$dev" ]] || continue
swapoff "$dev" 2>/dev/null || true
while read -r target; do
[[ -n "$target" ]] || continue
umount "$target" 2>/dev/null || true
done < <(findmnt -rn -S "$dev" -o TARGET 2>/dev/null || true)
done < <(lsblk -rnpo PATH "$disk")
# Deactivate any LVM volume groups whose physical volumes live on the selected
# disk. This is the common case when replacing Fedora/Alma/RHEL installs.
while read -r dev type; do
[[ "$type" == "disk" || "$type" == "part" || "$type" == "crypt" ]] || continue
while read -r vg; do
[[ -n "$vg" ]] || continue
vgchange -an "$vg" 2>/dev/null || true
done < <(pvs --noheadings -o vg_name "$dev" 2>/dev/null | awk '{$1=$1; print}' | sort -u)
done < <(lsblk -rnpo PATH,TYPE "$disk")
# Close any LUKS mappings stacked on the selected disk after filesystems and
# swap have been released.
while read -r dev type; do
[[ "$type" == "crypt" ]] || continue
cryptsetup close "$dev" 2>/dev/null || true
done < <(lsblk -rnpo PATH,TYPE "$disk")
blockdev --flushbufs "$disk" 2>/dev/null || true
partprobe "$disk" 2>/dev/null || true
udevadm settle || true
}
install_base_system() {
# Initialize and populate the keyring
pacman-key --init
pacman-key --populate archlinux
pacman-key --populate omarchy
# Sync the offline database so pacman can find packages
pacman -Sy --noconfirm
cleanup_install_disk "$(install_disk)"
# Workarounds for archinstall 4.2 regressions under Python 3.14:
# 1. sync_log_to_install_medium: `self.target / absolute_logfile` drops
# self.target because the RHS is absolute, so Path.copy() raises EINVAL
# (source == target).
# 2. _add_limine_bootloader: `Path.copy(efi_dir_path)` raises IsADirectoryError
# because 3.14's Path.copy treats target as a literal path, not a directory
# (shutil.copy used to auto-append the source filename).
sed -i \
-e 's|logfile_target = self\.target / absolute_logfile$|logfile_target = self.target / absolute_logfile.relative_to("/")|' \
-e 's|(limine_path / file)\.copy(efi_dir_path)|(limine_path / file).copy(efi_dir_path / file)|' \
-e "s|(limine_path / 'limine-bios.sys')\.copy(boot_limine_path)|(limine_path / 'limine-bios.sys').copy(boot_limine_path / 'limine-bios.sys')|" \
/usr/lib/python3.14/site-packages/archinstall/lib/installer.py
# Install using files generated by the ./configurator
# Skip NTP and WKD sync since we're offline (keyring is pre-populated in ISO)
archinstall \
--config user_configuration.json \
--creds user_credentials.json \
--silent \
--skip-ntp \
--skip-wkd \
--skip-wifi-check
# Archinstall unmounts the ESP when it finishes. Omarchy's boot finalizer
# needs the generated Limine config and EFI artifacts available under /boot.
if ! mountpoint -q /mnt/boot; then
arch-chroot /mnt mount /boot
fi
# The installed fstab keeps the ESP root-only, but Omarchy finalization runs
# as the target user and must discover the Limine config before using sudo to
# replace it. Temporarily allow reads and directory traversal during install.
boot_device=$(findmnt -nro SOURCE --target /mnt/boot)
umount /mnt/boot
mount -t vfat -o rw,fmask=0022,dmask=0022 "$boot_device" /mnt/boot
if ! arch-chroot -u "$OMARCHY_USER" /mnt test -x /boot; then
echo "Target user cannot access the mounted ESP" >&2
return 1
fi
# After archinstall sets up the base system but before our installer runs,
# we need to ensure the offline pacman.conf is in place
cp /etc/pacman.conf /mnt/etc/pacman.conf
# Mount the offline mirror so it's accessible in the chroot
mkdir -p /mnt/var/cache/omarchy/mirror/offline
mount --bind /var/cache/omarchy/mirror/offline /mnt/var/cache/omarchy/mirror/offline
# Mount the packages dir so it's accessible in the chroot
mkdir -p /mnt/opt/packages
mount --bind /opt/packages /mnt/opt/packages
# No need to ask for sudo during the installation (omarchy itself responsible for removing after install)
mkdir -p /mnt/etc/sudoers.d
cat >/mnt/etc/sudoers.d/99-omarchy-installer <<EOF
root ALL=(ALL:ALL) NOPASSWD: ALL
%wheel ALL=(ALL:ALL) NOPASSWD: ALL
$OMARCHY_USER ALL=(ALL:ALL) NOPASSWD: ALL
EOF
chmod 440 /mnt/etc/sudoers.d/99-omarchy-installer
# Copy the local omarchy repo to the user's home directory
mkdir -p /mnt/home/$OMARCHY_USER/.local/share/
cp -r /root/omarchy /mnt/home/$OMARCHY_USER/.local/share/
chown -R 1000:1000 /mnt/home/$OMARCHY_USER/.local/
# Ensure all necessary scripts are executable
find /mnt/home/$OMARCHY_USER/.local/share/omarchy -type f -path "*/bin/*" -exec chmod +x {} \;
chmod +x /mnt/home/$OMARCHY_USER/.local/share/omarchy/boot.sh 2>/dev/null || true
find /mnt/home/$OMARCHY_USER/.local/share/omarchy/default/waybar -type f -name "*.sh" -exec chmod +x {} \; 2>/dev/null || true
}
configure_login_for_unencrypted_install() {
if [[ $(<user_encrypt_installation.txt) != "false" ]]; then
return
fi
# Unencrypted installs must stop at SDDM so the user password is entered
# before reaching the desktop. Omarchy's normal encrypted path may autologin
# because the disk password was already entered at boot.
#
# Keep the Omarchy SDDM theme and seed SDDM's last user/session state so
# first boot looks like the SDDM screen shown after logging out of Omarchy.
mkdir -p /mnt/etc/sddm.conf.d
rm -f /mnt/etc/sddm.conf.d/autologin.conf
cat >/mnt/etc/sddm.conf.d/99-omarchy-login.conf <<EOF
[Theme]
Current=omarchy
[Users]
RememberLastUser=true
RememberLastSession=true
EOF
mkdir -p /mnt/var/lib/sddm
cat >/mnt/var/lib/sddm/state.conf <<EOF
[Last]
Session=omarchy.desktop
User=$OMARCHY_USER
EOF
rm -f /mnt/etc/systemd/system/getty@tty1.service.d/autologin.conf
arch-chroot /mnt chown sddm:sddm /var/lib/sddm /var/lib/sddm/state.conf >/dev/null 2>&1 || true
arch-chroot /mnt systemctl enable sddm.service >/dev/null 2>&1 || true
}
chroot_bash() {
HOME=/home/$OMARCHY_USER \
arch-chroot -u $OMARCHY_USER /mnt/ \
env OMARCHY_CHROOT_INSTALL=1 \
OMARCHY_USER_NAME="$(<user_full_name.txt)" \
OMARCHY_USER_EMAIL="$(<user_email_address.txt)" \
OMARCHY_MIRROR="$OMARCHY_MIRROR" \
USER="$OMARCHY_USER" \
HOME="/home/$OMARCHY_USER" \
/bin/bash "$@"
}
if [[ $(tty) == "/dev/tty1" ]]; then
use_omarchy_helpers
run_configurator
install_arch
install_omarchy
fi fi
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
if [[ -f /root/omarchy_iso_ref ]]; then
export OMARCHY_ISO_REF="$(cat /root/omarchy_iso_ref)"
fi
if [[ -f /usr/share/omarchy-iso/package-targets ]]; then
# shellcheck disable=SC1091
source /usr/share/omarchy-iso/package-targets
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
fi
export OMARCHY_PATH=/usr/share/omarchy
export OMARCHY_INSTALL=$OMARCHY_PATH/install
export OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log
if [[ -f /usr/share/omarchy-iso/install-debug ]]; then
export OMARCHY_INSTALL_DEBUG=1
fi
# Tokyo Night palette so the live VT matches the installed look.
set_tokyo_night_colors() {
echo -en "\e]P01a1b26"; echo -en "\e]P1f7768e"; echo -en "\e]P29ece6a"
echo -en "\e]P3e0af68"; echo -en "\e]P47aa2f7"; echo -en "\e]P5bb9af7"
echo -en "\e]P67dcfff"; echo -en "\e]P7a9b1d6"; echo -en "\e]P8414868"
echo -en "\e]P9f7768e"; echo -en "\e]PA9ece6a"; echo -en "\e]PBe0af68"
echo -en "\e]PC7aa2f7"; echo -en "\e]PDbb9af7"; echo -en "\e]PE7dcfff"
echo -en "\e]PFc0caf5"
echo -en "\033[0m"
clear
}
# foot sets the same palette from foot.ini; these escapes are VT-only.
[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] || set_tokyo_night_colors
mkdir -p /var/log
touch "$OMARCHY_INSTALL_LOG_FILE"
export COLUMNS=$(tput cols)
export LINES=$(tput lines)
exec > >(tee >(sed -u 's/\x1b\[[0-9;?]*[A-Za-z]//g' >>"$OMARCHY_INSTALL_LOG_FILE") 2>/dev/null) 2>/dev/tty
export CLICOLOR_FORCE=1
export FORCE_COLOR=1
if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
echo "=== Omarchy ISO debug build ==="
[[ -f /usr/share/omarchy-iso/build-info ]] && cat /usr/share/omarchy-iso/build-info
pacman -Q omarchy-settings omarchy-keyring 2>/dev/null || true
echo "================================"
fi
# Warm the page cache for the bundled packages while the user works through the
# wizard. The install reads ~3GB out of the offline mirror, and pacman consumes
# it at only ~33MB/s, so on media slower than that the install is read-bound and
# every byte cached here is a byte it never waits for. On faster media this costs
# nothing but otherwise-idle bandwidth: the medium is untouched while the user
# types, and the target disk it writes to later is a different device.
#
# Clean page cache only, so the kernel reclaims it under pressure instead of
# OOMing, and a budget so small machines never evict what was just warmed.
# Set OMARCHY_NO_PREFETCH=1 to A/B the same ISO with this disabled.
warm_offline_mirror() {
local mirror=/var/cache/omarchy/mirror/offline
local budget_kb spent_kb=0 size_kb path
[[ ${OMARCHY_NO_PREFETCH:-} == 1 ]] && return 0
[[ -d $mirror ]] || return 0
budget_kb=$(($(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo) / 2))
((budget_kb > 262144)) || return 0
# Largest first: the install reads most of the mirror, so when the budget
# cannot cover all of it this still front-loads the bytes that dominate.
while read -r size_kb path; do
((spent_kb + size_kb > budget_kb)) && continue
cat -- "$path" >/dev/null 2>&1 || true
spent_kb=$((spent_kb + size_kb))
done < <(du -k "$mirror"/*.pkg.tar.zst 2>/dev/null | sort -rn)
}
warm_offline_mirror &
warm_pid=$!
trap 'kill "$warm_pid" 2>/dev/null' EXIT
# The CJK console's font comes from the offline mirror's noto-fonts-cjk, not the live image.
unpack_cjk_font() {
local packages=(/var/cache/omarchy/mirror/offline/noto-fonts-cjk-*.pkg.tar.zst)
if ((${#packages[@]} != 1)) || [[ ! -f ${packages[0]} ]]; then
echo "Expected one noto-fonts-cjk package in the offline mirror, found: ${packages[*]}" >&2
return 1
fi
bsdtar -xf "${packages[0]}" -C / usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc
}
cd /root
# Autoinstall: a cidata drive carrying the configurator's own output files
# stands in for the wizard. omarchy-cidata-load copies them into /root and
# everything downstream runs the ordinary path against ordinary inputs.
if /usr/local/bin/omarchy-cidata-load; then
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
export OMARCHY_UI_INTERACTIVE=no
else
# Chinese moves to cage + foot, which draw Chinese; English moves back to the VT, which applies keymaps.
cjk_relaunch=10
vt_return=11
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
export OMARCHY_VT_RETURN=$vt_return
elif command -v cage >/dev/null; then
export OMARCHY_CJK_RELAUNCH=$cjk_relaunch
fi
./configurator && status=0 || status=$?
while ((status == cjk_relaunch)); do
cjk_runtime=/run/omarchy-cjk-console
mkdir -p -m 700 "$cjk_runtime"
rm -f "$cjk_runtime/cjk-console-started" "$cjk_runtime/return-to-vt"
if ! unpack_cjk_font; then
echo "The Chinese font could not be unpacked from the offline mirror; continuing in English."
unset OMARCHY_CJK_RELAUNCH
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
continue
fi
cage_status=0
env -u OMARCHY_CJK_RELAUNCH XDG_RUNTIME_DIR=$cjk_runtime OMARCHY_CJK_CONSOLE=1 OMARCHY_INSTALL_LANGUAGE=zh \
LANG=C.UTF-8 WLR_RENDERER=pixman cage -- foot -c /usr/share/omarchy-iso/foot.ini "$0" || cage_status=$?
if [[ -f $cjk_runtime/return-to-vt ]]; then
OMARCHY_INSTALL_LANGUAGE=en ./configurator && status=0 || status=$?
elif [[ -f $cjk_runtime/cjk-console-started ]]; then
exit "$cage_status"
else
echo "The Chinese console could not start (cage exit $cage_status); continuing in English."
unset OMARCHY_CJK_RELAUNCH
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
fi
done
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && ((status == vt_return)); then
touch "$XDG_RUNTIME_DIR/return-to-vt"
exit 0
fi
((status == 0)) || exit "$status"
fi
# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on
# their own — the owner completes setup at first boot. Signalled by the config's
# defer_provisioning flag (interactive) or the defer-provisioning marker (cidata).
# Parse the flag with jq (the same JSON semantics the orchestrator uses) rather
# than a line regex, so a reformatted config can't read as a direct install.
if [[ -f /root/defer-provisioning ]] ||
[[ "$(jq -r '.omarchy_install.defer_provisioning // false' /root/user_configuration.json 2>/dev/null)" == "true" ]]; then
export OMARCHY_UI_DEFER_PROVISIONING=yes
fi
# Only Chinese reaches the install from the CJK console; English returns to the VT.
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
export OMARCHY_INSTALL_LANG=zh
fi
# The foreground dashboard is now the sole visible install UI owner. It starts
# the actual installer as a non-interactive child, logs child output, waits for
# completion, then renders the final installed-time/reboot prompt itself.
export OMARCHY_DASHBOARD_TTY="$(tty)"
rm -f /run/omarchy-install/state.json
/usr/local/bin/omarchy-install-dashboard \
"$OMARCHY_INSTALL_LOG_FILE" \
/run/omarchy-install/state.json \
-- \
/usr/local/bin/omarchy-iso-install \
--config /root/user_configuration.json \
--creds /root/user_credentials.json \
--full-name-file /root/user_full_name.txt \
--email-file /root/user_email_address.txt \
--encrypt-file /root/user_encrypt_installation.txt \
--authorized-keys-file /root/authorized_keys \
--tailscale-authkey-file /root/tailscale_authkey \
--defer-provisioning-file /root/defer-provisioning
File diff suppressed because it is too large. Load diff
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
#
# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox,
# libvirt and Packer all know how to attach one) carrying the configurator's own
# output files stands in for the wizard. Copy those files into /root and
# everything downstream runs the ordinary path against ordinary inputs.
#
# Exits 0 when an autoinstall drive was loaded and the configurator can be
# skipped. The required pair is the same one the orchestrator treats as
# mandatory; the rest are optional there too, so don't be stricter here.
# Anything less than the pair means this isn't an autoinstall drive, and the
# caller falls back to the wizard.
#
# A `defer-provisioning` marker file stands in for user_credentials.json: deferred-provisioning
# installs defer user creation to first boot, so imaging rigs ship no credentials.
#
# The optional argument prefixes every path the script touches; tests use it to
# run against a sandbox instead of the live system.
set -uo pipefail
root=${1:-}
by_label=$root/dev/disk/by-label
dest=$root/root
mnt=$root/run/cidata
# The by-label symlinks come from udev, and on a fast boot this can run before
# device enumeration has finished. Wait for the queue to drain instead of
# racing it and silently dropping a headless install into the wizard.
udevadm settle 2>/dev/null || true
device=""
for label in cidata CIDATA; do
[[ -e $by_label/$label ]] && { device=$by_label/$label; break; }
done
[[ -n $device ]] || exit 1
mkdir -p "$mnt"
mount -o ro "$device" "$mnt" || exit 1
# Clear any inputs a previous load in this live session left in /root before
# copying the current drive: a stale `defer-provisioning` marker or credentials file would
# otherwise force a fresh drive back into deferred provisioning or hand it an old
# LUKS passphrase. The interactive wizard writes these same names, but a
# cidata load always precedes it, so nothing of the user's is discarded here.
optional_inputs=(user_credentials.json defer-provisioning user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey)
for file in user_configuration.json "${optional_inputs[@]}"; do
rm -f "$dest/$file"
done
loaded=1
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then
files=("$mnt"/user_configuration.json)
for file in "${optional_inputs[@]}"; do
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
done
if cp "${files[@]}" "$dest"/; then
loaded=0
else
# A partial copy must not leave files behind: the wizard cleans up only
# what it writes itself, so a half-copied authorized_keys would leak into
# the interactive install that follows.
for file in "${files[@]}"; do
rm -f "$dest/${file##*/}"
done
fi
fi
umount "$mnt"
exit $loaded
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,124 @@
#!/bin/bash
#
# Say whether an install failure was the install medium, and say it in words
# that name the fix.
#
# The ISO's offline mirror is bind-mounted onto the target's package cache for
# the duration of pacstrap (see _mount_offline_package_cache), so a package
# pacman calls corrupt under that path is a byte that came off the medium --
# nothing else ever writes there. That much is certain, and it is the part
# worth saying out loud, because the failure screen otherwise ends at "get help
# at discord" with the word USB nowhere on it.
#
# What is not certain is which way the medium failed. Bytes written wrong and
# bytes read wrong produce the same checksum mismatch, and pacstrap runs with
# --noconfirm, which answers yes to pacman's "delete it?" and unlinks the
# package through the bind mount before anything here can weigh it. So the
# usual verdict names the medium and gives both remedies in the order worth
# trying. When the package does survive, its checksum in the mirror's database
# tells the two apart, and then this says which.
#
# Prints the diagnosis on stdout, one line per line, headline first. Exits 1
# with no output when there is nothing here to diagnose, so the caller can ask
# unconditionally.
set -uo pipefail
TARGET_PACKAGE_CACHE="${OMARCHY_TARGET_PACKAGE_CACHE:-/mnt/var/cache/pacman/pkg}"
OFFLINE_MIRROR="${OMARCHY_OFFLINE_MIRROR:-/var/cache/omarchy/mirror/offline}"
if (( $# == 0 )); then
echo "Usage: omarchy-install-diagnose-media <log-file>..." >&2
exit 2
fi
# Only packages under the target cache count. A corrupt package anywhere else
# was not read off this ISO, and guessing wrong here sends someone to buy a USB
# stick they did not need.
corrupt_package() {
local log match package=""
for log in "$@"; do
[[ -f $log ]] || continue
match=$(sed -n "s|.*${TARGET_PACKAGE_CACHE}/\([^ ]*\) is corrupted.*|\1|p" "$log" | tail -n 1)
[[ -n $match ]] && package="$match"
done
printf '%s' "$package"
}
expected_checksum() {
local package="$1" reader
[[ -f $OFFLINE_MIRROR/offline.db ]] || return 1
if command -v bsdtar >/dev/null 2>&1; then
reader=(bsdtar -xOf "$OFFLINE_MIRROR/offline.db")
else
reader=(tar -xOzf "$OFFLINE_MIRROR/offline.db")
fi
# repo-add writes %FILENAME% before %SHA256SUM% in every desc record, so the
# name in hand when a checksum arrives is the name it belongs to. The wanted
# name comes through the environment rather than -v, which would read a
# backslash in a package version as an escape sequence.
"${reader[@]}" 2>/dev/null | want="$package" awk '
BEGIN { want = ENVIRON["want"] }
$0 == "%FILENAME%" { getline name; next }
$0 == "%SHA256SUM%" { getline sha; if (name == want) { print sha; exit } }
'
}
actual_checksum() {
local package="$1" sum
[[ -f $OFFLINE_MIRROR/$package ]] || return 1
sum=$(sha256sum "$OFFLINE_MIRROR/$package" 2>/dev/null) || return 1
printf '%s' "${sum%% *}"
}
package=$(corrupt_package "$@")
if [[ -z $package ]]; then
exit 1
fi
# The name reaches a terminal, and a package version may legally hold a
# backslash that the renderer would read as an escape. Strip anything a package
# filename has no business containing before printing it.
display_package="${package//[^A-Za-z0-9._+:@%-]/}"
expected=$(expected_checksum "$package")
actual=$(actual_checksum "$package")
# The package name goes on a line of its own throughout. Names run past fifty
# characters and the screen is only as wide as the logo, so a name sharing a
# line with the sentence that explains it truncates the explanation away. The
# blank line after it separates what happened from what to do about it.
if [[ -n $expected && -n $actual ]]; then
if [[ $expected == "$actual" ]]; then
# Intact now, so the bytes were misread rather than written wrong, and
# re-downloading fixes neither of the things that could have done it.
echo "The install medium was misread"
echo "This package is intact on this ISO, so the download and the write were fine:"
echo "$display_package"
echo
echo "Try another USB port or another stick, and test this machine's memory"
else
echo "The install medium is damaged"
echo "This package no longer matches the checksum it was built with:"
echo "$display_package"
echo
echo "Re-download the ISO, verify it with sha256sum, and write the USB again"
echo "If a verified ISO fails here too, the USB stick itself is the problem"
fi
exit 0
fi
# The usual case: pacman deleted the package it rejected, so the medium is the
# answer but the manner of its failure is not recoverable from here.
echo "The install medium is damaged or misread"
echo "pacman rejected this package, read straight off this ISO:"
echo "$display_package"
echo
echo "Verify your download with sha256sum, then write the USB again"
echo "If the download verifies, the stick or this machine's memory is at fault"
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
#
# Clean up any holders / mounts / LVM / crypt devices on the install disk
# before archinstall starts wiping. Idempotent — called once per install attempt
# from the orchestrator's cleanup_disk phase.
#
# Ported verbatim from .automated_script.sh's cleanup_install_disk(). Kept in
# bash because lsblk / findmnt / lvm / cryptsetup are all easier to drive
# here than from Python.
set -uo pipefail
if [[ ${1:-} == --protected ]]; then
target="${2:-}"
[[ -n $target ]] || { echo "Usage: $0 --protected <target>" >&2; exit 1; }
findmnt -R "$target" >/dev/null 2>&1 && umount -R "$target" || true
exit 0
fi
disk="${1:-}"
[[ -n $disk && -b $disk ]] || { echo "Need a block device argument; got '$disk'" >&2; exit 1; }
echo "Cleaning up existing holders on install disk: $disk"
findmnt -R /mnt >/dev/null && umount -R /mnt || true
while read -r dev; do
[[ -b $dev ]] || continue
swapoff "$dev" 2>/dev/null || true
while read -r target; do
[[ -n $target ]] && umount "$target" 2>/dev/null || true
done < <(findmnt -rn -S "$dev" -o TARGET 2>/dev/null || true)
done < <(lsblk -rnpo PATH "$disk")
while read -r dev type; do
[[ $type == disk || $type == part || $type == crypt ]] || continue
while read -r vg; do
[[ -n $vg ]] && vgchange -an "$vg" 2>/dev/null || true
done < <(pvs --noheadings -o vg_name "$dev" 2>/dev/null | awk '{$1=$1; print}' | sort -u)
done < <(lsblk -rnpo PATH,TYPE "$disk")
while read -r dev type; do
[[ $type == crypt ]] && cryptsetup close "$dev" 2>/dev/null || true
done < <(lsblk -rnpo PATH,TYPE "$disk")
blockdev --flushbufs "$disk" 2>/dev/null || true
partprobe "$disk" 2>/dev/null || true
udevadm settle || true
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
#
# Live-ISO entry point for the Omarchy install orchestrator. Invoked from
# /root/.automated_script.sh after the configurator wizard has produced its
# user_*.json / user_*.txt files. The outer shim owns install logging.
set -eEo pipefail
# Parse our args; convert to env vars before exec'ing Python so archinstall's
# own argparse (which runs at module import time inside the orchestrator)
# doesn't choke on flags it doesn't recognize.
while [[ $# -gt 0 ]]; do
case $1 in
--config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;;
--creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;;
--full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;;
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
--defer-provisioning-file) export OMARCHY_INSTALL_DEFER_PROVISIONING_FILE="$2"; shift 2 ;;
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
esac
done
cd /usr/share/omarchy-iso
exec python -m orchestrator.main
@@ -0,0 +1,131 @@
# Disk partitioning helpers, shared by the ISO configurator and its tests.
#
# The rule here is: never predict a partition number. parted fills the lowest
# free GPT slot, not "highest existing + 1", so any disk whose numbering has a
# hole — exactly what deleting a partition to free space leaves behind, which
# is what our own partition tool tells the user to do — hands back a number we
# did not choose. Everything below reads back what was actually created.
#
# Sourced, not executed. The configurator defines abort() and
# disk_abort_hook(); tests get the plain fallbacks.
# Partitions this run created, in creation order. rollback_created_parts()
# undoes exactly these and nothing else.
created_parts=()
# Set by create_partition() instead of being printed: a command substitution
# would run it in a subshell and lose the created_parts bookkeeping.
created_partition_number=""
# Compute partition device path, handling NVMe/mmcblk's pN naming.
partition_path() {
local _disk="$1" _num="$2"
if [[ "$_disk" == *nvme* || "$_disk" == *mmcblk* ]]; then
echo "${_disk}p${_num}"
else
echo "${_disk}${_num}"
fi
}
# Partition numbers as the on-disk GPT reports them. parted rather than lsblk
# on purpose: this is the table parted itself is about to modify, so discovery
# never depends on the kernel having re-read the partition table yet — and the
# same code works against an image file in tests, where lsblk sees nothing.
partition_numbers() {
parted -ms "$1" unit B print 2>/dev/null | tail -n +3 | cut -d: -f1
}
partition_size_bytes() {
parted -ms "$1" unit B print 2>/dev/null |
awk -F: -v n="$2" '$1 == n { gsub(/B/, "", $4); print $4; exit }'
}
# Fail loudly. The former version looped over sleep and returned its status,
# so a device that never appeared was indistinguishable from one that did.
wait_for_device() {
local i
for i in $(seq 1 10); do
[[ -b "$1" ]] && return 0
udevadm settle 2>/dev/null || true
sleep 1
done
return 1
}
_disk_abort() {
if declare -F disk_abort_hook >/dev/null; then
disk_abort_hook "$1"
elif declare -F abort >/dev/null; then
abort "$1"
fi
echo "Error: $1" >&2
exit 1
}
# Run a disk-writing command, fold its stderr into stdout, and abort on a
# non-zero exit. The fold matters: .automated_script.sh tees stdout into
# /var/log/omarchy-install.log but sends stderr straight to the tty (gum draws
# its TUI there), so an unwrapped failure leaves no trace in the log the user
# uploads — and the configurator's next screen clears it off the display too.
disk_step() {
local desc="$1"
shift
local output status=0
output=$("$@" 2>&1) || status=$?
[[ -n $output ]] && printf '%s\n' "$output"
(( status == 0 )) && return 0
_disk_abort "$desc failed (exit $status)"
}
# Create one partition and report the number parted actually assigned.
# Returns non-zero without touching created_parts if anything looks wrong;
# the caller decides how loudly to fail.
create_partition() {
local disk="$1" start="$2" end="$3" fstype="$4" name="$5"
local before after num actual want tolerance
created_partition_number=""
# Lexicographic sort on both sides: comm needs its inputs ordered the same
# way it compares them, and `sort -n` (1, 2, 10) is not that order.
before=$(partition_numbers "$disk" | sort)
parted --script "$disk" mkpart primary "$fstype" "${start}B" "${end}B" || return 1
partprobe "$disk" 2>/dev/null || true
udevadm settle 2>/dev/null || true
after=$(partition_numbers "$disk" | sort)
num=$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after") | head -1)
[[ -n $num ]] || return 1
# The number must be genuinely new. This is the safety property that keeps a
# numbering mistake from ever formatting a partition somebody else is using;
# it is cheaper and more reliable than sniffing the target for signatures,
# which would false-positive on remnants left in freed space.
grep -qx "$num" <<<"$before" && return 1
actual=$(partition_size_bytes "$disk" "$num")
[[ -n $actual ]] || return 1
want=$((end - start))
tolerance=$((1024 * 1024))
(( actual >= want - tolerance && actual <= want + tolerance )) || return 1
parted --script "$disk" name "$num" "$name" || true
created_parts+=("$num")
created_partition_number="$num"
}
# Undo the partitions this run created, highest number first. Scoped strictly
# to created_parts: without this, a failed install leaves the user's freed
# space occupied by orphans, and the retry reports "not enough free space"
# with no way to connect that to what just happened.
rollback_created_parts() {
local disk="$1" n
(( ${#created_parts[@]} > 0 )) || return 0
for n in $(printf '%s\n' "${created_parts[@]}" | sort -rn); do
parted --script "$disk" rm "$n" >/dev/null 2>&1 || true
done
partprobe "$disk" 2>/dev/null || true
created_parts=()
}
@@ -0,0 +1,29 @@
# The Chinese installer's console: the VT's Tokyo Night palette, with Noto Sans Mono CJK from the offline mirror.
[main]
font=Noto Sans Mono CJK SC:size=13
# Noto's tall default rows leave the summary too few lines for the logo.
line-height=22px
[colors-dark]
background=1a1b26
foreground=a9b1d6
regular0=1a1b26
regular1=f7768e
regular2=9ece6a
regular3=e0af68
regular4=7aa2f7
regular5=bb9af7
regular6=7dcfff
regular7=a9b1d6
bright0=414868
bright1=f7768e
bright2=9ece6a
bright3=e0af68
bright4=7aa2f7
bright5=bb9af7
bright6=7dcfff
bright7=c0caf5
# cage draws no title bar, and a reserved one leaves a black strip.
[csd]
preferred=none
Whitespace-only changes.
@@ -0,0 +1,254 @@
"""Thin compatibility wall around the archinstall Python library.
ONLY this module imports from archinstall. Everything else uses these helpers.
If archinstall's API churns, the blast radius is contained here.
Tested against archinstall 4.4 (Python 3.14).
The canonical call sequence (mirrored from archinstall.scripts.guided.py) is:
FilesystemHandler(disk_config).perform_filesystem_operations()
with Installer(mountpoint, disk_config, kernels=, silent=) as inst:
# guided.py:84-85 SKIPS this for DiskLayoutType.Pre_mount — pre-mounted
# configs do their own mounting before the Installer context opens.
if disk_config.config_type != DiskLayoutType.Pre_mount:
inst.mount_ordered_layout()
sanity_check(inst)
inst.generate_key_files() # encrypted only
inst.set_mirrors(handler, mirror_config, on_target=False)
inst.minimal_installation(...) # base + linux pacstrap
inst.set_mirrors(handler, mirror_config, on_target=True)
inst.setup_swap(algo=...)
inst.create_users(users)
inst.add_additional_packages(packages)
inst.set_timezone(tz)
inst.activate_time_synchronization()
inst.set_user_password(root_user)
inst.enable_service(services)
inst.genfstab()
Our orchestrator installs Omarchy's Limine files directly instead of invoking
archinstall's bootloader helper, so EFI paths and efibootmgr labels are ours
from the start.
"""
from __future__ import annotations
import importlib
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
# Imports are top-level so a missing/incompatible archinstall surfaces at
# orchestrator startup, not deep inside a phase.
from archinstall.lib.args import ArchConfig, ArchConfigHandler
from archinstall.lib.authentication.authentication_handler import AuthenticationHandler
from archinstall.lib.disk.filesystem import FilesystemHandler
from archinstall.lib.disk.utils import get_parent_device_path, get_unique_path_for_device, udev_sync
from archinstall.lib.hardware import SysInfo
from archinstall.lib.installer import Installer
from archinstall.lib.mirror.mirror_handler import MirrorListHandler
from archinstall.lib.models import Bootloader
from archinstall.lib.models.device import DiskLayoutType, EncryptionType
from archinstall.lib.models.users import User
from .ui import info
def load_arch_config(config_path: Path, creds_path: Path) -> ArchConfigHandler:
"""Build an ArchConfigHandler from on-disk JSON.
archinstall's ArchConfigHandler reads --config / --creds from sys.argv
via argparse at construction time (lib/args.py:_parse_args). It does
NOT consult env vars for these paths. We can't pass them on our real
argv because the wrapper strips them before exec'ing Python (so other
archinstall arg-parsing code doesn't choke on our flags), so we hand
archinstall a synthetic argv just for this call.
"""
import sys
saved_argv = sys.argv
sys.argv = [
saved_argv[0] if saved_argv else "omarchy-iso-install",
"--config", str(config_path),
"--creds", str(creds_path),
]
try:
return ArchConfigHandler()
finally:
sys.argv = saved_argv
def make_mirror_handler(offline: bool = True) -> MirrorListHandler:
return MirrorListHandler(offline=offline, verbose=False)
def perform_filesystem_operations(arch_config: ArchConfig) -> None:
"""Partition, format, encrypt. archinstall's FilesystemHandler is its own
object (separate from Installer) so we run it before opening the
Installer context manager.
parted's partition-table commit races udev: the disk wipe and the first
BLKPG partition registration trigger probes that briefly hold the disk
open, and the kernel then refuses to register the remaining partitions
(_ped.IOException: "Partition(s) ... have been written, but we have been
unable to inform the kernel"). The table is written correctly when that
happens — only the kernel's view is stale — so settle udev and redo the
operations. The wipe/partition/format sequence is idempotent."""
if not arch_config.disk_config:
raise RuntimeError("disk_config missing from arch config")
handler = FilesystemHandler(arch_config.disk_config)
# archinstall's perform_filesystem_operations defaults to a ~4.5s
# "5...4...3...2...1" countdown (FilesystemHandler._final_warning) before it
# wipes the disk. That warning is meant for its interactive TUI; in our
# orchestrated install it renders nowhere and just sleeps. Suppress it when
# the running archinstall still takes the flag — newer releases dropped both
# the countdown and the parameter, so only pass it when it's accepted.
fs_kwargs = (
{"show_countdown": False}
if _method_accepts(handler.perform_filesystem_operations, "show_countdown")
else {}
)
attempts = 3
for attempt in range(1, attempts + 1):
udev_sync()
try:
handler.perform_filesystem_operations(**fs_kwargs)
return
except Exception as exc:
if attempt == attempts or "unable to inform the kernel" not in str(exc):
raise
info(f"› partition commit lost a udev race (attempt {attempt}/{attempts}); retrying")
@contextmanager
def open_installer(
arch_config: ArchConfig,
mountpoint: Path,
silent: bool = True,
) -> Iterator[Installer]:
"""Yield an open Installer; ensures __exit__ runs even on exception so
/mnt is left clean for a retry."""
if not arch_config.disk_config:
raise RuntimeError("disk_config missing from arch config")
with Installer(
mountpoint,
arch_config.disk_config,
kernels=arch_config.kernels,
silent=silent,
) as installer:
yield installer
def is_encrypted(arch_config: ArchConfig) -> bool:
disk = arch_config.disk_config
if not disk or not disk.disk_encryption:
return False
return disk.disk_encryption.encryption_type != EncryptionType.NO_ENCRYPTION
def is_pre_mount(arch_config: ArchConfig) -> bool:
return bool(
arch_config.disk_config
and arch_config.disk_config.config_type == DiskLayoutType.Pre_mount
)
def bootloader_enabled(arch_config: ArchConfig) -> bool:
bl = arch_config.bootloader_config
return bool(bl and bl.bootloader != Bootloader.NO_BOOTLOADER)
def is_limine(arch_config: ArchConfig) -> bool:
bl = arch_config.bootloader_config
return bool(bl and bl.bootloader == Bootloader.Limine)
def has_uefi() -> bool:
return SysInfo.has_uefi()
def parent_device_path(dev_path: Path) -> Path:
return get_parent_device_path(dev_path)
def unique_device_path(dev_path: Path) -> Path | None:
return get_unique_path_for_device(dev_path)
def _application_handler():
"""Return archinstall's application handler across archinstall versions."""
module = importlib.import_module("archinstall.lib.applications.application_handler")
handler = getattr(module, "application_handler", None)
if handler is not None and callable(getattr(handler, "install_applications", None)):
return handler
handler_class = getattr(module, "ApplicationHandler", None)
if handler_class is None:
raise RuntimeError("archinstall application handler is unavailable")
try:
handler = handler_class()
except TypeError as exc:
raise RuntimeError("archinstall ApplicationHandler cannot be constructed") from exc
if not callable(getattr(handler, "install_applications", None)):
raise RuntimeError("archinstall ApplicationHandler has no install_applications method")
return handler
def _method_accepts(method, name: str) -> bool:
"""Return whether a bound archinstall method accepts a named argument.
Do not use inspect.signature here: Python 3.14 may evaluate archinstall's
lazy annotations, and some archinstall releases annotate with names that are
only imported under TYPE_CHECKING.
"""
fn = getattr(method, "__func__", method)
code = getattr(fn, "__code__", None)
if code is None:
return True
positional = code.co_varnames[:code.co_argcount]
kwonly = code.co_varnames[code.co_argcount:code.co_argcount + code.co_kwonlyargcount]
return name in (*positional, *kwonly)
def sanity_check(installer) -> None:
"""Archinstall 4.5 removed offline; retain it for older releases."""
options = {"skip_ntp": True, "skip_wkd": True}
if _method_accepts(installer.sanity_check, "offline"):
options["offline"] = True
installer.sanity_check(**options)
def _method_accepts_users(method) -> bool:
return _method_accepts(method, "users")
def install_applications(installer: Installer, arch_config: ArchConfig) -> None:
"""Install archinstall application selections such as PipeWire audio.
The configurator still writes archinstall's audio_config. We own phase
ordering now, but should not silently drop archinstall's hardware-aware
application installers (SOF/ALSA firmware detection, PipeWire packages,
Bluetooth selections, etc.).
"""
app_config = arch_config.app_config
if not app_config:
return
users = arch_config.auth_config.users if arch_config.auth_config else None
handler = _application_handler()
install_applications_method = handler.install_applications
if _method_accepts_users(install_applications_method):
install_applications_method(installer, app_config, users)
else:
install_applications_method(installer, app_config)
def root_user(arch_config: ArchConfig) -> User | None:
auth = arch_config.auth_config
if not auth or not auth.root_enc_password:
return None
return User("root", auth.root_enc_password, False)
@@ -0,0 +1,54 @@
r"""Reading text out of the commands the installer shells out to.
Firmware and filesystem metadata reach the orchestrator as command output, and
neither is obliged to be UTF-8: an HP ProBook's NVRAM held a legacy BBS entry
whose device path began `BBS(HD,\x80\x7f\xff\x04`, efibootmgr printed those
bytes verbatim, and subprocess's strict text decoding turned a boot entry
nothing here reads into a failed install.
So capture() replaces what it cannot decode, and the boot-entry parsing that
reads its output stays tolerant on purpose: an entry's text is its label and its
device path together, and the junk lives in the path, so refusing one would abort
the install this exists to let finish. Nothing mangled reaches efibootmgr from
there either way — only four-digit ASCII boot numbers become arguments, and a
boot order is filtered through the entries that parsed — so a label that loses a
byte costs a stale entry left behind rather than a broken system.
An identifier is the other case entirely: require_text() refuses a UUID or a
device name that came through mangled, because that one is written into fstab and
the kernel cmdline, or handed to mount.
"""
from __future__ import annotations
import subprocess
REPLACEMENT = "\ufffd"
def capture(cmd: list[str], *, check: bool = False) -> subprocess.CompletedProcess:
"""Run cmd and return it with stdout/stderr decoded, bad bytes replaced."""
return subprocess.run(
cmd,
check=check,
capture_output=True,
text=True,
errors="replace",
)
def require_text(value: str, what: str) -> str:
"""Refuse a value the installed system depends on if bytes were replaced in it.
Validation reads these back from the same command that wrote them, so a
mangled UUID agrees with itself and ships a machine that cannot find its
root at boot. Stopping here says why while someone is still watching.
"""
if REPLACEMENT in value:
raise RuntimeError(f"{what} is not valid text: {value!r}")
return value
def capture_identifier(cmd: list[str], what: str) -> str:
"""Capture a single value the installed system will depend on."""
return require_text(capture(cmd, check=True).stdout.strip(), what)
@@ -0,0 +1,241 @@
"""Install context: parsed configurator output, invocation paths, and a
mutable `state` dict for objects that live across phases (e.g., the
archinstall config handler and mirror list handler)."""
from __future__ import annotations
import json
import os
import secrets
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .keyboard import validate_input_selection
from .region import GLOBAL, resolve_region
@dataclass
class InstallContext:
config_path: Path
creds_path: Path
full_name: str
email: str
encrypt: bool
authorized_keys_path: Path | None
tailscale_authkey_path: Path | None
user_configuration: dict
user_credentials: dict
arch_config_path: Path
omarchy_install: dict[str, Any]
defer_provisioning: bool = False
region: str = GLOBAL
target: Path = Path("/mnt")
omarchy_path: Path = Path("/usr/share/omarchy")
state_dir: Path = Path("/run/omarchy-install")
log_path: Path = Path("/var/log/omarchy-install.log")
target_log_path: Path = Path("/mnt/var/log/omarchy-install.log")
# Mutable per-run state shared across phases (e.g., 'arch_config_handler',
# 'mirror_handler'). Phases populate as needed; later phases read.
state: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_env(cls) -> "InstallContext":
config_str = os.environ.get("OMARCHY_INSTALL_CONFIG")
creds_str = os.environ.get("OMARCHY_INSTALL_CREDS")
if not config_str or not creds_str:
raise RuntimeError("OMARCHY_INSTALL_CONFIG and OMARCHY_INSTALL_CREDS must be set")
config_path = Path(config_str)
creds_path = Path(creds_str)
user_configuration = json.loads(config_path.read_text())
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
# Autoinstall configs may omit kernels, which makes archinstall default
# to stock linux. Apply the same hardware default as the configurator,
# while honoring an explicit selection (including storage-only configs).
if not user_configuration.get("kernels"):
kernel = (omarchy_install.get("storage") or {}).get("kernel") or _default_kernel()
user_configuration["kernels"] = [kernel]
validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", ""))
# Resolved before any disk is touched, so an autoinstall naming a
# region this ISO lacks fails without wiping anything.
region = resolve_region(omarchy_install, user_configuration.get("timezone"))
omarchy_install["region"] = region
# Deferred provisioning: the whole system installs but user creation is deferred to
# first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by
# an `defer-provisioning` marker file on an autoinstall drive, which also replaces the
# user_credentials.json requirement.
defer_provisioning_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE"))
defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None
omarchy_install["defer_provisioning"] = defer_provisioning
if creds_path.exists():
user_credentials = json.loads(creds_path.read_text())
elif defer_provisioning:
user_credentials = {"users": []}
else:
raise RuntimeError(f"credentials file missing: {creds_path}")
if defer_provisioning:
# Deferred provisioning promises no account until first boot. A supplied
# credentials file (a rig handing over its LUKS passphrase) must
# not smuggle in users or a root password.
encryption_password = user_credentials.get("encryption_password")
user_credentials = {"users": []}
if encryption_password:
user_credentials["encryption_password"] = encryption_password
arch_configuration = dict(user_configuration)
arch_configuration.pop("omarchy_install", None)
if defer_provisioning:
# The userless invariant covers the archinstall config too: a
# combined/legacy config could carry account authentication that
# archinstall would still act on (users, a root password). Strip
# every such field so no account is created before first boot.
_strip_account_fields(arch_configuration)
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
state_dir.mkdir(parents=True, exist_ok=True)
if defer_provisioning:
# Encrypted deferred-provisioning installs have no user password to protect LUKS
# with. Generate a throwaway passphrase (staged for first-boot
# re-key by the stage_provisioning_state phase) and hand it to archinstall
# through both places it may look: the disk_encryption block and
# the credentials file.
_inject_provisioning_encryption_password(arch_configuration, user_credentials)
creds_path = state_dir / "provisioning-user_credentials.json"
creds_path.write_text(json.dumps(user_credentials, indent=2) + "\n")
creds_path.chmod(0o600)
arch_config_path = state_dir / "archinstall-user_configuration.json"
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
ctx = cls(
config_path=config_path,
creds_path=creds_path,
full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")),
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"),
authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")),
tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
user_configuration=user_configuration,
user_credentials=user_credentials,
arch_config_path=arch_config_path,
omarchy_install=omarchy_install,
defer_provisioning=defer_provisioning,
region=region,
state_dir=state_dir,
)
disk_config = user_configuration.get("disk_config", {})
target_mount = omarchy_install.get("target_mount") or disk_config.get("mountpoint")
if target_mount:
ctx.target = Path(target_mount)
return ctx
@property
def username(self) -> str:
users = self.user_credentials.get("users") or []
if users:
return users[0]["username"]
if self.defer_provisioning:
return ""
raise RuntimeError("user_credentials.json contains no users")
@property
def mode(self) -> str:
if mode := self.omarchy_install.get("mode"):
return mode
cfg_type = self.user_configuration.get("disk_config", {}).get("config_type")
return "protected" if cfg_type == "pre_mounted_config" else "full_disk"
@property
def is_protected(self) -> bool:
return self.mode == "protected"
def _strip_account_fields(arch_configuration: dict) -> None:
"""Remove every field archinstall reads to create users or set a root
password, so it cannot ship a hidden provisioning account.
Encryption material lives elsewhere (disk_config.disk_encryption) and is
untouched."""
for key in ("!users", "!root-password", "root_enc_password", "users"):
arch_configuration.pop(key, None)
auth = arch_configuration.get("auth_config")
if isinstance(auth, dict):
for key in ("users", "root_enc_password"):
auth.pop(key, None)
def _inject_provisioning_encryption_password(arch_configuration: dict, user_credentials: dict) -> None:
"""Ensure an encrypted deferred-provisioning install has a LUKS passphrase archinstall can
use. Reuse one the input already carries (an autoinstall rig may supply
its own); otherwise generate a throwaway. Mutates the disk_encryption
block in place — arch_configuration shares it with user_configuration, so
the stage_provisioning_state phase can read the effective passphrase back."""
disk_encryption = (arch_configuration.get("disk_config") or {}).get("disk_encryption")
if disk_encryption is None:
return
password = disk_encryption.get("encryption_password") or user_credentials.get("encryption_password")
if not password:
password = secrets.token_urlsafe(24)
disk_encryption["encryption_password"] = password
user_credentials["encryption_password"] = password
def _default_kernel(pci_devices: Path = Path("/sys/bus/pci/devices")) -> str:
for device in pci_devices.glob("*"):
try:
vendor = (device / "vendor").read_text().strip().lower()
device_id = (device / "device").read_text().strip().lower()
except OSError:
continue
if vendor == "0x106b" and device_id in {"0x1801", "0x1802"}:
return "linux-t2"
return "linux-omarchy"
def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
disk_config = user_configuration.get("disk_config", {})
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
return {
"mode": mode,
"target_mount": disk_config.get("mountpoint") or "/mnt",
"boot": {
"esp_mount": "/boot",
"esp_path": "/EFI/limine",
"efi_binary": "limine_x64.efi",
"enable_fallback": mode == "full_disk",
},
"storage": {},
}
def _optional_path(path: str | None) -> Path | None:
"""A path the caller always passes but that need not exist. The live ISO
passes --authorized-keys-file on every install; only autoinstall drives put
a file there."""
if not path:
return None
p = Path(path)
return p if p.exists() else None
def _read_text(path: str | None) -> str:
if not path:
return ""
p = Path(path)
if not p.exists():
return ""
return p.read_text().strip()
@@ -0,0 +1,75 @@
"""Offline target keyboard configuration.
archinstall's set_keyboard_language boots the installed system in a
systemd-nspawn container just to run localectl. systemd-firstboot --root
produces the part of that output Omarchy actually consumes — KEYMAP for the
console plus the XKB* settings in vconsole.conf that omarchy's
detect-keyboard-layout.sh copies into Hyprland's kb_layout — without booting
anything. The Xorg 00-keyboard.conf that localectl also writes is not
generated: nothing on an Omarchy system reads it.
"""
from __future__ import annotations
from pathlib import Path
from .command import capture
def configure_keyboard(target: Path, language: str, input_method: str | None = None, xkb_layout: str = "") -> bool:
"""Write the console keymap into a mounted target without booting it.
Returns False for layouts localectl doesn't know, matching archinstall:
warn and keep the default. Every layout the configurator offers is known;
the guard is for the kb_layout an autoinstall drive can name freely.
"""
validate_input_selection(input_method, xkb_layout)
if not language.strip():
if input_method is None:
return True
language = "us"
# Input engines are separate from real console keymaps. The shared picker
# uses US for Chinese/Korean and carries its preference in omarchy_install.
if input_method is None:
input_method = "mozc" if language == "jp106" else "none"
result = capture(["localectl", "--no-pager", "list-keymaps"])
if result.returncode != 0:
detail = result.stderr.strip() or "localectl returned an error"
raise RuntimeError(f"Unable to list keyboard layouts: {detail}")
if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}:
if input_method != "none":
raise ValueError(f"Unknown keyboard language: {language}")
return False
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
# FONT= line archinstall's set_vconsole wrote earlier.
vconsole_path = target / "etc" / "vconsole.conf"
font = None
if vconsole_path.exists():
font = next(
(line for line in vconsole_path.read_text().splitlines() if line.startswith("FONT=")),
None,
)
result = capture(["systemd-firstboot", f"--root={target}", f"--keymap={language}", "--force"])
if result.returncode != 0:
detail = result.stderr.strip() or "systemd-firstboot returned an error"
raise RuntimeError(f"Unable to configure keyboard layout {language}: {detail}")
if font:
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
preference = target / "etc/omarchy/input-method"
preference.parent.mkdir(parents=True, exist_ok=True)
preference.write_text(f"INPUT_METHOD={input_method}\nXKB_LAYOUT={xkb_layout}\n")
return True
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", "la"}:
raise ValueError(f"Unknown input keyboard layout: {xkb_layout}")
@@ -0,0 +1,111 @@
"""Omarchy install orchestrator.
Single tool that owns the full install phase ordering, with archinstall used as
a library subsystem (not as the top-level installer).
The live-ISO wrapper consumes CLI args and passes configuration paths via
OMARCHY_INSTALL_* environment variables before Python starts. This keeps
archinstall's import-time CLI parsing from seeing Omarchy-specific flags.
"""
from __future__ import annotations
import sys
from .context import InstallContext
from .phases import PhaseError, run
from .ui import error, info
def build_phases(ctx: InstallContext):
"""Phase order. Each entry is (display name, callable taking InstallContext).
The ordering is the whole point of this orchestrator: package-install
hooks (limine-mkinitcpio-hook, in particular) and useradd happen at
points where their prerequisites are guaranteed to be in place.
Full-disk and protected installs use the same phase sequence. The
configurator only changes the JSON input: full-disk asks archinstall to
create/mount the layout, while protected provides an already-mounted target
and the partition details Omarchy needs for boot/fstab generation.
"""
from .phases_impl import (
prepare_live,
prepare_install_target,
arch_install_system,
configure_hibernation,
run_system_finalizer,
stage_provisioning_state,
finalize_limine_boot,
run_chroot_finalizer,
configure_dns_resolver,
configure_login,
configure_ssh_access,
configure_tailscale,
validate_boot,
create_factory_snapshot,
)
return [
("Preparing live environment", prepare_live),
("Preparing install target", prepare_install_target),
("Installing Arch + Omarchy", arch_install_system),
("Configuring hibernation", configure_hibernation),
("Configuring system", run_system_finalizer),
# Before finalize_limine_boot: the deferred-provisioning cryptkey drop-in and keyfile
# must be in place for the final UKI build.
("Staging provisioning", stage_provisioning_state),
("Finalizing Limine boot", finalize_limine_boot),
("Finalizing user", run_chroot_finalizer),
("Configuring login", configure_login),
("Configuring SSH access", configure_ssh_access),
("Configuring Tailscale", configure_tailscale),
("Configuring DNS resolver", configure_dns_resolver),
("Validating boot setup", validate_boot),
("Creating factory snapshot", create_factory_snapshot),
]
def main() -> int:
try:
ctx = InstallContext.from_env()
except RuntimeError as e:
error(f"Configuration error: {e}")
return 2
who = ctx.username or "deferred provisioning (user created at first boot)"
info(f"Installing Omarchy for {who} → {ctx.target}")
from .phases_impl import (
boost_cpu_governor,
cleanup_bind_mounts,
cleanup_protected_state,
cleanup_target_hook_masks,
restore_cpu_governors,
)
governors = boost_cpu_governor()
success = False
try:
try:
run(ctx, build_phases(ctx))
success = True
except PhaseError:
error("Installation halted.")
return 1
except KeyboardInterrupt:
error("Installation interrupted.")
return 130
info("Installation complete.")
return 0
finally:
restore_cpu_governors(governors)
cleanup_bind_mounts(ctx)
cleanup_target_hook_masks(ctx)
if not success:
cleanup_protected_state(ctx)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,105 @@
"""Phase state machine. Each phase is a (name, callable) pair; callables take
the InstallContext and either return cleanly or raise to abort the install."""
from __future__ import annotations
import json
import os
import time
import traceback
from collections.abc import Callable
from pathlib import Path
from .context import InstallContext
from .ui import error, info
PhaseFn = Callable[[InstallContext], None]
class PhaseError(Exception):
"""Raised when a phase fails. Wrapped with the phase name."""
def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
ctx.state_dir.mkdir(parents=True, exist_ok=True)
state_path = ctx.state_dir / "state.json"
state = {
"started_at": time.time(),
# The dashboard counts packages under <target>/var/lib/pacman/local;
# publish the path rather than have the UI assume /mnt.
"target": str(ctx.target),
"total_phases": len(phases),
"current_index": 0,
"current_phase": "Starting installation",
"phases": [],
}
_write_state(state_path, state)
for index, (name, fn) in enumerate(phases):
state["current_index"] = index
state["current_phase"] = name
state["phase_started_at"] = time.time()
_write_state(state_path, state)
info(f"› {name}")
started = time.time()
try:
fn(ctx)
except Exception as exc: # noqa: BLE001
elapsed = time.time() - started
state["phases"].append({
"name": name,
"status": "failed",
"elapsed": elapsed,
"error": str(exc),
})
_write_state(state_path, state)
error(f"Phase '{name}' failed after {elapsed:.1f}s: {exc}")
traceback.print_exc()
raise PhaseError(f"phase {name} failed: {exc}") from exc
elapsed = time.time() - started
state["phases"].append({"name": name, "status": "ok", "elapsed": elapsed})
_write_state(state_path, state)
state["current_index"] = max(len(phases) - 1, 0)
state["current_phase"] = "Installation complete"
state["finished_at"] = time.time()
# Expected vs actual for the bar's denominator, so drift is visible in
# acceptance runs rather than only by watching a bar creep.
state["installed_packages"] = _installed_package_count(ctx.target)
state["expected_packages"] = _expected_package_count()
_write_state(state_path, state)
timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json"
timing_path.parent.mkdir(parents=True, exist_ok=True)
_write_state(timing_path, state)
def _installed_package_count(target: Path) -> int:
"""Packages libalpm installed into the target — one directory each under
local/, which is what the dashboard counts live."""
local_db = target / "var" / "lib" / "pacman" / "local"
try:
with os.scandir(local_db) as entries:
return sum(1 for entry in entries if entry.is_dir())
except OSError:
return 0
def _expected_package_count() -> int:
path = Path("/usr/share/omarchy-iso/expected-packages")
try:
return int(path.read_text().split()[0])
except (OSError, ValueError, IndexError):
return 0
def _write_state(path: Path, state: dict) -> None:
# Dashboard polls this file while phases update it. Write atomically so the
# reader never observes a truncated/partial JSON document and resets UI.
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(json.dumps(state, indent=2, default=str))
tmp.replace(path)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,68 @@
"""Region selection: which of the runtime's region profiles a target gets.
The builder copies every profile from the bundled runtime's default/regions/
into REGIONS_DIR, and their packages into the offline mirror. Nobody picks a
region by hand: the timezone the user already chose selects it, and an
autoinstall config may name one explicitly in omarchy_install.region. Run as
`python -m orchestrator.region <timezone>` to print the region the installer
would use, which is how the configurator fills its summary.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REGIONS_DIR = Path("/usr/share/omarchy-iso/regions")
GLOBAL = "global"
def _entries(path: Path) -> list[str]:
if not path.is_file():
return []
lines = (line.strip() for line in path.read_text().splitlines())
return [line for line in lines if line and not line.startswith("#")]
def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) -> str:
regions_dir = regions_dir or REGIONS_DIR
if timezone and regions_dir.is_dir():
for profile in sorted(regions_dir.iterdir()):
if timezone in _entries(profile / "timezones"):
return profile.name
return GLOBAL
def resolve_region(omarchy_install: dict, timezone: str | None, regions_dir: Path | None = None) -> str:
regions_dir = regions_dir or REGIONS_DIR
region = omarchy_install.get("region")
if region is None or region == "":
return region_for_timezone(timezone, regions_dir)
# Profiles are named by ISO 3166-1 alpha-2 code; checking the shape first
# keeps a path like ".." from passing the directory test.
if not isinstance(region, str) or (
region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir())
):
raise RuntimeError(f"Unsupported region: {region!r}")
return region
def region_packages(region: str, regions_dir: Path | None = None) -> list[str]:
if region == GLOBAL:
return []
return _entries((regions_dir or REGIONS_DIR) / region / "packages")
def region_keyrings(region: str, regions_dir: Path | None = None) -> list[str]:
"""Pacman keyrings the profile ships, named the Arch way: <name>-keyring
installs /usr/share/pacman/keyrings/<name>.gpg."""
return [
package.removesuffix("-keyring")
for package in region_packages(region, regions_dir)
if package.endswith("-keyring")
]
if __name__ == "__main__":
print(region_for_timezone(sys.argv[1] if len(sys.argv) > 1 else None))
@@ -0,0 +1,28 @@
"""Orchestrator progress lines.
These land in the install log, not on a screen: omarchy-install-dashboard owns
the visible UI, captures the orchestrator's stdout into the support log, and
strips CSI sequences on the way in. The colouring was therefore never seen by
anyone, while gum charged a Go process start per line for it. Plain writes keep
the same indented shape at no cost.
Flush every line: pacstrap and arch-chroot inherit this fd, so buffering here
would reorder our lines against theirs in the log.
"""
from __future__ import annotations
import sys
def _emit(text: str) -> None:
sys.stdout.write(f"\n {text}\n")
sys.stdout.flush()
def info(text: str) -> None:
_emit(text)
def error(text: str) -> None:
_emit(text)
@@ -2,4 +2,4 @@ title Omarchy (x86_64, UEFI)
sort-key 01 sort-key 01
linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
+2 -2
View File
@@ -54,13 +54,13 @@ timeout_style=hidden
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' { menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' { menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
+2 -2
View File
@@ -30,13 +30,13 @@ timeout_style=hidden
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' { menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash xe.enable_panel_replay=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash xe.enable_panel_replay=0 initramfs_async=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' { menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on xe.enable_panel_replay=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on xe.enable_panel_replay=0 initramfs_async=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
+8 -1
View File
@@ -13,6 +13,13 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional LocalFileSigLevel = Optional
# No signature checks for the offline mirror: it ships inside the ISO, whose
# integrity is already covered by the airootfs sha512 and the ISO signature.
# "Optional TrustAll" is NOT a bypass — Optional verifies whenever a .sig file
# exists (they all do), and pacstrap verifies against the LIVE keyring (pacman
# -r relocates the root but not GpgDir). That made installs depend on archiso's
# slow boot-time pacman-init.service having finished, which stalled or failed
# pacstrap on real hardware with "required key missing from keyring".
[offline] [offline]
SigLevel = Optional TrustAll SigLevel = Never
Server = file:///var/cache/omarchy/mirror/offline/ Server = file:///var/cache/omarchy/mirror/offline/
+4 -4
View File
@@ -13,6 +13,9 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional LocalFileSigLevel = Optional
[omarchy]
Server = https://pkgs.omarchy.org/edge/$arch
[core] [core]
Server = https://mirror.omarchy.org/$repo/os/$arch Server = https://mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
@@ -25,10 +28,7 @@ Include = /etc/pacman.d/mirrorlist
Server = https://mirror.omarchy.org/$repo/os/$arch Server = https://mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
[arch-mact2] [arch-mact2]
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
SigLevel = Never SigLevel = Never
+4 -4
View File
@@ -13,6 +13,9 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional LocalFileSigLevel = Optional
[omarchy]
Server = https://pkgs.omarchy.org/rc/$arch
[core] [core]
Server = https://rc-mirror.omarchy.org/$repo/os/$arch Server = https://rc-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
@@ -25,10 +28,7 @@ Include = /etc/pacman.d/mirrorlist
Server = https://rc-mirror.omarchy.org/$repo/os/$arch Server = https://rc-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
[arch-mact2] [arch-mact2]
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
SigLevel = Never SigLevel = Never
+4 -4
View File
@@ -13,6 +13,9 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional LocalFileSigLevel = Optional
[omarchy]
Server = https://pkgs.omarchy.org/stable/$arch
[core] [core]
Server = https://stable-mirror.omarchy.org/$repo/os/$arch Server = https://stable-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
@@ -25,10 +28,7 @@ Include = /etc/pacman.d/mirrorlist
Server = https://stable-mirror.omarchy.org/$repo/os/$arch Server = https://stable-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/stable/$arch
[arch-mact2] [arch-mact2]
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
SigLevel = Never SigLevel = Never
+23 -3
View File
@@ -12,15 +12,35 @@ bootmodes=('bios.syslinux' 'uefi.grub')
arch="x86_64" arch="x86_64"
pacman_conf="pacman-offline.conf" pacman_conf="pacman-offline.conf"
airootfs_image_type="squashfs" airootfs_image_type="squashfs"
airootfs_image_tool_options=('-comp' 'xz' '-Xbcj' 'x86' '-b' '1M' '-Xdict-size' '1M') # Package archives in the offline mirror are already zstd-compressed. Storing
# them in an outer stream saves little space but makes pacman decompress the
# outer layer while hashing and extracting every package during installation.
#
# Everything else in the live root is zstd rather than xz. Squashfs decompresses
# on the page-fault path through a single stream (CONFIG_SQUASHFS_DECOMP_SINGLE),
# where xz manages ~100MB/s against zstd's ~900MB/s, and the live root is read
# cold on every boot: kernel, plymouth, systemd, python, archinstall, gum. The
# whole ISO grows well under a percent for it, and dropping the x86 BCJ filter
# also removes one of the blockers listed in plans/aarch64-support.md.
airootfs_image_tool_options=(
'-comp' 'zstd'
'-Xcompression-level' '19'
'-b' '1M'
'-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)'
)
bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19') bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19')
file_permissions=( file_permissions=(
["/etc/shadow"]="0:0:400" ["/etc/shadow"]="0:0:400"
["/root"]="0:0:750" ["/root"]="0:0:750"
["/root/.automated_script.sh"]="0:0:755" ["/root/.automated_script.sh"]="0:0:755"
["/root/.gnupg"]="0:0:700" ["/root/.gnupg"]="0:0:700"
["/usr/local/bin/choose-mirror"]="0:0:755"
["/root/configurator"]="0:0:755" ["/root/configurator"]="0:0:755"
["/var/cache/omarchy/mirror/offline/"]="0:0:775" ["/usr/local/bin/choose-mirror"]="0:0:755"
["/usr/local/bin/omarchy-cidata-load"]="0:0:755"
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
["/usr/local/bin/omarchy-install-dashboard"]="0:0:755"
["/usr/local/bin/omarchy-install-diagnose-media"]="0:0:755"
["/usr/local/bin/omarchy-iso-install"]="0:0:755"
["/usr/local/bin/omarchy-upload-log"]="0:0:755" ["/usr/local/bin/omarchy-upload-log"]="0:0:755"
["/var/cache/omarchy/mirror/offline/"]="0:0:775"
) )
+3 -3
View File
@@ -6,7 +6,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, NBD) MENU LABEL Omarchy install medium (x86_64, NBD)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y initramfs_async=0
SYSAPPEND 3 SYSAPPEND 3
LABEL arch64_nfs LABEL arch64_nfs
@@ -17,7 +17,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, NFS) MENU LABEL Omarchy install medium (x86_64, NFS)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y initramfs_async=0
SYSAPPEND 3 SYSAPPEND 3
LABEL arch64_http LABEL arch64_http
@@ -28,5 +28,5 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, HTTP) MENU LABEL Omarchy install medium (x86_64, HTTP)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y initramfs_async=0
SYSAPPEND 3 SYSAPPEND 3
+2 -2
View File
@@ -6,7 +6,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, BIOS) MENU LABEL Omarchy install medium (x86_64, BIOS)
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
# Accessibility boot option # Accessibility boot option
LABEL arch64speech LABEL arch64speech
@@ -17,4 +17,4 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -16,7 +16,7 @@ These exist outside omarchy-iso. Flagged for visibility — without them, nothin
1. **aarch64 base packages must exist somewhere we can pacman from.** Vanilla Arch (`geo.mirror.pkgbuild.com`) is x86_64-only — there is no `core/os/aarch64`. The realistic source is **Arch Linux ARM** (`mirror.archlinuxarm.org`) for `core`/`extra`/`alarm`/`aur`. Decision required: target Arch Linux ARM as the aarch64 base distribution. 1. **aarch64 base packages must exist somewhere we can pacman from.** Vanilla Arch (`geo.mirror.pkgbuild.com`) is x86_64-only — there is no `core/os/aarch64`. The realistic source is **Arch Linux ARM** (`mirror.archlinuxarm.org`) for `core`/`extra`/`alarm`/`aur`. Decision required: target Arch Linux ARM as the aarch64 base distribution.
2. **`pkgs.omarchy.org/{stable,edge}/aarch64/`** must serve a real repo. Probed today, both return 404. omarchy-pkgs already has multi-arch build support per its README, so this is a publish step, not a port. 2. **`pkgs.omarchy.org/{stable,edge}/aarch64/`** must serve a real repo. Probed today, both return 404. omarchy-pkgs already has multi-arch build support per its README, so this is a publish step, not a port.
3. **Omarchy installer guard** at `~/.local/share/omarchy/install/preflight/guard.sh:25` hard-aborts on `uname -m != x86_64`. Must be relaxed to allow aarch64 (or scoped behind a flag). 3. **Omarchy ISO architecture guard** must allow supported architectures or scope any x86_64-only checks behind an explicit guard.
4. **archinstall + Limine** must work end-to-end on aarch64. archinstall supports it; Limine supports aarch64 UEFI. Worth a manual verification before committing to this bootloader path on ARM. 4. **archinstall + Limine** must work end-to-end on aarch64. archinstall supports it; Limine supports aarch64 UEFI. Worth a manual verification before committing to this bootloader path on ARM.
--- ---
@@ -76,9 +76,9 @@ aarch64 has no BIOS — only UEFI. Drop the syslinux path entirely on ARM.
The cleanest cut: keep one `grub.cfg` shared, strip the x86 shell/memtest fragments at build time when `OMARCHY_ARCH=aarch64`. Don't fork the file. The cleanest cut: keep one `grub.cfg` shared, strip the x86 shell/memtest fragments at build time when `OMARCHY_ARCH=aarch64`. Don't fork the file.
### 6. mkinitcpio preset — `configs/airootfs/etc/mkinitcpio.d/linux.preset` ### 6. mkinitcpio preset — `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset`
References `vmlinuz-linux-t2` and `initramfs-linux-t2.img` (lines 5, 8). For aarch64, swap to `vmlinuz-linux` / `initramfs-linux.img`. Generate the file at build time from a template, keyed on arch. Names `vmlinuz-linux-t2` and `initramfs-linux-t2.img`. The filename is the pkgbase and the alpm hook keys off it, so aarch64 does not rename this file — it simply does not ship it, and releng's own `linux.preset` covers the stock kernel. Drop `linux-t2` from `arch_packages` on aarch64 and the boot entries point at `vmlinuz-linux` / `initramfs-linux.img`.
### 7. Configurator — `configs/airootfs/root/configurator` ### 7. Configurator — `configs/airootfs/root/configurator`
@@ -109,9 +109,9 @@ Both scripts hardcode the OVMF path; small, mechanical fix.
### 10. Release script — `bin/omarchy-iso-release:52` ### 10. Release script — `bin/omarchy-iso-release:52`
```bash ```bash
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$INSTALLER_REF".iso | head -n1) latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*${OMARCHY_ARCH}-"$ISO_REF".iso | head -n1)
``` ```
Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${INSTALLER_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized. Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${ISO_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized.
### 11. CI — `.github/workflows/nightly-build.yml` ### 11. CI — `.github/workflows/nightly-build.yml`
@@ -145,7 +145,7 @@ Code:
- `configs/efiboot/loader/loader.conf` — per-arch default entry - `configs/efiboot/loader/loader.conf` — per-arch default entry
- `configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf` — generate aarch64 sibling at build time - `configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf` — generate aarch64 sibling at build time
- `configs/grub/grub.cfg`, `loopback.cfg` — strip x86-only fragments on aarch64 - `configs/grub/grub.cfg`, `loopback.cfg` — strip x86-only fragments on aarch64
- `configs/airootfs/etc/mkinitcpio.d/linux.preset` — template by arch (drop linux-t2 on aarch64) - `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset` — omit on aarch64 (drop linux-t2 there; releng's linux.preset covers the stock kernel)
- `configs/airootfs/root/configurator` — branch archinstall JSON mirror list on `uname -m`; optionally guard lspci T2 probe - `configs/airootfs/root/configurator` — branch archinstall JSON mirror list on `uname -m`; optionally guard lspci T2 probe
- `.github/workflows/nightly-build.yml` — matrix x86_64/aarch64 - `.github/workflows/nightly-build.yml` — matrix x86_64/aarch64
+43 -31
View File
@@ -6,6 +6,17 @@ Support Omarchy installs on consumer laptops that ship with UEFI Secure Boot ena
This plan targets normal consumer hardware, especially Windows dual-boot machines. OEM-controlled hardware with Omarchy-owned firmware keys is a separate path. This plan targets normal consumer hardware, especially Windows dual-boot machines. OEM-controlled hardware with Omarchy-owned firmware keys is a separate path.
## Current State (August 2026)
Where the repo stands today, and what that changes about this plan:
- The ISO builds with `bootmodes=('bios.syslinux' 'uefi.grub')` (`configs/profiledef.sh`): an unsigned GRUB as `BOOTX64.EFI` loading an unsigned kernel and initramfs. Firmware that trusts only the Microsoft UEFI CA rejects the ISO at the first stage, so Secure Boot must be disabled today.
- The vendored mkarchiso already supports a `uefi.systemd-boot` bootmode (mutually exclusive with `uefi.grub`), so the live-ISO half of `shim -> systemd-boot -> UKI` needs no archiso surgery — a bootmode switch plus signing.
- The installed system already boots a UKI: the orchestrator's `finalize_limine_boot` phase has mkinitcpio build the UKI onto the ESP, and `validate_boot` asserts it exists. "Build an installed UKI" is no longer new work; the new work is signing that UKI and choosing a second stage that enforces signatures.
- Kernel updates already regenerate the UKI through the target's mkinitcpio pacman hooks. The update flow below reduces to adding a signing step and fallback retention to machinery that exists.
The critical path is external: the Microsoft-signed shim. That requires an EV code-signing certificate for the Omarchy org, a shim built from a reviewed upstream release with Omarchy SBAT identity and NX compatibility, and a `shim-review` submission — historically weeks to months of calendar time, with standing obligations afterward (SBAT revocation response, resubmission when the embedded cert or shim version changes). Start it first; every phase below except release signing can proceed in parallel on dev test keys in QEMU.
## Core Architecture ## Core Architecture
Use a Microsoft-signed first-stage shim, then boot only signed Omarchy-controlled artifacts. Use a Microsoft-signed first-stage shim, then boot only signed Omarchy-controlled artifacts.
@@ -51,7 +62,7 @@ This preserves offline tamper resistance for `/boot` while avoiding any central
## Bootloader Decision ## Bootloader Decision
Signing Limine as an EFI binary is not sufficient if Limine can then boot unsigned kernels, initrds, or configs. For v1 Secure Boot support, use a boot path with a small, enforceable verification surface: Installed systems already boot a UKI through Limine, so the boot *artifact* needs no change. But signing Limine as an EFI binary is not sufficient if Limine will then load unsigned payloads or take direction from an unsigned `limine.conf`. For v1 Secure Boot support, use a boot path with a small, enforceable verification surface:
```text ```text
shim -> systemd-boot -> signed UKI shim -> systemd-boot -> signed UKI
@@ -104,12 +115,13 @@ bin/omarchy-iso-make --secure-boot
Build behavior: Build behavior:
1. Add Microsoft-signed Omarchy shim artifacts to the ISO UEFI boot path. 1. Switch the UEFI bootmode from `uefi.grub` to `uefi.systemd-boot` in `configs/profiledef.sh` (mkarchiso forbids running both). BIOS syslinux stays as-is; Secure Boot is UEFI-only.
2. Build the live environment boot payload as a UKI. 2. Add Microsoft-signed Omarchy shim artifacts to the ISO UEFI boot path.
3. Sign the live UKI with the Omarchy release key in official builds. 3. Build the live environment boot payload as a UKI.
4. Sign any second-stage EFI binary with the Omarchy release key. 4. Sign the live UKI with the Omarchy release key in official builds.
5. Assert that every Secure Boot UEFI entry uses the signed shim path. 5. Sign any second-stage EFI binary with the Omarchy release key.
6. Assert that no unsigned kernel/initramfs path is offered in Secure Boot UEFI mode. 6. Assert that every Secure Boot UEFI entry uses the signed shim path.
7. Assert that no unsigned kernel/initramfs path is offered in Secure Boot UEFI mode.
Local developer builds should support a dev-signing mode for QEMU validation: Local developer builds should support a dev-signing mode for QEMU validation:
@@ -124,7 +136,7 @@ Dev mode creates local test keys and is only expected to boot in QEMU firmware e
When the live installer detects Secure Boot enabled: When the live installer detects Secure Boot enabled:
1. Show a Secure Boot explanation before disk mutation. 1. Show a Secure Boot explanation before disk mutation.
2. If Windows is detected, warn the user to suspend BitLocker and have the recovery key available. 2. If Windows is detected, follow the shipped dual-boot BitLocker policy (decrypt, not suspend — see the configurator's existing checks) and warn the user to have the recovery key available.
3. Install Omarchy's shim to its own ESP directory, for example `EFI/Omarchy`. 3. Install Omarchy's shim to its own ESP directory, for example `EFI/Omarchy`.
4. Do not modify `EFI/Microsoft`. 4. Do not modify `EFI/Microsoft`.
5. Generate a machine-local MOK keypair under the installed encrypted root. 5. Generate a machine-local MOK keypair under the installed encrypted root.
@@ -145,13 +157,11 @@ Firmware -> Omarchy shim -> MokManager -> user enrolls Omarchy machine key -> re
Secure Boot installs need deterministic UKI regeneration and signing after kernel or initramfs changes. Secure Boot installs need deterministic UKI regeneration and signing after kernel or initramfs changes.
Add a target-system hook that: The regeneration half already exists: the target's mkinitcpio pacman hooks rebuild the UKI on kernel or initramfs changes. Add the signing half — a hook ordered after the UKI build that:
1. Regenerates the initramfs. 1. Signs the freshly built UKI with the machine-local MOK private key.
2. Rebuilds the UKI. 2. Writes it atomically to the ESP.
3. Signs the UKI with the machine-local MOK private key. 3. Keeps at least one previously booted signed UKI as fallback.
4. Writes it atomically to the ESP.
5. Keeps at least one previously booted signed UKI as fallback.
Failure policy: Failure policy:
@@ -199,10 +209,9 @@ Do not store private keys in this directory.
Add Secure Boot detection and UX: Add Secure Boot detection and UX:
- Detect UEFI Secure Boot state through efivars and/or `mokutil --sb-state`. - Detect UEFI Secure Boot state through efivars and/or `mokutil --sb-state`.
- Detect Windows through ESP vendor paths and partition metadata. - Detect Windows through ESP vendor paths and partition metadata (the dual-boot path's `detect_windows_esp` and BitLocker checks already exist — extend, don't duplicate).
- Warn about BitLocker recovery risk when Windows is present.
- Explain one-time MokManager enrollment. - Explain one-time MokManager enrollment.
- Write `secure_boot.sh` with simple shell state for `.automated_script.sh`. - Write `secure_boot.sh` with simple shell state for the install orchestrator.
Example state: Example state:
@@ -214,17 +223,18 @@ SECURE_BOOT_UKI=true
SECURE_BOOT_MOK_COMMON_NAME="Omarchy Machine Owner Key" SECURE_BOOT_MOK_COMMON_NAME="Omarchy Machine Owner Key"
``` ```
### `configs/airootfs/root/.automated_script.sh` ### `configs/airootfs/usr/share/omarchy-iso/orchestrator/`
Add Secure Boot install support: Add Secure Boot install support to the install phases:
- Source `secure_boot.sh` after configurator runs. - Read the `secure_boot.sh` state the configurator wrote.
- Install the Secure Boot boot path when `SECURE_BOOT_INSTALL=true`. - When `SECURE_BOOT_INSTALL=true`, install the shim boot path and the enforcing second stage instead of (or in front of) the plain Limine setup in `_configure_limine_boot`.
- Generate machine-local MOK keypair in the installed encrypted root. - Generate the machine-local MOK keypair on the installed encrypted root.
- Build and sign UKI. - Sign the UKI that `finalize_limine_boot` already builds.
- Schedule MOK enrollment with `mokutil --import`. - Schedule MOK enrollment with `mokutil --import`.
- Install kernel-update hooks for UKI rebuild/signing. - Install the UKI signing hook alongside the existing mkinitcpio hooks.
- Preserve dual-boot firmware boot order unless explicitly promoted. - Preserve dual-boot firmware boot order unless explicitly promoted.
- Extend `validate_boot` to assert the UKI on the ESP carries a valid signature.
### Target System Files ### Target System Files
@@ -275,7 +285,7 @@ On Windows dual-boot machines:
- Never change BitLocker protectors. - Never change BitLocker protectors.
- Preserve existing firmware default boot order by default. - Preserve existing firmware default boot order by default.
- Warn that adding a boot entry or changing ESP contents can trigger BitLocker recovery. - Warn that adding a boot entry or changing ESP contents can trigger BitLocker recovery.
- Recommend suspending BitLocker from Windows before install and resuming it after both OSes boot. - Follow the shipped dual-boot policy: tell users to turn BitLocker off in Windows and let the drive finish decrypting before installing (the configurator already enforces this; suspending alone proved insufficient — see #105).
If the user chooses to make Omarchy first in boot order, capture the old `BootOrder` and provide rollback guidance. If the user chooses to make Omarchy first in boot order, capture the old `BootOrder` and provide rollback guidance.
@@ -326,7 +336,7 @@ Minimum hardware tests:
1. Microsoft Secure Boot enabled laptop boots official Secure Boot ISO. 1. Microsoft Secure Boot enabled laptop boots official Secure Boot ISO.
2. Windows dual-boot install preserves Windows boot. 2. Windows dual-boot install preserves Windows boot.
3. BitLocker-suspended install avoids recovery prompt after resuming BitLocker. 3. BitLocker-decrypted install boots both OSes cleanly afterward.
4. BitLocker-active install warning is visible before any disk mutation. 4. BitLocker-active install warning is visible before any disk mutation.
5. Installed Omarchy boots only after MOK enrollment. 5. Installed Omarchy boots only after MOK enrollment.
6. Secure Boot remains enabled after install. 6. Secure Boot remains enabled after install.
@@ -338,11 +348,13 @@ Minimum hardware tests:
### Phase 1: Research And Decisions ### Phase 1: Research And Decisions
- Kick off the EV certificate purchase and `shim-review` submission prep immediately — this is the calendar-bound item that gates Phase 4, and nothing else depends on it being done first.
- Confirm shim submission requirements and timeline. - Confirm shim submission requirements and timeline.
- Confirm selected second-stage boot manager. - Confirm selected second-stage boot manager (systemd-boot unless Limine's signature enforcement is proven).
- Confirm UKI generation mechanism on Arch. - ~~Confirm UKI generation mechanism on Arch~~ — resolved: mkinitcpio already builds the installed UKI today.
- Confirm package availability for signing and MOK workflows. - Confirm package availability for signing and MOK workflows.
- Decide local MOK key storage and update-hook policy. - Decide local MOK key storage and update-hook policy.
- Decide the DKMS/out-of-tree module signing story (see Open Questions — this is a v1 blocker, not a nice-to-have).
### Phase 2: Dev Secure Boot In QEMU ### Phase 2: Dev Secure Boot In QEMU
@@ -387,9 +399,9 @@ Minimum hardware tests:
## Open Questions ## Open Questions
- Whether v1 should use systemd-boot, GRUB, or a verified Limine configuration as the second stage. - Whether Limine can enforce signature verification on the UKIs and config it loads; if not proven, Secure Boot installs use systemd-boot as the second stage while non-Secure-Boot installs keep Limine.
- Whether the local MOK private key should be encrypted with a user-supplied passphrase or stored root-only on encrypted root. - Whether the local MOK private key should be encrypted with a user-supplied passphrase or stored root-only on encrypted root.
- How to support signed out-of-tree kernel modules, especially proprietary GPU drivers or DKMS packages. - How to support signed out-of-tree kernel modules, especially proprietary GPU drivers or DKMS packages. **This must be answered for v1:** once Secure Boot is enforcing, unsigned modules will not load, and NVIDIA laptops are exactly the consumer hardware this plan targets. The module signing hook must sign with the machine-local MOK alongside the UKI.
- How snapshot booting should work when each bootable kernel must be a signed UKI. - How snapshot booting (factory reset restores from Btrfs snapshots) should work when each bootable kernel must be a signed UKI.
- Whether official builds should always be Secure Boot-capable or ship a separate Secure Boot ISO flavor. - Whether official builds should always be Secure Boot-capable or ship a separate Secure Boot ISO flavor.
- What user-facing recovery path should exist if MOK enrollment is skipped or fails. - What user-facing recovery path should exist if MOK enrollment is skipped or fails.
-158
View File
@@ -1,158 +0,0 @@
# Dual-Boot Installation Support for Omarchy ISO
## Context
Currently, the Omarchy installer wipes the entire selected disk (`"wipe": true`) and creates two partitions: a 2GB FAT32 ESP and a LUKS-encrypted Btrfs root. This means users must dedicate an entire disk to Omarchy. Adding dual-boot support lets users install alongside an existing OS (typically Windows) while keeping the Omarchy partition encrypted.
## Core Approach: `pre_mounted_config`
**Do our own partitioning, then hand off to archinstall for package installation only.**
archinstall's `manual_partitioning` mode has a long history of bugs with existing partitions and LUKS (archinstall issues #998, #2072, #2444). Instead, we:
1. Detect existing partitions and free space ourselves (with `sgdisk`/`parted`)
2. Create the LUKS partition and Btrfs subvolumes ourselves (with `cryptsetup`/`mkfs.btrfs`)
3. Mount everything at `/mnt/archinstall`
4. Tell archinstall to use `"config_type": "pre_mounted_config"` — it installs packages into our pre-mounted tree and does zero disk operations
5. Handle encryption config (crypttab, mkinitcpio hooks, bootloader cmdline) in post-install
The existing full-disk path stays completely unchanged. Dual-boot is a parallel code path.
## Implementation Guardrails
- **Run target changes in chroot** — all target-system commands (`mkinitcpio -P`, `limine-scan`, bootloader config updates) must run via `arch-chroot "$INSTALL_ROOT" ...`
- **Edit target files under `$INSTALL_ROOT` only** — write `mkinitcpio` hook config, `limine.conf`, `crypttab`, and related files in `$INSTALL_ROOT/etc` and `$INSTALL_ROOT/boot` (never in the live environment root)
- **Settle kernel partition state** — after creating the new partition, run `partprobe "$DISK"` and `udevadm settle` before `cryptsetup` to avoid device-node races
- **Deterministic fstab** — always generate `/etc/fstab` for dual-boot with overwrite semantics (e.g., `genfstab -U "$INSTALL_ROOT" > "$INSTALL_ROOT/etc/fstab"`) to avoid duplicate entries on retries
- **Hook compatibility** — when updating mkinitcpio hooks, add `encrypt` for busybox initramfs or `sd-encrypt` for systemd initramfs; never assume one universally
- **Safe rollback on failure** — on any dual-boot setup failure: unmount mounts, close LUKS mapping, and surface recovery guidance; optionally delete the just-created partition only if it was created in this run
## Scope Constraints
- **UEFI/GPT only** — dual-boot on MBR/BIOS is too fragile; those users get full-disk only
- **No auto-shrinking** — users must free up space from their existing OS first (e.g., Windows Disk Management). Too risky to shrink partitions automatically
- **Minimum 20GB free space** required for Omarchy
## User Flow
```
Select disk → Detect layout →
If disk has existing OS + ESP + ≥20GB free space:
"How would you like to install?"
→ "Install alongside Windows (use 85GB free space)"
→ "Erase entire disk"
If disk is empty or no free space:
Existing "Confirm overwriting" flow (unchanged)
```
## File Changes
### 1. `configs/airootfs/root/configurator`
**Add after disk selection (line 233), before config generation:**
- `detect_disk_layout()` — examines selected disk for:
- GPT partition table (required for dual-boot)
- Existing ESP partition (via `lsblk` PARTTYPE matching EFI GUID)
- Free unallocated space (via `sgdisk -F`/`-E` or `parted print free`)
- OS detection for installer UX warnings/prompts (not boot correctness): mount ESP read-only, check common vendor paths like `EFI/Microsoft`, `EFI/systemd`, `EFI/ubuntu`, `EFI/fedora`, `EFI/GRUB`
- `install_mode_form()` — shows gum choose with dual-boot vs erase options when conditions are met. Sets `DUAL_BOOT=true/false`.
**Generate different JSON when `DUAL_BOOT=true`:**
```json
{
"disk_config": {
"config_type": "pre_mounted_config",
"mountpoint": "/mnt/archinstall"
}
}
```
No `device_modifications`, no `disk_encryption`, no `btrfs_options` — we handle all of that ourselves. Everything else (hostname, kernels, packages, etc.) stays the same.
**Write `install_mode.sh`** alongside the other config files:
```bash
DUAL_BOOT=true
DISK=/dev/nvme0n1
ESP_PARTITION=/dev/nvme0n1p1
```
### 2. `configs/airootfs/root/.automated_script.sh`
**Source `install_mode.sh`** in `run_configurator()` if it exists.
**Add `setup_dual_boot_partitions()`** — runs before archinstall when `DUAL_BOOT=true`:
1. Select the largest contiguous free region and run `sgdisk` to create new partition there (type 8309 = Linux LUKS)
2. `partprobe "$DISK"` + `udevadm settle` so new partition node is present
3. `cryptsetup luksFormat` + `luksOpen` with user's password
4. `mkfs.btrfs` on `/dev/mapper/omarchy_root`
5. Create Btrfs subvolumes: `@`, `@home`, `@log`, `@pkg`
6. Mount subvolumes at `/mnt/archinstall` with `compress=zstd`
7. Mount existing ESP (untouched) at `/mnt/archinstall/boot`
8. Generate target `/etc/fstab` from mounted filesystems before post-install steps
**Add `configure_dual_boot_post_install()`** — runs after archinstall:
1. Write `/etc/crypttab` entry using LUKS UUID/PARTUUID (stable identifier), e.g. `omarchy_root UUID=<luks-uuid> none luks,discard`
2. Update mkinitcpio hooks before `filesystems`: `encrypt` (busybox) or `sd-encrypt` (systemd)
3. Regenerate initramfs with `arch-chroot "$INSTALL_ROOT" mkinitcpio -P`
4. Configure Limine kernel cmdline to match initramfs mode:
- busybox `encrypt`: `cryptdevice=UUID=<uuid>:omarchy_root root=/dev/mapper/omarchy_root`
- systemd `sd-encrypt`: `rd.luks.name=<uuid>=omarchy_root root=/dev/mapper/omarchy_root` (or equivalent `rd.luks.uuid=` form)
5. Run `arch-chroot "$INSTALL_ROOT" limine-scan` to detect other OS boot entries (Windows, etc.)
6. Preserve current firmware default boot order unless user explicitly chooses to make Limine first (capture and restore `BootOrder` if `efibootmgr` side effects reorder entries)
**Use `$INSTALL_ROOT` variable** — defaults to `/mnt` for full-disk, `/mnt/archinstall` for dual-boot. Post-install steps (offline mirror bind-mount, sudoers, omarchy copy) reference this instead of hardcoded `/mnt`.
### 3. `builder/build-iso.sh` + `builder/archinstall.packages`
Add `parted` to packages available in the live ISO (needed for free space detection).
## Bootloader Coexistence
Limine installs to `ESP/EFI/arch-limine/` and registers via `efibootmgr`. This sits alongside:
- Windows Boot Manager at `ESP/EFI/Microsoft/Boot/bootmgfw.efi`
- Any other bootloader in its own ESP subdirectory
`limine-scan` auto-detects other UEFI boot entries and adds them to `limine.conf`. Users can choose OS at boot.
**Boot order policy**: creating a new EFI entry must not silently change the current default boot target. Keep existing firmware boot order by default and only promote Limine when user opts in.
Implementation note: when registering Limine with `efibootmgr`, do not pass reorder flags by default; if firmware still changes order, explicitly restore prior `BootOrder` unless the user opted to promote Limine.
**ESP size note**: Windows typically creates a 100-260MB ESP. This is enough for Limine + kernel + initramfs, but Snapper boot snapshots (which copy kernels to ESP) will be limited or disabled.
**ESP free-space threshold**:
- If ESP has `<100MB` free at install time: show a strong warning (do not block install)
- If ESP has `<64MB` free: hide dual-boot option and require full-disk install (or user frees space first)
**BitLocker warning**: If Windows is detected, show a warning about having the BitLocker recovery key ready, since modifying ESP/UEFI entries can trigger recovery.
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| Disk is empty | Full-disk install only (current behavior) |
| Existing OS but no free space | Full-disk install only, with note to free space first |
| Free space < 20GB | Dual-boot option not shown |
| MBR/BIOS disk | Dual-boot option not shown |
| ESP < 100MB free after mount | Warn user; continue allowed |
| ESP < 64MB free after mount | Dual-boot option not shown |
| Multiple free regions | Use largest contiguous region |
| Partitioning/setup failure mid-run | Cleanup mounts/LUKS and abort safely |
## Verification
1. **Regression**: Full-disk install in VM with empty disk — must work identically to today
2. **Dual-boot with Windows**: VM with Windows installed + 40GB free space — detect Windows, install alongside, both OSes boot
3. **Dual-boot with Linux**: VM with another Linux + free space — similar test
4. **No free space**: VM with fully partitioned disk — dual-boot option should not appear
5. **Encryption**: After dual-boot install, verify LUKS unlock prompt at boot and encrypted root
6. **Initramfs mode**: Validate both busybox and systemd hook paths boot correctly (`encrypt` vs `sd-encrypt`)
7. **Boot order**: Verify existing default UEFI entry remains unchanged unless user opted to promote Limine
-224
View File
@@ -1,224 +0,0 @@
# OEM Install + First-Boot Setup Plan
## Goal
Create a second ISO flavor for OEM preloading that is installed to internal storage at factory time, then boots into customer setup on first power-on (no external media required), and ends in a fully encrypted Omarchy install.
## Product Requirements
- Factory can preload one image to internal disk.
- Customer first boot launches setup flow automatically (already works: `.automated_script.sh` runs configurator on tty1).
- Customer chooses credentials/settings.
- Final installed OS uses full-disk encryption (LUKS) with customer password (already works: configurator collects password and passes it to archinstall as `encryption_password`).
- Existing consumer ISO behavior remains unchanged.
---
## Chosen Architecture
### Bootstrapping model
- OEM devices ship with an OEM bootstrap ISO image written to internal disk.
- On first boot, `copytoram=y` kernel arg loads the live environment into RAM, freeing the internal disk.
- Bootstrap environment launches existing configurator + installer.
- Installer performs a fresh install to internal disk with LUKS encryption enabled.
- Bootstrap environment is overwritten during install.
### Recovery model: zero-recovery
- No permanent recovery partition.
- Simplest and strongest security story.
- Recovery requires external reinstall media.
---
## Non-Goals
- No on-device recovery workflow.
- No changes to normal installer UX outside explicit OEM mode checks and warnings.
- No factory credential provisioning.
---
## Repo Implementation Plan
## 1) Add OEM build mode
### Files
- `bin/omarchy-iso-make`
- `builder/build-iso.sh`
### Changes
- Add CLI flag `--oem` to `bin/omarchy-iso-make`.
- Pass `OMARCHY_MODE=oem` into Docker build env (default `normal`).
- In `builder/build-iso.sh`, write mode marker `/root/omarchy_mode` containing `oem` or `normal` (same pattern as `omarchy_mirror`).
- Keep OEM on current boot stack (`uefi.grub` + `bios.syslinux`); do not add or depend on systemd-boot paths.
- When `OMARCHY_MODE=oem`, inject `copytoram=y` into all shipped OEM boot entry points before `mkarchiso` runs:
- `grub/grub.cfg`: append to `linux` cmdline entries.
- `grub/loopback.cfg`: append to `linux` cmdline entries.
- `syslinux/archiso_sys-linux.cfg`: append to `APPEND` lines.
- When `OMARCHY_MODE=oem`, remove accessibility/speakup boot entries:
- `grub/grub.cfg`: remove `archlinux-accessibility` menuentry.
- `grub/loopback.cfg`: remove `archlinux-accessibility` menuentry.
- `syslinux/archiso_sys-linux.cfg`: remove `arch64speech` label block.
- When `OMARCHY_MODE=oem`, override `iso_application` in `profiledef.sh` to `"Omarchy OEM Installer"`.
- Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_INSTALLER_REF` suffix).
### Hardening
- Add post-injection build assertion that fails build if any expected boot config does not contain `copytoram=y`.
- Add post-injection build assertion that fails build if any OEM boot config still contains accessibility/speakup entries.
- Make injection idempotent (do not duplicate `copytoram=y` if already present).
### Acceptance criteria
- `./bin/omarchy-iso-make --oem` builds successfully.
- OEM ISO boot entries contain `copytoram=y` in all supported boot paths.
- OEM ISO does not expose accessibility/speakup boot entries.
- Normal build output and behavior remain unchanged.
### Suggested verification commands
- `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg copytoram=y`
- `bsdtar -xOf out/<iso>.iso boot/grub/loopback.cfg | rg copytoram=y`
- `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg copytoram=y`
- `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg 'accessibility|speakup'` (should return no matches)
- `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg 'speech|accessibility'` (should return no matches)
---
## 2) Add OEM safety guardrails
### Why
OEM image boots from internal disk and later wipes that same disk. Live environment must be fully in RAM before install begins.
### File
- `configs/airootfs/root/.automated_script.sh`
### Changes
- Before `run_configurator`, if `/root/omarchy_mode` is `oem`:
- Verify `/proc/cmdline` contains `copytoram=y`; abort with clear actionable error if missing.
- Verify factory hardware minimum using `MemTotal`:
- Require `MemTotal >= 8 GiB`.
- Abort with clear error if below minimum.
- Verify runtime memory safety using `MemAvailable`:
- Minimum threshold: `1.5 x ISO size + 1 GiB`.
- Abort with clear error if below threshold.
- Verify runtime state indicates live root is in RAM (for example, expected archiso runtime mounts present and boot media not actively mounted as install target path).
### Acceptance criteria
- OEM mode aborts safely if `copytoram=y` missing from cmdline.
- OEM mode aborts safely if `MemTotal < 8 GiB`.
- OEM mode aborts safely if `MemAvailable` below threshold.
- OEM mode aborts safely if runtime state does not confirm RAM-backed live environment.
### Error message requirements
- State exactly what failed.
- State why install is blocked.
- Provide next step (reboot using proper OEM media or use external recovery media).
---
## 3) OEM disk warning
### Why
With `copytoram=y`, archiso copies squashfs to RAM and releases boot device. Existing configurator call `findmnt -no SOURCE /run/archiso/bootmnt` may return empty, so exclusion filter is skipped and all physical disks are shown, including former boot disk. No disk selection logic change is required.
OEM mode still needs an unmistakable warning before destructive install proceeds.
### File
- `configs/airootfs/root/configurator`
### Changes
- Read mode from `/root/omarchy_mode`.
- If mode is `oem`, add OEM-specific warning text before existing destructive confirmation.
- Upgrade confirmation in OEM mode to explicit typed confirmation (for example: `YES, WIPE DISK`) while preserving existing normal-mode flow.
### Acceptance criteria
- Normal ISO behavior is unchanged.
- OEM ISO shows OEM-specific warning before disk wipe confirmation.
- OEM typed confirmation is required before destructive action.
- In VM with `copytoram=y`, former boot disk appears in disk list without exclusion-logic changes.
---
## 4) Documentation
### File
- `README.md`
### Changes
- Document `--oem` build flag and resulting artifact naming.
- Document factory workflow: writing OEM ISO to internal disk and expected first-boot UX.
- Document failure and recovery behavior:
- Missing `copytoram=y`, `MemTotal < 8 GiB`, or low `MemAvailable` causes safe abort.
- Interrupted/failed install requires external reinstall media.
- Add short operator checklist for manufacturing line validation.
---
## 5) Validation
### VM test matrix
1. Build OEM ISO.
2. Write OEM ISO to virtual disk (not external media emulation).
3. Boot from virtual disk and verify:
- `/proc/cmdline` contains `copytoram=y`.
- Guardrail checks pass in healthy case.
- OEM boot menu does not include accessibility/speech entries.
4. Complete setup flow on first boot.
5. Install to same disk.
6. Reboot into installed OS.
7. Verify encryption:
- `lsblk` shows crypt mapping.
- `cryptsetup status` reports active LUKS device.
- Boot requires unlock passphrase.
8. Validate OEM warning + typed confirmation appears and gates destructive action.
### Boot path coverage
- UEFI + GRUB path.
- BIOS/syslinux path (if still shipped).
### Hardware test matrix
- UEFI test on at least two hardware families (Intel + AMD).
- Confirm no external media required.
- Confirm first boot starts setup automatically.
### Negative tests
- OEM boot without `copytoram=y` -> safe abort with clear error.
- `MemTotal < 8 GiB` -> safe abort with clear error.
- Low `MemAvailable` -> safe abort with clear error.
- Runtime state not RAM-backed -> safe abort with clear error.
- Power interruption before installer starts -> deterministic reboot behavior.
- Power interruption during install -> recovery requires external media (documented).
---
## 6) Security hardening checklist
- No factory credentials exist; customer sets all passwords at first boot.
- Clear shell history and transient setup artifacts at end of install.
- Verify no secrets in logs shipped to customer.
- Regenerate host identity on first installed boot as needed:
- `machine-id`
- SSH host keys (if installed/enabled)
- Keep package signature verification and keyring handling intact.
---
## Rollout Plan
1. Implement OEM build flag, mode marker, boot config injection, and build assertions.
2. Implement copytoram, memory, and runtime-state guardrails in `.automated_script.sh`.
3. Add OEM warning text and typed destructive confirmation in configurator.
4. Add docs and operator checklist.
5. Run VM matrix, including negative tests.
6. Run hardware matrix.
7. Cut first OEM preproduction release for manufacturing pilot.
---
## Definition of Done
- `--oem` build exists and is documented.
- Device preloaded with OEM image boots directly to setup.
- Setup installs to internal disk without external media.
- Final installed system is LUKS-encrypted with customer-owned passphrase.
- OEM safety guardrails fail closed with actionable errors.
- Existing non-OEM ISO behavior remains unchanged.
- VM and hardware QA matrix pass.
-643
View File
@@ -1,643 +0,0 @@
# Protected Partition Install Plan: Dual-Boot + Restore
## Goal
Implement one shared non-destructive install path that supports both:
- Dual-boot installs alongside an existing OS.
- OEM/restore installs from a permanent internal restore partition set.
Beyond install: every standalone Omarchy machine — whether OEM-provisioned or installed from the consumer ISO onto an empty disk — exposes a "factory reset" command that durably stages the next boot into the on-disk restore partition set. The installed-OS command leaves the machine as though it had just been prepared to boot from restore; the restore environment owns the destructive wipe/reinstall flow. Dual-boot installs do not get factory reset (no room for a restore partition set).
The current full-disk install path remains unchanged until the protected-partition path is proven. The protected path does its own partitioning, encryption, filesystems, mounts, and boot configuration, then hands a prepared mount tree to archinstall with `pre_mounted_config`.
## Core Architecture
Use a single installer engine with multiple partition policies.
```text
full_disk existing archinstall default_layout + wipe:true path
dual_boot protect existing OS partitions, reuse existing ESP, add Omarchy LUKS root
restore_install protect restore partition set, create/recreate installed ESP + Omarchy LUKS root
```
The key rule: protected modes must never pass the whole disk to archinstall with `wipe: true`.
## Why This Combines Both Features
Dual-boot and restore installs are the same technical problem:
1. Detect disk layout.
2. Identify protected partitions.
3. Create or format only explicitly writable Omarchy partitions.
4. Set up encrypted root manually.
5. Mount a complete install tree manually.
6. Run archinstall only as a package/base-system installer.
7. Configure encryption, fstab, initramfs, and bootloader after archinstall.
Only the partition policy differs.
## Scope Constraints
- UEFI/GPT only for `dual_boot` and `restore_install` v1.
- Existing BIOS/syslinux ISO behavior stays available for normal full-disk installs.
- No automatic shrinking of existing partitions.
- Dual-boot requires pre-existing free space.
- Restore install requires a detected Omarchy restore partition set.
- Installed Omarchy root is encrypted with LUKS.
- Restore partitions are not encrypted unless a later product requirement demands it.
- Restore payload partitions are normally left unmounted in the installed OS and mounted read-only by the restore environment. A separate restore state partition is the only restore-owned partition mounted read-write for reset triggers and recovery logs.
- Swap matches the current full-disk install: zram for memory pressure, plus a Btrfs swapfile in a dedicated `NODATACOW` `/swap` subvolume sized to RAM for hibernation. The subvolume, swapfile, `resume` mkinitcpio hook, and `resume=<dev> resume_offset=<offset>` Limine cmdline drop-in are created post-install by Omarchy's existing `omarchy-hibernation-setup`. Protected modes must not break this: the `encrypt`/`sd-encrypt` hook must precede `resume` in mkinitcpio so the swapfile is reachable after LUKS unlock, and Limine must honor `/etc/limine-entry-tool.d/*.conf` drop-ins so the post-install hook can append resume parameters. No swap partition.
## Partition Policies
### Full Disk
Current behavior:
```text
p1 installed ESP FAT32 2G
p2 installed root LUKS rest of disk
```
Implementation stays on archinstall `default_layout` with `wipe: true` for now.
Phase 7 replaces this with a layout that mirrors `restore_install`:
```text
p1 restore ESP FAT32 protected after install
p2 restore payload ext4 protected after install, read-only payload
p3 restore state ext4 protected after install, writable trigger/log state
p4 installed ESP FAT32 writable
p5 installed root LUKS writable
```
Rationale: factory reset requires an on-disk restore partition set. Making this the default for standalone installs gives every consumer machine the same self-recovery capability OEM-provisioned machines get. The legacy two-partition layout becomes opt-in (e.g. `--no-restore`) for users who explicitly want the disk space back.
### Dual Boot
Example target disk before install:
```text
p1 existing ESP FAT32 protected, reused as /efi
p2 Windows MSR MSR protected
p3 Windows NTFS protected
free space writable region
```
After install:
```text
p1 existing ESP FAT32 protected, mounted as /efi during install
p2 Windows MSR MSR protected
p3 Windows NTFS protected
p4 Omarchy root LUKS new encrypted Btrfs root
```
Policy:
- Protect all existing partitions.
- Reuse the existing ESP only for Omarchy boot files under `EFI/Omarchy`. Do not write top-level `vmlinuz-*`, `initramfs-*`, or vendor-looking paths on a shared ESP.
- Mount the shared ESP at `/efi`, keep `/boot` on the encrypted root, and install an Omarchy-owned kernel/initramfs sync hook that copies boot artifacts into `/efi/EFI/Omarchy` after kernel or initramfs updates.
- Refuse to install if the existing ESP is below 200 MB total or has less than 150 MB free. Warn below 300 MB total or below 200 MB free. These thresholds size for one Omarchy kernel + initramfs + Limine alongside a typical vendor loader; revisit if the Omarchy initramfs grows materially. Free space must be measured from the mounted ESP, not inferred from partition size.
- Create one new LUKS partition in the largest single contiguous free region of at least 40 GB. Refuse otherwise. Do not stitch across multiple non-contiguous regions.
- Preserve current firmware boot order unless the user explicitly opts to make Omarchy first.
- Abort with a clear error if any Windows partition is detected as BitLocker/FVE, including suspended BitLocker. Do not proceed past detection. Adding a new partition to a disk with a BitLocker-protected volume changes the firmware/boot environment enough that Windows can demand the recovery key on next boot — and many users do not have it. The error message must instruct the user to turn off BitLocker from inside Windows and wait for decryption to complete, then reboot into the Omarchy installer and retry. Do not offer an "I have my recovery key, continue anyway" override in v1; users who really want it can fully disable BitLocker themselves.
- Warn if non-BitLocker Windows is detected, since boot order changes can still surprise users.
### Restore Install
Recommended factory disk image layout:
```text
p1 restore ESP FAT32 protected, boots restore environment
p2 restore payload ext4 protected, contains live installer/offline payload
p3 restore state ext4 protected from formatting, writable reset trigger/log state
p4 installed ESP FAT32 writable, created/recreated by installer
p5 installed root LUKS writable, created/recreated by installer
```
Policy:
- Protect restore ESP, restore payload, and restore state partitions from delete/format operations. Only the restore state partition is mounted read-write, and only for trigger files and logs.
- On first boot, create installed ESP and installed root in remaining disk space.
- On reinstall, format the installed Omarchy partitions in place by default. Only delete and recreate them when their geometry no longer matches policy (e.g. the disk was resized). Format-in-place preserves PARTUUIDs, keeps NVRAM entries valid, and avoids touching the partition table.
- Set installed Omarchy first in boot order after successful install.
- Keep restore boot available as firmware fallback and as a boot menu entry.
- Require `copytoram=y` before mutating the internal disk from restore mode.
Use separate restore and installed ESPs for v1. A shared ESP saves space but makes restore easier to break during normal bootloader maintenance or reinstall.
## Restore Image Provisioning
Create a factory artifact separate from the normal consumer ISO:
```text
omarchy-restore.img.zst
```
Factory provisioning writes this image to the internal disk once:
```bash
zstd -dc omarchy-restore.img.zst | dd of=/dev/nvme0n1 bs=16M status=progress conv=fsync
```
The restore image should contain:
- A restore ESP with `EFI/BOOT/BOOTX64.EFI` fallback boot path.
- A restore payload partition labeled `OMARCHY_RESTORE`.
- A small restore state partition labeled `OMARCHY_STATE` for reset triggers and recovery logs.
- The archiso live environment payload, offline package mirror, configurator, and installer scripts.
- A boot entry that passes `omarchy.mode=restore copytoram=y`.
- Boot configuration that locates the restore payload by stable label/UUID, not by transient disk name.
- A signed manifest covering the archiso payload and offline mirror. The verification public key must be shipped outside the mutable restore payload, such as in the restore boot environment and installed reset tool package.
The factory flasher must verify the integrity of `omarchy-restore.img.zst` before writing. The on-disk restore environment must verify its signed payload manifest at boot before mutating the internal disk. A plain checksum stored on the same writable disk is not sufficient. Without a signed manifest, a tampered or corrupted restore image silently destroys the user's data the next time they invoke "factory reset."
On first restore boot:
1. Bind the candidate restore disk to the actual boot source (`/run/archiso/bootmnt`, kernel command line, or equivalent). Do not select an arbitrary disk just because it has matching labels; cloned restore images can have duplicate labels and UUIDs.
2. Move the backup GPT header to the real end of disk with `sgdisk -e` if the factory image was written to a larger disk. This relocates the header only — existing partitions are not grown. The newly-available space at the end of the disk is what installed ESP and installed root consume.
3. On first boot only, randomize the GPT disk GUID, partition GUIDs/PARTUUIDs, and filesystem UUIDs for restore-owned partitions before generating any NVRAM entries, fstab entries, or installer state. Labels may remain stable; UUIDs must not stay cloned across machines.
4. Detect and protect restore partitions by the newly-randomized PARTUUIDs.
5. Create installed ESP and installed root in remaining space.
6. Continue through the shared protected install path.
## Factory Reset
Goal: an installed Omarchy machine that has a restore partition set (any standalone install — `restore_install` today, `full_disk` after Phase 7) exposes a user-facing factory reset command that prepares the device to boot from internal restore without external media. After the next restore boot, the restore environment returns the device to a fresh-install state.
### User Flow
1. User runs `omarchy-factory-reset` from a terminal with sudo. (A GUI entry under Settings is deferred to a follow-up.)
2. The command prints which partitions will be destroyed (installed ESP, installed root) and which will be preserved (restore ESP, restore payload, restore state), by stable identifier, and asks the user to type a confirmation phrase. The typed-phrase gate is intentional friction; no `--yes` shortcut in v1.
3. On confirmation, the command writes a trigger file to the restore state partition, verifies or recreates the restore boot entry, and sets `BootNext` via `efibootmgr` to that restore entry.
4. The command exits only after the trigger file is fsynced, the restore state partition is unmounted, and `BootNext` is confirmed. At that point the installed-OS side is complete: the machine has been left as though it had just been prepared to boot from the restore partition.
5. The user reboots when ready. A later GUI may offer a "reboot now" action, but rebooting is a convenience after the staged state is durable, not part of the safety-critical mutation.
6. The restore environment detects the trigger on boot, verifies the signed restore payload manifest, writes a reset-in-progress marker to restore state, and temporarily sets persistent `BootOrder` to restore first before mutating installed partitions.
7. The restore environment runs the existing `restore_install` reinstall path — format installed ESP, LUKS-format installed root, run the standard install flow — non-interactively, then deletes the trigger and reset-in-progress marker.
8. On install completion, the restore environment restores persistent `BootOrder` to installed Omarchy first, restore second, sets `BootNext` to the installed Omarchy entry, and reboots.
9. The user lands in a fresh installed Omarchy. Normal persistent `BootOrder` (Omarchy first, restore second) has been restored.
User data on installed ESP and installed root is destroyed. Restore ESP, restore payload, and restore state are protected from destructive operations throughout. The restore payload is the source of truth for the rebuild; restore state contains only trigger and recovery state.
### Trigger Mechanism
Use a flag file on the restore state partition rather than NVRAM variables or the restore payload. NVRAM is fragile across firmware updates, and the restore payload should stay immutable/read-only so signed manifest verification remains meaningful.
- Path: `<restore-state-mount>/.omarchy-factory-reset`
- Contents: timestamp, hostname at trigger time, optional reason. Plain text, one `key=value` per line.
- The installed-OS command mounts the restore state partition by `OMARCHY_STATE` label, writes the flag, fsync, and unmounts. It may mount the restore payload read-only only long enough to verify the signed manifest before triggering.
- The restore environment checks for the flag at boot. If present, it runs the reset flow non-interactively and deletes the flag on success. If absent, restore boot behaves normally (manual install/repair menu).
- The flag must be deleted before reboot. Otherwise the machine resets itself in a loop.
### Boot Order
`efibootmgr --bootnext <restore-entry>` is one-shot — it takes effect on the next boot only, then NVRAM reverts to the existing `BootOrder`. This is what we want before the destructive phase: if the reset is interrupted before restore boot, the next normal boot still tries the installed Omarchy first.
After the restore environment accepts the trigger and before it wipes installed partitions, it must temporarily set persistent `BootOrder` to restore first, installed Omarchy second. This prevents a power loss during reinstall from leaving firmware stuck on a half-written installed ESP. On successful reinstall, the restore environment deletes the reset-in-progress marker, restores persistent `BootOrder` to installed Omarchy first and restore second, then sets `BootNext` to the installed Omarchy entry to land the user in their fresh OS.
If the installed OS cannot write and confirm `BootNext`, the staging command must remove any trigger it wrote and abort; a manual reinstall can still proceed through the firmware boot menu, but `omarchy-factory-reset` has not successfully prepared the machine. If the restore environment cannot set temporary restore-first `BootOrder` for an unattended reset, abort before destructive operations; manual reinstall can still proceed with an explicit warning.
### Safety
The factory reset command is a staging tool, not the destructive installer. It must:
- Require root and refuse to run otherwise.
- Refuse to run if no complete restore partition set is detected (restore ESP, restore payload, restore state), e.g. on a dual-boot machine or a `--no-restore` full-disk install.
- Refuse to run if the signed restore payload manifest check fails — a corrupt restore is not recoverable from inside the running OS, and triggering a reset would brick the machine.
- Verify the restore boot entry exists in NVRAM before triggering; if not, repopulate it from `EFI/BOOT/BOOTX64.EFI` fallback or abort with a message.
- Confirm `BootNext` points to the restore boot entry before exiting, so the system is actually prepared to boot restore.
- Print a final pre-confirmation list of partitions that will be destroyed, by stable identifier, so the user sees exactly what they are committing to.
The restore environment, when running due to the trigger flag, must still apply every guard from the Safety Model. The trigger is permission to run the reinstall flow, not permission to skip safety checks.
### File Changes for Factory Reset
- Ship `omarchy-factory-reset` on the installed system. Source location depends on Omarchy's package boundary — either as part of the Omarchy package on the offline mirror, or copied from the live ISO into the installed system during install. Pick one consistent approach during implementation.
- Restore state must be mountable read-write from the installed OS by an admin via `OMARCHY_STATE`. Restore payload should only be mounted read-only for manifest verification via `OMARCHY_RESTORE`.
## Safety Model
Protected installs must use stable identifiers, not only `/dev/nvme0n1pX` names.
Track:
```text
protected partition PARTUUIDs
restore state PARTUUID
writable partition PARTUUIDs
selected disk ID
install mode
install root
```
Every destructive operation must call a guard first:
```text
assert_writable_partition <partition>
assert_not_protected <partition>
```
Guarded operations include:
- `sgdisk --delete`
- `sgdisk --new`
- `wipefs`
- `mkfs.fat`
- `mkfs.btrfs`
- `cryptsetup luksFormat`
- partition table rewrites
In restore mode, also assert:
- `/proc/cmdline` contains `copytoram=y`.
- The restore payload is not mounted read-write from the target disk at mutation time.
- Restore ESP, restore payload, and restore state partitions are detected and marked protected from delete/format operations.
- The installed target partitions do not overlap protected partition ranges.
### Power Loss Recovery
A torn install must be recoverable on next boot rather than leaving the disk in a state that needs manual intervention.
- After partition table changes but before LUKS format: the new partition exists but holds no recognizable filesystem or LUKS header. On the next install boot, treat any writable partition without a valid header as discardable and re-run the format step.
- After LUKS format but before archinstall completes: the LUKS header exists but no working OS is installed. On the next boot from restore or ISO, offer to wipe and retry rather than attempting to resume.
- During archinstall: same as above. Resuming partway through a torn pacstrap is more brittle than restarting it.
In dual-boot mode, "wipe and retry" applies only to the writable Omarchy partition. Protected partitions must be re-verified by PARTUUID before any retry runs — power loss does not unlock protected ranges.
## Installer State Contract
The configurator writes `install_mode.sh` for `.automated_script.sh` to source.
Example dual-boot state:
```bash
INSTALL_MODE=dual_boot
INSTALL_ROOT=/mnt/archinstall
DISK=/dev/nvme0n1
BOOT_PARTITION=/dev/nvme0n1p1
ROOT_PARTITION=/dev/nvme0n1p4
PROTECTED_PARTUUIDS="1111-2222 3333-4444 5555-6666"
WRITABLE_PARTUUIDS="7777-8888"
PROMOTE_OMARCHY_BOOT=false
```
Example restore state:
```bash
INSTALL_MODE=restore_install
INSTALL_ROOT=/mnt/archinstall
DISK=/dev/nvme0n1
RESTORE_ESP_PARTITION=/dev/nvme0n1p1
RESTORE_PAYLOAD_PARTITION=/dev/nvme0n1p2
RESTORE_STATE_PARTITION=/dev/nvme0n1p3
INSTALLED_ESP_PARTITION=/dev/nvme0n1p4
ROOT_PARTITION=/dev/nvme0n1p5
PROTECTED_PARTUUIDS="1111-2222 3333-4444 5555-6666"
WRITABLE_PARTUUIDS="7777-8888 9999-aaaa"
PROMOTE_OMARCHY_BOOT=true
```
Keep this file simple shell, generated only by our configurator, and quote values safely.
Restore mode uses `INSTALLED_ESP_PARTITION` (distinct from `RESTORE_ESP_PARTITION`) because two ESPs are present. It also tracks `RESTORE_STATE_PARTITION` separately because that partition is writable state but protected from delete/format operations. Dual-boot mode keeps `BOOT_PARTITION` since only one ESP exists and is unambiguous.
## Archinstall Config
Protected modes generate this disk config and explicitly disable archinstall bootloader writes:
```json
{
"bootloader": null,
"disk_config": {
"config_type": "pre_mounted_config",
"mountpoint": "/mnt/archinstall"
}
}
```
Do not include `device_modifications`, `disk_encryption`, or `btrfs_options` in protected modes. The installer handles those before and after archinstall. If archinstall does not accept `"bootloader": null`, Phase 2a must identify the supported equivalent; protected mode must not proceed while archinstall can write boot files or NVRAM entries independently.
Full-disk mode keeps the existing generated `default_layout` config.
**Verify before Phase 2b.** Under `pre_mounted_config`, archinstall still tends to run `genfstab`, write a bootloader, and may write `/etc/crypttab`. The plan currently assumes archinstall acts only as a base-system installer in protected mode; this needs to be confirmed on a throwaway VM. Once confirmed, decide for each step (fstab, crypttab, bootloader, initramfs, kernel cmdline) whether the manual steps below run *instead of* archinstall's behavior, *after* archinstall has had its turn, or with archinstall's step disabled in config. The bootloader outcome is a hard gate: protected mode must have a confirmed way to prevent archinstall from writing Limine files or NVRAM entries. Do not start writing the manual pre/post steps until this is resolved — the unknown sits underneath every downstream step.
## File Changes
### `configs/airootfs/root/configurator`
Add shared detection and policy selection:
- `detect_disk_layout` finds GPT state, partitions, partition ranges, filesystem labels, ESPs, ESP free space, and free regions.
- `detect_existing_os` mounts ESP read-only and checks known vendor paths like `EFI/Microsoft`, `EFI/ubuntu`, `EFI/fedora`, `EFI/GRUB`, and `EFI/systemd`.
- `detect_bitlocker` checks every Windows/basic-data candidate partition on the selected disk for BitLocker. Use `blkid -o value -s TYPE`; BitLocker volumes report `BitLocker` rather than `ntfs`. As a backup, read 8 bytes at offset 3 of the partition — BitLocker volumes contain `-FVE-FS-` instead of `NTFS `. If any partition reports BitLocker, the dual-boot path aborts before any disk mutation with the message described in the dual-boot policy.
- `detect_restore_layout` finds `OMARCHY_RESTORE` and `OMARCHY_STATE` partitions and validates the restore ESP/payload/state set.
- `find_free_regions` finds unallocated GPT space using `sgdisk` or `parted`. Selection rule: pick the largest single contiguous region of at least 40 GB. Do not concatenate non-contiguous regions.
- `install_mode_form` chooses between full disk, dual boot, and restore install based on context.
User flow:
```text
Normal ISO boot:
select disk
detect layout
if existing OS/install is detected:
show detected installs and disk layout summary
always offer "Exclusive install (erase disk)" using the existing full-disk `wipe: true` path
if existing ESP + enough free space + guardrails pass:
also offer "Install alongside"
else:
show why "Install alongside" is unavailable (for example: no ESP, no suitable free space, BitLocker/FVE, ESP too small)
else:
keep current erase-disk flow
Restore boot:
detect restore disk automatically
show first-install or reinstall warning
protect restore ESP/payload/state partitions
create/recreate installed partitions only
```
Generated files:
- `user_credentials.json`
- `user_configuration.json`
- `user_full_name.txt`
- `user_email_address.txt`
- `install_mode.sh`
### `configs/airootfs/root/.automated_script.sh`
Add shared protected install functions:
- Source `install_mode.sh` after configurator runs.
- Set `INSTALL_ROOT=/mnt/archinstall` for all modes, including full-disk. A single mount root avoids subtle bugs in code touched by both paths and removes one branch from every post-install step.
- Before changing the mount root, grep the live ISO and post-install scripts for hardcoded `/mnt` references and produce an explicit list of call sites. Update each one consciously rather than via a global rename.
- Add `setup_protected_partitions` before archinstall.
- Add `configure_protected_post_install` after archinstall.
- Add cleanup trap for mounts, LUKS mappings, and temporary directories.
Protected pre-install steps:
1. Verify mode-specific guardrails.
2. Create writable partitions if needed.
3. Run `partprobe "$DISK"` and `udevadm settle`.
4. Format installed ESP when policy requires it.
5. `cryptsetup luksFormat` the root partition.
6. `cryptsetup open` root as `omarchy_root`.
7. `mkfs.btrfs /dev/mapper/omarchy_root`.
8. Create Btrfs subvolumes `@`, `@home`, `@log`, and `@pkg`.
9. Mount subvolumes at `$INSTALL_ROOT` with `compress=zstd`.
10. Mount ESP at `$INSTALL_ROOT/efi`; keep `$INSTALL_ROOT/boot` as a normal directory on encrypted root.
11. Generate deterministic fstab with overwrite semantics.
Protected post-install steps:
1. Configure root unlock for the initramfs hook actually in use. Root unlock is driven by initramfs + kernel cmdline, not by `/etc/crypttab`:
- `encrypt` hook: kernel cmdline `cryptdevice=UUID=<luks-uuid>:omarchy_root root=/dev/mapper/omarchy_root`. No crypttab entry needed for root.
- `sd-encrypt` hook: write `/etc/crypttab.initramfs` (not `/etc/crypttab`) with the LUKS UUID, and pass `rd.luks.name=<luks-uuid>=omarchy_root root=/dev/mapper/omarchy_root` on the kernel cmdline.
2. Update mkinitcpio hooks for the initramfs mode actually present.
3. Regenerate initramfs in chroot with `mkinitcpio -P`.
4. Install an Omarchy-owned kernel/initramfs sync hook that copies boot artifacts from `/boot` to `/efi/EFI/Omarchy` after kernel or initramfs updates.
5. Run the initial boot artifact sync before writing Limine config.
6. Configure Limine kernel cmdline for encrypted root per the hook chosen above, pointing at namespaced artifacts under `EFI/Omarchy`.
7. Install or update Limine on the selected installed ESP.
8. Run `limine-scan` in chroot when appropriate.
9. Apply mode-specific boot order policy.
10. Leave swapfile creation, `resume` hook, and `resume=` cmdline injection to Omarchy's existing `omarchy-hibernation-setup` post-install hook. Verify `/etc/limine-entry-tool.d/` drop-ins are honored by the Limine config layout written here so that hook works unchanged.
### `builder/archinstall.packages`
Ensure the live environment has the tools needed for protected installs:
- `parted`
- `gptfdisk`
- `btrfs-progs`
- `cryptsetup`
- `dosfstools`
- `efibootmgr`
- `mtools`
`efibootmgr` already exists in this file, but keep it explicit in the protected-install dependency checklist.
### `builder/build-iso.sh`
Add build-time mode support:
- Persist `/root/omarchy_mode` as `normal`, `dual_boot_capable`, or `restore`.
- For restore builds, inject `copytoram=y omarchy.mode=restore` into restore boot entries.
- For restore builds, assert boot entries contain `copytoram=y`.
- For normal ISO builds, keep existing behavior except adding dual-boot capability.
### `bin/omarchy-iso-make`
Add optional build flags:
```text
--restore-image build omarchy-restore.img.zst factory artifact
```
Keep the existing ISO output path unchanged unless a restore image is explicitly requested.
## Bootloader Policy
### Default Boot Target
For any standalone install (full-disk or restore_install, with or without a restore partition set), installed Omarchy is the default boot target. The persistent `BootOrder` after every successful install or factory reset is:
1. Installed Omarchy
2. Restore (when a restore partition set exists)
3. Firmware fallback
The user should never have to pick Omarchy from a boot menu under normal operation. Restore is reachable but never default. Boot-menu entries inside Limine for restore/reinstall are convenience; they do not replace this NVRAM ordering.
`BootNext` is used only for one-shot transitions (factory reset trigger, post-reset return to installed Omarchy) and must not be used to make Omarchy "stickily" default — that's the job of `BootOrder`. The only temporary exception is an in-progress factory reset: after the restore environment accepts the trigger and before it mutates installed partitions, restore becomes the persistent first boot target until reinstall succeeds.
For dual-boot, the previous OS's firmware order is preserved unless the user explicitly opts to make Omarchy first. Dual-boot is the only mode where Omarchy may not be the firmware default.
### Dual Boot
- Install Omarchy boot files under `EFI/Omarchy` on the shared ESP.
- Do not overwrite existing vendor directories.
- Preserve firmware default boot order unless user opts in.
- Run `limine-scan` so Windows/Linux entries are discoverable from Omarchy's menu. Verify on a real Windows + Linux dual-boot before depending on this — Limine's auto-detection is less battle-tested than GRUB's. If it does not pick them up cleanly, fall back to the firmware boot menu and document the limitation rather than hand-writing entries.
- BitLocker is handled at detection time by aborting the install (see Dual Boot policy). The bootloader path therefore only ever sees Windows volumes with BitLocker fully off/decrypted.
### Restore Install
- Installed Omarchy gets its own ESP and boot entry.
- Restore ESP keeps `EFI/BOOT/BOOTX64.EFI` fallback path.
- After successful install, write `BootOrder` as installed Omarchy first, restore second. Do not reorder on every boot — only after install or factory reset.
- Add a restore/reinstall entry to the installed Limine menu if it can be done cleanly.
- If NVRAM writes fail, rely on fallback boot paths and show a warning. The user can still recover via firmware boot menu.
## Rollout Plan
### Phase 1: Refactor Without Behavior Change
- Introduce `INSTALL_MODE=full_disk`.
- Introduce `$INSTALL_ROOT` and update post-install paths.
- Keep existing full-disk JSON and archinstall behavior.
- Add tests or dry-run checks for config generation.
### Phase 2a: Archinstall pre_mounted_config Spike
Single-purpose spike on a throwaway VM. No production code changes.
- Drive archinstall with a hand-crafted mount tree and `pre_mounted_config`.
- Record exactly what archinstall does: fstab generation, crypttab writes, bootloader install, initramfs regeneration, kernel cmdline writes.
- Prove the protected-mode config disables archinstall bootloader and NVRAM writes, or document the supported replacement setting. This is a release blocker for Phase 2b.
- Decide whether the manual fstab/Limine/initramfs steps in this plan run before, after, or instead of archinstall's own steps.
- Update this plan with the answers before Phase 2b begins. The downstream design depends on this; do not skip.
### Phase 2b: Shared Protected Install Engine
- Add `pre_mounted_config` generation.
- Add manual LUKS, Btrfs, subvolume, mount, fstab, and cleanup logic.
- Add encrypted boot post-install configuration.
- Add namespaced ESP boot artifact sync under `EFI/Omarchy`.
- Keep it behind internal mode switches until validated.
### Phase 3: Dual-Boot UI and Policy
- Add disk layout detection.
- When another install is detected, show an install-mode choice instead of assuming dual boot: "Install alongside" when eligible, and "Exclusive install (erase disk)" using the existing full-disk `wipe: true` path.
- If another install is detected but dual boot is not eligible, explain why and still offer the exclusive install path.
- Add Windows/BitLocker warnings.
- Validate alongside Windows and Linux.
### Phase 4: Restore Install Policy
- Add restore layout detection.
- Add restore partition set protection.
- Add first-install and reinstall flows.
- Validate that restore ESP, payload, and state survive installation and later reinstall.
### Phase 5: Restore Factory Image
- Build `omarchy-restore.img.zst`.
- Add factory flashing instructions or a dedicated factory flasher ISO/PXE workflow.
- Validate first boot from internal disk with no external media.
- Sign `omarchy-restore.img.zst` and the restore payload manifest. Verify in the factory flasher and at restore boot before any disk mutation.
- Add first-boot UUID randomization for cloned restore images before generating installed boot entries or state.
- Decide on Secure Boot before this phase ships. Shipping with SB disabled is a non-starter for some buyers, and BitLocker (when present on a dual-boot machine restored later) reseals against firmware state. Either implement a signed shim/Limine path, or commit to "SB off" with a documented rationale and a way for the user to flip it on later if they have keys.
### Phase 6: Factory Reset
- Implement `omarchy-factory-reset` as the installed-OS staging command: write and fsync the trigger flag, verify/recreate the restore boot entry, set and confirm `BootNext`, then leave the machine ready to boot restore.
- Wire the restore environment to detect the trigger flag and run the reinstall flow non-interactively.
- Set `BootNext` for one-shot transitions and temporary restore-first persistent `BootOrder` during the destructive reset window.
- Validate on `restore_install` machines first (the flow already has a restore partition set).
### Phase 7: Restore Partition for Standalone Installs
- Extend the standalone full-disk install path to create restore ESP + restore payload + restore state + installed ESP + installed root, mirroring the `restore_install` layout.
- Populate restore payload at install time from the live ISO offline mirror.
- Add `--no-restore` opt-out for users who explicitly want the legacy two-partition layout.
- After this phase, every consumer install gets factory reset by default.
## Acceptance Criteria
- Full-disk install behavior remains unchanged through Phase 6. Phase 7 changes the default layout to include a restore partition set; legacy layout remains available via `--no-restore`.
- Dual-boot install preserves existing OS partitions.
- Dual-boot install creates encrypted Omarchy root in free space.
- When another install is detected, the installer offers the current exclusive full-disk wipe path and only offers dual boot when guardrails pass.
- Dual-boot install writes Omarchy boot artifacts only under `EFI/Omarchy` on a shared ESP and leaves existing vendor paths untouched.
- Dual-boot install aborts cleanly when BitLocker/FVE is detected, including suspended BitLocker, before any disk mutation, with an actionable message.
- Restore install preserves restore ESP, restore payload, and restore state partitions.
- Restore install creates or recreates only installed Omarchy partitions.
- Protected modes never generate archinstall config with `wipe: true`.
- Protected modes prevent archinstall from independently writing boot files or NVRAM entries.
- Protected modes abort before destructive operations if a target partition is protected.
- Restore payload integrity is verified with a signed manifest before any restore-triggered disk mutation.
- Installed Omarchy boots and requires LUKS unlock.
- Installed Omarchy is the default firmware boot target for any standalone install (full-disk or restore_install).
- Restore boot remains available after first install but is never the default.
- Reinstall from restore works without external media.
- `omarchy-factory-reset` from the installed OS leaves the machine staged exactly like a freshly prepared restore-boot device: trigger flag durable on restore state, restore boot entry present, and `BootNext` pointing at restore. No installed partitions are destroyed before the next restore boot.
- A subsequent restore boot from that staged state wipes installed partitions, reinstalls a fresh Omarchy, and lands the user back in the installed OS without external media. Restore ESP, payload, and state partitions survive intact.
- Factory reset refuses to run when no complete restore partition set is present (e.g. dual-boot, `--no-restore` install).
## Validation Matrix
### Regression
1. Full-disk UEFI install on empty disk.
2. Current normal ISO boot menu and default install behavior.
3. Existing offline package installation flow.
### Dual Boot
1. Windows + free space + existing ESP (BitLocker off).
2. Windows + BitLocker enabled — installer must abort before any disk mutation with a clear "turn off BitLocker and wait for decryption" message.
3. Windows + BitLocker suspended — installer must still abort, because v1 treats any BitLocker/FVE signature as unsafe.
4. Linux distro + free space + existing ESP.
5. GPT disk with no free space.
6. ESP below total-size warning threshold.
7. ESP below total-size minimum threshold.
8. ESP large enough by total size but below free-space minimum.
9. Multiple free regions; choose largest suitable region.
10. Firmware boot order preservation.
### Restore
1. Factory image written to larger virtual disk.
2. First boot from internal restore ESP.
3. First install randomizes cloned disk/partition/filesystem UUIDs before creating installed boot entries.
4. First install creates installed ESP/root and preserves restore ESP, payload, and state.
5. Installed OS boots with encrypted root.
6. Installed Omarchy is the firmware default; restore is reachable but not default.
7. Restore boot still works after installed OS exists.
8. Reinstall formats only the installed Omarchy partitions in place; PARTUUIDs unchanged.
9. Restore mode without `copytoram=y` aborts before disk mutation.
10. Missing restore payload or restore state aborts before disk mutation.
11. NVRAM boot entry write failure still leaves fallback boot path usable.
### Factory Reset
1. `omarchy-factory-reset` from installed OS leaves the system staged for restore boot: trigger flag durable on restore state, restore boot entry present, and `BootNext` confirmed.
2. No installed partitions are destroyed before the staged system actually boots into restore.
3. Booting into restore from the staged state reinstalls and returns to a fresh installed Omarchy as the default-booted OS.
4. Trigger flag is deleted before the post-reset reboot. Machine does not loop.
5. Reset interrupted between trigger and restore boot: next normal boot still goes to installed Omarchy (because `BootNext` is one-shot).
6. Reset interrupted during reinstall (power loss): persistent temporary restore-first `BootOrder` boots restore again and re-runs the flow from the trigger flag/reset-in-progress state.
7. Reset refused when no complete restore partition set is detected.
8. Reset refused when signed restore payload verification fails.
9. Reset refused when running without root.
10. Reset refused when restore boot entry is missing from NVRAM and cannot be repopulated from fallback.
### Negative Safety Tests
1. Attempt to format protected restore ESP.
2. Attempt to format protected restore payload.
3. Attempt to format protected restore state.
4. Attempt to format existing Windows partition in dual-boot mode.
5. Partition overlap with protected range.
6. Power loss before partition creation.
7. Power loss after partition creation but before archinstall.
8. Power loss during archinstall.
## Open Questions
- Restore payload size: full offline mirror versus smaller network-capable restore image. Affects whether the consumer ISO's restore-partition default in Phase 7 is feasible at typical disk sizes.
- Restore update strategy after devices ship.
- Whether restore should also expose diagnostics / log upload alongside factory reset.
- Whether factory reset should preserve `/home` user data optionally, or always wipe. v1 always wipes (simpler, matches user expectation of "factory reset"); a "keep my files" variant could land later.
Resolved:
- User-facing reset staging command: yes, `omarchy-factory-reset` (Phase 6).
- Secure Boot: promoted to Phase 5 decision gate.
- Btrfs subvolume layout: protected modes use the current full-disk layout, `@`, `@home`, `@log`, and `@pkg`. The `/swap` subvolume for hibernation is created post-install by `omarchy-hibernation-setup` and is not predeclared at install time.
## Definition of Done
- One shared protected-partition implementation supports both dual-boot and restore install policies.
- Full-disk installs remain stable through Phase 6 and gain a default restore partition set in Phase 7.
- Dual-boot users can install alongside existing OSes without data loss.
- OEM devices can boot internal restore on first power-on, install Omarchy, and retain restore for future reinstall.
- All destructive operations are guarded by protected partition checks.
- Installed Omarchy is the firmware default boot target on every standalone install.
- `omarchy-factory-reset` stages any standalone install to boot internal restore without external media; the restore-triggered reinstall returns the user to a fresh installed Omarchy.
- VM and hardware validation matrices pass.
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
#
# Fast, VM-free tests only. The integration scenarios that boot the ISO in
# QEMU live in test/integration.d/ and run via ./test/integration.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
for test in "$ROOT"/test/unit/*-test.sh; do
printf '==> %s\n' "${test#$ROOT/}"
bash "$test"
done
printf '==> test/unit/test_*.py\n'
python3 -m unittest discover -t "$ROOT" -s "$ROOT/test/unit"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
#
# Integration runner: boots an ISO's installed system in QEMU and runs the
# scenarios in test/integration.d/ against it. The unattended cidata install
# happens once per ISO and is saved as a reusable base image; every scenario
# then boots a throwaway overlay of that base.
#
# Usage: test/integration [release/omarchy.iso] [scenario ...] [options]
#
# scenario Basename of a test/integration.d/<scenario>-test.sh file;
# all scenarios run when none are named
# --reuse-base Skip the install if a saved base image exists
# --port PORT Host port forwarded to guest SSH (default 2322)
# --memory MB VM memory (default 8192)
# --timeout SECS Install timeout (default 2400)
# --no-preview Do not open the collected screenshots in imv
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
ISO=""
REUSE_BASE=false
SCENARIOS=()
export OMARCHY_INTEGRATION_SSH_PORT=2322
export OMARCHY_INTEGRATION_MEMORY=8192
export OMARCHY_INTEGRATION_INSTALL_TIMEOUT=2400
export OMARCHY_INTEGRATION_NO_PREVIEW=false
while (($#)); do
case "$1" in
--reuse-base) REUSE_BASE=true ;;
--no-preview) OMARCHY_INTEGRATION_NO_PREVIEW=true ;;
--port) OMARCHY_INTEGRATION_SSH_PORT="$2"; shift ;;
--memory) OMARCHY_INTEGRATION_MEMORY="$2"; shift ;;
--timeout) OMARCHY_INTEGRATION_INSTALL_TIMEOUT="$2"; shift ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*)
if [[ $1 == *.iso ]]; then
ISO="$1"
else
SCENARIOS+=("$ROOT/test/integration.d/$1-test.sh")
fi
;;
esac
shift
done
if [[ -z $ISO && -t 0 ]]; then
ISO=$(ls -t "$ROOT"/release/*.iso 2>/dev/null | gum choose --header "Select ISO to test")
fi
if [[ ! -f ${ISO:-} ]]; then
echo "No ISO to test. Pass one explicitly or put it in release/." >&2
exit 1
fi
if (( ${#SCENARIOS[@]} == 0 )); then
SCENARIOS=("$ROOT"/test/integration.d/*-test.sh)
else
for scenario in "${SCENARIOS[@]}"; do
if [[ $(basename "$scenario") == "base-test.sh" || ! -f $scenario ]]; then
echo "No such scenario: $scenario" >&2
exit 1
fi
done
fi
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng mtools
export OMARCHY_INTEGRATION_ISO
OMARCHY_INTEGRATION_ISO=$(realpath "$ISO")
# The install phase runs in this process; scenarios run in their own so each
# gets its own VM lifecycle, cleanup trap, and run directory.
SCENARIO=install
source "$ROOT/test/integration.d/base-test.sh"
if $REUSE_BASE && base_image_ready; then
log "Reusing existing base image: $BASE_DISK"
else
install_phase
fi
status=0
for scenario in "${SCENARIOS[@]}"; do
[[ $(basename "$scenario") == "base-test.sh" ]] && continue
printf '\033[1;35m==> %s\033[0m\n' "${scenario#$ROOT/}"
# env -u: a SCENARIO exported by the caller (or leaked from this process)
# must not override the name each scenario derives from its own $0.
env -u SCENARIO bash "$scenario" || status=1
done
exit $status
+617
View File
@@ -0,0 +1,617 @@
#!/bin/bash
#
# Shared harness for the integration scenarios in test/integration.d/: QEMU
# lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest
# SSH, cidata autoinstall, and the reusable base-image contract. Source this
# from a scenario; ./test/integration exports the configuration and ensures
# the base image exists before any scenario runs.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
ISO="$OMARCHY_INTEGRATION_ISO"
SSH_PORT="${OMARCHY_INTEGRATION_SSH_PORT:-2322}"
MEMORY="${OMARCHY_INTEGRATION_MEMORY:-8192}"
INSTALL_TIMEOUT="${OMARCHY_INTEGRATION_INSTALL_TIMEOUT:-2400}"
# cache=none keeps the host page cache out of the guest's disk writes, so
# install times measure the installer rather than the host (CI sets it).
DISK_CACHE="${OMARCHY_INTEGRATION_DISK_CACHE:-}"
NO_PREVIEW="${OMARCHY_INTEGRATION_NO_PREVIEW:-false}"
BOOT_TIMEOUT=600
GUEST_USER="omarchy"
GUEST_PASSWORD="omarchy"
GUEST_HOSTNAME="omarchy-test"
SCENARIO="${SCENARIO:-$(basename "${0%-test.sh}")}"
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
BASE_DIR="$ROOT/test-runs/$(basename "$ISO" .iso)-integration"
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)-$SCENARIO"
BASE_DISK="$BASE_DIR/base.qcow2"
BASE_OVMF="$BASE_DIR/OVMF_VARS.4m.fd"
ACTIVE_OVMF="$BASE_OVMF"
SSH_KEY="$BASE_DIR/id_ed25519"
CIDATA_IMG="$BASE_DIR/cidata.img"
HTTP_PORT=$((SSH_PORT + 1))
HTTP_PID=""
mkdir -p "$BASE_DIR" "$RUN_DIR"
QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-integration-qmp.XXXXXX.sock")
PIDFILE="$RUN_DIR/qemu.pid"
FAILURES=0
log() {
printf '\033[1;35m==> %s\033[0m\n' "$1"
}
check() {
local description="$1"
shift
if "$@" >/dev/null 2>&1; then
printf 'ok - %s\n' "$description"
else
printf 'not ok - %s\n' "$description"
((FAILURES += 1))
fi
}
finish() {
if ((FAILURES == 0)); then
log "$SCENARIO passed. Artifacts: $RUN_DIR"
else
log "$SCENARIO FAILED: $FAILURES assertion(s). Artifacts: $RUN_DIR"
exit 1
fi
}
base_image_ready() {
[[ -f $BASE_DISK && -f $BASE_OVMF && -f $SSH_KEY ]]
}
# ---------------------------------------------------------------- vm control
vm_running() {
[[ -f $PIDFILE ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
}
qmp() {
printf '{"execute":"qmp_capabilities"}\n{"execute":%s}\n' "$1" |
timeout 5 socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true
}
screendump() {
qmp "\"screendump\", \"arguments\": {\"filename\": \"$1\"}" >/dev/null
}
capture_console() {
local name="$1" shot="$RUN_DIR/.capture.ppm"
sleep 1
screendump "$shot"
[[ -s $shot ]] || return 0
magick "$shot" "$RUN_DIR/$name.png" 2>/dev/null || true
rm -f "$shot"
}
stop_vm() {
vm_running || return 0
if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then
qmp '"system_powerdown"' >/dev/null
fi
local waited=0
while vm_running && ((waited < 15)); do
sleep 1
((waited += 1))
done
if vm_running; then
qmp '"quit"' >/dev/null
sleep 1
fi
if vm_running; then
kill "$(cat "$PIDFILE")" 2>/dev/null || true
sleep 1
fi
}
open_screenshots() {
local entry
local -a screenshots=()
while IFS= read -r -d '' entry; do
screenshots+=("${entry#* }")
done < <(
find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -printf '%T@ %p\0' |
sort -zn
)
(( ${#screenshots[@]} > 0 )) || return 0
log "Visual review: ${#screenshots[@]} screenshots in $RUN_DIR"
[[ $NO_PREVIEW == "true" ]] && return 0
if command -v imv >/dev/null && [[ -n ${WAYLAND_DISPLAY:-}${DISPLAY:-} ]]; then
setsid -f imv "${screenshots[@]}" >/dev/null 2>&1
fi
}
cleanup() {
local status=$?
[[ -n $HTTP_PID ]] && kill "$HTTP_PID" 2>/dev/null || true
vm_running && log "Stopping test VM"
stop_vm
rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png"
open_screenshots
rmdir "$RUN_DIR" 2>/dev/null || true
return $status
}
trap cleanup EXIT
start_vm() {
local disk="$1" serial="$2"
shift 2
qemu-system-x86_64 \
-cpu host -enable-kvm -machine q35,accel=kvm \
-smp "$(nproc)" \
-m "$MEMORY" \
-drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \
-drive if=pflash,format=raw,file="$ACTIVE_OVMF" \
-drive file="$disk",format=qcow2,if=none,id=drive0${DISK_CACHE:+,cache=$DISK_CACHE} \
-device virtio-blk-pci,drive=drive0,bootindex=1 \
-device virtio-vga \
-display none \
-usb -device usb-tablet \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \
-device virtio-net-pci,netdev=net0 \
-qmp "unix:$QMP_SOCK,server,nowait" \
-serial "file:$serial" \
-pidfile "$PIDFILE" \
-daemonize \
"$@"
}
# Boot a throwaway overlay of the installed base. The disk gets a per-run
# overlay; the firmware vars need the same isolation or NVRAM state would
# leak between runs.
start_vm_from_base() {
qemu-img create -f qcow2 -b "$BASE_DISK" -F qcow2 "$RUN_DIR/run.qcow2" >/dev/null
cp "$BASE_OVMF" "$RUN_DIR/OVMF_VARS.4m.fd"
ACTIVE_OVMF="$RUN_DIR/OVMF_VARS.4m.fd"
start_vm "$RUN_DIR/run.qcow2" "$RUN_DIR/serial.log"
}
# ------------------------------------------------------------ console driver
ocr_screen() {
local shot="$RUN_DIR/.screen.ppm" prepped="$RUN_DIR/.screen.png"
screendump "$shot"
[[ -s $shot ]] || return 0
magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0
tesseract "$prepped" - --psm 6 2>/dev/null || true
}
wait_for_screen() {
local text="$1" timeout="$2" waited=0 slug
until ocr_screen | grep -qi "$text"; do
if ! vm_running; then
echo "VM exited while waiting for screen: $text" >&2
return 1
fi
if ((waited >= timeout)); then
slug=${text,,}
slug=${slug// /-}
slug=${slug//[^a-z0-9-]/}
capture_console "failure-waiting-for-$slug"
echo "Timed out after ${timeout}s waiting for screen: $text" >&2
return 1
fi
sleep 3
((waited += 3))
done
sleep 1
}
press() {
local part json=""
local -a parts
IFS='-' read -ra parts <<<"$1"
for part in "${parts[@]}"; do
json+="{\"type\":\"qcode\",\"data\":\"$part\"},"
done
qmp "\"send-key\", \"arguments\": {\"keys\": [${json%,}]}" >/dev/null
}
type_text() {
local text="$1" ch i
for ((i = 0; i < ${#text}; i++)); do
ch=${text:i:1}
case "$ch" in
[a-z0-9]) press "$ch" ;;
[A-Z]) press "shift-${ch,,}" ;;
" ") press spc ;;
.) press dot ;;
,) press comma ;;
-) press minus ;;
_) press shift-minus ;;
/) press slash ;;
:) press shift-semicolon ;;
"&") press shift-7 ;;
"=") press equal ;;
"?") press shift-slash ;;
"!") press shift-1 ;;
"~") press shift-grave_accent ;;
@) press shift-2 ;;
*) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
esac
sleep 0.05
done
}
# --------------------------------------------------------------------- guest
ssh_guest() {
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
-o LogLevel=ERROR \
"$GUEST_USER@127.0.0.1" "$@"
}
ssh_sudo() {
ssh_guest "echo $GUEST_PASSWORD | sudo -S -p '' bash -c $(printf %q "$1")"
}
# The deadline is wall-clock: under QEMU user networking every attempt can
# spend its whole ConnectTimeout waiting for a banner, so counting only the
# sleeps let a "300 s" wait run for ten minutes or more, silently.
wait_for_ssh() {
local timeout="$1" failure_name="${2:-failure-ssh-timeout}"
local started=$SECONDS next_note=30 progress_name
while ! ssh_guest true 2>/dev/null; do
if ! vm_running; then
echo "VM exited while waiting for SSH" >&2
return 1
fi
if ((SECONDS - started >= timeout)); then
capture_console "$failure_name"
echo "Timed out after ${timeout}s waiting for SSH" >&2
return 1
fi
if ((SECONDS - started >= next_note)); then
printf -v progress_name 'waiting-ssh-%04ds' "$((SECONDS - started))"
capture_console "$progress_name"
echo " ... waiting for SSH ($((SECONDS - started))s)"
((next_note += 30))
fi
sleep 2
done
}
# Authorize SSH the way a person would when the guest has no key yet (or a
# reset just scrubbed it): console login on a spare TTY, then a bootstrap
# script fetched from a throwaway host HTTP server.
bootstrap_ssh() {
log "Authorizing SSH access via console login"
mkdir -p "$BASE_DIR/www"
cat >"$BASE_DIR/www/bootstrap" <<EOF
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "$(cat "$SSH_KEY.pub")" >>~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
echo "$GUEST_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp
echo "$GUEST_PASSWORD" | sudo -S systemctl enable --now sshd.service
EOF
(cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) &
HTTP_PID=$!
local waited=0
while true; do
press ctrl-alt-f3
sleep 4
ocr_screen | grep -qi "login:" && break
((waited += 8))
if ((waited >= 300)); then
capture_console "failure-console-timeout"
echo "Timed out waiting for a console login prompt" >&2
return 1
fi
sleep 4
done
type_text "$GUEST_USER"
press ret
wait_for_screen "Password" 60
type_text "$GUEST_PASSWORD"
press ret
sleep 3
type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs"
press ret
wait_for_ssh 360 "failure-bootstrap-ssh-timeout"
capture_console "success-bootstrap-ssh"
kill "$HTTP_PID" 2>/dev/null || true
HTTP_PID=""
press ctrl-alt-f1
}
# ---------------------------------------------------------- cidata autoinstall
# The configurator's own output files, synthesized for the 40G virtio disk the
# VM boots from. Sizing mirrors the configurator: 1MiB gap, 2GiB ESP, the rest
# btrfs minus the GPT backup reserve.
build_cidata() {
local dir="$BASE_DIR/cidata" hash
local disk_bytes=$((40 * 1024 * 1024 * 1024))
local mib=$((1024 * 1024)) gib=$((1024 * 1024 * 1024))
local boot_start=$mib boot_size=$((2 * gib))
local main_start=$((boot_size + boot_start))
local main_size=$((disk_bytes - main_start - mib))
rm -rf "$dir"
mkdir -p "$dir"
hash=$(openssl passwd -6 "$GUEST_PASSWORD")
cat >"$dir/user_credentials.json" <<EOF
{
"root_enc_password": $(jq -Rn --arg v "$hash" '$v'),
"users": [
{
"enc_password": $(jq -Rn --arg v "$hash" '$v'),
"groups": [],
"sudo": true,
"username": "$GUEST_USER"
}
]
}
EOF
cat >"$dir/user_configuration.json" <<EOF
{
"app_config": null,
"archinstall-language": "English",
"auth_config": {},
"audio_config": { "audio": "pipewire" },
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
"custom_commands": [],
"omarchy_install": {
"mode": "full_disk",
"defer_provisioning": false,
"target_mount": "/mnt",
"boot": {
"esp_mount": "/boot",
"esp_path": "/EFI/limine",
"efi_binary": "limine_x64.efi",
"enable_fallback": true
},
"storage": { "kernel": "linux-omarchy" }
},
"disk_config": {
"config_type": "default_layout",
"device_modifications": [
{
"device": "/dev/vda",
"partitions": [
{
"btrfs": [],
"dev_path": null,
"flags": [ "boot", "esp" ],
"fs_type": "fat32",
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "ea21d3f2-82bb-49cc-ab5d-6f81ae94e18d",
"size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $boot_size },
"start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $boot_start },
"status": "create",
"type": "primary"
},
{
"btrfs": [
{ "mountpoint": "/", "name": "@" },
{ "mountpoint": "/home", "name": "@home" },
{ "mountpoint": "/var/log", "name": "@log" },
{ "mountpoint": "/var/cache/pacman/pkg", "name": "@pkg" }
],
"dev_path": null,
"flags": [],
"fs_type": "btrfs",
"mount_options": [ "compress=zstd" ],
"mountpoint": null,
"obj_id": "8c2c2b92-1070-455d-b76a-56263bab24aa",
"size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $main_size },
"start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $main_start },
"status": "create",
"type": "primary"
}
],
"wipe": true
}
]
},
"hostname": "$GUEST_HOSTNAME",
"kernels": [ "linux-omarchy" ],
"network_config": { "type": "iso" },
"ntp": true,
"parallel_downloads": 8,
"script": null,
"services": [],
"swap": true,
"timezone": "UTC",
"locale_config": { "kb_layout": "us", "sys_enc": "UTF-8", "sys_lang": "en_US.UTF-8" },
"mirror_config": {
"custom_repositories": [],
"custom_servers": [
{"url": "https://mirror.omarchy.org/\$repo/os/\$arch"},
{"url": "https://mirror.rackspace.com/archlinux/\$repo/os/\$arch"},
{"url": "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"}
],
"mirror_regions": {},
"optional_repositories": []
},
"packages": [
"base-devel",
"git",
"omarchy-keyring",
"$SETTINGS_PACKAGE",
"$RUNTIME_PACKAGE"
],
"profile_config": { "gfx_driver": null, "greeter": null, "profile": {} },
"version": "3.0.9"
}
EOF
echo "Omarchy Test" >"$dir/user_full_name.txt"
echo "test@omarchy.org" >"$dir/user_email_address.txt"
echo "false" >"$dir/user_encrypt_installation.txt"
cp "$SSH_KEY.pub" "$dir/authorized_keys"
rm -f "$CIDATA_IMG"
truncate -s 4M "$CIDATA_IMG"
mkfs.vfat -n CIDATA "$CIDATA_IMG" >/dev/null
mcopy -i "$CIDATA_IMG" "$dir"/* ::/
}
# 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") != *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)"
[[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-integration" -f "$SSH_KEY"
detect_packages
build_cidata
# Build under a staging name: the finished base is promoted only after a
# clean shutdown, so a failed install can never pass for a reusable base.
rm -f "$BASE_DISK" "$BASE_DISK.building"
qemu-img create -f qcow2 "$BASE_DISK.building" 40G >/dev/null
cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF"
ACTIVE_OVMF="$BASE_OVMF"
start_vm "$BASE_DISK.building" "$RUN_DIR/install-serial.log" \
-drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \
-device ide-cd,drive=cdrom0,bootindex=2 \
-drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \
-device usb-storage,drive=cidata
log "Waiting for the unattended install to finish (timeout ${INSTALL_TIMEOUT}s)"
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).
if ssh_guest true 2>/dev/null; then
log "Install finished and rebooted into the installed system."
capture_console "success-install-first-boot"
break
fi
text=$(ocr_screen)
if grep -qi "Reboot Now" <<<"$text"; then
log "Install finished. Confirming the reboot prompt."
capture_console "success-install-reboot"
press ret
fi
if grep -qi "installation stopped" <<<"$text"; then
capture_console "failure-install-stopped"
echo "Install failed — screenshot saved to $RUN_DIR" >&2
return 1
fi
if ! vm_running; then
echo "VM exited during install" >&2
return 1
fi
if ((waited >= INSTALL_TIMEOUT)); then
capture_console "failure-install-timeout"
echo "Timed out after ${INSTALL_TIMEOUT}s waiting for install" >&2
return 1
fi
# A line every 30 s so a slow runner never looks hung; a screenshot of
# the installer's dashboard every 2 min, which is enough to see progress.
if ((waited % 120 == 0)); then
printf -v progress_name 'success-install-progress-%04ds' "$waited"
capture_console "$progress_name"
fi
# 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))
done
# Keep the installer's own clock, its log and the first boot's numbers next
# to the base image while the system is reachable, so CI can report them
# without attaching the disk.
ssh_sudo "cat /var/log/omarchy-install-timing.json" >"$BASE_DIR/omarchy-install-timing.json" 2>/dev/null || true
ssh_sudo "cat /var/log/omarchy-install.log" >"$BASE_DIR/omarchy-install.log" 2>/dev/null || true
# SSH can answer before startup finishes, and systemd-analyze refuses until
# it has; wait (bounded) so the first-boot numbers are final.
ssh_guest "timeout 120 systemctl is-system-running --wait >/dev/null; systemd-analyze" \
>"$BASE_DIR/first-boot-systemd-analyze.txt" 2>/dev/null || true
log "Installed system is up. Saving base image."
stop_vm
mv "$BASE_DISK.building" "$BASE_DISK"
}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
#
# Everyday boot: boots the installed system a second time and records how
# long it takes to answer SSH and what systemd-analyze reports, then asserts
# that systemd finished starting with no failed units. CI reports these next
# to the install time, so a change that slows booting shows up in its PR.
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; }
log "Booting the installed system"
boot_started=$(date +%s%N)
start_vm_from_base
if ! wait_for_ssh 300 failure-boot-ssh-timeout; then
# The console screenshot is saved; the serial log shows what the firmware,
# loader and kernel printed, which a screenshot of a hung boot often lacks.
echo "--- last lines of the serial console ---" >&2
tail -n 40 "$RUN_DIR/serial.log" >&2 2>/dev/null || true
exit 1
fi
boot_ms=$((($(date +%s%N) - boot_started) / 1000000))
# Coarse: wait_for_ssh polls every 2 s. systemd-analyze below has the exact
# firmware, loader, kernel and userspace split.
log "SSH answered within ${boot_ms} ms of power-on"
# --wait returns once startup has finished, so systemd-analyze has final
# numbers and a unit that fails late in boot is still counted. It exits
# non-zero for "degraded"; keep the answer so the diagnostics below are still
# collected and the assertion reports it.
state=$(ssh_guest "timeout 120 systemctl is-system-running --wait" 2>/dev/null | tr -d '\r' || true)
ssh_guest "systemd-analyze" >"$RUN_DIR/systemd-analyze.txt" 2>/dev/null || true
ssh_guest "systemd-analyze blame --no-pager | head -n 20" >"$RUN_DIR/systemd-analyze-blame.txt" 2>/dev/null || true
# A query that fails must not read as "no failed units".
ssh_guest "systemctl --failed --no-legend --plain" >"$RUN_DIR/failed-units.txt" 2>/dev/null ||
echo "could not query failed units over SSH" >"$RUN_DIR/failed-units.txt"
printf '{"boot_to_ssh_ms": %d, "system_state": "%s"}\n' "$boot_ms" "$state" >"$RUN_DIR/boot-timing.json"
check "systemd reports the system running (got: ${state:-nothing})" test "$state" = running
check "no failed units" test ! -s "$RUN_DIR/failed-units.txt"
capture_console "success-boot"
stop_vm
finish
+287
View File
@@ -0,0 +1,287 @@
#!/bin/bash
#
# Factory reset on a shared ESP: proves omarchy-system-factory-reset hands a
# machine on without destroying a dual-boot setup (basecamp/omarchy#6847).
# Dresses the installed ESP up as a dual-boot machine (Windows + a foreign
# Linux, exactly what the reset must not destroy), runs a real factory reset,
# and walks the first-boot setup that follows.
#
# Staging asserts the Windows and foreign Linux entries and their ESP payload
# survive, the old Omarchy identity (entry + directory) is gone, and a fresh
# Omarchy entry exists. First boot asserts provisioning completes, the machine
# identity changed, and the foreign entries are still intact afterwards.
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
FOREIGN_ID="fedcfedcfedcfedcfedcfedcfedcfedc"
base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; }
# --------------------------------------------------------------- esp fixture
# Dress the ESP up as a shared dual-boot ESP: a Windows entry, and a second
# Linux installation cloned from the real Omarchy entry so its shape is
# exactly what limine-entry-tool writes, under a foreign machine-id with its
# own boot directory and byte-identical UKIs (so the cloned entry hashes
# stay valid).
fixture_shared_esp() {
OLD_ID=$(ssh_guest "cat /etc/machine-id" | tr -d '\r\n')
[[ $OLD_ID =~ ^[0-9a-f]{32}$ ]] || { echo "unexpected machine-id: $OLD_ID" >&2; return 1; }
ssh_sudo "cp /boot/limine.conf /boot/limine.conf.pretest" >/dev/null
ssh_sudo "
set -euo pipefail
awk '/^\//{block=1} block' /boot/limine.conf |
sed -e 's/$OLD_ID/$FOREIGN_ID/g' \
-e 's|/EFI/Linux/omarchy_|/EFI/Linux/foreign_|g' \
-e 's|^/\(+\{0,1\}\)\([^/+]\)|/\1Foreign \2|' >/tmp/foreign-entries
grep -q 'machine-id=$FOREIGN_ID' /tmp/foreign-entries
for uki in /boot/EFI/Linux/omarchy_*.efi; do
cp \"\$uki\" \"\${uki/omarchy_/foreign_}\"
done
cat >>/boot/limine.conf <<'CONF'
/Windows
protocol: efi
path: boot():/EFI/Microsoft/Boot/bootmgfw.efi
CONF
cat /tmp/foreign-entries >>/boot/limine.conf
mkdir -p /boot/$FOREIGN_ID /boot/EFI/Microsoft/Boot
echo foreign-payload >/boot/$FOREIGN_ID/marker
echo windows-payload >/boot/EFI/Microsoft/Boot/bootmgfw.efi
" >/dev/null
ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.fixtured"
log "ESP fixtured as a shared dual-boot ESP (old machine-id $OLD_ID)"
}
# ------------------------------------------------------------- reset driver
# Drive the interactive reset through a pty on the guest: `script` supplies
# the terminal gum needs, a fifo feeds it answers on our schedule, and the
# typescript lands in /tmp/reset.out for the polling below.
start_reset() {
# script hands the pty over at 0x0 when its own stdin is not a terminal,
# which crashes gum's text input; give it a real size first. setsid keeps
# both processes alive after this ssh session hangs up.
ssh_guest "
rm -f /tmp/reset.in /tmp/reset.out
mkfifo /tmp/reset.in
setsid bash -c 'exec 3<>/tmp/reset.in && sleep 3600' >/dev/null 2>&1 &
setsid script -qefc 'stty rows 35 cols 130; sudo -S -p \"\" omarchy-system-factory-reset' /tmp/reset.out \
</tmp/reset.in >/dev/null 2>&1 &
"
sleep 2
ssh_guest "printf '%s\n' '$GUEST_PASSWORD' >/tmp/reset.in"
}
reset_output() {
ssh_guest "sed 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\x1b\][^\x07]*\x07//g' /tmp/reset.out 2>/dev/null" || true
}
wait_for_reset_output() {
local text="$1" timeout="$2" waited=0
until reset_output | grep -qi "$text"; do
if reset_output | grep -qE "^Error:|COMMAND_EXIT_CODE"; then
reset_output >"$RUN_DIR/reset.out"
echo "The factory reset failed before reaching: $text" >&2
reset_output | grep -E "^Error:" >&2 || true
return 1
fi
if ((waited >= timeout)); then
reset_output >"$RUN_DIR/reset.out"
echo "Timed out after ${timeout}s waiting for reset output: $text" >&2
return 1
fi
sleep 3
((waited += 3))
done
}
reset_phase() {
log "Booting reset VM from base image overlay"
start_vm_from_base
wait_for_ssh "$BOOT_TIMEOUT"
fixture_shared_esp
log "Running omarchy-system-factory-reset"
start_reset
wait_for_reset_output "Type 'reset' to continue" 60
capture_console "success-reset-confirm"
ssh_guest "printf 'reset\r' >/tmp/reset.in"
# Staging clones @factory, rebuilds the UKI in a chroot, and reworks the
# ESP; only then does the reboot prompt appear.
wait_for_reset_output "Reboot to complete the reset" 900
reset_output >"$RUN_DIR/reset.out"
log "Reset staged. Asserting on the shared ESP before rebooting."
check "windows entry survives staging" \
ssh_sudo "grep -q '^/Windows' /boot/limine.conf"
check "windows boot payload survives staging" \
ssh_sudo "grep -q windows-payload /boot/EFI/Microsoft/Boot/bootmgfw.efi"
check "foreign linux entry survives staging" \
ssh_sudo "grep -q 'machine-id=$FOREIGN_ID' /boot/limine.conf"
check "foreign boot directory survives staging" \
ssh_sudo "grep -q foreign-payload /boot/$FOREIGN_ID/marker"
check "foreign UKI survives staging" \
ssh_sudo "test -f /boot/EFI/Linux/foreign_linux.efi"
check "old omarchy entry is removed by staging" \
ssh_sudo "! grep -q 'machine-id=$OLD_ID' /boot/limine.conf"
check "old omarchy boot directory is removed by staging" \
ssh_sudo "! test -e /boot/$OLD_ID"
check "a fresh omarchy entry exists after staging" \
ssh_sudo "grep -o 'machine-id=[0-9a-f]\{32\}' /boot/limine.conf | grep -qv 'machine-id=$FOREIGN_ID'"
ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.staged" || true
ssh_sudo "cat /var/log/omarchy-system-factory-reset.log" >"$RUN_DIR/factory-reset.log" || true
capture_console "success-reset-staged"
if ((FAILURES > 0)); then
log "Staging assertions failed; keeping the VM off the reboot path."
return 1
fi
# Decline the prompt's reboot; reboot deliberately so the ssh session
# closing cannot race the answer.
ssh_guest "printf 'n' >/tmp/reset.in"
sleep 2
log "Rebooting into the factory first boot"
ssh_sudo "systemctl reboot" || true
}
# --------------------------------------------------------------- first boot
# Answer whichever setup screens the factory first boot presents. The form's
# exact steps depend on what the reset deferred, so dispatch on the screen
# actually showing instead of scripting a fixed sequence.
drive_first_boot() {
log "Driving the factory first-boot setup"
# The reset must leave a bootable default: the machine has to reach the
# first-boot greeter on its own. A numeric default_entry pointing at a
# moved entry parks Limine at the menu instead — catch that explicitly,
# then boot the Omarchy entry by hand so the rest of the flow still runs.
if wait_for_screen "Linux by DHH" 120 2>/dev/null; then
printf 'ok - %s\n' "the machine boots into first-boot setup unattended"
else
if ocr_screen | grep -qi "Omarchy Bootloader"; then
printf 'not ok - %s\n' "the machine boots into first-boot setup unattended (parked at the Limine menu)"
((FAILURES += 1))
capture_console "failure-firstboot-parked-at-menu"
log "Selecting the Omarchy entry manually to continue the run"
press down; sleep 1; press down; sleep 1; press down; sleep 1
press ret
wait_for_screen "Linux by DHH" 600
else
echo "Neither the greeter nor the Limine menu appeared" >&2
return 1
fi
fi
capture_console "success-firstboot-00-greeter"
press ret
local waited=0 text step
local -A answered=()
while true; do
text=$(ocr_screen)
step=""
if grep -qi "look right" <<<"$text"; then
capture_console "success-firstboot-03-review"
press ret
break
elif grep -qi "keyboard layout" <<<"$text"; then
step="keyboard"; [[ -v answered[$step] ]] || { capture_console "success-firstboot-01-keyboard"; press ret; }
elif grep -qi "Username" <<<"$text"; then
step="username"; [[ -v answered[$step] ]] || { type_text "$GUEST_USER"; press ret; }
elif grep -qi "Conf *irm" <<<"$text"; then
step="confirm"; [[ -v answered[$step] ]] || { type_text "$GUEST_PASSWORD"; press ret; }
elif grep -qi "Password" <<<"$text"; then
step="password"; [[ -v answered[$step] ]] || { type_text "$GUEST_PASSWORD"; press ret; }
elif grep -qi "Full name" <<<"$text"; then
step="fullname"; [[ -v answered[$step] ]] || { type_text "Omarchy Test"; press ret; }
elif grep -qi "Email address" <<<"$text"; then
step="email"; [[ -v answered[$step] ]] || { type_text "test@omarchy.org"; capture_console "success-firstboot-02-form"; press ret; }
elif grep -qi "Hostname" <<<"$text"; then
step="hostname"; [[ -v answered[$step] ]] || { type_text "$GUEST_HOSTNAME"; press ret; }
elif grep -qi "Timezone" <<<"$text"; then
step="timezone"; [[ -v answered[$step] ]] || press ret
fi
[[ -n $step ]] && answered[$step]=1
if ! vm_running; then
echo "VM exited during first-boot setup" >&2
return 1
fi
if ((waited >= 600)); then
capture_console "failure-firstboot-form-timeout"
echo "Timed out driving the first-boot form" >&2
return 1
fi
sleep 3
((waited += 3))
done
capture_console "success-firstboot-04-finalizing"
}
first_boot_phase() {
drive_first_boot
# Finalization can still be running when the console appears; the pending
# flag clearing is the definitive end of provisioning. The reset scrubbed
# users, host keys, and sshd state, so SSH has to be re-authorized first.
bootstrap_ssh
log "Waiting for provisioning to finish"
local waited=0 rc
while true; do
rc=0
ssh_guest "test -f /var/lib/omarchy/provisioning/pending" 2>/dev/null || rc=$?
(( rc == 0 || rc == 255 )) || break
if ((waited >= 900)); then
capture_console "failure-provisioning-timeout"
echo "Timed out waiting for first-boot provisioning to complete" >&2
return 1
fi
sleep 10
((waited += 10))
done
local new_id
new_id=$(ssh_guest "cat /etc/machine-id" | tr -d '\r\n')
check "provisioning completed on first boot" \
ssh_guest "! test -f /var/lib/omarchy/provisioning/pending"
# A well-formed identity, and not one we already know: an empty or echoed
# ID would make the boot-entry grep below match the wrong entry.
check "the reset minted a fresh machine identity" \
bash -c "[[ '$new_id' =~ ^[0-9a-f]{32}\$ && '$new_id' != '$OLD_ID' && '$new_id' != '$FOREIGN_ID' ]]"
check "windows entry survives the first boot" \
ssh_sudo "grep -q '^/Windows' /boot/limine.conf"
check "windows boot payload survives the first boot" \
ssh_sudo "grep -q windows-payload /boot/EFI/Microsoft/Boot/bootmgfw.efi"
check "foreign linux entry survives the first boot" \
ssh_sudo "grep -q 'machine-id=$FOREIGN_ID' /boot/limine.conf"
check "foreign boot directory survives the first boot" \
ssh_sudo "grep -q foreign-payload /boot/$FOREIGN_ID/marker"
check "foreign UKI survives the first boot" \
ssh_sudo "test -f /boot/EFI/Linux/foreign_linux.efi"
check "old omarchy identity never returns" \
ssh_sudo "! grep -q 'machine-id=$OLD_ID' /boot/limine.conf"
check "the new identity owns a boot entry" \
ssh_sudo "grep -q 'machine-id=$new_id' /boot/limine.conf"
ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.final" || true
capture_console "success-final"
}
# ---------------------------------------------------------------------- main
reset_phase
first_boot_phase
finish
View File
Whitespace-only changes.
+224
View File
@@ -0,0 +1,224 @@
#!/bin/bash
#
# Unit tests for omarchy-cidata-load. The script takes a path prefix, so every
# case runs against a throwaway sandbox with mount/umount/udevadm stubbed out:
# "mounting" copies the fake drive's contents into the mountpoint, and every
# stub logs its invocation so the cases can assert what was (not) called.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
local description="$1"
local detail="${2:-}"
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
printf 'not ok - %s\n' "$description" >&2
exit 1
}
work=$(mktemp -d)
trap 'chmod -R u+w "$work"; rm -rf "$work"' EXIT
stub_dir="$work/stubs"
mkdir -p "$stub_dir"
cat >"$stub_dir/udevadm" <<'STUB'
#!/bin/bash
printf 'udevadm %s\n' "$*" >>"$TEST_LOG"
STUB
cat >"$stub_dir/mount" <<'STUB'
#!/bin/bash
printf 'mount %s\n' "$*" >>"$TEST_LOG"
[[ ${MOUNT_FAIL:-} == 1 ]] && exit 32
device=$3 mountpoint=$4
cp -a "$(readlink -f "$device")"/. "$mountpoint"/
STUB
cat >"$stub_dir/umount" <<'STUB'
#!/bin/bash
printf 'umount %s\n' "$*" >>"$TEST_LOG"
STUB
chmod +x "$stub_dir"/*
new_sandbox() {
sandbox=$(mktemp -d "$work/sandbox.XXXXXX")
mkdir -p "$sandbox/dev/disk/by-label" "$sandbox/root" "$sandbox/media"
export TEST_LOG="$sandbox/calls.log"
: >"$TEST_LOG"
}
attach_drive() {
ln -s "$sandbox/media" "$sandbox/dev/disk/by-label/$1"
}
run_load() {
PATH="$stub_dir:$PATH" "$CIDATA_LOAD" "$sandbox"
}
write_required_pair() {
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
echo '{"users": []}' >"$sandbox/media/user_credentials.json"
}
# No cidata drive attached: fall back to the wizard without mounting anything.
new_sandbox
! run_load || fail "no drive exits non-zero"
! grep -q '^mount ' "$TEST_LOG" || fail "no drive mounts nothing"
pass "no drive falls back to the wizard"
# The probe must wait for udev to finish enumerating before concluding there
# is no drive.
grep -q '^udevadm settle$' "$TEST_LOG" || fail "probe settles udev first"
pass "probe settles udev first"
# A drive with the full file set: everything lands in /root and the drive is
# unmounted afterwards.
new_sandbox
attach_drive cidata
write_required_pair
echo "Jeff" >"$sandbox/media/user_full_name.txt"
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
echo 'tskey-auth-kFAKEKEY' >"$sandbox/media/tailscale_authkey"
run_load || fail "full file set loads"
for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
done
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
pass "full file set loads, copies everything, and unmounts"
# The uppercase label variant some tools produce works too.
new_sandbox
attach_drive CIDATA
write_required_pair
run_load || fail "uppercase CIDATA label loads"
pass "uppercase CIDATA label loads"
# The required pair alone is a valid autoinstall drive; the optional files
# stay optional.
new_sandbox
attach_drive cidata
write_required_pair
run_load || fail "required pair alone loads"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files"
pass "required pair alone loads without optional files"
# Optional files are copied individually when present.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
run_load || fail "required pair plus authorized_keys loads"
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
[[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied"
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
pass "present optional files are copied, absent ones skipped"
# A defer-provisioning marker replaces user_credentials.json: deferred-provisioning installs
# creation to first boot, so imaging rigs ship no credentials at all.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
: >"$sandbox/media/defer-provisioning"
run_load || fail "config plus defer-provisioning marker loads"
[[ -f $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker is copied"
[[ ! -e $sandbox/root/user_credentials.json ]] || fail "defer-provisioning drive copies no credentials"
pass "defer-provisioning marker stands in for credentials"
# The defer-provisioning marker and credentials can coexist (rig supplies its own LUKS
# passphrase in the credentials file); both are copied.
new_sandbox
attach_drive cidata
write_required_pair
: >"$sandbox/media/defer-provisioning"
run_load || fail "defer-provisioning marker plus credentials loads"
[[ -f $sandbox/root/defer-provisioning && -f $sandbox/root/user_credentials.json ]] || fail "both defer-provisioning marker and credentials are copied"
pass "defer-provisioning marker plus credentials copies both"
# An defer-provisioning marker without the configuration is still not an autoinstall drive.
new_sandbox
attach_drive cidata
: >"$sandbox/media/defer-provisioning"
! run_load || fail "defer-provisioning marker alone is not an autoinstall drive"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker alone copies nothing"
pass "defer-provisioning marker alone falls back to the wizard"
# Stale deferred-provisioning inputs from a previous load in the same session are cleared before
# the current drive is copied: a normal drive must not inherit an old defer-provisioning
# marker or credentials.
new_sandbox
attach_drive cidata
write_required_pair
: >"$sandbox/root/defer-provisioning" # leftover from a prior deferred-provisioning load
echo 'old-keys' >"$sandbox/root/authorized_keys"
run_load || fail "normal drive after a stale defer-provisioning load loads"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker is cleared"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "stale optional inputs are cleared"
pass "stale deferred-provisioning inputs are cleared before loading a normal drive"
# A drive that isn't an autoinstall drive at all still clears stale inputs so
# the wizard that follows doesn't inherit them.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" # half a pair
: >"$sandbox/root/defer-provisioning"
! run_load || fail "half-pair drive still falls back"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker cleared even on fallback"
pass "stale inputs are cleared even when falling back to the wizard"
# Half the required pair is not an autoinstall drive: unmount and fall back.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
! run_load || fail "half the required pair exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "half the required pair copies nothing"
grep -q '^umount ' "$TEST_LOG" || fail "half the required pair still unmounts"
pass "half the required pair falls back and unmounts"
# An empty drive labeled cidata is not an autoinstall drive either.
new_sandbox
attach_drive cidata
! run_load || fail "empty drive exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "empty drive still unmounts"
pass "empty drive falls back and unmounts"
# A drive that will not mount falls back rather than failing the boot.
new_sandbox
attach_drive cidata
write_required_pair
! MOUNT_FAIL=1 run_load || fail "mount failure exits non-zero"
! grep -q '^umount ' "$TEST_LOG" || fail "mount failure has nothing to unmount"
pass "mount failure falls back to the wizard"
# A copy failure must not report a loaded drive: the install would start with
# missing inputs. Fall back and let the wizard produce them instead.
new_sandbox
attach_drive cidata
write_required_pair
chmod 555 "$sandbox/root"
! run_load 2>/dev/null || fail "copy failure exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "copy failure still unmounts"
pass "copy failure falls back and unmounts"
# A partial copy must clean up after itself: the wizard removes only what it
# writes, so anything the loader left behind would leak into the interactive
# install that follows.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
mkdir "$sandbox/root/user_credentials.json"
! run_load 2>/dev/null || fail "partial copy exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind"
pass "partial copy cleans up what it copied"
+458
View File
@@ -0,0 +1,458 @@
#!/bin/bash
#
# Unit tests for omarchy-install-diagnose-media and the failure screen that
# renders it. Every case builds a throwaway offline mirror -- a package file
# and a real repo-add-shaped offline.db naming its sha256 -- so the three
# verdicts are told apart by the same evidence the live ISO has.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
DIAGNOSE="$ROOT/configs/airootfs/usr/local/bin/omarchy-install-diagnose-media"
DASHBOARD="$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard"
PACKAGE="npth-1.8-1-x86_64.pkg.tar.zst"
TARGET_CACHE="/mnt/var/cache/pacman/pkg"
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
local description="$1"
local detail="${2:-}"
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
printf 'not ok - %s\n' "$description" >&2
exit 1
}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mirror="$work/mirror"
mkdir -p "$mirror"
# repo-add writes one desc record per package, %FILENAME% before %SHA256SUM%.
# Building a real gzipped tar of that shape keeps the parser honest.
write_mirror_db() {
local checksum="$1" filename="${2:-$PACKAGE}" staging="$work/db"
rm -rf "$staging"
mkdir -p "$staging/npth-1.8-1"
{
printf '%%FILENAME%%\n%s\n\n' "$filename"
printf '%%NAME%%\nnpth\n\n'
printf '%%VERSION%%\n1.8-1\n\n'
printf '%%SHA256SUM%%\n%s\n\n' "$checksum"
} >"$staging/npth-1.8-1/desc"
# A second package proves the lookup matches on name rather than on order.
mkdir -p "$staging/zlib-1.3-1"
{
printf '%%FILENAME%%\nzlib-1.3-1-x86_64.pkg.tar.zst\n\n'
printf '%%NAME%%\nzlib\n\n'
printf '%%SHA256SUM%%\n%s\n\n' "$(printf 'f%.0s' {1..64})"
} >"$staging/zlib-1.3-1/desc"
tar -czf "$mirror/offline.db" -C "$staging" npth-1.8-1 zlib-1.3-1
}
write_log() {
local log="$1" path="${2:-$TARGET_CACHE/$PACKAGE}"
{
echo "[dashboard] checking package integrity..."
echo "[dashboard] :: File $path is corrupted (invalid or corrupted package)."
echo "[dashboard] error: failed to commit transaction (invalid or corrupted package)"
echo "[dashboard] ==> ERROR: Failed to install packages to new root"
} >"$log"
}
diagnose() {
OMARCHY_OFFLINE_MIRROR="$mirror" OMARCHY_TARGET_PACKAGE_CACHE="$TARGET_CACHE" \
"$DIAGNOSE" "$@"
}
log="$work/install.log"
write_log "$log"
# The medium was written wrong: the package on the ISO no longer hashes to what
# the build recorded for it.
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
write_mirror_db "$(printf '0%.0s' {1..64})"
set +e
output=$(diagnose "$log")
status=$?
set -e
(( status == 0 )) || fail "a checksum mismatch is diagnosed" "exit status $status"
[[ $(head -n 1 <<<"$output") == "The install medium is damaged" ]] ||
fail "a checksum mismatch is diagnosed" "$output"
grep -qF "$PACKAGE" <<<"$output" ||
fail "the diagnosis names the package" "$output"
grep -qF "no longer matches the checksum" <<<"$output" ||
fail "the diagnosis says the checksum disagrees" "$output"
grep -qF "sha256sum" <<<"$output" ||
fail "the diagnosis says how to verify a fresh download" "$output"
pass "a checksum mismatch says the medium is damaged and names the package"
# The medium reads clean now, so the bytes were misread rather than written
# wrong. Re-downloading fixes nothing, and the advice has to say so.
write_mirror_db "$(sha256sum "$mirror/$PACKAGE" | cut -d " " -f 1)"
set +e
output=$(diagnose "$log")
status=$?
set -e
(( status == 0 )) || fail "an intact package is still diagnosed" "exit status $status"
[[ $(head -n 1 <<<"$output") == "The install medium was misread" ]] ||
fail "an intact package is diagnosed as a misread" "$output"
grep -qF "another USB port" <<<"$output" ||
fail "a misread points at the hardware" "$output"
if grep -qF "Re-download" <<<"$output"; then
fail "a misread does not send anyone to re-download" "$output"
fi
pass "an intact package on the medium is diagnosed as a misread, not a bad download"
# The usual case. pacstrap runs with --noconfirm, which answers yes to pacman's
# "delete it?" and unlinks the package through the bind mount, so by the time
# anything here looks there is nothing left to weigh. The verdict must name the
# medium without claiming a comparison it never made, and must offer both
# remedies: a bad write and a bad read leave identical evidence.
rm "$mirror/$PACKAGE"
set +e
output=$(diagnose "$log")
status=$?
set -e
(( status == 0 )) || fail "a deleted package is still diagnosed" "exit status $status"
[[ $(head -n 1 <<<"$output") == "The install medium is damaged or misread" ]] ||
fail "a deleted package is diagnosed without over-claiming" "$output"
grep -qF "read straight off this ISO" <<<"$output" ||
fail "a deleted package still names the medium" "$output"
grep -qF "sha256sum" <<<"$output" ||
fail "a deleted package offers the download remedy" "$output"
grep -qF "memory is at fault" <<<"$output" ||
fail "a deleted package offers the hardware remedy too" "$output"
if grep -qF "no longer matches the checksum" <<<"$output"; then
fail "a deleted package claims no comparison" "$output"
fi
pass "a deleted package names the medium without claiming which way it failed"
# A record with no %SHA256SUM% must not borrow the next record's, which would
# compare a real file against another package's checksum.
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
staging="$work/db"
rm -rf "$staging"
mkdir -p "$staging/npth-1.8-1" "$staging/zlib-1.3-1"
printf '%%FILENAME%%\n%s\n\n%%NAME%%\nnpth\n\n' "$PACKAGE" >"$staging/npth-1.8-1/desc"
{
printf '%%FILENAME%%\nzlib-1.3-1-x86_64.pkg.tar.zst\n\n'
printf '%%SHA256SUM%%\n%s\n\n' "$(sha256sum "$mirror/$PACKAGE" | cut -d " " -f 1)"
} >"$staging/zlib-1.3-1/desc"
tar -czf "$mirror/offline.db" -C "$staging" npth-1.8-1 zlib-1.3-1
set +e
output=$(diagnose "$log")
set -e
[[ $(head -n 1 <<<"$output") == "The install medium is damaged or misread" ]] ||
fail "a record without a checksum borrows nobody else's" "$output"
pass "a record without a checksum borrows nobody else's"
# A package version may legally hold a backslash. It must not reach a terminal
# as an escape sequence, and awk must look it up literally rather than as one.
escaped='demo-1\033[2J-1-x86_64.pkg.tar.zst'
write_log "$work/escaped.log" "$TARGET_CACHE/$escaped"
printf 'a package, but damaged\n' >"$mirror/$escaped"
write_mirror_db "$(printf '0%.0s' {1..64})" "$escaped"
set +e
output=$(diagnose "$work/escaped.log")
set -e
# The dashboard renders these lines through printf %b, which turns a surviving
# backslash into whatever escape it spells. None may reach it.
if grep -qF '\' <<<"$output"; then
fail "a backslash in a package name never reaches the renderer" "$(cat -v <<<"$output")"
fi
grep -qF "demo-1033" <<<"$output" ||
fail "the sanitized name still identifies the package" "$output"
# The database record is found despite the backslash, so the verdict is the
# mismatch rather than the one that means no comparison could be made.
[[ $(head -n 1 <<<"$output") == "The install medium is damaged" ]] ||
fail "a backslash in a package name is looked up literally" "$output"
pass "a backslash in a package name is looked up literally and never rendered as an escape"
rm "$mirror/$escaped"
# The live ISO has bsdtar through pacman's libarchive, but the tar fallback is
# the one that runs if that ever stops being true.
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
write_mirror_db "$(sha256sum "$mirror/$PACKAGE" | cut -d " " -f 1)"
no_bsdtar="$work/no-bsdtar"
mkdir -p "$no_bsdtar"
for tool in sed awk tar gzip sha256sum cat grep head tail cut printf; do
command -v "$tool" >/dev/null && ln -sf "$(command -v "$tool")" "$no_bsdtar/$tool"
done
set +e
output=$(PATH="$no_bsdtar" diagnose "$log")
set -e
[[ $(head -n 1 <<<"$output") == "The install medium was misread" ]] ||
fail "the tar fallback reads the database when bsdtar is absent" "$output"
pass "the tar fallback reads the database when bsdtar is absent"
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
write_mirror_db "$(printf '0%.0s' {1..64})"
# A corrupt package that was never read off this ISO is somebody else's
# problem. Guessing here sends people to buy a USB stick they did not need.
write_log "$work/elsewhere.log" "/var/tmp/build/$PACKAGE"
set +e
output=$(diagnose "$work/elsewhere.log")
status=$?
set -e
(( status == 1 )) || fail "corruption outside the target cache is not diagnosed" "exit $status: $output"
[[ -z $output ]] || fail "corruption outside the target cache prints nothing" "$output"
pass "corruption outside the target cache is not blamed on the medium"
# An ordinary failure has no media diagnosis, and the caller asks
# unconditionally, so silence has to be the answer.
printf 'error: something else went wrong entirely\n' >"$work/other.log"
set +e
output=$(diagnose "$work/other.log")
status=$?
set -e
(( status == 1 )) || fail "an unrelated failure is not diagnosed" "exit $status: $output"
[[ -z $output ]] || fail "an unrelated failure prints nothing" "$output"
pass "an unrelated failure produces no diagnosis"
set +e
output=$(diagnose "$work/does-not-exist.log")
status=$?
set -e
(( status == 1 )) || fail "a missing log is not diagnosed" "exit $status: $output"
pass "a missing log produces no diagnosis"
# End to end: the dashboard runs a failing installer and the diagnosis has to
# reach both the screen and the support log, above the tail it explains.
#
# The screen goes through a real pty rather than a file. The dashboard's exit
# handler reopens its TTY with a truncating redirect, which does nothing to a
# character device and empties a regular file, so a file would capture nothing.
stubs="$work/stubs"
mkdir -p "$stubs"
{
echo '#!/bin/bash'
echo "OMARCHY_OFFLINE_MIRROR='$mirror' OMARCHY_TARGET_PACKAGE_CACHE='$TARGET_CACHE' exec '$DIAGNOSE' \"\$@\""
} >"$stubs/omarchy-install-diagnose-media"
chmod +x "$stubs/omarchy-install-diagnose-media"
state_file="$work/state.json"
screen="$work/screen"
# A ten-line logo, the height of the real one. Without it the dashboard falls
# back to a single centred word and the screen can never be tall enough to
# overflow, which is exactly the case worth testing.
omarchy_share="$work/share"
mkdir -p "$omarchy_share"
# 81 columns wide, like the real logo: the dashboard takes its content width
# from the logo, and a narrow one would truncate every line under test.
{
printf 'LOGO TOP ROW%*s\n' 69 ''
for n in 2 3 4 5 6 7 8 9; do printf 'logo row %s%*s\n' "$n" 71 ''; done
printf 'logo bottom row%*s\n' 66 ''
} >"$omarchy_share/logo.txt"
# The ISO vendors the runtime's setup form; the dashboard only needs its text lookup.
cat >"$work/setup-form.sh" <<'FORM'
declare -gA OMARCHY_ZH=()
omarchy_text() { local text=$1; shift; printf -- "$text" "$@"; }
FORM
run_dashboard() {
local install_log="$1"
# A real failure carries the failed phase as well as the current one, and
# that second summary line is another row the screen has to find space for.
cat >"$state_file" <<'STATE'
{"current_phase": "Installing Arch + Omarchy",
"phases": [{"name": "Installing Arch + Omarchy", "status": "failed",
"error": "Pacstrap failed. See /var/log/archinstall.log"}]}
STATE
: >"$screen"
script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' SETUP_FORM='$work/setup-form.sh' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \
"$screen" >/dev/null 2>&1
}
visible_screen() {
# Carriage returns as well as escapes: the pty ends every line with one, and
# a row that looks blank still carries it.
sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g; s/\r//g' "$screen"
}
dashboard_log="$work/dashboard.log"
write_log "$dashboard_log"
# A real install log is longer than the tail can show, so the tail actually
# spends its whole budget. A short one hides an over-tall screen.
{
for n in $(seq 1 20); do printf '[dashboard] installing package %s\n' "$n"; done
cat "$dashboard_log"
} >"$dashboard_log.padded"
mv "$dashboard_log.padded" "$dashboard_log"
# State the case rather than inheriting whichever verdict the previous one left
# behind: this is the shape a real install fails in, with pacman having deleted
# the package it rejected.
rm -f "$mirror/$PACKAGE"
write_mirror_db "$(printf '0%.0s' {1..64})"
set +e
run_dashboard "$dashboard_log"
dashboard_status=$?
set -e
(( dashboard_status == 1 )) ||
fail "the dashboard still exits with the installer's status" "exit $dashboard_status"
visible_screen | grep -qF "The install medium is damaged" ||
fail "the failure screen shows the diagnosis" "$(visible_screen | tail -n 25)"
visible_screen | grep -qF "$PACKAGE" ||
fail "the failure screen names the package" "$(visible_screen | tail -n 25)"
grep -qF "[dashboard] The install medium is damaged" "$dashboard_log" ||
fail "the diagnosis is written to the support log" "$(tail -n 20 "$dashboard_log")"
# Once on the screen. The log tail rendered underneath reads the same file the
# diagnosis is recorded in, and the same finding printed twice reads as two.
headline_count=$(visible_screen | grep -cF "The install medium is damaged or misread" || true)
(( headline_count == 1 )) ||
fail "the diagnosis appears once on the screen" "found $headline_count times"
# The renderer used to drop blank lines, so the break has to be checked where
# it matters: on the screen, not only in the text the helper produced.
package_row=$(visible_screen | grep -nF "$PACKAGE" | grep -v "dashboard" | head -n 1 | cut -d: -f1)
[[ -n $package_row ]] ||
fail "the screen names the package on its own row" "$(visible_screen | tail -n 25)"
next_row=$(visible_screen | sed -n "$((package_row + 1))p")
[[ -z ${next_row// /} ]] ||
fail "the screen breaks after the package" "next row was: $next_row"
pass "the dashboard renders the diagnosis once, with the break, and logs it"
# The diagnosis costs rows the log tail's budget knew nothing about, so a
# screen carrying one scrolled its own logo off the top of the console. The
# tail gives those rows back now, which is visible as a shorter tail: count the
# filler lines it reaches, with a diagnosis and without one.
#
# Asserting the logo is still on screen would be the direct test, and it cannot
# be written here: this reads the output stream, which keeps every row ever
# emitted whether or not it stayed visible. Only a terminal emulator could tell
# the difference, so the mechanism is what gets checked.
tail_filler_rows() {
visible_screen | grep -c "installing package" || true
}
with_diagnosis=$(tail_filler_rows)
# The same log line for line, differing only in the one path that decides
# whether there is a diagnosis at all. Two differently shaped logs would show
# different amounts of filler for reasons that have nothing to do with the
# budget, and the comparison would prove nothing.
elsewhere_log="$work/elsewhere-padded.log"
write_log "$elsewhere_log" "/var/tmp/build/$PACKAGE"
{
for n in $(seq 1 20); do printf '[dashboard] installing package %s\n' "$n"; done
cat "$elsewhere_log"
} >"$elsewhere_log.padded"
mv "$elsewhere_log.padded" "$elsewhere_log"
set +e
run_dashboard "$elsewhere_log"
set -e
without_diagnosis=$(tail_filler_rows)
(( with_diagnosis < without_diagnosis )) ||
fail "the tail gives up rows to the diagnosis" \
"with: $with_diagnosis, without: $without_diagnosis"
pass "the log tail gives up rows to the diagnosis so the screen still fits"
# The screen is only as wide as the logo and the dashboard truncates to it with
# an ellipsis. Every sentence has to survive that, whatever the package is
# called, or the advice arrives with its second half cut off.
long_package="linux-firmware-nvidia-tegra-20250917.0e800e46-1-any.pkg.tar.zst"
long_log="$work/long.log"
write_log "$long_log" "$TARGET_CACHE/$long_package"
check_widths() {
local label="$1" line
while IFS= read -r line; do
(( ${#line} <= 81 )) ||
fail "no diagnosis line outruns the screen ($label)" "${#line} chars: $line"
done < <(diagnose "$long_log")
}
# The package line and the remedy that follows it are two different thoughts,
# so a blank line separates them in every verdict.
check_break() {
local label="$1" package="$2" out
out=$(diagnose "$long_log")
local -a lines
mapfile -t lines <<<"$out"
local i
for i in "${!lines[@]}"; do
[[ ${lines[i]} == "$package" ]] || continue
[[ -z ${lines[i + 1]-x} ]] ||
fail "a break follows the package ($label)" "$out"
[[ -n ${lines[i + 2]-} ]] ||
fail "the remedy follows the break ($label)" "$out"
return 0
done
fail "the package is on a line of its own ($label)" "$out"
}
# All three verdicts, since each writes its own sentences.
rm -f "$mirror/$long_package"
check_widths "deleted"
check_break "deleted" "$long_package"
printf 'these are not the bytes that were built\n' >"$mirror/$long_package"
write_mirror_db "$(printf '0%.0s' {1..64})" "$long_package"
check_widths "damaged"
check_break "damaged" "$long_package"
write_mirror_db "$(sha256sum "$mirror/$long_package" | cut -d " " -f 1)" "$long_package"
check_widths "misread"
check_break "misread" "$long_package"
rm -f "$mirror/$long_package"
pass "no diagnosis line outruns the screen, even with a long package name"
pass "a break separates the package from the remedy in every verdict"
# A failure with no media explanation must leave the screen as it was, rather
# than gaining a banner or an empty gap where the diagnosis would go.
printf 'error: something else went wrong entirely\n' >"$work/plain.log"
set +e
run_dashboard "$work/plain.log"
set -e
if visible_screen | grep -qF "install medium"; then
fail "an unrelated failure gets no media banner" "$(visible_screen | tail -n 25)"
fi
visible_screen | grep -qF "Omarchy installation stopped" ||
fail "an unrelated failure still renders the normal failure screen" "$(visible_screen | tail -n 25)"
pass "an unrelated failure renders the failure screen unchanged"
# The failure menu redraws the screen after uploading or viewing the log, and a
# redraw that drops the diagnosis loses it exactly when someone went looking.
# Those paths need gum and a person at the keyboard, so they are checked here.
missing_redraw=$(grep -n 'render_failure "' "$DASHBOARD" | grep -v 'failure_media_diagnosis' || true)
[[ -z $missing_redraw ]] ||
fail "every failure-screen redraw carries the diagnosis" "$missing_redraw"
pass "every failure-screen redraw carries the diagnosis"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
#
# Every installer text needs an OMARCHY_ZH entry, or a Chinese install shows it in English.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
SETUP_FORM="${OMARCHY_SOURCE:-$ROOT/../omarchy}/install/provisioning/setup-form.sh"
fail() {
printf 'not ok - %s\n' "$1"
[[ -z ${2:-} ]] || printf '%s\n' "$2" | sed 's/^/# /'
exit 1
}
if [[ ! -r $SETUP_FORM ]]; then
printf 'ok - # skip the setup form is not checked out next to this repo (set OMARCHY_SOURCE)\n'
exit 0
fi
source "$SETUP_FORM"
for file in configs/airootfs/root/configurator configs/airootfs/usr/local/bin/omarchy-install-dashboard; do
eval "$(sed -n '/^OMARCHY_ZH+=(/,/^)/p' "$ROOT/$file")"
done
mapfile -t texts < <(
grep -ho 'omarchy_text "[^"$]*"' "$ROOT/configs/airootfs/root/configurator" "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard" |
sed 's/^omarchy_text "//; s/"$//' | sort -u
)
((${#texts[@]} > 50)) || fail "the installer routes its text through omarchy_text" "found ${#texts[@]}"
for text in "${texts[@]}"; do
[[ -n ${OMARCHY_ZH[$text]:-} ]] || fail "every installer text has a Chinese entry" "missing: $text"
done
printf 'ok - every installer and dashboard text has a Chinese entry\n'
for action in "View full log" "Drop to shell" "Reboot" "Power off" "Upload log for support"; do
[[ -n ${OMARCHY_ZH[$action]:-} ]] || fail "labels passed through variables have Chinese entries" "missing: $action"
done
printf 'ok - labels passed through variables have Chinese entries\n'
mapfile -t diagnosis < <(grep -x ' *echo "[^"$]*"' "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-diagnose-media" | sed 's/^ *echo "//; s/"$//')
((${#diagnosis[@]} >= 10)) || fail "the media diagnosis sentences are found" "found ${#diagnosis[@]}"
for line in "${diagnosis[@]}"; do
[[ -n ${OMARCHY_ZH[$line]:-} ]] || fail "every media diagnosis sentence has a Chinese entry" "missing: $line"
done
printf 'ok - every media diagnosis sentence has a Chinese entry\n'
[[ $(OMARCHY_INSTALL_LANG=zh omarchy_text "Confirm overwriting %s" /dev/vda) == "确认覆盖 /dev/vda" ]] ||
fail "Chinese text keeps its printf arguments"
[[ $(omarchy_text "Confirm overwriting %s" /dev/vda) == "Confirm overwriting /dev/vda" ]] ||
fail "English stays the default"
printf 'ok - omarchy_text formats Chinese and English alike\n'
+175
View File
@@ -0,0 +1,175 @@
#!/bin/bash
#
# Unit tests for the checksum sidecar omarchy-iso-release writes and
# omarchy-iso-upload ships. Both scripts derive everything from their own
# location and their argument, so each case runs against a throwaway sandbox
# with the real script copied in and rclone/sign/upload stubbed out.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
local description="$1"
local detail="${2:-}"
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
printf 'not ok - %s\n' "$description" >&2
exit 1
}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# omarchy-iso-release resolves its release directory from the script's own
# path, so relocating the script is what points it at the sandbox.
sandbox="$work/repo"
mkdir -p "$sandbox/bin" "$sandbox/release" "$work/stubs"
cp "$ROOT/bin/omarchy-iso-release" "$sandbox/bin/"
printf 'not really an iso\n' >"$sandbox/release/omarchy-2099.01.01-x86_64-quattro.iso"
cat >"$work/stubs/omarchy-iso-sign" <<'STUB'
#!/bin/bash
printf 'signature\n' >"$1.sig"
STUB
cat >"$work/stubs/omarchy-iso-upload" <<'STUB'
#!/bin/bash
printf 'upload %s\n' "$*" >>"$TEST_LOG"
STUB
# Log one argument per field rather than "$*": the point of several cases below
# is that a path survives as a single argument, which "$*" cannot show.
cat >"$work/stubs/rclone" <<'STUB'
#!/bin/bash
printf 'rclone'
printf '|%s' "$@"
printf '\n'
[[ -n ${RCLONE_FAIL_ON:-} && $* == *"$RCLONE_FAIL_ON"* ]] && exit 1
exit 0
STUB
chmod +x "$work/stubs"/*
export TEST_LOG="$work/log"
: >"$TEST_LOG"
run_upload() {
PATH="$work/stubs:$PATH" "$ROOT/bin/omarchy-iso-upload" "$@" >"$work/rclone-log" 2>"$work/upload-err"
}
PATH="$work/stubs:$PATH" "$sandbox/bin/omarchy-iso-release" --no-make 9.9.9 >/dev/null
release_iso="$sandbox/release/omarchy-9.9.9.iso"
checksum_file="$release_iso.sha256"
[[ -f $checksum_file ]] ||
fail "release writes a checksum beside the ISO" "missing: $checksum_file"
pass "release writes a checksum beside the ISO"
# The name in the file has to be the ISO alone. A build path in there verifies
# only on the release machine, which is the one machine that never needs it.
expected="$(sha256sum "$release_iso" | cut -d " " -f 1) omarchy-9.9.9.iso"
actual="$(cat "$checksum_file")"
[[ $actual == "$expected" ]] ||
fail "the checksum names the ISO alone" "expected: $expected"$'\n'"actual: $actual"
pass "the checksum names the ISO alone"
(cd "$sandbox/release" && sha256sum -c --status omarchy-9.9.9.iso.sha256) ||
fail "sha256sum -c verifies the ISO from its own directory"
pass "sha256sum -c verifies the ISO from its own directory"
# The whole point of the sidecar is catching bytes that changed after release,
# so prove it fails on bytes that changed after release.
printf 'corrupted\n' >>"$release_iso"
if (cd "$sandbox/release" && sha256sum -c --status omarchy-9.9.9.iso.sha256 2>/dev/null); then
fail "sha256sum -c rejects a corrupted ISO"
fi
pass "sha256sum -c rejects a corrupted ISO"
# An unreadable ISO must stop the release rather than publish an empty digest
# beside a stale sidecar from the previous one.
stale="$sandbox/release/omarchy-8.8.8.iso.sha256"
printf 'deadbeef omarchy-8.8.8.iso\n' >"$stale"
rm "$sandbox/release/omarchy-2099.01.01-x86_64-quattro.iso"
printf 'not really an iso\n' >"$sandbox/release/omarchy-2099.01.02-x86_64-quattro.iso"
cp "$sandbox/release/omarchy-2099.01.02-x86_64-quattro.iso" "$sandbox/release/omarchy-8.8.8.iso"
chmod 000 "$sandbox/release/omarchy-8.8.8.iso"
set +e
PATH="$work/stubs:$PATH" "$sandbox/bin/omarchy-iso-release" --no-make 8.8.8 >/dev/null 2>&1
release_status=$?
set -e
chmod 644 "$sandbox/release/omarchy-8.8.8.iso"
(( release_status != 0 )) ||
fail "release stops when the ISO cannot be checksummed" "exit status was 0"
[[ "$(cat "$stale")" == "deadbeef omarchy-8.8.8.iso" ]] ||
fail "a failed checksum leaves no half-written sidecar" "$(cat "$stale")"
pass "release stops when the ISO cannot be checksummed"
# Uploading: both sidecars go up with the ISO, and a missing one is skipped
# rather than handed to rclone as a path that does not exist.
export HOME="$work/home"
mkdir -p "$HOME/.config/rclone"
: >"$HOME/.config/rclone/rclone.conf"
# A space in the path is the case that tells a quoted argument from an unquoted
# one, so every upload case runs from a directory that has one.
mkdir -p "$work/release builds"
upload_iso="$work/release builds/omarchy-9.9.9.iso"
printf 'not really an iso\n' >"$upload_iso"
printf 'signature\n' >"$upload_iso.sig"
printf 'checksum\n' >"$upload_iso.sha256"
run_upload "$upload_iso"
for suffix in "" ".sig" ".sha256"; do
grep -qxF "rclone|copy|$upload_iso$suffix|Omarchy:omarchy/|-P" "$work/rclone-log" ||
fail "upload ships the ISO, its signature and its checksum, path spaces intact" \
"$(cat "$work/rclone-log")"
done
pass "upload ships the ISO, its signature and its checksum, path spaces intact"
rm "$upload_iso.sig"
run_upload "$upload_iso"
if grep -qF ".sig" "$work/rclone-log"; then
fail "upload skips a sidecar that is not there" "$(cat "$work/rclone-log")"
fi
grep -qF "$upload_iso.sha256" "$work/rclone-log" ||
fail "upload still ships the checksum without a signature" "$(cat "$work/rclone-log")"
pass "upload skips a sidecar that is not there"
# A failed copy has to fail the upload even when a later copy succeeds, or a
# release reports success having published an ISO nobody can download.
printf 'signature\n' >"$upload_iso.sig"
set +e
RCLONE_FAIL_ON=".sig" run_upload "$upload_iso"
upload_status=$?
set -e
(( upload_status != 0 )) ||
fail "a failed sidecar copy fails the upload" "$(cat "$work/rclone-log")"
grep -qF "$upload_iso.sha256" "$work/rclone-log" ||
fail "a failed sidecar copy still attempts the rest" "$(cat "$work/rclone-log")"
pass "a failed sidecar copy fails the upload without skipping the rest"
set +e
RCLONE_FAIL_ON="omarchy-9.9.9.iso" run_upload "$upload_iso"
upload_status=$?
set -e
(( upload_status != 0 )) ||
fail "a failed ISO copy fails the upload" "$(cat "$work/rclone-log")"
pass "a failed ISO copy fails the upload"
# Publishing an ISO nobody can verify is the thing this change exists to stop,
# so the uploader says so out loud rather than exiting quietly.
rm "$upload_iso.sha256"
run_upload "$upload_iso"
grep -qF "nobody can verify" "$work/upload-err" ||
fail "upload warns when there is no checksum to publish" "$(cat "$work/upload-err")"
pass "upload warns when there is no checksum to publish"
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/install/provisioning"
cat > "$work/install/provisioning/setup-form.sh" <<'FORM'
OMARCHY_KEYBOARD_LAYOUTS=$'Chinese (Simplified, Pinyin)|us|pinyin
English (US)|us
Japanese|jp106|mozc'
FORM
export OMARCHY_PATH="$work"
eval "$(sed -n '/^select_keyboard() {$/,/^}$/p' "$ROOT/bin/omarchy-iso-test")"
press() { pressed+=("$1"); }
sleep() { :; }
pressed=()
select_keyboard "Japanese"
[[ ${pressed[*]} == "down" ]]
pressed=()
select_keyboard "Chinese (Simplified, Pinyin)"
[[ ${pressed[*]} == "up" ]]
pressed=()
select_keyboard "English (US)"
(( ${#pressed[@]} == 0 ))
if (set -e; select_keyboard "Missing") 2>"$work/error"; then
echo "Unknown keyboard unexpectedly succeeded" >&2
exit 1
fi
grep -q 'Keyboard choice is absent' "$work/error"
echo "ok - VM keyboard choices are relative to the default and reject absent rows"
+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"
+131
View File
@@ -0,0 +1,131 @@
#!/bin/bash
#
# create_partition() must report the number parted actually assigned, never a
# predicted one. The regression this pins: the configurator used to compute
# "highest existing partition + 1", while parted fills the lowest free GPT
# slot. Any disk whose numbering has a hole — what deleting a partition to
# free space leaves behind — then had every later command (cryptsetup, mkfs,
# mount) pointed at device nodes that did not exist, surfacing much later as
# "protected mode: /mnt is not a mountpoint".
#
# parted operates on image files directly, so this needs no root, no loop
# devices, and no real disk.
set -uo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
LIB="$ROOT/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh"
if ! command -v parted >/dev/null 2>&1; then
echo "SKIP: parted is not installed"
exit 0
fi
# shellcheck source=../configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh
source "$LIB"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
IMG="$WORK/disk.img"
MIB=$((1024 * 1024))
failures=0
check() {
local label="$1" expected="$2" actual="$3"
if [[ $expected == "$actual" ]]; then
printf ' ok %s\n' "$label"
else
printf ' FAIL %s: expected %s, got %s\n' "$label" "$expected" "$actual"
failures=$((failures + 1))
fi
}
# parted reports a partition's size inclusive of both endpoints, so it lands
# one sector above (end - start). Same slack create_partition allows.
check_size() {
local label="$1" want="$2" actual="${3:-0}" delta
delta=$((actual - want))
(( delta < 0 )) && delta=$((-delta))
if (( delta <= MIB )); then
printf ' ok %s\n' "$label"
else
printf ' FAIL %s: expected ~%s, got %s\n' "$label" "$want" "$actual"
failures=$((failures + 1))
fi
}
# parted's end position is inclusive, so partitions laid back to back overlap
# by a sector and are refused. Leave a MiB between them.
mkpart_mib() {
parted --script "$IMG" mkpart "$1" "$2" "$(($3 * MIB))B" "$(($4 * MIB))B"
}
# A disk with holes: four partitions, then delete the middle two. Slots 2 and
# 3 are free while the highest number in use is 4 — the shape of the reported
# failure, where the user deleted a partition to make room.
build_holey_disk() {
rm -f "$IMG"
truncate -s 4G "$IMG"
parted --script "$IMG" mklabel gpt
mkpart_mib one ext4 1 200
mkpart_mib two ext4 201 400
mkpart_mib three ext4 401 600
mkpart_mib four ext4 601 800
parted --script "$IMG" rm 2
parted --script "$IMG" rm 3
created_parts=()
}
echo "==> numbering on a disk with holes"
build_holey_disk
check "existing numbers" "1 4" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')"
create_partition "$IMG" "$((1000 * MIB))" "$((1200 * MIB))" fat32 OMARCHY_EFI
check "create_partition succeeded (esp)" "0" "$?"
esp_num="$created_partition_number"
check "esp took the lowest free slot" "2" "$esp_num"
# Starts a MiB past the ESP, the way run_partition_decide aligns ROOT_START_B
# up from EFI_END_B + 1.
create_partition "$IMG" "$((1201 * MIB))" "$((2000 * MIB))" btrfs OMARCHY_ROOT
check "create_partition succeeded (root)" "0" "$?"
root_num="$created_partition_number"
check "root took the next free slot" "3" "$root_num"
# The old prediction. If either of these ever matches again, the regression is
# back: the highest number in use was 4, so the guess would have been 5 and 6.
check "esp is not the predicted number" "not-5" "$([[ $esp_num == 5 ]] && echo 5 || echo not-5)"
check "root is not the predicted number" "not-6" "$([[ $root_num == 6 ]] && echo 6 || echo not-6)"
check "both creations tracked for rollback" "2 3" "${created_parts[*]}"
check_size "esp size" "$((200 * MIB))" "$(partition_size_bytes "$IMG" "$esp_num")"
check_size "root size" "$((799 * MIB))" "$(partition_size_bytes "$IMG" "$root_num")"
echo "==> rollback reclaims only what this run created"
rollback_created_parts "$IMG"
check "created partitions removed" "1 4" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')"
check "rollback list cleared" "0" "${#created_parts[@]}"
echo "==> numbering on a disk without holes"
rm -f "$IMG"
truncate -s 4G "$IMG"
parted --script "$IMG" mklabel gpt
mkpart_mib one ext4 1 200
mkpart_mib two ext4 201 400
created_parts=()
create_partition "$IMG" "$((1000 * MIB))" "$((1200 * MIB))" fat32 OMARCHY_EFI
check "appends when there is no hole" "3" "$created_partition_number"
echo "==> a refused creation stays out of the rollback list"
created_parts=()
create_partition "$IMG" "$((100 * MIB))" "$((300 * MIB))" ext4 OVERLAP
check "overlapping creation failed" "1" "$?"
check "nothing tracked" "0" "${#created_parts[@]}"
if (( failures > 0 )); then
printf '\n%d check(s) failed\n' "$failures"
exit 1
fi
printf '\nall checks passed\n'
+29
View File
@@ -0,0 +1,29 @@
"""Exercise the compatibility boundary without importing host archinstall."""
import ast
from pathlib import Path
import types
import unittest
ROOT = Path(__file__).resolve().parents[2]
source = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py"
tree = ast.parse(source.read_text())
namespace = {}
functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in {"_method_accepts", "sanity_check"}]
exec(compile(ast.Module(body=functions, type_ignores=[]), str(source), "exec"), namespace)
class SanityCheckTest(unittest.TestCase):
def test_legacy_archinstall_stays_offline(self):
calls = []
def check(offline=False, skip_ntp=False, skip_wkd=False):
calls.append((offline, skip_ntp, skip_wkd))
namespace["sanity_check"](types.SimpleNamespace(sanity_check=check))
self.assertEqual(calls, [(True, True, True)])
def test_archinstall_45_does_not_receive_removed_argument(self):
calls = []
class Installer:
def sanity_check(self, skip_ntp=False, skip_wkd=False):
calls.append((skip_ntp, skip_wkd))
namespace["sanity_check"](Installer())
self.assertEqual(calls, [(True, True)])
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/python
"""Undecodable bytes in command output, and where tolerating them stops.
The orchestrator reads firmware and filesystem metadata out of commands whose
output is not required to be UTF-8: noise in a boot entry must not stop an
install, while a mangled identifier must stop one. These tests run stand-in
commands emitting the bytes two HP firmwares actually put in a legacy BBS entry,
so they exercise the decode itself rather than a mocked result.
"""
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO.
sys.modules.setdefault(
"orchestrator.archinstall_adapter",
types.ModuleType("orchestrator.archinstall_adapter"),
)
from orchestrator import phases_impl # noqa: E402
from orchestrator.command import capture, capture_identifier # noqa: E402
# Boot0000 is the entry that aborted the install: raw bytes inside a BBS device
# path, in a legacy entry nothing in the installer reads. 0x80 and 0xe0 are the
# bytes two different HP firmwares emitted.
EFIBOOTMGR_OUTPUT = (
b"BootCurrent: 0003\n"
b"Timeout: 0 seconds\n"
b"BootOrder: 2001,0003,0000\n"
b"Boot0000* Notebook Hard Drive\tBBS(HD,\x80\x7f\xff\x04\xe0\x7f,)\n"
b"Boot0003* Omarchy\tHD(1,GPT,0d9c9d4f)/File(\\EFI\\limine\\BOOTX64.EFI)\n"
b"Boot2001* USB Drive (UEFI)\tRC\n"
)
class CommandCaptureTest(unittest.TestCase):
def setUp(self):
self.bin_dir = None
def fake_bin(self) -> Path:
"""A directory at the front of PATH, made once per test."""
if self.bin_dir is None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.bin_dir = Path(tmp.name)
path = os.environ["PATH"]
self.addCleanup(os.environ.__setitem__, "PATH", path)
os.environ["PATH"] = f"{self.bin_dir}:{path}"
return self.bin_dir
def fake_script(self, name: str, body: str) -> None:
script = self.fake_bin() / name
script.write_text(f"#!/bin/sh\n{body}\n")
script.chmod(0o755)
def fake_command(self, name: str, output: bytes) -> None:
"""A command printing exactly these bytes, undecodable ones included."""
payload = self.fake_bin() / f"{name}.out"
payload.write_bytes(output)
self.fake_script(name, f'cat "{payload}"')
def fake_findmnt(self, source: bytes) -> None:
"""A findmnt answering per column, so the phase gets as far as SOURCE."""
payload = self.fake_bin() / "findmnt.source"
payload.write_bytes(source)
self.fake_script(
"findmnt",
'case "$2" in\n'
" FSTYPE) echo btrfs ;;\n"
" OPTIONS) echo rw,subvol=/@ ;;\n"
f' SOURCE) cat "{payload}" ;;\n'
"esac",
)
def test_undecodable_bytes_are_replaced_rather_than_raised(self):
self.fake_command("omarchy-not-utf8", b"before\x80after\n")
self.assertEqual(capture(["omarchy-not-utf8"]).stdout, "before�after\n")
def test_efibootmgr_entries_parse_around_a_legacy_bbs_entry(self):
self.fake_command("efibootmgr", EFIBOOTMGR_OUTPUT)
state = phases_impl._read_efibootmgr()
self.assertEqual(state["order"], ["2001", "0003", "0000"])
self.assertEqual(phases_impl._find_label_entries(state["entries"], "Omarchy"), ["0003"])
self.assertTrue(state["entries"]["0000"].startswith("Notebook Hard Drive"))
def test_a_mangled_identifier_stops_the_install_rather_than_reaching_fstab(self):
# _validate_pre_mounted_filesystems reads the UUID back from this same
# command, so a replaced byte would agree with itself all the way to a
# machine that cannot find its root.
self.fake_command("blkid", b"1a2b3c4d-\x80-dead-beef\n")
with self.assertRaises(RuntimeError) as raised:
phases_impl._blkid_uuid("/dev/sda2")
self.assertIn("the UUID of /dev/sda2 is not valid text", str(raised.exception))
def test_a_mangled_btrfs_device_is_never_handed_to_mount(self):
# create_factory_snapshot mounts this device and deletes subvolumes
# under it; a replaced byte must stop the phase before that, not turn
# into an opaque mount failure.
self.fake_findmnt(source=b"/dev/sda\xe02[/@]")
ctx = types.SimpleNamespace(target=Path("/mnt"), state_dir=Path("/run/omarchy"))
with mock.patch.object(phases_impl, "info"), self.assertRaises(RuntimeError) as raised:
phases_impl.create_factory_snapshot(ctx)
self.assertIn("is not valid text", str(raised.exception))
def test_a_clean_identifier_is_returned_unchanged(self):
self.fake_command("blkid", b"1a2b3c4d-0e5f-dead-beef\n")
self.assertEqual(
capture_identifier(["blkid"], "the UUID"), "1a2b3c4d-0e5f-dead-beef"
)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Unit tests for the orchestrator's configure_ssh_access phase.
The phase touches the target only through the filesystem and arch-chroot, so
the tests run it against a temp directory with subprocess.run recorded and the
ufw side effect (writing user.rules) simulated by the fake.
"""
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO. configure_ssh_access
# never touches it, so stub the adapter out before the import.
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
from orchestrator import phases_impl # noqa: E402
UFW_RULE = "-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n"
class ConfigureSshAccessTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
self.ufw_writes_rule = True
def fake_run(self, cmd, **kwargs):
self.calls.append(cmd)
if cmd[2] == "ufw":
if self.ufw_writes_rule:
rules = self.target / "etc" / "ufw" / "user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text(UFW_RULE)
# ufw cannot reach netfilter inside a chroot and exits non-zero
# even when it recorded the rule; the phase must not trust this.
return CompletedProcess(cmd, 1)
return CompletedProcess(cmd, 0)
def ctx(self, authorized_keys=None):
authorized_keys_path = None
if authorized_keys is not None:
authorized_keys_path = self.target / "authorized_keys"
authorized_keys_path.write_text(authorized_keys)
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False)
def configure(self, **kwargs):
phases_impl.configure_ssh_access(self.ctx(**kwargs))
def authorized_keys(self):
return self.target / "home" / "jeff" / ".ssh" / "authorized_keys"
def chrooted(self, program):
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
def test_no_authorized_keys_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists())
def test_installs_keys_one_per_line(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n")
def test_keys_with_options_pass_through(self):
key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host'
self.configure(authorized_keys=f"{key}\n")
self.assertEqual(self.authorized_keys().read_text(), f"{key}\n")
def test_ssh_dir_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode)
self.assertEqual(mode, 0o700)
def test_authorized_keys_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600)
def test_chowns_ssh_dir_to_the_user(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("chown"), [
["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"],
])
def test_enables_sshd(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"],
])
def test_allows_ssh_through_ufw_despite_chroot_exit_status(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "ssh"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="")
def test_blank_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="\n \n")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="# no keys here\n")
class OptionalPathTest(unittest.TestCase):
def test_unset_and_missing_paths_are_none(self):
from orchestrator.context import _optional_path
self.assertIsNone(_optional_path(None))
self.assertIsNone(_optional_path(""))
self.assertIsNone(_optional_path("/does/not/exist/authorized_keys"))
def test_existing_path_is_returned(self):
from orchestrator.context import _optional_path
with tempfile.NamedTemporaryFile() as f:
self.assertEqual(_optional_path(f.name), Path(f.name))
if __name__ == "__main__":
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
"""Unit tests for the orchestrator's configure_tailscale phase.
Same harness as the SSH access tests: the phase touches the target only
through the filesystem and arch-chroot, so it runs against a temp directory
with subprocess.run recorded and the ufw side effect (writing user.rules)
simulated by the fake.
"""
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO. configure_tailscale
# never touches it, so stub the adapter out before the import.
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
from orchestrator import phases_impl # noqa: E402
UFW_RULE = "-A ufw-user-input -i tailscale0 -j ACCEPT\n"
class ConfigureTailscaleTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
self.ufw_writes_rule = True
def fake_run(self, cmd, **kwargs):
self.calls.append(cmd)
if cmd[2] == "ufw":
if self.ufw_writes_rule:
rules = self.target / "etc" / "ufw" / "user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text(UFW_RULE)
# ufw cannot reach netfilter inside a chroot and exits non-zero
# even when it recorded the rule; the phase must not trust this.
return CompletedProcess(cmd, 1)
return CompletedProcess(cmd, 0)
def ctx(self, authkey=None, tailscale_installed=True):
authkey_path = None
if authkey is not None:
authkey_path = self.target / "tailscale_authkey"
authkey_path.write_text(authkey)
if tailscale_installed:
binary = self.target / "usr" / "bin" / "tailscale"
binary.parent.mkdir(parents=True, exist_ok=True)
binary.touch()
return types.SimpleNamespace(target=self.target, tailscale_authkey_path=authkey_path)
def configure(self, **kwargs):
phases_impl.configure_tailscale(self.ctx(**kwargs))
def staged_key(self):
return self.target / "etc" / "tailscale" / "authkey"
def unit(self):
return self.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
def chrooted(self, program):
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
def test_no_authkey_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "etc" / "tailscale").exists())
def test_stages_the_key(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authkey="# reusable, tagged\n\n tskey-auth-kFAKEKEY \n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_key_dir_and_file_are_root_private(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(stat.S_IMODE((self.target / "etc" / "tailscale").stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(self.staged_key().stat().st_mode), 0o600)
def test_installs_the_join_unit(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text)
# The key must not outlive a successful join, the unit must not run
# again after one, and cleanup must be sequenced after the join
# succeeds -- inside the script, not in ExecStartPost.
self.assertIn(
"ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:/etc/tailscale/authkey;"
" do sleep 15; done; rm -f /etc/tailscale/authkey;"
" systemctl disable omarchy-tailscale-join.service'",
text,
)
def test_join_unit_does_not_hold_up_boot(self):
# A Type=oneshot wanted by multi-user.target holds the whole boot
# (SDDM included) hostage until the join finishes or times out --
# target units implicitly gain After= for their Wants=.
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("Type=simple", text)
self.assertNotIn("Type=oneshot", text)
self.assertNotIn("TimeoutStartSec", text)
def test_unit_avoids_systemd_variable_expansion(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertNotIn("$", self.unit().read_text())
def test_enables_tailscaled_and_the_join(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable",
"tailscaled.service", "omarchy-tailscale-join.service"],
])
def test_allows_tailnet_traffic_through_ufw_despite_chroot_exit_status(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "in", "on", "tailscale0"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for tailscale0"):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
def test_fails_when_tailscale_is_not_on_the_target(self):
with self.assertRaisesRegex(RuntimeError, "not installed on the target"):
self.configure(authkey="tskey-auth-kFAKEKEY\n", tailscale_installed=False)
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="# no key here\n")
def test_multiple_keys_fail_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "expected exactly one"):
self.configure(authkey="tskey-auth-kONE\ntskey-auth-kTWO\n")
if __name__ == "__main__":
unittest.main()
+197
View File
@@ -0,0 +1,197 @@
"""Fresh installs use the Omarchy kernel, except for T2 Macs."""
import json
import os
import re
import subprocess
import sys
import tempfile
import types
import unittest
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
sys.modules.setdefault(
"orchestrator.archinstall_adapter",
types.ModuleType("orchestrator.archinstall_adapter"),
)
from orchestrator import context, phases_impl # noqa: E402
class KernelSelectionTest(unittest.TestCase):
def test_hardware_detection(self):
# Run the configurator's actual probe without starting its disk wizard.
configurator = (ROOT / "configs/airootfs/root/configurator").read_text()
probe = re.search(r"^detect_kernel\(\) \{\n.*?^\}", configurator, re.M | re.S)
self.assertIsNotNone(probe)
cases = [
("00:02.0 VGA compatible controller [0300]: Intel [8086:b080]", "linux-omarchy"),
("00:01.0 Ethernet controller [0200]: Apple [106b:2001]", "linux-omarchy"),
("04:00.0 Mass storage controller [0180]: Apple [106b:1801]", "linux-t2"),
("04:00.0 Mass storage controller [0180]: Apple [106b:1802]", "linux-t2"),
("", "linux-omarchy"),
]
for devices, expected in cases:
with self.subTest(devices=devices):
result = subprocess.run(
["bash", "-c", 'lspci() { printf "%s\\n" "$TEST_PCI"; }\n'
+ probe.group() + "\ndetect_kernel"],
env={**os.environ, "TEST_PCI": devices},
check=True, capture_output=True, text=True,
)
self.assertEqual(result.stdout.strip(), expected)
def test_unattended_kernel_default_uses_pci_hardware(self):
for vendor, device, expected in [
("0x8086", "0xb080", "linux-omarchy"),
("0x106b", "0x2001", "linux-omarchy"),
("0x106b", "0x1801", "linux-t2"),
("0x106b", "0x1802", "linux-t2"),
]:
with self.subTest(device=device), tempfile.TemporaryDirectory() as tmp:
pci = Path(tmp)
slot = pci / "0000:00:00.0"
slot.mkdir()
(slot / "vendor").write_text(vendor + "\n")
(slot / "device").write_text(device + "\n")
self.assertEqual(context._default_kernel(pci), expected)
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(context._default_kernel(Path(tmp)), "linux-omarchy")
def test_context_passes_effective_kernels_to_archinstall(self):
for config, default, expected in [
({}, "linux-omarchy", ["linux-omarchy"]),
({"kernels": []}, "linux-omarchy", ["linux-omarchy"]),
({"kernels": None}, "linux-t2", ["linux-t2"]),
({}, "linux-t2", ["linux-t2"]),
({"kernels": ["linux-lts"]}, "linux-omarchy", ["linux-lts"]),
({"kernels": ["linux-omarchy", "linux-lts"]}, "linux-t2", ["linux-omarchy", "linux-lts"]),
({"omarchy_install": {"storage": {"kernel": "linux-t2"}}}, "linux-omarchy", ["linux-t2"]),
]:
with self.subTest(config=config, default=default), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_path = root / "config.json"
creds_path = root / "creds.json"
config_path.write_text(json.dumps(config))
creds_path.write_text(json.dumps({"users": [{"username": "test"}]}))
with mock.patch.dict(os.environ, {
"OMARCHY_INSTALL_CONFIG": str(config_path),
"OMARCHY_INSTALL_CREDS": str(creds_path),
"OMARCHY_INSTALL_STATE_DIR": str(root / "state"),
}, clear=True), mock.patch.object(context, "_default_kernel", return_value=default):
ctx = context.InstallContext.from_env()
self.assertEqual(ctx.user_configuration["kernels"], expected)
self.assertEqual(json.loads(ctx.arch_config_path.read_text())["kernels"], expected)
def test_headers_install_before_dkms_packages(self):
for kernels in (["linux-omarchy"], ["linux-t2"], ["linux-omarchy", "linux-lts"]):
for fail_headers in (False, True):
with self.subTest(kernels=kernels, fail_headers=fail_headers), ExitStack() as stack:
events = []
installer = mock.MagicMock()
installer.minimal_installation.side_effect = lambda **kwargs: events.append("base")
def install_packages(packages):
events.append(packages)
if fail_headers:
raise RuntimeError("header install failed")
installer.add_additional_packages.side_effect = install_packages
config = types.SimpleNamespace(
kernels=kernels, locale_config=None, mirror_config=None,
swap=None, auth_config=None, app_config=None, timezone=None,
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
)
ctx = types.SimpleNamespace(
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
target=Path("/unused"), tailscale_authkey_path=None, omarchy_install={},
region="global",
)
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
"_configure_limine_boot", "_write_pre_mounted_fstab"):
stack.enter_context(mock.patch.object(phases_impl, name))
unmask = stack.enter_context(mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"))
unmount = stack.enter_context(mock.patch.object(phases_impl, "_unmount_offline_package_cache"))
stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True))
stack.enter_context(mock.patch.object(phases_impl, "_install_early_packages", side_effect=lambda inst: events.append("early")))
stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"]))
stack.enter_context(mock.patch.object(phases_impl.arch, "is_pre_mount", return_value=True, create=True))
stack.enter_context(mock.patch.object(phases_impl.arch, "root_user", return_value=None, create=True))
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
opened.return_value.__enter__.return_value = installer
if fail_headers:
with self.assertRaisesRegex(RuntimeError, "header install failed"):
phases_impl.arch_install_system(ctx)
else:
phases_impl.arch_install_system(ctx)
expected = ["base", [f"{kernel}-headers" for kernel in kernels]]
if not fail_headers:
expected += ["early", ["omarchy"]]
self.assertEqual(events, expected)
unmask.assert_called_once_with(ctx)
unmount.assert_called_once_with(ctx)
def test_validation_rejects_missing_or_mismatched_headers(self):
for kernel in ("linux-omarchy", "linux-t2"):
with self.subTest(kernel=kernel), tempfile.TemporaryDirectory() as tmp:
target = Path(tmp)
ctx = types.SimpleNamespace(target=target)
with self.assertRaisesRegex(RuntimeError, "no installed kernel"):
phases_impl._validate_kernel_headers(ctx)
modules = target / "usr/lib/modules/7.2-test"
modules.mkdir(parents=True)
(modules / "pkgbase").write_text(kernel + "\n")
with self.assertRaisesRegex(RuntimeError, "no kernel headers"):
phases_impl._validate_kernel_headers(ctx)
header_release = modules / "build/include/config/kernel.release"
header_release.parent.mkdir(parents=True)
header_release.write_text("7.2-stock\n")
with self.assertRaisesRegex(RuntimeError, "headers do not match"):
phases_impl._validate_kernel_headers(ctx)
header_release.write_text("7.2-test\n")
phases_impl._validate_kernel_headers(ctx)
def test_boot_validation_uses_default_or_explicit_kernel(self):
for storage, configuration, expected in [
({}, {}, "linux-omarchy"),
({}, {"kernels": ["linux-t2"]}, "linux-t2"),
({"kernel": "linux-t2"}, {"kernels": ["linux-omarchy"]}, "linux-t2"),
({}, {"kernels": ["linux-lts"]}, "linux-lts"),
]:
with self.subTest(kernel=expected, storage=storage), tempfile.TemporaryDirectory() as tmp:
target = Path(tmp)
files = {
f"usr/lib/modules/7.2-test/pkgbase": expected + "\n",
"usr/lib/modules/7.2-test/build/include/config/kernel.release": "7.2-test\n",
"boot/limine.conf": "/Omarchy\n",
"etc/kernel/cmdline": "root=UUID=test\n",
"etc/default/limine": "CUSTOM_UKI_NAME=omarchy\n",
"boot/EFI/limine/limine_x64.efi": "bootloader",
f"boot/EFI/Linux/omarchy_{expected}.efi": "UKI",
}
for name, content in files.items():
path = target / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
ctx = types.SimpleNamespace(
target=target, omarchy_install={"storage": storage},
user_configuration=configuration, encrypt=False,
is_protected=False, defer_provisioning=False,
)
with mock.patch.object(phases_impl, "_assert_boot_hooks_restored"), \
mock.patch.object(phases_impl.arch, "has_uefi", return_value=True, create=True), \
mock.patch.object(phases_impl, "_read_efibootmgr", return_value={
"entries": {"0001": "Limine\tHD(1,GPT,test)"},
}):
phases_impl.validate_boot(ctx)
(target / f"boot/EFI/Linux/omarchy_{expected}.efi").unlink()
with self.assertRaisesRegex(RuntimeError, "missing or empty"):
phases_impl.validate_boot(ctx)
if __name__ == "__main__":
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/python
import re
import os
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
from orchestrator import keyboard as KEYBOARD # noqa: E402
# The layout list lives in the Omarchy runtime now, shared verbatim with the
# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from
# wherever this checkout can see a runtime, and skip the coverage test rather
# than fail when none is around (a bare CI checkout of just this repo).
SETUP_FORM_CANDIDATES = (
Path(os.environ.get("OMARCHY_PATH", "/nonexistent")) / "install/provisioning/setup-form.sh",
Path("/omarchy-source/install/provisioning/setup-form.sh"),
ROOT.parent / "omarchy/install/provisioning/setup-form.sh",
Path("/usr/share/omarchy/install/provisioning/setup-form.sh"),
)
def supported_keymaps():
for candidate in SETUP_FORM_CANDIDATES:
if not candidate.is_file():
continue
block = re.search(
r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL
)
assert block, f"no layout list in {candidate}"
return [line.split("|")[1] for line in block.group(1).splitlines()]
return None
SUPPORTED_KEYMAPS = supported_keymaps()
class KeyboardConfigurationTest(unittest.TestCase):
def target(self, directory: str) -> Path:
target = Path(directory)
(target / "etc").mkdir()
(target / "etc/vconsole.conf").write_text(
"KEYMAP=us\nFONT=default8x16\n"
)
return target
def test_writes_keymap_and_xkb_settings_and_preserves_font(self):
# XKBLAYOUT is load-bearing: omarchy's detect-keyboard-layout.sh copies
# it into Hyprland's kb_layout on the installed system.
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, "us"))
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn("KEYMAP=us", lines)
self.assertIn("XKBLAYOUT=us", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_all_configurator_keymaps_are_known_to_localectl(self):
if SUPPORTED_KEYMAPS is None:
self.skipTest("no Omarchy runtime checkout to read the layout list from")
for keymap in SUPPORTED_KEYMAPS:
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap))
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn(f"KEYMAP={keymap}", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertEqual(KEYBOARD.configure_keyboard(target, keymap), expected)
self.assertEqual(
(target / "etc/vconsole.conf").read_text(),
"KEYMAP=us\nFONT=default8x16\n",
)
def test_input_choice_survives_while_console_keymap_stays_real(self):
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))
self.assertIn(f"KEYMAP={keymap}", (target / "etc/vconsole.conf").read_text())
if keymap == "jp106":
self.assertIn("XKBLAYOUT=jp", (target / "etc/vconsole.conf").read_text())
self.assertEqual((target / "etc/omarchy/input-method").read_text(), f"INPUT_METHOD={method}\nXKB_LAYOUT={layout}\n")
def test_input_choice_with_no_console_map_defaults_to_us(self):
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, "", "pinyin"))
self.assertIn("INPUT_METHOD=pinyin", (target / "etc/omarchy/input-method").read_text())
def test_unknown_keyboard_does_not_silently_drop_an_input_choice(self):
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
with self.assertRaises(ValueError):
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"), ("none", "az")):
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
with self.assertRaises(ValueError):
KEYBOARD.configure_keyboard(target, "us", method, layout)
self.assertFalse((target / "etc/omarchy/input-method").exists())
if __name__ == "__main__":
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/python
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PRUNER = ROOT / "builder/prune-offline-mirror.sh"
class OfflineMirrorPruningTest(unittest.TestCase):
def run_pruner(self, mirror: Path, required: list[str]):
return subprocess.run(
["bash", str(PRUNER), str(mirror)],
input="".join(f"{filename}\n" for filename in required),
capture_output=True,
text=True,
)
def add_file(self, mirror: Path, filename: str):
(mirror / filename).write_bytes(b"package")
def test_keeps_only_exactly_selected_package_archives(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
keep = "keep-2.0-1-x86_64.pkg.tar.zst"
other = "other-1.0-1-any.pkg.tar.zst"
stale_version = "keep-1.0-1-x86_64.pkg.tar.zst"
removed_package = "removed-1.0-1-x86_64.pkg.tar.zst"
orphan_signature = "interrupted-1.0-1-x86_64.pkg.tar.zst.sig"
for filename in (keep, other, stale_version, removed_package):
self.add_file(mirror, filename)
for filename in (keep, stale_version, removed_package):
self.add_file(mirror, f"{filename}.sig")
self.add_file(mirror, orphan_signature)
self.add_file(mirror, "offline.db.tar.gz")
result = self.run_pruner(mirror, [keep, other])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
{path.name for path in mirror.iterdir()},
{keep, f"{keep}.sig", other, "offline.db.tar.gz"},
)
self.assertIn(stale_version, result.stdout)
self.assertIn(removed_package, result.stdout)
def test_missing_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(
mirror, ["missing-1.0-1-x86_64.pkg.tar.zst"]
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("selected packages are missing", result.stderr)
def test_empty_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(mirror, [])
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("empty package selection", result.stderr)
if __name__ == "__main__":
unittest.main()
+440
View File
@@ -0,0 +1,440 @@
"""Unit tests for deferred-provisioning install support in the orchestrator.
Covers the InstallContext deferred-provisioning plumbing (marker file, credential-less input,
throwaway LUKS passphrase injection) and the stage_provisioning_state /
create_factory_snapshot / configure_login phases against a temp target with
subprocess mocked out.
"""
import json
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso"))
sys.modules.setdefault(
"orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter")
)
from orchestrator import phases_impl # noqa: E402
from orchestrator.context import InstallContext # noqa: E402
class ContextDeferProvisioningTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = Path(self.tmp.name)
self.state_dir = self.dir / "state"
self.env = {
"OMARCHY_INSTALL_CONFIG": str(self.dir / "user_configuration.json"),
"OMARCHY_INSTALL_CREDS": str(self.dir / "user_credentials.json"),
"OMARCHY_INSTALL_STATE_DIR": str(self.state_dir),
}
def write_config(self, config):
(self.dir / "user_configuration.json").write_text(json.dumps(config))
def write_creds(self, creds):
(self.dir / "user_credentials.json").write_text(json.dumps(creds))
def from_env(self, **extra_env):
env = {**self.env, **extra_env}
with mock.patch.dict(os.environ, env, clear=False):
for key in ("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE",):
if key not in env:
os.environ.pop(key, None)
return InstallContext.from_env()
def base_config(self, defer_provisioning=None, disk_encryption=None):
config = {
"disk_config": {"config_type": "default_layout"},
"omarchy_install": {"mode": "full_disk", "target_mount": "/mnt"},
}
if defer_provisioning is not None:
config["omarchy_install"]["defer_provisioning"] = defer_provisioning
if disk_encryption is not None:
config["disk_config"]["disk_encryption"] = disk_encryption
return config
def test_defer_provisioning_flag_from_config(self):
self.write_config(self.base_config(defer_provisioning=True))
ctx = self.from_env()
self.assertTrue(ctx.defer_provisioning)
self.assertEqual(ctx.user_credentials, {"users": []})
self.assertEqual(ctx.username, "")
def test_marker_file_arms_deferred_provisioning_without_credentials(self):
self.write_config(self.base_config())
marker = self.dir / "defer_provisioning"
marker.touch()
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(marker))
self.assertTrue(ctx.defer_provisioning)
self.assertTrue(ctx.omarchy_install["defer_provisioning"])
def test_absent_marker_file_is_not_deferred_provisioning(self):
self.write_config(self.base_config())
self.write_creds({"users": [{"username": "jeff"}]})
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(self.dir / "missing-defer_provisioning"))
self.assertFalse(ctx.defer_provisioning)
self.assertEqual(ctx.username, "jeff")
def test_missing_credentials_without_defer_provisioning_raises(self):
self.write_config(self.base_config())
with self.assertRaisesRegex(RuntimeError, "credentials file missing"):
self.from_env()
def test_deferred_provisioning_generates_throwaway_luks_passphrase(self):
self.write_config(self.base_config(defer_provisioning=True, disk_encryption={
"encryption_type": "luks", "partitions": ["x"],
}))
ctx = self.from_env()
password = ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"]
self.assertTrue(password)
self.assertEqual(ctx.user_credentials["encryption_password"], password)
# archinstall reads both files from disk; the injected passphrase must
# be present in each.
arch_config = json.loads(ctx.arch_config_path.read_text())
self.assertEqual(
arch_config["disk_config"]["disk_encryption"]["encryption_password"], password
)
synthesized = json.loads(ctx.creds_path.read_text())
self.assertEqual(synthesized["encryption_password"], password)
def test_deferred_provisioning_reuses_rig_supplied_passphrase(self):
self.write_config(self.base_config(defer_provisioning=True, disk_encryption={
"encryption_type": "luks", "partitions": ["x"],
}))
self.write_creds({"users": [], "encryption_password": "rig-secret"})
ctx = self.from_env()
self.assertEqual(
ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"],
"rig-secret",
)
def test_deferred_provisioning_discards_account_material(self):
# A rig credentials file must not smuggle in users or a root password.
self.write_config(self.base_config(defer_provisioning=True))
self.write_creds({
"users": [{"username": "backdoor", "enc_password": "x", "sudo": True}],
"root_enc_password": "x",
"encryption_password": "rig-secret",
})
ctx = self.from_env()
self.assertEqual(ctx.user_credentials["users"], [])
self.assertNotIn("root_enc_password", ctx.user_credentials)
self.assertEqual(ctx.user_credentials["encryption_password"], "rig-secret")
synthesized = json.loads(ctx.creds_path.read_text())
self.assertEqual(synthesized["users"], [])
self.assertNotIn("root_enc_password", synthesized)
def test_defer_provisioning_strips_account_fields(self):
config = self.base_config(defer_provisioning=True)
config["!users"] = [{"username": "rig"}]
config["!root-password"] = "hunter2"
config["root_enc_password"] = "x"
config["auth_config"] = {"users": [{"username": "rig"}], "root_enc_password": "x"}
self.write_config(config)
ctx = self.from_env()
arch_config = json.loads(ctx.arch_config_path.read_text())
for key in ("!users", "!root-password", "root_enc_password", "users"):
self.assertNotIn(key, arch_config)
self.assertEqual(arch_config["auth_config"], {})
def test_deferred_provisioning_unencrypted_touches_nothing(self):
self.write_config(self.base_config(defer_provisioning=True))
ctx = self.from_env()
self.assertNotIn("encryption_password", ctx.user_credentials)
def make_ctx(target, **overrides):
defaults = dict(
target=target,
defer_provisioning=True,
encrypt=False,
username="",
omarchy_install={"mode": "full_disk", "defer_provisioning": True, "storage": {}},
user_configuration={"disk_config": {}},
user_credentials={"users": []},
state_dir=target / "state",
authorized_keys_path=None,
)
defaults.update(overrides)
return types.SimpleNamespace(**defaults)
class StageProvisioningStateTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name) / "mnt"
self.target.mkdir()
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
# A fake bundled Node tarball on the "live ISO".
self.packages = Path(self.tmp.name) / "opt-packages"
self.packages.mkdir()
(self.packages / "node-v24.0.0-linux-x64.tar.gz").write_bytes(b"node")
node_patch = mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", self.packages)
node_patch.start()
self.addCleanup(node_patch.stop)
def install_runtime_provisioning_support(self):
service = self.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
service.parent.mkdir(parents=True, exist_ok=True)
service.write_text("[Unit]\n")
setup_bin = self.target / "usr/bin/omarchy-provision-owner"
setup_bin.parent.mkdir(parents=True, exist_ok=True)
setup_bin.write_text("#!/bin/bash\n")
def provisioning_dir(self):
return self.target / "var/lib/omarchy/provisioning"
def test_normal_install_stages_only_the_node_tarball(self):
ctx = make_ctx(self.target, defer_provisioning=False)
phases_impl.stage_provisioning_state(ctx)
self.assertTrue((self.provisioning_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists())
self.assertFalse((self.provisioning_dir() / "pending").exists())
self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
def test_deferred_provisioning_against_runtime_without_support_fails_clearly(self):
ctx = make_ctx(self.target)
with self.assertRaisesRegex(RuntimeError, "does not ship"):
phases_impl.stage_provisioning_state(ctx)
def test_deferred_provisioning_without_node_tarball_fails(self):
for tarball in self.packages.glob("*.tar.gz"):
tarball.unlink()
self.install_runtime_provisioning_support()
ctx = make_ctx(self.target)
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
phases_impl.stage_provisioning_state(ctx)
def test_missing_node_tarball_fails_normal_installs_too(self):
# The stash is what makes a later factory reset work offline.
for tarball in self.packages.glob("*.tar.gz"):
tarball.unlink()
ctx = make_ctx(self.target, defer_provisioning=False)
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
phases_impl.stage_provisioning_state(ctx)
def test_deferred_provisioning_arms_first_boot_setup(self):
self.install_runtime_provisioning_support()
ctx = make_ctx(self.target)
phases_impl.stage_provisioning_state(ctx)
self.assertTrue((self.provisioning_dir() / "pending").exists())
self.assertTrue((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
link = self.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service"
self.assertTrue(link.is_symlink())
self.assertEqual(
os.readlink(link), "/etc/systemd/system/omarchy-provision-owner.service"
)
# Unencrypted: no LUKS staging.
self.assertFalse((self.provisioning_dir() / "luks-key").exists())
def test_deferred_provisioning_encrypted_stages_luks_auto_unlock(self):
self.install_runtime_provisioning_support()
ctx = make_ctx(
self.target,
user_configuration={"disk_config": {"disk_encryption": {
"encryption_type": "luks",
"encryption_password": "throwaway-secret",
}}},
)
phases_impl.stage_provisioning_state(ctx)
# Byte-for-byte the slot passphrase — no trailing newline.
self.assertEqual((self.provisioning_dir() / "luks-key").read_text(), "throwaway-secret")
keyfile = self.target / "etc/omarchy/provisioning.key"
self.assertEqual(keyfile.read_text(), "throwaway-secret")
self.assertEqual(keyfile.stat().st_mode & 0o777, 0o600)
cmdline = (self.target / "etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf").read_text()
self.assertIn("cryptkey=rootfs:/etc/omarchy/provisioning.key", cmdline)
files = (self.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf").read_text()
self.assertIn("FILES+=(/etc/omarchy/provisioning.key)", files)
def test_deferred_provisioning_pre_encrypted_without_passphrase_fails(self):
self.install_runtime_provisioning_support()
ctx = make_ctx(
self.target,
omarchy_install={"mode": "protected", "defer_provisioning": True, "storage": {"luks_uuid": "abc"}},
)
with self.assertRaisesRegex(RuntimeError, "passphrase"):
phases_impl.stage_provisioning_state(ctx)
class ConfigureLoginDeferProvisioningTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
run_patch = mock.patch.object(
phases_impl.subprocess, "run",
side_effect=lambda cmd, **kw: (self.calls.append(cmd), CompletedProcess(cmd, 0))[1],
)
run_patch.start()
self.addCleanup(run_patch.stop)
def test_deferred_provisioning_login_leaves_user_state_to_first_boot(self):
ctx = make_ctx(self.target, encrypt=True)
phases_impl.configure_login(ctx)
self.assertTrue((self.target / "etc/sddm.conf.d/99-omarchy-login.conf").exists())
self.assertFalse((self.target / "etc/sddm.conf.d/autologin.conf").exists())
self.assertFalse((self.target / "var/lib/sddm/state.conf").exists())
self.assertTrue(any("sddm.service" in cmd for cmd in self.calls))
def test_normal_encrypted_login_still_autologs_in(self):
ctx = make_ctx(self.target, defer_provisioning=False, encrypt=True, username="jeff")
phases_impl.configure_login(ctx)
autologin = (self.target / "etc/sddm.conf.d/autologin.conf").read_text()
self.assertIn("User=jeff", autologin)
state = (self.target / "var/lib/sddm/state.conf").read_text()
self.assertIn("User=jeff", state)
class ConfigureSshAccessDeferProvisioningTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name) / "mnt"
self.target.mkdir()
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
def fake_run(cmd, **kwargs):
self.calls.append(cmd)
if "ufw" in cmd:
rules = self.target / "etc/ufw/user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text("-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n")
return CompletedProcess(cmd, 0)
self.calls = []
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
def test_deferred_provisioning_stages_keys_for_first_boot(self):
keys = Path(self.tmp.name) / "authorized_keys"
keys.write_text("ssh-ed25519 AAAA rig@host\n")
ctx = make_ctx(self.target, authorized_keys_path=keys)
phases_impl.configure_ssh_access(ctx)
staged = self.target / "var/lib/omarchy/provisioning/authorized_keys"
self.assertEqual(staged.read_text(), "ssh-ed25519 AAAA rig@host\n")
self.assertEqual(staged.stat().st_mode & 0o777, 0o600)
# No user yet — nothing under /home, no chown.
self.assertFalse((self.target / "home").exists())
self.assertFalse(any("chown" in cmd for cmd in self.calls))
# The door is still opened for first boot.
self.assertTrue(any("sshd.service" in cmd for cmd in self.calls))
class CreateFactorySnapshotTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name) / "mnt"
self.target.mkdir()
self.state_dir = Path(self.tmp.name) / "state"
self.state_dir.mkdir()
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
self.findmnt = {
"FSTYPE": "btrfs",
"OPTIONS": "rw,noatime,compress=zstd:3,subvol=/@",
"SOURCE": "/dev/mapper/omarchy_root[/@]",
}
self.calls = []
def fake_run(cmd, **kwargs):
self.calls.append(cmd)
if cmd[0] == "findmnt":
return CompletedProcess(cmd, 0, stdout=self.findmnt[cmd[2]] + "\n", stderr="")
if cmd[0] == "mount":
# Simulate the top level containing @.
(Path(cmd[-1]) / "@").mkdir(parents=True, exist_ok=True)
return CompletedProcess(cmd, 0, stdout="", stderr="")
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
def ctx(self):
return make_ctx(self.target, defer_provisioning=False, state_dir=self.state_dir)
def test_snapshots_root_as_factory(self):
phases_impl.create_factory_snapshot(self.ctx())
top = str(self.state_dir / "factory-top")
self.assertIn(["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top], self.calls)
self.assertIn(
["btrfs", "subvolume", "snapshot", f"{top}/@", f"{top}/@factory"],
self.calls,
)
self.assertIn(
["btrfs", "property", "set", "-ts", f"{top}/@factory", "ro", "true"],
self.calls,
)
self.assertIn(["umount", top], self.calls)
def test_factory_snapshot_scrubs_provisioning_credentials(self):
# Simulate the snapshot carrying deployment credentials; the scrub
# must drop them before the snapshot goes read-only.
top = self.state_dir / "factory-top"
factory = top / "@factory"
secrets = [
factory / "var/lib/omarchy/provisioning/authorized_keys",
factory / "etc/tailscale/authkey",
factory / "etc/omarchy/provisioning.key",
]
keep = factory / "var/lib/omarchy/provisioning/groups"
for path in [*secrets, keep]:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("secret")
phases_impl.create_factory_snapshot(self.ctx())
for path in secrets:
self.assertFalse(path.exists(), path)
self.assertTrue(keep.exists())
def test_non_btrfs_target_skips(self):
self.findmnt["FSTYPE"] = "ext4"
phases_impl.create_factory_snapshot(self.ctx())
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
def test_non_subvol_root_skips(self):
self.findmnt["OPTIONS"] = "rw,noatime"
phases_impl.create_factory_snapshot(self.ctx())
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
if __name__ == "__main__":
unittest.main()
+236
View File
@@ -0,0 +1,236 @@
"""The target's region comes from its timezone, using the runtime's profiles."""
import json
import os
import subprocess
import sys
import tempfile
import types
import unittest
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
sys.modules.setdefault(
"orchestrator.archinstall_adapter",
types.ModuleType("orchestrator.archinstall_adapter"),
)
from orchestrator import context, phases_impl, region # noqa: E402
def write_profiles(root: Path) -> Path:
regions = root / "regions"
cn = regions / "cn"
(cn / "pacman").mkdir(parents=True)
(cn / "timezones").write_text("# China\nAsia/Shanghai\n\n Asia/Urumqi \n")
(cn / "packages").write_text("# keyring first\narchlinuxcn-keyring\nextra-tool\n")
(cn / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://example.invalid/$arch\n")
for channel in ("stable", "rc", "edge"):
(cn / f"pacman/mirrorlist-{channel}.prepend").write_text(f"Server = https://{channel}.example.invalid/$repo/os/$arch\n")
return regions
class RegionSelectionTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.regions = write_profiles(self.root)
patcher = mock.patch.object(region, "REGIONS_DIR", self.regions)
patcher.start()
self.addCleanup(patcher.stop)
def test_timezone_selects_region(self):
for timezone, expected in [
("Asia/Shanghai", "cn"),
("Asia/Urumqi", "cn"),
("Asia/Hong_Kong", "global"),
("America/New_York", "global"),
("UTC", "global"),
("# China", "global"),
("", "global"),
(None, "global"),
]:
with self.subTest(timezone=timezone):
self.assertEqual(region.region_for_timezone(timezone), expected)
def test_iso_without_profiles_is_global(self):
self.assertEqual(region.region_for_timezone("Asia/Shanghai", self.root / "missing"), "global")
self.assertEqual(region.resolve_region({}, "Asia/Shanghai", self.root / "missing"), "global")
def test_autoinstall_region_overrides_timezone(self):
self.assertEqual(region.resolve_region({"region": "cn"}, "UTC"), "cn")
self.assertEqual(region.resolve_region({"region": "global"}, "Asia/Shanghai"), "global")
self.assertEqual(region.resolve_region({}, "Asia/Shanghai"), "cn")
for bad in ("us", "CN", "..", "cn/../cn", True, 1, ["cn"]):
with self.subTest(region=bad), self.assertRaises(RuntimeError):
region.resolve_region({"region": bad}, "UTC")
def test_profile_packages_and_keyrings(self):
self.assertEqual(region.region_packages("cn"), ["archlinuxcn-keyring", "extra-tool"])
self.assertEqual(region.region_keyrings("cn"), ["archlinuxcn"])
self.assertEqual(region.region_packages("global"), [])
self.assertEqual(region.region_keyrings("global"), [])
def from_env(self, config: dict) -> context.InstallContext:
config_path = self.root / "config.json"
creds_path = self.root / "creds.json"
config_path.write_text(json.dumps(config))
creds_path.write_text(json.dumps({"users": [{"username": "test"}]}))
with mock.patch.dict(os.environ, {
"OMARCHY_INSTALL_CONFIG": str(config_path),
"OMARCHY_INSTALL_CREDS": str(creds_path),
"OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"),
}, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"):
return context.InstallContext.from_env()
def test_context_resolves_region_before_install(self):
self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn")
self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global")
# Deferred installs carry UTC until first boot, so they are global
# unless the autoinstall config names a region.
self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global")
self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn")
with self.assertRaises(RuntimeError):
self.from_env({"omarchy_install": {"region": "zz"}})
def test_marker_is_written_only_for_regional_targets(self):
target = self.root / "target"
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global"))
self.assertFalse((target / "etc/omarchy/region").exists())
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn"))
marker = target / "etc/omarchy/region"
self.assertEqual(marker.read_text(), "cn\n")
self.assertEqual(marker.stat().st_mode & 0o777, 0o644)
def test_region_packages_install_while_the_offline_mirror_is_mounted(self):
for selected, expected_packages in [("global", []), ("cn", [["archlinuxcn-keyring", "extra-tool"]])]:
with self.subTest(region=selected), ExitStack() as stack:
events = []
installer = mock.MagicMock()
installer.add_additional_packages.side_effect = lambda packages: events.append(packages)
config = types.SimpleNamespace(
kernels=["linux-omarchy"], locale_config=None, mirror_config=None,
swap=None, auth_config=None, app_config=None, timezone=None,
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
)
ctx = types.SimpleNamespace(
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
target=self.root / "target", tailscale_authkey_path=None, omarchy_install={},
region=selected,
)
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
"_unmask_mkinitcpio_pacman_hooks", "_configure_limine_boot",
"_write_pre_mounted_fstab", "_install_early_packages"):
stack.enter_context(mock.patch.object(phases_impl, name))
stack.enter_context(mock.patch.object(
phases_impl, "_unmount_offline_package_cache", side_effect=lambda _: events.append("unmount")))
stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True))
stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"]))
for name, value in (("is_pre_mount", True), ("root_user", None)):
stack.enter_context(mock.patch.object(phases_impl.arch, name, return_value=value, create=True))
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
opened.return_value.__enter__.return_value = installer
phases_impl.arch_install_system(ctx)
self.assertEqual(events, [["linux-omarchy-headers"], ["omarchy"], *expected_packages, "unmount"])
self.assertEqual((ctx.target / "etc/omarchy/region").exists(), selected != "global")
def test_finalizer_trusts_only_the_selected_region(self):
for selected, expected in [
("global", []),
("cn", [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]),
]:
with self.subTest(region=selected), \
mock.patch.object(phases_impl, "_run_target_setup_command") as run, \
mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \
mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"):
ctx = types.SimpleNamespace(region=selected, defer_provisioning=True, target=Path("/unused"))
phases_impl.run_system_finalizer(ctx)
commands = [call.args[1] for call in run.call_args_list]
self.assertEqual(commands[:-1], expected)
self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system")
class PrepareRegionsTest(unittest.TestCase):
def run_prepare(self, regions: Path, root: Path) -> tuple[Path, Path, str]:
payload = root / "payload"
payload.mkdir()
online = root / "online.conf"
online.write_text("[core]\nInclude = /etc/pacman.d/mirrorlist\n")
stubs = root / "bin"
stubs.mkdir()
log = root / "calls.log"
for name in ("pacman", "pacman-key"):
stub = stubs / name
stub.write_text(f'#!/bin/bash\necho "{name} $*" >> "{log}"\n')
stub.chmod(0o755)
subprocess.run(
["bash", str(ROOT / "builder/prepare-regions.sh"), str(regions), str(payload), str(online)],
env={**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}"},
check=True, capture_output=True, text=True,
)
return payload, online, log.read_text() if log.exists() else ""
def test_ships_every_profile_and_bootstraps_its_keyring(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
regions = write_profiles(root)
payload, online, calls = self.run_prepare(regions, root)
self.assertEqual((payload / "regions/cn/timezones").read_text(), (regions / "cn/timezones").read_text())
self.assertIn("[archlinuxcn]", online.read_text())
self.assertTrue(online.read_text().startswith("[core]\n"))
self.assertEqual(calls.splitlines(), [
f"pacman --config {online} --noconfirm -Sy --needed archlinuxcn-keyring",
"pacman-key --populate archlinuxcn",
])
def settings_package(self, root: Path, with_regions: bool) -> Path:
tree = root / "pkgroot"
(tree / "usr/share/omarchy/default/pacman").mkdir(parents=True)
if with_regions:
write_profiles(root / "src")
(root / "src/regions").rename(tree / "usr/share/omarchy/default/regions")
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
subprocess.run(["bsdtar", "--zstd", "-cf", str(package), "-C", str(tree), "usr"], check=True)
return package
def test_published_build_reads_profiles_from_the_settings_package(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=True), root)
self.assertEqual((payload / "regions/cn/packages").read_text(), "# keyring first\narchlinuxcn-keyring\nextra-tool\n")
self.assertIn("[archlinuxcn]", online.read_text())
self.assertIn("pacman-key --populate archlinuxcn", calls)
def test_settings_package_without_profiles_builds_a_global_iso(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=False), root)
self.assertFalse((payload / "regions").exists())
self.assertEqual(calls, "")
def test_unreadable_settings_package_fails_the_build(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
package.write_text("not an archive")
with self.assertRaises(subprocess.CalledProcessError):
self.run_prepare(package, root)
def test_runtime_without_profiles_builds_a_global_iso(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
payload, online, calls = self.run_prepare(root / "missing", root)
self.assertFalse((payload / "regions").exists())
self.assertNotIn("archlinuxcn", online.read_text())
self.assertEqual(calls, "")
if __name__ == "__main__":
unittest.main()
+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