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.
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.
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.
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".
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.
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.
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.
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.
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
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.
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.
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.
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.
--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.
--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".
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.
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.
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).
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.
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.
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.
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.
* 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>
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>
* 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>
* 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>
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
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>
* 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>
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>
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>
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>
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>
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>
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>
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>