Compare commits
63
Commits
pr-206
..
24f188a4be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f188a4be
|
||
|
|
e22a13dfeb
|
||
|
|
53b80d406c | ||
|
|
4f4b3e3da9 | ||
|
|
bddcb7bfc8 | ||
|
|
dcfd3e9917 | ||
|
|
3e21939b9e | ||
|
|
40c5fb8e98 | ||
|
|
d4ad44c9f6 | ||
|
|
df10011064 | ||
|
|
d6a32f0dcf | ||
|
|
b5936a6cbe | ||
|
|
7f0ae48661 | ||
|
|
522b92a8d3 | ||
|
|
21a44ee4b4 | ||
|
|
ed0dfc5052 | ||
|
|
22ef6e12c5 | ||
|
|
f9caa25766 | ||
|
|
cabc8d1ea9 | ||
|
|
b01a396e62 | ||
|
|
bb1ad28887 | ||
|
|
20aeec8364 | ||
|
|
415831b1eb | ||
|
|
20fd1dd00b | ||
|
|
f9d184f2bc | ||
|
|
2b8d012688 | ||
|
|
97e06f025f | ||
|
|
04ff1f5ea1 | ||
|
|
82cec53728 | ||
|
|
469052b93e | ||
|
|
2a39b849c1 | ||
|
|
7cce6045fa | ||
|
|
18a968f467 | ||
|
|
f6377d2f87 | ||
|
|
61b309cf9a | ||
|
|
f99443dc4c | ||
|
|
65589d2138 | ||
|
|
0abcbb0b97 | ||
|
|
ff30b7f558 | ||
|
|
d054c00d24 | ||
|
|
4f7d4a9db3 | ||
|
|
f0216cf0c0 | ||
|
|
32f917fdff | ||
|
|
98b20a6829 | ||
|
|
c97836e4f2 | ||
|
|
41fa32d671 | ||
|
|
9d4d03ff1c | ||
|
|
d2677b3046 | ||
|
|
ebfbf43aa0 | ||
|
|
0bc00ff572 | ||
|
|
c6b1d2dadc | ||
|
|
d90119f5d8 | ||
|
|
c0a0e787f3 | ||
|
|
466a7a8221 | ||
|
|
4e7b18eb7d | ||
|
|
31cca48c14 | ||
|
|
abc1a7716d | ||
|
|
27b7e76c5a | ||
|
|
9dcfdb035a | ||
|
|
e121561851 | ||
|
|
1c912658aa | ||
|
|
8b17699997 | ||
|
|
595d38cb04 |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# Every action is pinned to a commit, so nothing updates by itself. Once a
|
||||
# week Dependabot proposes the newer releases in one pull request, moving each
|
||||
# commit and the version in its comment together.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
||||
@@ -21,35 +21,52 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
install:
|
||||
runs-on: ubuntu-24.04
|
||||
# Our own branches run on Namespace. A pull request from a fork stays on
|
||||
# GitHub's runner: it is somebody else's code, and it has no business on
|
||||
# our account's machines. No cache volume either way: what one pull
|
||||
# request leaves in a cache the next run would trust.
|
||||
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-26.04' || 'namespace-profile-linux-amd64-16x32' }}
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The hosted runner has far more disk than the build needs (145 GB,
|
||||
# ~86 GB free); fail early and loudly if that ever changes.
|
||||
# Fail early and loudly if the runner ever has less disk than this needs.
|
||||
- name: Check disk space
|
||||
run: |
|
||||
df -h /
|
||||
avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
|
||||
[ "$avail_gb" -ge 40 ] || { echo "::error::only ${avail_gb} GB free, the ISO build and install test need ~40 GB"; exit 1; }
|
||||
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Build the ISO
|
||||
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
|
||||
|
||||
# The harness runs in an Arch container: Arch's current QEMU and OVMF
|
||||
# (QEMU 11) instead of Ubuntu 24.04's QEMU 8.2, at the paths the
|
||||
# (QEMU 11) instead of the runner's older one, at the paths the
|
||||
# harness expects, the same as on an Omarchy workstation.
|
||||
- name: Install unattended, then boot the installed system
|
||||
run: |
|
||||
sudo chmod 0666 /dev/kvm
|
||||
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
|
||||
-e OMARCHY_INTEGRATION_DISK_CACHE=none \
|
||||
archlinux:latest bash -euo pipefail -c '
|
||||
pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \
|
||||
imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null
|
||||
ghcr.io/archlinux/archlinux:latest bash -euo pipefail -c '
|
||||
# pacman 7 fails every sync on a kernel without Landlock, which
|
||||
# these runners have; builder/build-iso.sh does the same.
|
||||
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||
printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman
|
||||
chmod 0755 /usr/local/bin/pacman
|
||||
fi
|
||||
# The tools come from our own Arch mirror first, the public ones
|
||||
# after it. A mirror that drops one file must not fail the test:
|
||||
# try again.
|
||||
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||
for attempt in 1 2 3; do
|
||||
pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \
|
||||
imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null && break
|
||||
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||
echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15
|
||||
done
|
||||
# test/integration installs its tools through Omarchy'"'"'s helper; they are installed above.
|
||||
printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add
|
||||
chmod 0755 /usr/local/bin/omarchy-pkg-add
|
||||
@@ -101,7 +118,7 @@ jobs:
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: install-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
|
||||
@@ -1,35 +1,322 @@
|
||||
name: Nightly ISO Build
|
||||
|
||||
# Every night, for two channels: installs an ISO unattended and boots what it
|
||||
# installed (the install test), turns that installed disk into a VM image, and
|
||||
# publishes it with a latest.json saying what is in it.
|
||||
#
|
||||
# edge the ISO is built here, from the edge channel: its packages, its
|
||||
# mirrors and the edge builds of Omarchy (omarchy-dev)
|
||||
# stable the ISO is the last release: omacom/omarchy's latest release names
|
||||
# the version, iso.omarchy.org has the ISO, its signature is checked.
|
||||
# Done once for each release, skipped on the nights after.
|
||||
#
|
||||
# https://nightly.omarchy.org/<channel>/latest.json
|
||||
# https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.iso
|
||||
# https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.qcow2
|
||||
# https://nightly.omarchy.org/edge/<version>/unpacked/ kernel, initramfs, vm-boot
|
||||
# https://nightly.omarchy.org/stable/<version>/<run>/omarchy-<version>.qcow2
|
||||
# https://nightly.omarchy.org/stable/<version>/<run>/unpacked/
|
||||
#
|
||||
# Nothing is published unless the ISO installed and the image passed its
|
||||
# checks. A version's files are written once and never replaced: the CDN keeps
|
||||
# them for a year. Only latest.json changes, and it is written last.
|
||||
#
|
||||
# No pull_request trigger: this workflow holds the key to the bucket, so it
|
||||
# never runs for a fork. To try a change, run it by hand on a branch; that
|
||||
# builds and checks everything and publishes nothing.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "1 0 * * *" # Daily at 00:01 UTC
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish to nightly.omarchy.org (only from quattro)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SITE: https://nightly.omarchy.org
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: ubuntu-latest
|
||||
# Which channels this run makes, under which version, and where each goes:
|
||||
# test/vm-image/nightly-plan decides, from what is fetched here.
|
||||
plan:
|
||||
name: Plan
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
channels: ${{ steps.plan.outputs.channels }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- id: plan
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release=$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name)
|
||||
published=$(curl -fsL "$SITE/stable/latest.json?run=$GITHUB_RUN_ID" | jq -r .version || true)
|
||||
channels=$(./test/vm-image/nightly-plan "$(date -u +%Y.%m.%d)" "${{ github.run_number }}" "${{ github.run_attempt }}" \
|
||||
"$GITHUB_EVENT_NAME" "$release" "$published")
|
||||
echo "channels=$channels" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: jlumbroso/free-disk-space@v1.3.1
|
||||
build:
|
||||
name: ${{ matrix.channel }} ${{ matrix.version }}
|
||||
needs: plan
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.plan.outputs.channels) }}
|
||||
runs-on: namespace-profile-linux-amd64-16x32
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CHANNEL: ${{ matrix.channel }}
|
||||
VERSION: ${{ matrix.version }}
|
||||
RELEASE: ${{ matrix.release }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build ISO
|
||||
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
|
||||
- name: Runner
|
||||
run: |
|
||||
uname -srm
|
||||
echo "$(nproc) CPUs, $(free -h | awk 'NR==2 {print $2}') memory"
|
||||
grep -m1 'model name' /proc/cpuinfo || true
|
||||
echo "LSMs: $(cat /sys/kernel/security/lsm 2>/dev/null || echo unreadable)"
|
||||
ls -l /dev/kvm
|
||||
|
||||
- name: Delete previously uploaded ISO artifact
|
||||
uses: philips-labs/action-delete-artifacts@v1.0.0
|
||||
- name: Check disk space
|
||||
run: |
|
||||
df -h /
|
||||
avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
|
||||
[ "$avail_gb" -ge 50 ] || { echo "::error::only ${avail_gb} GB free, the build, the install test and the image need ~50 GB"; exit 1; }
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
if: matrix.channel == 'edge'
|
||||
|
||||
# No package cache, on purpose: every package the ISO is made of is
|
||||
# fetched from the mirrors again each night, so one that has gone
|
||||
# missing there fails this build instead of being served from a cache.
|
||||
- name: Build the edge ISO
|
||||
if: matrix.channel == 'edge'
|
||||
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make --edge
|
||||
|
||||
# Good only if the signature is the release key's, the one in this repo.
|
||||
- name: Download the released ISO and check its signature
|
||||
if: matrix.channel == 'stable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
iso=omarchy-$VERSION.iso
|
||||
curl -fSL --retry 3 -o "release/$iso" "https://iso.omarchy.org/$iso"
|
||||
curl -fsSL --retry 3 -o "release/$iso.sig" "https://iso.omarchy.org/$iso.sig"
|
||||
home=$(mktemp -d)
|
||||
gpg --homedir "$home" --quiet --batch --import builder/omarchy.gpg
|
||||
key=$(gpg --homedir "$home" --batch --with-colons --list-keys | awk -F: '$1 == "fpr" { print $10; exit }')
|
||||
gpg --homedir "$home" --batch --status-fd 1 --verify "release/$iso.sig" "release/$iso" 2>/dev/null |
|
||||
grep -q "^\[GNUPG:\] VALIDSIG $key " || { echo "::error::$iso is not signed by $key"; exit 1; }
|
||||
echo "$iso is signed by $key"
|
||||
rm -rf "$home" "release/$iso.sig"
|
||||
|
||||
# The same install test a pull request gets (install-test.yml), then the
|
||||
# image: the installed disk that test leaves behind, sealed and checked.
|
||||
- name: Install unattended, boot the installed system, seal it
|
||||
run: |
|
||||
sudo chmod 0666 /dev/kvm
|
||||
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
|
||||
-e OMARCHY_INTEGRATION_DISK_CACHE=none \
|
||||
ghcr.io/archlinux/archlinux:latest bash -euo pipefail -c '
|
||||
# pacman 7 fails every sync on a kernel without Landlock, which
|
||||
# these runners have; builder/build-iso.sh does the same.
|
||||
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||
printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman
|
||||
chmod 0755 /usr/local/bin/pacman
|
||||
fi
|
||||
# The tools come from our own Arch mirror first, the public ones
|
||||
# after it. A mirror that drops one file must not fail the test:
|
||||
# try again.
|
||||
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||
for attempt in 1 2 3; do
|
||||
pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \
|
||||
imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl binutils >/dev/null && break
|
||||
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||
echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15
|
||||
done
|
||||
# test/integration installs its tools through Omarchy'"'"'s helper; they are installed above.
|
||||
printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add
|
||||
chmod 0755 /usr/local/bin/omarchy-pkg-add
|
||||
qemu-system-x86_64 --version | head -1
|
||||
./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800
|
||||
|
||||
base=$(ls -d test-runs/*-integration)
|
||||
./test/vm-image/vm-seal "$base" sealed
|
||||
./test/vm-image/vm-verify sealed | tee vm-verify.txt
|
||||
./test/vm-image/vm-boot sealed "pacman -Qq omarchy omarchy-dev 2>/dev/null | xargs pacman -Q; pacman -Qo /usr/lib/modules/\$(uname -r)/pkgbase; uname -r" >vm-packages.txt
|
||||
cat vm-packages.txt
|
||||
'
|
||||
|
||||
# The container ran as root; what it wrote belongs to root until here.
|
||||
- name: Hand the results to the runner's user
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p test-runs sealed
|
||||
sudo chown -R "$(id -u):$(id -g)" test-runs sealed release vm-*.txt 2>/dev/null || true
|
||||
|
||||
- name: Lay out the release and describe it
|
||||
env:
|
||||
ISO_COMMIT: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./test/vm-image/nightly-layout "$CHANNEL" "$VERSION" "$RELEASE" release/*.iso sealed vm-packages.txt out
|
||||
dir=out/$RELEASE
|
||||
|
||||
{
|
||||
echo "## Nightly $CHANNEL $VERSION"
|
||||
echo
|
||||
echo "| File | Size |"
|
||||
echo "| --- | ---: |"
|
||||
(cd "$dir" && find . -type f -printf '| `%P` | %s |\n' | sort) | awk -F'|' '{ printf "|%s| %.0f MiB |\n", $2, $3 / 1048576 }'
|
||||
echo
|
||||
echo '```'
|
||||
cat vm-verify.txt
|
||||
echo '```'
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Keep the release for the publish job
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
workflow: nightly-build.yml
|
||||
continue-on-error: true
|
||||
name: nightly-${{ matrix.channel }}-${{ github.run_id }}
|
||||
path: out/
|
||||
compression-level: 0
|
||||
retention-days: 2
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload newly built ISO artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Keep the install test's logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: omarchy-iso-${{ github.run_number }}
|
||||
path: ./release/*.iso
|
||||
retention-days: 1
|
||||
name: nightly-install-test-${{ matrix.channel }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
test-runs/*/omarchy-install-timing.json
|
||||
test-runs/*/omarchy-install.log
|
||||
test-runs/*/first-boot-systemd-analyze.txt
|
||||
test-runs/*/runs/**/*.png
|
||||
test-runs/*/runs/**/*.txt
|
||||
test-runs/*/runs/**/*.json
|
||||
test-runs/*/runs/**/*.log
|
||||
vm-verify.txt
|
||||
vm-packages.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# The only job that holds the bucket's key, and it builds nothing: it copies
|
||||
# what the job above made, checks what the CDN then serves, and only then
|
||||
# points latest.json at it.
|
||||
# One channel failing does not hold back the other: each is published if
|
||||
# its own build left a release behind.
|
||||
publish:
|
||||
name: Publish ${{ matrix.channel }}
|
||||
needs: [plan, build]
|
||||
if: ${{ !cancelled() && github.ref == 'refs/heads/quattro' && (github.event_name == 'schedule' || inputs.publish) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.plan.outputs.channels) }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
environment: nightly
|
||||
# actions: read is for finding and fetching the build job's artifact.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
env:
|
||||
CHANNEL: ${{ matrix.channel }}
|
||||
VERSION: ${{ matrix.version }}
|
||||
RELEASE: ${{ matrix.release }}
|
||||
RCLONE_CONFIG_R2_TYPE: s3
|
||||
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
|
||||
RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
BUCKET: omarchy-nightly
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: test/vm-image
|
||||
persist-credentials: false
|
||||
|
||||
# Not the download action: GitHub's storage limits each connection to a
|
||||
# few megabytes a second, and the action took 10 minutes over 4.6 GB.
|
||||
# nightly-fetch takes the same artifact over 16 connections.
|
||||
- name: Fetch the release from the build job
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# Not /tmp: on Ubuntu 26.04 it is held in memory, and the zip is
|
||||
# gigabytes.
|
||||
TMPDIR: ${{ runner.temp }}
|
||||
run: |
|
||||
df -h "$TMPDIR" .
|
||||
command -v aria2c unzip >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq aria2 unzip >/dev/null; }
|
||||
./test/vm-image/nightly-fetch "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "nightly-$CHANNEL-$GITHUB_RUN_ID" out
|
||||
|
||||
# A current rclone, by version and checksum. Ubuntu's is 1.60 from 2022:
|
||||
# with it R2 answered every first upload "501 Not Implemented", though
|
||||
# it had stored the file, and the publish only passed on rclone's retry.
|
||||
# 1.60 sends "x-amz-acl: private" with each upload; current rclone does
|
||||
# not send it to Cloudflare.
|
||||
# Not RCLONE_VERSION: rclone reads every RCLONE_<FLAG> variable as that
|
||||
# flag, and "--version=v1.75.2" stops it before it does anything.
|
||||
- name: rclone
|
||||
env:
|
||||
PINNED_RCLONE: v1.75.2
|
||||
PINNED_RCLONE_SHA256: efbfe852181f7191eb3c9043ed1ab49c9b2d0ba045c61c926a5da111c939ec5c
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -n "$RCLONE_CONFIG_R2_ACCESS_KEY_ID" ] || { echo "::error::the nightly environment has no bucket key"; exit 1; }
|
||||
curl -fsSL --retry 3 -o rclone.deb "https://github.com/rclone/rclone/releases/download/$PINNED_RCLONE/rclone-$PINNED_RCLONE-linux-amd64.deb"
|
||||
echo "$PINNED_RCLONE_SHA256 rclone.deb" | sha256sum -c -
|
||||
sudo dpkg -i rclone.deb >/dev/null
|
||||
rclone version | head -1
|
||||
|
||||
# --immutable: a name that exists with other bytes is an error, not an
|
||||
# overwrite.
|
||||
# nightly-rclone is rclone, with a warning on the run's page when an
|
||||
# upload only passed on a retry: the files are compared afterwards either
|
||||
# way, but it should not go unnoticed again.
|
||||
- name: Upload this version
|
||||
run: |
|
||||
./test/vm-image/nightly-rclone copy "out/$RELEASE" "r2:$BUCKET/$RELEASE" \
|
||||
--immutable --s3-no-check-bucket --transfers 8 --s3-upload-concurrency 8 --s3-chunk-size 64M --stats 30s --stats-one-line
|
||||
|
||||
# Fetched the way a user fetches them. It proves what is served, and it
|
||||
# is the first request the CDN sees for each file.
|
||||
- name: Check what the CDN serves
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dir=out/$RELEASE
|
||||
check() { # url, expected sha256
|
||||
got=$(curl -fsSL --retry 3 "$1" | sha256sum | cut -d" " -f1)
|
||||
[ "$got" = "$2" ] || { echo "::error::$1 serves $got, expected $2"; exit 1; }
|
||||
echo "ok $1"
|
||||
}
|
||||
# Every file of the version, the checksum files too.
|
||||
(cd "$dir" && find . -type f -printf '%P\n' | sort) >files.txt
|
||||
[ -s files.txt ] || { echo "::error::nothing to check under $dir"; exit 1; }
|
||||
while read -r file; do
|
||||
check "$SITE/$RELEASE/$file" "$(sha256sum "$dir/$file" | cut -d" " -f1)"
|
||||
done <files.txt
|
||||
echo "$(wc -l <files.txt) files published and checked"
|
||||
|
||||
- name: Point latest.json at it
|
||||
run: |
|
||||
./test/vm-image/nightly-rclone copyto "out/$CHANNEL/latest.json" "r2:$BUCKET/$CHANNEL/latest.json" --s3-no-check-bucket
|
||||
served=$(curl -fsSL "$SITE/$CHANNEL/latest.json?run=$GITHUB_RUN_ID" | jq -r .version)
|
||||
[ "$served" = "$VERSION" ] || { echo "::error::latest.json serves version $served, expected $VERSION"; exit 1; }
|
||||
echo "Published [$VERSION]($SITE/$CHANNEL/latest.json)" >>"$GITHUB_STEP_SUMMARY"
|
||||
@@ -15,10 +15,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The tests run where the code they cover runs: on Arch, the same as
|
||||
# the live ISO and an Omarchy workstation. The container boots systemd
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
run: |
|
||||
docker run -d --name unit --privileged --cgroupns=host \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw --tmpfs /run --tmpfs /tmp:exec \
|
||||
-v "$PWD:/src:ro" archlinux:latest bash -c '
|
||||
-v "$PWD:/src:ro" ghcr.io/archlinux/archlinux:latest bash -c '
|
||||
systemctl mask getty.target console-getty.service \
|
||||
systemd-udev-trigger.service systemd-udevd.service \
|
||||
systemd-vconsole-setup.service
|
||||
@@ -46,7 +46,14 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
echo "systemd: $state"
|
||||
pacman -Syu --noconfirm --needed python parted >/dev/null
|
||||
# From our own Arch mirror first, and a second and third try: a
|
||||
# mirror that drops one file must not fail the tests.
|
||||
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||
for attempt in 1 2 3; do
|
||||
pacman -Syu --noconfirm --needed python parted diffutils jq zip unzip >/dev/null && break
|
||||
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||
sleep 15
|
||||
done
|
||||
useradd -m tester
|
||||
cp -r /src /home/tester/omarchy-iso
|
||||
chown -R tester /home/tester/omarchy-iso
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
name: Upgrade test
|
||||
|
||||
# What an existing user gets: installs a release from its ISO, moves it to
|
||||
# another channel the way a user would (omarchy-channel-set), reboots it, and
|
||||
# checks what came back. test/upgrade-test does the work and says what it
|
||||
# checks.
|
||||
#
|
||||
# Started by hand only: from the latest release to edge unless told otherwise.
|
||||
#
|
||||
# When there is a pull request for the branch it ran on, the result goes there
|
||||
# as one comment, updated in place on later runs, with a picture of the desktop
|
||||
# before and after. The pictures are committed to the upgrade-test-screenshots
|
||||
# branch, which is what lets a comment show them.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
to:
|
||||
description: "Channel to move to"
|
||||
type: choice
|
||||
options: [edge, rc]
|
||||
default: edge
|
||||
from:
|
||||
description: "Release to start from, like 4.0.4 (default: the latest)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: upgrade-test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TO: ${{ inputs.to || 'edge' }}
|
||||
|
||||
jobs:
|
||||
upgrade:
|
||||
name: Install, upgrade, reboot
|
||||
runs-on: namespace-profile-linux-amd64-16x32
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
from: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FROM: ${{ inputs.from }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=${FROM:-$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name)}
|
||||
version=${version#v}
|
||||
[[ $version =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "::error::'$version' is not a release version"; exit 1; }
|
||||
echo "version=$version" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Good only if the signature is the release key's, the one in this repo.
|
||||
- name: Download the released ISO and check its signature
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
iso=omarchy-$VERSION.iso
|
||||
curl -fSL --retry 3 -o "release/$iso" "https://iso.omarchy.org/$iso"
|
||||
curl -fsSL --retry 3 -o "release/$iso.sig" "https://iso.omarchy.org/$iso.sig"
|
||||
home=$(mktemp -d)
|
||||
gpg --homedir "$home" --quiet --batch --import builder/omarchy.gpg
|
||||
key=$(gpg --homedir "$home" --batch --with-colons --list-keys | awk -F: '$1 == "fpr" { print $10; exit }')
|
||||
gpg --homedir "$home" --batch --status-fd 1 --verify "release/$iso.sig" "release/$iso" 2>/dev/null |
|
||||
grep -q "^\[GNUPG:\] VALIDSIG $key " || { echo "::error::$iso is not signed by $key"; exit 1; }
|
||||
echo "$iso is signed by $key"
|
||||
rm -rf "$home" "release/$iso.sig"
|
||||
|
||||
# The install test's own install, which leaves the installed disk behind;
|
||||
# the upgrade then starts from that disk. A failed upgrade does not stop
|
||||
# here: the evidence is kept first, and the last step fails the job.
|
||||
- name: Install the release, then upgrade it
|
||||
run: |
|
||||
sudo chmod 0666 /dev/kvm
|
||||
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
|
||||
-e OMARCHY_INTEGRATION_DISK_CACHE=none -e TO \
|
||||
ghcr.io/archlinux/archlinux:latest bash -euo pipefail -c '
|
||||
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||
printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman
|
||||
chmod 0755 /usr/local/bin/pacman
|
||||
fi
|
||||
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||
for attempt in 1 2 3; do
|
||||
pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \
|
||||
imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null && break
|
||||
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||
echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15
|
||||
done
|
||||
printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add
|
||||
chmod 0755 /usr/local/bin/omarchy-pkg-add
|
||||
./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800
|
||||
|
||||
status=0
|
||||
VM_STATE_DIR=/w ./test/upgrade-test "$(ls -d test-runs/*-integration)" "$TO" upgrade-evidence || status=$?
|
||||
echo "$status" >upgrade-evidence/status
|
||||
'
|
||||
|
||||
- name: Hand the results to the runner's user
|
||||
if: always()
|
||||
run: sudo chown -R "$(id -u):$(id -g)" upgrade-evidence test-runs 2>/dev/null || true
|
||||
|
||||
- name: Keep the evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: upgrade-evidence/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: The upgrade's result
|
||||
run: |
|
||||
cat upgrade-evidence/report.md >>"$GITHUB_STEP_SUMMARY"
|
||||
[ "$(cat upgrade-evidence/status)" = 0 ] || { echo "::error::the upgrade test failed, see the summary"; exit 1; }
|
||||
|
||||
# The only job that can write: it commits the two pictures and writes the
|
||||
# comment. It runs nothing from the test; it handles two PNG files and a
|
||||
# Markdown report.
|
||||
report:
|
||||
name: Report
|
||||
needs: upgrade
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FROM: ${{ needs.upgrade.outputs.from }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
SHOTS_BRANCH: upgrade-test-screenshots
|
||||
steps:
|
||||
# No evidence at all still gets a comment saying so.
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: evidence
|
||||
|
||||
# A comment can only show a picture that has a public address. A commit
|
||||
# on a branch of this repository gives it one, and the address of a
|
||||
# commit never changes.
|
||||
- name: Commit the pictures
|
||||
id: pictures
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -s evidence/before.png ] || [ -s evidence/after.png ] || { echo "no pictures to commit"; exit 0; }
|
||||
url=https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git
|
||||
dir=$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
|
||||
git init -q shots && cd shots
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
# Runs on other branches push here too. A push that loses the race
|
||||
# is made again on top of whatever won: the folder is this run's own,
|
||||
# so there is never anything to merge.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git fetch -q --depth 1 "$url" "$SHOTS_BRANCH" 2>/dev/null; then
|
||||
git checkout -q -B "$SHOTS_BRANCH" FETCH_HEAD
|
||||
else
|
||||
git checkout -q --orphan "$SHOTS_BRANCH" 2>/dev/null || true
|
||||
fi
|
||||
mkdir -p "$dir"
|
||||
cp ../evidence/*.png "$dir/"
|
||||
git add "$dir"
|
||||
git commit -q -m "Upgrade test $GITHUB_RUN_ID, attempt $GITHUB_RUN_ATTEMPT: $FROM to $TO"
|
||||
if git push -q "$url" "HEAD:refs/heads/$SHOTS_BRANCH"; then break; fi
|
||||
[ "$attempt" -lt 5 ] || { echo "::error::could not push the pictures after 5 tries"; exit 1; }
|
||||
echo "The push lost a race, trying again"
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
echo "base=https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$(git rev-parse HEAD)/$dir" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write the comment
|
||||
env:
|
||||
PICTURES: ${{ steps.pictures.outputs.base }}
|
||||
RESULT: ${{ needs.upgrade.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
marker="<!-- upgrade-test -->"
|
||||
{
|
||||
echo "$marker"
|
||||
echo "## Upgrade test: $FROM → $TO"
|
||||
echo
|
||||
if [ -s evidence/report.md ]; then
|
||||
cat evidence/report.md
|
||||
else
|
||||
echo "**The test did not get as far as a report** (job result: $RESULT)."
|
||||
fi
|
||||
if [ -n "$PICTURES" ] && [ -s evidence/before.png ] && [ -s evidence/after.png ]; then
|
||||
echo
|
||||
echo "| Before, $FROM | After, on $TO |"
|
||||
echo "| --- | --- |"
|
||||
echo "|  |  |"
|
||||
fi
|
||||
# The picture of a reboot that stuck stands by itself: it is the
|
||||
# one worth having when there is no "after" to show.
|
||||
if [ -n "$PICTURES" ] && [ -s evidence/stuck.png ]; then
|
||||
echo
|
||||
echo "The reboot did not come back by itself. This is what the screen showed:"
|
||||
echo
|
||||
echo ""
|
||||
fi
|
||||
echo
|
||||
echo "[Run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID), attempt $GITHUB_RUN_ATTEMPT, at \`${GITHUB_SHA:0:7}\`."
|
||||
} >comment.md
|
||||
cat comment.md >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The open pull request for the branch this was started on, if any.
|
||||
pr=$(gh pr list -R "$GITHUB_REPOSITORY" --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
|
||||
[ -n "$pr" ] || { echo "No pull request for $BRANCH: the result is on this run's page."; exit 0; }
|
||||
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate \
|
||||
--jq "[.[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\")))][0].id // empty")
|
||||
if [ -n "$existing" ]; then
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -F body=@comment.md >/dev/null
|
||||
echo "Updated the comment on #$pr"
|
||||
else
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -F body=@comment.md >/dev/null
|
||||
echo "Commented on #$pr"
|
||||
fi
|
||||
@@ -1,49 +1,152 @@
|
||||
# Omarchy ISO
|
||||
|
||||
The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user.
|
||||
[](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml?query=branch%3Aquattro+event%3Aschedule)
|
||||
|
||||
## Downloading the latest ISO
|
||||
The Omarchy ISO is the only supported way to install Omarchy. It includes the Omarchy Configurator and installs Arch Linux and the Omarchy packages from the bundled mirror. It then runs target system setup in the chroot, creates the user, and runs `omarchy-provision-user` for that user.
|
||||
|
||||
See the ISO link on [omarchy.org](https://omarchy.org).
|
||||
## Get Omarchy
|
||||
|
||||
Every published ISO has a `.sha256` beside it at the same URL. Download both into the same directory and check the ISO before writing it to a USB stick:
|
||||
1. [Download the ISO](https://omarchy.org).
|
||||
2. Write it to a USB stick. Use [balenaEtcher](https://etcher.balena.io/) on Mac or Windows, or [caligula](https://github.com/ifd3f/caligula) on Linux.
|
||||
3. Turn off Secure Boot and/or TPM in the BIOS.
|
||||
4. Boot from the stick.
|
||||
5. Answer the configuration questions and select a drive.
|
||||
|
||||
The install takes under a minute on the fastest machines and up to 5 minutes on an older computer.
|
||||
|
||||
### Verify the download
|
||||
|
||||
Verify the ISO before you write it to the stick. Otherwise, a corrupted download causes a pacman "invalid or corrupted package" error several minutes into the install.
|
||||
|
||||
Every release ISO has a `.sha256` file and a `.sig` file next to it. Run these commands to download and verify the ISO of the latest release of [omacom/omarchy](https://github.com/omacom/omarchy/releases):
|
||||
|
||||
```bash
|
||||
sha256sum -c omarchy-3.0.iso.sha256
|
||||
version=$(curl -fsSL https://api.github.com/repos/omacom/omarchy/releases/latest | jq -r .tag_name)
|
||||
version=${version#v}
|
||||
curl -fLO "https://iso.omarchy.org/omarchy-$version.iso"
|
||||
curl -fsSLO "https://iso.omarchy.org/omarchy-$version.iso.sha256"
|
||||
sha256sum -c "omarchy-$version.iso.sha256"
|
||||
```
|
||||
|
||||
Corruption anywhere in the ISO is worth catching before the write, and corruption in the bundled package mirror is worth catching most: the mirror lives inside the ISO and the installer reads it straight off the medium, so those bytes surface minutes into the install as a pacman "invalid or corrupted package" error rather than as anything that names the download. Corruption elsewhere is louder and earlier — it stops the medium booting or mounting. There is a `.sig` beside the ISO too for anyone who wants to verify it against the Omarchy signing key.
|
||||
Use the `.sig` file to verify the ISO against the Omarchy signing key.
|
||||
|
||||
## Creating the ISO
|
||||
### More in the manual
|
||||
|
||||
Run `./bin/omarchy-iso-make`; output goes into `./release`. By default the ISO uses the Omarchy packages and tracks the `quattro` branch, from the stable mirror. Pass `--edge` to use `omarchy-dev` and `omarchy-settings-dev` from the edge mirror.
|
||||
- [Getting started](https://omarchy.org/manual/getting-started/): the install step by step, installing for another owner, and installing without encryption.
|
||||
- [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): install Omarchy in the free space of a drive, beside Windows or another OS.
|
||||
- [Unattended installs](https://omarchy.org/manual/unattended-installs/): let the ISO install itself from a configuration on a second drive, with no keyboard and no wizard.
|
||||
|
||||
For local development, build the ISO from sibling checkouts:
|
||||
# For maintainers
|
||||
|
||||
The rest of this file covers building, testing and publishing the ISO.
|
||||
|
||||
## What is published, and where
|
||||
|
||||
| What | Where | When |
|
||||
| --- | --- | --- |
|
||||
| Release ISO | `https://iso.omarchy.org/omarchy-<version>.iso` | Each release |
|
||||
| Edge ISO | `https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.iso` | Every night |
|
||||
| VM image of edge | `https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.qcow2` | Every night |
|
||||
| VM image of the latest release | `https://nightly.omarchy.org/stable/<version>/<run>/omarchy-<version>.qcow2` | Once per release |
|
||||
| `latest.json` | `https://nightly.omarchy.org/edge/latest.json` and `.../stable/latest.json` | With each nightly build |
|
||||
|
||||
Every ISO and every VM image has a `.sha256` file next to it. The release ISO also has a `.sig` file. Each VM image has an `unpacked/` folder next to it.
|
||||
|
||||
On nightly.omarchy.org, a published file is never replaced. Only `latest.json` changes. It is written last, after every file of the version has been fetched back through the CDN and compared.
|
||||
|
||||
## The nightly build
|
||||
|
||||
`.github/workflows/nightly-build.yml` runs every night for two channels:
|
||||
|
||||
- `edge`: builds the ISO from the edge channel: the edge packages and mirrors, and the edge builds of Omarchy (`omarchy-dev`).
|
||||
- `stable`: uses the latest release. The latest release of omacom/omarchy determines the version. The ISO comes from iso.omarchy.org and is used only if its signature matches the release key. A scheduled run skips a release that is already published. A manual run builds it again.
|
||||
|
||||
Each ISO goes through the same install test that runs for pull requests: an unattended install, then a boot of the installed system. `test/vm-image/vm-seal` then turns the installed disk into a VM image, and `test/vm-image/vm-verify` boots that image and checks it. Nothing is published unless every check passes.
|
||||
|
||||
A separate job publishes. It is the only job with the bucket's key, and it runs only on `quattro`. To try a change, run the workflow manually on a branch. That run builds and checks everything and publishes nothing.
|
||||
|
||||
`test/vm-image/nightly-plan` decides which channels a run builds and where they are published. `test/vm-image/nightly-layout` lays out the files and writes `latest.json`. `test/unit/nightly-test.sh` tests both.
|
||||
|
||||
## latest.json
|
||||
|
||||
| Field | What it is |
|
||||
| --- | --- |
|
||||
| `version` | Edge: the build's date and run number, `2026.10.09.431`. Stable: the release, `4.0.4`. |
|
||||
| `channel` | `edge` or `stable`. |
|
||||
| `built_at` | When this file was written, after the image passed its checks. UTC, `2026-10-09T18:02:35Z`. |
|
||||
| `url` | The ISO that was installed. Edge: on nightly.omarchy.org. Stable: the release ISO on iso.omarchy.org. |
|
||||
| `sha256`, `size` | The ISO's SHA-256 and its size in bytes. |
|
||||
| `iso_commit` | The omarchy-iso commit the nightly build ran from. |
|
||||
| `omarchy_version` | The version of the Omarchy package in the image. |
|
||||
| `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty. |
|
||||
| `kernel` | The image's kernel release, as reported by `uname -r`. |
|
||||
| `vm.url`, `vm.sha256`, `vm.size` | The VM image: its URL, SHA-256 and size in bytes. |
|
||||
| `vm.unpacked` | The URL of the image's `unpacked/` folder, ending in `/`. |
|
||||
| `vm.unpacked_files` | Every file in that folder. |
|
||||
| `vm.user`, `vm.password` | The account for the desktop and `sudo`. |
|
||||
| `vm.ssh` | SSH authentication: keys only. Password authentication is off. |
|
||||
|
||||
The version is also in the edge ISO's file name.
|
||||
|
||||
## VM images
|
||||
|
||||
VM images are for testing. Users download the release ISO from iso.omarchy.org.
|
||||
|
||||
A VM image is Omarchy already installed, as one `.qcow2` file. There is an image of the edge channel, rebuilt every night, and an image of the latest release. Every boot is a new machine, with a new machine ID and new SSH host keys.
|
||||
|
||||
The user is `omarchy` and the password is `omarchy`. SSH takes keys only.
|
||||
|
||||
### Boot the image by itself
|
||||
|
||||
The image boots through UEFI with no other file. Run these commands on a Linux machine with QEMU, read and write access to `/dev/kvm`, `curl` and `jq`:
|
||||
|
||||
```bash
|
||||
./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs
|
||||
latest=https://nightly.omarchy.org/edge/latest.json # or .../stable/latest.json
|
||||
image=$(curl -fsSL "$latest" | jq -r .vm.url)
|
||||
curl -fLO "$image"
|
||||
curl -fsSLO "$image.sha256"
|
||||
sha256sum -c "${image##*/}.sha256"
|
||||
|
||||
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd vars.fd
|
||||
qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 4G \
|
||||
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
|
||||
-drive if=pflash,format=raw,file=vars.fd \
|
||||
-drive file="${image##*/}",if=virtio,snapshot=on \
|
||||
-device virtio-vga
|
||||
```
|
||||
|
||||
Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo.
|
||||
The firmware paths are Arch's. `snapshot=on` keeps the downloaded image unchanged. The image is a 4 to 5 GB download.
|
||||
|
||||
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
|
||||
### Boot it faster, with SSH
|
||||
|
||||
### Regional builds
|
||||
The `unpacked/` folder next to each image holds what a direct boot needs:
|
||||
|
||||
`--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.
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments. |
|
||||
| `vm-boot` | A script that boots the image directly into its kernel and gives it an SSH key. |
|
||||
| `SHA256SUMS` | Checksums of the files above. |
|
||||
|
||||
For an unpublished regional runtime, use matching local source and package-recipe checkouts:
|
||||
A direct boot skips the firmware and the boot loader: SSH answers after about 6 seconds instead of about 20. Download the folder next to the image, then run `vm-boot`. It also needs `qemu-img` and `ssh`:
|
||||
|
||||
```bash
|
||||
./bin/omarchy-iso-make --region cn --no-boot-offer --local-source /path/to/omarchy /path/to/omarchy-pkgs
|
||||
unpacked=$(curl -fsSL "$latest" | jq -r .vm.unpacked)
|
||||
for file in $(curl -fsSL "$latest" | jq -r '.vm.unpacked_files[]'); do curl -fsSLO "$unpacked$file"; done
|
||||
sha256sum -c SHA256SUMS
|
||||
chmod +x vm-boot
|
||||
|
||||
./vm-boot "${image##*/}" # boots, prints how to log in, runs until Ctrl-C
|
||||
./vm-boot "${image##*/}" uname -r # boots, runs the command over SSH, powers off
|
||||
```
|
||||
|
||||
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.
|
||||
`vm-boot` makes a new SSH key for each boot. It runs the machine on an overlay under `/var/tmp`, so the downloaded image is never changed, and deletes the overlay on exit. Set `VM_STATE_DIR` to put the overlay somewhere else.
|
||||
|
||||
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.
|
||||
To get the URL of the edge ISO, run `jq -r .url` on the same `latest.json`.
|
||||
|
||||
## Autoinstall
|
||||
|
||||
The [manual's page](https://omarchy.org/manual/unattended-installs/) is the one for users; this is the same feature in full.
|
||||
|
||||
The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
|
||||
|
||||
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one.
|
||||
@@ -62,7 +165,7 @@ These are the configurator's own output files, so the way to get a starting set
|
||||
| `authorized_keys` | No | SSH public keys in sshd's own format, one per line |
|
||||
| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot |
|
||||
|
||||
Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
|
||||
Both required files must be present or the installer falls back to the configurator. (A `defer-provisioning` marker file can stand in for `user_credentials.json`: user creation is then deferred to first boot.) Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
|
||||
|
||||
Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption.
|
||||
|
||||
@@ -72,10 +175,12 @@ Encryption itself is configured by the `disk_encryption` block inside `user_conf
|
||||
ssh-ed25519 AAAA... you@host
|
||||
```
|
||||
|
||||
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach.
|
||||
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend and Docker's DNS. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no keys in it fails the install rather than producing a machine nobody can reach.
|
||||
|
||||
When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs.
|
||||
|
||||
The region follows `timezone`, exactly as in an interactive install: a timezone listed in one of the runtime's region profiles (`default/regions/*/timezones`, such as `Asia/Shanghai` for `cn`) selects that profile's mirrors and repositories, and anything else is global. Set `omarchy_install.region` in `user_configuration.json` to pick one explicitly, for example `"cn"` on a deferred-provisioning install, which carries `UTC` until first boot. Every ISO bundles every profile's packages, so a regional install still needs no network.
|
||||
|
||||
### Building the drive
|
||||
|
||||
```bash
|
||||
@@ -104,6 +209,22 @@ Boot order is disk first: the empty disk falls through to the ISO on the first b
|
||||
|
||||
Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot.
|
||||
|
||||
## Creating the ISO
|
||||
|
||||
Run `./bin/omarchy-iso-make`; output goes into `./release`. By default the ISO uses the Omarchy packages and tracks the `quattro` branch, from the stable mirror. Pass `--edge` to use `omarchy-dev` and `omarchy-settings-dev` from the edge mirror.
|
||||
|
||||
For local development, build the ISO from sibling checkouts:
|
||||
|
||||
```bash
|
||||
./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs
|
||||
```
|
||||
|
||||
Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo.
|
||||
|
||||
The configurator asks for the language on its first screen: English or Simplified Chinese. Chinese reruns the installer in cage + foot, a console that draws Chinese, with Noto Sans Mono CJK unpacked from the offline mirror's `noto-fonts-cjk`. It opens on the keyboard screen, offers only keyboards with a US keymap (cage keeps a US layout) and preselects Chinese (Simplified, Pinyin); every later screen and the install progress read in Chinese. Esc there goes back to a bilingual language screen, where English returns to the VT, which applies the chosen keymap. Without a working display or the font, Chinese continues in English on the VT. Either way a Chinese install sets the target's `LANG` to `zh_CN.UTF-8` through archinstall's `locale_config`. The language never decides the region: that follows the timezone. The Chinese text lives in `OMARCHY_ZH` tables beside the English it replaces.
|
||||
|
||||
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
|
||||
|
||||
## Testing the ISO
|
||||
|
||||
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
|
||||
@@ -146,6 +267,25 @@ The first scenario is `factory-reset`: it proves `omarchy-system-factory-reset`
|
||||
|
||||
Artifacts — screenshots, the fixtured/staged/final `limine.conf`, the reset typescript, and the factory-reset log — land under `test-runs/<iso>-integration/runs/<timestamp>-<scenario>/`, and `--no-preview` skips the `imv` review just like the acceptance harness.
|
||||
|
||||
## Upgrade testing
|
||||
|
||||
`test/upgrade-test` checks what an existing user gets. It takes an installed system, moves it to another channel with `omarchy-channel-set`, reboots it, and checks what came back: the update's exit status, skipped migrations, that the reboot comes back by itself into an installed kernel, failed units, the desktop, the user's wallpaper and the boot menu.
|
||||
|
||||
```bash
|
||||
./test/integration release/omarchy.iso boot # installs once, leaves the installed disk
|
||||
./test/upgrade-test test-runs/omarchy-integration edge upgrade-evidence # edge or rc
|
||||
```
|
||||
|
||||
The evidence folder gets `report.md`, the update's output, and a picture of the desktop before and after.
|
||||
|
||||
The "Upgrade test" workflow does both steps from a release's ISO. Start it by hand, from the latest release to edge by default:
|
||||
|
||||
```bash
|
||||
gh workflow run upgrade-test.yml -f to=edge
|
||||
```
|
||||
|
||||
It only runs when started. When the branch it ran on has a pull request, the result is posted there as one comment and updated in place on later runs.
|
||||
|
||||
## Signing the ISO
|
||||
|
||||
Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI.
|
||||
|
||||
+9
-37
@@ -6,17 +6,8 @@ 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
|
||||
@@ -66,17 +57,12 @@ while [[ $# -gt 0 ]]; do
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--region global|cn] [--local-source <omarchy-checkout> <pkgs-checkout>]"
|
||||
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--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; }
|
||||
@@ -130,10 +116,6 @@ 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}"
|
||||
@@ -143,11 +125,10 @@ 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_OUT/:/out/"
|
||||
-v "$BUILD_RELEASE_PATH/:/out/"
|
||||
-v "$BUILD_ROOT/$BUILD_SCRIPT:/$BUILD_SCRIPT:ro"
|
||||
-v "$BUILD_ROOT/archiso:/archiso:ro"
|
||||
-v "$BUILD_ROOT/builder:/builder:ro"
|
||||
@@ -165,14 +146,10 @@ if [[ -d /var/cache/pacman/pkg ]]; then
|
||||
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
|
||||
fi
|
||||
|
||||
# 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
|
||||
# Mount the build cache directory where packages are downloaded. Channels use
|
||||
# different package snapshots and must never share an offline mirror cache.
|
||||
if [[ -z "$NO_CACHE" ]]; then
|
||||
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}${region_suffix}/airootfs/var/cache/omarchy"
|
||||
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
|
||||
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
|
||||
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
|
||||
else
|
||||
@@ -189,16 +166,11 @@ if ! docker version &>/dev/null; then
|
||||
DOCKER=(sudo docker)
|
||||
fi
|
||||
|
||||
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
|
||||
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" ghcr.io/archlinux/archlinux:latest /$BUILD_SCRIPT
|
||||
|
||||
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"
|
||||
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
|
||||
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
|
||||
mv -f "$latest_iso" "$iso_ref"
|
||||
|
||||
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
|
||||
echo
|
||||
|
||||
+127
-27
@@ -13,7 +13,11 @@
|
||||
#
|
||||
# Usage: omarchy-iso-test [release/omarchy.iso] [options]
|
||||
#
|
||||
# --keyboard LABEL English (US/UK), Japanese, Japanese (US keyboard), Korean,
|
||||
# or Chinese (Simplified, Pinyin)/(Traditional, Zhuyin).
|
||||
# OMARCHY_PATH must be the source tree used to build the ISO.
|
||||
# --encrypt Drive the encrypted install flow (default: unencrypted)
|
||||
# --language LANG Pick en (default) or zh; zh keeps the preselected Pinyin keyboard
|
||||
# --provision Drive the deferred-provisioning install flow: no user during install, the
|
||||
# first boot runs omarchy-provision-owner and creates it there
|
||||
# --reuse-base Skip the install phase if a base image already exists
|
||||
@@ -39,6 +43,7 @@ MEMORY=8192
|
||||
INSTALL_TIMEOUT=2400
|
||||
BOOT_TIMEOUT=600
|
||||
ENCRYPT=false
|
||||
INSTALL_LANGUAGE=en
|
||||
PROVISION=false
|
||||
REUSE_BASE=false
|
||||
INSTALL_ONLY=false
|
||||
@@ -47,13 +52,16 @@ NO_PREVIEW=false
|
||||
SYNC_DIR=""
|
||||
SYNC_ALL=false
|
||||
|
||||
KEYBOARD_LABEL="English (US)"
|
||||
GUEST_USER="omarchy"
|
||||
GUEST_PASSWORD="omarchy"
|
||||
GUEST_HOSTNAME="omarchy-test"
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--keyboard) KEYBOARD_LABEL="$2"; shift ;;
|
||||
--encrypt) ENCRYPT=true ;;
|
||||
--language) INSTALL_LANGUAGE="$2"; shift ;;
|
||||
--provision) PROVISION=true ;;
|
||||
--reuse-base) REUSE_BASE=true ;;
|
||||
--install-only) INSTALL_ONLY=true ;;
|
||||
@@ -84,13 +92,42 @@ if [[ ! -f ${ISO:-} ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng
|
||||
if [[ $INSTALL_LANGUAGE != "en" && $INSTALL_LANGUAGE != "zh" ]]; then
|
||||
echo "--language must be en or zh, not $INSTALL_LANGUAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $INSTALL_LANGUAGE == "zh" && $KEYBOARD_LABEL != "English (US)" ]]; then
|
||||
echo "--language zh takes the preselected Pinyin keyboard; drop --keyboard" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$KEYBOARD_LABEL" in
|
||||
"English (US)" | "English (UK)" | "Japanese" | "Japanese (US keyboard)" | "Korean" | "Chinese (Simplified, Pinyin)" | "Chinese (Traditional, Zhuyin)") ;;
|
||||
*) echo "The VM harness cannot type credentials with keyboard: $KEYBOARD_LABEL" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng tesseract-data-chi_sim
|
||||
|
||||
# After the language screen a Chinese install reads in Chinese.
|
||||
OCR_LANGS=eng
|
||||
[[ $INSTALL_LANGUAGE == "zh" ]] && OCR_LANGS=eng+chi_sim
|
||||
# lang_text <english> <chinese> picks the text a screen shows in this run.
|
||||
lang_text() {
|
||||
if [[ $INSTALL_LANGUAGE == "zh" ]]; then
|
||||
printf '%s' "$2"
|
||||
else
|
||||
printf '%s' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
|
||||
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
|
||||
|
||||
BASE_NAME="$(basename "$ISO" .iso)"
|
||||
if [[ $KEYBOARD_LABEL != "English (US)" ]]; then
|
||||
BASE_NAME+="-$(printf '%s' "$KEYBOARD_LABEL" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g')"
|
||||
fi
|
||||
$ENCRYPT && BASE_NAME+="-encrypted"
|
||||
[[ $INSTALL_LANGUAGE == "zh" ]] && BASE_NAME+="-zh"
|
||||
$PROVISION && BASE_NAME+="-provision"
|
||||
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
|
||||
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
|
||||
@@ -240,7 +277,13 @@ ocr_screen() {
|
||||
screendump "$shot"
|
||||
[[ -s $shot ]] || return 0
|
||||
magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0
|
||||
tesseract "$prepped" - --psm 6 2>/dev/null || true
|
||||
# The logo throws tesseract off CJK, so Chinese runs also read below it, without spaces.
|
||||
if [[ $INSTALL_LANGUAGE == "zh" ]]; then
|
||||
magick "$shot" -gravity north -chop 0x35% -colorspace gray -negate -resize 150% "$prepped.text.png" 2>/dev/null || return 0
|
||||
{ tesseract "$prepped" - --psm 6 -l "$OCR_LANGS"; tesseract "$prepped.text.png" - --psm 6 -l "$OCR_LANGS"; } 2>/dev/null | sed 'p; s/ //g' || true
|
||||
else
|
||||
tesseract "$prepped" - --psm 6 -l "$OCR_LANGS" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait until the console shows the given text. Saves the offending screen as
|
||||
@@ -610,6 +653,35 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
|
||||
|
||||
# ----------------------------------------------------- driving the installer
|
||||
|
||||
select_keyboard() {
|
||||
local label="$1" index direction="down"
|
||||
# gum choose uses arrows, not a searchable text field. Read the same ordered
|
||||
# choices the ISO vendors from the runtime rather than hard-code row numbers.
|
||||
index=$(OMARCHY_TEST_KEYBOARD="$label" python - <<'PYTHON'
|
||||
import os, re
|
||||
from pathlib import Path
|
||||
source = Path(os.environ.get("OMARCHY_PATH", "")) / "install/provisioning/setup-form.sh"
|
||||
if not source.is_file():
|
||||
raise SystemExit("Set OMARCHY_PATH to the source tree used to build this ISO")
|
||||
text = source.read_text()
|
||||
rows = re.search(r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", text, re.S).group(1).splitlines()
|
||||
labels = [row.split("|")[0] for row in rows]
|
||||
label = os.environ["OMARCHY_TEST_KEYBOARD"]
|
||||
if label not in labels:
|
||||
raise SystemExit(f"Keyboard choice is absent from the ISO source: {label}")
|
||||
print(labels.index(label) - labels.index("English (US)"))
|
||||
PYTHON
|
||||
) || return 1
|
||||
if (( index < 0 )); then
|
||||
direction="up"
|
||||
index=$((-index))
|
||||
fi
|
||||
for (( i = 0; i < index; i++ )); do
|
||||
press "$direction"
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
drive_configurator() {
|
||||
log "Driving the installer wizard"
|
||||
|
||||
@@ -618,23 +690,28 @@ drive_configurator() {
|
||||
capture_console "success-installer-00-greeter"
|
||||
press ret
|
||||
|
||||
# Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning
|
||||
# mode; a normal install just selects the layout.
|
||||
wait_for_screen "keyboard layout" 300
|
||||
# Language comes first: English on top, Chinese below.
|
||||
wait_for_screen "Select language" 300
|
||||
capture_console "success-installer-01-language"
|
||||
if [[ $INSTALL_LANGUAGE == "zh" ]]; then press end; else press home; fi
|
||||
press ret
|
||||
|
||||
# The keyboard follows, for Chinese in cage + foot; Ctrl+C there defers provisioning.
|
||||
wait_for_screen "$(lang_text "keyboard layout" "键盘布局")" 300
|
||||
capture_console "success-installer-01-keyboard-layout"
|
||||
|
||||
if $PROVISION; then
|
||||
press ctrl-c # arm "prepare for another owner"
|
||||
wait_for_screen "another owner" 30
|
||||
wait_for_screen "$(lang_text "another owner" "另一位")" 30
|
||||
capture_console "success-installer-02-prepare-confirm"
|
||||
press ret # "Yes, prepare for another owner" is the affirmative default
|
||||
|
||||
# Jumps straight to disk selection, then the overwrite confirm.
|
||||
wait_for_screen "install disk" 60
|
||||
wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60
|
||||
capture_console "success-installer-03-prepare-install-disk"
|
||||
press ret
|
||||
|
||||
wait_for_screen "recovery possible" 60
|
||||
wait_for_screen "$(lang_text "recovery possible" "无法恢复")" 60
|
||||
capture_console "success-installer-04-prepare-disk-warning-encrypted"
|
||||
if ! $ENCRYPT; then
|
||||
press ctrl-c
|
||||
@@ -651,58 +728,70 @@ drive_configurator() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
press ret # English (US) is preselected
|
||||
if [[ $KEYBOARD_LABEL != "English (US)" ]]; then
|
||||
select_keyboard "$KEYBOARD_LABEL"
|
||||
capture_console "success-installer-01-selected-keyboard"
|
||||
fi
|
||||
press ret
|
||||
|
||||
# Normal flow: user step comes before disk selection.
|
||||
wait_for_screen "Username" 120
|
||||
wait_for_screen "$(lang_text "Username" "用户名")" 120
|
||||
type_text "$GUEST_USER"
|
||||
capture_console "success-installer-02-username"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Password" 60
|
||||
wait_for_screen "$(lang_text "Password" "密码")" 60
|
||||
type_text "$GUEST_PASSWORD"
|
||||
capture_console "success-installer-03-password"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Must match" 60
|
||||
wait_for_screen "$(lang_text "Must match" "确认")" 60
|
||||
type_text "$GUEST_PASSWORD"
|
||||
capture_console "success-installer-04-password-confirmation"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Full name" 60
|
||||
wait_for_screen "$(lang_text "Full name" "姓名")" 60
|
||||
type_text "Omarchy Test"
|
||||
capture_console "success-installer-05-full-name"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Email address" 60
|
||||
type_text "test@omarchy.org"
|
||||
wait_for_screen "$(lang_text "Email address" "邮箱")" 60
|
||||
type_text "test"
|
||||
if [[ $KEYBOARD_LABEL == "Japanese" ]]; then
|
||||
press bracket_left
|
||||
elif [[ $KEYBOARD_LABEL == "English (UK)" ]]; then
|
||||
press shift-apostrophe
|
||||
else
|
||||
press shift-2
|
||||
fi
|
||||
type_text "omarchy.org"
|
||||
capture_console "success-installer-06-email"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Hostname" 60
|
||||
wait_for_screen "$(lang_text "Hostname" "主机名")" 60
|
||||
type_text "$GUEST_HOSTNAME"
|
||||
capture_console "success-installer-07-hostname"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment
|
||||
wait_for_screen "$(lang_text "Timezone" "时区")" 180 # tzupdate geo-guess can take a moment
|
||||
capture_console "success-installer-08-timezone"
|
||||
press ret
|
||||
|
||||
wait_for_screen "look right" 60
|
||||
wait_for_screen "$(lang_text "look right" "正确")" 60
|
||||
capture_console "success-installer-09-review"
|
||||
press ret
|
||||
|
||||
wait_for_screen "install disk" 60
|
||||
wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60
|
||||
capture_console "success-installer-10-install-disk"
|
||||
press ret # the single virtio disk
|
||||
|
||||
wait_for_screen "installation mode\|recovery possible" 60
|
||||
if ocr_screen | grep -qi "installation mode"; then
|
||||
wait_for_screen "$(lang_text "installation mode\|recovery possible" "安装方式\|无法恢复")" 60
|
||||
if ocr_screen | grep -qi "$(lang_text "installation mode" "安装方式")"; then
|
||||
capture_console "success-installer-11-install-mode"
|
||||
press ret # "Full disk install" is preselected
|
||||
fi
|
||||
|
||||
wait_for_screen "recovery possible" 60
|
||||
wait_for_screen "$(lang_text "recovery possible" "无法恢复")" 60
|
||||
capture_console "success-installer-12-disk-warning-encrypted"
|
||||
if ! $ENCRYPT; then
|
||||
press ctrl-c # toggles the confirm to the unencrypted flow
|
||||
@@ -736,8 +825,12 @@ drive_provision_owner() {
|
||||
press ret
|
||||
|
||||
wait_for_screen "keyboard layout" 120
|
||||
press down
|
||||
capture_console "success-provision-01-keyboard-layout-uk"
|
||||
if [[ $KEYBOARD_LABEL == "English (US)" ]]; then
|
||||
press down # preserve the harness's default UK provisioning coverage
|
||||
else
|
||||
select_keyboard "$KEYBOARD_LABEL"
|
||||
fi
|
||||
capture_console "success-provision-01-keyboard-layout"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Username" 120
|
||||
@@ -763,7 +856,13 @@ drive_provision_owner() {
|
||||
# Shift+2 as on US); send that keycode so the typed email is correct under
|
||||
# the new layout — itself further proof the deferred keymap took effect.
|
||||
type_text "test"
|
||||
press shift-apostrophe
|
||||
if [[ $KEYBOARD_LABEL == "English (US)" || $KEYBOARD_LABEL == "English (UK)" ]]; then
|
||||
press shift-apostrophe
|
||||
elif [[ $KEYBOARD_LABEL == "Japanese" ]]; then
|
||||
press bracket_left
|
||||
else
|
||||
press shift-2
|
||||
fi
|
||||
type_text "omarchy.org"
|
||||
capture_console "success-provision-04-email"
|
||||
press ret
|
||||
@@ -796,7 +895,8 @@ wait_for_install() {
|
||||
while true; do
|
||||
text=$(ocr_screen)
|
||||
|
||||
if grep -qi "Reboot Now" <<<"$text"; then
|
||||
# OCR cannot read the Chinese reboot button, so Chinese runs match the title above it.
|
||||
if grep -qi "$(lang_text "Reboot Now" "安装完成")" <<<"$text"; then
|
||||
log "Install finished. Confirming the reboot prompt."
|
||||
capture_console "success-installer-15-reboot"
|
||||
press ret
|
||||
@@ -812,7 +912,7 @@ wait_for_install() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -qi "installation stopped" <<<"$text"; then
|
||||
if grep -qi "$(lang_text "installation stopped" "安装已停止")" <<<"$text"; then
|
||||
capture_console "failure-installer-installation-stopped"
|
||||
echo "Install failed — screenshot saved to $RUN_DIR" >&2
|
||||
return 1
|
||||
|
||||
+58
-32
@@ -4,11 +4,6 @@ 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
|
||||
@@ -28,6 +23,19 @@ esac
|
||||
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
|
||||
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
|
||||
|
||||
# pacman 7 sandboxes its downloads with Landlock and fails every sync on a
|
||||
# kernel without it (Namespace's AmpereOne arm64 machines): "restricting
|
||||
# filesystem access failed because Landlock is not supported by the kernel".
|
||||
# There, every pacman in this container (pacstrap's too) runs with
|
||||
# --disable-sandbox, which pacman provides for exactly this. A wrapper rather
|
||||
# than DisableSandbox in a config: pacman-offline.conf ships on the ISO.
|
||||
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||
echo "No Landlock in this kernel: pacman runs with --disable-sandbox in this container"
|
||||
printf '#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox "$@"\n' >/usr/local/bin/pacman
|
||||
chmod +x /usr/local/bin/pacman
|
||||
hash -r
|
||||
fi
|
||||
|
||||
# Packages installed into the Arch container used to build the ISO.
|
||||
pacman-key --init
|
||||
pacman --noconfirm -Sy archlinux-keyring
|
||||
@@ -46,11 +54,14 @@ pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571
|
||||
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring
|
||||
pacman-key --populate omarchy
|
||||
|
||||
# Append the [omarchy] repo to the container's /etc/pacman.conf so subsequent
|
||||
# tools (notably makepkg in build-omarchy-packages.sh) can resolve omarchy-
|
||||
# only build deps like limine-snapper-sync and limine-mkinitcpio-hook.
|
||||
# Put OPR first in the container too so makepkg resolves the same overrides
|
||||
# as the online installer configuration.
|
||||
if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then
|
||||
awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf >> /etc/pacman.conf
|
||||
opr_section=$(awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf)
|
||||
awk -v opr="$opr_section" '/^\[core\]/ { print opr; print "" } { print }' \
|
||||
/etc/pacman.conf >/tmp/pacman-opr-first.conf
|
||||
install -m 644 /tmp/pacman-opr-first.conf /etc/pacman.conf
|
||||
rm -f /tmp/pacman-opr-first.conf
|
||||
fi
|
||||
|
||||
# Build locations
|
||||
@@ -72,7 +83,6 @@ 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
|
||||
@@ -86,7 +96,6 @@ 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"
|
||||
@@ -125,7 +134,8 @@ cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
|
||||
# The selected omarchy-settings package is needed here so its post_install hook
|
||||
# drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the
|
||||
# live initramfs.
|
||||
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted)
|
||||
# cage and foot give Chinese installs a console that draws Chinese.
|
||||
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted cage foot)
|
||||
printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64"
|
||||
|
||||
# The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so
|
||||
@@ -146,7 +156,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
|
||||
regions_source=/omarchy-source/default/regions
|
||||
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
|
||||
setup_form=/omarchy-source/install/provisioning/setup-form.sh
|
||||
else
|
||||
@@ -163,20 +173,23 @@ 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
|
||||
# build here (set -e) with a bare "Not found in archive" instead of the
|
||||
# actionable error below.
|
||||
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true
|
||||
# Region profiles live under default/, which the settings package ships,
|
||||
# not the runtime. Its own cache dir keeps the runtime lookup above from
|
||||
# ever matching it.
|
||||
settings_cache_dir="$bootstrap_cache_dir/settings"
|
||||
mkdir -p "$settings_cache_dir"
|
||||
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_SETTINGS_PACKAGE" --cachedir "$settings_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
|
||||
regions_source=$(find "$settings_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_SETTINGS_PACKAGE-*.pkg.tar.zst" | sort | head -1)
|
||||
if [[ -z $regions_source ]]; then
|
||||
echo "ERROR: downloaded package for $OMARCHY_SETTINGS_PACKAGE not found in $settings_cache_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh
|
||||
fi
|
||||
|
||||
@@ -184,15 +197,6 @@ 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
|
||||
@@ -212,6 +216,24 @@ if [[ ! -f $setup_form ]]; then
|
||||
fi
|
||||
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
|
||||
|
||||
# Every region's packages ride in the offline mirror, and its community
|
||||
# repositories join a writable copy of the online config to download them.
|
||||
online_config=/tmp/omarchy-pacman-online.conf
|
||||
cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config"
|
||||
bash /builder/prepare-regions.sh "$regions_source" \
|
||||
"$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config"
|
||||
|
||||
# Profiles ship in the settings package, but the finalizer that applies them
|
||||
# ships in the runtime. A runtime without it would ignore the region marker,
|
||||
# leaving regional targets with the keyring but none of the repositories.
|
||||
if [[ -n ${omarchy_pkg:-} && -d $build_cache_dir/airootfs/usr/share/omarchy-iso/regions ]]; then
|
||||
runtime_listing=$(bsdtar -tf "$omarchy_pkg")
|
||||
if ! grep -qx 'usr/bin/omarchy-apply-pacman' <<<"$runtime_listing"; then
|
||||
echo "ERROR: $OMARCHY_SETTINGS_PACKAGE ships region profiles, but $OMARCHY_RUNTIME_PACKAGE lacks omarchy-apply-pacman to apply them." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Collect every package we want available in the offline mirror.
|
||||
declare -a all_packages
|
||||
mapfile -t all_packages < <(
|
||||
@@ -219,6 +241,9 @@ mapfile -t all_packages < <(
|
||||
cat "$build_cache_dir/packages.x86_64"
|
||||
grep -hv '^#\|^$' "${base_pkg_lists[@]}"
|
||||
grep -hv '^#\|^$' /builder/archinstall.packages
|
||||
# Downloaded but not installed: the installer adds a region's packages
|
||||
# only to targets in that region.
|
||||
grep -hsv '^#\|^$' "$build_cache_dir"/airootfs/usr/share/omarchy-iso/regions/*/packages || true
|
||||
# Always include the selected Omarchy packages so the target install can
|
||||
# find the runtime and companion packages in the offline mirror.
|
||||
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"
|
||||
@@ -313,8 +338,9 @@ printf '%s\n' "${required_package_files[@]}" |
|
||||
|
||||
# Rebuild the offline repo db from scratch so size/checksum/depends entries
|
||||
# always reflect only the package files selected for this build.
|
||||
rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files*
|
||||
repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
|
||||
# Every package archive in the mirror, indexed by one repo-add per CPU: one
|
||||
# alone hashes and lists a thousand packages one after another.
|
||||
bash /builder/index-offline-mirror.sh "$offline_mirror_dir"
|
||||
|
||||
# mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the
|
||||
# container (the airootfs path); symlink rather than duplicate.
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Index the offline mirror: <dir>/offline.db.tar.gz and offline.files.tar.gz
|
||||
# from every package in <dir>, the same databases one repo-add call writes.
|
||||
#
|
||||
# index-offline-mirror.sh <dir> [jobs]
|
||||
#
|
||||
# repo-add handles one package at a time, and for each it hashes the archive
|
||||
# and lists its files, which means decompressing all of it. A database is only
|
||||
# a tar of one directory per package, so the packages are dealt out to one
|
||||
# repo-add per CPU, each writing its own database, and the pieces are put
|
||||
# together afterwards.
|
||||
set -euo pipefail
|
||||
|
||||
dir=$(realpath "$1")
|
||||
jobs=${2:-$(nproc)}
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
mapfile -t packages < <(find "$dir" -maxdepth 1 -name '*.pkg.tar.*' ! -name '*.sig' | sort)
|
||||
(( ${#packages[@]} > 0 )) || { echo "index-offline-mirror: no packages in $dir" >&2; exit 1; }
|
||||
(( jobs > ${#packages[@]} )) && jobs=${#packages[@]}
|
||||
(( jobs >= 1 )) || jobs=1
|
||||
|
||||
# Deal the sorted list out round-robin: big and small packages spread evenly.
|
||||
for (( i = 0; i < jobs; i++ )); do mkdir "$work/$i"; done
|
||||
for (( i = 0; i < ${#packages[@]}; i++ )); do
|
||||
printf '%s\0' "${packages[i]}" >>"$work/$(( i % jobs ))/list"
|
||||
done
|
||||
|
||||
pids=()
|
||||
for (( i = 0; i < jobs; i++ )); do
|
||||
xargs -0 -a "$work/$i/list" repo-add -q "$work/$i/offline.db.tar.gz" >"$work/$i/log" 2>&1 &
|
||||
pids+=($!)
|
||||
done
|
||||
failed=0
|
||||
for (( i = 0; i < jobs; i++ )); do
|
||||
if ! wait "${pids[i]}"; then
|
||||
failed=1
|
||||
echo "index-offline-mirror: repo-add failed on part $i:" >&2
|
||||
cat "$work/$i/log" >&2
|
||||
fi
|
||||
done
|
||||
(( failed == 0 )) || exit 1
|
||||
|
||||
mkdir "$work/db" "$work/files"
|
||||
for (( i = 0; i < jobs; i++ )); do
|
||||
bsdtar -xf "$work/$i/offline.db.tar.gz" -C "$work/db"
|
||||
bsdtar -xf "$work/$i/offline.files.tar.gz" -C "$work/files"
|
||||
done
|
||||
|
||||
# One repo-add keeps a single entry per package name. Apart, two versions of
|
||||
# one name would both survive, so that is an error here rather than a choice.
|
||||
entries=$(find "$work/db" -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
names=$(cat "$work"/db/*/desc | awk '/^%NAME%$/ { getline; print }' | sort -u | wc -l)
|
||||
if (( entries != ${#packages[@]} || names != entries )); then
|
||||
echo "index-offline-mirror: ${#packages[@]} packages gave $entries entries for $names names; two versions of one package?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$dir"/offline.db* "$dir"/offline.files*
|
||||
for kind in db files; do
|
||||
find "$work/$kind" -mindepth 1 -maxdepth 1 -type d -printf '%P\n' | sort |
|
||||
bsdtar -czf "$dir/offline.$kind.tar.gz" -C "$work/$kind" -T -
|
||||
ln -s "offline.$kind.tar.gz" "$dir/offline.$kind"
|
||||
done
|
||||
echo "Indexed ${#packages[@]} packages with $jobs repo-add processes."
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ship every region profile from the bundled runtime, so one ISO serves every
|
||||
# region. The installer picks a profile from the target's timezone; this only
|
||||
# stages the profiles and makes their packages downloadable. Repository
|
||||
# defaults stay in the runtime, not in a second set of ISO-specific templates.
|
||||
set -euo pipefail
|
||||
|
||||
regions_source="$1"
|
||||
iso_payload="$2"
|
||||
online_config="$3"
|
||||
|
||||
# The source is a checkout's default/regions, or the published settings
|
||||
# package that ships default/. One predating region profiles ships none; the
|
||||
# ISO then installs global targets only, exactly as before. Anything else that
|
||||
# fails here, such as an unreadable package, aborts the build.
|
||||
regions="$regions_source"
|
||||
if [[ -f $regions_source ]]; then
|
||||
member=usr/share/omarchy/default/regions
|
||||
listing=$(bsdtar -tf "$regions_source")
|
||||
if grep -q "^$member/" <<<"$listing"; then
|
||||
extracted=$(mktemp -d)
|
||||
trap 'rm -rf "$extracted"' EXIT
|
||||
bsdtar -xf "$regions_source" -C "$extracted" "$member"
|
||||
regions="$extracted/$member"
|
||||
fi
|
||||
fi
|
||||
if [[ ! -d $regions ]]; then
|
||||
echo "Runtime ships no region profiles; every install will be global."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp -a "$regions" "$iso_payload/regions"
|
||||
|
||||
for profile in "$regions"/*/; do
|
||||
# Only the community repositories are needed to download regional packages.
|
||||
# Keep the channel's build mirrors intact; target mirrors are set later by
|
||||
# the runtime's own pacman finalizer.
|
||||
printf '\n' >> "$online_config"
|
||||
cat "$profile/pacman/pacman.conf.append" >> "$online_config"
|
||||
|
||||
# Each keyring package must be signed by a key Arch already trusts. Do not
|
||||
# bootstrap trust from an unchecked keyserver response or disable signatures.
|
||||
# https://github.com/archlinuxcn/repo#usage
|
||||
mapfile -t keyring_packages < <(grep -hv '^#\|^$' "$profile/packages" | grep -- '-keyring$' || true)
|
||||
if (( ${#keyring_packages[@]} > 0 )); then
|
||||
pacman --config "$online_config" --noconfirm -Sy --needed "${keyring_packages[@]}"
|
||||
pacman-key --populate "${keyring_packages[@]%-keyring}"
|
||||
fi
|
||||
done
|
||||
@@ -10,7 +10,10 @@
|
||||
# - COLUMNS/LINES so gum picks up real terminal size
|
||||
set -euo pipefail
|
||||
|
||||
[[ $(tty) == /dev/tty1 ]] || exit 0
|
||||
[[ $(tty) == /dev/tty1 || -n ${OMARCHY_CJK_CONSOLE:-} ]] || exit 0
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
touch "$XDG_RUNTIME_DIR/cjk-console-started"
|
||||
fi
|
||||
|
||||
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
|
||||
if [[ -f /root/omarchy_iso_ref ]]; then
|
||||
@@ -39,7 +42,8 @@ set_tokyo_night_colors() {
|
||||
echo -en "\033[0m"
|
||||
clear
|
||||
}
|
||||
set_tokyo_night_colors
|
||||
# foot sets the same palette from foot.ini; these escapes are VT-only.
|
||||
[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] || set_tokyo_night_colors
|
||||
|
||||
mkdir -p /var/log
|
||||
touch "$OMARCHY_INSTALL_LOG_FILE"
|
||||
@@ -90,6 +94,16 @@ warm_offline_mirror &
|
||||
warm_pid=$!
|
||||
trap 'kill "$warm_pid" 2>/dev/null' EXIT
|
||||
|
||||
# The CJK console's font comes from the offline mirror's noto-fonts-cjk, not the live image.
|
||||
unpack_cjk_font() {
|
||||
local packages=(/var/cache/omarchy/mirror/offline/noto-fonts-cjk-*.pkg.tar.zst)
|
||||
if ((${#packages[@]} != 1)) || [[ ! -f ${packages[0]} ]]; then
|
||||
echo "Expected one noto-fonts-cjk package in the offline mirror, found: ${packages[*]}" >&2
|
||||
return 1
|
||||
fi
|
||||
bsdtar -xf "${packages[0]}" -C / usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc
|
||||
}
|
||||
|
||||
cd /root
|
||||
# Autoinstall: a cidata drive carrying the configurator's own output files
|
||||
# stands in for the wizard. omarchy-cidata-load copies them into /root and
|
||||
@@ -98,7 +112,43 @@ if /usr/local/bin/omarchy-cidata-load; then
|
||||
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
|
||||
export OMARCHY_UI_INTERACTIVE=no
|
||||
else
|
||||
./configurator
|
||||
# Chinese moves to cage + foot, which draw Chinese; English moves back to the VT, which applies keymaps.
|
||||
cjk_relaunch=10
|
||||
vt_return=11
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
export OMARCHY_VT_RETURN=$vt_return
|
||||
elif command -v cage >/dev/null; then
|
||||
export OMARCHY_CJK_RELAUNCH=$cjk_relaunch
|
||||
fi
|
||||
./configurator && status=0 || status=$?
|
||||
while ((status == cjk_relaunch)); do
|
||||
cjk_runtime=/run/omarchy-cjk-console
|
||||
mkdir -p -m 700 "$cjk_runtime"
|
||||
rm -f "$cjk_runtime/cjk-console-started" "$cjk_runtime/return-to-vt"
|
||||
if ! unpack_cjk_font; then
|
||||
echo "The Chinese font could not be unpacked from the offline mirror; continuing in English."
|
||||
unset OMARCHY_CJK_RELAUNCH
|
||||
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
|
||||
continue
|
||||
fi
|
||||
cage_status=0
|
||||
env -u OMARCHY_CJK_RELAUNCH XDG_RUNTIME_DIR=$cjk_runtime OMARCHY_CJK_CONSOLE=1 OMARCHY_INSTALL_LANGUAGE=zh \
|
||||
LANG=C.UTF-8 WLR_RENDERER=pixman cage -- foot -c /usr/share/omarchy-iso/foot.ini "$0" || cage_status=$?
|
||||
if [[ -f $cjk_runtime/return-to-vt ]]; then
|
||||
OMARCHY_INSTALL_LANGUAGE=en ./configurator && status=0 || status=$?
|
||||
elif [[ -f $cjk_runtime/cjk-console-started ]]; then
|
||||
exit "$cage_status"
|
||||
else
|
||||
echo "The Chinese console could not start (cage exit $cage_status); continuing in English."
|
||||
unset OMARCHY_CJK_RELAUNCH
|
||||
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
|
||||
fi
|
||||
done
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && ((status == vt_return)); then
|
||||
touch "$XDG_RUNTIME_DIR/return-to-vt"
|
||||
exit 0
|
||||
fi
|
||||
((status == 0)) || exit "$status"
|
||||
fi
|
||||
|
||||
# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on
|
||||
@@ -111,6 +161,11 @@ if [[ -f /root/defer-provisioning ]] ||
|
||||
export OMARCHY_UI_DEFER_PROVISIONING=yes
|
||||
fi
|
||||
|
||||
# Only Chinese reaches the install from the CJK console; English returns to the VT.
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
export OMARCHY_INSTALL_LANG=zh
|
||||
fi
|
||||
|
||||
# The foreground dashboard is now the sole visible install UI owner. It starts
|
||||
# the actual installer as a non-interactive child, logs child output, waits for
|
||||
# completion, then renders the final installed-time/reboot prompt itself.
|
||||
|
||||
@@ -24,6 +24,72 @@ if [[ ! -r $SETUP_FORM ]]; then
|
||||
fi
|
||||
source "$SETUP_FORM"
|
||||
|
||||
# Chinese text for OMARCHY_INSTALL_LANG=zh; disk-step failures stay English for support logs.
|
||||
OMARCHY_ZH+=(
|
||||
["Aborted installation"]="安装已中止"
|
||||
["You can retry later by running: ./.automated_script.sh"]="稍后可运行 ./.automated_script.sh 重试"
|
||||
["Let's setup your machine..."]="开始设置这台机器……"
|
||||
["Press Ctrl+C to prepare this machine for another owner."]="按 Ctrl+C 为另一位使用者准备这台机器。"
|
||||
["This prepares the machine for another owner."]="这会为另一位使用者准备这台机器。"
|
||||
["The system installs now, but setup is delayed until first boot."]="系统现在安装,设置留到首次开机。"
|
||||
["Yes, prepare for another owner"]="是,为他人准备"
|
||||
["No, keep setting up"]="否,继续设置"
|
||||
["Prepare this machine for another owner?"]="为另一位使用者准备这台机器?"
|
||||
["Let's setup your user account..."]="开始设置用户账户……"
|
||||
["Field"]="项目"
|
||||
["Value"]="值"
|
||||
["Username"]="用户名"
|
||||
["Password"]="密码"
|
||||
["Full name"]="姓名"
|
||||
["Email address"]="邮箱"
|
||||
["Hostname"]="主机名"
|
||||
["Keyboard"]="键盘"
|
||||
["Region"]="区域"
|
||||
["[Skipped]"]="[已跳过]"
|
||||
["Yes"]="是"
|
||||
["No, change it"]="否,去修改"
|
||||
["Does this look right?"]="以上信息正确吗?"
|
||||
["Not enough free space on %s"]="%s 的空闲空间不足"
|
||||
["%s has %s of usable free space; Omarchy needs at least 32GB."]="%s 有 %s 可用空闲空间;Omarchy 至少需要 32GB。"
|
||||
["Open the partition tool to free at least 32GB on %s, then try again."]="打开分区工具,在 %s 上腾出至少 32GB,然后重试。"
|
||||
["Back"]="返回"
|
||||
["Open partition tool"]="打开分区工具"
|
||||
["Return to installation mode?"]="返回安装方式选择?"
|
||||
["Checking for BitLocker on %s"]="检查 %s 上的 BitLocker"
|
||||
["BitLocker signature detected on %s."]="在 %s 上发现 BitLocker 签名。"
|
||||
["Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."]="在 Windows 中关闭 BitLocker,等磁盘解密完成后重试。"
|
||||
["Suspending BitLocker is not enough — the drive stays encrypted."]="只暂停 BitLocker 不够,磁盘仍是加密的。"
|
||||
["Aborted: BitLocker is enabled on this disk."]="已中止:这块磁盘开启了 BitLocker。"
|
||||
["Checking existing EFI partitions on %s"]="检查 %s 上已有的 EFI 分区"
|
||||
["Found a Windows ESP at %s — leaving it untouched."]="在 %s 发现 Windows ESP,保持不动。"
|
||||
["Omarchy will create its own dedicated EFI partition in free space."]="Omarchy 会在空闲空间中创建自己的 EFI 分区。"
|
||||
["A new EFI partition will be created in free space."]="会在空闲空间中新建 EFI 分区。"
|
||||
["Analyzing free space on %s"]="分析 %s 的空闲空间"
|
||||
["Install Omarchy in the %s of free space."]="在 %s 空闲空间中安装 Omarchy。"
|
||||
["Press Ctrl+C for unencrypted install."]="按 Ctrl+C 改为不加密安装。"
|
||||
["Yes, install"]="是,开始安装"
|
||||
["Yes, install without encryption"]="是,不加密安装"
|
||||
["Confirm installing on %s"]="确认安装到 %s"
|
||||
["Creating partitions on %s"]="在 %s 上创建分区"
|
||||
["Setting up LUKS2 on %s"]="在 %s 上设置 LUKS2 加密"
|
||||
["Creating Btrfs filesystem and subvolumes"]="创建 Btrfs 文件系统和子卷"
|
||||
["Let's select where to install Omarchy..."]="选择 Omarchy 的安装位置……"
|
||||
["Select install disk"]="选择安装磁盘"
|
||||
["Partition tool for %s"]="%s 的分区工具"
|
||||
["Create unallocated free space for Omarchy, then write changes and quit."]="为 Omarchy 腾出未分配空间,然后写入更改并退出。"
|
||||
["Do not create an Omarchy partition here — leave the target area as Free space."]="不要在这里创建 Omarchy 分区,目标区域保持为 Free space。"
|
||||
["Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."]="提示:空闲空间安装至少需要 32GB 未分配空间;没有 ESP 时再加 2GB。"
|
||||
["Open cfdisk"]="打开 cfdisk"
|
||||
["Open partition tool for %s?"]="打开 %s 的分区工具?"
|
||||
["Full disk install"]="整盘安装"
|
||||
["Free space install (alongside existing data)"]="空闲空间安装(保留现有数据)"
|
||||
["Choose a different disk"]="换一块磁盘"
|
||||
["Let's select how to install Omarchy..."]="选择 Omarchy 的安装方式……"
|
||||
["Select installation mode on %s"]="选择 %s 上的安装方式"
|
||||
["Everything will be overwritten. There is no recovery possible."]="磁盘上的一切都会被覆盖,无法恢复。"
|
||||
["Confirm overwriting %s"]="确认覆盖 %s"
|
||||
)
|
||||
|
||||
# Partition creation helpers, shared with test/partition-numbering-test.sh so
|
||||
# the numbering logic can be exercised without a real disk.
|
||||
DISK_PARTITIONING="${DISK_PARTITIONING:-/usr/share/omarchy-iso/disk-partitioning.sh}"
|
||||
@@ -176,9 +242,9 @@ greeter() {
|
||||
}
|
||||
|
||||
abort() {
|
||||
gum style "${1:-Aborted installation}"
|
||||
gum style "${1:-$(omarchy_text "Aborted installation")}"
|
||||
echo
|
||||
gum style "You can retry later by running: ./.automated_script.sh"
|
||||
gum style "$(omarchy_text "You can retry later by running: ./.automated_script.sh")"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -203,6 +269,30 @@ notice() {
|
||||
|
||||
# STEP 1: KEYBOARD LAYOUT
|
||||
|
||||
# The target's LANG follows the language picked on the first screen.
|
||||
target_lang() {
|
||||
if [[ $language == "zh" ]]; then
|
||||
echo zh_CN.UTF-8
|
||||
else
|
||||
echo en_US.UTF-8
|
||||
fi
|
||||
}
|
||||
|
||||
# .automated_script.sh moves Chinese into the CJK console and English back to the VT, which applies keymaps.
|
||||
apply_language() {
|
||||
if [[ $language == "zh" && -n ${OMARCHY_CJK_RELAUNCH:-} ]]; then
|
||||
exit "$OMARCHY_CJK_RELAUNCH"
|
||||
fi
|
||||
if [[ $language == "en" && -n ${OMARCHY_VT_RETURN:-} ]]; then
|
||||
exit "$OMARCHY_VT_RETURN"
|
||||
fi
|
||||
if [[ $language == "zh" && -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
export OMARCHY_INSTALL_LANG=zh
|
||||
else
|
||||
unset OMARCHY_INSTALL_LANG
|
||||
fi
|
||||
}
|
||||
|
||||
keyboard_form() {
|
||||
# Ctrl+C is the hidden entry into deferred provisioning, offered on every
|
||||
# visit: the user step unwinds back here and nothing is on disk yet. Callers
|
||||
@@ -212,11 +302,24 @@ keyboard_form() {
|
||||
while true; do
|
||||
clear_logo
|
||||
echo
|
||||
say "Let's setup your machine..."
|
||||
say --foreground 8 "Press Ctrl+C to prepare this machine for another owner."
|
||||
say "$(omarchy_text "Let's setup your machine...")"
|
||||
say --foreground 8 "$(omarchy_text "Press Ctrl+C to prepare this machine for another owner.")"
|
||||
echo
|
||||
|
||||
omarchy_prompt_keyboard && status=0 || status=$?
|
||||
status=0
|
||||
if ! $language_chosen; then
|
||||
omarchy_prompt_language && status=0 || status=$?
|
||||
((status == 0)) && apply_language
|
||||
fi
|
||||
# Esc, Ctrl+C or a later "No, change it" asks the language again.
|
||||
language_chosen=false
|
||||
if ((status == 0)); then
|
||||
local layouts=$OMARCHY_KEYBOARD_LAYOUTS selected="English (US)"
|
||||
[[ $language == "zh" ]] && selected="Chinese (Simplified, Pinyin)"
|
||||
# cage keeps its US layout, so the CJK console offers only US-keymap keyboards.
|
||||
[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && layouts=$(awk -F'|' '$2 == "us"' <<<"$layouts")
|
||||
OMARCHY_KEYBOARD_LAYOUTS=$layouts omarchy_prompt_keyboard "$selected" && status=0 || status=$?
|
||||
fi
|
||||
((status == 0)) && break
|
||||
|
||||
# Esc means "back", and nothing precedes the first screen, so re-ask.
|
||||
@@ -245,11 +348,11 @@ keyboard_form() {
|
||||
confirm_prepare_for_another_owner() {
|
||||
clear_logo
|
||||
echo
|
||||
say "This prepares the machine for another owner."
|
||||
say --foreground 8 "The system installs now, but setup is delayed until first boot."
|
||||
say "$(omarchy_text "This prepares the machine for another owner.")"
|
||||
say --foreground 8 "$(omarchy_text "The system installs now, but setup is delayed until first boot.")"
|
||||
echo
|
||||
gum confirm --affirmative "Yes, prepare for another owner" --negative "No, keep setting up" \
|
||||
"Prepare this machine for another owner?"
|
||||
gum confirm --affirmative "$(omarchy_text "Yes, prepare for another owner")" --negative "$(omarchy_text "No, keep setting up")" \
|
||||
"$(omarchy_text "Prepare this machine for another owner?")"
|
||||
}
|
||||
|
||||
# The user step. Deferred-provisioning installs skip it entirely — the machine's first owner
|
||||
@@ -257,7 +360,7 @@ confirm_prepare_for_another_owner() {
|
||||
# operator sets nothing user-specific.
|
||||
|
||||
user_form() {
|
||||
step "Let's setup your user account..."
|
||||
step "$(omarchy_text "Let's setup your user account...")"
|
||||
|
||||
# Each prompt reports 0 (set), OMARCHY_FORM_BACK (Esc), or OMARCHY_FORM_SIGNAL
|
||||
# (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they
|
||||
@@ -290,19 +393,29 @@ user_step() {
|
||||
continue
|
||||
fi
|
||||
|
||||
# The timezone selects the region (mirrors and repositories), using the
|
||||
# same lookup as the orchestrator. Global installs get no extra row.
|
||||
region=$(cd /usr/share/omarchy-iso 2>/dev/null && python -m orchestrator.region "$timezone") || region="global"
|
||||
region_row=""
|
||||
if [[ $region != "global" ]]; then
|
||||
region_row="\n$(omarchy_text Region),$region"
|
||||
fi
|
||||
|
||||
# Add manual padding since gum table -p doesn't respect padding
|
||||
echo -e "Field,Value
|
||||
Username,$username
|
||||
Password,$(printf "%${#password}s" | tr ' ' '*')
|
||||
Full name,${full_name:-[Skipped]}
|
||||
Email address,${email_address:-[Skipped]}
|
||||
Hostname,$hostname
|
||||
Timezone,$timezone
|
||||
Keyboard,$keyboard" |
|
||||
local skipped
|
||||
skipped=$(omarchy_text "[Skipped]")
|
||||
echo -e "$(omarchy_text Field),$(omarchy_text Value)
|
||||
$(omarchy_text Username),$username
|
||||
$(omarchy_text Password),$(printf "%${#password}s" | tr ' ' '*')
|
||||
$(omarchy_text "Full name"),${full_name:-$skipped}
|
||||
$(omarchy_text "Email address"),${email_address:-$skipped}
|
||||
$(omarchy_text Hostname),$hostname
|
||||
$(omarchy_text Timezone),$timezone$region_row
|
||||
$(omarchy_text Keyboard),\"$keyboard_label\"" |
|
||||
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
|
||||
|
||||
echo
|
||||
if gum confirm --negative "No, change it" "Does this look right?"; then
|
||||
if gum confirm --affirmative "$(omarchy_text "Yes")" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Does this look right?")"; then
|
||||
break
|
||||
else
|
||||
keyboard_form
|
||||
@@ -504,11 +617,11 @@ print_dry_run_files() {
|
||||
# action so users have a way to actually fix the situation.
|
||||
not_enough_space() {
|
||||
local available="${1:-0}"
|
||||
step "Not enough free space on $disk"
|
||||
say --foreground 1 "$disk has $(to_gb $available) of usable free space; Omarchy needs at least 32GB."
|
||||
say "Open the partition tool to free at least 32GB on $disk, then try again."
|
||||
step "$(omarchy_text "Not enough free space on %s" "$disk")"
|
||||
say --foreground 1 "$(omarchy_text "%s has %s of usable free space; Omarchy needs at least 32GB." "$disk" "$(to_gb $available)")"
|
||||
say "$(omarchy_text "Open the partition tool to free at least 32GB on %s, then try again." "$disk")"
|
||||
echo
|
||||
if ! gum confirm --affirmative "Back" --negative "Open partition tool" "Return to installation mode?"; then
|
||||
if ! gum confirm --affirmative "$(omarchy_text "Back")" --negative "$(omarchy_text "Open partition tool")" "$(omarchy_text "Return to installation mode?")"; then
|
||||
open_partition_tool
|
||||
fi
|
||||
return 1
|
||||
@@ -518,14 +631,14 @@ not_enough_space() {
|
||||
# encryption confirm. Sets the globals run_partition_execute needs. Returns 1
|
||||
# to fall back to the install-mode picker.
|
||||
run_partition_decide() {
|
||||
step "Checking for BitLocker on $disk"
|
||||
step "$(omarchy_text "Checking for BitLocker on %s" "$disk")"
|
||||
local bl_part
|
||||
bl_part=$(detect_bitlocker || true)
|
||||
if [[ -n "$bl_part" ]]; then
|
||||
say --foreground 1 "BitLocker signature detected on $bl_part."
|
||||
say "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."
|
||||
say "Suspending BitLocker is not enough — the drive stays encrypted."
|
||||
abort "Aborted: BitLocker is enabled on this disk."
|
||||
say --foreground 1 "$(omarchy_text "BitLocker signature detected on %s." "$bl_part")"
|
||||
say "$(omarchy_text "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again.")"
|
||||
say "$(omarchy_text "Suspending BitLocker is not enough — the drive stays encrypted.")"
|
||||
abort "$(omarchy_text "Aborted: BitLocker is enabled on this disk.")"
|
||||
fi
|
||||
|
||||
# Omarchy always creates its own dedicated ESP in free space and never
|
||||
@@ -534,17 +647,17 @@ run_partition_decide() {
|
||||
# Windows ESP is far too small for our Unified Kernel Images, and a shared
|
||||
# ESP forces the install unencrypted. A separate Linux ESP keeps Windows
|
||||
# and Omarchy fully isolated and keeps LUKS on the table.
|
||||
step "Checking existing EFI partitions on $disk"
|
||||
step "$(omarchy_text "Checking existing EFI partitions on %s" "$disk")"
|
||||
local detected_win_esp
|
||||
detected_win_esp=$(detect_windows_esp || true)
|
||||
if [[ -n "$detected_win_esp" ]]; then
|
||||
say "Found a Windows ESP at $detected_win_esp — leaving it untouched."
|
||||
say "Omarchy will create its own dedicated EFI partition in free space."
|
||||
say "$(omarchy_text "Found a Windows ESP at %s — leaving it untouched." "$detected_win_esp")"
|
||||
say "$(omarchy_text "Omarchy will create its own dedicated EFI partition in free space.")"
|
||||
else
|
||||
say "A new EFI partition will be created in free space."
|
||||
say "$(omarchy_text "A new EFI partition will be created in free space.")"
|
||||
fi
|
||||
|
||||
step "Analyzing free space on $disk"
|
||||
step "$(omarchy_text "Analyzing free space on %s" "$disk")"
|
||||
partprobe "$disk" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
@@ -619,21 +732,21 @@ run_partition_decide() {
|
||||
while true; do
|
||||
clear_logo
|
||||
echo
|
||||
say "Install Omarchy in the $(to_gb $INSTALL_MAX_B) of free space."
|
||||
say "$(omarchy_text "Install Omarchy in the %s of free space." "$(to_gb $INSTALL_MAX_B)")"
|
||||
|
||||
case $mode in
|
||||
encrypted)
|
||||
say --foreground 8 "Press Ctrl+C for unencrypted install."
|
||||
affirmative="Yes, install"
|
||||
say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")"
|
||||
affirmative="$(omarchy_text "Yes, install")"
|
||||
;;
|
||||
unencrypted)
|
||||
affirmative="Yes, install without encryption"
|
||||
affirmative="$(omarchy_text "Yes, install without encryption")"
|
||||
;;
|
||||
esac
|
||||
echo
|
||||
|
||||
gum confirm --affirmative "$affirmative" --negative "No, change it" \
|
||||
"Confirm installing on $disk"
|
||||
gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" \
|
||||
"$(omarchy_text "Confirm installing on %s" "$disk")"
|
||||
confirm_status=$?
|
||||
|
||||
case $confirm_status in
|
||||
@@ -691,7 +804,7 @@ run_partition_execute() {
|
||||
say "[dry] partition numbers are assigned by parted at creation, then read back"
|
||||
say "[dry] encrypted=$encrypt_installation kernel=$kernel_choice"
|
||||
else
|
||||
step "Creating partitions on $disk"
|
||||
step "$(omarchy_text "Creating partitions on %s" "$disk")"
|
||||
if $needs_mklabel; then
|
||||
disk_step "initializing GPT on $disk" parted --script "$disk" mklabel gpt
|
||||
partprobe "$disk" 2>/dev/null || true
|
||||
@@ -729,7 +842,7 @@ run_partition_execute() {
|
||||
disk_step "clearing stale signatures on $root_partition_device" wipefs -af "$root_partition_device"
|
||||
|
||||
if [[ "$encrypt_installation" == "true" ]]; then
|
||||
step "Setting up LUKS2 on $root_partition_device"
|
||||
step "$(omarchy_text "Setting up LUKS2 on %s" "$root_partition_device")"
|
||||
# Kept as pipes rather than routed through disk_step: the passphrase must
|
||||
# not become an argv the process table can show. Folding stderr into
|
||||
# stdout still lands any error in the install log.
|
||||
@@ -746,7 +859,7 @@ run_partition_execute() {
|
||||
root_mapper="$root_partition_device"
|
||||
fi
|
||||
|
||||
step "Creating Btrfs filesystem and subvolumes"
|
||||
step "$(omarchy_text "Creating Btrfs filesystem and subvolumes")"
|
||||
disk_step "creating the Btrfs filesystem on $root_mapper" \
|
||||
mkfs.btrfs -f -L OMARCHY "$root_mapper"
|
||||
wait_for_device "$root_mapper" || disk_abort_hook "Root device $root_mapper never appeared"
|
||||
@@ -800,6 +913,8 @@ run_partition_execute() {
|
||||
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
|
||||
"custom_commands": [],
|
||||
"omarchy_install": {
|
||||
"input_method": "${keyboard_input_method:-none}",
|
||||
"input_xkb_layout": "${keyboard_xkb_layout:-}",
|
||||
"mode": "protected",
|
||||
"target_mount": "/mnt",
|
||||
"boot": {
|
||||
@@ -832,7 +947,7 @@ run_partition_execute() {
|
||||
"locale_config": {
|
||||
"kb_layout": "$keyboard",
|
||||
"sys_enc": "UTF-8",
|
||||
"sys_lang": "en_US.UTF-8"
|
||||
"sys_lang": "$(target_lang)"
|
||||
},
|
||||
"mirror_config": {
|
||||
"custom_repositories": [],
|
||||
@@ -862,7 +977,7 @@ _EOF_
|
||||
}
|
||||
|
||||
disk_form() {
|
||||
step "Let's select where to install Omarchy..."
|
||||
step "$(omarchy_text "Let's select where to install Omarchy...")"
|
||||
|
||||
# Don't offer the install media as an option (Arch ISO mounts it here)
|
||||
local boot_source
|
||||
@@ -888,19 +1003,19 @@ disk_form() {
|
||||
fi
|
||||
done <<<"$available_disks"
|
||||
|
||||
selected_display=$(echo "$disk_options" | gum choose --header "Select install disk") || abort
|
||||
selected_display=$(echo "$disk_options" | gum choose --header "$(omarchy_text "Select install disk")") || abort
|
||||
disk=$(echo "$selected_display" | awk '{print $1}')
|
||||
}
|
||||
|
||||
# STEP 4: INSTALL MODE
|
||||
|
||||
open_partition_tool() {
|
||||
step "Partition tool for $disk"
|
||||
gum style "Create unallocated free space for Omarchy, then write changes and quit."
|
||||
gum style --foreground 8 "Do not create an Omarchy partition here — leave the target area as Free space."
|
||||
gum style --foreground 8 "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."
|
||||
step "$(omarchy_text "Partition tool for %s" "$disk")"
|
||||
gum style "$(omarchy_text "Create unallocated free space for Omarchy, then write changes and quit.")"
|
||||
gum style --foreground 8 "$(omarchy_text "Do not create an Omarchy partition here — leave the target area as Free space.")"
|
||||
gum style --foreground 8 "$(omarchy_text "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists.")"
|
||||
echo
|
||||
gum confirm --affirmative "Open cfdisk" --negative "Back" "Open partition tool for $disk?" || return 0
|
||||
gum confirm --affirmative "$(omarchy_text "Open cfdisk")" --negative "$(omarchy_text "Back")" "$(omarchy_text "Open partition tool for %s?" "$disk")" || return 0
|
||||
|
||||
clear
|
||||
cfdisk "$disk" || true
|
||||
@@ -933,17 +1048,17 @@ requires_full_disk_install() {
|
||||
}
|
||||
|
||||
install_mode_form() {
|
||||
local choices=("Full disk install")
|
||||
local choices=("$(omarchy_text "Full disk install")")
|
||||
|
||||
# The free-space/protected path registers an EFI boot entry and is UEFI-only.
|
||||
# Full-disk installs still support BIOS through the orchestrator's BIOS branch.
|
||||
if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then
|
||||
choices+=("Free space install (alongside existing data)")
|
||||
choices+=("$(omarchy_text "Free space install (alongside existing data)")")
|
||||
fi
|
||||
choices+=("Choose a different disk")
|
||||
choices+=("$(omarchy_text "Choose a different disk")")
|
||||
|
||||
step "Let's select how to install Omarchy..."
|
||||
install_mode=$(gum choose --header "Select installation mode on $disk" "${choices[@]}") || abort
|
||||
step "$(omarchy_text "Let's select how to install Omarchy...")"
|
||||
install_mode=$(gum choose --header "$(omarchy_text "Select installation mode on %s" "$disk")" "${choices[@]}") || abort
|
||||
}
|
||||
|
||||
confirm_disk_overwrite() {
|
||||
@@ -953,17 +1068,17 @@ confirm_disk_overwrite() {
|
||||
while true; do
|
||||
clear_logo
|
||||
echo
|
||||
say "Everything will be overwritten. There is no recovery possible."
|
||||
say "$(omarchy_text "Everything will be overwritten. There is no recovery possible.")"
|
||||
|
||||
if [[ $mode == "encrypted" ]]; then
|
||||
say --foreground 8 "Press Ctrl+C for unencrypted install."
|
||||
affirmative="Yes, install"
|
||||
say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")"
|
||||
affirmative="$(omarchy_text "Yes, install")"
|
||||
else
|
||||
affirmative="Yes, install without encryption"
|
||||
affirmative="$(omarchy_text "Yes, install without encryption")"
|
||||
fi
|
||||
|
||||
echo
|
||||
gum confirm --affirmative "$affirmative" --negative "No, change it" "Confirm overwriting ${disk}"
|
||||
gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Confirm overwriting %s" "$disk")"
|
||||
confirm_status=$?
|
||||
|
||||
case $confirm_status in
|
||||
@@ -1002,13 +1117,13 @@ select_installation() {
|
||||
# With no free-space option to offer, a full-disk install is the only
|
||||
# mode, so skip the picker and go straight to the overwrite confirm.
|
||||
if $full_disk_only; then
|
||||
install_mode="Full disk install"
|
||||
install_mode=$(omarchy_text "Full disk install")
|
||||
else
|
||||
install_mode_form
|
||||
fi
|
||||
|
||||
case "$install_mode" in
|
||||
"Full disk install")
|
||||
"$(omarchy_text "Full disk install")")
|
||||
if confirm_disk_overwrite; then
|
||||
install_target="full_disk"
|
||||
return 0
|
||||
@@ -1017,13 +1132,13 @@ select_installation() {
|
||||
# wants a different disk.
|
||||
$full_disk_only && disk_form
|
||||
;;
|
||||
"Free space install"*)
|
||||
"$(omarchy_text "Free space install (alongside existing data)")")
|
||||
if run_partition_decide; then
|
||||
install_target="free_space"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
"Choose a different disk")
|
||||
"$(omarchy_text "Choose a different disk")")
|
||||
disk_form
|
||||
;;
|
||||
esac
|
||||
@@ -1034,12 +1149,20 @@ select_installation() {
|
||||
|
||||
defer_provisioning=false
|
||||
install_target="full_disk"
|
||||
# A rerun arrives with the language chosen; otherwise English.
|
||||
language="${OMARCHY_INSTALL_LANGUAGE:-en}"
|
||||
language_chosen=false
|
||||
|
||||
# Let the live VT reach its settled width before the first screen so the logo
|
||||
# isn't measured against the transient 80-column boot console.
|
||||
wait_for_stable_terminal
|
||||
|
||||
greeter
|
||||
if [[ -n ${OMARCHY_INSTALL_LANGUAGE:-} ]]; then
|
||||
apply_language
|
||||
language_chosen=true
|
||||
else
|
||||
greeter
|
||||
fi
|
||||
|
||||
keyboard_form
|
||||
|
||||
@@ -1056,6 +1179,8 @@ if $defer_provisioning; then
|
||||
# their real layout in the OOBE, which re-keys the console and, on encrypted
|
||||
# installs, rebuilds the initramfs so the LUKS prompt matches.
|
||||
keyboard="us"
|
||||
keyboard_input_method="none"
|
||||
keyboard_xkb_layout=""
|
||||
username=""
|
||||
password=""
|
||||
password_hash=""
|
||||
@@ -1138,6 +1263,8 @@ cat <<-_EOF_ >user_configuration.json
|
||||
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
|
||||
"custom_commands": [],
|
||||
"omarchy_install": {
|
||||
"input_method": "${keyboard_input_method:-none}",
|
||||
"input_xkb_layout": "${keyboard_xkb_layout:-}",
|
||||
"mode": "full_disk",
|
||||
"defer_provisioning": $defer_provisioning,
|
||||
"target_mount": "/mnt",
|
||||
@@ -1221,7 +1348,7 @@ cat <<-_EOF_ >user_configuration.json
|
||||
"locale_config": {
|
||||
"kb_layout": "$keyboard",
|
||||
"sys_enc": "UTF-8",
|
||||
"sys_lang": "en_US.UTF-8"
|
||||
"sys_lang": "$(target_lang)"
|
||||
},
|
||||
"mirror_config": {
|
||||
"custom_repositories": [],
|
||||
|
||||
@@ -88,6 +88,81 @@ tips=(
|
||||
"Keep the system fresh with Update in the Omarchy menu"
|
||||
)
|
||||
|
||||
# omarchy_text and its Chinese table come from the setup form the configurator uses.
|
||||
SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}"
|
||||
[[ -r $SETUP_FORM ]] || SETUP_FORM="${OMARCHY_PATH:-/usr/share/omarchy}/install/provisioning/setup-form.sh"
|
||||
source "$SETUP_FORM"
|
||||
OMARCHY_ZH+=(
|
||||
["Tip: %s"]="提示:%s"
|
||||
["Installed Omarchy"]="Omarchy 安装完成"
|
||||
["Installed Omarchy in %s"]="Omarchy 安装完成,用时 %s"
|
||||
["Reboot Now"]="立即重启"
|
||||
["Last log lines:"]="日志末尾:"
|
||||
["Last target log lines:"]="目标系统日志末尾:"
|
||||
["Press Enter to continue…"]="按回车继续……"
|
||||
["No log uploader is available in this environment."]="这个环境没有日志上传工具。"
|
||||
["Install log: %s"]="安装日志:%s"
|
||||
["Target log: %s"]="目标系统日志:%s"
|
||||
["Omarchy installation stopped"]="Omarchy 安装已停止"
|
||||
["Installer exited with status %s"]="安装程序退出,状态码 %s"
|
||||
["Get help at https://omarchy.org/discord"]="求助:https://omarchy.org/discord"
|
||||
["What would you like to do?"]="接下来做什么?"
|
||||
["Upload log for support"]="上传日志求助"
|
||||
["View full log"]="查看完整日志"
|
||||
["Drop to shell"]="进入 shell"
|
||||
["Reboot"]="重启"
|
||||
["Power off"]="关机"
|
||||
["The install medium was misread"]="安装介质读取出错"
|
||||
["This package is intact on this ISO, so the download and the write were fine:"]="这个软件包在 ISO 中完好,下载和写入都没有问题:"
|
||||
["Try another USB port or another stick, and test this machine's memory"]="换一个 USB 口或换一个 U 盘,并检测这台机器的内存"
|
||||
["The install medium is damaged"]="安装介质已损坏"
|
||||
["This package no longer matches the checksum it was built with:"]="这个软件包和构建时的校验和不再一致:"
|
||||
["Re-download the ISO, verify it with sha256sum, and write the USB again"]="重新下载 ISO,用 sha256sum 校验,再重新写入 U 盘"
|
||||
["If a verified ISO fails here too, the USB stick itself is the problem"]="如果校验过的 ISO 在这里也失败,问题出在 U 盘本身"
|
||||
["The install medium is damaged or misread"]="安装介质已损坏或读取出错"
|
||||
["pacman rejected this package, read straight off this ISO:"]="pacman 拒绝了这个直接从 ISO 读出的软件包:"
|
||||
["Verify your download with sha256sum, then write the USB again"]="用 sha256sum 校验下载的文件,再重新写入 U 盘"
|
||||
["If the download verifies, the stick or this machine's memory is at fault"]="如果下载校验无误,问题出在 U 盘或这台机器的内存"
|
||||
)
|
||||
|
||||
if [[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]]; then
|
||||
whimsy=(
|
||||
"备好贴心的默认设置"
|
||||
"省掉装机的折腾"
|
||||
"挑选打磨过的软件包"
|
||||
"调配协调的配色"
|
||||
"准备悦目的像素"
|
||||
"唤醒 Hyprland"
|
||||
"接好顺手的界面"
|
||||
"搭起漂亮的系统"
|
||||
"写好趁手的命令"
|
||||
"让机器焕然一新"
|
||||
"擦亮每一个像素"
|
||||
"调好终端的品味"
|
||||
"织出流畅的工作流"
|
||||
)
|
||||
tips=(
|
||||
"Super + Space 打开 Omarchy 菜单:应用、设置等"
|
||||
"Super + K 查看全部快捷键"
|
||||
"Super 是键盘上的 Windows 或 Command 键"
|
||||
"用 Xournal++ 给 PDF 签名"
|
||||
"用 LocalSend 和手机、电脑互传文件"
|
||||
"菜单 安装 > 网页应用 把任意网站变成应用"
|
||||
"Super + Return 打开终端,Super + Shift + Return 打开浏览器"
|
||||
"图片用 Pinta,视频用 Kdenlive,文档用 LibreOffice"
|
||||
"Super + Ctrl + Print 用 OCR 抓取屏幕文字"
|
||||
"Print 截屏,Alt + Print 录屏"
|
||||
"Super + Ctrl + R 设置提醒"
|
||||
"菜单 风格 > 主题 切换主题"
|
||||
"双击菜单栏让它透明"
|
||||
"菜单 安装 > Windows 运行完整的 Windows 虚拟机"
|
||||
"Super + Ctrl + V 打开剪贴板管理器"
|
||||
"Super + 1 到 0 切换工作区,加 Shift 带上窗口"
|
||||
"Super + Print 拾取屏幕上任意位置的颜色"
|
||||
"用菜单里的 更新 让系统保持最新"
|
||||
)
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
printf '%s%s' "$RESET" "$SHOW_CURSOR" >"$TTY_PATH" 2>/dev/null || true
|
||||
}
|
||||
@@ -286,8 +361,8 @@ left_padding() {
|
||||
|
||||
visible_len() {
|
||||
local text="$1"
|
||||
text="$(printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g')"
|
||||
printf '%s' "${#text}"
|
||||
# wc -L counts display columns, so double-width CJK centers too.
|
||||
printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' | wc -L
|
||||
}
|
||||
|
||||
blank_line() { printf '\r%s\n' "$CLEAR_LINE"; }
|
||||
@@ -678,7 +753,7 @@ render_dynamic() {
|
||||
render_icon "$DISPLAY_PM"
|
||||
|
||||
message="$(truncate_for_width "$(current_whimsy)" "$PROGRESS_TEXT_WIDTH")"
|
||||
tip="$(truncate_for_width "Tip: $(current_tip)" "$PROGRESS_TEXT_WIDTH")"
|
||||
tip="$(truncate_for_width "$(omarchy_text "Tip: %s" "$(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"
|
||||
@@ -718,9 +793,13 @@ install_duration() {
|
||||
}
|
||||
|
||||
render_finish() {
|
||||
local duration effect_canvas_width title="Installed Omarchy"
|
||||
local duration effect_canvas_width title
|
||||
duration="$(install_duration || true)"
|
||||
[[ -n $duration ]] && title+=" in ${duration}"
|
||||
if [[ -n $duration ]]; then
|
||||
title=$(omarchy_text "Installed Omarchy in %s" "$duration")
|
||||
else
|
||||
title=$(omarchy_text "Installed Omarchy")
|
||||
fi
|
||||
# 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)"
|
||||
@@ -765,7 +844,7 @@ render_finish() {
|
||||
}
|
||||
|
||||
reboot_prompt() {
|
||||
local prompt_pad
|
||||
local prompt_pad label
|
||||
|
||||
# Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still
|
||||
# applies, so setting both stops on the finish screen without a keyboard.
|
||||
@@ -776,24 +855,31 @@ reboot_prompt() {
|
||||
# gum allows. FINISH_HEIGHT budgets for the same three rows.
|
||||
printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH"
|
||||
|
||||
prompt_pad=$(( ( $(term_cols) - 18 ) / 2 ))
|
||||
# gum pads the button label by four columns on each side.
|
||||
label=$(omarchy_text "Reboot Now")
|
||||
prompt_pad=$(( ( $(term_cols) - $(visible_len "$label") - 8 ) / 2 ))
|
||||
(( prompt_pad < 0 )) && prompt_pad=0
|
||||
gum confirm \
|
||||
--padding "0 0 0 $prompt_pad" \
|
||||
--show-help=false \
|
||||
--default \
|
||||
--affirmative "Reboot Now" \
|
||||
--affirmative "$label" \
|
||||
--negative "" \
|
||||
"" <"$TTY_PATH" >"$TTY_PATH" 2>&1
|
||||
}
|
||||
|
||||
# Columns, not characters: each CJK character takes two.
|
||||
truncate_for_width() {
|
||||
local text="$1" width="$2"
|
||||
if (( ${#text} > width )); then
|
||||
printf '%s…' "${text:0:$((width - 1))}"
|
||||
else
|
||||
if (( $(printf '%s' "$text" | wc -L) <= width )); then
|
||||
printf '%s' "$text"
|
||||
return
|
||||
fi
|
||||
text=${text:0:$((width - 1))}
|
||||
while (( $(printf '%s' "$text" | wc -L) > width - 1 )); do
|
||||
text=${text%?}
|
||||
done
|
||||
printf '%s…' "$text"
|
||||
}
|
||||
|
||||
render_failure_log_tail() {
|
||||
@@ -819,8 +905,8 @@ render_failure_log_tail() {
|
||||
fi
|
||||
[[ -f $tail_log ]] || return 0
|
||||
|
||||
label="Last log lines:"
|
||||
[[ $tail_log == /mnt/* ]] && label="Last target log lines:"
|
||||
label=$(omarchy_text "Last log lines:")
|
||||
[[ $tail_log == /mnt/* ]] && label=$(omarchy_text "Last target log lines:")
|
||||
line_at "$CONTENT_WIDTH" 2 "${DIM}${label}${RESET}"
|
||||
printf '\n'
|
||||
tail -n "$lines" "$tail_log" 2>/dev/null | while IFS= read -r line; do
|
||||
@@ -843,7 +929,7 @@ find_log_uploader() {
|
||||
}
|
||||
|
||||
prompt_enter() {
|
||||
printf '\nPress Enter to continue…' >"$TTY_PATH"
|
||||
printf '\n%s' "$(omarchy_text "Press Enter to continue…")" >"$TTY_PATH"
|
||||
IFS= read -r _ <"$TTY_PATH" || true
|
||||
}
|
||||
|
||||
@@ -854,9 +940,9 @@ upload_failure_log() {
|
||||
"$uploader" install >"$TTY_PATH" 2>&1 || true
|
||||
else
|
||||
{
|
||||
echo "No log uploader is available in this environment."
|
||||
echo "Install log: $LOG_FILE"
|
||||
[[ -f /mnt/var/log/omarchy-install.log ]] && echo "Target log: /mnt/var/log/omarchy-install.log"
|
||||
printf '%s\n' "$(omarchy_text "No log uploader is available in this environment.")"
|
||||
printf '%s\n' "$(omarchy_text "Install log: %s" "$LOG_FILE")"
|
||||
[[ -f /mnt/var/log/omarchy-install.log ]] && printf '%s\n' "$(omarchy_text "Target log: %s" /mnt/var/log/omarchy-install.log)"
|
||||
} >"$TTY_PATH"
|
||||
fi
|
||||
prompt_enter
|
||||
@@ -907,6 +993,8 @@ render_media_diagnosis() {
|
||||
blank_line
|
||||
continue
|
||||
fi
|
||||
# The support log keeps English; the screen shows the translation, and package names pass through.
|
||||
[[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]] && line=${OMARCHY_ZH[$line]:-$line}
|
||||
if (( first )); then
|
||||
center "${RED}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH"
|
||||
first=0
|
||||
@@ -922,8 +1010,8 @@ render_failure() {
|
||||
blank_line
|
||||
render_logo
|
||||
blank_line
|
||||
center "${RED}Omarchy installation stopped${RESET}" "$CONTENT_WIDTH"
|
||||
center "Installer exited with status $status" "$CONTENT_WIDTH"
|
||||
center "${RED}$(omarchy_text "Omarchy installation stopped")${RESET}" "$CONTENT_WIDTH"
|
||||
center "$(omarchy_text "Installer exited with status %s" "$status")" "$CONTENT_WIDTH"
|
||||
|
||||
render_media_diagnosis "$media_diagnosis"
|
||||
|
||||
@@ -938,12 +1026,12 @@ render_failure() {
|
||||
blank_line
|
||||
render_failure_log_tail "$(media_diagnosis_rows "$media_diagnosis")"
|
||||
blank_line
|
||||
center "${DIM}Get help at https://omarchy.org/discord${RESET}" "$CONTENT_WIDTH"
|
||||
center "${DIM}$(omarchy_text "Get help at https://omarchy.org/discord")${RESET}" "$CONTENT_WIDTH"
|
||||
blank_line
|
||||
}
|
||||
|
||||
failure_menu() {
|
||||
local choice uploader
|
||||
local choice uploader actions shown i
|
||||
|
||||
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
|
||||
# The failure screen and log tail have already rendered; exit with the
|
||||
@@ -953,26 +1041,20 @@ failure_menu() {
|
||||
while true; do
|
||||
uploader=""
|
||||
uploader=$(find_log_uploader || true)
|
||||
if [[ -n $uploader ]]; then
|
||||
choice=$(gum choose \
|
||||
--height 6 \
|
||||
--header "What would you like to do?" \
|
||||
"Upload log for support" \
|
||||
"View full log" \
|
||||
"Drop to shell" \
|
||||
"Reboot" \
|
||||
"Power off" \
|
||||
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
|
||||
else
|
||||
choice=$(gum choose \
|
||||
--height 5 \
|
||||
--header "What would you like to do?" \
|
||||
"View full log" \
|
||||
"Drop to shell" \
|
||||
"Reboot" \
|
||||
"Power off" \
|
||||
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
|
||||
fi
|
||||
actions=("View full log" "Drop to shell" "Reboot" "Power off")
|
||||
[[ -n $uploader ]] && actions=("Upload log for support" "${actions[@]}")
|
||||
shown=()
|
||||
for i in "${!actions[@]}"; do
|
||||
shown+=("$(omarchy_text "${actions[i]}")")
|
||||
done
|
||||
choice=$(gum choose \
|
||||
--height "$((${#actions[@]} + 1))" \
|
||||
--header "$(omarchy_text "What would you like to do?")" \
|
||||
"${shown[@]}" \
|
||||
<"$TTY_PATH" 2>"$TTY_PATH") || choice=""
|
||||
for i in "${!shown[@]}"; do
|
||||
[[ ${shown[i]} == "$choice" ]] && choice=${actions[i]}
|
||||
done
|
||||
|
||||
case "$choice" in
|
||||
"Upload log for support")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# The Chinese installer's console: the VT's Tokyo Night palette, with Noto Sans Mono CJK from the offline mirror.
|
||||
[main]
|
||||
font=Noto Sans Mono CJK SC:size=13
|
||||
# Noto's tall default rows leave the summary too few lines for the logo.
|
||||
line-height=22px
|
||||
|
||||
[colors-dark]
|
||||
background=1a1b26
|
||||
foreground=a9b1d6
|
||||
regular0=1a1b26
|
||||
regular1=f7768e
|
||||
regular2=9ece6a
|
||||
regular3=e0af68
|
||||
regular4=7aa2f7
|
||||
regular5=bb9af7
|
||||
regular6=7dcfff
|
||||
regular7=a9b1d6
|
||||
bright0=414868
|
||||
bright1=f7768e
|
||||
bright2=9ece6a
|
||||
bright3=e0af68
|
||||
bright4=7aa2f7
|
||||
bright5=bb9af7
|
||||
bright6=7dcfff
|
||||
bright7=c0caf5
|
||||
|
||||
# cage draws no title bar, and a reserved one leaves a black strip.
|
||||
[csd]
|
||||
preferred=none
|
||||
@@ -13,7 +13,7 @@ The canonical call sequence (mirrored from archinstall.scripts.guided.py) is:
|
||||
# configs do their own mounting before the Installer context opens.
|
||||
if disk_config.config_type != DiskLayoutType.Pre_mount:
|
||||
inst.mount_ordered_layout()
|
||||
inst.sanity_check(offline=, skip_ntp=, skip_wkd=)
|
||||
sanity_check(inst)
|
||||
inst.generate_key_files() # encrypted only
|
||||
inst.set_mirrors(handler, mirror_config, on_target=False)
|
||||
inst.minimal_installation(...) # base + linux pacstrap
|
||||
@@ -214,6 +214,14 @@ def _method_accepts(method, name: str) -> bool:
|
||||
return name in (*positional, *kwonly)
|
||||
|
||||
|
||||
def sanity_check(installer) -> None:
|
||||
"""Archinstall 4.5 removed offline; retain it for older releases."""
|
||||
options = {"skip_ntp": True, "skip_wkd": True}
|
||||
if _method_accepts(installer.sanity_check, "offline"):
|
||||
options["offline"] = True
|
||||
installer.sanity_check(**options)
|
||||
|
||||
|
||||
def _method_accepts_users(method) -> bool:
|
||||
return _method_accepts(method, "users")
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .keyboard import validate_input_selection
|
||||
from .region import GLOBAL, resolve_region
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallContext:
|
||||
@@ -27,6 +30,7 @@ class InstallContext:
|
||||
arch_config_path: Path
|
||||
omarchy_install: dict[str, Any]
|
||||
defer_provisioning: bool = False
|
||||
region: str = GLOBAL
|
||||
|
||||
target: Path = Path("/mnt")
|
||||
omarchy_path: Path = Path("/usr/share/omarchy")
|
||||
@@ -57,6 +61,13 @@ class InstallContext:
|
||||
kernel = (omarchy_install.get("storage") or {}).get("kernel") or _default_kernel()
|
||||
user_configuration["kernels"] = [kernel]
|
||||
|
||||
validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", ""))
|
||||
|
||||
# Resolved before any disk is touched, so an autoinstall naming a
|
||||
# region this ISO lacks fails without wiping anything.
|
||||
region = resolve_region(omarchy_install, user_configuration.get("timezone"))
|
||||
omarchy_install["region"] = region
|
||||
|
||||
# Deferred provisioning: the whole system installs but user creation is deferred to
|
||||
# first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by
|
||||
# an `defer-provisioning` marker file on an autoinstall drive, which also replaces the
|
||||
@@ -121,6 +132,7 @@ class InstallContext:
|
||||
arch_config_path=arch_config_path,
|
||||
omarchy_install=omarchy_install,
|
||||
defer_provisioning=defer_provisioning,
|
||||
region=region,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
disk_config = user_configuration.get("disk_config", {})
|
||||
|
||||
@@ -16,21 +16,31 @@ from pathlib import Path
|
||||
from .command import capture
|
||||
|
||||
|
||||
def configure_keyboard(target: Path, language: str) -> bool:
|
||||
def configure_keyboard(target: Path, language: str, input_method: str | None = None, xkb_layout: str = "") -> bool:
|
||||
"""Write the console keymap into a mounted target without booting it.
|
||||
|
||||
Returns False for layouts localectl doesn't know, matching archinstall:
|
||||
warn and keep the default. Every layout the configurator offers is known;
|
||||
the guard is for the kb_layout an autoinstall drive can name freely.
|
||||
"""
|
||||
validate_input_selection(input_method, xkb_layout)
|
||||
if not language.strip():
|
||||
return True
|
||||
if input_method is None:
|
||||
return True
|
||||
language = "us"
|
||||
|
||||
# Input engines are separate from real console keymaps. The shared picker
|
||||
# uses US for Chinese/Korean and carries its preference in omarchy_install.
|
||||
if input_method is None:
|
||||
input_method = "mozc" if language == "jp106" else "none"
|
||||
|
||||
result = capture(["localectl", "--no-pager", "list-keymaps"])
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "localectl returned an error"
|
||||
raise RuntimeError(f"Unable to list keyboard layouts: {detail}")
|
||||
if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}:
|
||||
if input_method != "none":
|
||||
raise ValueError(f"Unknown keyboard language: {language}")
|
||||
return False
|
||||
|
||||
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
|
||||
@@ -51,4 +61,15 @@ def configure_keyboard(target: Path, language: str) -> bool:
|
||||
if font:
|
||||
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
|
||||
|
||||
preference = target / "etc/omarchy/input-method"
|
||||
preference.parent.mkdir(parents=True, exist_ok=True)
|
||||
preference.write_text(f"INPUT_METHOD={input_method}\nXKB_LAYOUT={xkb_layout}\n")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_input_selection(input_method: str | None, xkb_layout: str) -> None:
|
||||
if input_method is not None and input_method not in {"none", "mozc", "hangul", "pinyin", "chewing"}:
|
||||
raise ValueError(f"Unknown input method: {input_method}")
|
||||
if xkb_layout not in {"", "kr", "la"}:
|
||||
raise ValueError(f"Unknown input keyboard layout: {xkb_layout}")
|
||||
@@ -36,39 +36,10 @@ from . import archinstall_adapter as arch
|
||||
from .command import capture, capture_identifier, require_text
|
||||
from .context import InstallContext
|
||||
from .keyboard import configure_keyboard
|
||||
from .region import GLOBAL, region_keyrings, region_packages
|
||||
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.
|
||||
@@ -265,11 +236,7 @@ def arch_install_system(ctx: InstallContext) -> None:
|
||||
if not pre_mounted:
|
||||
installer.mount_ordered_layout()
|
||||
|
||||
installer.sanity_check(
|
||||
offline=True,
|
||||
skip_ntp=True,
|
||||
skip_wkd=True,
|
||||
)
|
||||
arch.sanity_check(installer)
|
||||
|
||||
if not pre_mounted and arch.is_encrypted(config):
|
||||
installer.generate_key_files()
|
||||
@@ -304,7 +271,11 @@ def arch_install_system(ctx: InstallContext) -> None:
|
||||
# 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):
|
||||
if not configure_keyboard(
|
||||
installer.target, kb_layout,
|
||||
ctx.omarchy_install.get("input_method"),
|
||||
ctx.omarchy_install.get("input_xkb_layout", ""),
|
||||
):
|
||||
error(f"Invalid keyboard language specified: {kb_layout}")
|
||||
|
||||
if config.mirror_config:
|
||||
@@ -317,7 +288,6 @@ 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)
|
||||
@@ -329,6 +299,11 @@ def arch_install_system(ctx: InstallContext) -> None:
|
||||
info("› installing Omarchy runtime + omarchy-base.packages")
|
||||
installer.add_additional_packages(_runtime_package_list(ctx))
|
||||
|
||||
if region_pkgs := region_packages(ctx.region):
|
||||
info(f"› installing {ctx.region} region packages: {', '.join(region_pkgs)}")
|
||||
installer.add_additional_packages(region_pkgs)
|
||||
_write_region_marker(ctx)
|
||||
|
||||
# Tailscale is bundled in the offline mirror but only installed
|
||||
# when an autoinstall drive staged an auth key; must happen here,
|
||||
# while the mirror is still bind-mounted, not in the phase that
|
||||
@@ -759,6 +734,17 @@ def _unmask_mkinitcpio_pacman_hooks(
|
||||
info(f"warning: failed to restore pacman hook mask for {name}: {exc}")
|
||||
|
||||
|
||||
def _write_region_marker(ctx: InstallContext) -> None:
|
||||
"""Tell the runtime's pacman finalizer which profile to apply. Global
|
||||
installs write nothing, so they stay identical to a region-less install."""
|
||||
if ctx.region == GLOBAL:
|
||||
return
|
||||
marker = ctx.target / "etc/omarchy/region"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(ctx.region + "\n")
|
||||
marker.chmod(0o644)
|
||||
|
||||
|
||||
def _runtime_package_list(ctx: InstallContext) -> list[str]:
|
||||
"""Selected Omarchy runtime package + every package in the ISO-bundled
|
||||
base package list that isn't already installed early."""
|
||||
@@ -1166,12 +1152,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":
|
||||
# The region's keyring packages are installed by now, but the build
|
||||
# container's GPG state is not inherited. Initialize the target's trust
|
||||
# database with only this region's keys before the finalizer enables its
|
||||
# online repositories; global installs never trust them.
|
||||
if keyrings := region_keyrings(ctx.region):
|
||||
_run_target_setup_command(ctx, ["pacman-key", "--init"])
|
||||
_run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", "archlinuxcn"])
|
||||
_run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", *keyrings])
|
||||
|
||||
if ctx.defer_provisioning:
|
||||
cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Region selection: which of the runtime's region profiles a target gets.
|
||||
|
||||
The builder copies every profile from the bundled runtime's default/regions/
|
||||
into REGIONS_DIR, and their packages into the offline mirror. Nobody picks a
|
||||
region by hand: the timezone the user already chose selects it, and an
|
||||
autoinstall config may name one explicitly in omarchy_install.region. Run as
|
||||
`python -m orchestrator.region <timezone>` to print the region the installer
|
||||
would use, which is how the configurator fills its summary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REGIONS_DIR = Path("/usr/share/omarchy-iso/regions")
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
def _entries(path: Path) -> list[str]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
lines = (line.strip() for line in path.read_text().splitlines())
|
||||
return [line for line in lines if line and not line.startswith("#")]
|
||||
|
||||
|
||||
def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) -> str:
|
||||
regions_dir = regions_dir or REGIONS_DIR
|
||||
if timezone and regions_dir.is_dir():
|
||||
for profile in sorted(regions_dir.iterdir()):
|
||||
if timezone in _entries(profile / "timezones"):
|
||||
return profile.name
|
||||
return GLOBAL
|
||||
|
||||
|
||||
def resolve_region(omarchy_install: dict, timezone: str | None, regions_dir: Path | None = None) -> str:
|
||||
regions_dir = regions_dir or REGIONS_DIR
|
||||
region = omarchy_install.get("region")
|
||||
if region is None or region == "":
|
||||
return region_for_timezone(timezone, regions_dir)
|
||||
# Profiles are named by ISO 3166-1 alpha-2 code; checking the shape first
|
||||
# keeps a path like ".." from passing the directory test.
|
||||
if not isinstance(region, str) or (
|
||||
region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir())
|
||||
):
|
||||
raise RuntimeError(f"Unsupported region: {region!r}")
|
||||
return region
|
||||
|
||||
|
||||
def region_packages(region: str, regions_dir: Path | None = None) -> list[str]:
|
||||
if region == GLOBAL:
|
||||
return []
|
||||
return _entries((regions_dir or REGIONS_DIR) / region / "packages")
|
||||
|
||||
|
||||
def region_keyrings(region: str, regions_dir: Path | None = None) -> list[str]:
|
||||
"""Pacman keyrings the profile ships, named the Arch way: <name>-keyring
|
||||
installs /usr/share/pacman/keyrings/<name>.gpg."""
|
||||
return [
|
||||
package.removesuffix("-keyring")
|
||||
for package in region_packages(region, regions_dir)
|
||||
if package.endswith("-keyring")
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(region_for_timezone(sys.argv[1] if len(sys.argv) > 1 else None))
|
||||
@@ -13,6 +13,9 @@ ParallelDownloads = 5
|
||||
SigLevel = Required DatabaseOptional
|
||||
LocalFileSigLevel = Optional
|
||||
|
||||
[omarchy]
|
||||
Server = https://pkgs.omarchy.org/edge/$arch
|
||||
|
||||
[core]
|
||||
Server = https://mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
@@ -25,11 +28,7 @@ Include = /etc/pacman.d/mirrorlist
|
||||
Server = https://mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/edge/$arch
|
||||
|
||||
[arch-mact2]
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
SigLevel = Never
|
||||
@@ -13,6 +13,9 @@ ParallelDownloads = 5
|
||||
SigLevel = Required DatabaseOptional
|
||||
LocalFileSigLevel = Optional
|
||||
|
||||
[omarchy]
|
||||
Server = https://pkgs.omarchy.org/rc/$arch
|
||||
|
||||
[core]
|
||||
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
@@ -25,11 +28,7 @@ Include = /etc/pacman.d/mirrorlist
|
||||
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/rc/$arch
|
||||
|
||||
[arch-mact2]
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
SigLevel = Never
|
||||
@@ -13,6 +13,9 @@ ParallelDownloads = 5
|
||||
SigLevel = Required DatabaseOptional
|
||||
LocalFileSigLevel = Optional
|
||||
|
||||
[omarchy]
|
||||
Server = https://pkgs.omarchy.org/stable/$arch
|
||||
|
||||
[core]
|
||||
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
@@ -25,11 +28,7 @@ Include = /etc/pacman.d/mirrorlist
|
||||
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/stable/$arch
|
||||
|
||||
[arch-mact2]
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
SigLevel = Never
|
||||
@@ -503,16 +503,23 @@ EOF
|
||||
mcopy -i "$CIDATA_IMG" "$dir"/* ::/
|
||||
}
|
||||
|
||||
# The dev/local ISO installs the -dev packages; a stable ISO the plain ones.
|
||||
# The edge/dev/local ISO installs the -dev packages, as builder/build-iso.sh
|
||||
# picks them; a stable ISO the plain ones.
|
||||
detect_packages() {
|
||||
RUNTIME_PACKAGE=omarchy-dev
|
||||
SETTINGS_PACKAGE=omarchy-settings-dev
|
||||
if [[ $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then
|
||||
if [[ $(basename "$ISO") != *edge* && $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then
|
||||
RUNTIME_PACKAGE=omarchy
|
||||
SETTINGS_PACKAGE=omarchy-settings
|
||||
fi
|
||||
}
|
||||
|
||||
# The firmware names what it starts on the serial port: the ISO's medium while
|
||||
# the installer runs, the installed disk's Limine once the install is over.
|
||||
installed_system_started() {
|
||||
grep -aq 'starting Boot.*"Limine"' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
install_phase() {
|
||||
log "Installing $(basename "$ISO") unattended via cidata (headless)"
|
||||
|
||||
@@ -534,7 +541,7 @@ install_phase() {
|
||||
-device usb-storage,drive=cidata
|
||||
|
||||
log "Waiting for the unattended install to finish (timeout ${INSTALL_TIMEOUT}s)"
|
||||
local waited=0 text progress_name
|
||||
local waited=0 text progress_name first_boot_at=""
|
||||
while true; do
|
||||
# An unattended install reboots on its own; SSH answering means the
|
||||
# installed system is up (cidata's authorized_keys enables sshd).
|
||||
@@ -575,7 +582,20 @@ install_phase() {
|
||||
printf -v progress_name 'success-install-progress-%04ds' "$waited"
|
||||
capture_console "$progress_name"
|
||||
fi
|
||||
((waited % 30 == 0)) && echo " ... installing (${waited}s)"
|
||||
# Once the installed system has started, the install is over and what is
|
||||
# being waited for is the first boot: say so, or a hung first boot reads
|
||||
# as a slow install.
|
||||
if [[ -z $first_boot_at ]] && installed_system_started "$RUN_DIR/install-serial.log"; then
|
||||
first_boot_at=$waited
|
||||
log "Install finished after about ${waited}s. Waiting for the installed system's first boot."
|
||||
fi
|
||||
if ((waited % 30 == 0)); then
|
||||
if [[ -n $first_boot_at ]]; then
|
||||
echo " ... first boot ($((waited - first_boot_at))s, install took about ${first_boot_at}s)"
|
||||
else
|
||||
echo " ... installing (${waited}s)"
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
((waited += 10))
|
||||
|
||||
@@ -273,6 +273,12 @@ mkdir -p "$omarchy_share"
|
||||
printf 'logo bottom row%*s\n' 66 ''
|
||||
} >"$omarchy_share/logo.txt"
|
||||
|
||||
# The ISO vendors the runtime's setup form; the dashboard only needs its text lookup.
|
||||
cat >"$work/setup-form.sh" <<'FORM'
|
||||
declare -gA OMARCHY_ZH=()
|
||||
omarchy_text() { local text=$1; shift; printf -- "$text" "$@"; }
|
||||
FORM
|
||||
|
||||
run_dashboard() {
|
||||
local install_log="$1"
|
||||
|
||||
@@ -284,7 +290,7 @@ run_dashboard() {
|
||||
"error": "Pacstrap failed. See /var/log/archinstall.log"}]}
|
||||
STATE
|
||||
: >"$screen"
|
||||
script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \
|
||||
script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' SETUP_FORM='$work/setup-form.sh' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \
|
||||
"$screen" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Every installer text needs an OMARCHY_ZH entry, or a Chinese install shows it in English.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
SETUP_FORM="${OMARCHY_SOURCE:-$ROOT/../omarchy}/install/provisioning/setup-form.sh"
|
||||
|
||||
fail() {
|
||||
printf 'not ok - %s\n' "$1"
|
||||
[[ -z ${2:-} ]] || printf '%s\n' "$2" | sed 's/^/# /'
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ ! -r $SETUP_FORM ]]; then
|
||||
printf 'ok - # skip the setup form is not checked out next to this repo (set OMARCHY_SOURCE)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
source "$SETUP_FORM"
|
||||
for file in configs/airootfs/root/configurator configs/airootfs/usr/local/bin/omarchy-install-dashboard; do
|
||||
eval "$(sed -n '/^OMARCHY_ZH+=(/,/^)/p' "$ROOT/$file")"
|
||||
done
|
||||
|
||||
mapfile -t texts < <(
|
||||
grep -ho 'omarchy_text "[^"$]*"' "$ROOT/configs/airootfs/root/configurator" "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard" |
|
||||
sed 's/^omarchy_text "//; s/"$//' | sort -u
|
||||
)
|
||||
((${#texts[@]} > 50)) || fail "the installer routes its text through omarchy_text" "found ${#texts[@]}"
|
||||
for text in "${texts[@]}"; do
|
||||
[[ -n ${OMARCHY_ZH[$text]:-} ]] || fail "every installer text has a Chinese entry" "missing: $text"
|
||||
done
|
||||
printf 'ok - every installer and dashboard text has a Chinese entry\n'
|
||||
|
||||
for action in "View full log" "Drop to shell" "Reboot" "Power off" "Upload log for support"; do
|
||||
[[ -n ${OMARCHY_ZH[$action]:-} ]] || fail "labels passed through variables have Chinese entries" "missing: $action"
|
||||
done
|
||||
printf 'ok - labels passed through variables have Chinese entries\n'
|
||||
|
||||
mapfile -t diagnosis < <(grep -x ' *echo "[^"$]*"' "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-diagnose-media" | sed 's/^ *echo "//; s/"$//')
|
||||
((${#diagnosis[@]} >= 10)) || fail "the media diagnosis sentences are found" "found ${#diagnosis[@]}"
|
||||
for line in "${diagnosis[@]}"; do
|
||||
[[ -n ${OMARCHY_ZH[$line]:-} ]] || fail "every media diagnosis sentence has a Chinese entry" "missing: $line"
|
||||
done
|
||||
printf 'ok - every media diagnosis sentence has a Chinese entry\n'
|
||||
|
||||
[[ $(OMARCHY_INSTALL_LANG=zh omarchy_text "Confirm overwriting %s" /dev/vda) == "确认覆盖 /dev/vda" ]] ||
|
||||
fail "Chinese text keeps its printf arguments"
|
||||
[[ $(omarchy_text "Confirm overwriting %s" /dev/vda) == "Confirm overwriting /dev/vda" ]] ||
|
||||
fail "English stays the default"
|
||||
printf 'ok - omarchy_text formats Chinese and English alike\n'
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/install/provisioning"
|
||||
cat > "$work/install/provisioning/setup-form.sh" <<'FORM'
|
||||
OMARCHY_KEYBOARD_LAYOUTS=$'Chinese (Simplified, Pinyin)|us|pinyin
|
||||
English (US)|us
|
||||
Japanese|jp106|mozc'
|
||||
FORM
|
||||
export OMARCHY_PATH="$work"
|
||||
eval "$(sed -n '/^select_keyboard() {$/,/^}$/p' "$ROOT/bin/omarchy-iso-test")"
|
||||
press() { pressed+=("$1"); }
|
||||
sleep() { :; }
|
||||
|
||||
pressed=()
|
||||
select_keyboard "Japanese"
|
||||
[[ ${pressed[*]} == "down" ]]
|
||||
pressed=()
|
||||
select_keyboard "Chinese (Simplified, Pinyin)"
|
||||
[[ ${pressed[*]} == "up" ]]
|
||||
pressed=()
|
||||
select_keyboard "English (US)"
|
||||
(( ${#pressed[@]} == 0 ))
|
||||
if (set -e; select_keyboard "Missing") 2>"$work/error"; then
|
||||
echo "Unknown keyboard unexpectedly succeeded" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Keyboard choice is absent' "$work/error"
|
||||
echo "ok - VM keyboard choices are relative to the default and reject absent rows"
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The nightly's decisions, tried without a runner: which channels a run makes
|
||||
# and where they go (test/vm-image/nightly-plan), what it lays out and what
|
||||
# latest.json then says (test/vm-image/nightly-layout), and two things the
|
||||
# install test decides from what it is given: which Omarchy packages an ISO
|
||||
# installs, and when an install is over.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
plan="$ROOT/test/vm-image/nightly-plan"
|
||||
layout="$ROOT/test/vm-image/nightly-layout"
|
||||
harness="$ROOT/test/integration.d/base-test.sh"
|
||||
bash -n "$plan"
|
||||
bash -n "$layout"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
equal() { [[ $2 == "$3" ]] || fail "$1: got '$2', expected '$3'"; }
|
||||
refused() { # description, command...: it has to fail and say why
|
||||
local what=$1; shift
|
||||
if "$@" >"$tmp/refused.out" 2>"$tmp/refused.err"; then fail "$what was accepted"; fi
|
||||
[[ -s $tmp/refused.err ]] || fail "$what was refused without a word"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- nightly-plan
|
||||
planned() { "$plan" "$@" 2>/dev/null; }
|
||||
|
||||
first=$(planned 2026.10.09 431 1 schedule v4.0.4 "")
|
||||
equal "a first run makes both channels" "$(jq -r 'map(.channel) | join(",")' <<<"$first")" "edge,stable"
|
||||
equal "the edge version" "$(jq -r '.[0].version' <<<"$first")" "2026.10.09.431"
|
||||
equal "the edge path" "$(jq -r '.[0].release' <<<"$first")" "edge/2026.10.09.431"
|
||||
equal "the stable version is the release's" "$(jq -r '.[1].version' <<<"$first")" "4.0.4"
|
||||
equal "the stable path has the run number" "$(jq -r '.[1].release' <<<"$first")" "stable/4.0.4/431"
|
||||
|
||||
again=$(planned 2026.10.09 431 2 schedule v4.0.4 "")
|
||||
equal "a run started again has its own edge version" "$(jq -r '.[0].version' <<<"$again")" "2026.10.09.431.2"
|
||||
equal "a run started again has its own stable path" "$(jq -r '.[1].release' <<<"$again")" "stable/4.0.4/431.2"
|
||||
[[ $(jq -r '.[].release' <<<"$first" | sort) != "$(jq -r '.[].release' <<<"$again" | sort)" ]] ||
|
||||
fail "two attempts of one run share a path"
|
||||
|
||||
equal "a published release is left out of a scheduled run" \
|
||||
"$(planned 2026.10.09 432 1 schedule v4.0.4 4.0.4 | jq -r 'map(.channel) | join(",")')" "edge"
|
||||
equal "a new release is made on the next scheduled run" \
|
||||
"$(planned 2026.10.09 432 1 schedule v4.0.5 4.0.4 | jq -r '.[1].version')" "4.0.5"
|
||||
equal "a manual run makes a published release again" \
|
||||
"$(planned 2026.10.09 432 1 workflow_dispatch v4.0.4 4.0.4 | jq -r 'map(.channel) | join(",")')" "edge,stable"
|
||||
|
||||
refused "a release tag that is not a version" "$plan" 2026.10.09 431 1 schedule nightly ""
|
||||
refused "an empty release tag" "$plan" 2026.10.09 431 1 schedule "" ""
|
||||
refused "a release tag with a path in it" "$plan" 2026.10.09 431 1 schedule "v4.0.4/../x" ""
|
||||
refused "a run number that is not a number" "$plan" 2026.10.09 "" 1 schedule v4.0.4 ""
|
||||
refused "a date in another form" "$plan" 2026-10-09 431 1 schedule v4.0.4 ""
|
||||
|
||||
# -------------------------------------------------------------- nightly-layout
|
||||
sealed() { # directory: what vm-seal leaves
|
||||
rm -rf "$1"; mkdir -p "$1"
|
||||
local file
|
||||
for file in base.qcow2 vmlinuz initrd cmdline; do echo "$file of ${1##*/}" >"$1/$file"; done
|
||||
echo "sums of the seal" >"$1/SHA256SUMS"
|
||||
}
|
||||
export SITE=https://nightly.example ISO_SITE=https://iso.example ISO_COMMIT=0123456789abcdef0123456789abcdef01234567
|
||||
|
||||
# Edge. pacman answers for omarchy and omarchy-dev with the one installed
|
||||
# package, so its line is there twice.
|
||||
mkdir -p "$tmp/release"
|
||||
echo "an edge iso" >"$tmp/release/omarchy-2026.10.09-x86_64-edge.iso"
|
||||
sealed "$tmp/sealed-edge"
|
||||
printf '%s\n' "omarchy-dev 4.0.0.r6818.gc352b62-2" "omarchy-dev 4.0.0.r6818.gc352b62-2" \
|
||||
"/usr/lib/modules/7.2.8-5-omarchy-bore/pkgbase is owned by linux-omarchy-bore 7.2.8-5" "7.2.8-5-omarchy-bore" >"$tmp/edge-packages"
|
||||
"$layout" edge 2026.10.09.431 edge/2026.10.09.431 "$tmp/release/omarchy-2026.10.09-x86_64-edge.iso" \
|
||||
"$tmp/sealed-edge" "$tmp/edge-packages" "$tmp/out" >/dev/null
|
||||
json=$tmp/out/edge/latest.json
|
||||
dir=$tmp/out/edge/2026.10.09.431
|
||||
field() { jq -r "$1" "$json"; }
|
||||
|
||||
equal "edge: files published" "$(cd "$tmp/out" && find . -type f | sort | paste -sd' ')" \
|
||||
"./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso.sha256 ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2 ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2.sha256 ./edge/2026.10.09.431/unpacked/SHA256SUMS ./edge/2026.10.09.431/unpacked/cmdline ./edge/2026.10.09.431/unpacked/initrd ./edge/2026.10.09.431/unpacked/vm-boot ./edge/2026.10.09.431/unpacked/vmlinuz ./edge/latest.json"
|
||||
(cd "$dir" && sha256sum -c --quiet ./*.sha256) || fail "edge: a checksum file does not match the file it is for"
|
||||
equal "edge: the ISO and the image each have a checksum file" "$(cd "$dir" && ls ./*.sha256 | wc -l)" "2"
|
||||
equal "edge: the image is the sealed disk" "$(cat "$dir/omarchy-edge-2026.10.09.431.qcow2")" "base.qcow2 of sealed-edge"
|
||||
(cd "$dir/unpacked" && sha256sum -c --quiet SHA256SUMS) || fail "edge: SHA256SUMS does not match the unpacked files"
|
||||
equal "edge: SHA256SUMS covers every file beside it" \
|
||||
"$(awk '{ print $2 }' "$dir/unpacked/SHA256SUMS" | sort | paste -sd' ')" "$(cd "$dir/unpacked" && ls | grep -v SHA256SUMS | sort | paste -sd' ')"
|
||||
cmp -s "$dir/unpacked/vm-boot" "$ROOT/test/vm-image/vm-boot" || fail "edge: the published vm-boot is not the repository's"
|
||||
equal "edge: version" "$(field .version)" "2026.10.09.431"
|
||||
equal "edge: channel" "$(field .channel)" "edge"
|
||||
equal "edge: url" "$(field .url)" "https://nightly.example/edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso"
|
||||
equal "edge: sha256 is the ISO's" "$(field .sha256)" "$(sha256sum "$dir/omarchy-edge-2026.10.09.431.iso" | cut -d' ' -f1)"
|
||||
equal "edge: size is the ISO's" "$(field .size)" "$(stat -c %s "$dir/omarchy-edge-2026.10.09.431.iso")"
|
||||
equal "edge: iso_commit" "$(field .iso_commit)" "$ISO_COMMIT"
|
||||
equal "edge: omarchy_version, once" "$(field .omarchy_version)" "4.0.0.r6818.gc352b62-2"
|
||||
equal "edge: omarchy_commit" "$(field .omarchy_commit)" "c352b62"
|
||||
equal "edge: kernel" "$(field .kernel)" "7.2.8-5-omarchy-bore"
|
||||
equal "edge: image address" "$(field .vm.url)" "https://nightly.example/edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2"
|
||||
equal "edge: image checksum is the image's" "$(field .vm.sha256)" "$(sha256sum "$dir/omarchy-edge-2026.10.09.431.qcow2" | cut -d' ' -f1)"
|
||||
equal "edge: image size is the image's" "$(field .vm.size)" "$(stat -c %s "$dir/omarchy-edge-2026.10.09.431.qcow2")"
|
||||
equal "edge: the ISO and the image differ only in their ending" "$(field .url | sed 's/\.iso$//')" "$(field .vm.url | sed 's/\.qcow2$//')"
|
||||
equal "edge: unpacked folder" "$(field .vm.unpacked)" "https://nightly.example/edge/2026.10.09.431/unpacked/"
|
||||
equal "edge: every unpacked file listed is published" "$(field '.vm.unpacked_files[]' | sort | paste -sd' ')" "$(cd "$dir/unpacked" && ls | sort | paste -sd' ')"
|
||||
[[ $(field .built_at) =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$ ]] || fail "edge: built_at is '$(field .built_at)'"
|
||||
# What a consumer does with the ISO's name: the first run of three or more
|
||||
# dotted numbers is the version.
|
||||
[[ $(basename "$(field .url)") =~ [0-9]+(\.[0-9]+){2,} ]] || fail "edge: the ISO's name carries no version"
|
||||
equal "edge: the version read from the ISO's name" "${BASH_REMATCH[0]}" "2026.10.09.431"
|
||||
|
||||
# Stable: the image is published, the released ISO stays where it is.
|
||||
rm -rf "$tmp/out"
|
||||
echo "a released iso" >"$tmp/release/omarchy-4.0.4.iso"
|
||||
sealed "$tmp/sealed-stable"
|
||||
printf '%s\n' "omarchy 4.0.4-1" "/usr/lib/modules/7.2.5-3-omarchy/pkgbase is owned by linux-omarchy 7.2.5-3" "7.2.5-3-omarchy" >"$tmp/stable-packages"
|
||||
"$layout" stable 4.0.4 stable/4.0.4/431 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-stable" "$tmp/stable-packages" "$tmp/out" >/dev/null
|
||||
json=$tmp/out/stable/latest.json
|
||||
equal "stable: no ISO is published" "$(find "$tmp/out" -name '*.iso*' | wc -l)" "0"
|
||||
[[ -f $tmp/release/omarchy-4.0.4.iso ]] || fail "stable: the released ISO was moved away"
|
||||
equal "stable: url is the release's" "$(field .url)" "https://iso.example/omarchy-4.0.4.iso"
|
||||
equal "stable: sha256 is the ISO's" "$(field .sha256)" "$(sha256sum "$tmp/release/omarchy-4.0.4.iso" | cut -d' ' -f1)"
|
||||
equal "stable: version" "$(field .version)" "4.0.4"
|
||||
equal "stable: omarchy_version" "$(field .omarchy_version)" "4.0.4-1"
|
||||
equal "stable: a release names no commit" "$(field .omarchy_commit)" ""
|
||||
equal "stable: image address has the run number" "$(field .vm.url)" "https://nightly.example/stable/4.0.4/431/omarchy-4.0.4.qcow2"
|
||||
equal "stable: unpacked folder" "$(field .vm.unpacked)" "https://nightly.example/stable/4.0.4/431/unpacked/"
|
||||
(cd "$tmp/out/stable/4.0.4/431" && sha256sum -c --quiet omarchy-4.0.4.qcow2.sha256) || fail "stable: the image's checksum file does not match it"
|
||||
(cd "$tmp/out/stable/4.0.4/431/unpacked" && sha256sum -c --quiet SHA256SUMS) || fail "stable: SHA256SUMS does not match the unpacked files"
|
||||
|
||||
# Nothing half-made is described.
|
||||
sealed "$tmp/sealed-short"; rm "$tmp/sealed-short/initrd"
|
||||
refused "a sealed image without its initrd" "$layout" stable 4.0.4 stable/4.0.4/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-short" "$tmp/stable-packages" "$tmp/out2"
|
||||
sealed "$tmp/sealed-x"; : >"$tmp/no-packages"
|
||||
refused "an image that named no Omarchy package" "$layout" stable 4.0.4 stable/4.0.4/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-x" "$tmp/no-packages" "$tmp/out3"
|
||||
sealed "$tmp/sealed-y"
|
||||
refused "a released ISO under another version's name" "$layout" stable 4.0.5 stable/4.0.5/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-y" "$tmp/stable-packages" "$tmp/out4"
|
||||
sealed "$tmp/sealed-z"
|
||||
refused "a channel that does not exist" "$layout" rc 4.0.4 rc/4.0.4 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-z" "$tmp/stable-packages" "$tmp/out5"
|
||||
|
||||
# --------------------------------------------------------------- nightly-fetch
|
||||
# The network is played by three stand-ins: gh lists the artifacts, curl gives
|
||||
# the signed address, aria2c "downloads" a zip made here.
|
||||
fetch="$ROOT/test/vm-image/nightly-fetch"
|
||||
bash -n "$fetch"
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
stubs=$tmp/stubs; mkdir -p "$stubs" "$tmp/payload/edge/1"
|
||||
echo "an image" >"$tmp/payload/edge/1/omarchy-edge-1.qcow2"
|
||||
echo '{"version": "1"}' >"$tmp/payload/edge/latest.json"
|
||||
(cd "$tmp/payload" && zip -qr "$tmp/artifact.zip" .)
|
||||
zip_size=$(stat -c %s "$tmp/artifact.zip")
|
||||
cat >"$stubs/gh" <<STUB
|
||||
#!/bin/bash
|
||||
case "\$*" in
|
||||
"auth token") echo token ;;
|
||||
*"/runs/7/artifacts"*) printf '%s\\n' \${FETCH_IDS:-11 12} ;;
|
||||
*"/artifacts/"*) echo \${FETCH_SIZE:-$zip_size} ;;
|
||||
esac
|
||||
STUB
|
||||
cat >"$stubs/curl" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo "${@: -1}" >>"$FETCH_LOG"; printf 'https://storage.example/signed'
|
||||
STUB
|
||||
cat >"$stubs/aria2c" <<STUB
|
||||
#!/bin/bash
|
||||
echo aria2c >>"\$FETCH_LOG"
|
||||
if [[ -n \${FETCH_FAIL_ONCE:-} && ! -e $tmp/failed ]]; then touch $tmp/failed; exit 1; fi
|
||||
for arg in "\$@"; do case \$arg in --dir=*) dir=\${arg#--dir=} ;; --out=*) out=\${arg#--out=} ;; esac; done
|
||||
cp "$tmp/artifact.zip" "\$dir/\$out"
|
||||
STUB
|
||||
chmod +x "$stubs"/*
|
||||
fetched() { PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$@"; }
|
||||
|
||||
: >"$tmp/fetch.log"
|
||||
fetched "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/fetched" >/dev/null
|
||||
equal "fetch: the files of the artifact" "$(cd "$tmp/fetched" && find . -type f | sort | paste -sd' ')" "./edge/1/omarchy-edge-1.qcow2 ./edge/latest.json"
|
||||
grep -q "/artifacts/12/zip" "$tmp/fetch.log" || fail "fetch: of two artifacts with one name, the newer was not the one fetched"
|
||||
equal "fetch: nothing left behind" "$(ls "$tmp" | grep -c 'nightly-fetch\.')" "0"
|
||||
|
||||
: >"$tmp/fetch.log"; rm -f "$tmp/failed"
|
||||
FETCH_FAIL_ONCE=1 fetched "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/fetched-again" >/dev/null 2>&1
|
||||
equal "fetch: a failed download is tried again with a new address" "$(grep -c zip "$tmp/fetch.log")" "2"
|
||||
|
||||
refused "an artifact that does not exist" env FETCH_IDS=" " PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f3"
|
||||
refused "a download of the wrong size" env FETCH_SIZE=1 PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f4"
|
||||
[[ ! -e $tmp/f4/edge ]] || fail "fetch: a download of the wrong size was unpacked"
|
||||
refused "an artifact larger than the free space" env FETCH_SIZE=9000000000000000 PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f5"
|
||||
grep -q "MiB free" "$tmp/refused.err" || fail "fetch: too little room is not what was reported"
|
||||
else
|
||||
echo "nightly-fetch tests skipped: no zip or unzip here"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------- nightly-rclone
|
||||
wrapped="$ROOT/test/vm-image/nightly-rclone"
|
||||
bash -n "$wrapped"
|
||||
mkdir -p "$tmp/rc"
|
||||
rclone_says() { # exit status, output lines...: a stand-in rclone
|
||||
local status=$1; shift
|
||||
{ echo '#!/bin/bash'; printf 'echo %q >&2\n' "$@"; echo "exit $status"; } >"$tmp/rc/rclone"
|
||||
chmod +x "$tmp/rc/rclone"
|
||||
}
|
||||
rclone_says 0 "Transferred: 3 / 3, 100%"
|
||||
out=$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b)
|
||||
[[ $out != *::warning::* ]] || fail "rclone: a clean upload was reported as a retry"
|
||||
[[ $out == *"Transferred: 3 / 3"* ]] || fail "rclone: its output was not passed through"
|
||||
|
||||
rclone_says 0 "<3>ERROR : a.qcow2: Failed to copy: NotImplemented: Not Implemented" \
|
||||
"<3>ERROR : a.qcow2: Failed to copy: NotImplemented: Not Implemented" \
|
||||
"<3>ERROR : unpacked/vmlinuz: Failed to copy: NotImplemented: Not Implemented" \
|
||||
"<3>ERROR : Attempt 2/3 failed with 2 errors" "<3>ERROR : Attempt 3/3 succeeded"
|
||||
out=$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b)
|
||||
[[ $out == *"::warning::rclone needed a retry for 2 file(s): a.qcow2 unpacked/vmlinuz"* ]] ||
|
||||
fail "rclone: a file that failed twice is not counted once: $(grep warning <<<"$out")"
|
||||
|
||||
rclone_says 3 "<3>ERROR : a.qcow2: Failed to copy: AccessDenied"
|
||||
if PATH="$tmp/rc:$PATH" "$wrapped" copy a b >"$tmp/rc/out" 2>&1; then fail "rclone: a failed upload passed"; fi
|
||||
equal "rclone: its exit status is kept" "$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b >/dev/null 2>&1; echo $?)" "3"
|
||||
if grep -q '::warning::' "$tmp/rc/out"; then fail "rclone: a failed upload was reported as a retry"; fi
|
||||
|
||||
# rclone reads every RCLONE_<FLAG> variable as that flag: RCLONE_VERSION=v1.75.2
|
||||
# was "--version=v1.75.2" and stopped the publish before it uploaded anything.
|
||||
# Only its remote's settings, RCLONE_CONFIG_*, belong in a workflow's environment.
|
||||
flags=$(sed -nE 's/^[[:space:]]+(RCLONE_[A-Z0-9_]+):.*/\1/p' "$ROOT"/.github/workflows/*.yml | grep -v '^RCLONE_CONFIG_' | sort -u | paste -sd' ' || true)
|
||||
[[ -z $flags ]] || fail "rclone: a workflow sets $flags, which rclone would read as flags"
|
||||
|
||||
# ------------------------------------------------------------ the install test
|
||||
# The harness is a script that starts a VM when it is read; take the two
|
||||
# functions out of it.
|
||||
eval "$(sed -n '/^detect_packages() {$/,/^}$/p; /^installed_system_started() {$/,/^}$/p' "$harness")"
|
||||
declare -F detect_packages installed_system_started >/dev/null || fail "the harness no longer has detect_packages and installed_system_started"
|
||||
|
||||
packages_of() { ISO=$1; detect_packages; echo "$RUNTIME_PACKAGE $SETTINGS_PACKAGE"; }
|
||||
equal "an edge ISO installs the -dev packages" "$(packages_of release/omarchy-2026.10.09-x86_64-edge.iso)" "omarchy-dev omarchy-settings-dev"
|
||||
equal "a dev ISO installs the -dev packages" "$(packages_of release/omarchy-2026.10.09-x86_64-dev.iso)" "omarchy-dev omarchy-settings-dev"
|
||||
equal "a released ISO installs the release packages" "$(packages_of release/omarchy-4.0.4.iso)" "omarchy omarchy-settings"
|
||||
equal "a stable build installs the release packages" "$(packages_of /x/edge/omarchy-2026.10.09-x86_64-stable.iso)" "omarchy omarchy-settings"
|
||||
|
||||
printf '%s\r\n' 'BdsDxe: loading Boot0002 "UEFI QEMU DVD-ROM QM00011 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x5,0xFFFF,0x0)' \
|
||||
'BdsDxe: starting Boot0002 "UEFI QEMU DVD-ROM QM00011 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x5,0xFFFF,0x0)' >"$tmp/serial"
|
||||
if installed_system_started "$tmp/serial"; then fail "the installer's own boot counts as the installed system's"; fi
|
||||
printf '%s\r\n' 'BdsDxe: loading Boot0005 "Limine" from HD(1,GPT,774175D2,0x800,0x400000)/\EFI\limine\limine_x64.efi' >>"$tmp/serial"
|
||||
if installed_system_started "$tmp/serial"; then fail "loading Limine counts as having started it"; fi
|
||||
printf '%s\r\n' 'BdsDxe: starting Boot0005 "Limine" from HD(1,GPT,774175D2,0x800,0x400000)/\EFI\limine\limine_x64.efi' >>"$tmp/serial"
|
||||
installed_system_started "$tmp/serial" || fail "the installed system's Limine starting is not noticed"
|
||||
if installed_system_started "$tmp/no-such-log"; then fail "a missing serial log counts as a started system"; fi
|
||||
|
||||
echo "nightly tests passed"
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The offline mirror is indexed by several repo-add processes whose databases
|
||||
# are then joined (builder/index-offline-mirror.sh). The result has to be the
|
||||
# database one repo-add writes over the same packages, and two versions of one
|
||||
# package have to be an error, since apart they would both be kept.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
index="$ROOT/builder/index-offline-mirror.sh"
|
||||
bash -n "$index"
|
||||
if ! command -v repo-add >/dev/null; then
|
||||
echo "offline mirror index tests skipped: no repo-add here"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
package() { # directory name version: the least repo-add accepts as a package
|
||||
local stage="$fixture/stage"
|
||||
rm -rf "$stage"; mkdir -p "$stage/usr/share/$2"
|
||||
echo "$2 $3" >"$stage/usr/share/$2/version"
|
||||
printf 'pkgname = %s\npkgver = %s\npkgdesc = fixture\narch = any\nsize = 1\n' "$2" "$3" >"$stage/.PKGINFO"
|
||||
bsdtar -czf "$1/$2-$3-any.pkg.tar.gz" -C "$stage" .PKGINFO usr
|
||||
}
|
||||
contents() { # database file -> directory holding its entries
|
||||
mkdir -p "$2"; bsdtar -xf "$1" -C "$2"
|
||||
}
|
||||
|
||||
mkdir "$fixture/one" "$fixture/many"
|
||||
for name in alpha bravo charlie delta echo foxtrot golf; do
|
||||
package "$fixture/one" "$name" 1.0-1
|
||||
cp "$fixture/one/$name-1.0-1-any.pkg.tar.gz" "$fixture/many/"
|
||||
done
|
||||
: >"$fixture/many/alpha-1.0-1-any.pkg.tar.gz.sig" # a signature is not a package
|
||||
|
||||
repo-add -q "$fixture/one/offline.db.tar.gz" "$fixture"/one/*.pkg.tar.gz >/dev/null 2>&1
|
||||
bash "$index" "$fixture/many" 3 >/dev/null || fail "indexing seven packages with three processes failed"
|
||||
for kind in db files; do
|
||||
[[ -L $fixture/many/offline.$kind ]] || fail "offline.$kind, the name pacman fetches, is missing"
|
||||
contents "$fixture/one/offline.$kind.tar.gz" "$fixture/x-one-$kind"
|
||||
contents "$fixture/many/offline.$kind" "$fixture/x-many-$kind"
|
||||
diff -r "$fixture/x-one-$kind" "$fixture/x-many-$kind" >/dev/null \
|
||||
|| fail "the joined $kind database differs from one repo-add's"
|
||||
done
|
||||
|
||||
# More processes than packages, and a second run over an indexed mirror.
|
||||
bash "$index" "$fixture/many" 64 >/dev/null || fail "more processes than packages failed"
|
||||
contents "$fixture/many/offline.db" "$fixture/x-again"
|
||||
diff -r "$fixture/x-one-db" "$fixture/x-again" >/dev/null || fail "indexing an already indexed mirror changed it"
|
||||
|
||||
package "$fixture/many" alpha 2.0-1
|
||||
bash "$index" "$fixture/many" 3 >/dev/null 2>&1 && fail "two versions of one package were indexed"
|
||||
|
||||
mkdir "$fixture/empty"
|
||||
bash "$index" "$fixture/empty" >/dev/null 2>&1 && fail "an empty mirror was indexed"
|
||||
|
||||
grep -Fq 'bash /builder/index-offline-mirror.sh "$offline_mirror_dir"' "$ROOT/builder/build-iso.sh" \
|
||||
|| fail "the build does not use the parallel index"
|
||||
|
||||
echo "offline mirror index tests passed"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Exercise the compatibility boundary without importing host archinstall."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import types
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
source = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py"
|
||||
tree = ast.parse(source.read_text())
|
||||
namespace = {}
|
||||
functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in {"_method_accepts", "sanity_check"}]
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), str(source), "exec"), namespace)
|
||||
|
||||
|
||||
class SanityCheckTest(unittest.TestCase):
|
||||
def test_legacy_archinstall_stays_offline(self):
|
||||
calls = []
|
||||
def check(offline=False, skip_ntp=False, skip_wkd=False):
|
||||
calls.append((offline, skip_ntp, skip_wkd))
|
||||
namespace["sanity_check"](types.SimpleNamespace(sanity_check=check))
|
||||
self.assertEqual(calls, [(True, True, True)])
|
||||
|
||||
def test_archinstall_45_does_not_receive_removed_argument(self):
|
||||
calls = []
|
||||
class Installer:
|
||||
def sanity_check(self, skip_ntp=False, skip_wkd=False):
|
||||
calls.append((skip_ntp, skip_wkd))
|
||||
namespace["sanity_check"](Installer())
|
||||
self.assertEqual(calls, [(True, True)])
|
||||
@@ -93,7 +93,6 @@ class KernelSelectionTest(unittest.TestCase):
|
||||
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)
|
||||
@@ -103,12 +102,13 @@ class KernelSelectionTest(unittest.TestCase):
|
||||
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,
|
||||
swap=None, auth_config=None, app_config=None, timezone=None,
|
||||
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
|
||||
)
|
||||
ctx = types.SimpleNamespace(
|
||||
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
|
||||
target=Path("/unused"), tailscale_authkey_path=None,
|
||||
target=Path("/unused"), tailscale_authkey_path=None, omarchy_install={},
|
||||
region="global",
|
||||
)
|
||||
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
|
||||
"_configure_limine_boot", "_write_pre_mounted_fstab"):
|
||||
@@ -117,10 +117,10 @@ class KernelSelectionTest(unittest.TestCase):
|
||||
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))
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
|
||||
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
|
||||
opened.return_value.__enter__.return_value = installer
|
||||
if fail_headers:
|
||||
@@ -130,7 +130,7 @@ class KernelSelectionTest(unittest.TestCase):
|
||||
phases_impl.arch_install_system(ctx)
|
||||
expected = ["base", [f"{kernel}-headers" for kernel in kernels]]
|
||||
if not fail_headers:
|
||||
expected += ["early", "region", "user", ["omarchy"]]
|
||||
expected += ["early", ["omarchy"]]
|
||||
self.assertEqual(events, expected)
|
||||
unmask.assert_called_once_with(ctx)
|
||||
unmount.assert_called_once_with(ctx)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -17,6 +18,7 @@ from orchestrator import keyboard as KEYBOARD # noqa: E402
|
||||
# wherever this checkout can see a runtime, and skip the coverage test rather
|
||||
# than fail when none is around (a bare CI checkout of just this repo).
|
||||
SETUP_FORM_CANDIDATES = (
|
||||
Path(os.environ.get("OMARCHY_PATH", "/nonexistent")) / "install/provisioning/setup-form.sh",
|
||||
Path("/omarchy-source/install/provisioning/setup-form.sh"),
|
||||
ROOT.parent / "omarchy/install/provisioning/setup-form.sh",
|
||||
Path("/usr/share/omarchy/install/provisioning/setup-form.sh"),
|
||||
@@ -31,7 +33,7 @@ def supported_keymaps():
|
||||
r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL
|
||||
)
|
||||
assert block, f"no layout list in {candidate}"
|
||||
return [line.split("|", 1)[1] for line in block.group(1).splitlines()]
|
||||
return [line.split("|")[1] for line in block.group(1).splitlines()]
|
||||
return None
|
||||
|
||||
|
||||
@@ -79,6 +81,36 @@ class KeyboardConfigurationTest(unittest.TestCase):
|
||||
"KEYMAP=us\nFONT=default8x16\n",
|
||||
)
|
||||
|
||||
def test_input_choice_survives_while_console_keymap_stays_real(self):
|
||||
for method, keymap, layout in (("mozc", "jp106", ""), ("mozc", "us", ""), ("hangul", "us", "kr"), ("none", "us", "la"), ("pinyin", "us", ""), ("chewing", "us", "")):
|
||||
with self.subTest(method=method), tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap, method, layout))
|
||||
self.assertIn(f"KEYMAP={keymap}", (target / "etc/vconsole.conf").read_text())
|
||||
if keymap == "jp106":
|
||||
self.assertIn("XKBLAYOUT=jp", (target / "etc/vconsole.conf").read_text())
|
||||
self.assertEqual((target / "etc/omarchy/input-method").read_text(), f"INPUT_METHOD={method}\nXKB_LAYOUT={layout}\n")
|
||||
|
||||
def test_input_choice_with_no_console_map_defaults_to_us(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
self.assertTrue(KEYBOARD.configure_keyboard(target, "", "pinyin"))
|
||||
self.assertIn("INPUT_METHOD=pinyin", (target / "etc/omarchy/input-method").read_text())
|
||||
|
||||
def test_unknown_keyboard_does_not_silently_drop_an_input_choice(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
with self.assertRaises(ValueError):
|
||||
KEYBOARD.configure_keyboard(target, "unknown", "pinyin")
|
||||
|
||||
def test_invalid_input_preferences_are_rejected_without_writes(self):
|
||||
for method, layout in (("unknown", ""), ("hangul", "kr\nKEYMAP=ru"), ("none", "az")):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
with self.assertRaises(ValueError):
|
||||
KEYBOARD.configure_keyboard(target, "us", method, layout)
|
||||
self.assertFalse((target / "etc/omarchy/input-method").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+197
-213
@@ -1,251 +1,235 @@
|
||||
"""Region bootstrap contracts without Docker, pacman, or a running guest."""
|
||||
"""The target's region comes from its timezone, using the runtime's profiles."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
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 phases_impl
|
||||
sys.modules.setdefault(
|
||||
"orchestrator.archinstall_adapter",
|
||||
types.ModuleType("orchestrator.archinstall_adapter"),
|
||||
)
|
||||
from orchestrator import context, phases_impl, region # noqa: E402
|
||||
|
||||
|
||||
class RegionTargetTest(unittest.TestCase):
|
||||
def write_profiles(root: Path) -> Path:
|
||||
regions = root / "regions"
|
||||
cn = regions / "cn"
|
||||
(cn / "pacman").mkdir(parents=True)
|
||||
(cn / "timezones").write_text("# China\nAsia/Shanghai\n\n Asia/Urumqi \n")
|
||||
(cn / "packages").write_text("# keyring first\narchlinuxcn-keyring\nextra-tool\n")
|
||||
(cn / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://example.invalid/$arch\n")
|
||||
for channel in ("stable", "rc", "edge"):
|
||||
(cn / f"pacman/mirrorlist-{channel}.prepend").write_text(f"Server = https://{channel}.example.invalid/$repo/os/$arch\n")
|
||||
return regions
|
||||
|
||||
|
||||
class RegionSelectionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
self.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)
|
||||
self.regions = write_profiles(self.root)
|
||||
patcher = mock.patch.object(region, "REGIONS_DIR", self.regions)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.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_timezone_selects_region(self):
|
||||
for timezone, expected in [
|
||||
("Asia/Shanghai", "cn"),
|
||||
("Asia/Urumqi", "cn"),
|
||||
("Asia/Hong_Kong", "global"),
|
||||
("America/New_York", "global"),
|
||||
("UTC", "global"),
|
||||
("# China", "global"),
|
||||
("", "global"),
|
||||
(None, "global"),
|
||||
]:
|
||||
with self.subTest(timezone=timezone):
|
||||
self.assertEqual(region.region_for_timezone(timezone), expected)
|
||||
|
||||
def test_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")
|
||||
def test_iso_without_profiles_is_global(self):
|
||||
self.assertEqual(region.region_for_timezone("Asia/Shanghai", self.root / "missing"), "global")
|
||||
self.assertEqual(region.resolve_region({}, "Asia/Shanghai", self.root / "missing"), "global")
|
||||
|
||||
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_autoinstall_region_overrides_timezone(self):
|
||||
self.assertEqual(region.resolve_region({"region": "cn"}, "UTC"), "cn")
|
||||
self.assertEqual(region.resolve_region({"region": "global"}, "Asia/Shanghai"), "global")
|
||||
self.assertEqual(region.resolve_region({}, "Asia/Shanghai"), "cn")
|
||||
for bad in ("us", "CN", "..", "cn/../cn", True, 1, ["cn"]):
|
||||
with self.subTest(region=bad), self.assertRaises(RuntimeError):
|
||||
region.resolve_region({"region": bad}, "UTC")
|
||||
|
||||
def test_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_profile_packages_and_keyrings(self):
|
||||
self.assertEqual(region.region_packages("cn"), ["archlinuxcn-keyring", "extra-tool"])
|
||||
self.assertEqual(region.region_keyrings("cn"), ["archlinuxcn"])
|
||||
self.assertEqual(region.region_packages("global"), [])
|
||||
self.assertEqual(region.region_keyrings("global"), [])
|
||||
|
||||
def 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"):
|
||||
def from_env(self, config: dict) -> context.InstallContext:
|
||||
config_path = self.root / "config.json"
|
||||
creds_path = self.root / "creds.json"
|
||||
config_path.write_text(json.dumps(config))
|
||||
creds_path.write_text(json.dumps({"users": [{"username": "test"}]}))
|
||||
with mock.patch.dict(os.environ, {
|
||||
"OMARCHY_INSTALL_CONFIG": str(config_path),
|
||||
"OMARCHY_INSTALL_CREDS": str(creds_path),
|
||||
"OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"),
|
||||
}, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"):
|
||||
return context.InstallContext.from_env()
|
||||
|
||||
def test_context_resolves_region_before_install(self):
|
||||
self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn")
|
||||
self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global")
|
||||
# Deferred installs carry UTC until first boot, so they are global
|
||||
# unless the autoinstall config names a region.
|
||||
self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global")
|
||||
self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.from_env({"omarchy_install": {"region": "zz"}})
|
||||
|
||||
def test_marker_is_written_only_for_regional_targets(self):
|
||||
target = self.root / "target"
|
||||
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global"))
|
||||
self.assertFalse((target / "etc/omarchy/region").exists())
|
||||
|
||||
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn"))
|
||||
marker = target / "etc/omarchy/region"
|
||||
self.assertEqual(marker.read_text(), "cn\n")
|
||||
self.assertEqual(marker.stat().st_mode & 0o777, 0o644)
|
||||
|
||||
def test_region_packages_install_while_the_offline_mirror_is_mounted(self):
|
||||
for selected, expected_packages in [("global", []), ("cn", [["archlinuxcn-keyring", "extra-tool"]])]:
|
||||
with self.subTest(region=selected), ExitStack() as stack:
|
||||
events = []
|
||||
installer = mock.MagicMock()
|
||||
installer.add_additional_packages.side_effect = lambda packages: events.append(packages)
|
||||
config = types.SimpleNamespace(
|
||||
kernels=["linux-omarchy"], locale_config=None, mirror_config=None,
|
||||
swap=None, auth_config=None, app_config=None, timezone=None,
|
||||
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
|
||||
)
|
||||
ctx = types.SimpleNamespace(
|
||||
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
|
||||
target=self.root / "target", tailscale_authkey_path=None, omarchy_install={},
|
||||
region=selected,
|
||||
)
|
||||
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
|
||||
"_unmask_mkinitcpio_pacman_hooks", "_configure_limine_boot",
|
||||
"_write_pre_mounted_fstab", "_install_early_packages"):
|
||||
stack.enter_context(mock.patch.object(phases_impl, name))
|
||||
stack.enter_context(mock.patch.object(
|
||||
phases_impl, "_unmount_offline_package_cache", side_effect=lambda _: events.append("unmount")))
|
||||
stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True))
|
||||
stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"]))
|
||||
for name, value in (("is_pre_mount", True), ("root_user", None)):
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, name, return_value=value, create=True))
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
|
||||
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
|
||||
opened.return_value.__enter__.return_value = installer
|
||||
|
||||
phases_impl.arch_install_system(ctx)
|
||||
|
||||
self.assertEqual(events, [["linux-omarchy-headers"], ["omarchy"], *expected_packages, "unmount"])
|
||||
self.assertEqual((ctx.target / "etc/omarchy/region").exists(), selected != "global")
|
||||
|
||||
def test_finalizer_trusts_only_the_selected_region(self):
|
||||
for selected, expected in [
|
||||
("global", []),
|
||||
("cn", [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]),
|
||||
]:
|
||||
with self.subTest(region=selected), \
|
||||
mock.patch.object(phases_impl, "_run_target_setup_command") as run, \
|
||||
mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \
|
||||
mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"):
|
||||
ctx = types.SimpleNamespace(region=selected, defer_provisioning=True, target=Path("/unused"))
|
||||
phases_impl.run_system_finalizer(ctx)
|
||||
commands = [call.args[1] for call in run.call_args_list]
|
||||
self.assertEqual(commands[: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)
|
||||
self.assertEqual(commands[:-1], expected)
|
||||
self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system")
|
||||
|
||||
|
||||
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')
|
||||
class PrepareRegionsTest(unittest.TestCase):
|
||||
def run_prepare(self, regions: Path, root: Path) -> tuple[Path, Path, str]:
|
||||
payload = root / "payload"
|
||||
payload.mkdir()
|
||||
online = root / "online.conf"
|
||||
online.write_text("[core]\nInclude = /etc/pacman.d/mirrorlist\n")
|
||||
stubs = root / "bin"
|
||||
stubs.mkdir()
|
||||
log = root / "calls.log"
|
||||
for name in ("pacman", "pacman-key"):
|
||||
stub = stubs / name
|
||||
stub.write_text(f'#!/bin/bash\necho "{name} $*" >> "{log}"\n')
|
||||
stub.chmod(0o755)
|
||||
|
||||
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,
|
||||
subprocess.run(
|
||||
["bash", str(ROOT / "builder/prepare-regions.sh"), str(regions), str(payload), str(online)],
|
||||
env={**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}"},
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
return payload, online, log.read_text() if log.exists() else ""
|
||||
|
||||
def test_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_ships_every_profile_and_bootstraps_its_keyring(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
regions = write_profiles(root)
|
||||
payload, online, calls = self.run_prepare(regions, root)
|
||||
self.assertEqual((payload / "regions/cn/timezones").read_text(), (regions / "cn/timezones").read_text())
|
||||
self.assertIn("[archlinuxcn]", online.read_text())
|
||||
self.assertTrue(online.read_text().startswith("[core]\n"))
|
||||
self.assertEqual(calls.splitlines(), [
|
||||
f"pacman --config {online} --noconfirm -Sy --needed archlinuxcn-keyring",
|
||||
"pacman-key --populate archlinuxcn",
|
||||
])
|
||||
|
||||
def 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 settings_package(self, root: Path, with_regions: bool) -> Path:
|
||||
tree = root / "pkgroot"
|
||||
(tree / "usr/share/omarchy/default/pacman").mkdir(parents=True)
|
||||
if with_regions:
|
||||
write_profiles(root / "src")
|
||||
(root / "src/regions").rename(tree / "usr/share/omarchy/default/regions")
|
||||
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
|
||||
subprocess.run(["bsdtar", "--zstd", "-cf", str(package), "-C", str(tree), "usr"], check=True)
|
||||
return package
|
||||
|
||||
def test_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_published_build_reads_profiles_from_the_settings_package(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=True), root)
|
||||
self.assertEqual((payload / "regions/cn/packages").read_text(), "# keyring first\narchlinuxcn-keyring\nextra-tool\n")
|
||||
self.assertIn("[archlinuxcn]", online.read_text())
|
||||
self.assertIn("pacman-key --populate archlinuxcn", calls)
|
||||
|
||||
def test_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_settings_package_without_profiles_builds_a_global_iso(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=False), root)
|
||||
self.assertFalse((payload / "regions").exists())
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
def test_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())
|
||||
def test_unreadable_settings_package_fails_the_build(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
|
||||
package.write_text("not an archive")
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
self.run_prepare(package, root)
|
||||
|
||||
|
||||
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())
|
||||
def test_runtime_without_profiles_builds_a_global_iso(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
payload, online, calls = self.run_prepare(root / "missing", root)
|
||||
self.assertFalse((payload / "regions").exists())
|
||||
self.assertNotIn("archlinuxcn", online.read_text())
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+270
@@ -0,0 +1,270 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Takes an installed Omarchy, as a user's machine holds it, moves it to another
|
||||
# channel the way a user would, reboots it, and checks what came back.
|
||||
#
|
||||
# test/upgrade-test <installed_dir> <channel> <evidence_dir>
|
||||
#
|
||||
# installed_dir is what test/integration leaves once its install has passed,
|
||||
# test-runs/<iso>-integration: base.qcow2, OVMF_VARS.4m.fd and id_ed25519. It is
|
||||
# not written to; the machine runs on an overlay. channel is edge or rc.
|
||||
#
|
||||
# The machine boots through the firmware and the boot loader, so the reboot
|
||||
# after the update starts whatever kernel the update installed. Commands run
|
||||
# in a login shell, as a user's terminal does: Omarchy's commands need its
|
||||
# environment.
|
||||
#
|
||||
# evidence_dir gets before.png and after.png (the desktop, unlocked), the
|
||||
# update's output, the facts before and after, and report.md; stuck.png too if
|
||||
# the reboot did not come back by itself. The exit status is 0 only if every
|
||||
# check passed.
|
||||
#
|
||||
# Needs qemu-system-x86_64, qemu-img, ssh, python3 and a usable /dev/kvm.
|
||||
# Env: SMP (4), MEM (8G), SSH_PORT (2422), GUEST_PASSWORD (omarchy),
|
||||
# UPDATE_TIMEOUT (seconds, 1800), EXTRA_MIGRATIONS (migration files to put
|
||||
# in place before the update, to try a fix that is not released yet).
|
||||
set -uo pipefail
|
||||
[[ $# -eq 3 ]] || { sed -n '3,24p' "$0" >&2; exit 2; }
|
||||
in=$(realpath "$1") channel=$2 out=$(realpath -m "$3")
|
||||
pw=${GUEST_PASSWORD:-omarchy} port=${SSH_PORT:-2422}
|
||||
[[ $channel == edge || $channel == rc ]] || { echo "upgrade-test: channel is edge or rc" >&2; exit 2; }
|
||||
for f in base.qcow2 OVMF_VARS.4m.fd id_ed25519; do
|
||||
[[ -s $in/$f ]] || { echo "upgrade-test: $in/$f is missing" >&2; exit 1; }
|
||||
done
|
||||
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "upgrade-test: /dev/kvm is not usable" >&2; exit 1; }
|
||||
for fw in /usr/share/edk2/x64/OVMF_CODE.4m.fd /usr/share/OVMF/OVMF_CODE_4M.fd; do
|
||||
[[ -f $fw ]] && { fw_code=$fw; break; }
|
||||
done
|
||||
: "${fw_code:?no UEFI firmware found}"
|
||||
|
||||
mkdir -p "$out"
|
||||
state=$(mktemp -d "${VM_STATE_DIR:-/var/tmp}/upgrade-test.XXXXXX")
|
||||
cleanup() {
|
||||
local status=$? pid
|
||||
if [[ -f $state/qemu.pid ]]; then
|
||||
pid=$(cat "$state/qemu.pid")
|
||||
kill "$pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||
fi
|
||||
rm -rf "$state"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT TERM
|
||||
|
||||
log() { printf '==> %s\n' "$*"; }
|
||||
install -m600 "$in/id_ed25519" "$state/key"
|
||||
ssh_opts=(-i "$state/key" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
|
||||
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=3)
|
||||
guest() { ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "bash -lc $(printf %q "$1")" </dev/null; }
|
||||
wait_ssh() { # seconds
|
||||
local start=$SECONDS
|
||||
until ssh "${ssh_opts[@]}" omarchy@127.0.0.1 true </dev/null 2>/dev/null; do
|
||||
kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null || { echo "upgrade-test: qemu exited" >&2; return 1; }
|
||||
(( SECONDS - start < $1 )) || return 1
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
qmp() { # shot <file> | key <qcode>...
|
||||
python3 - "$state/qmp.sock" "$@" <<'PY'
|
||||
import json, socket, sys
|
||||
s = socket.socket(socket.AF_UNIX); s.settimeout(20); s.connect(sys.argv[1]); f = s.makefile("rw"); f.readline()
|
||||
def run(command):
|
||||
f.write(json.dumps(command) + "\n"); f.flush()
|
||||
while "return" not in (reply := json.loads(f.readline())):
|
||||
if "error" in reply: sys.exit(reply["error"]["desc"])
|
||||
run({"execute": "qmp_capabilities"})
|
||||
if sys.argv[2] == "shot":
|
||||
run({"execute": "screendump", "arguments": {"filename": sys.argv[3], "format": "png"}})
|
||||
else:
|
||||
for key in sys.argv[3:]:
|
||||
run({"execute": "send-key", "arguments": {"keys": [{"type": "qcode", "data": key}]}})
|
||||
PY
|
||||
}
|
||||
# The session starts locked. Type the password, as its owner would, and wait
|
||||
# for the desktop to draw.
|
||||
desktop_shot() { # file
|
||||
local key keys=()
|
||||
for (( key = 0; key < ${#pw}; key++ )); do keys+=("${pw:key:1}"); done
|
||||
qmp key "${keys[@]}" ret || true
|
||||
sleep 8
|
||||
qmp shot "$1" || echo "upgrade-test: no screenshot" >&2
|
||||
}
|
||||
# One fact per line, name=value, the same names before and after.
|
||||
facts() { # file
|
||||
guest '
|
||||
link=~/.local/state/omarchy/current/background; target=$(readlink -f "$link" 2>/dev/null)
|
||||
echo "omarchy=$(pacman -Qq omarchy omarchy-dev 2>/dev/null | sort -u | xargs -r pacman -Q 2>/dev/null | paste -sd, -)"
|
||||
echo "channel=$(omarchy-channel-current 2>/dev/null)"
|
||||
echo "kernel=$(uname -r)"
|
||||
echo "kernel_is_installed=$([[ -d /usr/lib/modules/$(uname -r)/kernel ]] && echo yes || echo no)"
|
||||
echo "systemd=$(systemctl --version | sed -n "1s/^systemd \([0-9.]*\).*/\1/p")"
|
||||
echo "hyprland=$(pacman -Q hyprland 2>/dev/null | cut -d" " -f2)"
|
||||
echo "system_state=$(systemctl is-system-running 2>/dev/null)"
|
||||
# A manager that cannot be asked is not a manager with nothing failed.
|
||||
units=$(systemctl --failed --no-legend --plain 2>/dev/null) && units=$(awk "{print \$1}" <<<"$units" | paste -sd, -) || units="could not ask systemd"
|
||||
user_units=$(systemctl --user --failed --no-legend --plain 2>/dev/null) && user_units=$(awk "{print \$1}" <<<"$user_units" | paste -sd, -) || user_units="could not ask the user manager"
|
||||
echo "failed_units=$units"
|
||||
echo "failed_user_units=$user_units"
|
||||
echo "hyprland_running=$(pgrep -c -x Hyprland)"
|
||||
echo "migrations_run=$(find ~/.local/state/omarchy/migrations -maxdepth 1 -name "*.sh" | wc -l)"
|
||||
echo "migrations_skipped=$(find ~/.local/state/omarchy/migrations/skipped -name "*.sh" 2>/dev/null | wc -l)"
|
||||
echo "wallpaper=${target##*/}"
|
||||
echo "wallpaper_exists=$([[ -n $target && -e $target ]] && echo yes || echo no)"
|
||||
echo "boot_entry_groups=$(sudo grep -c "^/+" /boot/limine.conf 2>/dev/null)"
|
||||
echo "boot_id=$(cut -c1-8 /proc/sys/kernel/random/boot_id)"
|
||||
' 2>/dev/null | grep -E '^[a-z_]+=' >"$1"
|
||||
}
|
||||
fact() { sed -n "s/^$2=//p" "$1" | head -n 1; }
|
||||
|
||||
qemu-img create -qf qcow2 -b "$in/base.qcow2" -F qcow2 "$state/run.qcow2"
|
||||
cp "$in/OVMF_VARS.4m.fd" "$state/vars.fd"
|
||||
: >"$out/serial.log"
|
||||
qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp "${SMP:-4}" -m "${MEM:-8G}" \
|
||||
-drive "if=pflash,format=raw,readonly=on,file=$fw_code" -drive "if=pflash,format=raw,file=$state/vars.fd" \
|
||||
-drive "file=$state/run.qcow2,format=qcow2,if=none,id=d0" -device virtio-blk-pci,drive=d0 \
|
||||
-device virtio-vga -device virtio-rng-pci -display none -usb -device usb-kbd -serial "file:$out/serial.log" \
|
||||
-netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \
|
||||
-qmp "unix:$state/qmp.sock,server,nowait" -pidfile "$state/qemu.pid" -daemonize
|
||||
|
||||
log "Booting the installed system"
|
||||
wait_ssh 300 || { echo "upgrade-test: the installed system did not come up" >&2; exit 1; }
|
||||
# The overlay is thrown away: let sudo through without asking, so a long
|
||||
# update does not stop at a password prompt nobody is there to answer.
|
||||
# sudo takes the last rule that matches, and reads this folder in name order:
|
||||
# the name has to sort after the install's own rule for the wheel group.
|
||||
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "echo $(printf %q "$pw") | sudo -S -p '' sh -c 'printf \"%s\\n\" \"omarchy ALL=(ALL:ALL) NOPASSWD: ALL\" \"Defaults:omarchy !authenticate\" >/etc/sudoers.d/zzz-upgrade-test'" </dev/null
|
||||
# "sudo -v" is the hard one: it asks unless every rule for the user lets it
|
||||
# through, which is why the rule alone is not enough.
|
||||
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 'sudo -k; sudo -n true && sudo -n -v' </dev/null ||
|
||||
{ echo "upgrade-test: sudo still asks for a password in the guest" >&2; exit 1; }
|
||||
guest 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
|
||||
facts "$out/before.txt"
|
||||
desktop_shot "$out/before.png"
|
||||
log "Before: $(fact "$out/before.txt" omarchy), kernel $(fact "$out/before.txt" kernel), channel $(fact "$out/before.txt" channel)"
|
||||
|
||||
for migration in ${EXTRA_MIGRATIONS:-}; do
|
||||
log "Putting $(basename "$migration") in place before the update"
|
||||
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "sudo tee /usr/share/omarchy/migrations/$(basename "$migration") >/dev/null" <"$migration"
|
||||
done
|
||||
|
||||
# A channel that offers what the machine already has, or something older, is
|
||||
# no upgrade. Say so now: the release's own updater, run with nothing to do,
|
||||
# ends at a prompt nobody is there to answer.
|
||||
case $channel in edge) package=omarchy-dev ;; *) package=omarchy ;; esac
|
||||
# A server that cannot be reached, or does not answer in time, is not a channel
|
||||
# with nothing on offer: that is a failure of its own, said as one.
|
||||
if ! offered=$(guest "set -o pipefail; curl -fsSL --connect-timeout 20 --max-time 120 --retry 2 https://pkgs.omarchy.org/$channel/\$(uname -m)/omarchy.db | bsdtar -xOf - '$package-[0-9]*/desc' | sed -n '/^%VERSION%\$/{n;p;}' | head -n 1" 2>"$out/probe.log") || [[ -z $offered ]]; then
|
||||
{
|
||||
echo "**The $channel channel could not be asked what it offers.** The test did not run."
|
||||
echo
|
||||
echo '```'
|
||||
tail -n 5 "$out/probe.log"
|
||||
echo '```'
|
||||
} >"$out/report.md"
|
||||
cat "$out/report.md"
|
||||
exit 1
|
||||
fi
|
||||
installed=$(guest "pacman -Q $package 2>/dev/null | cut -d' ' -f2" 2>/dev/null)
|
||||
log "The $channel channel offers $package $offered; installed: ${installed:-none}"
|
||||
# pacman's own ordering: 0 for the same version, below 0 for an older offer.
|
||||
newer=1
|
||||
[[ -z $installed ]] || newer=$(guest "vercmp $(printf %q "$offered") $(printf %q "$installed")" 2>/dev/null)
|
||||
if (( ${newer:-0} <= 0 )); then
|
||||
{
|
||||
echo "**Nothing to upgrade to.** The $channel channel offers \`$package $offered\`, and the machine has \`$(fact "$out/before.txt" omarchy)\`."
|
||||
echo
|
||||
echo "The test did not run: point it at a channel that holds a newer Omarchy."
|
||||
} >"$out/report.md"
|
||||
cat "$out/report.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "omarchy-channel-set $channel"
|
||||
update_start=$SECONDS
|
||||
guest "timeout ${UPDATE_TIMEOUT:-1800} omarchy-channel-set $channel" >"$out/update.log" 2>&1
|
||||
update_status=$?
|
||||
update_seconds=$(( SECONDS - update_start ))
|
||||
sed -i 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\r/\n/g' "$out/update.log"
|
||||
migrations_in_update=$(grep -c '^Running migration' "$out/update.log" || true)
|
||||
log "The update exited $update_status after $update_seconds s, $migrations_in_update migrations"
|
||||
|
||||
log "Rebooting"
|
||||
guest 'sudo systemctl reboot' >/dev/null 2>&1 || true
|
||||
# Wait for the old boot to go away first: its sshd answers for a moment yet.
|
||||
for _ in $(seq 1 90); do
|
||||
ssh "${ssh_opts[@]}" -o ConnectTimeout=2 omarchy@127.0.0.1 true </dev/null 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
rebooted=no
|
||||
if wait_ssh 150; then
|
||||
rebooted=yes
|
||||
else
|
||||
# A boot that sits there until a key is pressed is a failure, and a known
|
||||
# kind: keep what the screen showed, then press Enter to find out whether
|
||||
# that was it, and to have a system to collect the rest from.
|
||||
qmp shot "$out/stuck.png" || true
|
||||
qmp key ret || true
|
||||
if wait_ssh 150; then rebooted="only after a key was pressed"; fi
|
||||
fi
|
||||
if [[ $rebooted != no ]]; then
|
||||
guest 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
|
||||
facts "$out/after.txt"
|
||||
desktop_shot "$out/after.png"
|
||||
else
|
||||
: >"$out/after.txt"
|
||||
qmp shot "$out/after.png" || true
|
||||
fi
|
||||
log "After: $(fact "$out/after.txt" omarchy), kernel $(fact "$out/after.txt" kernel), channel $(fact "$out/after.txt" channel)"
|
||||
|
||||
failures=0
|
||||
rows=()
|
||||
check() { # description, command...
|
||||
local what=$1; shift
|
||||
if "$@"; then rows+=("| ✅ | $what |"); else rows+=("| ❌ | $what |"); failures=$((failures + 1)); fi
|
||||
}
|
||||
b() { fact "$out/before.txt" "$1"; }
|
||||
a() { fact "$out/after.txt" "$1"; }
|
||||
check "The update finished without an error (exit $update_status)" test "$update_status" = 0
|
||||
check "No migration was skipped ($(a migrations_skipped) skipped)" test "$(a migrations_skipped)" = 0
|
||||
check "The system came back from the reboot by itself (came back: $rebooted)" test "$rebooted" = yes
|
||||
check "It is a new boot (boot ID $(b boot_id) → $(a boot_id))" test -n "$(a boot_id)" -a "$(a boot_id)" != "$(b boot_id)"
|
||||
check "It is on the $channel channel (got: $(a channel))" test "$(a channel)" = "$channel"
|
||||
# A channel that holds what the machine already had upgrades nothing, and
|
||||
# every other check would pass on a machine that was only rebooted.
|
||||
check "Omarchy itself changed ($(b omarchy) → $(a omarchy))" test -n "$(a omarchy)" -a "$(a omarchy)" != "$(b omarchy)"
|
||||
check "The running kernel is an installed one ($(a kernel))" test "$(a kernel_is_installed)" = yes
|
||||
check "systemd reports the system running (got: $(a system_state))" test "$(a system_state)" = running
|
||||
failed="$(a failed_units) $(a failed_user_units)"
|
||||
failed=$(xargs <<<"$failed")
|
||||
check "No failed units (${failed:-none})" test -z "$failed"
|
||||
check "The desktop is up (Hyprland processes: $(a hyprland_running))" test "$(a hyprland_running)" = 1
|
||||
# The same picture, whatever format it is kept in now: an update may convert
|
||||
# the bundled images, and may not swap the one the user chose for another.
|
||||
before_wallpaper=$(b wallpaper) after_wallpaper=$(a wallpaper)
|
||||
check "The wallpaper is the one the user had, and exists ($before_wallpaper → $after_wallpaper)" \
|
||||
test "$(a wallpaper_exists)" = yes -a -n "$after_wallpaper" -a "${after_wallpaper%.*}" = "${before_wallpaper%.*}"
|
||||
check "The boot menu has one Omarchy group ($(a boot_entry_groups))" test "$(a boot_entry_groups)" = 1
|
||||
|
||||
row() { printf '| %s | %s | %s |\n' "$1" "$(b "$2")" "$(a "$2")"; }
|
||||
{
|
||||
if (( failures )); then echo "**$failures of ${#rows[@]} checks failed.**"; else echo "**All ${#rows[@]} checks passed.**"; fi
|
||||
echo
|
||||
echo "| | Before | After the update and a reboot |"
|
||||
echo "| --- | --- | --- |"
|
||||
row "Omarchy" omarchy
|
||||
row "Channel" channel
|
||||
row "Kernel running" kernel
|
||||
row "systemd" systemd
|
||||
row "Hyprland" hyprland
|
||||
row "Migrations run" migrations_run
|
||||
row "Wallpaper" wallpaper
|
||||
echo
|
||||
echo "The update took $update_seconds s and ran $migrations_in_update migrations."
|
||||
echo
|
||||
echo "| | Check |"
|
||||
echo "| --- | --- |"
|
||||
printf '%s\n' "${rows[@]}"
|
||||
} >"$out/report.md"
|
||||
cat "$out/report.md"
|
||||
(( failures == 0 ))
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Fetches a run's artifact for the publish job, and fast.
|
||||
#
|
||||
# nightly-fetch <owner/repo> <run_id> <artifact_name> <out_dir>
|
||||
#
|
||||
# The download action takes a multi-gigabyte artifact over a few connections,
|
||||
# and GitHub's storage limits each one to a few megabytes a second: an 11 GB
|
||||
# release would outlast the job. An artifact is one zip at a signed address,
|
||||
# so this asks the API for that address and takes it over 16 connections.
|
||||
#
|
||||
# The zip goes to TMPDIR and is deleted once unpacked, so there has to be room
|
||||
# for the artifact twice: point TMPDIR at a disk, not at a /tmp held in memory.
|
||||
#
|
||||
# Needs gh (with GH_TOKEN allowed to read the run's artifacts), curl, aria2c
|
||||
# and unzip. A run started again keeps the artifacts of its earlier attempts
|
||||
# under the same name: the newest is the one fetched.
|
||||
set -euo pipefail
|
||||
[[ $# -eq 4 ]] || { sed -n '2,16p' "$0" >&2; exit 2; }
|
||||
repo=$1 run=$2 name=$3 out=$4
|
||||
fail() { echo "nightly-fetch: $*" >&2; exit 1; }
|
||||
|
||||
id=$(gh api "repos/$repo/actions/runs/$run/artifacts" --paginate \
|
||||
--jq ".artifacts[] | select(.name == \"$name\" and .expired == false) | .id" | sort -n | tail -n 1)
|
||||
[[ -n $id ]] || fail "run $run has no artifact named $name"
|
||||
size=$(gh api "repos/$repo/actions/artifacts/$id" --jq .size_in_bytes)
|
||||
echo "nightly-fetch: artifact $id, $((size / 1048576)) MiB"
|
||||
|
||||
zip=$(mktemp -u "${TMPDIR:-/tmp}/nightly-fetch.XXXXXX.zip")
|
||||
mkdir -p "$out"
|
||||
for place in "$(dirname "$zip")" "$out"; do
|
||||
free=$(( $(df --output=avail -B1 "$place" | tail -n 1) ))
|
||||
(( free > size )) || fail "$place has $((free / 1048576)) MiB free, the artifact is $((size / 1048576)) MiB"
|
||||
done
|
||||
trap 'rm -f "$zip" "$zip.aria2"' EXIT
|
||||
started=$(date +%s)
|
||||
# The address is signed for a few minutes. A try that fails asks for a new one
|
||||
# and carries on from what is already there.
|
||||
for attempt in 1 2 3; do
|
||||
url=$(curl -fsS -o /dev/null -w '%{redirect_url}' \
|
||||
-H "Authorization: Bearer $(gh auth token)" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$repo/actions/artifacts/$id/zip")
|
||||
[[ $url == https://* ]] || fail "the API gave no download address for artifact $id"
|
||||
if aria2c --quiet --continue --max-connection-per-server=16 --split=16 --min-split-size=16M \
|
||||
--file-allocation=none --dir="$(dirname "$zip")" --out="$(basename "$zip")" "$url"; then
|
||||
break
|
||||
fi
|
||||
(( attempt < 3 )) || fail "the download failed three times"
|
||||
echo "nightly-fetch: attempt $attempt failed, trying again" >&2
|
||||
sleep 10
|
||||
done
|
||||
[[ $(stat -c %s "$zip") == "$size" ]] || fail "downloaded $(stat -c %s "$zip") bytes, the artifact is $size"
|
||||
seconds=$(( $(date +%s) - started ))
|
||||
echo "nightly-fetch: downloaded in $seconds s, $(( size / 1048576 / (seconds > 0 ? seconds : 1) )) MiB/s"
|
||||
|
||||
unzip -q "$zip" -d "$out"
|
||||
echo "nightly-fetch: $(find "$out" -type f | wc -l) files in $out"
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# Lays out what one channel of a nightly run publishes and writes the
|
||||
# latest.json that describes it.
|
||||
#
|
||||
# nightly-layout <channel> <version> <release> <iso> <sealed_dir> <vm_packages> <out_dir>
|
||||
#
|
||||
# release the path under the site, from nightly-plan
|
||||
# iso the ISO that was installed
|
||||
# sealed_dir what vm-seal made
|
||||
# vm_packages what the sealed image answered to: pacman -Q of its Omarchy
|
||||
# package, pacman -Qo of its kernel's pkgbase, uname -r
|
||||
#
|
||||
# Env: SITE (https://nightly.omarchy.org), ISO_SITE (https://iso.omarchy.org),
|
||||
# ISO_COMMIT (the omarchy-iso commit, default: HEAD of this checkout).
|
||||
#
|
||||
# out_dir gets, under <release>/:
|
||||
#
|
||||
# omarchy-<...>.qcow2 and .qcow2.sha256 the VM image: one file, which boots
|
||||
# by itself through UEFI
|
||||
# unpacked/ its kernel, initramfs and kernel
|
||||
# arguments for booting it directly,
|
||||
# vm-boot, and their SHA256SUMS
|
||||
# omarchy-edge-<version>.iso and .sha256 edge only
|
||||
#
|
||||
# and <channel>/latest.json. The released ISO already has its place:
|
||||
# latest.json points there and the ISO stays where it is.
|
||||
#
|
||||
# The ISO and the image are moved, not copied: they are gigabytes.
|
||||
set -euo pipefail
|
||||
[[ $# -eq 7 ]] || { sed -n '2,12p' "$0" >&2; exit 2; }
|
||||
channel=$1 version=$2 release=$3 iso_file=$4 sealed=$5 packages=$6 out=$7
|
||||
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
site=${SITE:-https://nightly.omarchy.org}
|
||||
iso_site=${ISO_SITE:-https://iso.omarchy.org}
|
||||
iso_commit=${ISO_COMMIT:-$(git -C "$here" rev-parse HEAD)}
|
||||
fail() { echo "nightly-layout: $*" >&2; exit 1; }
|
||||
|
||||
[[ $channel == edge || $channel == stable ]] || fail "'$channel' is not a channel"
|
||||
[[ -f $iso_file ]] || fail "no ISO at $iso_file"
|
||||
unpacked=(vmlinuz initrd cmdline)
|
||||
for file in base.qcow2 "${unpacked[@]}"; do
|
||||
[[ -s $sealed/$file ]] || fail "the sealed image has no $file"
|
||||
done
|
||||
|
||||
omarchy=$(awk '$1 == "omarchy" || $1 == "omarchy-dev" { print $2; exit }' "$packages")
|
||||
kernel=$(tail -n 1 "$packages")
|
||||
[[ -n $omarchy ]] || fail "$packages names no omarchy package"
|
||||
[[ $kernel =~ ^[0-9]+\.[0-9] ]] || fail "$packages does not end with a kernel release"
|
||||
# An edge package's version carries the commit it was built from.
|
||||
omarchy_commit=$(sed -nE 's/.*\.g([0-9a-f]+)-.*/\1/p' <<<"$omarchy")
|
||||
|
||||
dir=$out/$release
|
||||
mkdir -p "$dir/unpacked" "$out/$channel"
|
||||
if [[ $channel == edge ]]; then
|
||||
name=omarchy-$channel-$version
|
||||
iso=$name.iso
|
||||
iso_url=$site/$release/$iso
|
||||
mv "$iso_file" "$dir/$iso"
|
||||
(cd "$dir" && sha256sum "$iso" >"$iso.sha256")
|
||||
iso_sha256=$(cut -d' ' -f1 "$dir/$iso.sha256") iso_size=$(stat -c %s "$dir/$iso")
|
||||
else
|
||||
name=omarchy-$version
|
||||
iso=$name.iso
|
||||
[[ ${iso_file##*/} == "$iso" ]] || fail "the released ISO is $iso, not ${iso_file##*/}"
|
||||
iso_url=$iso_site/$iso
|
||||
iso_sha256=$(sha256sum "$iso_file" | cut -d' ' -f1) iso_size=$(stat -c %s "$iso_file")
|
||||
fi
|
||||
|
||||
# The image has the ISO's name, so the two are told apart by their endings.
|
||||
image=$name.qcow2
|
||||
mv "$sealed/base.qcow2" "$dir/$image"
|
||||
(cd "$dir" && sha256sum "$image" >"$image.sha256")
|
||||
|
||||
for file in "${unpacked[@]}"; do mv "$sealed/$file" "$dir/unpacked/$file"; done
|
||||
cp "$here/vm-boot" "$dir/unpacked/vm-boot"
|
||||
unpacked+=(vm-boot)
|
||||
(cd "$dir/unpacked" && sha256sum "${unpacked[@]}" >SHA256SUMS)
|
||||
|
||||
latest=$out/$channel/latest.json
|
||||
jq -n \
|
||||
--arg version "$version" --arg channel "$channel" --arg release "$site/$release" --arg image "$image" \
|
||||
--arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg url "$iso_url" --arg sha256 "$iso_sha256" --argjson size "$iso_size" \
|
||||
--arg iso_commit "$iso_commit" --arg omarchy_version "$omarchy" --arg omarchy_commit "$omarchy_commit" \
|
||||
--arg kernel "$kernel" \
|
||||
--arg vm_sha256 "$(cut -d' ' -f1 "$dir/$image.sha256")" --argjson vm_size "$(stat -c %s "$dir/$image")" \
|
||||
--args '{
|
||||
version: $version, channel: $channel, built_at: $built_at,
|
||||
url: $url, sha256: $sha256, size: $size,
|
||||
iso_commit: $iso_commit, omarchy_version: $omarchy_version, omarchy_commit: $omarchy_commit,
|
||||
kernel: $kernel,
|
||||
vm: {
|
||||
url: "\($release)/\($image)", sha256: $vm_sha256, size: $vm_size,
|
||||
unpacked: "\($release)/unpacked/", unpacked_files: ($ARGS.positional + ["SHA256SUMS"]),
|
||||
user: "omarchy", password: "omarchy",
|
||||
ssh: "keys only; vm-boot makes one for each boot"
|
||||
}
|
||||
}' "${unpacked[@]}" >"$latest"
|
||||
|
||||
# What is about to be published has to say what it should: one value in every
|
||||
# field, and checksums that are checksums.
|
||||
jq -e --arg channel "$channel" '
|
||||
def one_line: type == "string" and test("^[^\n]+$");
|
||||
def sha: type == "string" and test("^[0-9a-f]{64}$");
|
||||
(.version | one_line) and .channel == $channel
|
||||
and (.url | test("^https://[^\n ]+\\.iso$")) and (.sha256 | sha) and .size > 0
|
||||
and (.iso_commit | test("^[0-9a-f]{40}$"))
|
||||
and (.omarchy_version | one_line) and (.omarchy_commit | test("^[0-9a-f]*$")) and (.kernel | one_line)
|
||||
and (.vm.url | test("^https://[^\n ]+\\.qcow2$")) and (.vm.sha256 | sha) and .vm.size > 0
|
||||
and (.vm.unpacked | test("^https://[^\n ]+/unpacked/$")) and (.vm.unpacked_files | length) == 5
|
||||
' "$latest" >/dev/null || { cat "$latest" >&2; fail "latest.json does not say what it should"; }
|
||||
cat "$latest"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Says which channels a nightly run makes, under which version, and where
|
||||
# each one is published. It decides; the workflow fetches what it needs to
|
||||
# know and passes it in, so every case can be tried without a runner.
|
||||
#
|
||||
# nightly-plan <date> <run_number> <run_attempt> <event> <release_tag> [published_stable]
|
||||
#
|
||||
# date today, as YYYY.MM.DD
|
||||
# event what started the run: schedule, workflow_dispatch
|
||||
# release_tag omacom/omarchy's latest release, such as v4.0.4
|
||||
# published_stable the version stable/latest.json names now, if any
|
||||
#
|
||||
# Prints a JSON list of {channel, version, release}; release is the path
|
||||
# under the site.
|
||||
#
|
||||
# An edge version is the date and the run number. A stable version is the
|
||||
# release's own, and on a scheduled run a release already published is left
|
||||
# out: it is done once. Two builds are never the same bytes and a published
|
||||
# file is never replaced, so no path is used twice: a stable image has the run
|
||||
# number in its path, and a run started again adds its attempt to the number.
|
||||
set -euo pipefail
|
||||
[[ $# -eq 5 || $# -eq 6 ]] || { sed -n '2,12p' "$0" >&2; exit 2; }
|
||||
date=$1 run=$2 attempt=$3 event=$4 tag=$5 published=${6:-}
|
||||
fail() { echo "nightly-plan: $*" >&2; exit 1; }
|
||||
|
||||
[[ $date =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || fail "'$date' is not a date like 2026.10.09"
|
||||
[[ $run =~ ^[1-9][0-9]*$ ]] || fail "'$run' is not a run number"
|
||||
[[ $attempt =~ ^[1-9][0-9]*$ ]] || fail "'$attempt' is not a run attempt"
|
||||
stable=${tag#v}
|
||||
[[ $stable =~ ^[0-9]+(\.[0-9]+)+$ ]] || fail "the latest release is '$tag', not a version"
|
||||
|
||||
build=$run
|
||||
[[ $attempt == 1 ]] || build+=.$attempt
|
||||
|
||||
channels=$(jq -cn --arg v "$date.$build" '[{channel: "edge", version: $v, release: "edge/\($v)"}]')
|
||||
if [[ $event == schedule && $published == "$stable" ]]; then
|
||||
echo "nightly-plan: stable $stable is already published" >&2
|
||||
else
|
||||
channels=$(jq -c --arg v "$stable" --arg build "$build" \
|
||||
'. + [{channel: "stable", version: $v, release: "stable/\($v)/\($build)"}]' <<<"$channels")
|
||||
fi
|
||||
echo "$channels"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Runs rclone for the publish job and says when it only got there on a retry.
|
||||
#
|
||||
# nightly-rclone <rclone arguments...>
|
||||
#
|
||||
# rclone tries a failed transfer again by itself, and a run that passes on the
|
||||
# second attempt looks like any other. This passes rclone's output and exit
|
||||
# status through, and adds a warning for the run's page naming the files that
|
||||
# failed on an earlier attempt.
|
||||
set -uo pipefail
|
||||
log=$(mktemp)
|
||||
trap 'rm -f "$log"' EXIT
|
||||
rclone "$@" 2>&1 | tee "$log"
|
||||
status=${PIPESTATUS[0]}
|
||||
files=$(sed -nE 's/^.*ERROR : (.*): Failed to copy: .*$/\1/p' "$log" | sort -u)
|
||||
if [[ $status -eq 0 && -n $files ]]; then
|
||||
echo "::warning::rclone needed a retry for $(wc -l <<<"$files") file(s): $(paste -sd' ' <<<"$files")"
|
||||
fi
|
||||
exit "$status"
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# Boots a sealed Omarchy image (vm-seal) as a throwaway machine.
|
||||
#
|
||||
# vm-boot <image> boot, print how to log in, run until Ctrl-C
|
||||
# vm-boot <image> <command...> boot, run the command over SSH, power off;
|
||||
# exits with the command's status
|
||||
#
|
||||
# <image> is a downloaded disk, omarchy-<...>.qcow2, with vmlinuz, initrd and
|
||||
# cmdline in the same directory as this script, as in a published "unpacked"
|
||||
# folder. Or it is a directory as vm-seal leaves it, holding base.qcow2 and
|
||||
# those three files.
|
||||
#
|
||||
# Nothing is written to the image: the machine runs on a copy-on-write overlay
|
||||
# that is deleted on exit. Each boot gets a new SSH key and a new machine ID;
|
||||
# the image itself holds no key and its SSH takes no password.
|
||||
#
|
||||
# Needs qemu-system-x86_64, qemu-img, ssh and a usable /dev/kvm.
|
||||
# Env: SMP (4), MEM (4G), SSH_PORT (random), VM_UUID (random), APPEND (more
|
||||
# kernel arguments), QEMU_EXTRA (more qemu arguments, split on spaces),
|
||||
# VM_STATE_DIR (/var/tmp; holds the overlay, so it needs room to grow).
|
||||
set -euo pipefail
|
||||
[[ $# -ge 1 ]] || { sed -n '2,21p' "$0" >&2; exit 2; }
|
||||
image=$(realpath "$1"); shift
|
||||
if [[ -d $image ]]; then
|
||||
base=$image disk=$image/base.qcow2
|
||||
else
|
||||
base=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) disk=$image
|
||||
fi
|
||||
[[ -s $disk ]] || { echo "vm-boot: there is no disk image at $disk" >&2; exit 1; }
|
||||
for f in vmlinuz initrd cmdline; do
|
||||
[[ -s $base/$f ]] || { echo "vm-boot: $base/$f is missing" >&2; exit 1; }
|
||||
done
|
||||
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "vm-boot: /dev/kvm is not usable" >&2; exit 1; }
|
||||
|
||||
state=$(mktemp -d "${VM_STATE_DIR:-/var/tmp}/vm.XXXXXX")
|
||||
cleanup() {
|
||||
local status=$? pid
|
||||
if [[ -f $state/qemu.pid ]]; then
|
||||
pid=$(cat "$state/qemu.pid")
|
||||
kill "$pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||
fi
|
||||
rm -rf "$state"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT TERM
|
||||
port=${SSH_PORT:-$(( 20000 + RANDOM % 10000 ))}
|
||||
uuid=${VM_UUID:-$(cat /proc/sys/kernel/random/uuid)}
|
||||
|
||||
# A key made for this boot only, handed in through the firmware tables as a
|
||||
# systemd credential that writes authorized_keys.
|
||||
ssh-keygen -q -t ed25519 -N '' -C vm-boot -f "$state/id_ed25519"
|
||||
keys=$(printf 'd /home/omarchy/.ssh 0700 omarchy omarchy -\nf+ /home/omarchy/.ssh/authorized_keys 0600 omarchy omarchy - %s\n' \
|
||||
"$(cat "$state/id_ed25519.pub")" | base64 -w0)
|
||||
ssh_opts=(-i "$state/id_ed25519" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes
|
||||
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR)
|
||||
|
||||
qemu-img create -qf qcow2 -b "$disk" -F qcow2 "$state/run.qcow2"
|
||||
t0=$(date +%s%N)
|
||||
# shellcheck disable=SC2086
|
||||
qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp "${SMP:-4}" -m "${MEM:-4G}" \
|
||||
-kernel "$base/vmlinuz" -initrd "$base/initrd" -append "$(cat "$base/cmdline")${APPEND:+ $APPEND}" \
|
||||
-uuid "$uuid" \
|
||||
-drive "file=$state/run.qcow2,format=qcow2,if=none,id=d0" -device virtio-blk-pci,drive=d0 \
|
||||
-smbios "type=11,value=io.systemd.credential.binary:tmpfiles.extra=$keys" \
|
||||
-device virtio-vga -device virtio-rng-pci -display none \
|
||||
-netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \
|
||||
-qmp "unix:$state/qmp.sock,server,nowait" -pidfile "$state/qemu.pid" -daemonize ${QEMU_EXTRA:-}
|
||||
|
||||
# </dev/null: the probe must not eat a script meant for the command below.
|
||||
until ssh "${ssh_opts[@]}" -o ConnectTimeout=1 omarchy@127.0.0.1 true </dev/null 2>/dev/null; do
|
||||
kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null || { echo "vm-boot: qemu exited before ssh came up" >&2; exit 1; }
|
||||
(( ($(date +%s%N) - t0) / 1000000 < 300000 )) || { echo "vm-boot: no ssh after 300s" >&2; exit 1; }
|
||||
sleep 0.2
|
||||
done
|
||||
echo "vm-boot: ssh up after $(( ($(date +%s%N) - t0) / 1000000 )) ms, machine ID ${uuid//-/}" >&2
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
# Stdin passes through, so `vm-boot dir bash -s <script` works.
|
||||
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "$@"
|
||||
else
|
||||
echo "ssh ${ssh_opts[*]} omarchy@127.0.0.1"
|
||||
echo "Desktop and sudo password: omarchy. QMP socket: $state/qmp.sock. Ctrl-C powers it off."
|
||||
while kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null; do sleep 1; done
|
||||
fi
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
# Turns an installed Omarchy disk (test/integration's base image) into an image anyone can
|
||||
# download and boot: every boot is its own machine, nothing of the build is
|
||||
# left in it, and it boots straight into its kernel, so a boot takes seconds.
|
||||
#
|
||||
# vm-seal <installed_dir> <sealed_dir>
|
||||
#
|
||||
# installed_dir holds base.qcow2, OVMF_VARS.4m.fd and id_ed25519, which is
|
||||
# what test/integration leaves in test-runs/<iso>-integration once its install
|
||||
# has passed. sealed_dir gets a standalone, zstd-compressed base.qcow2, which
|
||||
# boots by itself through UEFI, and the UKI's vmlinuz, initrd and cmdline for
|
||||
# booting it directly. It holds no key: each boot is handed one (see vm-boot).
|
||||
#
|
||||
# The image keeps the installer's user and password (GUEST_PASSWORD, default
|
||||
# omarchy) for the desktop and sudo. SSH takes keys only.
|
||||
set -euo pipefail
|
||||
[[ $# -eq 2 ]] || { echo "Usage: vm-seal <installed_dir> <sealed_dir>" >&2; exit 2; }
|
||||
in=$(realpath "$1") out=$(realpath -m "$2")
|
||||
pw=${GUEST_PASSWORD:-omarchy}
|
||||
for fw in /usr/share/edk2/x64/OVMF_CODE.4m.fd /usr/share/OVMF/OVMF_CODE_4M.fd; do
|
||||
[[ -f $fw ]] && { fw_code=$fw; break; }
|
||||
done
|
||||
: "${fw_code:?no OVMF firmware found}"
|
||||
|
||||
state=$(mktemp -d /var/tmp/seal.XXXXXX)
|
||||
cleanup() {
|
||||
local status=$? pid
|
||||
if [[ -f $state/qemu.pid ]]; then
|
||||
pid=$(cat "$state/qemu.pid")
|
||||
kill "$pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||
fi
|
||||
rm -rf "$state"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
port=$(( 30000 + RANDOM % 5000 ))
|
||||
|
||||
ssh_vm() {
|
||||
ssh -i "$in/id_ed25519" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=2 -o LogLevel=ERROR omarchy@127.0.0.1 "$@"
|
||||
}
|
||||
# Runs a script as root in the guest and fails if any line of it fails. The
|
||||
# password goes to sudo on stdin, never into a command line.
|
||||
root() {
|
||||
printf '%s\n' "$pw" | ssh_vm "sudo -S -p '' bash -ec $(printf %q "trap 'echo \"vm-seal: failed in the guest at: \$BASH_COMMAND\" >&2' ERR
|
||||
$1")"
|
||||
}
|
||||
|
||||
qemu-img create -qf qcow2 -b "$in/base.qcow2" -F qcow2 "$state/edit.qcow2"
|
||||
cp "$in/OVMF_VARS.4m.fd" "$state/vars.fd"
|
||||
qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp 4 -m 4G \
|
||||
-drive if=pflash,format=raw,readonly=on,file="$fw_code" \
|
||||
-drive if=pflash,format=raw,file="$state/vars.fd" \
|
||||
-drive "file=$state/edit.qcow2,format=qcow2,if=virtio,discard=unmap" -display none \
|
||||
-netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \
|
||||
-pidfile "$state/qemu.pid" -daemonize
|
||||
for i in $(seq 1 150); do
|
||||
ssh_vm true 2>/dev/null && break
|
||||
(( i < 150 )) || { echo "vm-seal: no ssh on the installed disk" >&2; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
# Let first-boot work finish before taking it apart.
|
||||
ssh_vm 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
|
||||
|
||||
mkdir -p "$out"
|
||||
# The kernel that is running: edge installs linux-omarchy-bore where stable
|
||||
# installs linux-omarchy, and the boot entry, not the UKI, may hold the
|
||||
# command line.
|
||||
kernel=$(ssh_vm 'cat "/usr/lib/modules/$(uname -r)/pkgbase"')
|
||||
root "cat /boot/EFI/Linux/omarchy_$kernel.efi" >"$state/uki.efi"
|
||||
ssh_vm 'cat /proc/cmdline' >"$state/cmdline"
|
||||
|
||||
root '
|
||||
# The ISO writes autologin only for encrypted installs; a test has nobody at
|
||||
# SDDM, and a desktop check that skips for want of a session reads as a pass.
|
||||
printf "[Autologin]\nUser=omarchy\nSession=omarchy.desktop\n" >/etc/sddm.conf.d/autologin.conf
|
||||
|
||||
# The image is public and its password is too, so SSH takes keys only. sshd
|
||||
# keeps the first value it reads, hence a name that sorts first.
|
||||
printf "PasswordAuthentication no\nKbdInteractiveAuthentication no\nAuthenticationMethods publickey\n" \
|
||||
>/etc/ssh/sshd_config.d/00-omarchy-vm.conf
|
||||
ssh-keygen -A >/dev/null # sshd -T needs host keys to parse; they go below
|
||||
sshd -T | grep -qix "passwordauthentication no"
|
||||
sshd -T | grep -qix "authenticationmethods publickey"
|
||||
|
||||
# Empty, not "uninitialized": that string marks a first boot, and a first boot
|
||||
# applies unit presets, which on Arch disable everything not listed. Empty lets
|
||||
# systemd take the VM SMBIOS UUID (qemu -uuid) as this boot machine ID.
|
||||
# Host keys come back from sshdgenkeys.service before sshd starts.
|
||||
: >/etc/machine-id
|
||||
rm -f /var/lib/dbus/machine-id /etc/ssh/ssh_host_*
|
||||
rm -f /home/omarchy/.ssh/authorized_keys /home/omarchy/.bash_history /root/.bash_history
|
||||
|
||||
# The factory snapshot the installer took carries the same machine ID.
|
||||
mkdir -p /run/seal-top
|
||||
mount -o subvolid=5 "$(findmnt -no SOURCE / | sed "s/\[.*//")" /run/seal-top
|
||||
if [ -f /run/seal-top/@factory/etc/machine-id ]; then
|
||||
ro=$(btrfs property get -ts /run/seal-top/@factory ro)
|
||||
[ "$ro" = ro=false ] || btrfs property set -ts /run/seal-top/@factory ro false
|
||||
: >/run/seal-top/@factory/etc/machine-id
|
||||
[ "$ro" = ro=false ] || btrfs property set -ts /run/seal-top/@factory ro true
|
||||
fi
|
||||
umount /run/seal-top
|
||||
|
||||
# Stopping the unit saves a seed; with it stopped, shutdown saves none.
|
||||
systemctl stop systemd-random-seed.service
|
||||
rm -f /var/lib/systemd/random-seed /var/lib/systemd/credential.secret
|
||||
|
||||
# Leases and the key NetworkManager derives stable addresses from.
|
||||
systemctl stop NetworkManager.service
|
||||
rm -f /var/lib/NetworkManager/*.lease /var/lib/NetworkManager/secret_key \
|
||||
/var/lib/NetworkManager/seen-bssids /var/lib/NetworkManager/timestamps \
|
||||
/var/lib/NetworkManager/NetworkManager.state
|
||||
|
||||
# Vacuuming leaves the active journal. Send journald to /run, then the
|
||||
# build logs on disk can go, and shutdown writes nothing back.
|
||||
journalctl --relinquish-var
|
||||
rm -rf /var/log/journal/*
|
||||
|
||||
test ! -s /etc/machine-id
|
||||
test -z "$(ls /etc/ssh/ssh_host_* /var/lib/NetworkManager/*.lease /var/log/journal/* 2>/dev/null)"
|
||||
test ! -e /home/omarchy/.ssh/authorized_keys
|
||||
test ! -e /var/lib/systemd/random-seed
|
||||
|
||||
sync
|
||||
# Hand freed blocks back to the disk image, so the copy skips them.
|
||||
fstrim -av >/dev/null
|
||||
(sleep 1; systemctl poweroff) >/dev/null 2>&1 &'
|
||||
|
||||
# The key that let us in is gone, so the poweroff above was the last word.
|
||||
pid=$(cat "$state/qemu.pid")
|
||||
for _ in $(seq 1 120); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
|
||||
if kill -0 "$pid" 2>/dev/null; then echo "vm-seal: the VM did not power off" >&2; exit 1; fi
|
||||
rm -f "$state/qemu.pid"
|
||||
|
||||
# Direct boot skips the firmware and Limine, which spend most of a boot reading
|
||||
# and hashing the UKI. resume= points at the image's own swap, so drop it.
|
||||
objcopy -O binary --only-section=.linux "$state/uki.efi" "$out/vmlinuz"
|
||||
objcopy -O binary --only-section=.initrd "$state/uki.efi" "$out/initrd"
|
||||
tr -d '\0' <"$state/cmdline" | tr ' ' '\n' | { grep -vE '^(resume|resume_offset)=|^$' || true; } | paste -sd' ' >"$out/cmdline"
|
||||
[[ -s $out/vmlinuz && -s $out/initrd && -s $out/cmdline ]] || { echo "vm-seal: the UKI gave no kernel, initrd or cmdline" >&2; exit 1; }
|
||||
|
||||
# zstd clusters: QEMU reads them as they are, so the download boots as it is.
|
||||
qemu-img convert -c -O qcow2 -o compression_type=zstd -m 16 -W "$state/edit.qcow2" "$out/base.qcow2"
|
||||
( cd "$out" && sha256sum base.qcow2 vmlinuz initrd cmdline >SHA256SUMS )
|
||||
echo "sealed: $out ($(du -sh "$out/base.qcow2" | cut -f1)); cmdline: $(cat "$out/cmdline")"
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Boots a sealed image once, as a throwaway machine, and checks that it is
|
||||
# what vm-seal promises: a desktop that comes up, nothing of the build left
|
||||
# in it, and SSH that takes keys only. Exits non-zero on any failed check.
|
||||
#
|
||||
# vm-verify <sealed_dir>
|
||||
set -euo pipefail
|
||||
[[ $# -eq 1 ]] || { echo "Usage: vm-verify <sealed_dir>" >&2; exit 2; }
|
||||
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
base=$(realpath "$1")
|
||||
( cd "$base" && sha256sum --quiet -c SHA256SUMS )
|
||||
uuid=$(cat /proc/sys/kernel/random/uuid)
|
||||
export VM_UUID=$uuid SSH_PORT=$(( 20000 + RANDOM % 10000 ))
|
||||
|
||||
VM_PW=${GUEST_PASSWORD:-omarchy} "$here/vm-boot" "$base" "WANT_ID=${uuid//-/} PW=${GUEST_PASSWORD:-omarchy} bash -s" <<'GUEST'
|
||||
fail=0
|
||||
check() { # description, then a command that must succeed
|
||||
local what=$1; shift
|
||||
if "$@" >/dev/null 2>&1; then echo "ok $what"; else echo "FAIL $what"; fail=1; fi
|
||||
}
|
||||
root() { printf '%s\n' "$PW" | sudo -S -p '' "$@"; }
|
||||
booted=$(( $(date +%s) - $(cut -d. -f1 /proc/uptime) - 5 ))
|
||||
newer() { [ "$(root stat -c %Y "$1")" -ge "$booted" ]; } # written by this boot
|
||||
|
||||
check "the system reaches running or degraded" \
|
||||
sh -c 's=$(timeout 120 systemctl is-system-running --wait); [ "$s" = running ] || [ "$s" = degraded ]'
|
||||
check "no unit failed" sh -c '[ -z "$(systemctl --failed --no-legend)" ]'
|
||||
check "a desktop session is logged in" sh -c 'for i in $(seq 1 60); do pgrep -x Hyprland && exit 0; sleep 0.5; done; exit 1'
|
||||
check "the machine ID is the one this boot was given" [ "$(cat /etc/machine-id)" = "$WANT_ID" ]
|
||||
check "the journal holds this machine only" [ "$(root ls /var/log/journal | tr '\n' ' ')" = "$WANT_ID " ]
|
||||
check "SSH refuses passwords" sh -c "printf '%s\n' '$PW' | sudo -S -p '' sshd -T | grep -qix 'passwordauthentication no'"
|
||||
check "SSH requires a key" sh -c "printf '%s\n' '$PW' | sudo -S -p '' sshd -T | grep -qix 'authenticationmethods publickey'"
|
||||
# Asking with no method makes sshd list the ones it would take.
|
||||
check "sshd offers a key as the only way in" \
|
||||
sh -c 'ssh -o PreferredAuthentications=none -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null omarchy@127.0.0.1 true 2>&1 | grep -qF "Permission denied (publickey)."'
|
||||
check "authorized_keys holds one key, this boot's" [ "$(grep -c . ~/.ssh/authorized_keys)" = 1 ]
|
||||
check "SSH host keys were made by this boot" newer /etc/ssh/ssh_host_ed25519_key
|
||||
check "NetworkManager's key was made by this boot" newer /var/lib/NetworkManager/secret_key
|
||||
check "one DHCP lease, this boot's" [ "$(root sh -c 'ls /var/lib/NetworkManager/*.lease | wc -l')" = 1 ]
|
||||
check "no shell history" sh -c '! [ -e ~/.bash_history ]'
|
||||
check "the package cache is empty" [ -z "$(ls /var/cache/pacman/pkg)" ]
|
||||
root mkdir -p /run/top && root mount -o subvolid=5,ro "$(findmnt -no SOURCE / | sed 's/\[.*//')" /run/top
|
||||
check "the factory snapshot has no machine ID" sh -c "! printf '%s\n' '$PW' | sudo -S -p '' test -s /run/top/@factory/etc/machine-id"
|
||||
check "the factory snapshot holds no SSH login key" sh -c "! printf '%s\n' '$PW' | sudo -S -p '' sh -c 'ls /run/top/@factory/root/.ssh/authorized_keys /run/top/@factory/home/*/.ssh/authorized_keys' 2>/dev/null | grep -q ."
|
||||
check "the factory snapshot has no SSH host keys" sh -c "[ -z \"\$(printf '%s\n' '$PW' | sudo -S -p '' sh -c 'ls /run/top/@factory/etc/ssh/ssh_host_* 2>/dev/null')\" ]"
|
||||
check "no snapshots besides the current system" [ -z "$(root ls /.snapshots)" ]
|
||||
echo "omarchy $(pacman -Q omarchy 2>/dev/null | cut -d' ' -f2), kernel $(uname -r), $(systemd-analyze | head -1)"
|
||||
exit $fail
|
||||
GUEST
|
||||
Reference in new issue
Block a user