Compare commits

...
Author SHA1 Message Date
Kros DaiandClaude Opus 5.5 81106d04e8 📝 docs(iso): scope regional builds to the architecture
- 📝 describe the cn build as repository defaults plus optional new-user staging, with locale and input to follow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3Jna1ogUUviYLJzhsG6Vr
2026-10-06 20:57:29 -04:00
Kros DaiandClaude Opus 5.5 043e819d01 ♻️ refactor(build): make regional user defaults optional
- ♻️ a repository-only profile is valid, so the build no longer requires skel while the installer still stages it when present
- ✅ use neutral fixtures instead of an input-method profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3Jna1ogUUviYLJzhsG6Vr
2026-10-06 20:57:29 -04:00
Kros DaiandClaude Opus 5.5 24d6573447 🐛 fix(build): name only the iso this build produced
- 🐛 concurrent global and regional builds shared `release/`, so picking the newest ISO could apply the wrong region suffix
- ✅ cover the per-build output directory and its cleanup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:57:29 -04:00
Kros DaiandClaude Opus 5.5 55d5f831d4 🐛 fix(build): require regional user defaults
- 🛡️ fail a regional build whose profile lacks `skel`, instead of shipping an ISO that silently skips the advertised input defaults

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:57:29 -04:00
Kros Dai 422d898033 📝 docs(iso): explain regional build requirements
- 📝 explain matching runtime sources and optional regional bootstrap
- ⚠️ disclose rolling-mirror compatibility and acceptance requirements

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 20:57:29 -04:00
Kros Dai 39d65593df ✅ test(region): cover regional iso contracts
- ✅ guard cache isolation, trust failures, and installation ordering
- 🛡️ keep global builds and existing user settings unchanged

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 20:57:29 -04:00
Kros Dai 27b33e7f1c ✨ feat(install): seed regional user defaults
- ✨ support offline input for immediate and deferred owners
- 🔒 initialize target trust before enabling regional repositories

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 20:57:29 -04:00
Kros Dai 53a41664fa ✨ feat(build): isolate regional iso variants
- ✨ separate regional payloads and caches without changing global defaults
- 🔒 verify regional package trust before building the offline repository

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 20:57:29 -04: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
17 changed files with 1342 additions and 97 deletions

No files matched your search

+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-latest
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
+117
View File
@@ -0,0 +1,117 @@
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:
runs-on: ubuntu-24.04
timeout-minutes: 75
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
# The hosted runner has far more disk than the build needs (145 GB,
# ~86 GB free); fail early and loudly if that ever changes.
- name: Check disk space
run: |
df -h /
avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
[ "$avail_gb" -ge 40 ] || { echo "::error::only ${avail_gb} GB free, the ISO build and install test need ~40 GB"; exit 1; }
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Build the ISO
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
# The harness runs in an Arch container: Arch's current QEMU and OVMF
# (QEMU 11) instead of Ubuntu 24.04's QEMU 8.2, at the paths the
# harness expects, the same as on an Omarchy workstation.
- name: Install unattended, then boot the installed system
run: |
sudo chmod 0666 /dev/kvm
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
-e OMARCHY_INTEGRATION_DISK_CACHE=none \
archlinux:latest bash -euo pipefail -c '
pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \
imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null
# 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
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
+55
View File
@@ -0,0 +1,55 @@
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-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
# 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" 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"
pacman -Syu --noconfirm --needed python parted >/dev/null
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
'
+14
View File
@@ -28,6 +28,20 @@ Despite the local folder name, the first argument is the Omarchy source checkout
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
### Regional builds
`--region cn` adds China bootstrap defaults independently of the channel. The default, `--region global`, preserves the existing package set, mirrors, cache paths, and artifact names. Supported country profiles use lowercase ISO 3166-1 alpha-2 codes; currently only `cn` is implemented.
For an unpublished regional runtime, use matching local source and package-recipe checkouts:
```bash
./bin/omarchy-iso-make --region cn --no-boot-offer --local-source /path/to/omarchy /path/to/omarchy-pkgs
```
China builds keep the channel mirror first, append the USTC Arch mirror, and add ArchLinuxCN with its verified keyring. These are a practical bridge until Omarchy runs its own mirror in China; USTC is rolling, not a matching stable/RC snapshot, so fallback version compatibility is not guaranteed. The region architecture also stages optional new-user defaults before user creation, including deferred setup, without touching existing homes. The China profile currently ships repository defaults only; language, timezone, and input-method defaults come as separate changes. See `docs/regions.md` in the companion runtime checkout.
Non-global cache and ISO names gain a `-cn` suffix. A runtime without the selected profile fails the build. These builds require an x86_64 Arch container environment; packaging checks on another architecture do not substitute for a fresh build, offline installation, and graphical acceptance run.
## Autoinstall
The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
+36 -8
View File
@@ -6,8 +6,17 @@ NO_CACHE=""
KEEP_PKG_CACHE=""
LOCAL_OMARCHY_PATH=""
LOCAL_PKGS_PATH=""
OMARCHY_REGION="${OMARCHY_REGION:-global}"
while [[ $# -gt 0 ]]; do
case $1 in
--region)
OMARCHY_REGION="${2:-}"
if [[ $OMARCHY_REGION != "global" && $OMARCHY_REGION != "cn" ]]; then
echo "Error: --region requires global or cn (ISO 3166-1 alpha-2)." >&2
exit 1
fi
shift 2
;;
--no-cache)
NO_CACHE=1
shift
@@ -57,12 +66,17 @@ while [[ $# -gt 0 ]]; do
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--region global|cn] [--local-source <omarchy-checkout> <pkgs-checkout>]"
exit 1
;;
esac
done
if [[ $OMARCHY_REGION != "global" && $OMARCHY_REGION != "cn" ]]; then
echo "Error: unsupported region '$OMARCHY_REGION'. Expected global or cn." >&2
exit 1
fi
if [[ -n $LOCAL_OMARCHY_PATH ]]; then
[[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; }
[[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; }
@@ -116,6 +130,10 @@ BUILD_SCRIPT="builder/build-iso.sh"
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
mkdir -p "$BUILD_RELEASE_PATH"
# Each build writes into its own directory, so concurrent builds (such as a
# global and a regional one) never pick up and rename each other's ISO.
BUILD_OUT=$(mktemp -d "$BUILD_RELEASE_PATH/.build-XXXXXX")
trap 'rm -rf "$BUILD_OUT"' EXIT
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
@@ -125,10 +143,11 @@ DOCKER_ARGS=(
--privileged
-e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF"
-e "OMARCHY_MIRROR=$OMARCHY_MIRROR"
-e "OMARCHY_REGION=$OMARCHY_REGION"
-e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}"
-e "HOST_UID=$(id -u)"
-e "HOST_GID=$(id -g)"
-v "$BUILD_RELEASE_PATH/:/out/"
-v "$BUILD_OUT/:/out/"
-v "$BUILD_ROOT/$BUILD_SCRIPT:/$BUILD_SCRIPT:ro"
-v "$BUILD_ROOT/archiso:/archiso:ro"
-v "$BUILD_ROOT/builder:/builder:ro"
@@ -146,10 +165,14 @@ if [[ -d /var/cache/pacman/pkg ]]; then
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
fi
# Mount the build cache directory where packages are downloaded. Channels use
# different package snapshots and must never share an offline mirror cache.
# Channels and regions must not share an offline mirror cache. Keep the
# existing global cache path and artifact suffix unchanged.
region_suffix=""
if [[ $OMARCHY_REGION != "global" ]]; then
region_suffix="-$OMARCHY_REGION"
fi
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}${region_suffix}/airootfs/var/cache/omarchy"
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
else
@@ -168,9 +191,14 @@ fi
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
mv -f "$latest_iso" "$iso_ref"
built_isos=("$BUILD_OUT"/*.iso)
if (( ${#built_isos[@]} != 1 )) || [[ ! -f ${built_isos[0]} ]]; then
echo "Error: expected exactly one ISO in $BUILD_OUT" >&2
exit 1
fi
built_iso_name=$(basename "${built_isos[0]}" .iso)
iso_ref="$BUILD_RELEASE_PATH/$built_iso_name-${OMARCHY_ISO_REF}${region_suffix}.iso"
mv -f "${built_isos[0]}" "$iso_ref"
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
echo
+3 -3
View File
@@ -614,7 +614,7 @@ drive_configurator() {
log "Driving the installer wizard"
# A greeter (animated logo + tagline) opens the installer; Return begins.
wait_for_screen "Opinionated" 300
wait_for_screen "Linux by DHH" 300
capture_console "success-installer-00-greeter"
press ret
@@ -731,7 +731,7 @@ drive_provision_owner() {
# layout — UK leaves every letter identical to US, so the typed password (sent
# as raw keycodes) still matches the literal one the harness pipes to sudo.
# A greeter (animated logo + tagline) opens first boot; Return begins.
wait_for_screen "Opinionated" 600
wait_for_screen "Linux by DHH" 600
capture_console "success-provision-00-greeter"
press ret
@@ -806,7 +806,7 @@ wait_for_install() {
# Deferred-provisioning installs skip the celebration and reboot on their own
# (no Reboot Now prompt); the first-boot greeter tagline appearing means the
# install finished and rebooted into setup.
if $PROVISION && grep -qi "Opinionated" <<<"$text"; then
if $PROVISION && grep -qi "Linux by DHH" <<<"$text"; then
log "Install finished and auto-rebooted into first-boot setup."
capture_console "success-installer-15-autoreboot"
return 0
+44 -2
View File
@@ -4,6 +4,11 @@ set -e
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
OMARCHY_REGION="${OMARCHY_REGION:-global}"
case "$OMARCHY_REGION" in
global|cn) ;;
*) echo "Unsupported region: $OMARCHY_REGION" >&2; exit 1 ;;
esac
# Edge, dev, and local-source ISOs install the dev packages explicitly. Those
# package recipes track the quattro branch. This avoids relying on pacman's
@@ -67,6 +72,7 @@ cp -r /configs/* "$build_cache_dir/"
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror"
echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref"
echo "$OMARCHY_REGION" > "$build_cache_dir/airootfs/root/omarchy_region"
cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <<EOF
OMARCHY_RUNTIME_PACKAGE=$OMARCHY_RUNTIME_PACKAGE
OMARCHY_SETTINGS_PACKAGE=$OMARCHY_SETTINGS_PACKAGE
@@ -80,6 +86,7 @@ if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
echo "built_at=$(date -Is)"
echo "ref=$OMARCHY_ISO_REF"
echo "mirror=$OMARCHY_MIRROR"
echo "region=$OMARCHY_REGION"
echo "runtime_package=$OMARCHY_RUNTIME_PACKAGE"
echo "settings_package=$OMARCHY_SETTINGS_PACKAGE"
echo "nvim_package=$OMARCHY_NVIM_PACKAGE"
@@ -139,6 +146,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# 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
runtime_root=/omarchy-source
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
@@ -155,6 +163,14 @@ else
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
runtime_root=/tmp/omarchy-pkglists/usr/share/omarchy
if [[ $OMARCHY_REGION != "global" ]]; then
if ! bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists \
"usr/share/omarchy/default/regions/$OMARCHY_REGION" usr/share/omarchy/bin/omarchy-apply-pacman; then
echo "ERROR: runtime lacks region support; publish a matching runtime or use --local-source." >&2
exit 1
fi
fi
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
@@ -168,6 +184,15 @@ 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"
# Work on writable copies, leaving the mounted runtime and ISO configs intact.
online_config=/tmp/omarchy-pacman-online.conf
cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config"
bash /builder/prepare-region.sh "$OMARCHY_REGION" "$runtime_root" "$build_cache_dir/airootfs" "$online_config"
base_pkg_lists=(
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
"$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
@@ -200,6 +225,23 @@ mapfile -t all_packages < <(
} | 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.
@@ -215,7 +257,7 @@ fi
mkdir -p /tmp/offlinedb
download_offline_packages() {
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \
pacman --config "$online_config" --noconfirm -Syw \
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
}
@@ -233,7 +275,7 @@ fi
# 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 "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \
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
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Add the selected runtime's regional payload to the ISO. Repository defaults
# stay in the runtime package, not in a second set of ISO-specific templates.
set -euo pipefail
region="$1"
runtime="$2"
iso_root="$3"
online_config="$4"
case "$region" in
global) exit 0 ;;
cn) ;;
*) echo "Unsupported region: $region" >&2; exit 1 ;;
esac
profile="$runtime/default/regions/$region"
if [[ ! -f $runtime/bin/omarchy-apply-pacman || ! -f $profile/packages ||
! -f $profile/pacman/pacman.conf.append || ! -f $profile/pacman/mirrorlist.append ]]; then
echo "Runtime does not support region '$region'; publish a matching runtime or use --local-source." >&2
exit 1
fi
# This is the target package list used both for download resolution and by the
# installer. Merely putting extra packages in the cache would not install them.
payload="$iso_root/usr/share/omarchy-iso"
cp -a "$profile" "$payload/region"
printf '\n' >> "$payload/omarchy-base.packages"
cat "$profile/packages" >> "$payload/omarchy-base.packages"
# Only the community repository is needed for regional package downloads.
# Keep the channel's build mirrors intact; target mirrors are restored later.
printf '\n' >> "$online_config"
cat "$profile/pacman/pacman.conf.append" >> "$online_config"
# The keyring package must be signed by a key already trusted by Arch. Do not
# bootstrap trust from an unchecked keyserver response or disable signatures.
# https://github.com/archlinuxcn/repo#usage
pacman --config "$online_config" --noconfirm -Sy --needed archlinuxcn-keyring
pacman-key --populate archlinuxcn
+1 -1
View File
@@ -415,7 +415,7 @@ detect_kernel() {
if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then
echo "linux-t2"
else
echo "linux"
echo "linux-omarchy"
fi
}
@@ -38,9 +38,7 @@ CLEAR="${CSI}2J${CSI}H"
CLEAR_LINE="${CSI}2K"
CLEAR_TO_END="${CSI}J"
GREEN="${CSI}32m"
WHITE="${CSI}37m"
RED="${CSI}31m"
DARK="${CSI}90m"
# The dashboard owns the final reboot prompt now, so keep the gum confirm
# styling that used to come from install/helpers/presentation.sh.
@@ -53,6 +51,22 @@ export GUM_CONFIRM_UNSELECTED_BACKGROUND="${GUM_CONFIRM_UNSELECTED_BACKGROUND:-0
child_pid=""
child_pgid=""
whimsy=(
"delivering delightful defaults"
"saving setup struggles"
"picking polished packages"
"curating cohesive colors"
"preparing pleasant pixels"
"hyping happy hyprland"
"integrating intuitive interfaces"
"bootstrapping beautiful boxes"
"crafting cozy commands"
"making machines marvelous"
"polishing pretty pixels"
"tuning tasteful terminals"
"weaving wonderful workflows"
)
tips=(
"Super + Space opens the Omarchy menu for apps, settings, and more"
"Super + K shows all the key bindings"
@@ -115,10 +129,58 @@ trap 'interrupt 143' TERM
term_size() { stty size 2>/dev/null <"$TTY_PATH" || printf '24 80\n'; }
term_cols() { local size; size="$(term_size)"; printf '%s\n' "${size#* }"; }
repeat() {
local char="$1" count="$2" out="" i
for ((i=0; i<count; i++)); do out+="$char"; done
printf '%s' "$out"
# Read the actual character dimensions instead of assuming an 8 x 16 font.
# Linux VTs expose the font through KDFONTOP; desktop terminals may expose
# pixels in their window size or answer the standard cell-size query.
term_cell_size() {
python3 - "$TTY_PATH" <<'PY'
import fcntl
import os
import re
import select
import struct
import sys
import termios
import time
import tty
with open(sys.argv[1], "r+b", buffering=0) as terminal:
fd = terminal.fileno()
rows, cols, width, height = struct.unpack(
"HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))
if rows and cols and width and height:
print(round(width / cols), round(height / rows))
sys.exit()
try:
font = bytearray(struct.pack("@5IP", 1, 0, 0, 0, 0, 0))
fcntl.ioctl(fd, 0x4B72, font) # KDFONTOP / KD_FONT_OP_GET, no glyph data
_, _, width, height, _ = struct.unpack_from("@5I", font)
if width and height:
print(width, height)
sys.exit()
except OSError:
pass
settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd, termios.TCSANOW)
os.write(fd, b"\x1b[16t")
response = b""
deadline = time.monotonic() + 0.15
while select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]:
response += os.read(fd, 64)
match = re.search(rb"\x1b\[6;(\d+);(\d+)t", response)
if match:
height, width = map(int, match.groups())
if width and height:
print(width, height)
sys.exit()
if time.monotonic() >= deadline:
break
finally:
termios.tcsetattr(fd, termios.TCSANOW, settings)
print(8, 16) # Conventional console font when dimensions aren't available.
PY
}
logo_width() {
@@ -151,22 +213,16 @@ LOGO_HEIGHT="$(logo_height)"
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
CONTENT_WIDTH="$LOGO_WIDTH"
# Rows the progress screen occupies: logo, blank, title, blank, bar, blank, tip.
PROGRESS_HEIGHT=$((LOGO_HEIGHT + 6))
TOP_ROW=1
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
LAST_SIZE=""
LAST_PM=10
# The finish screen mirrors the greeter's frame — logo, blank, one line, gap,
# one button — so the last thing the installer shows matches the first. It runs
# one row taller: `gum confirm` always paints prompt/blank/button and clears all
# three, so the button cannot sit closer than two rows under a title we drew.
# Wordmark, blank, title, and the three rows gum confirm uses.
FINISH_HEIGHT=$((LOGO_HEIGHT + 5))
FINISH_TOP_ROW=2
# ── Install progress ─────────────────────────────────────────────────────────
#
# Position = max(floor, work), both non-decreasing, in per-mille of the bar.
# Position = max(floor, work), both non-decreasing, in per-mille of the drawing.
#
# floor lo + (span-1) * t / (t+tau), on time since this process saw the
# phase. Asymptotic, so it approaches the band end without reaching it.
@@ -189,8 +245,8 @@ fi
# A wrong denominator is worse than none: it never triggers the fallback.
(( PKG_TOTAL < 300 || PKG_TOTAL > 5000 )) && PKG_TOTAL=0
PKG_BONUS_MAX_PM=100 # cap on how far the bar may run ahead of the count
PKG_TAIL_PM=40 # band held back so a stalled bar has somewhere to go
PKG_BONUS_MAX_PM=100 # cap on how far progress may run ahead of the count
PKG_TAIL_PM=40 # band held back so stalled progress has somewhere to go
PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the
# first package, so slow it until it cannot outrun data
@@ -263,13 +319,161 @@ render_logo() {
fi
}
progress_bar() {
local pm="$1" width="${2:-34}" filled empty
(( pm < 0 )) && pm=0
(( pm > 1000 )) && pm=1000
filled=$(( pm * width / 1000 ))
empty=$(( width - filled ))
printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET"
# The icon uses half-block glyphs, scaled to the terminal's actual cell aspect
# ratio so its square silhouette stays square. No graphics protocol is needed.
# Coordinates follow the 15 x 15 grid of Omarchy's icon.svg. Each segment
# paints the full stroke width, starting at the bottom middle of the outer ring.
ICON_WIDTH=30
ICON_HEIGHT=15
CELL_WIDTH=8
CELL_HEIGHT=16
ICON_LEFT=1
ICON_STEPS=0
ICON_DRAWN=0
DISPLAY_PM=0
ICON_FRAME=""
declare -a ICON_ORDER=() ICON_TRACE=() ICON_FILL=()
icon_segment() {
local scale="$1" x1="$2" y1="$3" x2="$4" y2="$5"
local x y from to
if (( y1 == y2 )); then
from=$(( (x1 * ICON_WIDTH + 14) / 15 ))
to=$(( ((x2 + 1) * ICON_WIDTH + 14) / 15 ))
if (( x2 < x1 )); then
from=$(( ((x1 + 1) * ICON_WIDTH + 14) / 15 - 1 ))
to=$(( (x2 * ICON_WIDTH + 14) / 15 - 1 ))
fi
for ((x=from; x!=to; x+=(x2 >= x1 ? 1 : -1))); do
for ((y=y1 * scale; y<(y1 + 1) * scale; y++)); do
icon_pixel "$((y * ICON_WIDTH + x))"
done
done
else
from=$(( y1 * scale ))
to=$(( (y2 + 1) * scale ))
if (( y2 < y1 )); then
from=$(( (y1 + 1) * scale - 1 ))
to=$(( y2 * scale - 1 ))
fi
for ((y=from; y!=to; y+=(y2 >= y1 ? 1 : -1))); do
for ((x=(x1 * ICON_WIDTH + 14) / 15; x<((x1 + 1) * ICON_WIDTH + 14) / 15; x++)); do
icon_pixel "$((y * ICON_WIDTH + x))"
done
done
fi
}
icon_pixel() {
local pixel="$1"
if [[ ! -v ICON_TRACE[pixel] ]]; then
ICON_ORDER+=("$pixel")
ICON_TRACE[pixel]=${#ICON_ORDER[@]}
fi
}
build_icon() {
local scale="$1" x y row
local -a mask=(
111111111111111
100000010000001
101111110001101
101000000000101
101000000000101
101000000000101
101000000000101
111000000000101
101000000000101
101000000000101
101000000000101
101000000000101
101111111111101
100000010000001
111111110111111
)
ICON_WIDTH=$(( (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) ))
ICON_HEIGHT=$(((15 * scale + 1) / 2))
ICON_ORDER=() ICON_TRACE=() ICON_FILL=()
ICON_DRAWN=0
# Start at the bottom middle, follow the outer left edge to the top cross,
# then descend into the inner shape. Fill its two bridges as we reach them.
icon_segment "$scale" 7 14 0 14
icon_segment "$scale" 0 14 0 0
icon_segment "$scale" 0 0 7 0
icon_segment "$scale" 7 0 7 2
icon_segment "$scale" 7 2 2 2
icon_segment "$scale" 2 2 2 7
icon_segment "$scale" 2 7 0 7
icon_segment "$scale" 2 7 2 12
icon_segment "$scale" 2 12 7 12
icon_segment "$scale" 7 12 7 14
icon_segment "$scale" 7 12 12 12
icon_segment "$scale" 12 12 12 2
icon_segment "$scale" 12 2 11 2
# Finish from the top cross: right along the outside, down, then left.
icon_segment "$scale" 7 0 14 0
icon_segment "$scale" 14 0 14 14
icon_segment "$scale" 14 14 9 14
ICON_STEPS=${#ICON_ORDER[@]}
for ((y=0; y<15 * scale; y++)); do
row=${mask[y / scale]}
for ((x=0; x<ICON_WIDTH; x++)); do
ICON_FILL[y * ICON_WIDTH + x]=${row:x * 15 / ICON_WIDTH:1}
done
done
}
# Append one cell with independently colored upper/lower pixels. This also
# handles a green tracing tip sharing a cell with the still-dark outline.
icon_cell() {
local pixel="$1" drawn="$2" solid="$3" upper=0 lower=0 step
if (( solid )); then
(( ICON_FILL[pixel] )) && upper=32
(( ${ICON_FILL[pixel + ICON_WIDTH]:-0} )) && lower=32
else
step=${ICON_TRACE[pixel]:-0}
(( step > 0 )) && upper=90
(( step > 0 && step <= drawn )) && upper=32
step=${ICON_TRACE[pixel + ICON_WIDTH]:-0}
(( step > 0 )) && lower=90
(( step > 0 && step <= drawn )) && lower=32
fi
if (( upper == 0 && lower == 0 )); then
ICON_FRAME+="${RESET} "
elif (( upper == lower )); then
ICON_FRAME+="${CSI}${upper};49m█"
elif (( lower == 0 )); then
ICON_FRAME+="${CSI}${upper};49m▀"
elif (( upper == 0 )); then
ICON_FRAME+="${CSI}${lower};49m▄"
else
ICON_FRAME+="${CSI}${upper};$((lower + 10))m▀"
fi
}
render_icon() {
local pm="$1" full="${2:-0}" drawn i pixel row col
drawn=$((pm * ICON_STEPS / 1000))
ICON_FRAME=""
if (( full || pm == 1000 )); then
for ((row=0; row<ICON_HEIGHT; row++)); do
ICON_FRAME+="${CSI}$((TOP_ROW + row));${ICON_LEFT}H"
for ((col=0; col<ICON_WIDTH; col++)); do
icon_cell "$((row * 2 * ICON_WIDTH + col))" "$drawn" "$((pm == 1000))"
done
done
else
# Between layout changes, repaint only newly traced pixels. This keeps the
# animation cheap even while pacstrap saturates the disk.
for ((i=ICON_DRAWN; i<drawn; i++)); do
pixel=${ICON_ORDER[i]}
row=$((pixel / ICON_WIDTH / 2)) col=$((pixel % ICON_WIDTH))
ICON_FRAME+="${CSI}$((TOP_ROW + row));$((ICON_LEFT + col))H"
icon_cell "$((row * 2 * ICON_WIDTH + col))" "$drawn" 0
done
fi
printf '%s%s' "$ICON_FRAME" "$RESET"
ICON_DRAWN=$drawn
}
# Packages libalpm has finished installing. local/ holds one directory per
@@ -290,7 +494,7 @@ count_packages() {
# Sets PROGRESS_PM. Never call in a command substitution: POS, the phase timer
# and the package counters must survive between frames. Print nothing — output
# here would land on the TTY between the cursor escape and the bar.
# here would land on the TTY between the cursor escape and the drawing.
install_progress() {
local phase finished index total target
local lo hi tau span t pos work gap n pkg_signal=0
@@ -377,7 +581,7 @@ install_progress() {
PKG_DB_DIR="$target/var/lib/pacman/local"
count_packages
# Baseline on first sight: a target that already holds packages would
# otherwise pin the bar at the band ceiling for the whole phase.
# otherwise pin progress at the band ceiling for the whole phase.
(( PKG_BASE < 0 )) && PKG_BASE=$PKG_COUNT
n=$(( PKG_COUNT - PKG_BASE ))
(( n < 0 )) && n=0
@@ -401,17 +605,20 @@ install_progress() {
return 0
}
# One tip per 8 seconds, in order. On elapsed time, not a frame counter: a frame
# is 0.5s of sleep plus a render, which stretches under pacstrap I/O — on the
# machine where the tips, not the bar, are carrying liveness.
# Keep the original loading messages on their own clock, so progress jumps
# cannot skip phrases and animation frame rate does not change their cadence.
current_whimsy() {
printf '%s' "${whimsy[$(( (NOW - DASH_T0) / 4 % ${#whimsy[@]} ))]}"
}
# One tip per 8 seconds, in order. Use elapsed time, since rendering can stretch
# under pacstrap I/O, when the tips may be the most visible sign of activity.
current_tip() {
printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}"
}
# Vertically center a block of the given height, echoing the row it starts on.
# Both screens here center the way the configurator's greeter centers its
# welcome, so the install opens, runs, and closes on the same composition
# rather than stacking each frame from row one.
# Center both the progress icon and the final wordmark on the console.
centered_top_row() {
local height="$1" rows top
rows="$(term_size)"
@@ -423,42 +630,60 @@ centered_top_row() {
}
measure_progress_layout() {
local rows cols scale=1
LAST_SIZE="$(term_size)"
TOP_ROW="$(centered_top_row "$PROGRESS_HEIGHT")"
DYNAMIC_ROW=$(( TOP_ROW + LOGO_HEIGHT + 1 ))
read -r rows cols <<<"$LAST_SIZE"
read -r CELL_WIDTH CELL_HEIGHT < <(term_cell_size 2>/dev/null || printf '8 16\n')
(( rows >= 34 && cols >= 49 )) && scale=2
(( rows >= 50 && cols >= 64 )) && scale=3
while (( scale > 1 && (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) > cols - 4 )); do
scale=$((scale - 1))
done
build_icon "$scale"
TOP_ROW="$(centered_top_row "$((ICON_HEIGHT + 6))")"
ICON_LEFT=$(( (cols - ICON_WIDTH) / 2 + 1 ))
(( ICON_LEFT < 1 )) && ICON_LEFT=1
PROGRESS_TEXT_WIDTH=$((cols - 2))
(( PROGRESS_TEXT_WIDTH > CONTENT_WIDTH )) && PROGRESS_TEXT_WIDTH=$CONTENT_WIDTH
return 0
}
render_static() {
measure_progress_layout
printf '%s%s%d;1H' "$CLEAR" "$CSI" "$TOP_ROW"
render_logo
blank_line
printf '%s' "$CLEAR"
render_icon "$DISPLAY_PM" 1
}
render_dynamic() {
local pm
# The console can still resize under us (the framebuffer settles late in VMs).
# Horizontal padding is recomputed every frame anyway, but the logo is drawn
# once, so redraw the static half at the new center rather than leave the
# centered layout stranded against a stale geometry.
[[ "$(term_size)" == "$LAST_SIZE" ]] || render_static
update_progress() {
set_now
install_progress
pm=$PROGRESS_PM
local pm=$PROGRESS_PM
[[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM
(( pm > 1000 )) && pm=1000
# Only the child's successful exit can complete the drawing.
(( pm > 999 )) && pm=999
# POS is monotone by construction; this is a guard, not the mechanism.
(( pm < LAST_PM )) && pm=$LAST_PM
LAST_PM=$pm
}
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
center "Installing Omarchy" "$CONTENT_WIDTH"
render_dynamic() {
local step message tip
# The framebuffer can settle late in VMs. Rebuild at the new size, preserving
# progress, instead of leaving the drawing at the old screen coordinates.
[[ "$(term_size)" == "$LAST_SIZE" ]] || render_static
# Ease phase jumps over several frames, with the percentage following the
# same position as the trace. The measured progress still never goes back.
step=$(( (LAST_PM - DISPLAY_PM + 3) / 4 ))
DISPLAY_PM=$((DISPLAY_PM + step))
render_icon "$DISPLAY_PM"
message="$(truncate_for_width "$(current_whimsy)" "$PROGRESS_TEXT_WIDTH")"
tip="$(truncate_for_width "Tip: $(current_tip)" "$PROGRESS_TEXT_WIDTH")"
printf '%s%d;1H' "$CSI" "$((TOP_ROW + ICON_HEIGHT + 1))"
center "${GREEN}$((DISPLAY_PM / 10))%${RESET}" "$PROGRESS_TEXT_WIDTH"
center "${GREEN}${message}${RESET}" "$PROGRESS_TEXT_WIDTH"
blank_line
line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) ""
progress_bar "$pm" 34
printf '\n'
blank_line
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH"
center "${DIM}${tip}${RESET}" "$PROGRESS_TEXT_WIDTH"
printf '%s' "$CLEAR_TO_END"
}
@@ -493,13 +718,11 @@ install_duration() {
}
render_finish() {
local duration effect_canvas_width
local duration effect_canvas_width title="Installed Omarchy"
duration="$(install_duration || true)"
duration="${duration:-Complete}"
# ttfx centers its text two columns right of plain centering, so hand it a
# canvas two columns narrower and the animated logo lands exactly on the
# static one left_padding already drew. Otherwise the logo jumps a column
# sideways when the effect starts and again when it ends.
[[ -n $duration ]] && title+=" in ${duration}"
# ttfx centers its text two columns right of plain centering. A canvas two
# columns narrower aligns the animation with the title and fallback logo.
effect_canvas_width="$(term_cols)"
(( effect_canvas_width > 2 )) && effect_canvas_width=$((effect_canvas_width - 2))
@@ -508,23 +731,21 @@ render_finish() {
FINISH_TOP_ROW="$(centered_top_row "$FINISH_HEIGHT")"
{
printf '%s%s%s%d;1H' "$SHOW_CURSOR" "$CLEAR" "$CSI" "$FINISH_TOP_ROW"
render_logo
blank_line
center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH"
# Leave the wordmark area blank: laseretch reveals it for the first time.
printf '%s%s%s%d;1H' "$HIDE_CURSOR" "$CLEAR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 1))"
center "$title" "$CONTENT_WIDTH"
blank_line
} >"$TTY_PATH"
if [[ -f $LOGO_PATH ]]; then
# Reuse mode restores a saved cursor before moving up over its input. Save
# this anchor so the effect redraws the same logo rows shown above.
# this anchor so the effect occupies the reserved wordmark rows.
printf '%s%d;1H\0337' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT))" >"$TTY_PATH"
# Laseretch runs ~4s at 260fps, so the 8s cap is a stuck-process guard rather
# than a trim. --xterm-colors keeps the whole effect in the 256-index palette
# the way the greeter does, since the console reduces indexed codes well and
# truecolor ones badly, and it settles on indexed 2 — the green gum already
# drew the logo in above, so the send-off ends on the frame it started from
# instead of recoloring it. The laser and sparks keep their own hot palette.
# truecolor ones badly. It settles on indexed 2, the Tokyo Night green used
# throughout the installer. The laser and sparks keep their own hot palette.
timeout 8s ttfx -i "$LOGO_PATH" \
--canvas-width "$effect_canvas_width" \
--anchor-text c \
@@ -533,9 +754,14 @@ render_finish() {
--xterm-colors \
laseretch \
--final-gradient-stops 2 \
>"$TTY_PATH" 2>/dev/null || dashboard_log "logo effect skipped (ttfx exited $?)"
printf '%s%d;1H' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH"
>"$TTY_PATH" 2>/dev/null || {
dashboard_log "logo effect skipped (ttfx exited $?)"
{ printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH"
}
else
{ printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH"
fi
printf '%s%s%d;1H' "$SHOW_CURSOR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH"
}
reboot_prompt() {
@@ -805,9 +1031,13 @@ PHASE_T0=$NOW
PKG_LAST_CHANGE=$NOW
launch_child
frame=0
while jobs -pr | grep -qx "$child_pid"; do
# Keep package counting/state reads at 2 Hz; animate the trace at 10 Hz.
(( frame % 5 == 0 )) && update_progress
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.5
frame=$((frame + 1))
sleep 0.1
done
set +e
@@ -820,8 +1050,14 @@ child_pgid=""
dashboard_log "installer child exited with status $status"
if (( status == 0 )); then
# finished_at lands microseconds before the child exits, so the 0.5s poll
# rarely renders a full bar before the finish screen replaces it.
# Finish the trace even when a short-lived child exits between state polls.
LAST_PM=1000
PHASE_NAME="Installation complete"
while (( DISPLAY_PM < 999 )); do
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.05
done
DISPLAY_PM=1000
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.4
fi
@@ -50,6 +50,13 @@ class InstallContext:
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]
# 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
@@ -175,6 +182,18 @@ def _inject_provisioning_encryption_password(arch_configuration: dict, user_cred
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"
@@ -39,6 +39,36 @@ from .keyboard import configure_keyboard
from .ui import error, info
ISO_REGION_FILE = Path("/root/omarchy_region")
ISO_REGION_PAYLOAD = Path("/usr/share/omarchy-iso/region")
def _iso_region() -> str:
region = ISO_REGION_FILE.read_text().strip() if ISO_REGION_FILE.exists() else "global"
if region not in {"global", "cn"}:
raise RuntimeError(f"Unsupported ISO region: {region}")
return region
def _stage_region_defaults(target: Path) -> None:
"""Seed the target before useradd, including deferred first-boot users.
Region is a bootstrap default, not a locale or a user-level policy.
Existing home directories are never rewritten by this step.
"""
region = _iso_region()
if region != "global":
if not ISO_REGION_PAYLOAD.is_dir():
raise RuntimeError(f"Missing ISO payload for region: {region}")
skel = ISO_REGION_PAYLOAD / "skel"
if skel.is_dir():
shutil.copytree(skel, target / "etc/skel", dirs_exist_ok=True)
marker = target / "etc/omarchy/region"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(region + "\n")
marker.chmod(0o644)
# Package targets are written by builder/build-iso.sh. Stable ISOs use the
# stable package names, while dev/local-source ISOs install the dev package
# names explicitly instead of relying on provides=omarchy resolution.
@@ -269,6 +299,11 @@ def arch_install_system(ctx: InstallContext) -> None:
pacman_config=config.pacman_config,
)
# Headers are optional kernel dependencies. Install them before any
# DKMS package so generic installs have them too, and module builds
# use the target kernel instead of the live ISO's kernel.
installer.add_additional_packages([f"{kernel}-headers" for kernel in config.kernels])
if not configure_keyboard(installer.target, kb_layout):
error(f"Invalid keyboard language specified: {kb_layout}")
@@ -282,6 +317,7 @@ def arch_install_system(ctx: InstallContext) -> None:
_install_early_packages(installer)
_configure_limine_boot(ctx, installer, config)
_stage_region_defaults(ctx.target)
info("› creating user (with /etc/skel populated)")
if config.auth_config and config.auth_config.users:
installer.create_users(config.auth_config.users)
@@ -790,7 +826,7 @@ def verify_protected_mounts(ctx: InstallContext) -> None:
esp_mp.mkdir(parents=True, exist_ok=True)
subprocess.run(["mount", esp_dev, str(esp_mp)], check=True)
info(f"› protected target verified: kernel={storage.get('kernel', 'linux')} esp={boot['esp_mount']}")
info(f"› protected target verified: kernel={storage.get('kernel', 'linux-omarchy')} esp={boot['esp_mount']}")
def _is_mountpoint(path: Path) -> bool:
@@ -1130,6 +1166,13 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
def run_system_finalizer(ctx: InstallContext) -> None:
# The runtime package transaction has installed the regional keyring from
# the ISO. Initialize the target's trust database before enabling the online
# community repository; the build container's GPG state is not inherited.
if _iso_region() == "cn":
_run_target_setup_command(ctx, ["pacman-key", "--init"])
_run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", "archlinuxcn"])
if ctx.defer_provisioning:
cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"]
else:
@@ -1644,6 +1687,7 @@ def _tailscale_authkey(path: Path) -> str:
def validate_boot(ctx: InstallContext) -> None:
_assert_boot_hooks_restored(ctx)
_validate_kernel_headers(ctx)
boot = _boot_intent(ctx)
storage = _storage_intent(ctx)
@@ -1666,7 +1710,7 @@ def validate_boot(ctx: InstallContext) -> None:
default_limine = ctx.target / "etc" / "default" / "limine"
config_text = _limine_combined_config_text(ctx, default_limine.read_text())
uki_prefix = _limine_setting(config_text, "CUSTOM_UKI_NAME", "omarchy") or "omarchy"
kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux"])[0]
kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux-omarchy"])[0]
if arch.has_uefi():
limine_binary = esp_mount / boot.get("esp_path", "/EFI/limine").lstrip("/") / boot.get("efi_binary", "limine_x64.efi")
@@ -1742,6 +1786,19 @@ def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
raise RuntimeError(f"{path} is missing — future kernel updates would ship no UKI")
def _validate_kernel_headers(ctx: InstallContext) -> None:
kernels = sorted((ctx.target / "usr/lib/modules").glob("*/pkgbase"))
if not kernels:
raise RuntimeError("no installed kernel found in target")
for pkgbase in kernels:
release = pkgbase.parent.name
header_release = pkgbase.parent / "build/include/config/kernel.release"
if not header_release.is_file():
raise RuntimeError(f"{pkgbase.read_text().strip()} ({release}) has no kernel headers")
if header_release.read_text().strip() != release:
raise RuntimeError(f"{pkgbase.read_text().strip()} headers do not match kernel {release}")
# Every kernel package leaves its pkgbase next to its modules, which is also
# the name limine-mkinitcpio-hook builds the UKI under.
def _installed_kernels(ctx: InstallContext) -> list[str]:
+33 -8
View File
@@ -14,6 +14,9 @@ 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
@@ -166,7 +169,7 @@ start_vm() {
-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 \
-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 \
@@ -282,8 +285,12 @@ 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}" waited=0
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
@@ -291,14 +298,20 @@ wait_for_ssh() {
return 1
fi
if ((waited >= timeout)); then
if ((SECONDS - started >= timeout)); then
capture_console "$failure_name"
echo "Timed out after ${timeout}s waiting for SSH" >&2
return 1
fi
sleep 5
((waited += 5))
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
}
@@ -403,7 +416,7 @@ EOF
"efi_binary": "limine_x64.efi",
"enable_fallback": true
},
"storage": { "kernel": "linux" }
"storage": { "kernel": "linux-omarchy" }
},
"disk_config": {
"config_type": "default_layout",
@@ -448,7 +461,7 @@ EOF
]
},
"hostname": "$GUEST_HOSTNAME",
"kernels": [ "linux" ],
"kernels": [ "linux-omarchy" ],
"network_config": { "type": "iso" },
"ntp": true,
"parallel_downloads": 8,
@@ -556,16 +569,28 @@ install_phase() {
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"
echo " ... installing (${waited}s)"
fi
((waited % 30 == 0)) && echo " ... installing (${waited}s)"
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
+2 -2
View File
@@ -165,7 +165,7 @@ drive_first_boot() {
# 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 "Opinionated" 120 2>/dev/null; then
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
@@ -175,7 +175,7 @@ drive_first_boot() {
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 "Opinionated" 600
wait_for_screen "Linux by DHH" 600
else
echo "Neither the greeter nor the Limine menu appeared" >&2
return 1
+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")
installer.create_users.side_effect = lambda users: events.append("user")
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=types.SimpleNamespace(users=["alice"]), 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,
)
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, "_stage_region_defaults", side_effect=lambda target: events.append("region")))
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))
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", "region", "user", ["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()
+252
View File
@@ -0,0 +1,252 @@
"""Region bootstrap contracts without Docker, pacman, or a running guest."""
import os
import shutil
import subprocess
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"))
sys.modules.setdefault("orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter"))
from orchestrator import phases_impl
class RegionTargetTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.target = self.root / "target"
self.marker = self.root / "omarchy_region"
self.payload = self.root / "payload"
for name, value in (("ISO_REGION_FILE", self.marker), ("ISO_REGION_PAYLOAD", self.payload)):
patch = mock.patch.object(phases_impl, name, value)
patch.start()
self.addCleanup(patch.stop)
def test_global_does_not_need_a_profile_or_change_user_defaults(self):
phases_impl._stage_region_defaults(self.target)
self.assertEqual((self.target / "etc/omarchy/region").read_text(), "global\n")
self.assertFalse((self.target / "etc/skel").exists())
def test_cn_seeds_skel_without_touching_existing_users_or_locale(self):
self.marker.write_text("cn\n")
profile = self.payload / "skel/.config/example/defaults"
profile.parent.mkdir(parents=True)
profile.write_text("regional defaults\n")
existing = self.target / "home/alice/.config/example/defaults"
existing.parent.mkdir(parents=True)
existing.write_text("personal settings\n")
skel = self.target / "etc/skel"
skel.mkdir(parents=True)
(skel / ".bashrc").write_text("original\n")
(self.target / "etc/locale.conf").write_text("LANG=en_US.UTF-8\n")
phases_impl._stage_region_defaults(self.target)
self.assertEqual((skel / ".config/example/defaults").read_text(), "regional defaults\n")
self.assertEqual((skel / ".bashrc").read_text(), "original\n")
self.assertEqual(existing.read_text(), "personal settings\n")
self.assertEqual((self.target / "etc/locale.conf").read_text(), "LANG=en_US.UTF-8\n")
self.assertEqual((self.target / "etc/omarchy/region").read_text(), "cn\n")
def test_bad_region_and_missing_payload_fail_before_target_changes(self):
for region in ("CN", "chn", "zz", "../cn", "", "cn"):
with self.subTest(region=region):
self.marker.write_text(region)
with self.assertRaises(RuntimeError):
phases_impl._stage_region_defaults(self.target)
self.assertFalse(self.target.exists())
def test_cn_keyring_is_initialized_before_finalization_for_both_install_modes(self):
self.marker.write_text("cn\n")
for deferred in (False, True):
ctx = types.SimpleNamespace(target=self.target, username="alice", defer_provisioning=deferred)
with 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"):
phases_impl.run_system_finalizer(ctx)
commands = [call.args[1] for call in run.call_args_list]
self.assertEqual(commands[:2], [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]])
expected = ["--defer-provisioning"] if deferred else ["--install-user", "alice"]
self.assertEqual(commands[2], ["/usr/bin/omarchy-apply-system", *expected, "--first-install"])
def test_keyring_failure_prevents_enabling_online_repositories(self):
self.marker.write_text("cn\n")
ctx = types.SimpleNamespace(target=self.target, username="alice", defer_provisioning=False)
with mock.patch.object(phases_impl, "_run_target_setup_command", side_effect=RuntimeError("keyring failed")) as run:
with self.assertRaisesRegex(RuntimeError, "keyring failed"):
phases_impl.run_system_finalizer(ctx)
self.assertEqual(run.call_count, 1)
class RegionBuildTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.runtime = self.root / "runtime"
self.profile = self.runtime / "default/regions/cn"
(self.profile / "pacman").mkdir(parents=True)
(self.runtime / "bin").mkdir()
(self.runtime / "bin/omarchy-apply-pacman").touch()
(self.profile / "packages").write_text("archlinuxcn-keyring\nexample-regional-package\n")
(self.profile / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n")
(self.profile / "pacman/mirrorlist.append").write_text("Server = https://mirrors.ustc.edu.cn/archlinux/$repo/os/$arch\n")
self.iso = self.root / "iso"
self.payload = self.iso / "usr/share/omarchy-iso"
self.payload.mkdir(parents=True)
self.packages = self.payload / "omarchy-base.packages"
self.packages.write_text("base\n")
self.config = self.root / "pacman.conf"
shutil.copyfile(ROOT / "configs/pacman-online-stable.conf", self.config)
self.original_config = self.config.read_bytes()
self.bin = self.root / "bin"
self.bin.mkdir()
self.log = self.root / "commands"
for command in ("pacman", "pacman-key"):
stub = self.bin / command
stub.write_text('#!/bin/bash\nprintf "%s %s\\n" "${0##*/}" "$*" >> "$TEST_LOG"\nif [[ ${0##*/} == pacman ]]; then exit "${TEST_PACMAN_STATUS:-0}"; fi\n')
stub.chmod(0o755)
def prepare(self, region, **env):
return subprocess.run(
["bash", str(ROOT / "builder/prepare-region.sh"), region, str(self.runtime), str(self.iso), str(self.config)],
env={**os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", "TEST_LOG": str(self.log), **env},
capture_output=True, text=True,
)
def test_cn_adds_real_install_targets_and_verifies_keyring_first(self):
result = self.prepare("cn")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.packages.read_text(), "base\n\narchlinuxcn-keyring\nexample-regional-package\n")
self.assertEqual(self.config.read_bytes(), self.original_config + b"\n[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n")
self.assertEqual((self.payload / "region/packages").read_text(), "archlinuxcn-keyring\nexample-regional-package\n")
self.assertEqual(self.log.read_text().splitlines(), [f"pacman --config {self.config} --noconfirm -Sy --needed archlinuxcn-keyring", "pacman-key --populate archlinuxcn"])
with mock.patch.object(phases_impl, "Path", return_value=self.packages), \
mock.patch.object(phases_impl, "_package_targets", return_value={"runtime": "omarchy-dev", "settings": "omarchy-settings-dev", "nvim": "omarchy-nvim"}):
self.assertEqual(phases_impl._runtime_package_list(None), ["omarchy-dev", "base", "archlinuxcn-keyring", "example-regional-package"])
def test_global_is_unchanged_without_region_support_in_runtime(self):
shutil.rmtree(self.runtime)
result = self.prepare("global")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.packages.read_text(), "base\n")
self.assertEqual(self.config.read_bytes(), self.original_config)
self.assertFalse(self.log.exists())
self.assertFalse((self.payload / "region").exists())
def test_missing_runtime_support_fails_before_package_operations(self):
(self.runtime / "bin/omarchy-apply-pacman").unlink()
result = self.prepare("cn")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--local-source", result.stderr)
self.assertFalse(self.log.exists())
self.assertEqual(self.config.read_bytes(), self.original_config)
def test_profile_without_user_defaults_is_supported(self):
result = self.prepare("cn")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.payload / "region/skel").exists())
def test_untrusted_keyring_aborts_without_importing_keys(self):
result = self.prepare("cn", TEST_PACMAN_STATUS="1")
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("pacman-key", self.log.read_text())
class RegionMakeTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
(self.root / "bin").mkdir()
shutil.copyfile(ROOT / "bin/omarchy-iso-make", self.root / "bin/omarchy-iso-make")
for directory in ("configs/airootfs/root", "configs/airootfs/usr/local/bin", "stubs"):
(self.root / directory).mkdir(parents=True)
(self.root / "configs/profiledef.sh").touch()
self.log = self.root / "docker-args"
stubs = {
"git": '#!/bin/bash\nexit 0\n',
"sudo": '#!/bin/bash\necho "unexpected sudo" >&2\nexit 99\n',
"docker": '''#!/bin/bash
if [[ $1 == version ]]; then exit 0; fi
printf '%s\\n' "$@" > "$TEST_LOG"
for arg in "$@"; do
if [[ $arg == *:/out/ ]]; then touch "${arg%:/out/}/omarchy-test-x86_64.iso"; fi
done
''',
}
for name, content in stubs.items():
stub = self.root / "stubs" / name
stub.write_text(content)
stub.chmod(0o755)
def make(self, *args):
env = {key: value for key, value in os.environ.items() if not key.startswith("OMARCHY_")}
return subprocess.run(
["bash", "bin/omarchy-iso-make", "--keep-pkg-cache", "--no-boot-offer", *args],
cwd=self.root, env={**env, "HOME": str(self.root), "PATH": f"{self.root / 'stubs'}:{env['PATH']}", "TEST_LOG": str(self.log), "TEST_ROOT": str(self.root)},
capture_output=True, text=True,
)
def test_region_is_independent_of_channel_and_names_cache_and_artifact(self):
for flags, channel, ref in (([], "stable", "quattro"), (["--rc"], "rc", "rc"), (["--edge"], "edge", "edge"), (["--dev"], "edge", "dev")):
with self.subTest(channel=ref):
release = self.root / "release"
if release.exists():
shutil.rmtree(release)
result = self.make(*flags, "--region", "cn")
self.assertEqual(result.returncode, 0, result.stderr)
args = self.log.read_text().splitlines()
self.assertIn("OMARCHY_REGION=cn", args)
self.assertIn(f"OMARCHY_MIRROR={channel}", args)
self.assertIn(f"OMARCHY_ISO_REF={ref}", args)
self.assertIn(f"{self.root}/.cache/omarchy/iso_{channel}-cn/airootfs/var/cache/omarchy:/var/cache/airootfs/var/cache/omarchy", args)
self.assertTrue((release / f"omarchy-test-x86_64-{ref}-cn.iso").exists())
def test_default_preserves_existing_global_cache_and_name(self):
result = self.make()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("OMARCHY_REGION=global", self.log.read_text().splitlines())
self.assertIn("/iso_stable/", self.log.read_text())
self.assertTrue((self.root / "release/omarchy-test-x86_64-quattro.iso").exists())
def test_build_renames_only_its_own_iso(self):
release = self.root / "release"
release.mkdir()
other = release / "omarchy-other-x86_64.iso"
other.touch()
result = self.make("--region", "cn")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(other.exists())
self.assertTrue((release / "omarchy-test-x86_64-quattro-cn.iso").exists())
self.assertEqual(list(release.glob(".build-*")), [])
def test_local_source_keeps_region_and_mounts_both_checkouts(self):
for name in ("runtime", "packages"):
(self.root / name).mkdir()
result = self.make("--region", "cn", "--local-source", str(self.root / "runtime"), str(self.root / "packages"))
self.assertEqual(result.returncode, 0, result.stderr)
args = self.log.read_text().splitlines()
self.assertIn("OMARCHY_REGION=cn", args)
self.assertIn("OMARCHY_ISO_REF=local", args)
self.assertIn(f"{self.root}/runtime:/omarchy-source:ro", args)
self.assertIn(f"{self.root}/packages:/omarchy-pkgs:ro", args)
self.assertTrue((self.root / "release/omarchy-test-x86_64-local-cn.iso").exists())
def test_invalid_regions_fail_without_starting_build(self):
for flags in (("--region",), ("--region", "chn"), ("--region", "CN"), ("--region", "zz"), ("--region", "../cn")):
with self.subTest(flags=flags):
result = self.make(*flags)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.log.exists())
self.assertFalse((self.root / "release").exists())
if __name__ == "__main__":
unittest.main()