From 68f6a6b091d07efc19e8d173c7f6a87c16519a0c Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Sun, 4 Oct 2026 13:04:16 +1000 Subject: [PATCH 1/7] Add omarchy-mac-migrate, a standalone move onto Omarchy's official Mac packages The migration engine leaves omarchy-mac-boot (omacom/omarchy-mac-pkgs drops it): one self-contained script built from migrate/src, journaled and resumable, that moves a quattro, mx-mac or test-image Mac onto its channel's official packages (the omarchy-dev pair on edge) and the core Apple Silicon pacman configuration, and defers while that channel has no Mac release. --- bin/omarchy-mac-migrate | 3482 +++++++++++++++++ migrate/README.md | 37 + migrate/build | 58 + migrate/src/cohort-legacy.sh | 564 +++ migrate/src/cohort-mx-mac.sh | 184 + migrate/src/cohort-tester.sh | 75 + migrate/src/engine.sh | 1615 ++++++++ migrate/src/main.sh | 55 + migrate/src/payload.sh | 56 + migrate/src/repairs.sh | 379 ++ migrate/src/target.sh | 380 ++ migrate/src/users.sh | 148 + test/fixtures/mac-migrate/bin/cryptsetup | 7 + test/fixtures/mac-migrate/bin/df | 3 + test/fixtures/mac-migrate/bin/findmnt | 7 + test/fixtures/mac-migrate/bin/gpasswd | 7 + test/fixtures/mac-migrate/bin/gpg | 19 + test/fixtures/mac-migrate/bin/limine-update | 7 + test/fixtures/mac-migrate/bin/locale | 3 + test/fixtures/mac-migrate/bin/locale-gen | 4 + test/fixtures/mac-migrate/bin/log-command | 6 + test/fixtures/mac-migrate/bin/lsblk | 2 + test/fixtures/mac-migrate/bin/lsinitcpio | 4 + test/fixtures/mac-migrate/bin/mkinitcpio | 27 + test/fixtures/mac-migrate/bin/mount | 23 + .../bin/omarchy-apple-silicon-boot-check | 7 + .../bin/omarchy-apple-silicon-channel | 6 + .../mac-migrate/bin/omarchy-drive-recover | 6 + .../mac-migrate/bin/omarchy-hw-platform | 2 + .../bin/omarchy-lifecycle-dispatch | 44 + .../mac-migrate/bin/omarchy-mac-boot-update | 6 + test/fixtures/mac-migrate/bin/omarchy-mac-esp | 10 + .../bin/omarchy-mac-initramfs-hooks | 11 + .../bin/omarchy-mac-limine-cmdline | 6 + .../mac-migrate/bin/omarchy-mac-limine-deploy | 4 + .../bin/omarchy-mac-setup-keyboard | 6 + .../mac-migrate/bin/omarchy-pkg-defaults | 8 + test/fixtures/mac-migrate/bin/pacman | 399 ++ test/fixtures/mac-migrate/bin/pacman-key | 36 + test/fixtures/mac-migrate/bin/repo-add | 15 + test/fixtures/mac-migrate/bin/sudo | 3 + test/fixtures/mac-migrate/bin/systemctl | 6 + test/fixtures/mac-migrate/bin/umount | 18 + test/fixtures/mac-migrate/bin/update-grub | 6 + test/fixtures/mac-migrate/bin/update-m1n1 | 6 + test/fixtures/mac-migrate/lib.sh | 204 + .../mac-boot/bin/omarchy-mac-initramfs-hooks | 143 + .../mac-boot/bin/omarchy-mac-limine-cmdline | 94 + .../mkinitcpio.conf.d/90-omarchy-mac.conf | 18 + .../91-omarchy-mac-encrypt.conf | 90 + .../93-omarchy-mac-plymouth.conf | 18 + .../94-omarchy-mac-vconsole.conf | 46 + .../runtime/install/config/locale.sh | 50 + test/shell.d/mac-migrate-legacy-test.sh | 894 +++++ test/shell.d/mac-migrate-mx-test.sh | 685 ++++ test/shell.d/mac-migrate-test.sh | 925 +++++ 56 files changed, 10924 insertions(+) create mode 100755 bin/omarchy-mac-migrate create mode 100644 migrate/README.md create mode 100755 migrate/build create mode 100644 migrate/src/cohort-legacy.sh create mode 100644 migrate/src/cohort-mx-mac.sh create mode 100644 migrate/src/cohort-tester.sh create mode 100644 migrate/src/engine.sh create mode 100644 migrate/src/main.sh create mode 100644 migrate/src/payload.sh create mode 100644 migrate/src/repairs.sh create mode 100644 migrate/src/target.sh create mode 100644 migrate/src/users.sh create mode 100755 test/fixtures/mac-migrate/bin/cryptsetup create mode 100755 test/fixtures/mac-migrate/bin/df create mode 100755 test/fixtures/mac-migrate/bin/findmnt create mode 100755 test/fixtures/mac-migrate/bin/gpasswd create mode 100755 test/fixtures/mac-migrate/bin/gpg create mode 100755 test/fixtures/mac-migrate/bin/limine-update create mode 100755 test/fixtures/mac-migrate/bin/locale create mode 100755 test/fixtures/mac-migrate/bin/locale-gen create mode 100755 test/fixtures/mac-migrate/bin/log-command create mode 100755 test/fixtures/mac-migrate/bin/lsblk create mode 100755 test/fixtures/mac-migrate/bin/lsinitcpio create mode 100755 test/fixtures/mac-migrate/bin/mkinitcpio create mode 100755 test/fixtures/mac-migrate/bin/mount create mode 100755 test/fixtures/mac-migrate/bin/omarchy-apple-silicon-boot-check create mode 100755 test/fixtures/mac-migrate/bin/omarchy-apple-silicon-channel create mode 100755 test/fixtures/mac-migrate/bin/omarchy-drive-recover create mode 100755 test/fixtures/mac-migrate/bin/omarchy-hw-platform create mode 100755 test/fixtures/mac-migrate/bin/omarchy-lifecycle-dispatch create mode 100755 test/fixtures/mac-migrate/bin/omarchy-mac-boot-update create mode 100755 test/fixtures/mac-migrate/bin/omarchy-mac-esp create mode 100755 test/fixtures/mac-migrate/bin/omarchy-mac-initramfs-hooks create mode 100755 test/fixtures/mac-migrate/bin/omarchy-mac-limine-cmdline create mode 100755 test/fixtures/mac-migrate/bin/omarchy-mac-limine-deploy create mode 100755 test/fixtures/mac-migrate/bin/omarchy-mac-setup-keyboard create mode 100755 test/fixtures/mac-migrate/bin/omarchy-pkg-defaults create mode 100755 test/fixtures/mac-migrate/bin/pacman create mode 100755 test/fixtures/mac-migrate/bin/pacman-key create mode 100755 test/fixtures/mac-migrate/bin/repo-add create mode 100755 test/fixtures/mac-migrate/bin/sudo create mode 100755 test/fixtures/mac-migrate/bin/systemctl create mode 100755 test/fixtures/mac-migrate/bin/umount create mode 100755 test/fixtures/mac-migrate/bin/update-grub create mode 100755 test/fixtures/mac-migrate/bin/update-m1n1 create mode 100644 test/fixtures/mac-migrate/lib.sh create mode 100755 test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-initramfs-hooks create mode 100755 test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-limine-cmdline create mode 100644 test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/90-omarchy-mac.conf create mode 100644 test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/91-omarchy-mac-encrypt.conf create mode 100644 test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/93-omarchy-mac-plymouth.conf create mode 100644 test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/94-omarchy-mac-vconsole.conf create mode 100644 test/fixtures/mac-migrate/runtime/install/config/locale.sh create mode 100644 test/shell.d/mac-migrate-legacy-test.sh create mode 100644 test/shell.d/mac-migrate-mx-test.sh create mode 100644 test/shell.d/mac-migrate-test.sh diff --git a/bin/omarchy-mac-migrate b/bin/omarchy-mac-migrate new file mode 100755 index 00000000000..99ac7dbbebc --- /dev/null +++ b/bin/omarchy-mac-migrate @@ -0,0 +1,3482 @@ +#!/bin/bash -p + +# omarchy:summary=Move this Mac onto Omarchy's official packages through a journaled, resumable migration +# omarchy:args=status | check | run [--target FILE] | verify +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# GENERATED from migrate/src by migrate/build: edit the sources there, then run +# migrate/build. One self-contained file, so it runs the same from a quattro +# checkout, from omarchy-mx-mac's final release and as a downloaded release +# asset, and needs no migration code in any package. +# +# It moves an Apple Silicon Mac running an Omarchy fork (omarchy-mac quattro, +# omarchy-mx-mac, a quattro-upstream test image) onto the official packages of +# the channel it follows: the Omarchy runtime pair from pkgs.omarchy.org (the +# omarchy-dev pair on edge), omarchy-mac and omarchy-mac-boot, and the Aurora +# boot chain, under the core Apple Silicon pacman configuration. A channel +# whose repository has no qualified Mac packages yet defers (75) with nothing +# changed; so does anything preflight refuses. +# +# status what this Mac's migration is doing +# check preflight only: says what run would do, changes nothing +# run migrate, or resume a migration cut short +# verify after the reboot: verify the new boot chain and finish +# +# Exit 0: migrated (or waiting for its reboot), or nothing to migrate. Exit 75: +# deferred, nothing changed. Any other status: a step failed part way; running +# it again resumes. +# +# Root starts over in an empty environment with a fixed PATH and reads only the +# live system. Unprivileged tests name a fixture root in +# OMARCHY_MAC_MIGRATE_ROOT. + +if (( EUID == 0 )) && [[ ${1:-} != "--clean-environment" ]]; then + exec /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/bin HOME=/root /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@" +fi +[[ ${1:-} != "--clean-environment" ]] || shift + +set -euo pipefail + +# shellcheck disable=SC2034 # R, fixture and self are the engine's inputs +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin + R="" + fixture=0 +else + R=${OMARCHY_MAC_MIGRATE_ROOT:-} + if [[ $R != /?* ]]; then + echo "omarchy-mac-migrate: run it as root: sudo omarchy-mac-migrate ${*:-status}" >&2 + exit 1 + fi + R=${R%/} + fixture=1 +fi +self=$(realpath -- "${BASH_SOURCE[0]}") + +# --- engine.sh ------------------------------------------------------------ + +# The journaled migration engine. +# +# It moves a Mac onto its target in thirteen ordered steps. Every step records +# its start and its end in an append-only journal synced to disk, so a power +# loss or a kill resumes at the first step that did not finish, and every step +# can run again from its start. Preflight changes nothing and freezes the plan +# the later steps follow. A cohort adapter (cohort-.sh) decides what +# its machines need: the package targets, the packages the transaction may +# remove and the compatibility state to retire. The engine owns the order, the +# journal and every change to the system. +# +# The caller sets R (the fixture root, empty on a live system), fixture (1 when +# unprivileged tests drive it) and self (this file). Adapters read the +# target_* values. +# +# Exit status: 0 when the Mac is migrated, waits for its reboot or has nothing +# to migrate; 75 (EX_TEMPFAIL) when it stopped before anything changed (a +# preflight refusal, or any failure before the journal exists); 1 when a step +# failed, and running again resumes it. +# shellcheck disable=SC2034,SC2154 + +# Raised with every change to what the tool does; the journal format only when +# a journal one version writes cannot be resumed by another. +tool_version=1 +journal_format=2 + +migrate_steps=(preflight backup keyring prefetch repositories transaction boot-chain loader defaults verify unpin reboot retire) + +# pacman's download user reads the work, cache and candidate directories. +umask 022 + +state=$R/var/lib/omarchy-mac/migration +journal=$state/journal +plan=$state/plan +cache=$state/cache +backup=$state/backup +expected=$state/expected +start=$state/start +interrupted_marker=$state/transaction-interrupted +set_copy=$state/set +complete=$state/complete +reboot_pending=$state/reboot-pending +user_pending=$state/user-pending +tool_copy=$state/tool/omarchy-mac-migrate +lock_file=$R/run/lock/omarchy-mac-migrate.lock +pacman_conf=$R/etc/pacman.conf +pacman_db=$R/var/lib/pacman +pacman_cache=$R/var/cache/pacman/pkg +pacman_gpg=$R/etc/pacman.d/gnupg +esp=/boot/efi +limine_gate=$R/var/lib/omarchy/limine.enabled +limine_default=$R/etc/default/limine +verify_unit=omarchy-mac-migrate-verify.service +verify_unit_file=$R/etc/systemd/system/$verify_unit +first_boot_marker=$R/var/lib/omarchy/mac-first-boot/pending +legacy_first_boot_marker=$R/var/lib/omarchy/first-boot/pending +# The user units a fresh install's first run enables +# (install/user/first-run/enable-user-units.sh). +fresh_user_units="bt-agent.service owed.service omarchy-recover-internal-monitor.service omarchy-sleep-lock.service omarchy-migrate-notify.service omarchy-fcitx5.service omarchy-crash-watch.service omarchy-brightness-keyboard-auto.service" + +current_step="" +check_only=0 +original_args=() +target_file="" +payload_dir="" +restarted=0 +restarts=0 +work="" +gpgdir="" + +say() { + printf '%s\n' "$*" +} + +die() { + echo "omarchy-mac-migrate: $*" >&2 + if [[ -n $current_step && -f $journal ]]; then + journal_write "$current_step" "fail" "$*" + fi + # With nothing journaled, nothing has changed: deferred, like a refusal. + [[ -f $journal ]] || exit 75 + # Before the repository switch the system still runs as it did (only the + # official key was trusted): the attempt is set aside and the next run starts + # over from preflight. + if before_boundary; then + abort_migration "$*" + exit 75 + fi + exit 1 +} + +# The repository switch is the first change that cannot be left in place: from +# its start on, the migration only goes forward. +before_boundary() { + [[ -f $journal ]] && ! awk '$2 == "repositories" { found = 1 } END { exit !found }' "$journal" +} + +abort_migration() { + local destination + destination=$state/history/aborted-$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$state/format" "$destination/" 2>/dev/null + rm -rf "$cache" "$backup" "$set_copy" "$start" "$expected" "$state/installed.now" "$state/tool" + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$1" | durable_write "$state/deferred" 2>/dev/null || true + say "Nothing on this Mac changed; the next run starts the migration over." >&2 +} + +on_exit() { + local status=$? + if (( status != 0 )) && [[ -n $current_step && -f $journal && $(step_state "$current_step") == "begin" ]]; then + journal_write "$current_step" "fail" "exit $status" + fi + [[ -z $work ]] || rm -rf "$work" +} + +# --- Journal ----------------------------------------------------------------- + +journal_write() { + local detail=${3:-} + printf '%s %s %s%s\n' "$(date +%s)" "$1" "$2" "${detail:+ ${detail//$'\n'/ }}" >>"$journal" + sync "$journal" +} + +# The last event recorded for a step: begin, done, fail, or nothing. +step_state() { + [[ -f $journal ]] || return 0 + awk -v step="$1" '$2 == step { event = $3 } END { print event }' "$journal" +} + +next_step() { + local step + for step in "${migrate_steps[@]}"; do + if [[ $(step_state "$step") != "done" ]]; then + printf '%s\n' "$step" + return + fi + done +} + +# Unprivileged tests kill the engine with SIGKILL part way through a step's +# work (mid), once the work is done (during) or once its end is recorded +# (after). Root never reads these. +interrupt_for_test() { + (( fixture )) || return 0 + if [[ $1 == "mid" && ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "$2" ]] || + [[ $1 == "during" && ${OMARCHY_MAC_MIGRATE_KILL_DURING:-} == "$2" ]] || + [[ $1 == "after" && ${OMARCHY_MAC_MIGRATE_KILL_AFTER:-} == "$2" ]]; then + kill -9 $$ + fi +} + +run_step() { + local step=$1 + current_step=$step + restarted=0 + journal_write "$step" "begin" + "step_${step//-/_}" + if (( restarted )); then + current_step="" + return 0 + fi + interrupt_for_test during "$step" + journal_write "$step" "done" + interrupt_for_test after "$step" + current_step="" +} + +# Replace a file whole: written beside it, synced, then renamed over it. +durable_write() { + local file=$1 mode=${2:-644} tmp + tmp=$(mktemp "$file.XXXXXX") || return 1 + if cat >"$tmp" && chmod "$mode" "$tmp" && sync "$tmp" && mv -f "$tmp" "$file"; then + sync "$(dirname "$file")" + else + rm -f "$tmp" + return 1 + fi +} + +# --- Helpers ------------------------------------------------------------------- + +# A root-owned (in a fixture, caller-owned) regular file or directory, not a +# symlink and not writable by group or others. Target files and sets decide +# what is installed as root. +trusted() { + local owner mode + [[ -e $1 && ! -L $1 ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1 + (( owner == EUID && (8#$mode & 8#022) == 0 )) +} + +pacman_run() { + env OMARCHY_UPDATE_PACMAN=1 LC_ALL=C pacman --gpgdir "${gpgdir:-$pacman_gpg}" "$@" +} + +installed_packages() { + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Q +} + +installed_version() { + awk -v name="$1" '$1 == name { print $2; exit }' "$2" +} + +# The upstream detector where the runtime has it; else the device tree, as +# Asahi's own tools read it (a quattro or mx-mac runtime predates the +# detector). +hardware_platform() { + if command -v omarchy-hw-platform >/dev/null; then + omarchy-hw-platform + elif (( ! fixture )) && [[ -r /proc/device-tree/compatible ]] && tr '\0' '\n' /dev/null | awk -F: '$1 == "pub" { print $2; exit }') + [[ $validity == "f" || $validity == "u" ]] +} + +key_present() { + gpg --homedir "${2:-$pacman_gpg}" --batch --no-auto-check-trustdb --with-colons --list-keys "$1" >/dev/null 2>&1 +} + +# Official trust in the keyring at HOME: the keyrings installed are populated, +# and a missing Omarchy key comes from the keyserver by its full fingerprint and +# is signed locally. Fails when the key is not trusted after it. +trust_official_key() { + local home=$1 keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$home" --populate "${keyrings[@]}" >/dev/null || return 1 + fi + if ! key_trusted "$target_keyring" "$home"; then + pacman-key --gpgdir "$home" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$home" --lsign-key "$target_keyring" >/dev/null || return 1 + fi + key_trusted "$target_keyring" "$home" +} + +sha256_of() { + sha256sum "$1" | cut -d' ' -f1 +} + +repositories_in() { + awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); if (name != "options") print name }' "$1" +} + +# The configuration the transaction runs with: the future one, with the +# verified candidate set as a local repository ahead of everything. It is never +# installed as /etc/pacman.conf, so candidates stay invisible afterwards. +transaction_conf() { + local conf=$1 candidate_dir=$2 + if [[ -z $candidate_dir ]]; then + cat "$conf" + return + fi + awk -v repo="$candidate_repo" -v server="file://$candidate_dir" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ && !inserted && $0 !~ /\[options\]/ { + print "[" repo "]"; print "SigLevel = Optional"; print "Server = " server; print ""; inserted = 1 + } + { print } + ' "$conf" +} + +# --- Candidate sets --------------------------------------------------------- + +# Prints the key that made a detached signature, or fails. A revoked or expired +# key or signature does not count. gpgv reads the set's keyring file and needs +# no agent, so nothing depends on where a gpg-agent socket could live. +signer_of() { + local home=$1 file=$2 signature=$3 status + status=$(gpgv --homedir "$home" --keyring "$home/key.gpg" --status-fd 1 "$signature" "$file" 2>/dev/null) || return 1 + awk '$1 != "[GNUPG:]" { next } + $2 ~ /^(BADSIG|ERRSIG|EXPSIG|EXPKEYSIG|REVKEYSIG|KEYEXPIRED|KEYREVOKED)$/ { bad = 1 } + $2 == "GOODSIG" { good = 1 } + $2 == "VALIDSIG" { primary = $NF; valid++ } + END { if (!good || valid != 1 || bad) exit 1; print primary }' <<<"$status" +} + +# Verifies a candidate set as tools/release/candidate-set verify does, trusting +# only the target's fingerprint. Prints why it fails. +verify_candidate_set() { + local dir=$1 home=$2 manifest=$1/manifest.json receipt=$1/signing.json name sha digest + rm -rf "$home" + mkdir -m 700 "$home" + if ! gpg --batch --homedir "$home" --dearmor <"$dir/candidate-signing-key.asc" >"$home/key.gpg" 2>/dev/null || + ! gpg --batch --homedir "$home" --with-colons --show-keys "$home/key.gpg" 2>/dev/null | awk -F: '$1 == "fpr" { print $10 }' | grep -qx "$target_fingerprint"; then + echo "its key is not $target_fingerprint" + return 1 + fi + [[ -f $receipt && -f $receipt.sig && $(signer_of "$home" "$receipt" "$receipt.sig") == "$target_fingerprint" ]] || + { echo "signing.json is not signed by $target_fingerprint"; return 1; } + [[ $(jq -r '.signer.fingerprint' "$receipt") == "$target_fingerprint" && + $(jq -r '.manifest_sha256' "$receipt") == "$(sha256_of "$manifest")" && + $(jq -r '.set_sha256' "$receipt") == "$(jq -r '.set_sha256' "$manifest")" ]] || + { echo "signing.json does not bind this manifest"; return 1; } + digest=$(jq -r '.packages[] | "\(.name) \(.version) \(.filename) \(.sha256)"' "$manifest" | LC_ALL=C sort | sha256sum | cut -d' ' -f1) + [[ $digest == "$(jq -r '.set_sha256' "$manifest")" ]] || { echo "the manifest's set digest does not match its packages"; return 1; } + [[ $(jq -r '[.signatures[].file] | sort | join(" ")' "$receipt") == "$(jq -r '[.packages[].filename] | sort | join(" ")' "$manifest")" ]] || + { echo "signing.json does not cover exactly the manifest's packages"; return 1; } + while IFS=$'\t' read -r name sha; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ -f $dir/$name && $(sha256_of "$dir/$name") == "$sha" ]] || { echo "$name is missing or changed"; return 1; } + [[ -f $dir/$name.sig && $(signer_of "$home" "$dir/$name" "$dir/$name.sig") == "$target_fingerprint" ]] || + { echo "$name is not signed by $target_fingerprint"; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$manifest") +} + +# Copies a set into a directory only root can write, so nothing can change it +# between its verification and its use; everything later reads the copy. +copy_candidate_set() { + local source=$1 destination=$2 name + rm -rf "$destination" + install -d -m 700 "$destination" || return 1 + for name in manifest.json signing.json signing.json.sig candidate-signing-key.asc; do + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + done + [[ -f $destination/manifest.json ]] || { echo "the set has no manifest.json"; return 1; } + while read -r name; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + [[ ! -f $source/$name.sig ]] || cp "$source/$name.sig" "$destination/$name.sig" || return 1 + done < <(jq -r '.packages[].filename' "$destination/manifest.json") || { echo "cannot read its manifest"; return 1; } +} + +# Verifies the frozen set again, then builds a local repository of copies whose +# digests are checked again, so what pacman reads is what was verified. +# Signatures stay out of it: pacman's keyring never trusts the candidate key. +stage_candidate_repo() { + local destination=$1 home=$2 reason name sha + reason=$(verify_candidate_set "$target_set" "$home") || { echo "$reason" >&2; return 1; } + rm -rf "$destination" + install -d -m 755 "$destination" || return 1 + while IFS=$'\t' read -r name sha; do + install -m 644 "$target_set/$name" "$destination/$name" || return 1 + [[ $(sha256_of "$destination/$name") == "$sha" ]] || { echo "the copy of $name changed" >&2; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$target_set/manifest.json") + index_candidate_repo "$destination" +} + +# Indexes the manifest's packages, and nothing else, in DIR (already holding +# copies, or given links to the set with "link"). repo-add embeds a signature +# lying beside a package, so the set's own signatures are never in DIR. +index_candidate_repo() { + local destination=$1 mode=${2:-} files=() name + mapfile -t files < <(jq -r '.packages[].filename' "$target_set/manifest.json") + if [[ $mode == "link" ]]; then + for name in "${files[@]}"; do + ln -sfn "$target_set/$name" "$destination/$name" || return 1 + done + fi + (cd "$destination" && repo-add -q "$candidate_repo.db.tar.gz" "${files[@]}") >/dev/null || return 1 + chmod -R go+rX "$destination" +} + +target_version() { + jq -r --arg name "$1" '.packages[] | select(.name == $name) | .version' "$target_set/manifest.json" +} + +# --- Preflight ----------------------------------------------------------------- + +# The cohort an Apple Silicon Mac belongs to, from what is installed. Each +# cohort needs an adapter defining _plan and _retire; it may +# also define _preflight, _prefetch, _prepare and _restore, which the +# steps of those names call, and _stage and _unstage, which the loader step of +# a GRUB Mac calls before Limine is activated and after a failed activation. +# Only a cohort with a stage may have its ESP mounted at /boot or its root +# unlocked by busybox encrypt: the stage moves both. A legacy omarchy-mac +# install runs Omarchy from a checkout, trusts the rc4 fork keyring or carries +# the quattro tree, whose 3.x upgrade command quattro-upstream never had. A +# Mac on the omarchy-dev pair without the mx-mac fork's updaters, records or a +# test image's pin already runs Omarchy's own dev packages. +detect_cohort() { + local list=$1 + if grep -Eq '^omarchy(-settings)?-dev ' "$list"; then + if mx_mac_fork; then + echo mx-mac + elif [[ -n $(test_pin_block "$pacman_conf") ]]; then + # A test image built from a dev pair candidate keeps it pinned. + echo tester + else + echo official-dev + fi + elif ! grep -Eq '^omarchy ' "$list" || grep -Eq '^omarchy-mac-keyring ' "$list" || + [[ -e $R/usr/share/omarchy/bin/omarchy-upgrade-to-quattro-mac ]]; then + echo legacy + else + echo tester + fi +} + +cohort_refusal() { + echo "no adapter handles the $1 cohort" +} + +# What stops a Mac running omarchy-dev from counting as a Mac on Omarchy's own +# dev channel: a retired repository or key, a repository trusted without +# signatures, or an [omarchy] served from anywhere but pkgs.omarchy.org. +official_trust_problems() { + local conf repos repo fpr + conf=$(cat "$1") + repos=$(repositories_in <(printf '%s\n' "$conf")) + for repo in "${retired_repos[@]}"; do + ! grep -Fxq "$repo" <<<"$repos" || echo "[$repo]" + done + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || echo "the key $fpr" + done + printf '%s\n' "$conf" | awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "omarchy" && /^[[:space:]]*Server[[:space:]]*=/ && $0 !~ /=[[:space:]]*https:\/\/pkgs\.omarchy\.org\// { print "an [omarchy] server other than pkgs.omarchy.org" } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { print "[" name "] without signature checks" }' | sort -u +} + +# Runs the cohort's optional hook for a step. +adapter_hook() { + local hook=${cohort//-/_}_$1 + shift + if declare -F "$hook" >/dev/null; then + "$hook" "$@" + fi +} + +# The LUKS partition beneath /, or nothing when / is not encrypted; fails when +# it cannot tell (as omarchy-drive-password decides it). +root_luks_device() { + local source ancestry device + source=$(findmnt -no SOURCE "$R/") && [[ -n $source ]] || return 1 + ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${source%%[*}") || return 1 + device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry") + if [[ -n $device ]]; then + printf '%s\n' "$device" + elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then + return 1 + fi +} + +free_bytes() { + df -B1 --output=avail "$1" 2>/dev/null | tail -n 1 | tr -d ' ' +} + +bytes_used() { + local bytes + bytes=$(du -sxb "$1" 2>/dev/null | cut -f1) + printf '%s\n' "${bytes:-0}" +} + +# Running on battery below 30% is refused: the transaction and the boot switch +# must not lose power. +low_battery() { + local supply capacity on_battery=0 low=0 + for supply in "$R"/sys/class/power_supply/*; do + [[ -f $supply/type ]] || continue + case $(<"$supply/type") in + Battery) + capacity=$(<"$supply/capacity") 2>/dev/null || capacity=100 + [[ $capacity =~ ^[0-9]+$ ]] && (( capacity < 30 )) && low=1 + on_battery=1 + ;; + Mains | USB | USB_C | USB_PD) + [[ $(cat "$supply/online" 2>/dev/null) == "1" ]] && return 1 + ;; + esac + done + (( on_battery && low )) +} + +# The configuration pacman reads: FILE with each Include replaced by the files +# it names, three levels deep. +pacman_conf_flat() { + local file=$1 depth=${2:-0} line included + while IFS= read -r line || [[ -n $line ]]; do + if (( depth < 3 )) && [[ $line =~ ^[[:space:]]*Include[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$ ]]; then + # shellcheck disable=SC2086 # Include takes a glob + for included in $R${BASH_REMATCH[1]}; do + [[ ! -f $included ]] || pacman_conf_flat "$included" $(( depth + 1 )) + done + else + printf '%s\n' "$line" + fi + done <"$file" +} + +# The administrator's repositories the switch keeps must not accept untrusted +# packages: the core configuration requires signatures. +pacman_trust_problems() { + admin_repositories "$1" | awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { + value = $0; sub(/^[^=]*=[[:space:]]*/, "", value) + print "[" name "] accepts untrusted packages (SigLevel = " value "); remove it or sign it first" + }' +} + +preflight() { + local reasons=() installed boot_state kernels hooks="" check_output luks="" need esp_mount="" staged=0 channel + local future transaction targets_file resolved name version problem official_problems="" names saved_path problems=() + work=$(mktemp -d "$R/var/tmp/omarchy-mac-migrate.XXXXXX") || die "cannot create a work directory" + chmod 755 "$work" + installed=$work/installed + installed_packages >"$installed" || die "cannot list the installed packages" + pacman_conf_flat "$pacman_conf" >"$work/flat.conf" || die "cannot read $pacman_conf" + + [[ -d $R/run/systemd/system ]] || reasons+=("this is not a booted system (an image build or a chroot)") + [[ ! -e $pacman_db/db.lck ]] || reasons+=("pacman is busy or was interrupted ($pacman_db/db.lck exists)") + + cohort=$(detect_cohort "$installed") + # A Mac following Omarchy's own dev channel already runs official packages. + # One that still trusts what the switch retires, or that an administrator + # points at a target, is moved like a tester: its packages are named. + if [[ $cohort == "official-dev" ]]; then + official_problems=$(official_trust_problems "$work/flat.conf" | paste -sd, | sed 's/,/, /g') + if [[ -z $official_problems && -z ${target_file:-} ]]; then + say "This Mac runs Omarchy's own packages (omarchy-dev from pkgs.omarchy.org): nothing to migrate." + exit 0 + fi + cohort=tester + fi + declare -F "${cohort//-/_}_plan" >/dev/null || reasons+=("$(cohort_refusal "$cohort")") + ! declare -F "${cohort//-/_}_stage" >/dev/null || staged=1 + + kernels=$(awk '$1 == "linux-asahi" || $1 == "linux-aurora" { print $1 }' "$installed" | xargs) + [[ $kernels == "linux-asahi" || $kernels == "linux-aurora" ]] || + reasons+=("expected one Apple kernel (linux-asahi or linux-aurora), found: ${kernels:-none}") + + if limine_mac; then + boot_state=limine + elif [[ -f $R/boot/grub/grub.cfg ]]; then + boot_state=grub + else + boot_state=unknown + reasons+=("cannot tell whether this Mac boots GRUB or Limine") + fi + # The Limine setup derives the kernel command line from GRUB's defaults. + [[ -f $R/etc/default/grub ]] || reasons+=("there is no /etc/default/grub, which the Limine setup reads the kernel command line from") + [[ -f $R/usr/share/pacman/keyrings/asahi-alarm.gpg ]] || reasons+=("asahi-alarm-keyring is not installed, so Asahi ALARM's packages cannot be verified") + if ! luks=$(root_luks_device); then + reasons+=("cannot tell whether the root filesystem is encrypted") + fi + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + reasons+=("first boot has not finished on this Mac") + fi + if low_battery; then + reasons+=("the battery is below 30% and no charger is connected") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(pacman_trust_problems "$work/flat.conf"; unsupported_options "$pacman_conf") + # The switch writes pacman.conf whole: a repository only an Include file + # defines would be lost or doubled. + for problem in $(comm -13 <(repositories_in "$pacman_conf" | LC_ALL=C sort -u) <(repositories_in "$work/flat.conf" | LC_ALL=C sort -u)); do + reasons+=("[$problem] is configured through an Include, which the repository switch cannot rewrite; move it into $pacman_conf first") + done + + # The target: the administrator's, else the channel this Mac follows. + if [[ -z ${target_file:-} ]]; then + if channel=$(detect_channel "$cohort" "$work/flat.conf"); then + write_channel_target "$channel" "$work/target" + target_file=$work/target + else + reasons+=("cannot tell which Omarchy channel this Mac follows (stable, rc or edge); set one in $admin_target") + fi + fi + [[ -z ${target_file:-} ]] || load_target "$target_file" + if [[ ${target_type:-} == "candidate-set" ]] && ! command -v gpgv >/dev/null; then + reasons+=("gpgv is not installed (gnupg), so the candidate set's signatures cannot be checked") + fi + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + + # The target, read in isolation: a copy of the local database and the future + # configuration, never the live sync databases. Signatures are checked + # against a copy of the keyring that trusts the target's key, so preflight + # never changes the live one. + future=$work/pacman.conf + future_pacman_conf "$pacman_conf" >"$future" || die "cannot compute the new pacman configuration" + mkdir -p "$work/db" + cp -a "$pacman_db/local" "$work/db/local" || die "cannot copy the package database" + install -d -m 700 "$work/pacman-gnupg" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$work/pacman-gnupg" -xf - || die "cannot copy the pacman keyring" + gpgdir=$work/pacman-gnupg + trust_official_key "$gpgdir" || refuse "cannot fetch and trust the Omarchy packaging key $target_keyring" + # The trust the switch leaves: no retired fork key verifies anything from here. + for name in "${retired_keys[@]}"; do + if key_present "$name" "$gpgdir"; then + pacman-key --gpgdir "$gpgdir" --delete "$name" >/dev/null 2>&1 || die "cannot drop $name from the keyring copy" + fi + done + if [[ $target_type == "candidate-set" ]]; then + install -d -m 755 "$work/candidate" + if ! problem=$(copy_candidate_set "$target_set" "$work/set") || ! problem=$(verify_candidate_set "$work/set" "$work/gnupg"); then + refuse "the candidate set does not verify: $problem" + fi + local loaded_id=$target_id + target_set=$work/set + candidate_identity "$target_set" || die "cannot read the candidate manifest" + [[ $target_id == "$loaded_id" ]] || refuse "the candidate set changed while it was read" + index_candidate_repo "$work/candidate" link || die "cannot index the candidate set" + fi + transaction=$work/transaction.conf + transaction_conf "$future" "${target_set:+$work/candidate}" >"$transaction" + pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sy --noconfirm >"$work/sync.log" 2>&1 || + refuse "cannot read the target repositories: $(tail -n 1 "$work/sync.log")" + mapfile -t problems < <(presence_problems "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "The $target_channel channel has no Mac release yet; this Mac stays as it is until it has one." + refuse "${problems[@]}" + fi + + targets_file=$work/targets + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + { + cat "$work/adapter-targets" + for name in $keyring_packages; do + sed 's|^.*/||' "$work/adapter-targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + } >"$targets_file" + names=$(sed 's|^.*/||' "$targets_file" | xargs) + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$pacman_conf holds back $problem, which the migration changes; remove it from IgnorePkg or IgnoreGroup first") + done < <(pinned_targets "$pacman_conf" "$names $(xargs <"$work/allowed-removals")" "$work/db" "$transaction") + (( ${#reasons[@]} == 0 )) || refuse "${reasons[@]}" + resolved=$work/resolved + # shellcheck disable=SC2046 + if ! pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sup --noconfirm --ask 4 \ + --print-format '%r/%n %v' $(plan_ignores "$work") $(cat "$targets_file") >"$resolved" 2>"$work/resolve.log"; then + refuse "the target set does not resolve on this Mac: $(tail -n 1 "$work/resolve.log")" + fi + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + grep -Fxq "$name $version" "$resolved" || refuse "${name#*/} does not resolve to the candidate's $version" + done <"$targets_file" + fi + + mapfile -t problems < <(archive_problems "$resolved" "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "This Mac cannot move to the $target_channel channel's packages yet; it stays as it is." + refuse "${problems[@]}" + fi + + # The boot tools of the omarchy-mac-boot the transaction installs judge the + # Mac from here on. + payload_dir=$work/payload + version=$(fetch_payload "$resolved" "$transaction" "$work/db" "$payload_dir") || + refuse "cannot take the boot tools from the target's omarchy-mac-boot: $version" + saved_path=$PATH + if (( fixture )); then + PATH=$PATH:$payload_dir/usr/bin + else + PATH=$payload_dir/usr/bin:$PATH + fi + # The busybox encrypt hook matters only where it unlocks the root: legacy + # omarchy-mac sets it on every Mac, and on an unencrypted one it does nothing. + # Only a cohort whose stage moves that unlock (legacy) may carry it. + if ! hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null); then + reasons+=("cannot read the initramfs HOOKS") + elif [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( ! staged )); then + reasons+=("the root unlocks through busybox encrypt, which only the legacy omarchy-mac migration moves") + fi + # Installed boot files, not the running kernel: an update that just replaced + # the kernel leaves a reboot pending, and the migration replaces it anyway. + if ! check_output=$(boot_check_pending 2>&1); then + reasons+=("the boot files are not coherent; repair them first: $(tail -n 1 <<<"$check_output")") + fi + # Limine and its UKI live on the ESP U-Boot boots, mounted at /boot/efi. A + # cohort with a stage moves an ESP mounted at /boot there first. + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" ]] && (( staged )); then + : + elif [[ $esp_mount != "$esp" ]]; then + reasons+=("the system ESP is not mounted at $esp") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(adapter_hook preflight "$installed" "$luks" "$hooks") + need=$(( 4 * 1024 * 1024 * 1024 + $(bytes_used "$R/etc") + $(bytes_used "$R/boot") )) + # An ESP mounted at /boot is also /boot: its kernel and initramfs move onto + # the root filesystem. + [[ $esp_mount != "/boot" ]] || need=$(( need + 512 * 1024 * 1024 )) + (( $(free_bytes "$R/var/lib") >= need )) || reasons+=("the root filesystem needs $(( need / 1024 / 1024 )) MiB free for backups and downloads") + (( $(free_bytes "$R${esp_mount:-$esp}") >= 64 * 1024 * 1024 )) || reasons+=("the ESP needs 64 MiB free") + [[ $esp_mount == "/boot" ]] || (( $(free_bytes "$R/boot") >= 128 * 1024 * 1024 )) || reasons+=("/boot needs 128 MiB free") + PATH=$saved_path + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + # The adapter's plan records the unlock its stage moves, now that the HOOKS + # are known. + if [[ $cohort == "legacy" ]]; then + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "$hooks" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + fi + + gpgdir="" + if already_on_target "$installed" "$resolved" "$targets_file" "$future"; then + say "This Mac already runs the target set ($target_id): nothing to migrate." + exit 0 + fi + if (( check_only )); then + say "Ready: run moves this Mac ($cohort, $boot_state boot${luks:+, encrypted}) onto $target_id ($target_channel)." + say "It installs: $names" + [[ ! -s $work/allowed-removals ]] || say "It may remove: $(xargs <"$work/allowed-removals")" + exit 0 + fi + + # Passed: freeze the plan. Nothing on the system has changed yet. + install -d -m 755 "$(dirname "$state")" "$state" + : >"$journal" + printf 'journal_format=%s\n' "$journal_format" >"$state/format" + sync "$journal" "$state/format" + current_step=preflight + journal_write preflight "begin" "$target_id" + rm -rf "$plan.new" + install -d -m 755 "$plan.new" + cp "$installed" "$plan.new/installed" + cp "$future" "$plan.new/pacman.conf" + cp -a "$work/db/sync" "$plan.new/sync" + guarded_pacman_conf "$future" "$pacman_conf" "$(printf '%s\n' $names $(xargs <"$work/allowed-removals") $guarded_boot | awk '!seen[$0]++' | xargs)" >"$plan.new/pacman.guarded.conf" + cp "$targets_file" "$plan.new/targets" + cp "$work/allowed-removals" "$plan.new/allowed-removals" + cp "$work/kept" "$plan.new/kept" 2>/dev/null || : >"$plan.new/kept" + cp "$work/removals" "$plan.new/removals" 2>/dev/null || : >"$plan.new/removals" + [[ ! -d $work/adapter ]] || cp -r "$work/adapter" "$plan.new/adapter" + cp "$target_file" "$plan.new/target" + printf '%s\n' "$target_id" >"$plan.new/target-id" + printf '%s\n' "$target_packages" >"$plan.new/target-packages" + printf '%s\n' "$cohort" >"$plan.new/cohort" + printf '%s\n' "$boot_state" >"$plan.new/boot" + printf '%s\n' "$luks" >"$plan.new/luks" + printf '%s\n' "$esp_mount" >"$plan.new/esp" + for name in $fresh_user_units; do + [[ ! -f $R/usr/lib/systemd/user/$name ]] || printf '%s\n' "$name" + done >"$plan.new/user-units" + find "$plan.new" -type f -exec sync {} + + if [[ $target_type == "candidate-set" ]]; then + rm -rf "$set_copy" + mv "$work/set" "$set_copy" || die "cannot keep the verified candidate set" + sync "$set_copy"/* + target_set=$set_copy + fi + rm -rf "$plan" + mv "$plan.new" "$plan" + keep_tool || die "cannot keep a copy of this tool for the migration's resume" + sync "$state" + interrupt_for_test during preflight + journal_write preflight "done" + interrupt_for_test after preflight + current_step="" +} + +refuse() { + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + die "$*" + fi + say "The migration was refused before anything changed:" >&2 + printf ' - %s\n' "$@" >&2 + install -d -m 755 "$state" 2>/dev/null && + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$*" | durable_write "$state/deferred" 2>/dev/null || true + exit 75 +} + +# Every target is installed at the version the target resolves to, the +# configuration is already the future one and no retired key is trusted. +# Ordinary upgrades of other packages are omarchy update's business. +already_on_target() { + local installed=$1 resolved=$2 targets=$3 future=$4 target name version fpr + cmp -s "$future" "$pacman_conf" || return 1 + while read -r target; do + name=${target#*/} + version=$(awk -v name="$name" '{ sub(/^[^\/]*\//, "", $1) } $1 == name { print $2; exit }' "$resolved") + [[ -n $version && $(installed_version "$name" "$installed") == "$version" ]] || return 1 + done <"$targets" + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 1 + done +} + +# --- Steps ------------------------------------------------------------------- + +# The frozen plan: the target as preflight read it, the candidate set as it +# verified it. Nothing is read from the original set again. +load_plan() { + target_file=$plan/target + load_target "$target_file" frozen + target_id=$(<"$plan/target-id") + target_packages=$(<"$plan/target-packages") + cohort=$(<"$plan/cohort") +} + +plan_targets() { + cat "$plan/targets" +} + +# Where the ESP was mounted at preflight: /boot/efi, or /boot where the +# cohort's stage moves it. +plan_esp() { + if [[ -s $plan/esp ]]; then + cat "$plan/esp" + else + printf '%s\n' "$esp" + fi +} + +# Unqualified names of every package the transaction replaces or may remove. +plan_package_names() { + { sed 's|^.*/||' "$plan/targets"; cat "$plan/allowed-removals"; } | sort -u +} + +step_backup() { + local partial=$state/backup.partial name version file found luks esp_mount + esp_mount=$(plan_esp) + rm -rf "$partial" + install -d -m 700 "$partial" "$partial/packages" + cp "$plan/installed" "$partial/installed" + : >"$partial/packages.missing" + while read -r name; do + version=$(installed_version "$name" "$plan/installed") + [[ -n $version ]] || continue + found=0 + for file in "$pacman_cache/$name-$version"-*.pkg.tar.*; do + [[ -f $file ]] || continue + cp -p "$file" "$partial/packages/" || die "cannot copy $file into the backup" + found=1 + done + (( found )) || printf '%s %s\n' "$name" "$version" >>"$partial/packages.missing" + done < <(plan_package_names) + tar -C "$R/" --xattrs --acls -cpf "$partial/etc.tar" etc 2>"$partial/etc.log" || die "cannot back up /etc" + interrupt_for_test mid backup + # An ESP mounted at /boot is /boot: esp.tar holds it. + if [[ $esp_mount != "/boot" ]]; then + tar -C "$R/boot" --one-file-system -cpf "$partial/boot.tar" . || die "cannot back up /boot" + fi + tar -C "$R$esp_mount" -cpf "$partial/esp.tar" . || die "cannot back up the ESP" + luks=$(<"$plan/luks") + if [[ -n $luks ]]; then + cryptsetup luksHeaderBackup "$luks" --header-backup-file "$partial/luks-header.img" || + die "cannot back up the LUKS header of $luks" + fi + (cd "$partial" && find . -type f ! -name SHA256SUMS -print0 | LC_ALL=C sort -z | xargs -0 sha256sum >SHA256SUMS) || + die "cannot record the backup's digests" + find "$partial" -type f -exec sync {} + || die "cannot sync the backup" + rm -rf "$backup" + mv "$partial" "$backup" || die "cannot finish the backup" + sync "$state" + if [[ -s $backup/packages.missing ]]; then + say "Not in the package cache, so not backed up: $(awk '{ print $1 }' "$backup/packages.missing" | xargs)" + fi +} + +# Official trust, bootstrapped without any repository the switch retires: the +# keyrings already installed are populated, and a missing Omarchy key comes +# from the keyserver by its full fingerprint and is signed locally. A candidate +# set's key never enters pacman's keyring. +step_keyring() { + local keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$pacman_gpg" --populate "${keyrings[@]}" >/dev/null || die "cannot populate the keyrings: ${keyrings[*]}" + fi + interrupt_for_test mid keyring + if ! key_trusted "$target_keyring"; then + pacman-key --gpgdir "$pacman_gpg" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$pacman_gpg" --lsign-key "$target_keyring" >/dev/null || + die "cannot fetch and trust the Omarchy key $target_keyring" + fi + key_trusted "$target_keyring" || die "the Omarchy key $target_keyring is not trusted after the bootstrap" +} + +# What the transaction may remove stays out of the upgrade: an official build +# of the same name that conflicts with a target (stock omarchy, which the +# omarchy-dev pair replaces on edge) would otherwise join the transaction, and +# pacman drops one of the two. Left alone, it leaves through the target's +# conflict, or by name after the transaction. +plan_ignores() { + local dir=${1:-$plan} names + names=$(cat "$dir/removals" "$dir/allowed-removals" 2>/dev/null | awk 'NF' | LC_ALL=C sort -u | paste -sd,) + [[ -z $names ]] || printf -- '--ignore=%s\n' "$names" + return 0 +} + +# Packages the transaction removes by name once it has installed the targets, +# as far as DB still has them. By exact name: pacman -Q NAME also answers with +# a package that provides NAME (mise-bin for mise), which pacman -R refuses. +plan_removals() { + local db=$1 name installed + [[ -f $plan/removals ]] || return 0 + installed=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Qq) || return 1 + while read -r name; do + [[ -n $name ]] && grep -Fxq -- "$name" <<<"$installed" && printf '%s\n' "$name" + done <"$plan/removals" + return 0 +} + +# Paths the installed package NAME owns in DB that another package there owns +# too. pacman -R deletes every file of the package it removes, whoever else +# owns it, so a planned removal that hands files over (omarchy-dev's commands +# to omarchy-mac-boot) must leave inside the transaction, through the conflict +# of the package that replaces it, never in the removals after it. +shared_files() { + local db=$1 name=$2 + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Ql 2>/dev/null | awk -v name="$name" ' + { owner = $1; path = $0; sub(/^[^ ]+ /, "", path) } + path ~ /\/$/ { next } + owner == name { mine[path] = 1; next } + { other[path] = 1 } + END { for (path in mine) if (path in other) print path }' | LC_ALL=C sort +} + +# Downloads and verifies every package the transaction needs, then rehearses the +# transaction on a copy of the package database (--dbonly: no files, scripts or +# hooks) to learn exactly what it installs and removes. Signatures are checked +# against the trust the switch leaves, a copy of the keyring without the +# retired keys, so nothing that needs a fork key gets this far. +step_prefetch() { + local db=$cache/db rehearsal=$cache/rehearsal conf=$cache/transaction.conf removed name version bad=() fpr removals shared + install -d -m 755 "$cache" "$cache/pkg" + rm -rf "$db" "$rehearsal" "$cache/candidate" "$cache/trust" + mkdir -p "$db" + cp -a "$pacman_db/local" "$db/local" || die "cannot copy the package database" + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Q >"$cache/start" || die "cannot read the package database copy" + if [[ $target_type == "candidate-set" ]]; then + stage_candidate_repo "$cache/candidate" "$cache/gnupg" || die "the candidate set does not verify" + fi + install -d -m 700 "$cache/trust" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$cache/trust" -xf - || die "cannot copy the pacman keyring" + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$cache/trust" --delete "$fpr" >/dev/null 2>&1 || die "cannot drop $fpr from the keyring copy" + fi + done + gpgdir=$cache/trust + transaction_conf "$plan/pacman.conf" "${target_set:+$cache/candidate}" >"$conf" + # The databases preflight qualified, never a newer sync: the transaction + # installs exactly the set preflight checked. + cp -a "$plan/sync" "$db/sync" || die "cannot copy the frozen package databases" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + -Suw --noconfirm --ask 4 $(plan_ignores) $(plan_targets) || die "cannot download and verify the target set" + interrupt_for_test mid prefetch + cp -a "$db" "$rehearsal" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$rehearsal" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + --dbonly -Su --noconfirm --ask 4 $(plan_ignores) $(plan_targets) >"$cache/rehearsal.log" 2>&1 || + die "the rehearsed transaction failed: $(tail -n 1 "$cache/rehearsal.log")" + removals=$(plan_removals "$rehearsal" | xargs) + for name in $removals; do + shared=$(shared_files "$rehearsal" "$name" | head -n 3 | xargs) + [[ -z $shared ]] || + die "the transaction would leave $name to be removed after it, but the packages it installs also own $shared; nothing was changed" + done + if [[ -n $removals ]]; then + # shellcheck disable=SC2086 + pacman_run --config "$conf" --dbpath "$rehearsal" --logfile "$cache/pacman.log" --dbonly -R --noconfirm $removals \ + >>"$cache/rehearsal.log" 2>&1 || die "the rehearsed removal of $removals failed: $(tail -n 1 "$cache/rehearsal.log")" + fi + gpgdir="" + gpgconf --homedir "$cache/trust" --kill all >/dev/null 2>&1 || true + LC_ALL=C pacman --config "$conf" --dbpath "$rehearsal" -Q >"$cache/expected" || die "cannot read the rehearsed result" + removed=$(comm -23 <(awk '{ print $1 }' "$cache/start" | LC_ALL=C sort) <(awk '{ print $1 }' "$cache/expected" | LC_ALL=C sort)) + for name in $removed; do + grep -Fxq "$name" "$plan/allowed-removals" || bad+=("$name") + done + (( ${#bad[@]} == 0 )) || die "the transaction would also remove ${bad[*]}; nothing was changed" + # pacman can drop a named target that conflicts with another package of the + # transaction; every target must end installed. + while read -r name; do + [[ -n $(installed_version "${name#*/}" "$cache/expected") ]] || + die "the rehearsed transaction would not install ${name#*/}; nothing was changed" + done < <(plan_targets) + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + [[ $(installed_version "${name#*/}" "$cache/expected") == "$version" ]] || die "${name#*/} would not end at the candidate's $version" + done < <(plan_targets) + fi + adapter_hook prefetch || die "the $cohort adapter refused the rehearsed transaction; nothing was changed" + durable_write "$start" <"$cache/start" && durable_write "$expected" <"$cache/expected" || + die "cannot record the rehearsed transaction" +} + +# The installed packages no longer match what the rehearsal started from: an +# omarchy update ran in between, or a transaction was cut short. The +# transaction is rehearsed again from what is installed now. +system_moved() { + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + ! cmp -s "$state/installed.now" "$start" +} + +restart_from_prefetch() { + local step + (( ++restarts <= 3 )) || die "the system keeps changing under the migration ($1); run it again when nothing else updates" + say "The system changed since the transaction was rehearsed ($1); rehearsing it again" + for step in prefetch repositories transaction; do + journal_write "$step" "reset" "$1" + done + restarted=1 +} + +# The sync databases the rehearsal resolved against, over any a later sync left. +# A signature the rehearsal has none of belongs to the database it replaces +# (a fork's signed [omarchy]), and pacman rejects a database beside a +# signature that does not match it. +install_rehearsed_databases() { + local repo extension + for repo in $(repositories_in "$cache/transaction.conf"); do + for extension in db db.sig; do + if [[ ! -f $cache/db/sync/$repo.$extension ]]; then + [[ $extension != "db.sig" || ! -f $cache/db/sync/$repo.db ]] || rm -f "$pacman_db/sync/$repo.db.sig" + continue + fi + cmp -s "$cache/db/sync/$repo.$extension" "$pacman_db/sync/$repo.$extension" && continue + durable_write "$pacman_db/sync/$repo.$extension" 644 <"$cache/db/sync/$repo.$extension" || + die "cannot install the $repo database" + done + done +} + +# The process holding pacman's lock: libalpm keeps it open while it works. +lock_holder() { + local fd + for fd in "$R"/proc/[0-9]*/fd/*; do + if [[ $(readlink "$fd" 2>/dev/null) == "$pacman_db/db.lck" ]]; then + fd=${fd#"$R/proc/"} + printf '%s\n' "${fd%%/*}" + return 0 + fi + done + return 1 +} + +# Official repository precedence and no legacy trust: the frozen +# configuration, the sync databases the rehearsal used, no retired database or +# fork key. Until the transaction is done the configuration carries the +# migration's guard (and a test image keeps its pin), so a plain pacman -Syu in +# between moves none of the packages the transaction is about to change. +step_repositories() { + local repo extension fpr + if system_moved; then + restart_from_prefetch "before the repository switch" + return 0 + fi + # From here on the fork's own update may be gone with its packages: a boot + # resumes whatever is left. + keep_tool && write_verify_unit && systemctl enable "$verify_unit" >/dev/null 2>&1 || + die "cannot install $verify_unit, which resumes the migration at boot" + if ! cmp -s "$plan/pacman.guarded.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.guarded.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid repositories + install_rehearsed_databases + for repo in "${retired_repos[@]}"; do + rm -f "$pacman_db/sync/$repo".{db,db.sig,files,files.sig} + done + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$pacman_gpg" --delete "$fpr" >/dev/null || die "cannot remove the retired key $fpr" + fi + done +} + +# Something rewrote pacman.conf or trusted a retired key again since the +# switch: on an mx-mac Mac, the fork's own omarchy update, whose channel +# updaters stay until the transaction removes them. +switch_undone() { + local fpr + cmp -s "$plan/pacman.guarded.conf" "$pacman_conf" || return 0 + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 0 + done + return 1 +} + +# The installed packages with the planned removals left out. +without_removals() { + awk 'NR == FNR { drop[$1]; next } !($1 in drop)' "$plan/removals" "$1" +} + +# The targets are installed and only the planned removals are left. +removals_pending() { + [[ -f $plan/removals ]] && ! cmp -s "$state/installed.now" "$expected" && + cmp -s <(without_removals "$state/installed.now") "$expected" +} + +# A path as a pacman --overwrite glob that matches only itself. +overwrite_glob() { + sed 's/[][*?\\]/\\&/g' <<<"$1" +} + +# One transaction from the prefetched cache and databases, without a new sync: +# it installs exactly what was verified and rehearsed. Same-name packages are +# named explicitly, so a higher installed version is replaced too. A lock left +# by a transaction that was killed means its hooks may not have run, so the +# transaction runs again even when the packages are all in place. The adapter +# prepares the system for it first and may list, in $state/overwrite, files no +# package owns that it may replace; when pacman fails, the adapter restores +# what it prepared. Planned removals run after it, by name. +step_transaction() { + local holder interrupted=0 overwrite=() remove=() path removal shared + if [[ -e $pacman_db/db.lck ]]; then + if holder=$(lock_holder); then + die "pacman is running (process $holder); run the migration again when it has finished" + fi + say "Removing the pacman lock an interrupted transaction left behind" + : | durable_write "$interrupted_marker" || die "cannot record the interrupted transaction" + rm -f "$pacman_db/db.lck" + fi + # Kept across a new rehearsal until a transaction has run to its end. + [[ ! -e $interrupted_marker ]] || interrupted=1 + if switch_undone; then + restart_from_prefetch "pacman.conf or a retired key came back after the repository switch" + return 0 + fi + if system_moved && ! cmp -s "$state/installed.now" "$expected" && ! removals_pending; then + restart_from_prefetch "before the transaction" + return 0 + fi + if (( interrupted )) || ! cmp -s "$state/installed.now" "$expected"; then + install_rehearsed_databases + if (( interrupted )) || ! removals_pending; then + rm -f "$state/overwrite" + if ! adapter_hook prepare; then + adapter_hook restore + die "the $cohort adapter could not prepare the transaction" + fi + if [[ -f $state/overwrite ]]; then + while IFS= read -r path; do + [[ -z $path ]] || overwrite+=(--overwrite "$(overwrite_glob "$path")") + done <"$state/overwrite" + fi + # shellcheck disable=SC2046 + if ! pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" \ + -Su --noconfirm --ask 4 "${overwrite[@]}" $(plan_ignores) $(plan_targets); then + adapter_hook restore + die "the package transaction failed" + fi + fi + interrupt_for_test mid removals + mapfile -t remove < <(plan_removals "$pacman_db") + for removal in "${remove[@]}"; do + shared=$(shared_files "$pacman_db" "$removal" | head -n 3 | xargs) + [[ -z $shared ]] || die "cannot remove $removal: other packages also own $shared, which pacman -R would delete" + done + if (( ${#remove[@]} )); then + pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" -R --noconfirm "${remove[@]}" || + die "cannot remove ${remove[*]}" + fi + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + cmp -s "$state/installed.now" "$expected" || + die "the installed packages differ from the rehearsed transaction: $(diff "$expected" "$state/installed.now" | grep '^[<>]' | head -n 3 | xargs)" + rm -f "$interrupted_marker" + fi + rm -f "$pacman_db/sync/$candidate_repo".{db,db.sig} + # Fresh-image provisioning is never armed on an existing machine (preflight + # refuses one whose first boot is unfinished). + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + say "Disarming the first-boot setup the transaction left on this installed Mac" + rm -f "$first_boot_marker" "$legacy_first_boot_marker" + fi +} + +# The new packages are installed, set up and verified: the configuration loses +# the migration's guard and a test image's pin, and is the core one from here +# on. +step_unpin() { + if ! cmp -s "$plan/pacman.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid unpin + pacman-key --gpgdir "$pacman_gpg" --populate $(installed_keyrings) >/dev/null || die "cannot populate the installed keyrings" +} + +# The keyrings pacman-key can populate from what is installed now. +installed_keyrings() { + local name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || printf '%s\n' "$name" + done +} + +# Aurora, m1n1 and U-Boot came with the transaction; their stage-two image +# (m1n1, the device trees and U-Boot) and the kernel's menu are rebuilt and +# checked. The loader U-Boot starts is not touched here. +step_boot_chain() { + local output + update-m1n1 >/dev/null || die "update-m1n1 could not rebuild m1n1, the device trees and U-Boot" + interrupt_for_test mid boot-chain + if [[ $(<"$plan/boot") == "limine" ]]; then + omarchy-mac-limine-cmdline && limine-update >/dev/null || die "cannot rebuild the Limine menu and UKI" + else + update-grub >/dev/null || die "cannot rebuild the GRUB menu" + fi + output=$(boot_check_pending linux-aurora 2>&1) || die "the rebuilt boot files do not check: $(tail -n 1 <<<"$output")" +} + +# The installed omarchy-mac-boot's setup-boot, through the new runtime's +# dispatcher, activates Limine (or refreshes it on a Limine Mac): it stages and +# verifies Limine before it takes U-Boot's EFI slot and restores every file it +# touched when anything fails, so a failed activation leaves the previous +# loader booting. On a GRUB Mac the cohort's stage runs first, while GRUB still +# boots the Mac, and is undone when it or the activation fails. A switch cut +# short is run again from its start. +step_loader() { + local output uki=$R$esp/EFI/Linux/omarchy_linux-aurora.efi + if [[ $(<"$plan/boot") == "limine" ]]; then + interrupt_for_test mid loader + omarchy-lifecycle-dispatch setup-boot >/dev/null || die "setup-boot could not refresh Limine; the previous loader stays" + else + if ! adapter_hook stage; then + adapter_hook unstage || die "the $cohort adapter could not stage the boot switch, nor undo it; GRUB is still the loader" + die "the $cohort adapter could not stage the boot switch; GRUB is still the loader" + fi + install -D -m 644 /dev/null "$limine_gate" || die "cannot mark this Mac for Limine" + interrupt_for_test mid loader + if ! omarchy-lifecycle-dispatch setup-boot >/dev/null; then + rm -f "$limine_gate" + adapter_hook unstage || die "Limine could not be activated, and the $cohort adapter could not undo its stage; GRUB is still the loader" + die "Limine could not be activated; GRUB is still the loader" + fi + fi + [[ -s $uki ]] && grep -Fq "boot():/EFI/Linux/omarchy_linux-aurora.efi" "$R$esp/limine.conf" || + die "Limine has no linux-aurora UKI entry after setup-boot" + cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || die "the ESP loader is not the packaged Limine" + output=$(boot_check_pending linux-aurora 2>&1) || die "the staged boot chain does not check: $(tail -n 1 <<<"$output")" +} + +# Runs a command as USER, whose home is HOME, in a clean environment, so +# nothing root does follows a link the user controls. +as_user() { + local user=$1 home=$2 + shift 2 + if (( fixture )); then + env HOME="$home" "$@" + else + runuser -u "$user" -- env -i HOME="$home" USER="$user" LOGNAME="$user" PATH="$PATH" "$@" + fi +} + +# Accounts that have used Omarchy: a regular UID and Omarchy's state in the home. +omarchy_users() { + local user home + awk -F: '$3 >= 1000 && $3 < 60000 { print $1, $6 }' "$R/etc/passwd" 2>/dev/null | + while read -r user home; do + [[ ! -d $R$home/.local/state/omarchy ]] || printf '%s %s\n' "$user" "$home" + done +} + +# Enables a user unit by the links its [Install] WantedBy names, as +# systemctl --user enable writes them for these units. A mask, an override or any enablement, the user's +# or the administrator's, stays as it is. +enable_user_unit() { + local user=$1 home=$2 unit=$3 config=$R$2/.config/systemd/user target path + [[ -f $R/usr/lib/systemd/user/$unit ]] || return 0 + for path in "$config/$unit" "$R/etc/systemd/user/$unit" "$config"/*.wants/"$unit" "$R/etc/systemd/user"/*.wants/"$unit"; do + [[ ! -e $path && ! -L $path ]] || return 0 + done + for target in $(awk -F= '/^\[/ { install = ($0 == "[Install]") } install && $1 == "WantedBy" { print $2 }' "$R/usr/lib/systemd/user/$unit"); do + as_user "$user" "$R$home" mkdir -p "$config/$target.wants" && + as_user "$user" "$R$home" ln -s "/usr/lib/systemd/user/$unit" "$config/$target.wants/$unit" || return 1 + done +} + +# What omarchy update checks before it offers a reboot, through the new +# runtime's dispatcher: the boot files the next boot reads. +update_verify() { + local output + output=$(omarchy-lifecycle-dispatch update-verify 2>&1) || die "update-verify does not pass: $(grep -v '^[[:space:]]*$' <<<"$output" | head -n 2 | xargs)" +} + +step_verify() { + update_verify +} + +# The unit that finishes the migration after its reboot runs the copy of this +# tool the migration keeps, so it works whatever else is installed. +write_verify_unit() { + local unit + unit="[Unit] +Description=Finish the Omarchy Mac migration after its reboot +# Either condition starts it: a migration past its repository switch and not +# complete, or user setup a migration left pending, retried at every boot until +# it succeeds. +ConditionPathExists=|/var/lib/omarchy-mac/migration/journal +ConditionPathExists=|/var/lib/omarchy-mac/migration/user-pending +Wants=network-online.target +After=local-fs.target network-online.target + +[Service] +Type=oneshot +ExecStart=${tool_copy#"$R"} verify + +[Install] +WantedBy=multi-user.target" + [[ -f $verify_unit_file && $(<"$verify_unit_file") == "$unit" ]] && return 0 + install -d -m 755 "$(dirname "$verify_unit_file")" && + printf '%s\n' "$unit" | durable_write "$verify_unit_file" 644 && + { systemctl daemon-reload >/dev/null 2>&1 || true; } +} + +# Waits for a reboot; after it, the new chain must have booted Aurora through +# Limine with every boot file coherent. The boot waited for stays recorded +# until retire, so a verification cut short is repeated on the same boot. +step_reboot() { + local staged current release output + current=$(boot_id) + if [[ ! -s $reboot_pending ]]; then + printf '%s\n' "$current" | durable_write "$reboot_pending" || die "cannot record the boot to wait for" + fi + interrupt_for_test mid reboot + keep_tool && write_verify_unit || die "cannot install $verify_unit, which verifies the next boot" + systemctl enable "$verify_unit" >/dev/null 2>&1 || die "cannot enable $verify_unit, which verifies the next boot" + staged=$(<"$reboot_pending") + if [[ $current == "$staged" ]]; then + say "Reboot to finish the migration to $target_id. The next boot verifies the new boot chain." + current_step="" + exit 0 + fi + release=$(kernel_release linux-aurora) || die "linux-aurora has no module tree" + [[ $(<"$R/proc/sys/kernel/osrelease") == "$release" ]] || + die "this boot runs $(<"$R/proc/sys/kernel/osrelease"), not linux-aurora $release; the backups are in $backup" + limine_mac && cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || + die "this Mac did not boot through the packaged Limine" + output=$(omarchy-apple-silicon-boot-check --boot-chain linux-aurora 2>&1) || die "the boot check failed after the reboot: $(tail -n 1 <<<"$output")" + update_verify +} + +# The completion record comes first: what is left after it is only cleanup, +# which every later run repeats until it is done. +step_retire() { + "${cohort//-/_}_retire" || die "the $cohort adapter could not retire its compatibility state" + printf 'target=%s\ncompleted=%s\n' "$target_id" "$(date +%Y-%m-%dT%H:%M:%S%z)" | durable_write "$complete" || + die "cannot record the completed migration" + interrupt_for_test mid retire + tidy_completed +} + +# The post-reboot unit is released once the working state is gone and no user +# setup is pending; while some is, it stays enabled to retry at every boot. +tidy_completed() { + local working=0 release=0 + if [[ -e $reboot_pending || -d $cache || -d $set_copy ]]; then + working=1 + release=1 + fi + if [[ -s $user_pending ]]; then + if retry_user_pending; then + release=1 + else + release=0 + fi + fi + if (( release )); then + release_verify_unit + fi + if (( working )); then + rm -rf "$cache" "$set_copy" "$state/installed.now" "$state/overwrite" + rm -f "$reboot_pending" + fi +} + +# The post-reboot unit goes once nothing is left for it to do, and with it, +# outside a migration in progress, the copy of the tool it runs. +release_verify_unit() { + systemctl disable "$verify_unit" >/dev/null 2>&1 || say "Could not disable $verify_unit; it does nothing from now on." + if [[ -f $verify_unit_file ]]; then + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi + if [[ -f $complete || ! -f $journal ]]; then + rm -rf "$state/tool" + fi +} + +# A copy of this tool beside the journal: the post-reboot unit runs it, and a +# migration in progress resumes with it (see hand_over). +keep_tool() { + [[ $self != "$tool_copy" ]] || return 0 + cmp -s "$self" "$tool_copy" && return 0 + install -d -m 755 "$(dirname "$tool_copy")" && + install -m 755 "$self" "$tool_copy.new" && sync "$tool_copy.new" && mv -f "$tool_copy.new" "$tool_copy" +} + +# The tool_version and journal_format a copy of the tool declares. +tool_field() { + sed -n "s/^$1=\([0-9][0-9]*\)$/\1/p" "$2" | head -n 1 +} + +# A migration in progress continues with the tool that started it, unless this +# one resumes the same journal format and is at least as new: then this one +# takes over and becomes the kept copy. +hand_over() { + local version format + [[ -f $tool_copy && $self != "$tool_copy" ]] || return 0 + version=$(tool_field tool_version "$tool_copy") + format=$(tool_field journal_format "$tool_copy") + if [[ $format == "$journal_format" ]] && (( tool_version >= ${version:-0} )); then + keep_tool || die "cannot update the kept copy of this tool" + return 0 + fi + say "Resuming with the tool this migration started with (version ${version:-unknown})" + exec "$tool_copy" "$@" +} + +# --- Commands ---------------------------------------------------------------- + +# Another run holding the lock owns the migration's state: this one leaves it +# alone, failing once the migration is past its switch and deferring before. +# A copy of the tool handed the migration keeps the lock it inherited. +take_lock() { + install -d -m 755 "$(dirname "$lock_file")" + if [[ $(readlink "/proc/$$/fd/9" 2>/dev/null) != "$(realpath -m "$lock_file")" ]]; then + exec 9>"$lock_file" + fi + if ! flock -n 9; then + echo "omarchy-mac-migrate: another migration run is in progress" >&2 + if past_boundary; then + exit 1 + fi + exit 75 + fi +} + +resume_steps() { + local step event + while step=$(next_step) && [[ -n $step ]]; do + [[ $step != "preflight" ]] || die "the migration has no finished preflight" + event=$(step_state "$step") + [[ -z $event || $event == "reset" || $step == "reboot" ]] || say "Resuming the migration at $step" + run_step "$step" + done + say "This Mac now runs $target_id. Backups stay in $backup." +} + +migrate_run() { + local target_arg="" candidate + while (( $# )); do + case $1 in + --target) target_arg=${2:?--target needs a file}; shift 2 ;; + *) usage; exit 2 ;; + esac + done + + platform=$(hardware_platform) || die "cannot determine the hardware platform" + if [[ $platform != "apple-silicon" ]]; then + say "Not an Apple Silicon Mac: nothing to migrate." + return 0 + fi + take_lock + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + + if [[ -f $complete && -f $plan/target-id ]]; then + tidy_completed + candidate=$(find_target "$target_arg") + if [[ -n $candidate ]]; then + load_target "$candidate" + fi + if [[ -z $candidate || $target_id == "$(<"$plan/target-id")" ]]; then + say "Already migrated to $(<"$plan/target-id")." + return 0 + fi + (( check_only )) || archive_state + else + # User setup an earlier migration left pending runs first, whatever this + # run does next. + (( check_only )) || retry_user_pending_now + fi + + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + if [[ -n $target_arg ]] && ! cmp -s "$target_arg" "$plan/target"; then + die "a migration to $(<"$plan/target-id") is in progress; finish it before choosing another target" + fi + if (( check_only )); then + migrate_status + return 0 + fi + load_plan + resume_steps + return 0 + fi + + target_file=$(find_target "$target_arg") + preflight + resume_steps +} + +# Keeps a finished migration's record and backups beside the next one. +archive_state() { + local destination + destination=$state/history/$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$start" "$expected" "$complete" "$destination/" 2>/dev/null + [[ ! -f $repaired ]] || mv "$repaired" "$destination/" + [[ ! -d $backup ]] || mv "$backup" "$destination/" +} + +# Run by omarchy-mac-migrate-verify.service at boot: continues a migration that +# is waiting for, or past, its reboot, and does nothing otherwise. +migrate_verify() { + platform=$(hardware_platform) || die "cannot determine the hardware platform" + [[ $platform == "apple-silicon" ]] || return 0 + past_boundary || [[ -s $user_pending ]] || return 0 + take_lock + if past_boundary; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + if [[ -f $complete ]]; then + tidy_completed + return 0 + fi + retry_user_pending_now + past_boundary || return 0 + load_plan + resume_steps +} + +# A migration that has started its repository switch and is not complete. +past_boundary() { + [[ -f $journal && ! -f $complete ]] && ! before_boundary +} + +migrate_status() { + local step event + if [[ -s $user_pending ]]; then + say "User setup pending, retried at the next run or boot: $(pending_summary)" + fi + if [[ ! -f $journal || $(step_state preflight) != "done" ]]; then + if [[ -s $state/deferred ]]; then + say "No migration has started on this Mac. The last run deferred: $(cut -d' ' -f2- "$state/deferred")" + else + say "No migration has started on this Mac." + fi + return 0 + fi + say "Target: $(<"$plan/target-id")" + if [[ -f $complete ]]; then + say "State: complete ($(awk -F= '$1 == "completed" { print $2 }' "$complete"))" + return 0 + fi + step=$(next_step) + event=$(step_state "$step") + if [[ $step == "reboot" && -s $reboot_pending && $event == "begin" ]]; then + if [[ $(boot_id) == "$(<"$reboot_pending")" ]]; then + say "State: waiting for a reboot" + else + say "State: rebooted; the new boot chain is not verified yet (sudo omarchy-mac-migrate verify)" + fi + elif [[ $event == "fail" ]]; then + say "State: failed at $step: $(awk -v step="$step" '$2 == step && $3 == "fail" { $1 = $2 = $3 = ""; line = $0 } END { sub(/^ +/, "", line); print line }' "$journal")" + else + say "State: in progress, next step $step" + fi +} + +usage() { + cat >&2 <<'USAGE' +Usage: omarchy-mac-migrate status + omarchy-mac-migrate check [--target FILE] + omarchy-mac-migrate run [--target FILE] + omarchy-mac-migrate verify + omarchy-mac-migrate version +USAGE +} + +migrate_main() { + local command=${1:-status} + original_args=("$@") + (( $# == 0 )) || shift + case $command in + status) migrate_status ;; + check) check_only=1; migrate_run "$@" ;; + run) migrate_run "$@" ;; + verify) migrate_verify ;; + version) say "omarchy-mac-migrate $tool_version (journal format $journal_format)" ;; + -h | --help | help) usage ;; + *) usage; exit 2 ;; + esac +} + +# --- target.sh ------------------------------------------------------------ + +# The target: which channel this Mac moves to, the packages and pacman +# configuration it ends with, and whether that channel is ready for Macs. +# shellcheck disable=SC2034,SC2154 + +# The Omarchy packaging key omarchy-keyring carries. +official_key=40DFB630FF42BCFFB047046CF0134EE680CAC571 +# Trust the converged system never keeps: the forks' repositories and keys +# (omarchy-mac's rc4 channel key and mx-mac's). Adapters add their own. +retired_repos=(omarchy-aarch64) +retired_keys=(FBD6874D423C418DDB6D143EECE19CDDE306DBD2 C81AC3E2A99556F9B21D5FEA3DD49BC9F8360BDC) +# The repositories the core configuration defines; any other one is the +# administrator's and is kept. +core_repos="omarchy asahi-alarm core extra alarm aur" +candidate_repo=omarchy-mac-candidate +admin_target=$R/etc/omarchy-mac/migration-target +# The image builder's test-image pin (omarchy-mac-installer +# image-builder/builder/test_image_pin.py): its first line, a reason line and +# the IgnorePkg line. +test_pin_mark="# Test image only (omarchy-mac-installer image-builder)" +guard_mark="# omarchy-mac-migrate: a migration to Omarchy's packages is in progress." + +# The runtime pair a channel's Macs run: Omarchy's edge builds its runtime from +# the development branch as omarchy-dev. +channel_pair() { + if [[ $1 == "edge" ]]; then + echo "omarchy-dev omarchy-settings-dev" + else + echo "omarchy omarchy-settings" + fi +} + +# Every package a migrated Mac takes from its channel's [omarchy]. +channel_packages() { + echo "$(channel_pair "$1") omarchy-mac omarchy-mac-boot linux-aurora linux-aurora-headers m1n1-aurora uboot-asahi limine-mkinitcpio-hook" +} + +# Installed or refreshed in the same transaction, from whichever repository +# carries them: the keyrings official trust comes from. +keyring_packages="asahi-alarm-keyring omarchy-keyring" + +# Held back with the transaction's packages while the migration is in +# progress: the rest of the boot chain they build on. +guarded_boot="limine limine-snapper-sync asahi-scripts mkinitcpio" + +# key=value lines, comments and blank lines ignored, anything else refused. +# format=1 +# type=repository | candidate-set +# channel=stable | rc | edge the [omarchy] channel after the switch +# server=URL optional; https://pkgs.omarchy.org//$arch +# keyring=FINGERPRINT optional; the Omarchy packaging key +# packages=NAME... repository only; optional +# set=DIR candidate-set: the set's files, manifest.json and signing.json +# fingerprint=FINGERPRINT candidate-set: the only key its signatures may carry +load_target() { + local file=$1 frozen=${2:-} line key value format="" packages="" + target_type="" target_channel="" target_server="" target_keyring=$official_key + target_set="" target_fingerprint="" target_repo=omarchy + trusted "$file" || refuse "refusing the target $file: it must be a regular file owned by root and writable only by root" + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + [[ $line == *=* ]] || refuse "the target $file is malformed: $line" + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + type) target_type=$value ;; + channel) target_channel=$value ;; + server) target_server=$value ;; + keyring) target_keyring=${value^^} ;; + packages) packages=$value ;; + set) target_set=${value%/} ;; + fingerprint) target_fingerprint=${value^^} ;; + *) refuse "the target $file has an unknown key: $key" ;; + esac + done <"$file" + [[ $format == "1" ]] || refuse "the target $file is not format=1" + [[ $target_channel =~ ^(stable|rc|edge)$ ]] || refuse "the target $file names no channel (stable, rc or edge)" + if [[ -z $target_server ]]; then + target_server="https://pkgs.omarchy.org/$target_channel/\$arch" + # Unprivileged tests serve the channels themselves. + if (( fixture )) && [[ -n ${OMARCHY_MAC_MIGRATE_SERVER:-} ]]; then + target_server=${OMARCHY_MAC_MIGRATE_SERVER//@channel@/$target_channel} + fi + fi + [[ $target_server =~ ^(https|file):// ]] || refuse "the target server must be https:// or file://: $target_server" + [[ $target_keyring =~ ^[0-9A-F]{40}$ ]] || refuse "the target keyring must be a 40-digit fingerprint" + target_packages=${packages:-$(channel_packages "$target_channel")} + case $target_type in + repository) + target_id="repository $target_server" + ;; + candidate-set) + [[ $target_fingerprint =~ ^[0-9A-F]{40}$ ]] || refuse "a candidate-set target needs its signer's 40-digit fingerprint" + target_repo=$candidate_repo + if [[ -n $frozen ]]; then + # After preflight only the verified copy counts; the original may be gone. + target_set=$set_copy + else + [[ $target_set == /* ]] && trusted "$target_set" || refuse "the candidate set $target_set must be a root-owned directory writable only by root" + [[ -f $target_set/manifest.json ]] || refuse "the candidate set has no manifest.json" + fi + candidate_identity "$target_set" || refuse "cannot read the candidate manifest" + ;; + *) + refuse "the target $file has no type (repository or candidate-set)" + ;; + esac +} + +# A candidate set's packages join the channel's: what the set carries comes +# from it, the rest of the Mac set from the channel's [omarchy]. +candidate_identity() { + local names + names=$(jq -r '.packages[].name' "$1/manifest.json") || return 1 + target_packages=$( { printf '%s\n' $(channel_packages "$target_channel"); printf '%s\n' "$names"; } | awk '!seen[$0]++' | xargs) && + target_id="candidate-set $(jq -r '.set' "$1/manifest.json") $(jq -r '.set_sha256' "$1/manifest.json")" +} + +# How the transaction names NAME: from the candidate set when it carries it, +# else from [omarchy]. +target_spec() { + if [[ $target_type == "candidate-set" ]] && jq -e --arg name "$1" '.packages[] | select(.name == $name)' "$target_set/manifest.json" >/dev/null; then + printf '%s/%s\n' "$candidate_repo" "$1" + else + printf 'omarchy/%s\n' "$1" + fi +} + +# --target, else the administrator's target. +find_target() { + local candidate + for candidate in "$@" "$admin_target"; do + if [[ -n $candidate && -e $candidate ]]; then + printf '%s\n' "$candidate" + return + fi + done +} + +# The channel this Mac's own configuration follows: an omarchy-mac lane +# ([omarchy-aarch64] on omarchy-mac/omarchy-pkgs-aarch64's releases, which +# quattro names after the channel), else an official [omarchy]. Anything else +# is unknown. +config_channel() { + local conf=$1 lane official + lane=$(section_servers "$conf" omarchy-aarch64 | sed -nE 's#^https://github\.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/(stable|rc|edge)/?$#\1#p' | sort -u) + official=$(section_servers "$conf" omarchy | sed -nE 's#^https://pkgs\.omarchy\.org/(stable|rc|edge)/(\$arch|aarch64)/?$#\1#p' | sort -u) + if [[ -n $(section_servers "$conf" omarchy-aarch64) ]]; then + [[ -n $lane && $lane != *$'\n'* ]] && printf '%s\n' "$lane" + elif [[ -n $official && $official != *$'\n'* ]]; then + printf '%s\n' "$official" + else + return 1 + fi +} + +# The Server values of SECTION in CONF. +section_servers() { + awk -v want="$2" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == want && /^[[:space:]]*Server[[:space:]]*=/ { value = $0; sub(/^[^=]*=[[:space:]]*/, "", value); sub(/[[:space:]]+$/, "", value); print value }' "$1" +} + +# The channel this Mac follows, or nothing when it cannot be told. An mx-mac +# Mac follows the fork's channel record; the rest follow their configuration. +detect_channel() { + local cohort=$1 conf=$2 channel="" + if [[ $cohort == "mx-mac" ]]; then + if command -v omarchy-apple-silicon-channel >/dev/null; then + channel=$(omarchy-apple-silicon-channel current 2>/dev/null) || channel="" + fi + else + channel=$(config_channel "$conf") || channel="" + fi + [[ $channel =~ ^(stable|rc|edge)$ ]] && printf '%s\n' "$channel" +} + +# A repository target for CHANNEL, written to FILE: what a Mac with no +# administrator's target moves to. +write_channel_target() { + printf 'format=1\ntype=repository\nchannel=%s\n' "$1" >"$2" + chmod 644 "$2" +} + +# The core Apple Silicon configuration (omacom/omarchy #13362, +# default/pacman/apple-silicon/pacman-edge.conf), with SERVER for [omarchy]. +# Unprivileged tests name their own Asahi ALARM server. +core_pacman_conf() { + local asahi=https://github.com/asahi-alarm/asahi-alarm/releases/download/aarch64 + (( ! fixture )) || asahi=${OMARCHY_MAC_MIGRATE_ASAHI_SERVER:-$asahi} + cat < 0 && /^#/ { skip--; next } + skip > 0 && /^[[:space:]]*IgnorePkg[[:space:]]*=/ { skip = 0; next } + { skip = 0 } + /^[[:space:]]*(#|$)/ { next } + { key = $0; sub(/^[[:space:]]*/, "", key); sub(/[[:space:]]*=.*$/, "", key); sub(/[[:space:]]+$/, "", key); if (!(key in core)) print }' "$1" +} + +# The test-image pin block of CONF, as it is written. +test_pin_block() { + awk -v pin="$test_pin_mark" ' + index($0, pin) == 1 { keep = 3 } + keep > 0 { print; keep-- }' "$1" +} + +# CONF's repositories that are neither the core ones nor retired, whole. +admin_repositories() { + local drop + drop="$core_repos ${retired_repos[*]} $candidate_repo" + awk -v drop="$drop" ' + BEGIN { n = split(drop, list, " "); for (i = 1; i <= n; i++) skip[list[i]] = 1; skip["options"] = 1 } + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); keep = !(name in skip) } + keep { print }' "$1" +} + +# The configuration after the switch: the core one for the target, with the +# administrator's own options and repositories kept. Applying it to its own +# output changes nothing. +future_pacman_conf() { + local conf=$1 options repositories + options=$(admin_options "$conf") + repositories=$(admin_repositories "$conf") + core_pacman_conf "$target_server" | awk -v options="$options" ' + { print } + /^LocalFileSigLevel/ && options != "" { print ""; print "# Kept from this Mac'"'"'s configuration"; print options }' + if [[ -n $repositories ]]; then + printf '\n%s\n' "$repositories" + fi +} + +# CONF with this migration's guard as the first lines of [options], and the +# test-image pin of OLD kept below it: until the package transaction is done, +# a plain pacman -Syu leaves every package the migration changes alone. +guarded_pacman_conf() { + local conf=$1 old=$2 names=$3 pin + pin=$(test_pin_block "$old") + awk -v guard="$guard_mark" -v names="$names" -v pin="$pin" ' + { print } + /^\[options\][[:space:]]*$/ && !done { + print guard " It removes these lines when its package transaction is done; sudo omarchy-mac-migrate run finishes it." + print "IgnorePkg = " names + if (pin != "") print pin + done = 1 + }' "$conf" +} + +# Administrator IgnorePkg entries (globs, as pacman reads them) matching a +# package this migration installs or removes, and IgnoreGroup entries holding +# one, one per line. +pinned_targets() { + local conf=$1 names=$2 db=$3 pattern name group member + for pattern in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnorePkg[[:space:]]*=//p'); do + for name in $names; do + # shellcheck disable=SC2053 # IgnorePkg takes globs + [[ $name != $pattern ]] || printf '%s\n' "$name" + done + done + for group in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnoreGroup[[:space:]]*=//p'); do + for member in $(LC_ALL=C pacman --config "$4" --dbpath "$db" -Sgq "$group" 2>/dev/null); do + [[ " $names " != *" $member "* ]] || printf '%s (group %s)\n' "$member" "$group" + done + done +} + +# An Include in [options] or an option the switch cannot keep as it is. +unsupported_options() { + awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "options" && /^[[:space:]]*Include[[:space:]]*=/ { print "an Include in [options] (" $0 ")" }' "$1" +} + +# --- Whether the channel is ready for Macs ----------------------------------- +# +# A channel takes Macs once its [omarchy] carries the Mac packages built from +# omacom/omarchy-mac-pkgs with a runtime that drives them. That is read from +# the signed archives the transaction would install, not from repository +# metadata: the runtime ships the lifecycle dispatcher, and omarchy-mac-boot +# ships its setup-boot and update-verify operations and no migration engine of +# its own. Until then every Mac on the channel defers. + +# The Mac packages the channel's repositories lack, one reason a line. +presence_problems() { + local listing name + listing=$(LC_ALL=C pacman --config "$1" --dbpath "$2" -Sl 2>/dev/null | awk '{ print $2 }' | LC_ALL=C sort -u) + for name in $(channel_pair "$target_channel") omarchy-mac omarchy-mac-boot linux-aurora m1n1-aurora uboot-asahi; do + grep -Fxq "$name" <<<"$listing" || echo "the $target_channel channel has no $name for Apple Silicon yet" + done +} + +# What the verified archives of the resolved runtime and omarchy-mac-boot +# lack, one reason a line. +archive_problems() { + local resolved=$1 conf=$2 db=$3 runtime listing + runtime=$(channel_pair "$target_channel") + runtime=${runtime%% *} + if listing=$(fetch_archive "$resolved" "$runtime" "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/bin/omarchy-lifecycle-dispatch' <<<"$listing" || + echo "the $target_channel channel's $runtime has no omarchy-lifecycle-dispatch to drive the Mac packages yet" + excluded_files "$listing" + else + echo "$listing" + fi + if listing=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/lib/omarchy/mac-boot/setup-boot' <<<"$listing" && grep -qx 'usr/lib/omarchy/mac-boot/update-verify' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot has no setup-boot and update-verify operations yet" + ! grep -qx 'usr/lib/omarchy-mac/boot/migrate-engine.sh' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot is not built from omacom/omarchy-mac-pkgs yet" + excluded_files "$listing" + else + echo "$listing" + fi +} + +# The administrator's NoExtract and NoUpgrade globs that keep a file of the +# migration's runtime or boot package (LISTING) from being installed as built. +excluded_files() { + local listing=$1 pattern path + while read -r pattern; do + [[ -n $pattern && $pattern != !* ]] || continue + while IFS= read -r path; do + [[ -n $path && $path != */ ]] || continue + # shellcheck disable=SC2053 # NoExtract and NoUpgrade take globs + if [[ $path == $pattern ]]; then + echo "NoExtract or NoUpgrade ($pattern) in $pacman_conf keeps $path from the packages the migration installs; remove it first" + break + fi + done <<<"$listing" + done < <(admin_options "$pacman_conf" | sed -nE 's/^[[:space:]]*(NoExtract|NoUpgrade)[[:space:]]*=[[:space:]]*//p' | tr ' ' '\n') +} + +# --- payload.sh ------------------------------------------------------------ + +# The boot tools preflight judges this Mac with: the boot check, the ESP +# finder and the HOOKS composer of the omarchy-mac-boot the transaction will +# install. A Mac on a fork has none it can trust, or older ones, so preflight +# takes them from that package's verified archive, unpacked where only root can +# write, and puts them first on its PATH. Nothing of the package is installed +# here and nothing in it runs but those read-only checks; after the +# transaction the installed package's own commands are used. +# shellcheck disable=SC2154 + +# The verified archive of NAME as the target resolves it, downloaded once into +# the work cache: a candidate set's from the verified copy of the set, a +# repository's by pacman, which checks its signature against the keyring copy +# that trusts the target's key (and no retired one). Prints its path, or why +# there is none. +fetch_archive() { + local resolved=$1 wanted=$2 conf=$3 db=$4 name version file archive="" + read -r name version < <(awk -v wanted="$wanted" '{ n = $1; sub(/^[^\/]*\//, "", n) } n == wanted { print $1, $2; exit }' "$resolved") + [[ -n $name ]] || { echo "the target installs no $wanted"; return 1; } + if [[ $name == "$candidate_repo/"* ]]; then + file=$(jq -r --arg name "$wanted" --arg version "$version" '.packages[] | select(.name == $name and .version == $version) | .filename' "$target_set/manifest.json") + [[ -n $file && -f $target_set/$file ]] && archive=$target_set/$file + else + install -d -m 755 "$work/archives" + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + if [[ -z $archive ]]; then + if ! pacman_run --config "$conf" --dbpath "$db" --cachedir "$work/archives" --logfile "$work/pacman.log" \ + -Swdd --noconfirm --ask 4 "$name" >"$work/download.log" 2>&1; then + echo "cannot download and verify $wanted $version: $(tail -n 1 "$work/download.log")" + return 1 + fi + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + fi + fi + [[ -n $archive ]] || { echo "the archive of $wanted $version is missing"; return 1; } + printf '%s\n' "$archive" +} + +# Unpacks the verified archive of the resolved omarchy-mac-boot into DIR, where +# only root can write, and prints its version. +fetch_payload() { + local resolved=$1 conf=$2 db=$3 dir=$4 archive version + archive=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db") || { echo "$archive"; return 1; } + version=$(awk '{ n = $1; sub(/^[^\/]*\//, "", n) } n == "omarchy-mac-boot" { print $2; exit }' "$resolved") + rm -rf "$dir" + install -d -m 700 "$dir" + bsdtar -xpf "$archive" -C "$dir" 2>/dev/null || { echo "cannot unpack omarchy-mac-boot $version"; return 1; } + [[ $(sed -n 's/^pkgname = //p' "$dir/.PKGINFO") == "omarchy-mac-boot" && $(sed -n 's/^pkgver = //p' "$dir/.PKGINFO") == "$version" ]] || + { echo "the archive is not omarchy-mac-boot $version"; return 1; } + [[ -z $(find "$dir" ! -type l \( ! -uid "$EUID" -o -perm /022 \) -print -quit) ]] || + { echo "the unpacked omarchy-mac-boot is writable by others"; return 1; } + printf '%s\n' "$version" +} + +# --- repairs.sh ------------------------------------------------------------ + +# Official migrations a migrated Mac records as done, and the repairs a fresh +# image does not need. +# shellcheck disable=SC2154 + +# Official migrations a migrated Mac records as done instead of running them, +# as a fresh Mac image has them (reviewed for ticket 53): initramfs and +# boot-chain repairs for the x86 Limine, T2, NVIDIA and linux-omarchy paths, +# whose Mac counterparts are this package's; the Intel Mac Broadcom quirk, which breaks +# Apple Silicon Wi-Fi; and systemd-oomd, which stays off on Macs. Every other official +# migration still pending runs on the next omarchy update, as on any install +# that upgraded. An adapter adds what its cohort already applied, and each +# repair below adds the Mac migration whose work it did. +settled_migrations="1784476564 1784917531 1785273276 1785424256 1785944594 1786137597 1786391100 1786482992 1786605598 1789325478 1789444024" +repaired=$state/repaired +repaired_migrations=() + +# The migrations USER records as done: the common ones, the repairs this +# migration made and the cohort's, comma-separated. +settled_for() { + local dir=$R$2/.local/state/omarchy/migrations + { printf '%s\n' $settled_migrations; cat "$repaired" 2>/dev/null; adapter_hook settled "$dir"; } | awk 'NF' | paste -sd, +} + +# Records the migrations NAMES lists (comma-separated) as done for USER, as +# the user, where they are not recorded yet. +settle_migrations() { + local user=$1 home=$2 names=$3 dir=$R$2/.local/state/omarchy/migrations name + as_user "$user" "$R$home" mkdir -p "$dir" || return 1 + for name in ${names//,/ }; do + [[ -e $dir/$name.sh ]] || as_user "$user" "$R$home" touch "$dir/$name.sh" || return 1 + done +} + +# --- Repairs a fresh image does not need ---------------------------------------- +# +# Macs set up before the runtime or its images carried a fix got it from a +# migration of the runtime they ran. Upstream Omarchy carries none of those +# migrations, so the engine does their work here, as root, for every cohort. +# Each repair can run again from its start and fails the step when it cannot +# finish; a later run repeats it. One that did its work, or found none to do, +# records its migration as done for every user. The target's runtime carries +# the leaves they run (install/config/snapper.sh and locale.sh) and its +# omarchy-mac the keyboard handover; a target +# without one is reported, and that migration is left to the runtime. + +runtime_leaf_present() { + [[ -f $R/usr/share/omarchy/$1 ]] +} + +# A runtime leaf, run whole in a strict shell as the runtime's migrations run them. +run_runtime_leaf() { + local leaf=$R/usr/share/omarchy/$1 + shift + env OMARCHY_PATH="$R/usr/share/omarchy" "$@" bash -euo pipefail "$leaf" +} + +# Snapper's root configuration (migration 1789148088): the asahi-overlay +# install skipped it. The leaf skips a root that is not btrfs; 3 means it +# found a layout it will not touch, left for manual repair, which is final. +repair_snapper() { + local status=0 + if ! runtime_leaf_present install/config/snapper.sh; then + say "This Omarchy has no Snapper setup leaf: the root's Snapper configuration was not checked" + return 0 + fi + run_runtime_leaf install/config/snapper.sh >/dev/null || status=$? + case $status in + 0) repaired_migrations+=(1789148088) ;; + 3) + say "The existing Snapper configuration was left for manual repair" + repaired_migrations+=(1789148088) + ;; + *) die "cannot set up Snapper for the root filesystem" ;; + esac +} + +# Asahi ALARM's bootstrap administrator (migration 1789158179): polkit asks +# for alarm's password while it stays in wheel. It leaves wheel only when +# another existing account is in wheel. Where alarm is itself an Omarchy user, +# the engine leaves the decision to that migration, which skips only alarm's +# own run. +repair_bootstrap_admin() { + local members member others=0 + members=$(awk -F: '$1 == "wheel" { print $4 }' "$R/etc/group" 2>/dev/null) || members="" + if omarchy_users | awk '{ print $1 }' | grep -Fxq alarm; then + say "alarm uses Omarchy here: its wheel membership is left to the runtime's migration" + else + if [[ ,$members, == *,alarm,* ]]; then + IFS=, read -ra members <<<"$members" + for member in "${members[@]}"; do + if [[ -n $member && $member != "alarm" ]] && awk -F: -v user="$member" '$1 == user { found = 1 } END { exit !found }' "$R/etc/passwd"; then + others=1 + fi + done + if (( others )); then + say "Removing Asahi's bootstrap account alarm from wheel" + gpasswd -d alarm wheel >/dev/null || die "cannot remove alarm from wheel" + fi + fi + repaired_migrations+=(1789158179) + fi +} + +# The Intel Mac Broadcom quirk (migration 1789172112): an older runtime wrote +# it on Apple Silicon too, where it breaks the WPA handshake. Only the exact +# block it wrote goes, and what the file held before it stays. The migration +# also required the Wi-Fi chip's PCI ID; on Apple Silicon the block does harm +# whichever chip carries it, so the engine does not. The rebuild it owes is +# recorded first, under the migration's own marker, so an interrupted run of +# either finishes it. +repair_broadcom_block() { + local conf=$R/etc/modprobe.d/brcmfmac.conf pending=$R/var/lib/omarchy/migrations/1789172112-initramfs-pending + local block content rest file + block="# Broadcom's firmware supplicant and authenticator fail the WPA four-way +# handshake on Apple hardware, which surfaces as a rejected password. Disable +# both so wpa_supplicant performs the handshake instead. +options brcmfmac feature_disable=0x82000" + if [[ -f $conf ]]; then + content=$(<"$conf") + if [[ $content == "$block" || $content == *$'\n'"$block" ]]; then + say "Removing the Intel Mac Broadcom quirk from $conf" + install -D -m 644 /dev/null "$pending" && sync "$pending" "$(dirname "$pending")" || + die "cannot record the initramfs rebuild the Broadcom repair needs" + interrupt_for_test mid broadcom + rest=${content%"$block"} + rest=${rest%$'\n'} + if [[ -z $rest && ! -L $conf ]]; then + rm -f -- "$conf" && sync "$(dirname "$conf")" + else + # A link keeps pointing where it did: its target is rewritten. + file=$(readlink -f -- "$conf") || die "cannot resolve $conf" + if [[ -n $rest ]]; then + printf '%s\n' "$rest" + fi | durable_write "$file" + fi || die "cannot remove the Broadcom quirk from $conf" + fi + fi + interrupt_for_test mid broadcom-rebuild + if [[ -f $pending ]]; then + omarchy-mac-boot-update >/dev/null || die "cannot rebuild the boot image without the Broadcom quirk" + rm -f "$pending" + fi + repaired_migrations+=(1789172112) +} + +# A UTF-8 locale (migration 1789146110): Asahi ALARM ships LANG=C. The leaf +# changes only an unset LANG, C or POSIX. +repair_locale() { + if ! runtime_leaf_present install/config/locale.sh; then + say "This Omarchy has no locale setup leaf: the locale was not checked" + return 0 + fi + run_runtime_leaf install/config/locale.sh OMARCHY_LOCALE_CONF="$R/etc/locale.conf" OMARCHY_LOCALE_GEN="$R/etc/locale.gen" >/dev/null || + die "cannot set up the UTF-8 locale" + repaired_migrations+=(1789146110) +} + +# The keyboard's function-key mode (migration 1790327324), handed to +# omarchy-mac. The line Omarchy generated here depends on the fork the Mac +# came from: fnmode=2 from the install leaf, replaced once by mx-mac +# (1790305681, fnmode=3) or quattro-upstream (1789132067, fnmode=1), as any +# of its users' migration records say, mx-mac first as in that migration. +# omarchy-mac-setup-keyboard decides once, and a fork rebuild still owed +# overrides this. +repair_keyboard_mode() { + local generated=2 user home dir + if ! command -v omarchy-mac-setup-keyboard >/dev/null; then + say "This omarchy-mac has no omarchy-mac-setup-keyboard: the keyboard mode was not handed over" + return 0 + fi + while read -r user home; do + [[ -n $user ]] || continue + dir=$R$home/.local/state/omarchy/migrations + if [[ -f $dir/1790305681.sh ]]; then + generated=3 + elif [[ -f $dir/1789132067.sh && $generated == 2 ]]; then + generated=1 + fi + done < <(omarchy_users) + env OMARCHY_MAC_FIXTURE_ROOT="$R" omarchy-mac-setup-keyboard "$generated" >/dev/null || + die "cannot hand the keyboard's function-key mode to omarchy-mac" + repaired_migrations+=(1790327324) +} + +repair_system() { + local output + repaired_migrations=() + repair_snapper + repair_bootstrap_admin + repair_broadcom_block + repair_locale + repair_keyboard_mode + # The Broadcom and keyboard repairs can rebuild the UKI. + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the repairs: $(tail -n 1 <<<"$output")" + printf '%s\n' "${repaired_migrations[@]}" | durable_write "$repaired" || die "cannot record the repairs made" +} + +# --- Fork leftovers -------------------------------------------------------------- +# +# Earlier Apple installs and omarchy-mx-mac wrote these files, which the Mac +# packages now ship as vendor defaults (omarchy-mac retired them itself until +# omacom/omarchy-mac-pkgs da8279b handed that to this migration). A copy that +# is byte for byte the one they wrote goes, kept beside itself as +# NAME.omarchy-mac-retired; an edited copy, a link (a mask included) or a +# different backup stays as it is. + +# The bytes a fork wrote as NAME. +leftover() { + case $1 in + wifi_backend.conf) + cat <<'LEFTOVER' +[device] +wifi.backend=iwd +LEFTOVER + ;; + asahi-notch.conf) + cat <<'LEFTOVER' +options appledrm show_notch=1 +LEFTOVER + ;; + omarchy-wifi-resume-fix.service) + cat <<'LEFTOVER' +[Unit] +Description=Reload brcmfmac if Wi-Fi does not return after resume +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +After=NetworkManager.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-wifi-resume-fix +TimeoutStartSec=120 + +[Install] +WantedBy=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +LEFTOVER + ;; + asahi-headset-mic.conf) + cat <<'LEFTOVER' +# The 3.5mm headset mic stays in the source list with nothing plugged in. +# Apps often pick it over the built-in array because it advertises a MONO map. +monitor.alsa.rules = [ + { + matches = [ + { node.name = "alsa_input.platform-sound.HiFi__Headset__source" } + ] + actions = { + update-props = { + priority.session = 1 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend-overlay.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## The companion software-dsp.lua overlay also stops the asahi-audio convolver +## graph from pausing when a client (Chromium, mpv, ...) closes its stream. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + *) return 1 ;; + esac +} + +# Writes every leftover into DIR, readable by every user. +write_leftovers() { + local dir=$1 name + install -d -m 755 "$dir" || return 1 + for name in wifi_backend.conf asahi-notch.conf omarchy-wifi-resume-fix.service asahi-headset-mic.conf asahi-audio-no-suspend.conf asahi-audio-no-suspend-overlay.conf; do + leftover "$name" >"$dir/$name" && chmod 644 "$dir/$name" || return 1 + done +} + +# FILE goes when it is the regular file ORIGINAL holds byte for byte. Fails +# when a backup that differs is in the way. +retire_copy() { + local file=$1 original=$2 + [[ -f $file && ! -L $file ]] && cmp -s "$file" "$original" || return 0 + if [[ -e $file.omarchy-mac-retired || -L $file.omarchy-mac-retired ]]; then + if ! cmp -s "$file" "$file.omarchy-mac-retired"; then + echo "$file.omarchy-mac-retired differs from $file; move it aside and run the migration again" >&2 + return 1 + fi + rm -- "$file" + else + mv -- "$file" "$file.omarchy-mac-retired" + fi +} + +# The machine's leftovers: the Wi-Fi backend and notch settings, and the Wi-Fi +# resume unit, whose enablement links into /etc are pointed at the vendor unit +# omarchy-mac ships. +retire_system_leftovers() { + local dir=$state/leftovers unit=omarchy-wifi-resume-fix.service target link + write_leftovers "$dir" || die "cannot stage the fork's leftover files" + retire_copy "$R/etc/NetworkManager/conf.d/wifi_backend.conf" "$dir/wifi_backend.conf" && + retire_copy "$R/etc/modprobe.d/asahi-notch.conf" "$dir/asahi-notch.conf" && + retire_copy "$R/etc/systemd/system/$unit" "$dir/$unit" || die "cannot retire the fork's leftover files" + if [[ ! -e $R/etc/systemd/system/$unit && ! -L $R/etc/systemd/system/$unit ]] && + cmp -s "$R/etc/systemd/system/$unit.omarchy-mac-retired" "$dir/$unit"; then + for target in suspend hibernate hybrid-sleep suspend-then-hibernate; do + link=$R/etc/systemd/system/$target.target.wants/$unit + if [[ -L $link && $(readlink "$link") == "/etc/systemd/system/$unit" ]]; then + ln -sfn "/usr/lib/systemd/system/$unit" "$link" || die "cannot point $link at the vendor unit" + fi + done + fi +} + +# A user's leftovers: the WirePlumber policies the fork copied into each +# user's configuration, retired as that user. +retire_user_leftovers() { + local user=$1 home=$2 dir=$state/leftovers policies=$R$2/.config/wireplumber/wireplumber.conf.d + [[ -d $policies && ! -L $policies ]] || return 0 + [[ -d $dir ]] || write_leftovers "$dir" || return 1 + # shellcheck disable=SC2016 # expanded by the user's shell + as_user "$user" "$R$home" bash -c "$(declare -f retire_copy)"' + retire_copy "$1/asahi-headset-mic.conf" "$2/asahi-headset-mic.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend-overlay.conf"' _ "$policies" "$dir" +} + +# --- users.sh ------------------------------------------------------------ + +# What a fresh install sets up, done for a migrated Mac: its default packages, +# the Mac services, the repairs and, for every Omarchy user, the settled +# migrations, the units first run enables and the user setup. +# shellcheck disable=SC2154 + +# The default packages the aarch64 and Apple lists add, where they are missing +# and a repository carries them (the base list's applications stay the owner's +# choice). Firmware among them rebuilds the initramfs and the UKI through +# pacman's hooks, so the boot files are checked again. +install_defaults() { + local generic apple available name missing=() absent=() output + if ! command -v omarchy-pkg-defaults >/dev/null; then + say "This Omarchy has no omarchy-pkg-defaults: the default packages were not checked" + return 0 + fi + generic=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults generic) && + apple=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults apple-silicon) || + die "cannot read the Apple Silicon default packages" + available=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl | awk '{ print $2 }') || + die "cannot read the repositories' packages" + while read -r name; do + [[ -n $name ]] && ! grep -Fxq -- "$name" <<<"$generic" || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qq "$name" >/dev/null 2>&1 && continue + if grep -Fxq -- "$name" <<<"$available"; then + missing+=("$name") + else + absent+=("$name") + fi + done <<<"$apple" + (( ${#absent[@]} == 0 )) || say "No repository carries these default packages, so they stay missing: ${absent[*]}" + (( ${#missing[@]} )) || return 0 + say "Installing the default packages a fresh install has: ${missing[*]}" + pacman_run --config "$pacman_conf" --dbpath "$pacman_db" -S --noconfirm "${missing[@]}" || + die "cannot install the default packages: ${missing[*]}" + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the default packages: $(tail -n 1 <<<"$output")" +} + +# --- User setup ------------------------------------------------------------------ +# +# Each Omarchy user gets the settled migrations, the units first run enables +# and the Mac user setup. What fails for one user (a broken home, a setup that +# exits nonzero) never stops the migration: it is kept in user-pending, a +# "user item" line each, and runs again at every later run and boot until it +# succeeds. The post-reboot unit stays enabled for that. + +# One item of a user's setup: settle:NAMES, a unit first run enables, +# retire-leftovers or setup-user. A pending settle keeps the names it was +# given, so a retry after the plan moved on records the same ones. +apply_user_item() { + local user=$1 home=$2 item=$3 + case $item in + settle:*) settle_migrations "$user" "$home" "${item#settle:}" ;; + retire-leftovers) retire_user_leftovers "$user" "$home" ;; + setup-user) as_user "$user" "$R$home" omarchy-lifecycle-dispatch setup-user >/dev/null ;; + *) enable_user_unit "$user" "$home" "$item" ;; + esac +} + +# The user's setup; prints what failed, one item a line. +setup_user() { + local user=$1 home=$2 item items=("settle:$(settled_for "$user" "$home")") + if [[ -f $plan/user-units ]]; then + for item in $fresh_user_units; do + grep -Fxq "$item" "$plan/user-units" || items+=("$item") + done + fi + for item in "${items[@]}" retire-leftovers setup-user; do + apply_user_item "$user" "$home" "$item" || printf '%s\n' "$item" + done +} + +# Replaces the pending record with FILE's lines, or removes it when FILE is empty. +record_user_pending() { + if [[ -s $1 ]]; then + LC_ALL=C sort -u "$1" | durable_write "$user_pending" || die "cannot record the pending user setup" + else + rm -f "$user_pending" + fi +} + +# "user item; ..." for messages, a settle item without its names. +pending_summary() { + awk '{ item = $2; sub(/:.*/, "", item); print $1 " " item }' "$user_pending" | paste -sd';' | sed 's/;/; /g' +} + +# Runs the pending items again, only those, so nothing a user turned off since +# comes back. An account that is gone or no longer uses Omarchy is dropped. +# Fails while any item is still pending. +retry_user_pending() { + local user home item left + [[ -s $user_pending ]] || return 0 + rm -f "$state"/user-pending.?????? + left=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + while read -r user item; do + home=$(omarchy_users | awk -v user="$user" '$1 == user { print $2; exit }') + [[ -n $home && -n $item ]] || continue + apply_user_item "$user" "$home" "$item" >"$left" + done <"$user_pending" + record_user_pending "$left" + rm -f "$left" + if [[ -s $user_pending ]]; then + say "User setup still pending, retried at the next run or boot: $(pending_summary)" + return 1 + fi + say "The pending user setup is done" +} + +# Outside a completed migration's cleanup: pending user setup runs again, and +# once none is left the post-reboot unit is released unless a migration is +# waiting for its reboot. +retry_user_pending_now() { + [[ -s $user_pending ]] || return 0 + if retry_user_pending && [[ ! -e $reboot_pending ]]; then + release_verify_unit + fi +} + +# A migrated Mac ends as a fresh install does: with its default packages, the +# Mac services the image's hardware setup enables, the repairs above and, for +# every Omarchy user, the migrations a fresh image records as done, the units +# first run enables and the Mac user setup. A unit the Mac already had before +# the migration is taken to be off by choice and stays off; a plan frozen +# before that was recorded enables none. The reboot that follows brings up +# what probes only at boot, such as the video decoder. +step_defaults() { + local user home item pending + install_defaults + interrupt_for_test mid defaults + retire_system_leftovers + omarchy-lifecycle-dispatch setup-system >/dev/null || die "setup-system (omarchy-lifecycle-dispatch) could not set up the Mac's services" + repair_system + interrupt_for_test mid user-setup + # What an earlier migration left pending stays pending until it succeeds. + retry_user_pending || : + rm -f "$state"/user-pending.?????? + pending=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + [[ ! -f $user_pending ]] || cat "$user_pending" >"$pending" + while read -r user home; do + [[ -n $user ]] || continue + while read -r item; do + [[ -n $item ]] || continue + say "Could not apply $item for $user; it runs again after the reboot" + printf '%s %s\n' "$user" "$item" >>"$pending" + done < <(setup_user "$user" "$home" /, so a higher +# installed version is replaced. Kernel headers come only where headers are +# installed. +# - A package installed from a retired repository, at the exact version that +# repository lists, is named by itself when an official repository carries +# it, so it moves to the official build even when that is older. One nothing +# official carries stays installed and is listed in WORK/kept. +tester_plan() { + local installed=$1 work=$2 name retired official + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + target_spec "$name" + done + + official=$work/official + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + : >"$work/kept" + for retired in "${retired_repos[@]}"; do + [[ -f $pacman_db/sync/$retired.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$retired" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") == "$version" ]] || continue + [[ " $target_packages $(replaced_pair) " != *" $name "* ]] || continue + if grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + fi + done + done + + : >"$work/allowed-removals" + for name in $tester_replaced $(replaced_pair); do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The runtime pair the target's pair replaces: omarchy and omarchy-settings on +# edge, where the omarchy-dev pair takes their place. +replaced_pair() { + [[ $target_channel != "edge" ]] || echo "omarchy omarchy-settings" +} + +# The collaboration repository's pending-sync marker outlives its repository. +tester_retire() { + rm -f "$R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending" + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} + +# --- cohort-legacy.sh ------------------------------------------------------------ + +# The legacy omarchy-mac adapter (omarchy-mac quattro): trust and packages, +# then the boot switch. +# +# A legacy Mac runs omarchy-mac's quattro fork in one of three layouts: +# - a 3.x checkout upgraded to Quattro (omarchy-upgrade-to-quattro-mac): no +# omarchy package; /usr/share/omarchy links to ~/.local/share/omarchy, +# /usr/bin/omarchy-* link into it and /etc/omarchy.conf points OMARCHY_PATH +# at it, and the setup it ran left the files a package would own unowned; +# - a guided install (omarchy-mac-setup, install.sh): omarchy and +# omarchy-settings, and the keyrings and font beside them, built from that +# checkout and installed with pacman -U; +# - a channel install: the pair from an [omarchy-aarch64] lane. +# All of them trust [omarchy-aarch64] (Optional TrustAll, TrustedOnly from rc5 +# on) and, since rc4, omarchy-mac-keyring, whose populate trusts the fork key +# FBD6874D…. The engine drops the repository and the key; this adapter plans +# the packages as the tester adapter does, and adds: +# - the packages the checkout built move to their official builds; +# - omarchy-mac-keyring is removed once nothing needs it, so no populate +# trusts the fork key again; +# - before the transaction, the files no package owns that the new packages +# bring are backed up and overwritten (pacman keeps a changed configuration +# file and writes .pacnew), then the checkout is unwired, and all of it is +# restored if pacman fails; a file another package keeps owning stops the +# migration. +# Nothing here changes the checkout itself. +# +# The boot switch is the loader step's stage, run while GRUB still boots the +# Mac and undone when it or the Limine activation fails: +# - an ESP mounted at /boot (the quattro guided installer's encrypted layout, +# omarchy-system-boot-to-esp) moves to /boot/efi, where Limine and its UKI +# live; /boot becomes the root filesystem's again and gets the kernel and +# its initramfs. GRUB's own files stay on the ESP, untouched, so the GRUB +# chain boots as before until Limine takes U-Boot's slot; +# - a root unlocked by busybox encrypt and cryptdevice= keeps its LUKS header, +# keyslots and passphrase and moves to the converged unlock: crypttab's root +# and rd.luks.name= on the kernel line, and the systemd initramfs the Apple +# boot package composes (sd-encrypt), which is checked before Limine is. +# The package transaction before it still builds the busybox image GRUB +# boots: preflight requires encrypt in mkinitcpio.conf's own HOOKS, which +# keeps the HOOKS baseline off such a line. An unencrypted Mac keeps its +# HOOKS and stays unencrypted. +# Retire removes the kernels and GRUB the moved ESP still carries. +# shellcheck disable=SC2154 # the engine and the tester adapter define the shared state + +# Built beside the pair by the checkout's build-packages.sh. +legacy_built="omarchy-keyring ttf-jetbrains-mono-nerd-basic" +legacy_keyring=omarchy-mac-keyring +# The kernel the boot switch puts on the root's /boot. +legacy_kernel=linux-aurora +legacy_channel_stages=$R/var/cache/omarchy/channels +legacy_packaged_path='export OMARCHY_PATH="/usr/share/omarchy"' + +# The checkout /usr/share/omarchy links to, or nothing on a packaged install. +legacy_checkout() { + [[ ! -L $R/usr/share/omarchy ]] || readlink "$R/usr/share/omarchy" +} + +# legacy_preflight INSTALLED LUKS HOOKS: prints the states this adapter refuses. +legacy_preflight() { + local installed=$1 luks=${2:-} hooks=${3:-} stage fpr esp_mount + if ! grep -Eq '^omarchy ' "$installed" && [[ ! -L $R/usr/share/omarchy ]]; then + echo "Omarchy is neither a package nor a Quattro checkout here: upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" + fi + if grep -Eq '^[[:space:]]*IgnorePkg[[:space:]]*=.*#[[:space:]]*omarchy-install-pair' "$pacman_conf"; then + echo "an interrupted omarchy-mac channel install left its package pin in $pacman_conf (# omarchy-install-pair); finish that install or remove the line" + fi + for stage in "$legacy_channel_stages"/transaction.*; do + [[ ! -e $stage/restore-sync ]] || + echo "an interrupted omarchy-mac channel switch still owes its sync databases a restore (${stage#"$R"}); finish it first" + done + if [[ -f $R/usr/share/pacman/keyrings/omarchy-mac-trusted ]]; then + while IFS=: read -r fpr _; do + [[ -z $fpr || " ${retired_keys[*]} " == *" $fpr "* ]] || + echo "omarchy-mac-keyring trusts $fpr, a key this migration does not remove" + done <"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" + fi + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" || ( -n $luks && " $hooks " == *" encrypt "* ) ]] && limine_mac; then + echo "this Mac boots Limine with its ESP at /boot or its root unlocked by busybox encrypt; the boot switch moves those only on a GRUB Mac" + fi + if [[ $esp_mount == "/boot" ]]; then + legacy_esp_space + legacy_fstab_esp >/dev/null || + echo "the ESP is mounted at /boot, but /etc/fstab has no single vfat line mounting it there to move to /boot/efi" + ! findmnt --mountpoint "$R/boot/efi" >/dev/null 2>&1 || + echo "the ESP is mounted at /boot and something else at /boot/efi, where the ESP moves" + fi + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + legacy_busybox_problems "$luks" "$esp_mount" + fi +} + +# An ESP at /boot holds GRUB's kernel and image until retire, the transaction's +# Aurora ones beside them and Limine's UKI of both: it needs room for another +# kernel and image on top of the engine's 64 MiB. +legacy_esp_space() { + local used=0 file + for file in "$R"/boot/vmlinuz-linux-* "$R"/boot/initramfs-linux-*.img; do + [[ -f $file && $file != *-fallback.img ]] && used=$(( used + $(stat -c %s "$file") )) + done + (( $(free_bytes "$R/boot") >= used + 64 * 1024 * 1024 )) || + echo "the ESP at /boot needs $(( (used + 64 * 1024 * 1024) / 1024 / 1024 )) MiB free for the Aurora kernel and Limine's UKI beside GRUB's" +} + +# The device of the one vfat line in fstab mounting the ESP at /boot. +legacy_fstab_esp() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { device = $1; found++ } END { if (found != 1) exit 1; print device }' "$R/etc/fstab" 2>/dev/null +} + +# fstab (stdin) with the ESP's /boot line mounting it at /boot/efi instead. +legacy_esp_fstab() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { $2 = "/boot/efi" } { print }' +} + +# GRUB's value of a defaults variable, as omarchy-mac-limine-cmdline reads it. +legacy_grub_value() { + sed -n "s/^$1=//p" "$R/etc/default/grub" 2>/dev/null | tail -n 1 | sed -E "s/^\"(.*)\"$/\1/; s/^'(.*)'$/\1/" +} + +# The busybox encrypt words GRUB's defaults pass, one per line. +legacy_crypt_words() { + local words=() word + read -ra words <<<"$(legacy_grub_value GRUB_CMDLINE_LINUX) $(legacy_grub_value GRUB_CMDLINE_LINUX_DEFAULT)" + for word in "${words[@]}"; do + [[ $word != cryptdevice=* && $word != cryptkey=* ]] || printf '%s\n' "$word" + done +} + +# A root busybox encrypt unlocks moves only from the layout the quattro guided +# installer made: one cryptdevice=UUID=:root, the kernels +# on the ESP at /boot, encrypt in mkinitcpio.conf's own HOOKS (which keeps the +# transaction's image unlocking until the switch) and no other root in crypttab. +legacy_busybox_problems() { + local luks=$1 esp_mount=$2 uuid words=() source spec + uuid=$(cryptsetup luksUUID "$luks" 2>/dev/null) || uuid="" + mapfile -t words < <(legacy_crypt_words) + if (( ${#words[@]} != 1 )) || [[ ! ${words[0]} =~ ^cryptdevice=UUID=([0-9A-Fa-f-]+):root(:allow-discards)?$ ]] || + [[ -z $uuid || ${BASH_REMATCH[1],,} != "${uuid,,}" ]]; then + echo "the root unlocks through busybox encrypt, but GRUB's defaults do not pass the one cryptdevice=UUID=${uuid:-}:root[:allow-discards] this migration moves (found: ${words[*]:-none})" + fi + source=$(findmnt -no SOURCE "$R/" 2>/dev/null) || source="" + [[ ${source%%[*} == "/dev/mapper/root" ]] || echo "the encrypted root is not mounted from /dev/mapper/root, the mapping cryptdevice= opens" + grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R/etc/mkinitcpio.conf" 2>/dev/null || + echo "busybox encrypt is not in /etc/mkinitcpio.conf's own HOOKS, so the package transaction could drop the unlock GRUB boots with; add it there first" + [[ $esp_mount == "/boot" ]] || + echo "the encrypted root's kernels are not on the ESP mounted at /boot (the quattro guided installer's layout); the ESP is at ${esp_mount:-no mountpoint}" + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ -z $spec || ${spec,,} == "uuid=${uuid,,}" ]] || echo "/etc/crypttab names another root ($spec)" + # The switch rewrites these lines double-quoted. + ! grep -Eq '^GRUB_CMDLINE_LINUX(_DEFAULT)?=.*[$`\\]' "$R/etc/default/grub" 2>/dev/null || + echo "GRUB_CMDLINE_LINUX in /etc/default/grub uses shell expansion, which the switch cannot rewrite; write the words out" +} + +# legacy_plan INSTALLED WORK LUKS HOOKS: the tester plan, plus the checkout's +# own builds where an official repository carries them, and the fork keyring's +# removal. The boot switch's unlock is recorded: "busybox UUID DISCARD" for a +# root busybox encrypt unlocks, nothing otherwise. +legacy_plan() { + local installed=$1 work=$2 luks=${3:-} hooks=${4:-} targets name word uuid + targets=$(tester_plan "$installed" "$work") || return 1 + printf '%s\n' "$targets" + for name in $legacy_built; do + [[ -n $(installed_version "$name" "$installed") ]] && grep -Fxq "$name" "$work/official" || continue + sed 's|^.*/||' <<<"$targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + : >"$work/removals" + if [[ -n $(installed_version "$legacy_keyring" "$installed") ]]; then + printf '%s\n' "$legacy_keyring" | tee -a "$work/allowed-removals" >"$work/removals" + sed -i "/^$legacy_keyring /d" "$work/kept" + fi + install -d -m 755 "$work/adapter" + legacy_checkout >"$work/adapter/checkout" + : >"$work/adapter/unlock" + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + word=$(legacy_crypt_words) + uuid=${word#cryptdevice=UUID=} + printf 'busybox %s %s\n' "${uuid%%:*}" "$([[ $word == *:allow-discards ]] && echo 1 || echo 0)" >"$work/adapter/unlock" + fi +} + +# The archives of what AFTER adds or replaces: the targets and every new name. +legacy_archives() { + local before=$1 after=$2 name version dir archive path + while read -r name version; do + [[ -z $(installed_version "$name" "$before") ]] || sed 's|^.*/||' "$plan/targets" | grep -Fxq "$name" || continue + archive="" + for dir in "$pacman_cache" "$cache/candidate" "$cache/pkg"; do + for path in "$dir/$name-$version"-*.pkg.tar.*; do + [[ -f $path && $path != *.sig ]] && archive=$path + done + done + [[ -n $archive ]] || die "the archive of $name $version is not in the cache" + printf '%s\n' "$archive" + done < <(comm -13 <(LC_ALL=C sort "$before") <(LC_ALL=C sort "$after")) +} + +# legacy_conflicts BEFORE AFTER: the files those archives would write over. A +# path no package owns is printed: the transaction may overwrite it. A path a +# package keeps owning stops the migration, before anything is written. While +# the checkout is still linked in, the paths its links reach are left out: +# the links go before pacman runs. +legacy_conflicts() { + local before=$1 after=$2 checkout archive path name paths=$state/conflicts + checkout=$(<"$plan/adapter/checkout") + : >"$paths.new" + legacy_archives "$before" "$after" >"$paths.archives" + while read -r archive; do + LC_ALL=C pacman -Qlpq "$archive" >>"$paths.new" || die "cannot list the files of $archive" + done <"$paths.archives" + LC_ALL=C sort -u "$paths.new" | while IFS= read -r path; do + [[ $path == */ ]] && continue + if [[ -n $checkout ]]; then + [[ ! ( $path == /usr/share/omarchy/* && -L $R/usr/share/omarchy ) ]] || continue + [[ ! ( $path == /usr/bin/omarchy-* && -L $R$path && $(readlink "$R$path") == "$checkout"/* ) ]] || continue + fi + [[ -e $R$path || -L $R$path ]] && [[ ! -d $R$path || -L $R$path ]] && printf '%s\n' "$path" + done >"$paths" || true + rm -f "$paths.new" "$paths.archives" + [[ -s $paths ]] || { rm -f "$paths"; return 0; } + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Ql | + awk 'NR == FNR { wanted[$0]; next } { owner = $1; sub(/^[^ ]+ /, "") } $0 in wanted { print $0 "\t" owner }' "$paths" - >"$paths.owned" || + die "cannot read which packages own the conflicting files" + while IFS= read -r path; do + name=$(awk -F'\t' -v path="$path" '$1 == path { print $2; exit }' "$paths.owned") + if [[ -z $name ]]; then + [[ $path != *,* ]] || die "the new packages bring $path, which no package owns, and pacman cannot be told to overwrite a path with a comma; move it away first" + printf '%s\n' "$path" + elif [[ $(installed_version "$name" "$before") == "$(installed_version "$name" "$after")" ]]; then + die "the new packages would overwrite $path, which $name owns and keeps; nothing was changed" + fi + done <"$paths" + rm -f "$paths" "$paths.owned" +} + +# Once the rehearsal knows what the transaction installs: its conflicts are +# checked, and the archives the check reads are linked into the migration's own +# cache, so pruning pacman's cache cannot strand a resumed transaction. +legacy_prefetch() { + local archive + legacy_conflicts "$cache/start" "$cache/expected" >/dev/null + legacy_archives "$cache/start" "$cache/expected" >"$cache/archives" + while read -r archive; do + [[ $archive != "$cache"/* ]] || continue + ln -f "$archive" "$cache/pkg/" 2>/dev/null || cp -p "$archive" "$cache/pkg/" || die "cannot keep $archive for the transaction" + done <"$cache/archives" +} + +# Keeps the first copy of a file the conversion replaces or removes. +legacy_keep() { + local path=$1 kept=$backup/converted/files$1 + [[ -e $kept || -L $kept ]] && return 0 + install -d -m 700 "$(dirname "$kept")" && cp -a "$R$path" "$kept" +} + +# Lists the unowned files the transaction replaces and backs each up, then +# unwires the checkout: nothing changes until everything that can fail on the +# way has passed. Every run repeats it from the start. +legacy_prepare() { + local checkout link target converted=$backup/converted + checkout=$(<"$plan/adapter/checkout") + install -d -m 700 "$converted" + touch "$converted/links" + legacy_conflicts "$state/installed.now" "$expected" >"$state/overwrite.new" + while IFS= read -r target; do + legacy_keep "$target" || return 1 + done <"$state/overwrite.new" + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + legacy_keep /etc/omarchy.conf || return 1 + fi + if [[ -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + legacy_keep /etc/sudoers.d/omarchy-dev-path || return 1 + fi + sync "$converted" + if [[ -n $checkout ]]; then + for link in "$R"/usr/bin/omarchy-* "$R/usr/share/omarchy"; do + [[ -L $link ]] || continue + target=$(readlink "$link") + [[ $target == "$checkout" || $target == "$checkout"/* ]] || continue + grep -Fxq "${link#"$R"}"$'\t'"$target" "$converted/links" || + printf '%s\t%s\n' "${link#"$R"}" "$target" >>"$converted/links" || return 1 + rm -f "$link" || return 1 + interrupt_for_test mid unwire + done + fi + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + printf '%s\n' "$legacy_packaged_path" | durable_write "$R/etc/omarchy.conf" 644 || return 1 + fi + rm -f "$R/etc/sudoers.d/omarchy-dev-path" || return 1 + mv "$state/overwrite.new" "$state/overwrite" || return 1 + interrupt_for_test mid convert +} + +# The transaction could not run: the links pacman did not replace come back, +# and so do the checkout's OMARCHY_PATH and a dev link's sudo path, so the Mac +# runs as before until the transaction is run again. +legacy_restore() { + local path target kept=$backup/converted/files + if [[ -f $backup/converted/links ]]; then + while IFS=$'\t' read -r path target; do + [[ -e $R$path || -L $R$path ]] || ln -s "$target" "$R$path" + done <"$backup/converted/links" + fi + if [[ -f $kept/etc/omarchy.conf ]] && grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf" 2>/dev/null; then + cp -a "$kept/etc/omarchy.conf" "$R/etc/omarchy.conf" + fi + if [[ -f $kept/etc/sudoers.d/omarchy-dev-path && ! -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + cp -a "$kept/etc/sudoers.d/omarchy-dev-path" "$R/etc/sudoers.d/omarchy-dev-path" + fi + return 0 +} + +# --- The boot switch ----------------------------------------------------------- + +# Keeps the first copy of a file the boot switch changes, or a note that it did +# not exist, so the switch can be undone; written whole, never half. +legacy_stage_keep() { + local path=$1 kept=$backup/boot-switch + [[ -e $kept/files$path || -L $kept/files$path || -e $kept/absent$path ]] && return 0 + if [[ -e $R$path || -L $R$path ]]; then + install -d -m 700 "$(dirname "$kept/files$path")" && + cp -a "$R$path" "$kept/files$path.new" && sync "$kept/files$path.new" && mv "$kept/files$path.new" "$kept/files$path" + else + install -d -m 700 "$(dirname "$kept/absent$path")" && : >"$kept/absent$path" && sync "$kept/absent$path" + fi +} + +# Puts back what legacy_stage_keep kept of PATH. +legacy_stage_restore() { + local path=$1 kept=$backup/boot-switch + if [[ -e $kept/absent$path ]]; then + rm -f "$R$path" + elif [[ -e $kept/files$path || -L $kept/files$path ]]; then + cp -a "$kept/files$path" "$R$path.restore" && mv -f "$R$path.restore" "$R$path" + fi +} + +# The ESP moves from /boot to /boot/efi: fstab first, then the mounts. Each +# part is skipped once done, so a run cut short continues where it was. +legacy_move_esp() { + local fstab=$R/etc/fstab + if legacy_fstab_esp >/dev/null; then + legacy_stage_keep /etc/fstab || return 1 + legacy_esp_fstab <"$fstab" | durable_write "$fstab" 644 || return 1 + fi + interrupt_for_test mid esp-fstab + if [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]]; then + systemctl daemon-reload >/dev/null 2>&1 || echo "systemctl daemon-reload failed; the mounts move anyway" >&2 + umount "$R/boot" || { echo "cannot unmount the ESP from /boot" >&2; return 1; } + fi + interrupt_for_test mid esp-unmounted + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + install -d -m 755 "$R/boot/efi" && mount "$R/boot/efi" || { echo "cannot mount the ESP at /boot/efi" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot/efi" ]] || { echo "the ESP is not mounted at /boot/efi after the move" >&2; return 1; } + # Asahi's update-grub, which the Limine activation still runs, resolves its + # directory before creating it: without one it fails. + install -d -m 755 "$R/boot/grub" || { echo "cannot create /boot/grub" >&2; return 1; } +} + +# The ESP goes back to /boot. What the switch put on the root's /boot is +# removed only once no ESP covers it. +legacy_restore_esp() { + local where + where=$(omarchy-mac-esp 2>/dev/null) || where="" + if [[ $where == "/boot/efi" ]]; then + umount "$R/boot/efi" || { echo "cannot unmount the ESP from /boot/efi" >&2; return 1; } + fi + if ! findmnt --mountpoint "$R/boot" >/dev/null 2>&1; then + rm -f "$R/boot/vmlinuz-$legacy_kernel" "$R/boot/initramfs-$legacy_kernel.img" "$R/boot/initramfs-$legacy_kernel-fallback.img" + rmdir "$R/boot/efi" "$R/boot/grub" 2>/dev/null || true + fi + legacy_stage_restore /etc/fstab || return 1 + systemctl daemon-reload >/dev/null 2>&1 || true + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]]; then + mount "$R/boot" || { echo "cannot mount the ESP at /boot again" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]] || { echo "the ESP is not back at /boot" >&2; return 1; } +} + +# HOOKS lines (stdin) without busybox encrypt and asahi: the HOOKS baseline +# and the Apple boot package's drop-ins then compose the systemd image, the +# asahi hook back in its place with the firmware loader beside it. A HOOKS +# assignment that does not fit on one line cannot be edited: exit 2. +legacy_drop_hooks() { + awk ' + /^[[:space:]]*HOOKS\+?=\(/ { + if (!match($0, /\([^)]*\)/)) { bad = 1; print; next } + head = substr($0, 1, RSTART); tail = substr($0, RSTART + RLENGTH - 1) + n = split(substr($0, RSTART + 1, RLENGTH - 2), words, /[[:space:]]+/) + kept = "" + for (i = 1; i <= n; i++) if (words[i] != "" && words[i] != "encrypt" && words[i] != "asahi") kept = kept (kept == "" ? "" : " ") words[i] + print head kept tail + next + } + { print } + END { exit bad ? 2 : 0 } + ' +} + +# GRUB's defaults FILE with cryptdevice= gone and the root's rd.luks.name= +# (and rd.luks.options= for allow-discards) on the last GRUB_CMDLINE_LINUX, +# where the Apple encrypt flow keeps them and omarchy-mac-limine-cmdline reads +# them. Both variables are written double-quoted. +legacy_grub_unlock() { + local file=$1 uuid=$2 discard=$3 want + want="rd.luks.name=$uuid=root" + (( ! discard )) || want+=" rd.luks.options=$uuid=discard" + awk -v want="$want" ' + function strip(value, n, i, words, out) { + n = split(value, words, /[[:space:]]+/) + out = "" + for (i = 1; i <= n; i++) + if (words[i] != "" && words[i] !~ /^(cryptdevice|rd\.luks\.name|rd\.luks\.options)=/) out = out (out == "" ? "" : " ") words[i] + return out + } + NR == FNR { if ($0 ~ /^GRUB_CMDLINE_LINUX=/) last = FNR; next } + /^GRUB_CMDLINE_LINUX(_DEFAULT)?=/ { + key = $0; sub(/=.*/, "", key) + value = $0; sub(/^[^=]*=/, "", value) + if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) value = substr(value, 2, length(value) - 2) + value = strip(value) + if (FNR == last) value = value (value == "" ? "" : " ") want + print key "=\"" value "\"" + next + } + { print } + END { if (!last) print "GRUB_CMDLINE_LINUX=\"" want "\"" } + ' "$file" "$file" +} + +# The root's unlock moves from busybox encrypt to sd-encrypt. Every file is +# kept first and written whole; running it again changes nothing. +legacy_switch_unlock() { + local uuid=$1 discard=$2 options=luks file owned conf=$R/etc/mkinitcpio.conf grub=$R/etc/default/grub + (( ! discard )) || options+=,discard + legacy_stage_keep /etc/crypttab || return 1 + { [[ ! -f $R/etc/crypttab ]] || awk '$1 != "root"' "$R/etc/crypttab"; printf 'root UUID=%s none %s\n' "$uuid" "$options"; } | + durable_write "$R/etc/crypttab" 644 || return 1 + legacy_stage_keep /etc/default/grub || return 1 + legacy_grub_unlock "$grub" "$uuid" "$discard" | durable_write "$grub" 644 || return 1 + interrupt_for_test mid unlock + legacy_stage_keep /etc/mkinitcpio.conf || return 1 + legacy_drop_hooks <"$conf" >"$state/mkinitcpio.conf.new" || + { echo "cannot edit the HOOKS in /etc/mkinitcpio.conf (one HOOKS=(...) line each is expected)" >&2; return 1; } + durable_write "$conf" 644 <"$state/mkinitcpio.conf.new" || return 1 + rm -f "$state/mkinitcpio.conf.new" + # The fork's omarchy_hooks.conf sets the busybox line outright and sorts + # after the Apple drop-ins. Where no package took it over, it goes too. Any + # other drop-in is left alone; the check below names the HOOKS it gives. + file=/etc/mkinitcpio.conf.d/omarchy_hooks.conf + if [[ -f $R$file ]] && grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R$file"; then + owned=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq 2>/dev/null) || + { echo "cannot read which packages own $file" >&2; return 1; } + if ! grep -Fxq "$file" <<<"$owned"; then + legacy_stage_keep "$file" && rm -f "$R$file" || return 1 + fi + fi +} + +# The kernel on the root's /boot (as the kernel's own install hook copies it) +# and its initramfs. Never onto an ESP still mounted at /boot: GRUB boots that. +legacy_build_initramfs() { + local release image target=$R/boot/vmlinuz-$legacy_kernel + [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]] || { echo "the ESP is still mounted at /boot" >&2; return 1; } + release=$(kernel_release "$legacy_kernel") || { echo "$legacy_kernel has no module tree" >&2; return 1; } + image=$R/usr/lib/modules/$release/vmlinuz + if ! cmp -s "$image" "$target"; then + install -m 644 "$image" "$target.new" && sync "$target.new" && mv -f "$target.new" "$target" || + { echo "cannot put $legacy_kernel on /boot" >&2; return 1; } + fi + interrupt_for_test mid initramfs + mkinitcpio -p "$legacy_kernel" >"$state/mkinitcpio.log" 2>&1 || + { echo "mkinitcpio -p $legacy_kernel failed: $(tail -n 1 "$state/mkinitcpio.log")" >&2; return 1; } +} + +# What the next boot unlocks with, checked before Limine is activated: the +# HOOKS, the image built from them, crypttab and the kernel line's source. +legacy_check_unlock() { + local uuid=$1 hooks listing spec + hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null) || { echo "cannot read the initramfs HOOKS after the switch" >&2; return 1; } + if [[ " $hooks " == *" encrypt "* || " $hooks " != *" systemd "* || " $hooks " != *" sd-encrypt "* || " $hooks " != *" asahi "* ]]; then + echo "the initramfs HOOKS after the switch do not unlock the root through systemd (sd-encrypt, with asahi): $hooks" >&2 + return 1 + fi + listing=$(lsinitcpio -l "$R/boot/initramfs-$legacy_kernel.img" 2>/dev/null) || { echo "/boot/initramfs-$legacy_kernel.img cannot be listed" >&2; return 1; } + if ! grep -Eq '(^|/)usr/lib/systemd/system-generators/systemd-cryptsetup-generator$' <<<"$listing" || + ! grep -Eq '(^|/)usr/bin/systemd-cryptsetup$' <<<"$listing" || grep -Eq '(^|/)hooks/encrypt$' <<<"$listing"; then + echo "/boot/initramfs-$legacy_kernel.img does not unlock the root through sd-encrypt" >&2 + return 1 + fi + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ ${spec,,} == "uuid=${uuid,,}" ]] || { echo "/etc/crypttab does not name the root UUID=$uuid" >&2; return 1; } + if [[ -n $(legacy_crypt_words) || " $(legacy_grub_value GRUB_CMDLINE_LINUX) " != *" rd.luks.name=$uuid=root "* ]]; then + echo "GRUB's defaults, which Limine's kernel line comes from, do not unlock the root with rd.luks.name=$uuid=root alone" >&2 + return 1 + fi +} + +# The loader step's stage, while GRUB still boots the Mac: the ESP off /boot, +# the unlock off busybox encrypt, then the kernel and initramfs Limine's UKI is +# built from. An unencrypted Mac with its ESP at /boot/efi has nothing to do. +legacy_stage() { + local esp_mount unlock="" uuid="" discard=0 + esp_mount=$(plan_esp) + [[ ! -s $plan/adapter/unlock ]] || read -r unlock uuid discard <"$plan/adapter/unlock" + [[ $esp_mount == "/boot" || $unlock == "busybox" ]] || return 0 + if [[ $esp_mount == "/boot" ]]; then + legacy_move_esp || return 1 + fi + if [[ $unlock == "busybox" ]]; then + legacy_switch_unlock "$uuid" "$discard" || return 1 + fi + legacy_build_initramfs || return 1 + if [[ $unlock == "busybox" ]]; then + legacy_check_unlock "$uuid" || return 1 + fi +} + +# Undoes the stage in reverse: the unlock's files, then the ESP's mount. The +# busybox image GRUB boots stayed on the ESP throughout. +legacy_unstage() { + local kept=$backup/boot-switch path + [[ -d $kept ]] || return 0 + while IFS= read -r path; do + [[ $path == "/etc/fstab" ]] || legacy_stage_restore "$path" || return 1 + done < <(cd "$kept" && find files absent \( -type f -o -type l \) ! -name '*.new' 2>/dev/null | sed -E 's#^(files|absent)##' | LC_ALL=C sort -u) + if [[ $(plan_esp) == "/boot" ]]; then + legacy_restore_esp || return 1 + fi + # Everything is back: a later stage keeps what it finds then. + rm -rf "$kept" + echo "The boot switch was undone; GRUB boots this Mac as before." >&2 +} + +# The kernels, initramfs images and GRUB a moved ESP still carries at its top: +# Limine boots the UKI now, and the backup holds the ESP as it was. +legacy_retire_esp() { + [[ $(plan_esp) == "/boot" ]] || return 0 + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + say "The ESP is not mounted at /boot/efi; its old kernels and GRUB stay on it." + return 0 + fi + rm -f "$R"/boot/efi/vmlinuz-linux-* "$R"/boot/efi/initramfs-linux-*.img && rm -rf "$R/boot/efi/grub" +} + +# The fork's channel machinery goes with its repository, and the copies of +# pacman.conf its tools left beside it that still trust unsigned packages move +# into the backup, so none is restored by mistake. The checkout stays where it +# is, unused. An autologin on an unencrypted root stays too: quattro retired +# the boot lock's own long ago, so one there now is an administrator's opt-in. +legacy_retire() { + local checkout file + tester_retire + legacy_retire_esp || return 1 + rm -rf "$legacy_channel_stages"/transaction.* + for file in "$R"/etc/pacman.conf.*; do + [[ -f $file ]] && grep -Eq '^[[:space:]]*SigLevel[[:space:]]*=.*TrustAll' "$file" || continue + legacy_keep "${file#"$R"}" && rm -f "$file" || return 1 + done + checkout=$(<"$plan/adapter/checkout") + if [[ -n $checkout ]]; then + say "Omarchy now runs from its packages. The checkout at $checkout is no longer used; keep or remove it." + fi +} + +# --- cohort-mx-mac.sh ------------------------------------------------------------ + +# The mx-mac adapter. +# +# An mx-mac Mac runs the omarchy-mx-mac fork: the omarchy-dev and +# omarchy-settings-dev runtime pair with the rest of the fork's bundle, which +# omarchy-update-asahi-bundle installs from signed release assets with +# pacman -U, the fork's own [omarchy] release repository, and the Aurora kernel +# from [omarchy-aurora]. omarchy-update-asahi-repository and +# omarchy-update-aurora-repository keep those two sections on the fork's +# latest releases by rewriting pacman.conf. One transaction swaps the fork's +# pair for the channel's official one (on edge, Omarchy's own omarchy-dev pair, +# named explicitly because the fork's builds sort above it; elsewhere omarchy and +# omarchy-settings, which conflict with it), and moves every fork build an +# official repository carries to that build. The switch drops both +# fork sections and the fork's keys. The updaters leave with omarchy-dev, and +# retire moves the state they read into the backup, so nothing can point the +# Mac back at a fork release. Encryption, snapshots and Limine stay the +# engine's: nothing here touches them. +# shellcheck disable=SC2154 # the engine defines the shared state + +retired_repos+=(omarchy-aurora) +# The fork's release key, which signs the bundle's release pointers. +retired_keys+=(5983B1CA32CB778F4D74D24ECFF35022CA5B5959) + +# What omarchy-update-asahi-bundle installs, so no repository lists it. +mx_mac_bundle="omarchy-dev omarchy-settings-dev omarchy-keyring omarchy-nvim quickshell-git ttf-jetbrains-mono-nerd-basic" +# What the target's packages replace, as on a tester. +mx_mac_replaced="linux-asahi linux-asahi-headers m1n1 omarchy-apple-boot omarchy-first-boot omarchy-settings-asahi" +# The records the bundle and channel updaters keep in /var/lib/omarchy. +mx_mac_state="asahi-quattro-release asahi-quattro-release.pending asahi-package-repository aurora-target.descriptor apple-silicon-channel apple-silicon-aurora-lane" + +# The official name of a fork build. The official package conflicts with the +# fork one it replaces, so naming it removes the fork build in the same +# transaction. +mx_mac_counterpart() { + case $1 in + omarchy-dev | omarchy-settings-dev) + if [[ $target_channel == "edge" ]]; then + echo "$1" + elif [[ $1 == "omarchy-dev" ]]; then + echo omarchy + else + echo omarchy-settings + fi + ;; + quickshell-git) echo quickshell ;; + mise | dotnet-host | dotnet-runtime) echo "$1-bin" ;; + *) echo "$1" ;; + esac +} + +# omacom's repositories carry an omarchy-dev of their own, so the fork is told +# by its updaters or their records, not by the package name alone. The engine +# asks this before it picks the cohort. +mx_mac_fork() { + local marker + for marker in usr/share/omarchy/bin/omarchy-update-asahi-bundle usr/share/omarchy/bin/omarchy-update-asahi-repository \ + usr/share/omarchy/bin/omarchy-update-aurora-repository var/lib/omarchy/asahi-quattro-release var/lib/omarchy/asahi-package-repository; do + [[ ! -e $R/$marker ]] || return 0 + done + return 1 +} + +# Official migrations the fork's runner settled as handled, not run: its +# Quattro transition applied their effect (packages, theme and Hyprland state, +# the network and zram changes), so running them again could edit the user's +# configuration twice. The runner records that in .sh.skipped. +mx_mac_handled="1778623107 1780739888 1781043107 1781063758 1781158082 1781485962 1781793381 1782002156 1784401744 1784672586 1784914435 1784961000 1785013000" + +# mx_mac_settled DIR: the handled migrations DIR's records say the fork's +# runner settled, which the engine records as done. What it skipped instead +# runs on the new packages, apart from what the engine settles on every Mac. +mx_mac_settled() { + local dir=$1 name record disposition + for name in $mx_mac_handled; do + record=$dir/$name.sh.skipped + [[ -f $record && ! -L $record ]] || continue + IFS=$'\t' read -r _ disposition _ <"$record" || continue + [[ $disposition != "handled" ]] || printf '%s\n' "$name" + done +} + +# mx_mac_preflight INSTALLED LUKS: prints the states this adapter refuses. +mx_mac_preflight() { + local name + for name in $(channel_pair "$target_channel"); do + [[ " $target_packages " == *" $name "* ]] || echo "the target has no $name to replace the fork's runtime pair" + done +} + +# mx_mac_plan INSTALLED WORK: prints the transaction's targets, one per line, +# and writes WORK/allowed-removals, WORK/removals and WORK/kept. WORK/db holds +# the target's synced databases; the live ones are still the fork's. +# +# - A fork build is a bundle package, or a package installed at the exact +# version the fork's [omarchy] or [omarchy-aurora] lists. +# - The target's packages are named with their repository (target_spec), so a higher +# installed version is replaced: the Mac packages always, kernel headers only +# where headers are installed, and every other one where it is installed or +# replaces a fork build. +# - Every other fork build is named when an official repository carries it, +# by its own name or else by its official counterpart's, so it moves to the +# official build even when that is older. One nothing official carries stays +# installed and is listed in WORK/kept. +# - The fork builds whose official counterpart has another name are removed: +# by the counterpart's conflict where it has one, else by name after the +# install (a versioned conflict, such as dotnet-runtime-bin's, can miss the +# fork's build). The transaction may also remove what the target's packages +# replace. +mx_mac_plan() { + local installed=$1 work=$2 name version counterpart repo official=$2/official fork=$2/fork present=$2/present named=$2/named + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + { + for name in $mx_mac_bundle; do + version=$(installed_version "$name" "$installed") + [[ -z $version ]] || printf '%s %s\n' "$name" "$version" + done + for repo in omarchy omarchy-aurora; do + [[ -f $pacman_db/sync/$repo.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$repo" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") != "$version" ]] || printf '%s %s\n' "$name" "$version" + done + done + } | LC_ALL=C sort -u >"$fork" + { + awk '{ print $1 }' "$installed" + while read -r name _; do + mx_mac_counterpart "$name" + done <"$fork" + } | LC_ALL=C sort -u >"$present" + + : >"$named" + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + if [[ " $(channel_packages "$target_channel") " == *" $name "* ]] || grep -Fxq "$name" "$present"; then + target_spec "$name" + printf '%s\n' "$name" >>"$named" + fi + done + + : >"$work/kept" + : >"$work/allowed-removals" + : >"$work/removals" + while read -r name version; do + counterpart=$(mx_mac_counterpart "$name") + if grep -Fxq "$name" "$named"; then + continue + elif grep -Fxq "$counterpart" "$named"; then + : + elif grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + continue + elif grep -Fxq "$counterpart" "$official"; then + printf '%s\n' "$counterpart" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + continue + fi + printf '%s\n' "$name" | tee -a "$work/allowed-removals" >>"$work/removals" + done <"$fork" + for name in $mx_mac_replaced; do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The updaters left with omarchy-dev; what they read is kept with the backup, +# where no updater or check looks for it. +mx_mac_retire() { + local name moved=$backup/mx-mac-state + for name in $mx_mac_state; do + [[ -e $R/var/lib/omarchy/$name || -L $R/var/lib/omarchy/$name ]] || continue + install -d -m 700 "$moved" && mv -f "$R/var/lib/omarchy/$name" "$moved/$name" || return 1 + interrupt_for_test mid mx-mac-retire + done + if [[ -d $moved ]]; then + sync "$moved" "$R/var/lib/omarchy" || return 1 + fi + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} + +migrate_main "$@" diff --git a/migrate/README.md b/migrate/README.md new file mode 100644 index 00000000000..d0f0d09d26c --- /dev/null +++ b/migrate/README.md @@ -0,0 +1,37 @@ +# omarchy-mac-migrate + +Moves an Apple Silicon Mac running an Omarchy fork onto Omarchy's official packages for the channel it follows, as one journaled, resumable migration. It is a single self-contained script (`bin/omarchy-mac-migrate`) built from `migrate/src`, and no package carries migration code: omarchy-mac (this repository's `quattro`) and omarchy-mx-mac ship the script, and testers download the release asset. + +## What a migrated Mac runs + +On edge: `omarchy-dev` and `omarchy-settings-dev` from `https://pkgs.omarchy.org/edge/aarch64`, `omarchy-mac` and `omarchy-mac-boot` (built from omacom/omarchy-mac-pkgs), `linux-aurora`, `m1n1-aurora`, `uboot-asahi` and Limine; `asahi-alarm-keyring` and `omarchy-keyring`. `/etc/pacman.conf` is Omarchy's Apple Silicon configuration (`[omarchy]` first, `[asahi-alarm]`, then Arch Linux ARM), plus the administrator's own options and repositories. No fork package, repository, key or pin is left. On stable and rc the runtime pair is `omarchy` and `omarchy-settings`. + +A channel takes Macs once the signed archives its `[omarchy]` would install carry the Mac: the runtime ships `omarchy-lifecycle-dispatch`, and `omarchy-mac-boot` ships its `setup-boot` and `update-verify` operations and no migration engine of its own (that is, it is built from omacom/omarchy-mac-pkgs). Until then every Mac on that channel defers, with nothing changed. + +## Who it moves + +| Cohort | Told by | Channel | +| --- | --- | --- | +| omarchy-mac quattro (legacy): checkout, guided or channel install | no `omarchy` package, `omarchy-mac-keyring`, or quattro's `omarchy-upgrade-to-quattro-mac` | `[omarchy-aarch64]` lane `…/omarchy-pkgs-aarch64/releases/download/` | +| Test images and collaboration builds (tester) | `omarchy` installed; or the dev pair with the image builder's pin | `[omarchy-aarch64]` lane, else `[omarchy]` `pkgs.omarchy.org/` | +| omarchy-mx-mac | `omarchy-dev` with the fork's updaters or records | `omarchy-apple-silicon-channel current` | +| Omarchy's own dev pair | `omarchy-dev` without the above | nothing to migrate | + +An administrator's `/etc/omarchy-mac/migration-target` (or `--target FILE`, root-owned) overrides the channel or points at a mirror or a signed candidate set. A channel that cannot be told defers. + +## How it runs + +`status`, `check` (preflight only), `run`, `verify` (the boot unit's). Exit 0: migrated, waiting for its reboot, or nothing to migrate. 75: deferred, nothing changed. Anything else: a step failed after the repository switch; the next run, or the next boot, resumes it. + +Steps, each journaled in `/var/lib/omarchy-mac/migration/journal`: `preflight` (refusals, channel, isolated resolution against a copy of the package database and keyring, the target's verified archives, its boot tools for the checks; freezes the plan and the sync databases), `backup` (packages, `/etc`, `/boot`, the ESP, the LUKS header), `keyring`, `prefetch` (downloads and rehearses the one transaction on a database copy; refuses any removal the plan does not allow and any file a removed package would take from another), `repositories` (the core configuration with a guard that holds back every package the migration changes; arms the boot unit), `transaction` (one `pacman -Su` from the frozen databases and cache), `boot-chain`, `loader` (the legacy GRUB→Limine stage, then `omarchy-lifecycle-dispatch setup-boot`), `defaults` (default packages, `setup-system`, repairs, settled migrations, user units, `setup-user` per user), `verify` (`update-verify`), `unpin` (drops the guard and a test image's pin), `reboot` (waits; after it, the running Aurora kernel, the packaged Limine, the full boot check and `update-verify`), `retire`. + +Before the repository switch a failure sets the attempt aside and defers (the next run starts over); from it on, the migration only goes forward. The tool keeps a copy of itself beside the journal; a migration in progress resumes with that copy unless a newer tool of the same journal format takes over. + +## Build and release + +```bash +migrate/build # writes bin/omarchy-mac-migrate and the README one-liner's checksum +migrate/build --check # CI: both are current +``` + +Tests: `test/shell.d/mac-migrate-test.sh` (tester cohort, engine), `mac-migrate-legacy-test.sh`, `mac-migrate-mx-test.sh`, `mac-move-migration-test.sh` (delivery). Release: raise `tool_version` in `src/engine.sh` (and `journal_format` only when a journal can no longer be resumed across versions), build, merge, then publish `bin/omarchy-mac-migrate` as the asset of release `mac-migrate-v`. omarchy-mx-mac vendors the same file byte for byte. diff --git a/migrate/build b/migrate/build new file mode 100755 index 00000000000..1ee8b89b159 --- /dev/null +++ b/migrate/build @@ -0,0 +1,58 @@ +#!/bin/bash + +# Builds bin/omarchy-mac-migrate, one self-contained script, from migrate/src. +# --check fails when the committed script is not what the sources build. + +set -euo pipefail + +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +output=$here/../bin/omarchy-mac-migrate +modules=(main.sh engine.sh target.sh payload.sh repairs.sh users.sh cohort-tester.sh cohort-legacy.sh cohort-mx-mac.sh) + +build() { + local module + for module in "${modules[@]}"; do + if [[ $module != "main.sh" ]]; then + printf '\n# --- %s %s\n\n' "$module" "$(printf '%.0s-' {1..60})" + fi + cat "$here/src/$module" + done + printf '\nmigrate_main "$@"\n' +} + +readme=$here/../README.md +version=$(sed -n 's/^tool_version=\([0-9]*\)$/\1/p' "$here/src/engine.sh") + +# The README's one-liner names this version's release asset and its checksum. +readme_line() { + printf 'd=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v%s/omarchy-mac-migrate && echo "%s $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run\n' \ + "$version" "$1" +} + +with_readme_line() { + awk -v line="$1" '/^d=\$\(mktemp -d\) && curl .*omarchy-mac-migrate/ { print line; next } { print }' "$readme" +} + +case ${1:-} in + --check) + if ! cmp -s <(build) "$output"; then + echo "bin/omarchy-mac-migrate is not built from migrate/src: run migrate/build" >&2 + exit 1 + fi + if ! cmp -s <(with_readme_line "$(readme_line "$(sha256sum "$output" | cut -d' ' -f1)")") "$readme"; then + echo "README.md's one-liner does not name this build: run migrate/build" >&2 + exit 1 + fi + ;; + "") + build >"$output.new" + chmod 755 "$output.new" + mv "$output.new" "$output" + with_readme_line "$(readme_line "$(sha256sum "$output" | cut -d' ' -f1)")" >"$readme.new" + mv "$readme.new" "$readme" + ;; + *) + echo "Usage: migrate/build [--check]" >&2 + exit 2 + ;; +esac diff --git a/migrate/src/cohort-legacy.sh b/migrate/src/cohort-legacy.sh new file mode 100644 index 00000000000..972f3b5118f --- /dev/null +++ b/migrate/src/cohort-legacy.sh @@ -0,0 +1,564 @@ +# The legacy omarchy-mac adapter (omarchy-mac quattro): trust and packages, +# then the boot switch. +# +# A legacy Mac runs omarchy-mac's quattro fork in one of three layouts: +# - a 3.x checkout upgraded to Quattro (omarchy-upgrade-to-quattro-mac): no +# omarchy package; /usr/share/omarchy links to ~/.local/share/omarchy, +# /usr/bin/omarchy-* link into it and /etc/omarchy.conf points OMARCHY_PATH +# at it, and the setup it ran left the files a package would own unowned; +# - a guided install (omarchy-mac-setup, install.sh): omarchy and +# omarchy-settings, and the keyrings and font beside them, built from that +# checkout and installed with pacman -U; +# - a channel install: the pair from an [omarchy-aarch64] lane. +# All of them trust [omarchy-aarch64] (Optional TrustAll, TrustedOnly from rc5 +# on) and, since rc4, omarchy-mac-keyring, whose populate trusts the fork key +# FBD6874D…. The engine drops the repository and the key; this adapter plans +# the packages as the tester adapter does, and adds: +# - the packages the checkout built move to their official builds; +# - omarchy-mac-keyring is removed once nothing needs it, so no populate +# trusts the fork key again; +# - before the transaction, the files no package owns that the new packages +# bring are backed up and overwritten (pacman keeps a changed configuration +# file and writes .pacnew), then the checkout is unwired, and all of it is +# restored if pacman fails; a file another package keeps owning stops the +# migration. +# Nothing here changes the checkout itself. +# +# The boot switch is the loader step's stage, run while GRUB still boots the +# Mac and undone when it or the Limine activation fails: +# - an ESP mounted at /boot (the quattro guided installer's encrypted layout, +# omarchy-system-boot-to-esp) moves to /boot/efi, where Limine and its UKI +# live; /boot becomes the root filesystem's again and gets the kernel and +# its initramfs. GRUB's own files stay on the ESP, untouched, so the GRUB +# chain boots as before until Limine takes U-Boot's slot; +# - a root unlocked by busybox encrypt and cryptdevice= keeps its LUKS header, +# keyslots and passphrase and moves to the converged unlock: crypttab's root +# and rd.luks.name= on the kernel line, and the systemd initramfs the Apple +# boot package composes (sd-encrypt), which is checked before Limine is. +# The package transaction before it still builds the busybox image GRUB +# boots: preflight requires encrypt in mkinitcpio.conf's own HOOKS, which +# keeps the HOOKS baseline off such a line. An unencrypted Mac keeps its +# HOOKS and stays unencrypted. +# Retire removes the kernels and GRUB the moved ESP still carries. +# shellcheck disable=SC2154 # the engine and the tester adapter define the shared state + +# Built beside the pair by the checkout's build-packages.sh. +legacy_built="omarchy-keyring ttf-jetbrains-mono-nerd-basic" +legacy_keyring=omarchy-mac-keyring +# The kernel the boot switch puts on the root's /boot. +legacy_kernel=linux-aurora +legacy_channel_stages=$R/var/cache/omarchy/channels +legacy_packaged_path='export OMARCHY_PATH="/usr/share/omarchy"' + +# The checkout /usr/share/omarchy links to, or nothing on a packaged install. +legacy_checkout() { + [[ ! -L $R/usr/share/omarchy ]] || readlink "$R/usr/share/omarchy" +} + +# legacy_preflight INSTALLED LUKS HOOKS: prints the states this adapter refuses. +legacy_preflight() { + local installed=$1 luks=${2:-} hooks=${3:-} stage fpr esp_mount + if ! grep -Eq '^omarchy ' "$installed" && [[ ! -L $R/usr/share/omarchy ]]; then + echo "Omarchy is neither a package nor a Quattro checkout here: upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" + fi + if grep -Eq '^[[:space:]]*IgnorePkg[[:space:]]*=.*#[[:space:]]*omarchy-install-pair' "$pacman_conf"; then + echo "an interrupted omarchy-mac channel install left its package pin in $pacman_conf (# omarchy-install-pair); finish that install or remove the line" + fi + for stage in "$legacy_channel_stages"/transaction.*; do + [[ ! -e $stage/restore-sync ]] || + echo "an interrupted omarchy-mac channel switch still owes its sync databases a restore (${stage#"$R"}); finish it first" + done + if [[ -f $R/usr/share/pacman/keyrings/omarchy-mac-trusted ]]; then + while IFS=: read -r fpr _; do + [[ -z $fpr || " ${retired_keys[*]} " == *" $fpr "* ]] || + echo "omarchy-mac-keyring trusts $fpr, a key this migration does not remove" + done <"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" + fi + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" || ( -n $luks && " $hooks " == *" encrypt "* ) ]] && limine_mac; then + echo "this Mac boots Limine with its ESP at /boot or its root unlocked by busybox encrypt; the boot switch moves those only on a GRUB Mac" + fi + if [[ $esp_mount == "/boot" ]]; then + legacy_esp_space + legacy_fstab_esp >/dev/null || + echo "the ESP is mounted at /boot, but /etc/fstab has no single vfat line mounting it there to move to /boot/efi" + ! findmnt --mountpoint "$R/boot/efi" >/dev/null 2>&1 || + echo "the ESP is mounted at /boot and something else at /boot/efi, where the ESP moves" + fi + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + legacy_busybox_problems "$luks" "$esp_mount" + fi +} + +# An ESP at /boot holds GRUB's kernel and image until retire, the transaction's +# Aurora ones beside them and Limine's UKI of both: it needs room for another +# kernel and image on top of the engine's 64 MiB. +legacy_esp_space() { + local used=0 file + for file in "$R"/boot/vmlinuz-linux-* "$R"/boot/initramfs-linux-*.img; do + [[ -f $file && $file != *-fallback.img ]] && used=$(( used + $(stat -c %s "$file") )) + done + (( $(free_bytes "$R/boot") >= used + 64 * 1024 * 1024 )) || + echo "the ESP at /boot needs $(( (used + 64 * 1024 * 1024) / 1024 / 1024 )) MiB free for the Aurora kernel and Limine's UKI beside GRUB's" +} + +# The device of the one vfat line in fstab mounting the ESP at /boot. +legacy_fstab_esp() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { device = $1; found++ } END { if (found != 1) exit 1; print device }' "$R/etc/fstab" 2>/dev/null +} + +# fstab (stdin) with the ESP's /boot line mounting it at /boot/efi instead. +legacy_esp_fstab() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { $2 = "/boot/efi" } { print }' +} + +# GRUB's value of a defaults variable, as omarchy-mac-limine-cmdline reads it. +legacy_grub_value() { + sed -n "s/^$1=//p" "$R/etc/default/grub" 2>/dev/null | tail -n 1 | sed -E "s/^\"(.*)\"$/\1/; s/^'(.*)'$/\1/" +} + +# The busybox encrypt words GRUB's defaults pass, one per line. +legacy_crypt_words() { + local words=() word + read -ra words <<<"$(legacy_grub_value GRUB_CMDLINE_LINUX) $(legacy_grub_value GRUB_CMDLINE_LINUX_DEFAULT)" + for word in "${words[@]}"; do + [[ $word != cryptdevice=* && $word != cryptkey=* ]] || printf '%s\n' "$word" + done +} + +# A root busybox encrypt unlocks moves only from the layout the quattro guided +# installer made: one cryptdevice=UUID=:root, the kernels +# on the ESP at /boot, encrypt in mkinitcpio.conf's own HOOKS (which keeps the +# transaction's image unlocking until the switch) and no other root in crypttab. +legacy_busybox_problems() { + local luks=$1 esp_mount=$2 uuid words=() source spec + uuid=$(cryptsetup luksUUID "$luks" 2>/dev/null) || uuid="" + mapfile -t words < <(legacy_crypt_words) + if (( ${#words[@]} != 1 )) || [[ ! ${words[0]} =~ ^cryptdevice=UUID=([0-9A-Fa-f-]+):root(:allow-discards)?$ ]] || + [[ -z $uuid || ${BASH_REMATCH[1],,} != "${uuid,,}" ]]; then + echo "the root unlocks through busybox encrypt, but GRUB's defaults do not pass the one cryptdevice=UUID=${uuid:-}:root[:allow-discards] this migration moves (found: ${words[*]:-none})" + fi + source=$(findmnt -no SOURCE "$R/" 2>/dev/null) || source="" + [[ ${source%%[*} == "/dev/mapper/root" ]] || echo "the encrypted root is not mounted from /dev/mapper/root, the mapping cryptdevice= opens" + grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R/etc/mkinitcpio.conf" 2>/dev/null || + echo "busybox encrypt is not in /etc/mkinitcpio.conf's own HOOKS, so the package transaction could drop the unlock GRUB boots with; add it there first" + [[ $esp_mount == "/boot" ]] || + echo "the encrypted root's kernels are not on the ESP mounted at /boot (the quattro guided installer's layout); the ESP is at ${esp_mount:-no mountpoint}" + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ -z $spec || ${spec,,} == "uuid=${uuid,,}" ]] || echo "/etc/crypttab names another root ($spec)" + # The switch rewrites these lines double-quoted. + ! grep -Eq '^GRUB_CMDLINE_LINUX(_DEFAULT)?=.*[$`\\]' "$R/etc/default/grub" 2>/dev/null || + echo "GRUB_CMDLINE_LINUX in /etc/default/grub uses shell expansion, which the switch cannot rewrite; write the words out" +} + +# legacy_plan INSTALLED WORK LUKS HOOKS: the tester plan, plus the checkout's +# own builds where an official repository carries them, and the fork keyring's +# removal. The boot switch's unlock is recorded: "busybox UUID DISCARD" for a +# root busybox encrypt unlocks, nothing otherwise. +legacy_plan() { + local installed=$1 work=$2 luks=${3:-} hooks=${4:-} targets name word uuid + targets=$(tester_plan "$installed" "$work") || return 1 + printf '%s\n' "$targets" + for name in $legacy_built; do + [[ -n $(installed_version "$name" "$installed") ]] && grep -Fxq "$name" "$work/official" || continue + sed 's|^.*/||' <<<"$targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + : >"$work/removals" + if [[ -n $(installed_version "$legacy_keyring" "$installed") ]]; then + printf '%s\n' "$legacy_keyring" | tee -a "$work/allowed-removals" >"$work/removals" + sed -i "/^$legacy_keyring /d" "$work/kept" + fi + install -d -m 755 "$work/adapter" + legacy_checkout >"$work/adapter/checkout" + : >"$work/adapter/unlock" + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + word=$(legacy_crypt_words) + uuid=${word#cryptdevice=UUID=} + printf 'busybox %s %s\n' "${uuid%%:*}" "$([[ $word == *:allow-discards ]] && echo 1 || echo 0)" >"$work/adapter/unlock" + fi +} + +# The archives of what AFTER adds or replaces: the targets and every new name. +legacy_archives() { + local before=$1 after=$2 name version dir archive path + while read -r name version; do + [[ -z $(installed_version "$name" "$before") ]] || sed 's|^.*/||' "$plan/targets" | grep -Fxq "$name" || continue + archive="" + for dir in "$pacman_cache" "$cache/candidate" "$cache/pkg"; do + for path in "$dir/$name-$version"-*.pkg.tar.*; do + [[ -f $path && $path != *.sig ]] && archive=$path + done + done + [[ -n $archive ]] || die "the archive of $name $version is not in the cache" + printf '%s\n' "$archive" + done < <(comm -13 <(LC_ALL=C sort "$before") <(LC_ALL=C sort "$after")) +} + +# legacy_conflicts BEFORE AFTER: the files those archives would write over. A +# path no package owns is printed: the transaction may overwrite it. A path a +# package keeps owning stops the migration, before anything is written. While +# the checkout is still linked in, the paths its links reach are left out: +# the links go before pacman runs. +legacy_conflicts() { + local before=$1 after=$2 checkout archive path name paths=$state/conflicts + checkout=$(<"$plan/adapter/checkout") + : >"$paths.new" + legacy_archives "$before" "$after" >"$paths.archives" + while read -r archive; do + LC_ALL=C pacman -Qlpq "$archive" >>"$paths.new" || die "cannot list the files of $archive" + done <"$paths.archives" + LC_ALL=C sort -u "$paths.new" | while IFS= read -r path; do + [[ $path == */ ]] && continue + if [[ -n $checkout ]]; then + [[ ! ( $path == /usr/share/omarchy/* && -L $R/usr/share/omarchy ) ]] || continue + [[ ! ( $path == /usr/bin/omarchy-* && -L $R$path && $(readlink "$R$path") == "$checkout"/* ) ]] || continue + fi + [[ -e $R$path || -L $R$path ]] && [[ ! -d $R$path || -L $R$path ]] && printf '%s\n' "$path" + done >"$paths" || true + rm -f "$paths.new" "$paths.archives" + [[ -s $paths ]] || { rm -f "$paths"; return 0; } + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Ql | + awk 'NR == FNR { wanted[$0]; next } { owner = $1; sub(/^[^ ]+ /, "") } $0 in wanted { print $0 "\t" owner }' "$paths" - >"$paths.owned" || + die "cannot read which packages own the conflicting files" + while IFS= read -r path; do + name=$(awk -F'\t' -v path="$path" '$1 == path { print $2; exit }' "$paths.owned") + if [[ -z $name ]]; then + [[ $path != *,* ]] || die "the new packages bring $path, which no package owns, and pacman cannot be told to overwrite a path with a comma; move it away first" + printf '%s\n' "$path" + elif [[ $(installed_version "$name" "$before") == "$(installed_version "$name" "$after")" ]]; then + die "the new packages would overwrite $path, which $name owns and keeps; nothing was changed" + fi + done <"$paths" + rm -f "$paths" "$paths.owned" +} + +# Once the rehearsal knows what the transaction installs: its conflicts are +# checked, and the archives the check reads are linked into the migration's own +# cache, so pruning pacman's cache cannot strand a resumed transaction. +legacy_prefetch() { + local archive + legacy_conflicts "$cache/start" "$cache/expected" >/dev/null + legacy_archives "$cache/start" "$cache/expected" >"$cache/archives" + while read -r archive; do + [[ $archive != "$cache"/* ]] || continue + ln -f "$archive" "$cache/pkg/" 2>/dev/null || cp -p "$archive" "$cache/pkg/" || die "cannot keep $archive for the transaction" + done <"$cache/archives" +} + +# Keeps the first copy of a file the conversion replaces or removes. +legacy_keep() { + local path=$1 kept=$backup/converted/files$1 + [[ -e $kept || -L $kept ]] && return 0 + install -d -m 700 "$(dirname "$kept")" && cp -a "$R$path" "$kept" +} + +# Lists the unowned files the transaction replaces and backs each up, then +# unwires the checkout: nothing changes until everything that can fail on the +# way has passed. Every run repeats it from the start. +legacy_prepare() { + local checkout link target converted=$backup/converted + checkout=$(<"$plan/adapter/checkout") + install -d -m 700 "$converted" + touch "$converted/links" + legacy_conflicts "$state/installed.now" "$expected" >"$state/overwrite.new" + while IFS= read -r target; do + legacy_keep "$target" || return 1 + done <"$state/overwrite.new" + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + legacy_keep /etc/omarchy.conf || return 1 + fi + if [[ -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + legacy_keep /etc/sudoers.d/omarchy-dev-path || return 1 + fi + sync "$converted" + if [[ -n $checkout ]]; then + for link in "$R"/usr/bin/omarchy-* "$R/usr/share/omarchy"; do + [[ -L $link ]] || continue + target=$(readlink "$link") + [[ $target == "$checkout" || $target == "$checkout"/* ]] || continue + grep -Fxq "${link#"$R"}"$'\t'"$target" "$converted/links" || + printf '%s\t%s\n' "${link#"$R"}" "$target" >>"$converted/links" || return 1 + rm -f "$link" || return 1 + interrupt_for_test mid unwire + done + fi + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + printf '%s\n' "$legacy_packaged_path" | durable_write "$R/etc/omarchy.conf" 644 || return 1 + fi + rm -f "$R/etc/sudoers.d/omarchy-dev-path" || return 1 + mv "$state/overwrite.new" "$state/overwrite" || return 1 + interrupt_for_test mid convert +} + +# The transaction could not run: the links pacman did not replace come back, +# and so do the checkout's OMARCHY_PATH and a dev link's sudo path, so the Mac +# runs as before until the transaction is run again. +legacy_restore() { + local path target kept=$backup/converted/files + if [[ -f $backup/converted/links ]]; then + while IFS=$'\t' read -r path target; do + [[ -e $R$path || -L $R$path ]] || ln -s "$target" "$R$path" + done <"$backup/converted/links" + fi + if [[ -f $kept/etc/omarchy.conf ]] && grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf" 2>/dev/null; then + cp -a "$kept/etc/omarchy.conf" "$R/etc/omarchy.conf" + fi + if [[ -f $kept/etc/sudoers.d/omarchy-dev-path && ! -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + cp -a "$kept/etc/sudoers.d/omarchy-dev-path" "$R/etc/sudoers.d/omarchy-dev-path" + fi + return 0 +} + +# --- The boot switch ----------------------------------------------------------- + +# Keeps the first copy of a file the boot switch changes, or a note that it did +# not exist, so the switch can be undone; written whole, never half. +legacy_stage_keep() { + local path=$1 kept=$backup/boot-switch + [[ -e $kept/files$path || -L $kept/files$path || -e $kept/absent$path ]] && return 0 + if [[ -e $R$path || -L $R$path ]]; then + install -d -m 700 "$(dirname "$kept/files$path")" && + cp -a "$R$path" "$kept/files$path.new" && sync "$kept/files$path.new" && mv "$kept/files$path.new" "$kept/files$path" + else + install -d -m 700 "$(dirname "$kept/absent$path")" && : >"$kept/absent$path" && sync "$kept/absent$path" + fi +} + +# Puts back what legacy_stage_keep kept of PATH. +legacy_stage_restore() { + local path=$1 kept=$backup/boot-switch + if [[ -e $kept/absent$path ]]; then + rm -f "$R$path" + elif [[ -e $kept/files$path || -L $kept/files$path ]]; then + cp -a "$kept/files$path" "$R$path.restore" && mv -f "$R$path.restore" "$R$path" + fi +} + +# The ESP moves from /boot to /boot/efi: fstab first, then the mounts. Each +# part is skipped once done, so a run cut short continues where it was. +legacy_move_esp() { + local fstab=$R/etc/fstab + if legacy_fstab_esp >/dev/null; then + legacy_stage_keep /etc/fstab || return 1 + legacy_esp_fstab <"$fstab" | durable_write "$fstab" 644 || return 1 + fi + interrupt_for_test mid esp-fstab + if [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]]; then + systemctl daemon-reload >/dev/null 2>&1 || echo "systemctl daemon-reload failed; the mounts move anyway" >&2 + umount "$R/boot" || { echo "cannot unmount the ESP from /boot" >&2; return 1; } + fi + interrupt_for_test mid esp-unmounted + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + install -d -m 755 "$R/boot/efi" && mount "$R/boot/efi" || { echo "cannot mount the ESP at /boot/efi" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot/efi" ]] || { echo "the ESP is not mounted at /boot/efi after the move" >&2; return 1; } + # Asahi's update-grub, which the Limine activation still runs, resolves its + # directory before creating it: without one it fails. + install -d -m 755 "$R/boot/grub" || { echo "cannot create /boot/grub" >&2; return 1; } +} + +# The ESP goes back to /boot. What the switch put on the root's /boot is +# removed only once no ESP covers it. +legacy_restore_esp() { + local where + where=$(omarchy-mac-esp 2>/dev/null) || where="" + if [[ $where == "/boot/efi" ]]; then + umount "$R/boot/efi" || { echo "cannot unmount the ESP from /boot/efi" >&2; return 1; } + fi + if ! findmnt --mountpoint "$R/boot" >/dev/null 2>&1; then + rm -f "$R/boot/vmlinuz-$legacy_kernel" "$R/boot/initramfs-$legacy_kernel.img" "$R/boot/initramfs-$legacy_kernel-fallback.img" + rmdir "$R/boot/efi" "$R/boot/grub" 2>/dev/null || true + fi + legacy_stage_restore /etc/fstab || return 1 + systemctl daemon-reload >/dev/null 2>&1 || true + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]]; then + mount "$R/boot" || { echo "cannot mount the ESP at /boot again" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]] || { echo "the ESP is not back at /boot" >&2; return 1; } +} + +# HOOKS lines (stdin) without busybox encrypt and asahi: the HOOKS baseline +# and the Apple boot package's drop-ins then compose the systemd image, the +# asahi hook back in its place with the firmware loader beside it. A HOOKS +# assignment that does not fit on one line cannot be edited: exit 2. +legacy_drop_hooks() { + awk ' + /^[[:space:]]*HOOKS\+?=\(/ { + if (!match($0, /\([^)]*\)/)) { bad = 1; print; next } + head = substr($0, 1, RSTART); tail = substr($0, RSTART + RLENGTH - 1) + n = split(substr($0, RSTART + 1, RLENGTH - 2), words, /[[:space:]]+/) + kept = "" + for (i = 1; i <= n; i++) if (words[i] != "" && words[i] != "encrypt" && words[i] != "asahi") kept = kept (kept == "" ? "" : " ") words[i] + print head kept tail + next + } + { print } + END { exit bad ? 2 : 0 } + ' +} + +# GRUB's defaults FILE with cryptdevice= gone and the root's rd.luks.name= +# (and rd.luks.options= for allow-discards) on the last GRUB_CMDLINE_LINUX, +# where the Apple encrypt flow keeps them and omarchy-mac-limine-cmdline reads +# them. Both variables are written double-quoted. +legacy_grub_unlock() { + local file=$1 uuid=$2 discard=$3 want + want="rd.luks.name=$uuid=root" + (( ! discard )) || want+=" rd.luks.options=$uuid=discard" + awk -v want="$want" ' + function strip(value, n, i, words, out) { + n = split(value, words, /[[:space:]]+/) + out = "" + for (i = 1; i <= n; i++) + if (words[i] != "" && words[i] !~ /^(cryptdevice|rd\.luks\.name|rd\.luks\.options)=/) out = out (out == "" ? "" : " ") words[i] + return out + } + NR == FNR { if ($0 ~ /^GRUB_CMDLINE_LINUX=/) last = FNR; next } + /^GRUB_CMDLINE_LINUX(_DEFAULT)?=/ { + key = $0; sub(/=.*/, "", key) + value = $0; sub(/^[^=]*=/, "", value) + if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) value = substr(value, 2, length(value) - 2) + value = strip(value) + if (FNR == last) value = value (value == "" ? "" : " ") want + print key "=\"" value "\"" + next + } + { print } + END { if (!last) print "GRUB_CMDLINE_LINUX=\"" want "\"" } + ' "$file" "$file" +} + +# The root's unlock moves from busybox encrypt to sd-encrypt. Every file is +# kept first and written whole; running it again changes nothing. +legacy_switch_unlock() { + local uuid=$1 discard=$2 options=luks file owned conf=$R/etc/mkinitcpio.conf grub=$R/etc/default/grub + (( ! discard )) || options+=,discard + legacy_stage_keep /etc/crypttab || return 1 + { [[ ! -f $R/etc/crypttab ]] || awk '$1 != "root"' "$R/etc/crypttab"; printf 'root UUID=%s none %s\n' "$uuid" "$options"; } | + durable_write "$R/etc/crypttab" 644 || return 1 + legacy_stage_keep /etc/default/grub || return 1 + legacy_grub_unlock "$grub" "$uuid" "$discard" | durable_write "$grub" 644 || return 1 + interrupt_for_test mid unlock + legacy_stage_keep /etc/mkinitcpio.conf || return 1 + legacy_drop_hooks <"$conf" >"$state/mkinitcpio.conf.new" || + { echo "cannot edit the HOOKS in /etc/mkinitcpio.conf (one HOOKS=(...) line each is expected)" >&2; return 1; } + durable_write "$conf" 644 <"$state/mkinitcpio.conf.new" || return 1 + rm -f "$state/mkinitcpio.conf.new" + # The fork's omarchy_hooks.conf sets the busybox line outright and sorts + # after the Apple drop-ins. Where no package took it over, it goes too. Any + # other drop-in is left alone; the check below names the HOOKS it gives. + file=/etc/mkinitcpio.conf.d/omarchy_hooks.conf + if [[ -f $R$file ]] && grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R$file"; then + owned=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq 2>/dev/null) || + { echo "cannot read which packages own $file" >&2; return 1; } + if ! grep -Fxq "$file" <<<"$owned"; then + legacy_stage_keep "$file" && rm -f "$R$file" || return 1 + fi + fi +} + +# The kernel on the root's /boot (as the kernel's own install hook copies it) +# and its initramfs. Never onto an ESP still mounted at /boot: GRUB boots that. +legacy_build_initramfs() { + local release image target=$R/boot/vmlinuz-$legacy_kernel + [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]] || { echo "the ESP is still mounted at /boot" >&2; return 1; } + release=$(kernel_release "$legacy_kernel") || { echo "$legacy_kernel has no module tree" >&2; return 1; } + image=$R/usr/lib/modules/$release/vmlinuz + if ! cmp -s "$image" "$target"; then + install -m 644 "$image" "$target.new" && sync "$target.new" && mv -f "$target.new" "$target" || + { echo "cannot put $legacy_kernel on /boot" >&2; return 1; } + fi + interrupt_for_test mid initramfs + mkinitcpio -p "$legacy_kernel" >"$state/mkinitcpio.log" 2>&1 || + { echo "mkinitcpio -p $legacy_kernel failed: $(tail -n 1 "$state/mkinitcpio.log")" >&2; return 1; } +} + +# What the next boot unlocks with, checked before Limine is activated: the +# HOOKS, the image built from them, crypttab and the kernel line's source. +legacy_check_unlock() { + local uuid=$1 hooks listing spec + hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null) || { echo "cannot read the initramfs HOOKS after the switch" >&2; return 1; } + if [[ " $hooks " == *" encrypt "* || " $hooks " != *" systemd "* || " $hooks " != *" sd-encrypt "* || " $hooks " != *" asahi "* ]]; then + echo "the initramfs HOOKS after the switch do not unlock the root through systemd (sd-encrypt, with asahi): $hooks" >&2 + return 1 + fi + listing=$(lsinitcpio -l "$R/boot/initramfs-$legacy_kernel.img" 2>/dev/null) || { echo "/boot/initramfs-$legacy_kernel.img cannot be listed" >&2; return 1; } + if ! grep -Eq '(^|/)usr/lib/systemd/system-generators/systemd-cryptsetup-generator$' <<<"$listing" || + ! grep -Eq '(^|/)usr/bin/systemd-cryptsetup$' <<<"$listing" || grep -Eq '(^|/)hooks/encrypt$' <<<"$listing"; then + echo "/boot/initramfs-$legacy_kernel.img does not unlock the root through sd-encrypt" >&2 + return 1 + fi + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ ${spec,,} == "uuid=${uuid,,}" ]] || { echo "/etc/crypttab does not name the root UUID=$uuid" >&2; return 1; } + if [[ -n $(legacy_crypt_words) || " $(legacy_grub_value GRUB_CMDLINE_LINUX) " != *" rd.luks.name=$uuid=root "* ]]; then + echo "GRUB's defaults, which Limine's kernel line comes from, do not unlock the root with rd.luks.name=$uuid=root alone" >&2 + return 1 + fi +} + +# The loader step's stage, while GRUB still boots the Mac: the ESP off /boot, +# the unlock off busybox encrypt, then the kernel and initramfs Limine's UKI is +# built from. An unencrypted Mac with its ESP at /boot/efi has nothing to do. +legacy_stage() { + local esp_mount unlock="" uuid="" discard=0 + esp_mount=$(plan_esp) + [[ ! -s $plan/adapter/unlock ]] || read -r unlock uuid discard <"$plan/adapter/unlock" + [[ $esp_mount == "/boot" || $unlock == "busybox" ]] || return 0 + if [[ $esp_mount == "/boot" ]]; then + legacy_move_esp || return 1 + fi + if [[ $unlock == "busybox" ]]; then + legacy_switch_unlock "$uuid" "$discard" || return 1 + fi + legacy_build_initramfs || return 1 + if [[ $unlock == "busybox" ]]; then + legacy_check_unlock "$uuid" || return 1 + fi +} + +# Undoes the stage in reverse: the unlock's files, then the ESP's mount. The +# busybox image GRUB boots stayed on the ESP throughout. +legacy_unstage() { + local kept=$backup/boot-switch path + [[ -d $kept ]] || return 0 + while IFS= read -r path; do + [[ $path == "/etc/fstab" ]] || legacy_stage_restore "$path" || return 1 + done < <(cd "$kept" && find files absent \( -type f -o -type l \) ! -name '*.new' 2>/dev/null | sed -E 's#^(files|absent)##' | LC_ALL=C sort -u) + if [[ $(plan_esp) == "/boot" ]]; then + legacy_restore_esp || return 1 + fi + # Everything is back: a later stage keeps what it finds then. + rm -rf "$kept" + echo "The boot switch was undone; GRUB boots this Mac as before." >&2 +} + +# The kernels, initramfs images and GRUB a moved ESP still carries at its top: +# Limine boots the UKI now, and the backup holds the ESP as it was. +legacy_retire_esp() { + [[ $(plan_esp) == "/boot" ]] || return 0 + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + say "The ESP is not mounted at /boot/efi; its old kernels and GRUB stay on it." + return 0 + fi + rm -f "$R"/boot/efi/vmlinuz-linux-* "$R"/boot/efi/initramfs-linux-*.img && rm -rf "$R/boot/efi/grub" +} + +# The fork's channel machinery goes with its repository, and the copies of +# pacman.conf its tools left beside it that still trust unsigned packages move +# into the backup, so none is restored by mistake. The checkout stays where it +# is, unused. An autologin on an unencrypted root stays too: quattro retired +# the boot lock's own long ago, so one there now is an administrator's opt-in. +legacy_retire() { + local checkout file + tester_retire + legacy_retire_esp || return 1 + rm -rf "$legacy_channel_stages"/transaction.* + for file in "$R"/etc/pacman.conf.*; do + [[ -f $file ]] && grep -Eq '^[[:space:]]*SigLevel[[:space:]]*=.*TrustAll' "$file" || continue + legacy_keep "${file#"$R"}" && rm -f "$file" || return 1 + done + checkout=$(<"$plan/adapter/checkout") + if [[ -n $checkout ]]; then + say "Omarchy now runs from its packages. The checkout at $checkout is no longer used; keep or remove it." + fi +} diff --git a/migrate/src/cohort-mx-mac.sh b/migrate/src/cohort-mx-mac.sh new file mode 100644 index 00000000000..16aa752eedc --- /dev/null +++ b/migrate/src/cohort-mx-mac.sh @@ -0,0 +1,184 @@ +# The mx-mac adapter. +# +# An mx-mac Mac runs the omarchy-mx-mac fork: the omarchy-dev and +# omarchy-settings-dev runtime pair with the rest of the fork's bundle, which +# omarchy-update-asahi-bundle installs from signed release assets with +# pacman -U, the fork's own [omarchy] release repository, and the Aurora kernel +# from [omarchy-aurora]. omarchy-update-asahi-repository and +# omarchy-update-aurora-repository keep those two sections on the fork's +# latest releases by rewriting pacman.conf. One transaction swaps the fork's +# pair for the channel's official one (on edge, Omarchy's own omarchy-dev pair, +# named explicitly because the fork's builds sort above it; elsewhere omarchy and +# omarchy-settings, which conflict with it), and moves every fork build an +# official repository carries to that build. The switch drops both +# fork sections and the fork's keys. The updaters leave with omarchy-dev, and +# retire moves the state they read into the backup, so nothing can point the +# Mac back at a fork release. Encryption, snapshots and Limine stay the +# engine's: nothing here touches them. +# shellcheck disable=SC2154 # the engine defines the shared state + +retired_repos+=(omarchy-aurora) +# The fork's release key, which signs the bundle's release pointers. +retired_keys+=(5983B1CA32CB778F4D74D24ECFF35022CA5B5959) + +# What omarchy-update-asahi-bundle installs, so no repository lists it. +mx_mac_bundle="omarchy-dev omarchy-settings-dev omarchy-keyring omarchy-nvim quickshell-git ttf-jetbrains-mono-nerd-basic" +# What the target's packages replace, as on a tester. +mx_mac_replaced="linux-asahi linux-asahi-headers m1n1 omarchy-apple-boot omarchy-first-boot omarchy-settings-asahi" +# The records the bundle and channel updaters keep in /var/lib/omarchy. +mx_mac_state="asahi-quattro-release asahi-quattro-release.pending asahi-package-repository aurora-target.descriptor apple-silicon-channel apple-silicon-aurora-lane" + +# The official name of a fork build. The official package conflicts with the +# fork one it replaces, so naming it removes the fork build in the same +# transaction. +mx_mac_counterpart() { + case $1 in + omarchy-dev | omarchy-settings-dev) + if [[ $target_channel == "edge" ]]; then + echo "$1" + elif [[ $1 == "omarchy-dev" ]]; then + echo omarchy + else + echo omarchy-settings + fi + ;; + quickshell-git) echo quickshell ;; + mise | dotnet-host | dotnet-runtime) echo "$1-bin" ;; + *) echo "$1" ;; + esac +} + +# omacom's repositories carry an omarchy-dev of their own, so the fork is told +# by its updaters or their records, not by the package name alone. The engine +# asks this before it picks the cohort. +mx_mac_fork() { + local marker + for marker in usr/share/omarchy/bin/omarchy-update-asahi-bundle usr/share/omarchy/bin/omarchy-update-asahi-repository \ + usr/share/omarchy/bin/omarchy-update-aurora-repository var/lib/omarchy/asahi-quattro-release var/lib/omarchy/asahi-package-repository; do + [[ ! -e $R/$marker ]] || return 0 + done + return 1 +} + +# Official migrations the fork's runner settled as handled, not run: its +# Quattro transition applied their effect (packages, theme and Hyprland state, +# the network and zram changes), so running them again could edit the user's +# configuration twice. The runner records that in .sh.skipped. +mx_mac_handled="1778623107 1780739888 1781043107 1781063758 1781158082 1781485962 1781793381 1782002156 1784401744 1784672586 1784914435 1784961000 1785013000" + +# mx_mac_settled DIR: the handled migrations DIR's records say the fork's +# runner settled, which the engine records as done. What it skipped instead +# runs on the new packages, apart from what the engine settles on every Mac. +mx_mac_settled() { + local dir=$1 name record disposition + for name in $mx_mac_handled; do + record=$dir/$name.sh.skipped + [[ -f $record && ! -L $record ]] || continue + IFS=$'\t' read -r _ disposition _ <"$record" || continue + [[ $disposition != "handled" ]] || printf '%s\n' "$name" + done +} + +# mx_mac_preflight INSTALLED LUKS: prints the states this adapter refuses. +mx_mac_preflight() { + local name + for name in $(channel_pair "$target_channel"); do + [[ " $target_packages " == *" $name "* ]] || echo "the target has no $name to replace the fork's runtime pair" + done +} + +# mx_mac_plan INSTALLED WORK: prints the transaction's targets, one per line, +# and writes WORK/allowed-removals, WORK/removals and WORK/kept. WORK/db holds +# the target's synced databases; the live ones are still the fork's. +# +# - A fork build is a bundle package, or a package installed at the exact +# version the fork's [omarchy] or [omarchy-aurora] lists. +# - The target's packages are named with their repository (target_spec), so a higher +# installed version is replaced: the Mac packages always, kernel headers only +# where headers are installed, and every other one where it is installed or +# replaces a fork build. +# - Every other fork build is named when an official repository carries it, +# by its own name or else by its official counterpart's, so it moves to the +# official build even when that is older. One nothing official carries stays +# installed and is listed in WORK/kept. +# - The fork builds whose official counterpart has another name are removed: +# by the counterpart's conflict where it has one, else by name after the +# install (a versioned conflict, such as dotnet-runtime-bin's, can miss the +# fork's build). The transaction may also remove what the target's packages +# replace. +mx_mac_plan() { + local installed=$1 work=$2 name version counterpart repo official=$2/official fork=$2/fork present=$2/present named=$2/named + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + { + for name in $mx_mac_bundle; do + version=$(installed_version "$name" "$installed") + [[ -z $version ]] || printf '%s %s\n' "$name" "$version" + done + for repo in omarchy omarchy-aurora; do + [[ -f $pacman_db/sync/$repo.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$repo" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") != "$version" ]] || printf '%s %s\n' "$name" "$version" + done + done + } | LC_ALL=C sort -u >"$fork" + { + awk '{ print $1 }' "$installed" + while read -r name _; do + mx_mac_counterpart "$name" + done <"$fork" + } | LC_ALL=C sort -u >"$present" + + : >"$named" + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + if [[ " $(channel_packages "$target_channel") " == *" $name "* ]] || grep -Fxq "$name" "$present"; then + target_spec "$name" + printf '%s\n' "$name" >>"$named" + fi + done + + : >"$work/kept" + : >"$work/allowed-removals" + : >"$work/removals" + while read -r name version; do + counterpart=$(mx_mac_counterpart "$name") + if grep -Fxq "$name" "$named"; then + continue + elif grep -Fxq "$counterpart" "$named"; then + : + elif grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + continue + elif grep -Fxq "$counterpart" "$official"; then + printf '%s\n' "$counterpart" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + continue + fi + printf '%s\n' "$name" | tee -a "$work/allowed-removals" >>"$work/removals" + done <"$fork" + for name in $mx_mac_replaced; do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The updaters left with omarchy-dev; what they read is kept with the backup, +# where no updater or check looks for it. +mx_mac_retire() { + local name moved=$backup/mx-mac-state + for name in $mx_mac_state; do + [[ -e $R/var/lib/omarchy/$name || -L $R/var/lib/omarchy/$name ]] || continue + install -d -m 700 "$moved" && mv -f "$R/var/lib/omarchy/$name" "$moved/$name" || return 1 + interrupt_for_test mid mx-mac-retire + done + if [[ -d $moved ]]; then + sync "$moved" "$R/var/lib/omarchy" || return 1 + fi + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} diff --git a/migrate/src/cohort-tester.sh b/migrate/src/cohort-tester.sh new file mode 100644 index 00000000000..2b82f63c789 --- /dev/null +++ b/migrate/src/cohort-tester.sh @@ -0,0 +1,75 @@ +# The tester adapter. +# +# A tester Mac runs omarchy, omarchy-settings and usually omarchy-mac built from +# quattro-upstream or a convergence branch: a pinned test image from a signed +# candidate set, the unsigned collaboration repository ([omarchy-aarch64]) or a +# pilot. Its same-name packages can be versioned above the target's (a +# candidate's pkgrel suffix, an rc runtime), so an upgrade would keep them: +# every one is named explicitly and replaced by the target's build. On edge the +# runtime pair becomes the omarchy-dev pair, which conflicts with it and +# replaces it in the same transaction. The Asahi kernel and m1n1 give way to +# their Aurora counterparts there too. +# shellcheck disable=SC2154 # the engine defines the shared state + +# Packages the tester transaction may remove: what the target's packages +# replace (the Asahi kernel, its headers and m1n1) and the boot and settings +# packages omarchy-mac and omarchy-mac-boot superseded. +tester_replaced="linux-asahi linux-asahi-headers m1n1 omarchy-apple-boot omarchy-first-boot omarchy-settings-asahi" + +# tester_plan INSTALLED WORK: prints the transaction's targets, one per line, +# and writes WORK/allowed-removals and WORK/kept. WORK/db holds the target's +# synced databases. +# +# - Each target package is named as /, so a higher +# installed version is replaced. Kernel headers come only where headers are +# installed. +# - A package installed from a retired repository, at the exact version that +# repository lists, is named by itself when an official repository carries +# it, so it moves to the official build even when that is older. One nothing +# official carries stays installed and is listed in WORK/kept. +tester_plan() { + local installed=$1 work=$2 name retired official + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + target_spec "$name" + done + + official=$work/official + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + : >"$work/kept" + for retired in "${retired_repos[@]}"; do + [[ -f $pacman_db/sync/$retired.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$retired" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") == "$version" ]] || continue + [[ " $target_packages $(replaced_pair) " != *" $name "* ]] || continue + if grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + fi + done + done + + : >"$work/allowed-removals" + for name in $tester_replaced $(replaced_pair); do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The runtime pair the target's pair replaces: omarchy and omarchy-settings on +# edge, where the omarchy-dev pair takes their place. +replaced_pair() { + [[ $target_channel != "edge" ]] || echo "omarchy omarchy-settings" +} + +# The collaboration repository's pending-sync marker outlives its repository. +tester_retire() { + rm -f "$R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending" + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} diff --git a/migrate/src/engine.sh b/migrate/src/engine.sh new file mode 100644 index 00000000000..6d3562070d6 --- /dev/null +++ b/migrate/src/engine.sh @@ -0,0 +1,1615 @@ +# The journaled migration engine. +# +# It moves a Mac onto its target in thirteen ordered steps. Every step records +# its start and its end in an append-only journal synced to disk, so a power +# loss or a kill resumes at the first step that did not finish, and every step +# can run again from its start. Preflight changes nothing and freezes the plan +# the later steps follow. A cohort adapter (cohort-.sh) decides what +# its machines need: the package targets, the packages the transaction may +# remove and the compatibility state to retire. The engine owns the order, the +# journal and every change to the system. +# +# The caller sets R (the fixture root, empty on a live system), fixture (1 when +# unprivileged tests drive it) and self (this file). Adapters read the +# target_* values. +# +# Exit status: 0 when the Mac is migrated, waits for its reboot or has nothing +# to migrate; 75 (EX_TEMPFAIL) when it stopped before anything changed (a +# preflight refusal, or any failure before the journal exists); 1 when a step +# failed, and running again resumes it. +# shellcheck disable=SC2034,SC2154 + +# Raised with every change to what the tool does; the journal format only when +# a journal one version writes cannot be resumed by another. +tool_version=1 +journal_format=2 + +migrate_steps=(preflight backup keyring prefetch repositories transaction boot-chain loader defaults verify unpin reboot retire) + +# pacman's download user reads the work, cache and candidate directories. +umask 022 + +state=$R/var/lib/omarchy-mac/migration +journal=$state/journal +plan=$state/plan +cache=$state/cache +backup=$state/backup +expected=$state/expected +start=$state/start +interrupted_marker=$state/transaction-interrupted +set_copy=$state/set +complete=$state/complete +reboot_pending=$state/reboot-pending +user_pending=$state/user-pending +tool_copy=$state/tool/omarchy-mac-migrate +lock_file=$R/run/lock/omarchy-mac-migrate.lock +pacman_conf=$R/etc/pacman.conf +pacman_db=$R/var/lib/pacman +pacman_cache=$R/var/cache/pacman/pkg +pacman_gpg=$R/etc/pacman.d/gnupg +esp=/boot/efi +limine_gate=$R/var/lib/omarchy/limine.enabled +limine_default=$R/etc/default/limine +verify_unit=omarchy-mac-migrate-verify.service +verify_unit_file=$R/etc/systemd/system/$verify_unit +first_boot_marker=$R/var/lib/omarchy/mac-first-boot/pending +legacy_first_boot_marker=$R/var/lib/omarchy/first-boot/pending +# The user units a fresh install's first run enables +# (install/user/first-run/enable-user-units.sh). +fresh_user_units="bt-agent.service owed.service omarchy-recover-internal-monitor.service omarchy-sleep-lock.service omarchy-migrate-notify.service omarchy-fcitx5.service omarchy-crash-watch.service omarchy-brightness-keyboard-auto.service" + +current_step="" +check_only=0 +original_args=() +target_file="" +payload_dir="" +restarted=0 +restarts=0 +work="" +gpgdir="" + +say() { + printf '%s\n' "$*" +} + +die() { + echo "omarchy-mac-migrate: $*" >&2 + if [[ -n $current_step && -f $journal ]]; then + journal_write "$current_step" "fail" "$*" + fi + # With nothing journaled, nothing has changed: deferred, like a refusal. + [[ -f $journal ]] || exit 75 + # Before the repository switch the system still runs as it did (only the + # official key was trusted): the attempt is set aside and the next run starts + # over from preflight. + if before_boundary; then + abort_migration "$*" + exit 75 + fi + exit 1 +} + +# The repository switch is the first change that cannot be left in place: from +# its start on, the migration only goes forward. +before_boundary() { + [[ -f $journal ]] && ! awk '$2 == "repositories" { found = 1 } END { exit !found }' "$journal" +} + +abort_migration() { + local destination + destination=$state/history/aborted-$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$state/format" "$destination/" 2>/dev/null + rm -rf "$cache" "$backup" "$set_copy" "$start" "$expected" "$state/installed.now" "$state/tool" + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$1" | durable_write "$state/deferred" 2>/dev/null || true + say "Nothing on this Mac changed; the next run starts the migration over." >&2 +} + +on_exit() { + local status=$? + if (( status != 0 )) && [[ -n $current_step && -f $journal && $(step_state "$current_step") == "begin" ]]; then + journal_write "$current_step" "fail" "exit $status" + fi + [[ -z $work ]] || rm -rf "$work" +} + +# --- Journal ----------------------------------------------------------------- + +journal_write() { + local detail=${3:-} + printf '%s %s %s%s\n' "$(date +%s)" "$1" "$2" "${detail:+ ${detail//$'\n'/ }}" >>"$journal" + sync "$journal" +} + +# The last event recorded for a step: begin, done, fail, or nothing. +step_state() { + [[ -f $journal ]] || return 0 + awk -v step="$1" '$2 == step { event = $3 } END { print event }' "$journal" +} + +next_step() { + local step + for step in "${migrate_steps[@]}"; do + if [[ $(step_state "$step") != "done" ]]; then + printf '%s\n' "$step" + return + fi + done +} + +# Unprivileged tests kill the engine with SIGKILL part way through a step's +# work (mid), once the work is done (during) or once its end is recorded +# (after). Root never reads these. +interrupt_for_test() { + (( fixture )) || return 0 + if [[ $1 == "mid" && ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "$2" ]] || + [[ $1 == "during" && ${OMARCHY_MAC_MIGRATE_KILL_DURING:-} == "$2" ]] || + [[ $1 == "after" && ${OMARCHY_MAC_MIGRATE_KILL_AFTER:-} == "$2" ]]; then + kill -9 $$ + fi +} + +run_step() { + local step=$1 + current_step=$step + restarted=0 + journal_write "$step" "begin" + "step_${step//-/_}" + if (( restarted )); then + current_step="" + return 0 + fi + interrupt_for_test during "$step" + journal_write "$step" "done" + interrupt_for_test after "$step" + current_step="" +} + +# Replace a file whole: written beside it, synced, then renamed over it. +durable_write() { + local file=$1 mode=${2:-644} tmp + tmp=$(mktemp "$file.XXXXXX") || return 1 + if cat >"$tmp" && chmod "$mode" "$tmp" && sync "$tmp" && mv -f "$tmp" "$file"; then + sync "$(dirname "$file")" + else + rm -f "$tmp" + return 1 + fi +} + +# --- Helpers ------------------------------------------------------------------- + +# A root-owned (in a fixture, caller-owned) regular file or directory, not a +# symlink and not writable by group or others. Target files and sets decide +# what is installed as root. +trusted() { + local owner mode + [[ -e $1 && ! -L $1 ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1 + (( owner == EUID && (8#$mode & 8#022) == 0 )) +} + +pacman_run() { + env OMARCHY_UPDATE_PACMAN=1 LC_ALL=C pacman --gpgdir "${gpgdir:-$pacman_gpg}" "$@" +} + +installed_packages() { + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Q +} + +installed_version() { + awk -v name="$1" '$1 == name { print $2; exit }' "$2" +} + +# The upstream detector where the runtime has it; else the device tree, as +# Asahi's own tools read it (a quattro or mx-mac runtime predates the +# detector). +hardware_platform() { + if command -v omarchy-hw-platform >/dev/null; then + omarchy-hw-platform + elif (( ! fixture )) && [[ -r /proc/device-tree/compatible ]] && tr '\0' '\n' /dev/null | awk -F: '$1 == "pub" { print $2; exit }') + [[ $validity == "f" || $validity == "u" ]] +} + +key_present() { + gpg --homedir "${2:-$pacman_gpg}" --batch --no-auto-check-trustdb --with-colons --list-keys "$1" >/dev/null 2>&1 +} + +# Official trust in the keyring at HOME: the keyrings installed are populated, +# and a missing Omarchy key comes from the keyserver by its full fingerprint and +# is signed locally. Fails when the key is not trusted after it. +trust_official_key() { + local home=$1 keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$home" --populate "${keyrings[@]}" >/dev/null || return 1 + fi + if ! key_trusted "$target_keyring" "$home"; then + pacman-key --gpgdir "$home" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$home" --lsign-key "$target_keyring" >/dev/null || return 1 + fi + key_trusted "$target_keyring" "$home" +} + +sha256_of() { + sha256sum "$1" | cut -d' ' -f1 +} + +repositories_in() { + awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); if (name != "options") print name }' "$1" +} + +# The configuration the transaction runs with: the future one, with the +# verified candidate set as a local repository ahead of everything. It is never +# installed as /etc/pacman.conf, so candidates stay invisible afterwards. +transaction_conf() { + local conf=$1 candidate_dir=$2 + if [[ -z $candidate_dir ]]; then + cat "$conf" + return + fi + awk -v repo="$candidate_repo" -v server="file://$candidate_dir" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ && !inserted && $0 !~ /\[options\]/ { + print "[" repo "]"; print "SigLevel = Optional"; print "Server = " server; print ""; inserted = 1 + } + { print } + ' "$conf" +} + +# --- Candidate sets --------------------------------------------------------- + +# Prints the key that made a detached signature, or fails. A revoked or expired +# key or signature does not count. gpgv reads the set's keyring file and needs +# no agent, so nothing depends on where a gpg-agent socket could live. +signer_of() { + local home=$1 file=$2 signature=$3 status + status=$(gpgv --homedir "$home" --keyring "$home/key.gpg" --status-fd 1 "$signature" "$file" 2>/dev/null) || return 1 + awk '$1 != "[GNUPG:]" { next } + $2 ~ /^(BADSIG|ERRSIG|EXPSIG|EXPKEYSIG|REVKEYSIG|KEYEXPIRED|KEYREVOKED)$/ { bad = 1 } + $2 == "GOODSIG" { good = 1 } + $2 == "VALIDSIG" { primary = $NF; valid++ } + END { if (!good || valid != 1 || bad) exit 1; print primary }' <<<"$status" +} + +# Verifies a candidate set as tools/release/candidate-set verify does, trusting +# only the target's fingerprint. Prints why it fails. +verify_candidate_set() { + local dir=$1 home=$2 manifest=$1/manifest.json receipt=$1/signing.json name sha digest + rm -rf "$home" + mkdir -m 700 "$home" + if ! gpg --batch --homedir "$home" --dearmor <"$dir/candidate-signing-key.asc" >"$home/key.gpg" 2>/dev/null || + ! gpg --batch --homedir "$home" --with-colons --show-keys "$home/key.gpg" 2>/dev/null | awk -F: '$1 == "fpr" { print $10 }' | grep -qx "$target_fingerprint"; then + echo "its key is not $target_fingerprint" + return 1 + fi + [[ -f $receipt && -f $receipt.sig && $(signer_of "$home" "$receipt" "$receipt.sig") == "$target_fingerprint" ]] || + { echo "signing.json is not signed by $target_fingerprint"; return 1; } + [[ $(jq -r '.signer.fingerprint' "$receipt") == "$target_fingerprint" && + $(jq -r '.manifest_sha256' "$receipt") == "$(sha256_of "$manifest")" && + $(jq -r '.set_sha256' "$receipt") == "$(jq -r '.set_sha256' "$manifest")" ]] || + { echo "signing.json does not bind this manifest"; return 1; } + digest=$(jq -r '.packages[] | "\(.name) \(.version) \(.filename) \(.sha256)"' "$manifest" | LC_ALL=C sort | sha256sum | cut -d' ' -f1) + [[ $digest == "$(jq -r '.set_sha256' "$manifest")" ]] || { echo "the manifest's set digest does not match its packages"; return 1; } + [[ $(jq -r '[.signatures[].file] | sort | join(" ")' "$receipt") == "$(jq -r '[.packages[].filename] | sort | join(" ")' "$manifest")" ]] || + { echo "signing.json does not cover exactly the manifest's packages"; return 1; } + while IFS=$'\t' read -r name sha; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ -f $dir/$name && $(sha256_of "$dir/$name") == "$sha" ]] || { echo "$name is missing or changed"; return 1; } + [[ -f $dir/$name.sig && $(signer_of "$home" "$dir/$name" "$dir/$name.sig") == "$target_fingerprint" ]] || + { echo "$name is not signed by $target_fingerprint"; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$manifest") +} + +# Copies a set into a directory only root can write, so nothing can change it +# between its verification and its use; everything later reads the copy. +copy_candidate_set() { + local source=$1 destination=$2 name + rm -rf "$destination" + install -d -m 700 "$destination" || return 1 + for name in manifest.json signing.json signing.json.sig candidate-signing-key.asc; do + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + done + [[ -f $destination/manifest.json ]] || { echo "the set has no manifest.json"; return 1; } + while read -r name; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + [[ ! -f $source/$name.sig ]] || cp "$source/$name.sig" "$destination/$name.sig" || return 1 + done < <(jq -r '.packages[].filename' "$destination/manifest.json") || { echo "cannot read its manifest"; return 1; } +} + +# Verifies the frozen set again, then builds a local repository of copies whose +# digests are checked again, so what pacman reads is what was verified. +# Signatures stay out of it: pacman's keyring never trusts the candidate key. +stage_candidate_repo() { + local destination=$1 home=$2 reason name sha + reason=$(verify_candidate_set "$target_set" "$home") || { echo "$reason" >&2; return 1; } + rm -rf "$destination" + install -d -m 755 "$destination" || return 1 + while IFS=$'\t' read -r name sha; do + install -m 644 "$target_set/$name" "$destination/$name" || return 1 + [[ $(sha256_of "$destination/$name") == "$sha" ]] || { echo "the copy of $name changed" >&2; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$target_set/manifest.json") + index_candidate_repo "$destination" +} + +# Indexes the manifest's packages, and nothing else, in DIR (already holding +# copies, or given links to the set with "link"). repo-add embeds a signature +# lying beside a package, so the set's own signatures are never in DIR. +index_candidate_repo() { + local destination=$1 mode=${2:-} files=() name + mapfile -t files < <(jq -r '.packages[].filename' "$target_set/manifest.json") + if [[ $mode == "link" ]]; then + for name in "${files[@]}"; do + ln -sfn "$target_set/$name" "$destination/$name" || return 1 + done + fi + (cd "$destination" && repo-add -q "$candidate_repo.db.tar.gz" "${files[@]}") >/dev/null || return 1 + chmod -R go+rX "$destination" +} + +target_version() { + jq -r --arg name "$1" '.packages[] | select(.name == $name) | .version' "$target_set/manifest.json" +} + +# --- Preflight ----------------------------------------------------------------- + +# The cohort an Apple Silicon Mac belongs to, from what is installed. Each +# cohort needs an adapter defining _plan and _retire; it may +# also define _preflight, _prefetch, _prepare and _restore, which the +# steps of those names call, and _stage and _unstage, which the loader step of +# a GRUB Mac calls before Limine is activated and after a failed activation. +# Only a cohort with a stage may have its ESP mounted at /boot or its root +# unlocked by busybox encrypt: the stage moves both. A legacy omarchy-mac +# install runs Omarchy from a checkout, trusts the rc4 fork keyring or carries +# the quattro tree, whose 3.x upgrade command quattro-upstream never had. A +# Mac on the omarchy-dev pair without the mx-mac fork's updaters, records or a +# test image's pin already runs Omarchy's own dev packages. +detect_cohort() { + local list=$1 + if grep -Eq '^omarchy(-settings)?-dev ' "$list"; then + if mx_mac_fork; then + echo mx-mac + elif [[ -n $(test_pin_block "$pacman_conf") ]]; then + # A test image built from a dev pair candidate keeps it pinned. + echo tester + else + echo official-dev + fi + elif ! grep -Eq '^omarchy ' "$list" || grep -Eq '^omarchy-mac-keyring ' "$list" || + [[ -e $R/usr/share/omarchy/bin/omarchy-upgrade-to-quattro-mac ]]; then + echo legacy + else + echo tester + fi +} + +cohort_refusal() { + echo "no adapter handles the $1 cohort" +} + +# What stops a Mac running omarchy-dev from counting as a Mac on Omarchy's own +# dev channel: a retired repository or key, a repository trusted without +# signatures, or an [omarchy] served from anywhere but pkgs.omarchy.org. +official_trust_problems() { + local conf repos repo fpr + conf=$(cat "$1") + repos=$(repositories_in <(printf '%s\n' "$conf")) + for repo in "${retired_repos[@]}"; do + ! grep -Fxq "$repo" <<<"$repos" || echo "[$repo]" + done + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || echo "the key $fpr" + done + printf '%s\n' "$conf" | awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "omarchy" && /^[[:space:]]*Server[[:space:]]*=/ && $0 !~ /=[[:space:]]*https:\/\/pkgs\.omarchy\.org\// { print "an [omarchy] server other than pkgs.omarchy.org" } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { print "[" name "] without signature checks" }' | sort -u +} + +# Runs the cohort's optional hook for a step. +adapter_hook() { + local hook=${cohort//-/_}_$1 + shift + if declare -F "$hook" >/dev/null; then + "$hook" "$@" + fi +} + +# The LUKS partition beneath /, or nothing when / is not encrypted; fails when +# it cannot tell (as omarchy-drive-password decides it). +root_luks_device() { + local source ancestry device + source=$(findmnt -no SOURCE "$R/") && [[ -n $source ]] || return 1 + ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${source%%[*}") || return 1 + device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry") + if [[ -n $device ]]; then + printf '%s\n' "$device" + elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then + return 1 + fi +} + +free_bytes() { + df -B1 --output=avail "$1" 2>/dev/null | tail -n 1 | tr -d ' ' +} + +bytes_used() { + local bytes + bytes=$(du -sxb "$1" 2>/dev/null | cut -f1) + printf '%s\n' "${bytes:-0}" +} + +# Running on battery below 30% is refused: the transaction and the boot switch +# must not lose power. +low_battery() { + local supply capacity on_battery=0 low=0 + for supply in "$R"/sys/class/power_supply/*; do + [[ -f $supply/type ]] || continue + case $(<"$supply/type") in + Battery) + capacity=$(<"$supply/capacity") 2>/dev/null || capacity=100 + [[ $capacity =~ ^[0-9]+$ ]] && (( capacity < 30 )) && low=1 + on_battery=1 + ;; + Mains | USB | USB_C | USB_PD) + [[ $(cat "$supply/online" 2>/dev/null) == "1" ]] && return 1 + ;; + esac + done + (( on_battery && low )) +} + +# The configuration pacman reads: FILE with each Include replaced by the files +# it names, three levels deep. +pacman_conf_flat() { + local file=$1 depth=${2:-0} line included + while IFS= read -r line || [[ -n $line ]]; do + if (( depth < 3 )) && [[ $line =~ ^[[:space:]]*Include[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$ ]]; then + # shellcheck disable=SC2086 # Include takes a glob + for included in $R${BASH_REMATCH[1]}; do + [[ ! -f $included ]] || pacman_conf_flat "$included" $(( depth + 1 )) + done + else + printf '%s\n' "$line" + fi + done <"$file" +} + +# The administrator's repositories the switch keeps must not accept untrusted +# packages: the core configuration requires signatures. +pacman_trust_problems() { + admin_repositories "$1" | awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { + value = $0; sub(/^[^=]*=[[:space:]]*/, "", value) + print "[" name "] accepts untrusted packages (SigLevel = " value "); remove it or sign it first" + }' +} + +preflight() { + local reasons=() installed boot_state kernels hooks="" check_output luks="" need esp_mount="" staged=0 channel + local future transaction targets_file resolved name version problem official_problems="" names saved_path problems=() + work=$(mktemp -d "$R/var/tmp/omarchy-mac-migrate.XXXXXX") || die "cannot create a work directory" + chmod 755 "$work" + installed=$work/installed + installed_packages >"$installed" || die "cannot list the installed packages" + pacman_conf_flat "$pacman_conf" >"$work/flat.conf" || die "cannot read $pacman_conf" + + [[ -d $R/run/systemd/system ]] || reasons+=("this is not a booted system (an image build or a chroot)") + [[ ! -e $pacman_db/db.lck ]] || reasons+=("pacman is busy or was interrupted ($pacman_db/db.lck exists)") + + cohort=$(detect_cohort "$installed") + # A Mac following Omarchy's own dev channel already runs official packages. + # One that still trusts what the switch retires, or that an administrator + # points at a target, is moved like a tester: its packages are named. + if [[ $cohort == "official-dev" ]]; then + official_problems=$(official_trust_problems "$work/flat.conf" | paste -sd, | sed 's/,/, /g') + if [[ -z $official_problems && -z ${target_file:-} ]]; then + say "This Mac runs Omarchy's own packages (omarchy-dev from pkgs.omarchy.org): nothing to migrate." + exit 0 + fi + cohort=tester + fi + declare -F "${cohort//-/_}_plan" >/dev/null || reasons+=("$(cohort_refusal "$cohort")") + ! declare -F "${cohort//-/_}_stage" >/dev/null || staged=1 + + kernels=$(awk '$1 == "linux-asahi" || $1 == "linux-aurora" { print $1 }' "$installed" | xargs) + [[ $kernels == "linux-asahi" || $kernels == "linux-aurora" ]] || + reasons+=("expected one Apple kernel (linux-asahi or linux-aurora), found: ${kernels:-none}") + + if limine_mac; then + boot_state=limine + elif [[ -f $R/boot/grub/grub.cfg ]]; then + boot_state=grub + else + boot_state=unknown + reasons+=("cannot tell whether this Mac boots GRUB or Limine") + fi + # The Limine setup derives the kernel command line from GRUB's defaults. + [[ -f $R/etc/default/grub ]] || reasons+=("there is no /etc/default/grub, which the Limine setup reads the kernel command line from") + [[ -f $R/usr/share/pacman/keyrings/asahi-alarm.gpg ]] || reasons+=("asahi-alarm-keyring is not installed, so Asahi ALARM's packages cannot be verified") + if ! luks=$(root_luks_device); then + reasons+=("cannot tell whether the root filesystem is encrypted") + fi + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + reasons+=("first boot has not finished on this Mac") + fi + if low_battery; then + reasons+=("the battery is below 30% and no charger is connected") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(pacman_trust_problems "$work/flat.conf"; unsupported_options "$pacman_conf") + # The switch writes pacman.conf whole: a repository only an Include file + # defines would be lost or doubled. + for problem in $(comm -13 <(repositories_in "$pacman_conf" | LC_ALL=C sort -u) <(repositories_in "$work/flat.conf" | LC_ALL=C sort -u)); do + reasons+=("[$problem] is configured through an Include, which the repository switch cannot rewrite; move it into $pacman_conf first") + done + + # The target: the administrator's, else the channel this Mac follows. + if [[ -z ${target_file:-} ]]; then + if channel=$(detect_channel "$cohort" "$work/flat.conf"); then + write_channel_target "$channel" "$work/target" + target_file=$work/target + else + reasons+=("cannot tell which Omarchy channel this Mac follows (stable, rc or edge); set one in $admin_target") + fi + fi + [[ -z ${target_file:-} ]] || load_target "$target_file" + if [[ ${target_type:-} == "candidate-set" ]] && ! command -v gpgv >/dev/null; then + reasons+=("gpgv is not installed (gnupg), so the candidate set's signatures cannot be checked") + fi + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + + # The target, read in isolation: a copy of the local database and the future + # configuration, never the live sync databases. Signatures are checked + # against a copy of the keyring that trusts the target's key, so preflight + # never changes the live one. + future=$work/pacman.conf + future_pacman_conf "$pacman_conf" >"$future" || die "cannot compute the new pacman configuration" + mkdir -p "$work/db" + cp -a "$pacman_db/local" "$work/db/local" || die "cannot copy the package database" + install -d -m 700 "$work/pacman-gnupg" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$work/pacman-gnupg" -xf - || die "cannot copy the pacman keyring" + gpgdir=$work/pacman-gnupg + trust_official_key "$gpgdir" || refuse "cannot fetch and trust the Omarchy packaging key $target_keyring" + # The trust the switch leaves: no retired fork key verifies anything from here. + for name in "${retired_keys[@]}"; do + if key_present "$name" "$gpgdir"; then + pacman-key --gpgdir "$gpgdir" --delete "$name" >/dev/null 2>&1 || die "cannot drop $name from the keyring copy" + fi + done + if [[ $target_type == "candidate-set" ]]; then + install -d -m 755 "$work/candidate" + if ! problem=$(copy_candidate_set "$target_set" "$work/set") || ! problem=$(verify_candidate_set "$work/set" "$work/gnupg"); then + refuse "the candidate set does not verify: $problem" + fi + local loaded_id=$target_id + target_set=$work/set + candidate_identity "$target_set" || die "cannot read the candidate manifest" + [[ $target_id == "$loaded_id" ]] || refuse "the candidate set changed while it was read" + index_candidate_repo "$work/candidate" link || die "cannot index the candidate set" + fi + transaction=$work/transaction.conf + transaction_conf "$future" "${target_set:+$work/candidate}" >"$transaction" + pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sy --noconfirm >"$work/sync.log" 2>&1 || + refuse "cannot read the target repositories: $(tail -n 1 "$work/sync.log")" + mapfile -t problems < <(presence_problems "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "The $target_channel channel has no Mac release yet; this Mac stays as it is until it has one." + refuse "${problems[@]}" + fi + + targets_file=$work/targets + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + { + cat "$work/adapter-targets" + for name in $keyring_packages; do + sed 's|^.*/||' "$work/adapter-targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + } >"$targets_file" + names=$(sed 's|^.*/||' "$targets_file" | xargs) + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$pacman_conf holds back $problem, which the migration changes; remove it from IgnorePkg or IgnoreGroup first") + done < <(pinned_targets "$pacman_conf" "$names $(xargs <"$work/allowed-removals")" "$work/db" "$transaction") + (( ${#reasons[@]} == 0 )) || refuse "${reasons[@]}" + resolved=$work/resolved + # shellcheck disable=SC2046 + if ! pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sup --noconfirm --ask 4 \ + --print-format '%r/%n %v' $(plan_ignores "$work") $(cat "$targets_file") >"$resolved" 2>"$work/resolve.log"; then + refuse "the target set does not resolve on this Mac: $(tail -n 1 "$work/resolve.log")" + fi + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + grep -Fxq "$name $version" "$resolved" || refuse "${name#*/} does not resolve to the candidate's $version" + done <"$targets_file" + fi + + mapfile -t problems < <(archive_problems "$resolved" "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "This Mac cannot move to the $target_channel channel's packages yet; it stays as it is." + refuse "${problems[@]}" + fi + + # The boot tools of the omarchy-mac-boot the transaction installs judge the + # Mac from here on. + payload_dir=$work/payload + version=$(fetch_payload "$resolved" "$transaction" "$work/db" "$payload_dir") || + refuse "cannot take the boot tools from the target's omarchy-mac-boot: $version" + saved_path=$PATH + if (( fixture )); then + PATH=$PATH:$payload_dir/usr/bin + else + PATH=$payload_dir/usr/bin:$PATH + fi + # The busybox encrypt hook matters only where it unlocks the root: legacy + # omarchy-mac sets it on every Mac, and on an unencrypted one it does nothing. + # Only a cohort whose stage moves that unlock (legacy) may carry it. + if ! hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null); then + reasons+=("cannot read the initramfs HOOKS") + elif [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( ! staged )); then + reasons+=("the root unlocks through busybox encrypt, which only the legacy omarchy-mac migration moves") + fi + # Installed boot files, not the running kernel: an update that just replaced + # the kernel leaves a reboot pending, and the migration replaces it anyway. + if ! check_output=$(boot_check_pending 2>&1); then + reasons+=("the boot files are not coherent; repair them first: $(tail -n 1 <<<"$check_output")") + fi + # Limine and its UKI live on the ESP U-Boot boots, mounted at /boot/efi. A + # cohort with a stage moves an ESP mounted at /boot there first. + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" ]] && (( staged )); then + : + elif [[ $esp_mount != "$esp" ]]; then + reasons+=("the system ESP is not mounted at $esp") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(adapter_hook preflight "$installed" "$luks" "$hooks") + need=$(( 4 * 1024 * 1024 * 1024 + $(bytes_used "$R/etc") + $(bytes_used "$R/boot") )) + # An ESP mounted at /boot is also /boot: its kernel and initramfs move onto + # the root filesystem. + [[ $esp_mount != "/boot" ]] || need=$(( need + 512 * 1024 * 1024 )) + (( $(free_bytes "$R/var/lib") >= need )) || reasons+=("the root filesystem needs $(( need / 1024 / 1024 )) MiB free for backups and downloads") + (( $(free_bytes "$R${esp_mount:-$esp}") >= 64 * 1024 * 1024 )) || reasons+=("the ESP needs 64 MiB free") + [[ $esp_mount == "/boot" ]] || (( $(free_bytes "$R/boot") >= 128 * 1024 * 1024 )) || reasons+=("/boot needs 128 MiB free") + PATH=$saved_path + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + # The adapter's plan records the unlock its stage moves, now that the HOOKS + # are known. + if [[ $cohort == "legacy" ]]; then + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "$hooks" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + fi + + gpgdir="" + if already_on_target "$installed" "$resolved" "$targets_file" "$future"; then + say "This Mac already runs the target set ($target_id): nothing to migrate." + exit 0 + fi + if (( check_only )); then + say "Ready: run moves this Mac ($cohort, $boot_state boot${luks:+, encrypted}) onto $target_id ($target_channel)." + say "It installs: $names" + [[ ! -s $work/allowed-removals ]] || say "It may remove: $(xargs <"$work/allowed-removals")" + exit 0 + fi + + # Passed: freeze the plan. Nothing on the system has changed yet. + install -d -m 755 "$(dirname "$state")" "$state" + : >"$journal" + printf 'journal_format=%s\n' "$journal_format" >"$state/format" + sync "$journal" "$state/format" + current_step=preflight + journal_write preflight "begin" "$target_id" + rm -rf "$plan.new" + install -d -m 755 "$plan.new" + cp "$installed" "$plan.new/installed" + cp "$future" "$plan.new/pacman.conf" + cp -a "$work/db/sync" "$plan.new/sync" + guarded_pacman_conf "$future" "$pacman_conf" "$(printf '%s\n' $names $(xargs <"$work/allowed-removals") $guarded_boot | awk '!seen[$0]++' | xargs)" >"$plan.new/pacman.guarded.conf" + cp "$targets_file" "$plan.new/targets" + cp "$work/allowed-removals" "$plan.new/allowed-removals" + cp "$work/kept" "$plan.new/kept" 2>/dev/null || : >"$plan.new/kept" + cp "$work/removals" "$plan.new/removals" 2>/dev/null || : >"$plan.new/removals" + [[ ! -d $work/adapter ]] || cp -r "$work/adapter" "$plan.new/adapter" + cp "$target_file" "$plan.new/target" + printf '%s\n' "$target_id" >"$plan.new/target-id" + printf '%s\n' "$target_packages" >"$plan.new/target-packages" + printf '%s\n' "$cohort" >"$plan.new/cohort" + printf '%s\n' "$boot_state" >"$plan.new/boot" + printf '%s\n' "$luks" >"$plan.new/luks" + printf '%s\n' "$esp_mount" >"$plan.new/esp" + for name in $fresh_user_units; do + [[ ! -f $R/usr/lib/systemd/user/$name ]] || printf '%s\n' "$name" + done >"$plan.new/user-units" + find "$plan.new" -type f -exec sync {} + + if [[ $target_type == "candidate-set" ]]; then + rm -rf "$set_copy" + mv "$work/set" "$set_copy" || die "cannot keep the verified candidate set" + sync "$set_copy"/* + target_set=$set_copy + fi + rm -rf "$plan" + mv "$plan.new" "$plan" + keep_tool || die "cannot keep a copy of this tool for the migration's resume" + sync "$state" + interrupt_for_test during preflight + journal_write preflight "done" + interrupt_for_test after preflight + current_step="" +} + +refuse() { + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + die "$*" + fi + say "The migration was refused before anything changed:" >&2 + printf ' - %s\n' "$@" >&2 + install -d -m 755 "$state" 2>/dev/null && + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$*" | durable_write "$state/deferred" 2>/dev/null || true + exit 75 +} + +# Every target is installed at the version the target resolves to, the +# configuration is already the future one and no retired key is trusted. +# Ordinary upgrades of other packages are omarchy update's business. +already_on_target() { + local installed=$1 resolved=$2 targets=$3 future=$4 target name version fpr + cmp -s "$future" "$pacman_conf" || return 1 + while read -r target; do + name=${target#*/} + version=$(awk -v name="$name" '{ sub(/^[^\/]*\//, "", $1) } $1 == name { print $2; exit }' "$resolved") + [[ -n $version && $(installed_version "$name" "$installed") == "$version" ]] || return 1 + done <"$targets" + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 1 + done +} + +# --- Steps ------------------------------------------------------------------- + +# The frozen plan: the target as preflight read it, the candidate set as it +# verified it. Nothing is read from the original set again. +load_plan() { + target_file=$plan/target + load_target "$target_file" frozen + target_id=$(<"$plan/target-id") + target_packages=$(<"$plan/target-packages") + cohort=$(<"$plan/cohort") +} + +plan_targets() { + cat "$plan/targets" +} + +# Where the ESP was mounted at preflight: /boot/efi, or /boot where the +# cohort's stage moves it. +plan_esp() { + if [[ -s $plan/esp ]]; then + cat "$plan/esp" + else + printf '%s\n' "$esp" + fi +} + +# Unqualified names of every package the transaction replaces or may remove. +plan_package_names() { + { sed 's|^.*/||' "$plan/targets"; cat "$plan/allowed-removals"; } | sort -u +} + +step_backup() { + local partial=$state/backup.partial name version file found luks esp_mount + esp_mount=$(plan_esp) + rm -rf "$partial" + install -d -m 700 "$partial" "$partial/packages" + cp "$plan/installed" "$partial/installed" + : >"$partial/packages.missing" + while read -r name; do + version=$(installed_version "$name" "$plan/installed") + [[ -n $version ]] || continue + found=0 + for file in "$pacman_cache/$name-$version"-*.pkg.tar.*; do + [[ -f $file ]] || continue + cp -p "$file" "$partial/packages/" || die "cannot copy $file into the backup" + found=1 + done + (( found )) || printf '%s %s\n' "$name" "$version" >>"$partial/packages.missing" + done < <(plan_package_names) + tar -C "$R/" --xattrs --acls -cpf "$partial/etc.tar" etc 2>"$partial/etc.log" || die "cannot back up /etc" + interrupt_for_test mid backup + # An ESP mounted at /boot is /boot: esp.tar holds it. + if [[ $esp_mount != "/boot" ]]; then + tar -C "$R/boot" --one-file-system -cpf "$partial/boot.tar" . || die "cannot back up /boot" + fi + tar -C "$R$esp_mount" -cpf "$partial/esp.tar" . || die "cannot back up the ESP" + luks=$(<"$plan/luks") + if [[ -n $luks ]]; then + cryptsetup luksHeaderBackup "$luks" --header-backup-file "$partial/luks-header.img" || + die "cannot back up the LUKS header of $luks" + fi + (cd "$partial" && find . -type f ! -name SHA256SUMS -print0 | LC_ALL=C sort -z | xargs -0 sha256sum >SHA256SUMS) || + die "cannot record the backup's digests" + find "$partial" -type f -exec sync {} + || die "cannot sync the backup" + rm -rf "$backup" + mv "$partial" "$backup" || die "cannot finish the backup" + sync "$state" + if [[ -s $backup/packages.missing ]]; then + say "Not in the package cache, so not backed up: $(awk '{ print $1 }' "$backup/packages.missing" | xargs)" + fi +} + +# Official trust, bootstrapped without any repository the switch retires: the +# keyrings already installed are populated, and a missing Omarchy key comes +# from the keyserver by its full fingerprint and is signed locally. A candidate +# set's key never enters pacman's keyring. +step_keyring() { + local keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$pacman_gpg" --populate "${keyrings[@]}" >/dev/null || die "cannot populate the keyrings: ${keyrings[*]}" + fi + interrupt_for_test mid keyring + if ! key_trusted "$target_keyring"; then + pacman-key --gpgdir "$pacman_gpg" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$pacman_gpg" --lsign-key "$target_keyring" >/dev/null || + die "cannot fetch and trust the Omarchy key $target_keyring" + fi + key_trusted "$target_keyring" || die "the Omarchy key $target_keyring is not trusted after the bootstrap" +} + +# What the transaction may remove stays out of the upgrade: an official build +# of the same name that conflicts with a target (stock omarchy, which the +# omarchy-dev pair replaces on edge) would otherwise join the transaction, and +# pacman drops one of the two. Left alone, it leaves through the target's +# conflict, or by name after the transaction. +plan_ignores() { + local dir=${1:-$plan} names + names=$(cat "$dir/removals" "$dir/allowed-removals" 2>/dev/null | awk 'NF' | LC_ALL=C sort -u | paste -sd,) + [[ -z $names ]] || printf -- '--ignore=%s\n' "$names" + return 0 +} + +# Packages the transaction removes by name once it has installed the targets, +# as far as DB still has them. By exact name: pacman -Q NAME also answers with +# a package that provides NAME (mise-bin for mise), which pacman -R refuses. +plan_removals() { + local db=$1 name installed + [[ -f $plan/removals ]] || return 0 + installed=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Qq) || return 1 + while read -r name; do + [[ -n $name ]] && grep -Fxq -- "$name" <<<"$installed" && printf '%s\n' "$name" + done <"$plan/removals" + return 0 +} + +# Paths the installed package NAME owns in DB that another package there owns +# too. pacman -R deletes every file of the package it removes, whoever else +# owns it, so a planned removal that hands files over (omarchy-dev's commands +# to omarchy-mac-boot) must leave inside the transaction, through the conflict +# of the package that replaces it, never in the removals after it. +shared_files() { + local db=$1 name=$2 + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Ql 2>/dev/null | awk -v name="$name" ' + { owner = $1; path = $0; sub(/^[^ ]+ /, "", path) } + path ~ /\/$/ { next } + owner == name { mine[path] = 1; next } + { other[path] = 1 } + END { for (path in mine) if (path in other) print path }' | LC_ALL=C sort +} + +# Downloads and verifies every package the transaction needs, then rehearses the +# transaction on a copy of the package database (--dbonly: no files, scripts or +# hooks) to learn exactly what it installs and removes. Signatures are checked +# against the trust the switch leaves, a copy of the keyring without the +# retired keys, so nothing that needs a fork key gets this far. +step_prefetch() { + local db=$cache/db rehearsal=$cache/rehearsal conf=$cache/transaction.conf removed name version bad=() fpr removals shared + install -d -m 755 "$cache" "$cache/pkg" + rm -rf "$db" "$rehearsal" "$cache/candidate" "$cache/trust" + mkdir -p "$db" + cp -a "$pacman_db/local" "$db/local" || die "cannot copy the package database" + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Q >"$cache/start" || die "cannot read the package database copy" + if [[ $target_type == "candidate-set" ]]; then + stage_candidate_repo "$cache/candidate" "$cache/gnupg" || die "the candidate set does not verify" + fi + install -d -m 700 "$cache/trust" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$cache/trust" -xf - || die "cannot copy the pacman keyring" + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$cache/trust" --delete "$fpr" >/dev/null 2>&1 || die "cannot drop $fpr from the keyring copy" + fi + done + gpgdir=$cache/trust + transaction_conf "$plan/pacman.conf" "${target_set:+$cache/candidate}" >"$conf" + # The databases preflight qualified, never a newer sync: the transaction + # installs exactly the set preflight checked. + cp -a "$plan/sync" "$db/sync" || die "cannot copy the frozen package databases" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + -Suw --noconfirm --ask 4 $(plan_ignores) $(plan_targets) || die "cannot download and verify the target set" + interrupt_for_test mid prefetch + cp -a "$db" "$rehearsal" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$rehearsal" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + --dbonly -Su --noconfirm --ask 4 $(plan_ignores) $(plan_targets) >"$cache/rehearsal.log" 2>&1 || + die "the rehearsed transaction failed: $(tail -n 1 "$cache/rehearsal.log")" + removals=$(plan_removals "$rehearsal" | xargs) + for name in $removals; do + shared=$(shared_files "$rehearsal" "$name" | head -n 3 | xargs) + [[ -z $shared ]] || + die "the transaction would leave $name to be removed after it, but the packages it installs also own $shared; nothing was changed" + done + if [[ -n $removals ]]; then + # shellcheck disable=SC2086 + pacman_run --config "$conf" --dbpath "$rehearsal" --logfile "$cache/pacman.log" --dbonly -R --noconfirm $removals \ + >>"$cache/rehearsal.log" 2>&1 || die "the rehearsed removal of $removals failed: $(tail -n 1 "$cache/rehearsal.log")" + fi + gpgdir="" + gpgconf --homedir "$cache/trust" --kill all >/dev/null 2>&1 || true + LC_ALL=C pacman --config "$conf" --dbpath "$rehearsal" -Q >"$cache/expected" || die "cannot read the rehearsed result" + removed=$(comm -23 <(awk '{ print $1 }' "$cache/start" | LC_ALL=C sort) <(awk '{ print $1 }' "$cache/expected" | LC_ALL=C sort)) + for name in $removed; do + grep -Fxq "$name" "$plan/allowed-removals" || bad+=("$name") + done + (( ${#bad[@]} == 0 )) || die "the transaction would also remove ${bad[*]}; nothing was changed" + # pacman can drop a named target that conflicts with another package of the + # transaction; every target must end installed. + while read -r name; do + [[ -n $(installed_version "${name#*/}" "$cache/expected") ]] || + die "the rehearsed transaction would not install ${name#*/}; nothing was changed" + done < <(plan_targets) + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + [[ $(installed_version "${name#*/}" "$cache/expected") == "$version" ]] || die "${name#*/} would not end at the candidate's $version" + done < <(plan_targets) + fi + adapter_hook prefetch || die "the $cohort adapter refused the rehearsed transaction; nothing was changed" + durable_write "$start" <"$cache/start" && durable_write "$expected" <"$cache/expected" || + die "cannot record the rehearsed transaction" +} + +# The installed packages no longer match what the rehearsal started from: an +# omarchy update ran in between, or a transaction was cut short. The +# transaction is rehearsed again from what is installed now. +system_moved() { + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + ! cmp -s "$state/installed.now" "$start" +} + +restart_from_prefetch() { + local step + (( ++restarts <= 3 )) || die "the system keeps changing under the migration ($1); run it again when nothing else updates" + say "The system changed since the transaction was rehearsed ($1); rehearsing it again" + for step in prefetch repositories transaction; do + journal_write "$step" "reset" "$1" + done + restarted=1 +} + +# The sync databases the rehearsal resolved against, over any a later sync left. +# A signature the rehearsal has none of belongs to the database it replaces +# (a fork's signed [omarchy]), and pacman rejects a database beside a +# signature that does not match it. +install_rehearsed_databases() { + local repo extension + for repo in $(repositories_in "$cache/transaction.conf"); do + for extension in db db.sig; do + if [[ ! -f $cache/db/sync/$repo.$extension ]]; then + [[ $extension != "db.sig" || ! -f $cache/db/sync/$repo.db ]] || rm -f "$pacman_db/sync/$repo.db.sig" + continue + fi + cmp -s "$cache/db/sync/$repo.$extension" "$pacman_db/sync/$repo.$extension" && continue + durable_write "$pacman_db/sync/$repo.$extension" 644 <"$cache/db/sync/$repo.$extension" || + die "cannot install the $repo database" + done + done +} + +# The process holding pacman's lock: libalpm keeps it open while it works. +lock_holder() { + local fd + for fd in "$R"/proc/[0-9]*/fd/*; do + if [[ $(readlink "$fd" 2>/dev/null) == "$pacman_db/db.lck" ]]; then + fd=${fd#"$R/proc/"} + printf '%s\n' "${fd%%/*}" + return 0 + fi + done + return 1 +} + +# Official repository precedence and no legacy trust: the frozen +# configuration, the sync databases the rehearsal used, no retired database or +# fork key. Until the transaction is done the configuration carries the +# migration's guard (and a test image keeps its pin), so a plain pacman -Syu in +# between moves none of the packages the transaction is about to change. +step_repositories() { + local repo extension fpr + if system_moved; then + restart_from_prefetch "before the repository switch" + return 0 + fi + # From here on the fork's own update may be gone with its packages: a boot + # resumes whatever is left. + keep_tool && write_verify_unit && systemctl enable "$verify_unit" >/dev/null 2>&1 || + die "cannot install $verify_unit, which resumes the migration at boot" + if ! cmp -s "$plan/pacman.guarded.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.guarded.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid repositories + install_rehearsed_databases + for repo in "${retired_repos[@]}"; do + rm -f "$pacman_db/sync/$repo".{db,db.sig,files,files.sig} + done + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$pacman_gpg" --delete "$fpr" >/dev/null || die "cannot remove the retired key $fpr" + fi + done +} + +# Something rewrote pacman.conf or trusted a retired key again since the +# switch: on an mx-mac Mac, the fork's own omarchy update, whose channel +# updaters stay until the transaction removes them. +switch_undone() { + local fpr + cmp -s "$plan/pacman.guarded.conf" "$pacman_conf" || return 0 + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 0 + done + return 1 +} + +# The installed packages with the planned removals left out. +without_removals() { + awk 'NR == FNR { drop[$1]; next } !($1 in drop)' "$plan/removals" "$1" +} + +# The targets are installed and only the planned removals are left. +removals_pending() { + [[ -f $plan/removals ]] && ! cmp -s "$state/installed.now" "$expected" && + cmp -s <(without_removals "$state/installed.now") "$expected" +} + +# A path as a pacman --overwrite glob that matches only itself. +overwrite_glob() { + sed 's/[][*?\\]/\\&/g' <<<"$1" +} + +# One transaction from the prefetched cache and databases, without a new sync: +# it installs exactly what was verified and rehearsed. Same-name packages are +# named explicitly, so a higher installed version is replaced too. A lock left +# by a transaction that was killed means its hooks may not have run, so the +# transaction runs again even when the packages are all in place. The adapter +# prepares the system for it first and may list, in $state/overwrite, files no +# package owns that it may replace; when pacman fails, the adapter restores +# what it prepared. Planned removals run after it, by name. +step_transaction() { + local holder interrupted=0 overwrite=() remove=() path removal shared + if [[ -e $pacman_db/db.lck ]]; then + if holder=$(lock_holder); then + die "pacman is running (process $holder); run the migration again when it has finished" + fi + say "Removing the pacman lock an interrupted transaction left behind" + : | durable_write "$interrupted_marker" || die "cannot record the interrupted transaction" + rm -f "$pacman_db/db.lck" + fi + # Kept across a new rehearsal until a transaction has run to its end. + [[ ! -e $interrupted_marker ]] || interrupted=1 + if switch_undone; then + restart_from_prefetch "pacman.conf or a retired key came back after the repository switch" + return 0 + fi + if system_moved && ! cmp -s "$state/installed.now" "$expected" && ! removals_pending; then + restart_from_prefetch "before the transaction" + return 0 + fi + if (( interrupted )) || ! cmp -s "$state/installed.now" "$expected"; then + install_rehearsed_databases + if (( interrupted )) || ! removals_pending; then + rm -f "$state/overwrite" + if ! adapter_hook prepare; then + adapter_hook restore + die "the $cohort adapter could not prepare the transaction" + fi + if [[ -f $state/overwrite ]]; then + while IFS= read -r path; do + [[ -z $path ]] || overwrite+=(--overwrite "$(overwrite_glob "$path")") + done <"$state/overwrite" + fi + # shellcheck disable=SC2046 + if ! pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" \ + -Su --noconfirm --ask 4 "${overwrite[@]}" $(plan_ignores) $(plan_targets); then + adapter_hook restore + die "the package transaction failed" + fi + fi + interrupt_for_test mid removals + mapfile -t remove < <(plan_removals "$pacman_db") + for removal in "${remove[@]}"; do + shared=$(shared_files "$pacman_db" "$removal" | head -n 3 | xargs) + [[ -z $shared ]] || die "cannot remove $removal: other packages also own $shared, which pacman -R would delete" + done + if (( ${#remove[@]} )); then + pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" -R --noconfirm "${remove[@]}" || + die "cannot remove ${remove[*]}" + fi + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + cmp -s "$state/installed.now" "$expected" || + die "the installed packages differ from the rehearsed transaction: $(diff "$expected" "$state/installed.now" | grep '^[<>]' | head -n 3 | xargs)" + rm -f "$interrupted_marker" + fi + rm -f "$pacman_db/sync/$candidate_repo".{db,db.sig} + # Fresh-image provisioning is never armed on an existing machine (preflight + # refuses one whose first boot is unfinished). + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + say "Disarming the first-boot setup the transaction left on this installed Mac" + rm -f "$first_boot_marker" "$legacy_first_boot_marker" + fi +} + +# The new packages are installed, set up and verified: the configuration loses +# the migration's guard and a test image's pin, and is the core one from here +# on. +step_unpin() { + if ! cmp -s "$plan/pacman.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid unpin + pacman-key --gpgdir "$pacman_gpg" --populate $(installed_keyrings) >/dev/null || die "cannot populate the installed keyrings" +} + +# The keyrings pacman-key can populate from what is installed now. +installed_keyrings() { + local name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || printf '%s\n' "$name" + done +} + +# Aurora, m1n1 and U-Boot came with the transaction; their stage-two image +# (m1n1, the device trees and U-Boot) and the kernel's menu are rebuilt and +# checked. The loader U-Boot starts is not touched here. +step_boot_chain() { + local output + update-m1n1 >/dev/null || die "update-m1n1 could not rebuild m1n1, the device trees and U-Boot" + interrupt_for_test mid boot-chain + if [[ $(<"$plan/boot") == "limine" ]]; then + omarchy-mac-limine-cmdline && limine-update >/dev/null || die "cannot rebuild the Limine menu and UKI" + else + update-grub >/dev/null || die "cannot rebuild the GRUB menu" + fi + output=$(boot_check_pending linux-aurora 2>&1) || die "the rebuilt boot files do not check: $(tail -n 1 <<<"$output")" +} + +# The installed omarchy-mac-boot's setup-boot, through the new runtime's +# dispatcher, activates Limine (or refreshes it on a Limine Mac): it stages and +# verifies Limine before it takes U-Boot's EFI slot and restores every file it +# touched when anything fails, so a failed activation leaves the previous +# loader booting. On a GRUB Mac the cohort's stage runs first, while GRUB still +# boots the Mac, and is undone when it or the activation fails. A switch cut +# short is run again from its start. +step_loader() { + local output uki=$R$esp/EFI/Linux/omarchy_linux-aurora.efi + if [[ $(<"$plan/boot") == "limine" ]]; then + interrupt_for_test mid loader + omarchy-lifecycle-dispatch setup-boot >/dev/null || die "setup-boot could not refresh Limine; the previous loader stays" + else + if ! adapter_hook stage; then + adapter_hook unstage || die "the $cohort adapter could not stage the boot switch, nor undo it; GRUB is still the loader" + die "the $cohort adapter could not stage the boot switch; GRUB is still the loader" + fi + install -D -m 644 /dev/null "$limine_gate" || die "cannot mark this Mac for Limine" + interrupt_for_test mid loader + if ! omarchy-lifecycle-dispatch setup-boot >/dev/null; then + rm -f "$limine_gate" + adapter_hook unstage || die "Limine could not be activated, and the $cohort adapter could not undo its stage; GRUB is still the loader" + die "Limine could not be activated; GRUB is still the loader" + fi + fi + [[ -s $uki ]] && grep -Fq "boot():/EFI/Linux/omarchy_linux-aurora.efi" "$R$esp/limine.conf" || + die "Limine has no linux-aurora UKI entry after setup-boot" + cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || die "the ESP loader is not the packaged Limine" + output=$(boot_check_pending linux-aurora 2>&1) || die "the staged boot chain does not check: $(tail -n 1 <<<"$output")" +} + +# Runs a command as USER, whose home is HOME, in a clean environment, so +# nothing root does follows a link the user controls. +as_user() { + local user=$1 home=$2 + shift 2 + if (( fixture )); then + env HOME="$home" "$@" + else + runuser -u "$user" -- env -i HOME="$home" USER="$user" LOGNAME="$user" PATH="$PATH" "$@" + fi +} + +# Accounts that have used Omarchy: a regular UID and Omarchy's state in the home. +omarchy_users() { + local user home + awk -F: '$3 >= 1000 && $3 < 60000 { print $1, $6 }' "$R/etc/passwd" 2>/dev/null | + while read -r user home; do + [[ ! -d $R$home/.local/state/omarchy ]] || printf '%s %s\n' "$user" "$home" + done +} + +# Enables a user unit by the links its [Install] WantedBy names, as +# systemctl --user enable writes them for these units. A mask, an override or any enablement, the user's +# or the administrator's, stays as it is. +enable_user_unit() { + local user=$1 home=$2 unit=$3 config=$R$2/.config/systemd/user target path + [[ -f $R/usr/lib/systemd/user/$unit ]] || return 0 + for path in "$config/$unit" "$R/etc/systemd/user/$unit" "$config"/*.wants/"$unit" "$R/etc/systemd/user"/*.wants/"$unit"; do + [[ ! -e $path && ! -L $path ]] || return 0 + done + for target in $(awk -F= '/^\[/ { install = ($0 == "[Install]") } install && $1 == "WantedBy" { print $2 }' "$R/usr/lib/systemd/user/$unit"); do + as_user "$user" "$R$home" mkdir -p "$config/$target.wants" && + as_user "$user" "$R$home" ln -s "/usr/lib/systemd/user/$unit" "$config/$target.wants/$unit" || return 1 + done +} + +# What omarchy update checks before it offers a reboot, through the new +# runtime's dispatcher: the boot files the next boot reads. +update_verify() { + local output + output=$(omarchy-lifecycle-dispatch update-verify 2>&1) || die "update-verify does not pass: $(grep -v '^[[:space:]]*$' <<<"$output" | head -n 2 | xargs)" +} + +step_verify() { + update_verify +} + +# The unit that finishes the migration after its reboot runs the copy of this +# tool the migration keeps, so it works whatever else is installed. +write_verify_unit() { + local unit + unit="[Unit] +Description=Finish the Omarchy Mac migration after its reboot +# Either condition starts it: a migration past its repository switch and not +# complete, or user setup a migration left pending, retried at every boot until +# it succeeds. +ConditionPathExists=|/var/lib/omarchy-mac/migration/journal +ConditionPathExists=|/var/lib/omarchy-mac/migration/user-pending +Wants=network-online.target +After=local-fs.target network-online.target + +[Service] +Type=oneshot +ExecStart=${tool_copy#"$R"} verify + +[Install] +WantedBy=multi-user.target" + [[ -f $verify_unit_file && $(<"$verify_unit_file") == "$unit" ]] && return 0 + install -d -m 755 "$(dirname "$verify_unit_file")" && + printf '%s\n' "$unit" | durable_write "$verify_unit_file" 644 && + { systemctl daemon-reload >/dev/null 2>&1 || true; } +} + +# Waits for a reboot; after it, the new chain must have booted Aurora through +# Limine with every boot file coherent. The boot waited for stays recorded +# until retire, so a verification cut short is repeated on the same boot. +step_reboot() { + local staged current release output + current=$(boot_id) + if [[ ! -s $reboot_pending ]]; then + printf '%s\n' "$current" | durable_write "$reboot_pending" || die "cannot record the boot to wait for" + fi + interrupt_for_test mid reboot + keep_tool && write_verify_unit || die "cannot install $verify_unit, which verifies the next boot" + systemctl enable "$verify_unit" >/dev/null 2>&1 || die "cannot enable $verify_unit, which verifies the next boot" + staged=$(<"$reboot_pending") + if [[ $current == "$staged" ]]; then + say "Reboot to finish the migration to $target_id. The next boot verifies the new boot chain." + current_step="" + exit 0 + fi + release=$(kernel_release linux-aurora) || die "linux-aurora has no module tree" + [[ $(<"$R/proc/sys/kernel/osrelease") == "$release" ]] || + die "this boot runs $(<"$R/proc/sys/kernel/osrelease"), not linux-aurora $release; the backups are in $backup" + limine_mac && cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || + die "this Mac did not boot through the packaged Limine" + output=$(omarchy-apple-silicon-boot-check --boot-chain linux-aurora 2>&1) || die "the boot check failed after the reboot: $(tail -n 1 <<<"$output")" + update_verify +} + +# The completion record comes first: what is left after it is only cleanup, +# which every later run repeats until it is done. +step_retire() { + "${cohort//-/_}_retire" || die "the $cohort adapter could not retire its compatibility state" + printf 'target=%s\ncompleted=%s\n' "$target_id" "$(date +%Y-%m-%dT%H:%M:%S%z)" | durable_write "$complete" || + die "cannot record the completed migration" + interrupt_for_test mid retire + tidy_completed +} + +# The post-reboot unit is released once the working state is gone and no user +# setup is pending; while some is, it stays enabled to retry at every boot. +tidy_completed() { + local working=0 release=0 + if [[ -e $reboot_pending || -d $cache || -d $set_copy ]]; then + working=1 + release=1 + fi + if [[ -s $user_pending ]]; then + if retry_user_pending; then + release=1 + else + release=0 + fi + fi + if (( release )); then + release_verify_unit + fi + if (( working )); then + rm -rf "$cache" "$set_copy" "$state/installed.now" "$state/overwrite" + rm -f "$reboot_pending" + fi +} + +# The post-reboot unit goes once nothing is left for it to do, and with it, +# outside a migration in progress, the copy of the tool it runs. +release_verify_unit() { + systemctl disable "$verify_unit" >/dev/null 2>&1 || say "Could not disable $verify_unit; it does nothing from now on." + if [[ -f $verify_unit_file ]]; then + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi + if [[ -f $complete || ! -f $journal ]]; then + rm -rf "$state/tool" + fi +} + +# A copy of this tool beside the journal: the post-reboot unit runs it, and a +# migration in progress resumes with it (see hand_over). +keep_tool() { + [[ $self != "$tool_copy" ]] || return 0 + cmp -s "$self" "$tool_copy" && return 0 + install -d -m 755 "$(dirname "$tool_copy")" && + install -m 755 "$self" "$tool_copy.new" && sync "$tool_copy.new" && mv -f "$tool_copy.new" "$tool_copy" +} + +# The tool_version and journal_format a copy of the tool declares. +tool_field() { + sed -n "s/^$1=\([0-9][0-9]*\)$/\1/p" "$2" | head -n 1 +} + +# A migration in progress continues with the tool that started it, unless this +# one resumes the same journal format and is at least as new: then this one +# takes over and becomes the kept copy. +hand_over() { + local version format + [[ -f $tool_copy && $self != "$tool_copy" ]] || return 0 + version=$(tool_field tool_version "$tool_copy") + format=$(tool_field journal_format "$tool_copy") + if [[ $format == "$journal_format" ]] && (( tool_version >= ${version:-0} )); then + keep_tool || die "cannot update the kept copy of this tool" + return 0 + fi + say "Resuming with the tool this migration started with (version ${version:-unknown})" + exec "$tool_copy" "$@" +} + +# --- Commands ---------------------------------------------------------------- + +# Another run holding the lock owns the migration's state: this one leaves it +# alone, failing once the migration is past its switch and deferring before. +# A copy of the tool handed the migration keeps the lock it inherited. +take_lock() { + install -d -m 755 "$(dirname "$lock_file")" + if [[ $(readlink "/proc/$$/fd/9" 2>/dev/null) != "$(realpath -m "$lock_file")" ]]; then + exec 9>"$lock_file" + fi + if ! flock -n 9; then + echo "omarchy-mac-migrate: another migration run is in progress" >&2 + if past_boundary; then + exit 1 + fi + exit 75 + fi +} + +resume_steps() { + local step event + while step=$(next_step) && [[ -n $step ]]; do + [[ $step != "preflight" ]] || die "the migration has no finished preflight" + event=$(step_state "$step") + [[ -z $event || $event == "reset" || $step == "reboot" ]] || say "Resuming the migration at $step" + run_step "$step" + done + say "This Mac now runs $target_id. Backups stay in $backup." +} + +migrate_run() { + local target_arg="" candidate + while (( $# )); do + case $1 in + --target) target_arg=${2:?--target needs a file}; shift 2 ;; + *) usage; exit 2 ;; + esac + done + + platform=$(hardware_platform) || die "cannot determine the hardware platform" + if [[ $platform != "apple-silicon" ]]; then + say "Not an Apple Silicon Mac: nothing to migrate." + return 0 + fi + take_lock + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + + if [[ -f $complete && -f $plan/target-id ]]; then + tidy_completed + candidate=$(find_target "$target_arg") + if [[ -n $candidate ]]; then + load_target "$candidate" + fi + if [[ -z $candidate || $target_id == "$(<"$plan/target-id")" ]]; then + say "Already migrated to $(<"$plan/target-id")." + return 0 + fi + (( check_only )) || archive_state + else + # User setup an earlier migration left pending runs first, whatever this + # run does next. + (( check_only )) || retry_user_pending_now + fi + + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + if [[ -n $target_arg ]] && ! cmp -s "$target_arg" "$plan/target"; then + die "a migration to $(<"$plan/target-id") is in progress; finish it before choosing another target" + fi + if (( check_only )); then + migrate_status + return 0 + fi + load_plan + resume_steps + return 0 + fi + + target_file=$(find_target "$target_arg") + preflight + resume_steps +} + +# Keeps a finished migration's record and backups beside the next one. +archive_state() { + local destination + destination=$state/history/$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$start" "$expected" "$complete" "$destination/" 2>/dev/null + [[ ! -f $repaired ]] || mv "$repaired" "$destination/" + [[ ! -d $backup ]] || mv "$backup" "$destination/" +} + +# Run by omarchy-mac-migrate-verify.service at boot: continues a migration that +# is waiting for, or past, its reboot, and does nothing otherwise. +migrate_verify() { + platform=$(hardware_platform) || die "cannot determine the hardware platform" + [[ $platform == "apple-silicon" ]] || return 0 + past_boundary || [[ -s $user_pending ]] || return 0 + take_lock + if past_boundary; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + if [[ -f $complete ]]; then + tidy_completed + return 0 + fi + retry_user_pending_now + past_boundary || return 0 + load_plan + resume_steps +} + +# A migration that has started its repository switch and is not complete. +past_boundary() { + [[ -f $journal && ! -f $complete ]] && ! before_boundary +} + +migrate_status() { + local step event + if [[ -s $user_pending ]]; then + say "User setup pending, retried at the next run or boot: $(pending_summary)" + fi + if [[ ! -f $journal || $(step_state preflight) != "done" ]]; then + if [[ -s $state/deferred ]]; then + say "No migration has started on this Mac. The last run deferred: $(cut -d' ' -f2- "$state/deferred")" + else + say "No migration has started on this Mac." + fi + return 0 + fi + say "Target: $(<"$plan/target-id")" + if [[ -f $complete ]]; then + say "State: complete ($(awk -F= '$1 == "completed" { print $2 }' "$complete"))" + return 0 + fi + step=$(next_step) + event=$(step_state "$step") + if [[ $step == "reboot" && -s $reboot_pending && $event == "begin" ]]; then + if [[ $(boot_id) == "$(<"$reboot_pending")" ]]; then + say "State: waiting for a reboot" + else + say "State: rebooted; the new boot chain is not verified yet (sudo omarchy-mac-migrate verify)" + fi + elif [[ $event == "fail" ]]; then + say "State: failed at $step: $(awk -v step="$step" '$2 == step && $3 == "fail" { $1 = $2 = $3 = ""; line = $0 } END { sub(/^ +/, "", line); print line }' "$journal")" + else + say "State: in progress, next step $step" + fi +} + +usage() { + cat >&2 <<'USAGE' +Usage: omarchy-mac-migrate status + omarchy-mac-migrate check [--target FILE] + omarchy-mac-migrate run [--target FILE] + omarchy-mac-migrate verify + omarchy-mac-migrate version +USAGE +} + +migrate_main() { + local command=${1:-status} + original_args=("$@") + (( $# == 0 )) || shift + case $command in + status) migrate_status ;; + check) check_only=1; migrate_run "$@" ;; + run) migrate_run "$@" ;; + verify) migrate_verify ;; + version) say "omarchy-mac-migrate $tool_version (journal format $journal_format)" ;; + -h | --help | help) usage ;; + *) usage; exit 2 ;; + esac +} diff --git a/migrate/src/main.sh b/migrate/src/main.sh new file mode 100644 index 00000000000..9e1ebbb6277 --- /dev/null +++ b/migrate/src/main.sh @@ -0,0 +1,55 @@ +#!/bin/bash -p + +# omarchy:summary=Move this Mac onto Omarchy's official packages through a journaled, resumable migration +# omarchy:args=status | check | run [--target FILE] | verify +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# GENERATED from migrate/src by migrate/build: edit the sources there, then run +# migrate/build. One self-contained file, so it runs the same from a quattro +# checkout, from omarchy-mx-mac's final release and as a downloaded release +# asset, and needs no migration code in any package. +# +# It moves an Apple Silicon Mac running an Omarchy fork (omarchy-mac quattro, +# omarchy-mx-mac, a quattro-upstream test image) onto the official packages of +# the channel it follows: the Omarchy runtime pair from pkgs.omarchy.org (the +# omarchy-dev pair on edge), omarchy-mac and omarchy-mac-boot, and the Aurora +# boot chain, under the core Apple Silicon pacman configuration. A channel +# whose repository has no qualified Mac packages yet defers (75) with nothing +# changed; so does anything preflight refuses. +# +# status what this Mac's migration is doing +# check preflight only: says what run would do, changes nothing +# run migrate, or resume a migration cut short +# verify after the reboot: verify the new boot chain and finish +# +# Exit 0: migrated (or waiting for its reboot), or nothing to migrate. Exit 75: +# deferred, nothing changed. Any other status: a step failed part way; running +# it again resumes. +# +# Root starts over in an empty environment with a fixed PATH and reads only the +# live system. Unprivileged tests name a fixture root in +# OMARCHY_MAC_MIGRATE_ROOT. + +if (( EUID == 0 )) && [[ ${1:-} != "--clean-environment" ]]; then + exec /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/bin HOME=/root /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@" +fi +[[ ${1:-} != "--clean-environment" ]] || shift + +set -euo pipefail + +# shellcheck disable=SC2034 # R, fixture and self are the engine's inputs +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin + R="" + fixture=0 +else + R=${OMARCHY_MAC_MIGRATE_ROOT:-} + if [[ $R != /?* ]]; then + echo "omarchy-mac-migrate: run it as root: sudo omarchy-mac-migrate ${*:-status}" >&2 + exit 1 + fi + R=${R%/} + fixture=1 +fi +self=$(realpath -- "${BASH_SOURCE[0]}") diff --git a/migrate/src/payload.sh b/migrate/src/payload.sh new file mode 100644 index 00000000000..22b6cf0e99f --- /dev/null +++ b/migrate/src/payload.sh @@ -0,0 +1,56 @@ +# The boot tools preflight judges this Mac with: the boot check, the ESP +# finder and the HOOKS composer of the omarchy-mac-boot the transaction will +# install. A Mac on a fork has none it can trust, or older ones, so preflight +# takes them from that package's verified archive, unpacked where only root can +# write, and puts them first on its PATH. Nothing of the package is installed +# here and nothing in it runs but those read-only checks; after the +# transaction the installed package's own commands are used. +# shellcheck disable=SC2154 + +# The verified archive of NAME as the target resolves it, downloaded once into +# the work cache: a candidate set's from the verified copy of the set, a +# repository's by pacman, which checks its signature against the keyring copy +# that trusts the target's key (and no retired one). Prints its path, or why +# there is none. +fetch_archive() { + local resolved=$1 wanted=$2 conf=$3 db=$4 name version file archive="" + read -r name version < <(awk -v wanted="$wanted" '{ n = $1; sub(/^[^\/]*\//, "", n) } n == wanted { print $1, $2; exit }' "$resolved") + [[ -n $name ]] || { echo "the target installs no $wanted"; return 1; } + if [[ $name == "$candidate_repo/"* ]]; then + file=$(jq -r --arg name "$wanted" --arg version "$version" '.packages[] | select(.name == $name and .version == $version) | .filename' "$target_set/manifest.json") + [[ -n $file && -f $target_set/$file ]] && archive=$target_set/$file + else + install -d -m 755 "$work/archives" + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + if [[ -z $archive ]]; then + if ! pacman_run --config "$conf" --dbpath "$db" --cachedir "$work/archives" --logfile "$work/pacman.log" \ + -Swdd --noconfirm --ask 4 "$name" >"$work/download.log" 2>&1; then + echo "cannot download and verify $wanted $version: $(tail -n 1 "$work/download.log")" + return 1 + fi + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + fi + fi + [[ -n $archive ]] || { echo "the archive of $wanted $version is missing"; return 1; } + printf '%s\n' "$archive" +} + +# Unpacks the verified archive of the resolved omarchy-mac-boot into DIR, where +# only root can write, and prints its version. +fetch_payload() { + local resolved=$1 conf=$2 db=$3 dir=$4 archive version + archive=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db") || { echo "$archive"; return 1; } + version=$(awk '{ n = $1; sub(/^[^\/]*\//, "", n) } n == "omarchy-mac-boot" { print $2; exit }' "$resolved") + rm -rf "$dir" + install -d -m 700 "$dir" + bsdtar -xpf "$archive" -C "$dir" 2>/dev/null || { echo "cannot unpack omarchy-mac-boot $version"; return 1; } + [[ $(sed -n 's/^pkgname = //p' "$dir/.PKGINFO") == "omarchy-mac-boot" && $(sed -n 's/^pkgver = //p' "$dir/.PKGINFO") == "$version" ]] || + { echo "the archive is not omarchy-mac-boot $version"; return 1; } + [[ -z $(find "$dir" ! -type l \( ! -uid "$EUID" -o -perm /022 \) -print -quit) ]] || + { echo "the unpacked omarchy-mac-boot is writable by others"; return 1; } + printf '%s\n' "$version" +} diff --git a/migrate/src/repairs.sh b/migrate/src/repairs.sh new file mode 100644 index 00000000000..e7b3e1ac79b --- /dev/null +++ b/migrate/src/repairs.sh @@ -0,0 +1,379 @@ +# Official migrations a migrated Mac records as done, and the repairs a fresh +# image does not need. +# shellcheck disable=SC2154 + +# Official migrations a migrated Mac records as done instead of running them, +# as a fresh Mac image has them (reviewed for ticket 53): initramfs and +# boot-chain repairs for the x86 Limine, T2, NVIDIA and linux-omarchy paths, +# whose Mac counterparts are this package's; the Intel Mac Broadcom quirk, which breaks +# Apple Silicon Wi-Fi; and systemd-oomd, which stays off on Macs. Every other official +# migration still pending runs on the next omarchy update, as on any install +# that upgraded. An adapter adds what its cohort already applied, and each +# repair below adds the Mac migration whose work it did. +settled_migrations="1784476564 1784917531 1785273276 1785424256 1785944594 1786137597 1786391100 1786482992 1786605598 1789325478 1789444024" +repaired=$state/repaired +repaired_migrations=() + +# The migrations USER records as done: the common ones, the repairs this +# migration made and the cohort's, comma-separated. +settled_for() { + local dir=$R$2/.local/state/omarchy/migrations + { printf '%s\n' $settled_migrations; cat "$repaired" 2>/dev/null; adapter_hook settled "$dir"; } | awk 'NF' | paste -sd, +} + +# Records the migrations NAMES lists (comma-separated) as done for USER, as +# the user, where they are not recorded yet. +settle_migrations() { + local user=$1 home=$2 names=$3 dir=$R$2/.local/state/omarchy/migrations name + as_user "$user" "$R$home" mkdir -p "$dir" || return 1 + for name in ${names//,/ }; do + [[ -e $dir/$name.sh ]] || as_user "$user" "$R$home" touch "$dir/$name.sh" || return 1 + done +} + +# --- Repairs a fresh image does not need ---------------------------------------- +# +# Macs set up before the runtime or its images carried a fix got it from a +# migration of the runtime they ran. Upstream Omarchy carries none of those +# migrations, so the engine does their work here, as root, for every cohort. +# Each repair can run again from its start and fails the step when it cannot +# finish; a later run repeats it. One that did its work, or found none to do, +# records its migration as done for every user. The target's runtime carries +# the leaves they run (install/config/snapper.sh and locale.sh) and its +# omarchy-mac the keyboard handover; a target +# without one is reported, and that migration is left to the runtime. + +runtime_leaf_present() { + [[ -f $R/usr/share/omarchy/$1 ]] +} + +# A runtime leaf, run whole in a strict shell as the runtime's migrations run them. +run_runtime_leaf() { + local leaf=$R/usr/share/omarchy/$1 + shift + env OMARCHY_PATH="$R/usr/share/omarchy" "$@" bash -euo pipefail "$leaf" +} + +# Snapper's root configuration (migration 1789148088): the asahi-overlay +# install skipped it. The leaf skips a root that is not btrfs; 3 means it +# found a layout it will not touch, left for manual repair, which is final. +repair_snapper() { + local status=0 + if ! runtime_leaf_present install/config/snapper.sh; then + say "This Omarchy has no Snapper setup leaf: the root's Snapper configuration was not checked" + return 0 + fi + run_runtime_leaf install/config/snapper.sh >/dev/null || status=$? + case $status in + 0) repaired_migrations+=(1789148088) ;; + 3) + say "The existing Snapper configuration was left for manual repair" + repaired_migrations+=(1789148088) + ;; + *) die "cannot set up Snapper for the root filesystem" ;; + esac +} + +# Asahi ALARM's bootstrap administrator (migration 1789158179): polkit asks +# for alarm's password while it stays in wheel. It leaves wheel only when +# another existing account is in wheel. Where alarm is itself an Omarchy user, +# the engine leaves the decision to that migration, which skips only alarm's +# own run. +repair_bootstrap_admin() { + local members member others=0 + members=$(awk -F: '$1 == "wheel" { print $4 }' "$R/etc/group" 2>/dev/null) || members="" + if omarchy_users | awk '{ print $1 }' | grep -Fxq alarm; then + say "alarm uses Omarchy here: its wheel membership is left to the runtime's migration" + else + if [[ ,$members, == *,alarm,* ]]; then + IFS=, read -ra members <<<"$members" + for member in "${members[@]}"; do + if [[ -n $member && $member != "alarm" ]] && awk -F: -v user="$member" '$1 == user { found = 1 } END { exit !found }' "$R/etc/passwd"; then + others=1 + fi + done + if (( others )); then + say "Removing Asahi's bootstrap account alarm from wheel" + gpasswd -d alarm wheel >/dev/null || die "cannot remove alarm from wheel" + fi + fi + repaired_migrations+=(1789158179) + fi +} + +# The Intel Mac Broadcom quirk (migration 1789172112): an older runtime wrote +# it on Apple Silicon too, where it breaks the WPA handshake. Only the exact +# block it wrote goes, and what the file held before it stays. The migration +# also required the Wi-Fi chip's PCI ID; on Apple Silicon the block does harm +# whichever chip carries it, so the engine does not. The rebuild it owes is +# recorded first, under the migration's own marker, so an interrupted run of +# either finishes it. +repair_broadcom_block() { + local conf=$R/etc/modprobe.d/brcmfmac.conf pending=$R/var/lib/omarchy/migrations/1789172112-initramfs-pending + local block content rest file + block="# Broadcom's firmware supplicant and authenticator fail the WPA four-way +# handshake on Apple hardware, which surfaces as a rejected password. Disable +# both so wpa_supplicant performs the handshake instead. +options brcmfmac feature_disable=0x82000" + if [[ -f $conf ]]; then + content=$(<"$conf") + if [[ $content == "$block" || $content == *$'\n'"$block" ]]; then + say "Removing the Intel Mac Broadcom quirk from $conf" + install -D -m 644 /dev/null "$pending" && sync "$pending" "$(dirname "$pending")" || + die "cannot record the initramfs rebuild the Broadcom repair needs" + interrupt_for_test mid broadcom + rest=${content%"$block"} + rest=${rest%$'\n'} + if [[ -z $rest && ! -L $conf ]]; then + rm -f -- "$conf" && sync "$(dirname "$conf")" + else + # A link keeps pointing where it did: its target is rewritten. + file=$(readlink -f -- "$conf") || die "cannot resolve $conf" + if [[ -n $rest ]]; then + printf '%s\n' "$rest" + fi | durable_write "$file" + fi || die "cannot remove the Broadcom quirk from $conf" + fi + fi + interrupt_for_test mid broadcom-rebuild + if [[ -f $pending ]]; then + omarchy-mac-boot-update >/dev/null || die "cannot rebuild the boot image without the Broadcom quirk" + rm -f "$pending" + fi + repaired_migrations+=(1789172112) +} + +# A UTF-8 locale (migration 1789146110): Asahi ALARM ships LANG=C. The leaf +# changes only an unset LANG, C or POSIX. +repair_locale() { + if ! runtime_leaf_present install/config/locale.sh; then + say "This Omarchy has no locale setup leaf: the locale was not checked" + return 0 + fi + run_runtime_leaf install/config/locale.sh OMARCHY_LOCALE_CONF="$R/etc/locale.conf" OMARCHY_LOCALE_GEN="$R/etc/locale.gen" >/dev/null || + die "cannot set up the UTF-8 locale" + repaired_migrations+=(1789146110) +} + +# The keyboard's function-key mode (migration 1790327324), handed to +# omarchy-mac. The line Omarchy generated here depends on the fork the Mac +# came from: fnmode=2 from the install leaf, replaced once by mx-mac +# (1790305681, fnmode=3) or quattro-upstream (1789132067, fnmode=1), as any +# of its users' migration records say, mx-mac first as in that migration. +# omarchy-mac-setup-keyboard decides once, and a fork rebuild still owed +# overrides this. +repair_keyboard_mode() { + local generated=2 user home dir + if ! command -v omarchy-mac-setup-keyboard >/dev/null; then + say "This omarchy-mac has no omarchy-mac-setup-keyboard: the keyboard mode was not handed over" + return 0 + fi + while read -r user home; do + [[ -n $user ]] || continue + dir=$R$home/.local/state/omarchy/migrations + if [[ -f $dir/1790305681.sh ]]; then + generated=3 + elif [[ -f $dir/1789132067.sh && $generated == 2 ]]; then + generated=1 + fi + done < <(omarchy_users) + env OMARCHY_MAC_FIXTURE_ROOT="$R" omarchy-mac-setup-keyboard "$generated" >/dev/null || + die "cannot hand the keyboard's function-key mode to omarchy-mac" + repaired_migrations+=(1790327324) +} + +repair_system() { + local output + repaired_migrations=() + repair_snapper + repair_bootstrap_admin + repair_broadcom_block + repair_locale + repair_keyboard_mode + # The Broadcom and keyboard repairs can rebuild the UKI. + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the repairs: $(tail -n 1 <<<"$output")" + printf '%s\n' "${repaired_migrations[@]}" | durable_write "$repaired" || die "cannot record the repairs made" +} + +# --- Fork leftovers -------------------------------------------------------------- +# +# Earlier Apple installs and omarchy-mx-mac wrote these files, which the Mac +# packages now ship as vendor defaults (omarchy-mac retired them itself until +# omacom/omarchy-mac-pkgs da8279b handed that to this migration). A copy that +# is byte for byte the one they wrote goes, kept beside itself as +# NAME.omarchy-mac-retired; an edited copy, a link (a mask included) or a +# different backup stays as it is. + +# The bytes a fork wrote as NAME. +leftover() { + case $1 in + wifi_backend.conf) + cat <<'LEFTOVER' +[device] +wifi.backend=iwd +LEFTOVER + ;; + asahi-notch.conf) + cat <<'LEFTOVER' +options appledrm show_notch=1 +LEFTOVER + ;; + omarchy-wifi-resume-fix.service) + cat <<'LEFTOVER' +[Unit] +Description=Reload brcmfmac if Wi-Fi does not return after resume +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +After=NetworkManager.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-wifi-resume-fix +TimeoutStartSec=120 + +[Install] +WantedBy=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +LEFTOVER + ;; + asahi-headset-mic.conf) + cat <<'LEFTOVER' +# The 3.5mm headset mic stays in the source list with nothing plugged in. +# Apps often pick it over the built-in array because it advertises a MONO map. +monitor.alsa.rules = [ + { + matches = [ + { node.name = "alsa_input.platform-sound.HiFi__Headset__source" } + ] + actions = { + update-props = { + priority.session = 1 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend-overlay.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## The companion software-dsp.lua overlay also stops the asahi-audio convolver +## graph from pausing when a client (Chromium, mpv, ...) closes its stream. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + *) return 1 ;; + esac +} + +# Writes every leftover into DIR, readable by every user. +write_leftovers() { + local dir=$1 name + install -d -m 755 "$dir" || return 1 + for name in wifi_backend.conf asahi-notch.conf omarchy-wifi-resume-fix.service asahi-headset-mic.conf asahi-audio-no-suspend.conf asahi-audio-no-suspend-overlay.conf; do + leftover "$name" >"$dir/$name" && chmod 644 "$dir/$name" || return 1 + done +} + +# FILE goes when it is the regular file ORIGINAL holds byte for byte. Fails +# when a backup that differs is in the way. +retire_copy() { + local file=$1 original=$2 + [[ -f $file && ! -L $file ]] && cmp -s "$file" "$original" || return 0 + if [[ -e $file.omarchy-mac-retired || -L $file.omarchy-mac-retired ]]; then + if ! cmp -s "$file" "$file.omarchy-mac-retired"; then + echo "$file.omarchy-mac-retired differs from $file; move it aside and run the migration again" >&2 + return 1 + fi + rm -- "$file" + else + mv -- "$file" "$file.omarchy-mac-retired" + fi +} + +# The machine's leftovers: the Wi-Fi backend and notch settings, and the Wi-Fi +# resume unit, whose enablement links into /etc are pointed at the vendor unit +# omarchy-mac ships. +retire_system_leftovers() { + local dir=$state/leftovers unit=omarchy-wifi-resume-fix.service target link + write_leftovers "$dir" || die "cannot stage the fork's leftover files" + retire_copy "$R/etc/NetworkManager/conf.d/wifi_backend.conf" "$dir/wifi_backend.conf" && + retire_copy "$R/etc/modprobe.d/asahi-notch.conf" "$dir/asahi-notch.conf" && + retire_copy "$R/etc/systemd/system/$unit" "$dir/$unit" || die "cannot retire the fork's leftover files" + if [[ ! -e $R/etc/systemd/system/$unit && ! -L $R/etc/systemd/system/$unit ]] && + cmp -s "$R/etc/systemd/system/$unit.omarchy-mac-retired" "$dir/$unit"; then + for target in suspend hibernate hybrid-sleep suspend-then-hibernate; do + link=$R/etc/systemd/system/$target.target.wants/$unit + if [[ -L $link && $(readlink "$link") == "/etc/systemd/system/$unit" ]]; then + ln -sfn "/usr/lib/systemd/system/$unit" "$link" || die "cannot point $link at the vendor unit" + fi + done + fi +} + +# A user's leftovers: the WirePlumber policies the fork copied into each +# user's configuration, retired as that user. +retire_user_leftovers() { + local user=$1 home=$2 dir=$state/leftovers policies=$R$2/.config/wireplumber/wireplumber.conf.d + [[ -d $policies && ! -L $policies ]] || return 0 + [[ -d $dir ]] || write_leftovers "$dir" || return 1 + # shellcheck disable=SC2016 # expanded by the user's shell + as_user "$user" "$R$home" bash -c "$(declare -f retire_copy)"' + retire_copy "$1/asahi-headset-mic.conf" "$2/asahi-headset-mic.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend-overlay.conf"' _ "$policies" "$dir" +} diff --git a/migrate/src/target.sh b/migrate/src/target.sh new file mode 100644 index 00000000000..252176998b1 --- /dev/null +++ b/migrate/src/target.sh @@ -0,0 +1,380 @@ +# The target: which channel this Mac moves to, the packages and pacman +# configuration it ends with, and whether that channel is ready for Macs. +# shellcheck disable=SC2034,SC2154 + +# The Omarchy packaging key omarchy-keyring carries. +official_key=40DFB630FF42BCFFB047046CF0134EE680CAC571 +# Trust the converged system never keeps: the forks' repositories and keys +# (omarchy-mac's rc4 channel key and mx-mac's). Adapters add their own. +retired_repos=(omarchy-aarch64) +retired_keys=(FBD6874D423C418DDB6D143EECE19CDDE306DBD2 C81AC3E2A99556F9B21D5FEA3DD49BC9F8360BDC) +# The repositories the core configuration defines; any other one is the +# administrator's and is kept. +core_repos="omarchy asahi-alarm core extra alarm aur" +candidate_repo=omarchy-mac-candidate +admin_target=$R/etc/omarchy-mac/migration-target +# The image builder's test-image pin (omarchy-mac-installer +# image-builder/builder/test_image_pin.py): its first line, a reason line and +# the IgnorePkg line. +test_pin_mark="# Test image only (omarchy-mac-installer image-builder)" +guard_mark="# omarchy-mac-migrate: a migration to Omarchy's packages is in progress." + +# The runtime pair a channel's Macs run: Omarchy's edge builds its runtime from +# the development branch as omarchy-dev. +channel_pair() { + if [[ $1 == "edge" ]]; then + echo "omarchy-dev omarchy-settings-dev" + else + echo "omarchy omarchy-settings" + fi +} + +# Every package a migrated Mac takes from its channel's [omarchy]. +channel_packages() { + echo "$(channel_pair "$1") omarchy-mac omarchy-mac-boot linux-aurora linux-aurora-headers m1n1-aurora uboot-asahi limine-mkinitcpio-hook" +} + +# Installed or refreshed in the same transaction, from whichever repository +# carries them: the keyrings official trust comes from. +keyring_packages="asahi-alarm-keyring omarchy-keyring" + +# Held back with the transaction's packages while the migration is in +# progress: the rest of the boot chain they build on. +guarded_boot="limine limine-snapper-sync asahi-scripts mkinitcpio" + +# key=value lines, comments and blank lines ignored, anything else refused. +# format=1 +# type=repository | candidate-set +# channel=stable | rc | edge the [omarchy] channel after the switch +# server=URL optional; https://pkgs.omarchy.org//$arch +# keyring=FINGERPRINT optional; the Omarchy packaging key +# packages=NAME... repository only; optional +# set=DIR candidate-set: the set's files, manifest.json and signing.json +# fingerprint=FINGERPRINT candidate-set: the only key its signatures may carry +load_target() { + local file=$1 frozen=${2:-} line key value format="" packages="" + target_type="" target_channel="" target_server="" target_keyring=$official_key + target_set="" target_fingerprint="" target_repo=omarchy + trusted "$file" || refuse "refusing the target $file: it must be a regular file owned by root and writable only by root" + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + [[ $line == *=* ]] || refuse "the target $file is malformed: $line" + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + type) target_type=$value ;; + channel) target_channel=$value ;; + server) target_server=$value ;; + keyring) target_keyring=${value^^} ;; + packages) packages=$value ;; + set) target_set=${value%/} ;; + fingerprint) target_fingerprint=${value^^} ;; + *) refuse "the target $file has an unknown key: $key" ;; + esac + done <"$file" + [[ $format == "1" ]] || refuse "the target $file is not format=1" + [[ $target_channel =~ ^(stable|rc|edge)$ ]] || refuse "the target $file names no channel (stable, rc or edge)" + if [[ -z $target_server ]]; then + target_server="https://pkgs.omarchy.org/$target_channel/\$arch" + # Unprivileged tests serve the channels themselves. + if (( fixture )) && [[ -n ${OMARCHY_MAC_MIGRATE_SERVER:-} ]]; then + target_server=${OMARCHY_MAC_MIGRATE_SERVER//@channel@/$target_channel} + fi + fi + [[ $target_server =~ ^(https|file):// ]] || refuse "the target server must be https:// or file://: $target_server" + [[ $target_keyring =~ ^[0-9A-F]{40}$ ]] || refuse "the target keyring must be a 40-digit fingerprint" + target_packages=${packages:-$(channel_packages "$target_channel")} + case $target_type in + repository) + target_id="repository $target_server" + ;; + candidate-set) + [[ $target_fingerprint =~ ^[0-9A-F]{40}$ ]] || refuse "a candidate-set target needs its signer's 40-digit fingerprint" + target_repo=$candidate_repo + if [[ -n $frozen ]]; then + # After preflight only the verified copy counts; the original may be gone. + target_set=$set_copy + else + [[ $target_set == /* ]] && trusted "$target_set" || refuse "the candidate set $target_set must be a root-owned directory writable only by root" + [[ -f $target_set/manifest.json ]] || refuse "the candidate set has no manifest.json" + fi + candidate_identity "$target_set" || refuse "cannot read the candidate manifest" + ;; + *) + refuse "the target $file has no type (repository or candidate-set)" + ;; + esac +} + +# A candidate set's packages join the channel's: what the set carries comes +# from it, the rest of the Mac set from the channel's [omarchy]. +candidate_identity() { + local names + names=$(jq -r '.packages[].name' "$1/manifest.json") || return 1 + target_packages=$( { printf '%s\n' $(channel_packages "$target_channel"); printf '%s\n' "$names"; } | awk '!seen[$0]++' | xargs) && + target_id="candidate-set $(jq -r '.set' "$1/manifest.json") $(jq -r '.set_sha256' "$1/manifest.json")" +} + +# How the transaction names NAME: from the candidate set when it carries it, +# else from [omarchy]. +target_spec() { + if [[ $target_type == "candidate-set" ]] && jq -e --arg name "$1" '.packages[] | select(.name == $name)' "$target_set/manifest.json" >/dev/null; then + printf '%s/%s\n' "$candidate_repo" "$1" + else + printf 'omarchy/%s\n' "$1" + fi +} + +# --target, else the administrator's target. +find_target() { + local candidate + for candidate in "$@" "$admin_target"; do + if [[ -n $candidate && -e $candidate ]]; then + printf '%s\n' "$candidate" + return + fi + done +} + +# The channel this Mac's own configuration follows: an omarchy-mac lane +# ([omarchy-aarch64] on omarchy-mac/omarchy-pkgs-aarch64's releases, which +# quattro names after the channel), else an official [omarchy]. Anything else +# is unknown. +config_channel() { + local conf=$1 lane official + lane=$(section_servers "$conf" omarchy-aarch64 | sed -nE 's#^https://github\.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/(stable|rc|edge)/?$#\1#p' | sort -u) + official=$(section_servers "$conf" omarchy | sed -nE 's#^https://pkgs\.omarchy\.org/(stable|rc|edge)/(\$arch|aarch64)/?$#\1#p' | sort -u) + if [[ -n $(section_servers "$conf" omarchy-aarch64) ]]; then + [[ -n $lane && $lane != *$'\n'* ]] && printf '%s\n' "$lane" + elif [[ -n $official && $official != *$'\n'* ]]; then + printf '%s\n' "$official" + else + return 1 + fi +} + +# The Server values of SECTION in CONF. +section_servers() { + awk -v want="$2" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == want && /^[[:space:]]*Server[[:space:]]*=/ { value = $0; sub(/^[^=]*=[[:space:]]*/, "", value); sub(/[[:space:]]+$/, "", value); print value }' "$1" +} + +# The channel this Mac follows, or nothing when it cannot be told. An mx-mac +# Mac follows the fork's channel record; the rest follow their configuration. +detect_channel() { + local cohort=$1 conf=$2 channel="" + if [[ $cohort == "mx-mac" ]]; then + if command -v omarchy-apple-silicon-channel >/dev/null; then + channel=$(omarchy-apple-silicon-channel current 2>/dev/null) || channel="" + fi + else + channel=$(config_channel "$conf") || channel="" + fi + [[ $channel =~ ^(stable|rc|edge)$ ]] && printf '%s\n' "$channel" +} + +# A repository target for CHANNEL, written to FILE: what a Mac with no +# administrator's target moves to. +write_channel_target() { + printf 'format=1\ntype=repository\nchannel=%s\n' "$1" >"$2" + chmod 644 "$2" +} + +# The core Apple Silicon configuration (omacom/omarchy #13362, +# default/pacman/apple-silicon/pacman-edge.conf), with SERVER for [omarchy]. +# Unprivileged tests name their own Asahi ALARM server. +core_pacman_conf() { + local asahi=https://github.com/asahi-alarm/asahi-alarm/releases/download/aarch64 + (( ! fixture )) || asahi=${OMARCHY_MAC_MIGRATE_ASAHI_SERVER:-$asahi} + cat < 0 && /^#/ { skip--; next } + skip > 0 && /^[[:space:]]*IgnorePkg[[:space:]]*=/ { skip = 0; next } + { skip = 0 } + /^[[:space:]]*(#|$)/ { next } + { key = $0; sub(/^[[:space:]]*/, "", key); sub(/[[:space:]]*=.*$/, "", key); sub(/[[:space:]]+$/, "", key); if (!(key in core)) print }' "$1" +} + +# The test-image pin block of CONF, as it is written. +test_pin_block() { + awk -v pin="$test_pin_mark" ' + index($0, pin) == 1 { keep = 3 } + keep > 0 { print; keep-- }' "$1" +} + +# CONF's repositories that are neither the core ones nor retired, whole. +admin_repositories() { + local drop + drop="$core_repos ${retired_repos[*]} $candidate_repo" + awk -v drop="$drop" ' + BEGIN { n = split(drop, list, " "); for (i = 1; i <= n; i++) skip[list[i]] = 1; skip["options"] = 1 } + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); keep = !(name in skip) } + keep { print }' "$1" +} + +# The configuration after the switch: the core one for the target, with the +# administrator's own options and repositories kept. Applying it to its own +# output changes nothing. +future_pacman_conf() { + local conf=$1 options repositories + options=$(admin_options "$conf") + repositories=$(admin_repositories "$conf") + core_pacman_conf "$target_server" | awk -v options="$options" ' + { print } + /^LocalFileSigLevel/ && options != "" { print ""; print "# Kept from this Mac'"'"'s configuration"; print options }' + if [[ -n $repositories ]]; then + printf '\n%s\n' "$repositories" + fi +} + +# CONF with this migration's guard as the first lines of [options], and the +# test-image pin of OLD kept below it: until the package transaction is done, +# a plain pacman -Syu leaves every package the migration changes alone. +guarded_pacman_conf() { + local conf=$1 old=$2 names=$3 pin + pin=$(test_pin_block "$old") + awk -v guard="$guard_mark" -v names="$names" -v pin="$pin" ' + { print } + /^\[options\][[:space:]]*$/ && !done { + print guard " It removes these lines when its package transaction is done; sudo omarchy-mac-migrate run finishes it." + print "IgnorePkg = " names + if (pin != "") print pin + done = 1 + }' "$conf" +} + +# Administrator IgnorePkg entries (globs, as pacman reads them) matching a +# package this migration installs or removes, and IgnoreGroup entries holding +# one, one per line. +pinned_targets() { + local conf=$1 names=$2 db=$3 pattern name group member + for pattern in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnorePkg[[:space:]]*=//p'); do + for name in $names; do + # shellcheck disable=SC2053 # IgnorePkg takes globs + [[ $name != $pattern ]] || printf '%s\n' "$name" + done + done + for group in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnoreGroup[[:space:]]*=//p'); do + for member in $(LC_ALL=C pacman --config "$4" --dbpath "$db" -Sgq "$group" 2>/dev/null); do + [[ " $names " != *" $member "* ]] || printf '%s (group %s)\n' "$member" "$group" + done + done +} + +# An Include in [options] or an option the switch cannot keep as it is. +unsupported_options() { + awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "options" && /^[[:space:]]*Include[[:space:]]*=/ { print "an Include in [options] (" $0 ")" }' "$1" +} + +# --- Whether the channel is ready for Macs ----------------------------------- +# +# A channel takes Macs once its [omarchy] carries the Mac packages built from +# omacom/omarchy-mac-pkgs with a runtime that drives them. That is read from +# the signed archives the transaction would install, not from repository +# metadata: the runtime ships the lifecycle dispatcher, and omarchy-mac-boot +# ships its setup-boot and update-verify operations and no migration engine of +# its own. Until then every Mac on the channel defers. + +# The Mac packages the channel's repositories lack, one reason a line. +presence_problems() { + local listing name + listing=$(LC_ALL=C pacman --config "$1" --dbpath "$2" -Sl 2>/dev/null | awk '{ print $2 }' | LC_ALL=C sort -u) + for name in $(channel_pair "$target_channel") omarchy-mac omarchy-mac-boot linux-aurora m1n1-aurora uboot-asahi; do + grep -Fxq "$name" <<<"$listing" || echo "the $target_channel channel has no $name for Apple Silicon yet" + done +} + +# What the verified archives of the resolved runtime and omarchy-mac-boot +# lack, one reason a line. +archive_problems() { + local resolved=$1 conf=$2 db=$3 runtime listing + runtime=$(channel_pair "$target_channel") + runtime=${runtime%% *} + if listing=$(fetch_archive "$resolved" "$runtime" "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/bin/omarchy-lifecycle-dispatch' <<<"$listing" || + echo "the $target_channel channel's $runtime has no omarchy-lifecycle-dispatch to drive the Mac packages yet" + excluded_files "$listing" + else + echo "$listing" + fi + if listing=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/lib/omarchy/mac-boot/setup-boot' <<<"$listing" && grep -qx 'usr/lib/omarchy/mac-boot/update-verify' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot has no setup-boot and update-verify operations yet" + ! grep -qx 'usr/lib/omarchy-mac/boot/migrate-engine.sh' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot is not built from omacom/omarchy-mac-pkgs yet" + excluded_files "$listing" + else + echo "$listing" + fi +} + +# The administrator's NoExtract and NoUpgrade globs that keep a file of the +# migration's runtime or boot package (LISTING) from being installed as built. +excluded_files() { + local listing=$1 pattern path + while read -r pattern; do + [[ -n $pattern && $pattern != !* ]] || continue + while IFS= read -r path; do + [[ -n $path && $path != */ ]] || continue + # shellcheck disable=SC2053 # NoExtract and NoUpgrade take globs + if [[ $path == $pattern ]]; then + echo "NoExtract or NoUpgrade ($pattern) in $pacman_conf keeps $path from the packages the migration installs; remove it first" + break + fi + done <<<"$listing" + done < <(admin_options "$pacman_conf" | sed -nE 's/^[[:space:]]*(NoExtract|NoUpgrade)[[:space:]]*=[[:space:]]*//p' | tr ' ' '\n') +} diff --git a/migrate/src/users.sh b/migrate/src/users.sh new file mode 100644 index 00000000000..c5de340c4e0 --- /dev/null +++ b/migrate/src/users.sh @@ -0,0 +1,148 @@ +# What a fresh install sets up, done for a migrated Mac: its default packages, +# the Mac services, the repairs and, for every Omarchy user, the settled +# migrations, the units first run enables and the user setup. +# shellcheck disable=SC2154 + +# The default packages the aarch64 and Apple lists add, where they are missing +# and a repository carries them (the base list's applications stay the owner's +# choice). Firmware among them rebuilds the initramfs and the UKI through +# pacman's hooks, so the boot files are checked again. +install_defaults() { + local generic apple available name missing=() absent=() output + if ! command -v omarchy-pkg-defaults >/dev/null; then + say "This Omarchy has no omarchy-pkg-defaults: the default packages were not checked" + return 0 + fi + generic=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults generic) && + apple=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults apple-silicon) || + die "cannot read the Apple Silicon default packages" + available=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl | awk '{ print $2 }') || + die "cannot read the repositories' packages" + while read -r name; do + [[ -n $name ]] && ! grep -Fxq -- "$name" <<<"$generic" || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qq "$name" >/dev/null 2>&1 && continue + if grep -Fxq -- "$name" <<<"$available"; then + missing+=("$name") + else + absent+=("$name") + fi + done <<<"$apple" + (( ${#absent[@]} == 0 )) || say "No repository carries these default packages, so they stay missing: ${absent[*]}" + (( ${#missing[@]} )) || return 0 + say "Installing the default packages a fresh install has: ${missing[*]}" + pacman_run --config "$pacman_conf" --dbpath "$pacman_db" -S --noconfirm "${missing[@]}" || + die "cannot install the default packages: ${missing[*]}" + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the default packages: $(tail -n 1 <<<"$output")" +} + +# --- User setup ------------------------------------------------------------------ +# +# Each Omarchy user gets the settled migrations, the units first run enables +# and the Mac user setup. What fails for one user (a broken home, a setup that +# exits nonzero) never stops the migration: it is kept in user-pending, a +# "user item" line each, and runs again at every later run and boot until it +# succeeds. The post-reboot unit stays enabled for that. + +# One item of a user's setup: settle:NAMES, a unit first run enables, +# retire-leftovers or setup-user. A pending settle keeps the names it was +# given, so a retry after the plan moved on records the same ones. +apply_user_item() { + local user=$1 home=$2 item=$3 + case $item in + settle:*) settle_migrations "$user" "$home" "${item#settle:}" ;; + retire-leftovers) retire_user_leftovers "$user" "$home" ;; + setup-user) as_user "$user" "$R$home" omarchy-lifecycle-dispatch setup-user >/dev/null ;; + *) enable_user_unit "$user" "$home" "$item" ;; + esac +} + +# The user's setup; prints what failed, one item a line. +setup_user() { + local user=$1 home=$2 item items=("settle:$(settled_for "$user" "$home")") + if [[ -f $plan/user-units ]]; then + for item in $fresh_user_units; do + grep -Fxq "$item" "$plan/user-units" || items+=("$item") + done + fi + for item in "${items[@]}" retire-leftovers setup-user; do + apply_user_item "$user" "$home" "$item" || printf '%s\n' "$item" + done +} + +# Replaces the pending record with FILE's lines, or removes it when FILE is empty. +record_user_pending() { + if [[ -s $1 ]]; then + LC_ALL=C sort -u "$1" | durable_write "$user_pending" || die "cannot record the pending user setup" + else + rm -f "$user_pending" + fi +} + +# "user item; ..." for messages, a settle item without its names. +pending_summary() { + awk '{ item = $2; sub(/:.*/, "", item); print $1 " " item }' "$user_pending" | paste -sd';' | sed 's/;/; /g' +} + +# Runs the pending items again, only those, so nothing a user turned off since +# comes back. An account that is gone or no longer uses Omarchy is dropped. +# Fails while any item is still pending. +retry_user_pending() { + local user home item left + [[ -s $user_pending ]] || return 0 + rm -f "$state"/user-pending.?????? + left=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + while read -r user item; do + home=$(omarchy_users | awk -v user="$user" '$1 == user { print $2; exit }') + [[ -n $home && -n $item ]] || continue + apply_user_item "$user" "$home" "$item" >"$left" + done <"$user_pending" + record_user_pending "$left" + rm -f "$left" + if [[ -s $user_pending ]]; then + say "User setup still pending, retried at the next run or boot: $(pending_summary)" + return 1 + fi + say "The pending user setup is done" +} + +# Outside a completed migration's cleanup: pending user setup runs again, and +# once none is left the post-reboot unit is released unless a migration is +# waiting for its reboot. +retry_user_pending_now() { + [[ -s $user_pending ]] || return 0 + if retry_user_pending && [[ ! -e $reboot_pending ]]; then + release_verify_unit + fi +} + +# A migrated Mac ends as a fresh install does: with its default packages, the +# Mac services the image's hardware setup enables, the repairs above and, for +# every Omarchy user, the migrations a fresh image records as done, the units +# first run enables and the Mac user setup. A unit the Mac already had before +# the migration is taken to be off by choice and stays off; a plan frozen +# before that was recorded enables none. The reboot that follows brings up +# what probes only at boot, such as the video decoder. +step_defaults() { + local user home item pending + install_defaults + interrupt_for_test mid defaults + retire_system_leftovers + omarchy-lifecycle-dispatch setup-system >/dev/null || die "setup-system (omarchy-lifecycle-dispatch) could not set up the Mac's services" + repair_system + interrupt_for_test mid user-setup + # What an earlier migration left pending stays pending until it succeeds. + retry_user_pending || : + rm -f "$state"/user-pending.?????? + pending=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + [[ ! -f $user_pending ]] || cat "$user_pending" >"$pending" + while read -r user home; do + [[ -n $user ]] || continue + while read -r item; do + [[ -n $item ]] || continue + say "Could not apply $item for $user; it runs again after the reboot" + printf '%s %s\n' "$user" "$item" >>"$pending" + done < <(setup_user "$user" "$home" >"$fixture/pacman.log" +case $1 in + luksHeaderBackup) echo "LUKS header of $2" >"$4" ;; + luksUUID) cat "$fixture/luks-uuid" 2>/dev/null ;; +esac diff --git a/test/fixtures/mac-migrate/bin/df b/test/fixtures/mac-migrate/bin/df new file mode 100755 index 00000000000..bf143a3b366 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/df @@ -0,0 +1,3 @@ +#!/bin/bash +echo Avail +if [[ -e ${MIGRATE_FIXTURE:?}/df-low ]]; then echo 1000; else echo 999999999999; fi diff --git a/test/fixtures/mac-migrate/bin/findmnt b/test/fixtures/mac-migrate/bin/findmnt new file mode 100755 index 00000000000..0f0bc77e274 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/findmnt @@ -0,0 +1,7 @@ +#!/bin/bash +fixture=${MIGRATE_FIXTURE:?} +case "$*" in + *--mountpoint*) grep -Fxq "${!#}" "$fixture/mounts" ;; + *SOURCE*) cat "$fixture/root-source" ;; + *) exit 1 ;; +esac diff --git a/test/fixtures/mac-migrate/bin/gpasswd b/test/fixtures/mac-migrate/bin/gpasswd new file mode 100755 index 00000000000..1bd789f1e38 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/gpasswd @@ -0,0 +1,7 @@ +#!/bin/bash +# gpasswd -d USER GROUP: drops USER from GROUP's members in the fixture's /etc/group. +fixture=${MIGRATE_FIXTURE:?} +echo "gpasswd $*" >>"$fixture/boot.log" +[[ ! -e $fixture/gpasswd-fail && $1 == "-d" ]] || exit 1 +group=${OMARCHY_MAC_MIGRATE_ROOT:?}/etc/group +awk -F: -v OFS=: -v user="$2" -v name="$3" '$1 == name { n = split($4, m, ","); $4 = ""; for (i = 1; i <= n; i++) if (m[i] != user) $4 = $4 ($4 == "" ? "" : ",") m[i] } { print }' "$group" >"$group.new" && mv "$group.new" "$group" diff --git a/test/fixtures/mac-migrate/bin/gpg b/test/fixtures/mac-migrate/bin/gpg new file mode 100755 index 00000000000..e2fa9688926 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/gpg @@ -0,0 +1,19 @@ +#!/bin/bash +# Answers key listings for the fixture's pacman keyring from its keys file and +# runs the real gpg for everything else (candidate set verification). +set -euo pipefail +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +home="" +args=("$@") +for (( i = 0; i < ${#args[@]}; i++ )); do + [[ ${args[i]} != "--homedir" ]] || home=${args[i + 1]} +done +if [[ -n $home && -f $home/keys ]]; then + fpr=${!#} + line=$(grep "^$fpr " "$home/keys") || exit 2 + echo "pub:${line#* }:255:22:${fpr:24}:::::::scESC:" + echo "fpr:::::::::$fpr:" + exit 0 +fi +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec env PATH="$(tr ':' '\n' <<<"$PATH" | grep -vx "$here" | paste -sd:)" gpg "$@" diff --git a/test/fixtures/mac-migrate/bin/limine-update b/test/fixtures/mac-migrate/bin/limine-update new file mode 100755 index 00000000000..9892a4291a7 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/limine-update @@ -0,0 +1,7 @@ +#!/bin/bash +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +echo "limine-update" >>"${MIGRATE_FIXTURE:?}/boot.log" +mkdir -p "$root/boot/efi/EFI/Linux" +echo "uki" >"$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" +grep -Fq 'omarchy_linux-aurora.efi' "$root/boot/efi/limine.conf" 2>/dev/null || + echo " image_path: boot():/EFI/Linux/omarchy_linux-aurora.efi" >>"$root/boot/efi/limine.conf" diff --git a/test/fixtures/mac-migrate/bin/locale b/test/fixtures/mac-migrate/bin/locale new file mode 100755 index 00000000000..ee4f17e2281 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/locale @@ -0,0 +1,3 @@ +#!/bin/bash +# locale -a: the locales the fixture has generated. +cat "${MIGRATE_FIXTURE:?}/locales" 2>/dev/null || printf 'C\nPOSIX\n' diff --git a/test/fixtures/mac-migrate/bin/locale-gen b/test/fixtures/mac-migrate/bin/locale-gen new file mode 100755 index 00000000000..95e106bd4d8 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/locale-gen @@ -0,0 +1,4 @@ +#!/bin/bash +fixture=${MIGRATE_FIXTURE:?} +echo "locale-gen $*" >>"$fixture/boot.log" +[[ ! -e $fixture/locale-gen-fail ]] diff --git a/test/fixtures/mac-migrate/bin/log-command b/test/fixtures/mac-migrate/bin/log-command new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/log-command @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/lsblk b/test/fixtures/mac-migrate/bin/lsblk new file mode 100755 index 00000000000..f6b040051e4 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/lsblk @@ -0,0 +1,2 @@ +#!/bin/bash +cat "${MIGRATE_FIXTURE:?}/lsblk" diff --git a/test/fixtures/mac-migrate/bin/lsinitcpio b/test/fixtures/mac-migrate/bin/lsinitcpio new file mode 100755 index 00000000000..f5f827a51c4 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/lsinitcpio @@ -0,0 +1,4 @@ +#!/bin/bash +# lsinitcpio -l IMAGE: the fixture image is its own listing. +[[ ${1:-} == "-l" && -f ${2:-} ]] || exit 1 +grep -v '^kernel \|^HOOKS ' "$2" diff --git a/test/fixtures/mac-migrate/bin/mkinitcpio b/test/fixtures/mac-migrate/bin/mkinitcpio new file mode 100755 index 00000000000..0f157f83d87 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/mkinitcpio @@ -0,0 +1,27 @@ +#!/bin/bash +# mkinitcpio -p KERNEL (or -P for linux-aurora): writes /boot/initramfs-KERNEL.img +# as a listing of what the resolved HOOKS put in an image, and fails as the +# preset does without /boot/vmlinuz-KERNEL. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +echo "mkinitcpio $*" >>"$fixture/boot.log" +[[ ! -e $fixture/mkinitcpio-fail ]] || { echo "==> ERROR: mkinitcpio failed" >&2; exit 1; } +kernel=linux-aurora +[[ ${1:-} != "-p" ]] || kernel=$2 +[[ -f $root/boot/vmlinuz-$kernel ]] || { echo "==> ERROR: invalid kernel specified: '/boot/vmlinuz-$kernel'" >&2; exit 1; } +hooks=$(omarchy-mac-initramfs-hooks) || { echo "==> ERROR: cannot read the HOOKS" >&2; exit 1; } +{ + echo "kernel $(sha256sum <"$root/boot/vmlinuz-$kernel" | cut -d' ' -f1)" + echo "HOOKS $hooks" + if [[ " $hooks " == *" systemd "* ]]; then + echo usr/lib/systemd/systemd + if [[ " $hooks " == *" sd-encrypt "* ]]; then + echo usr/lib/systemd/system-generators/systemd-cryptsetup-generator + echo usr/bin/systemd-cryptsetup + fi + else + echo init_functions + for hook in $hooks; do echo "hooks/$hook"; done + fi +} >"$root/boot/initramfs-$kernel.img" diff --git a/test/fixtures/mac-migrate/bin/mount b/test/fixtures/mac-migrate/bin/mount new file mode 100755 index 00000000000..92dbb6b5f11 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/mount @@ -0,0 +1,23 @@ +#!/bin/bash +# mount MOUNTPOINT, as fstab describes it. The fixture's only mountable device +# is the ESP ($MIGRATE_FIXTURE/esp-device), whose files live in +# $MIGRATE_FIXTURE/esp: mounting it links the mountpoint there and keeps the +# directory it covers in $MIGRATE_FIXTURE/covered. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +target=${!#} +relative=${target#"$root"} +echo "mount $relative" >>"$fixture/boot.log" +device=$(awk -v point="$relative" '$1 !~ /^#/ && $2 == point { print $1; exit }' "$root/etc/fstab") +[[ -n $device ]] || { echo "mount: $relative: can't find in /etc/fstab" >&2; exit 1; } +[[ $device == "$(cat "$fixture/esp-device")" ]] || { echo "mount: $device: special device does not exist" >&2; exit 32; } +if grep -q . "$fixture/mounts" 2>/dev/null; then + echo "mount: the ESP is already mounted at $(head -n 1 "$fixture/mounts")" >&2 + exit 32 +fi +[[ -d $target && ! -L $target ]] || { echo "mount: $relative: mount point does not exist" >&2; exit 32; } +mkdir -p "$fixture/covered" +mv "$target" "$fixture/covered/${relative//\//_}" +ln -s "$fixture/esp" "$target" +echo "$target" >>"$fixture/mounts" diff --git a/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-boot-check b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-boot-check new file mode 100755 index 00000000000..e7088b51d68 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-boot-check @@ -0,0 +1,7 @@ +#!/bin/bash +fixture=${MIGRATE_FIXTURE:?} +echo "boot-check ${OMARCHY_BOOT_CHECK_ALLOW_PENDING_REBOOT:+pending }$*" >>"$fixture/boot.log" +if [[ -e $fixture/boot-check-fail ]]; then + echo "Apple Silicon boot check: $(cat "$fixture/boot-check-fail")" >&2 + exit 1 +fi diff --git a/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-channel b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-channel new file mode 100755 index 00000000000..a02fe86bb7a --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-channel @@ -0,0 +1,6 @@ +#!/bin/bash +# The mx-mac fork's channel command: `current` prints $MIGRATE_FIXTURE/channel +# and fails, as the fork's does, when there is no channel record. +fixture=${MIGRATE_FIXTURE:?} +[[ ${1:-} == "current" && -f $fixture/channel ]] || exit 1 +cat "$fixture/channel" diff --git a/test/fixtures/mac-migrate/bin/omarchy-drive-recover b/test/fixtures/mac-migrate/bin/omarchy-drive-recover new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-drive-recover @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-hw-platform b/test/fixtures/mac-migrate/bin/omarchy-hw-platform new file mode 100755 index 00000000000..ad246f75c9b --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-hw-platform @@ -0,0 +1,2 @@ +#!/bin/bash +cat "${MIGRATE_FIXTURE:?}/platform" diff --git a/test/fixtures/mac-migrate/bin/omarchy-lifecycle-dispatch b/test/fixtures/mac-migrate/bin/omarchy-lifecycle-dispatch new file mode 100755 index 00000000000..5c98ee591fd --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-lifecycle-dispatch @@ -0,0 +1,44 @@ +#!/bin/bash +# The new runtime's dispatcher, with omarchy-mac-boot's and omarchy-mac's +# operations standing in: logs the call and fails while the fixture holds +# -fail. setup-boot activates Limine on a Mac that opted in (the +# gate file), as the package's limine-boot.sh does; setup-user runs as the +# user, never as root. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +operation=${1:?} +shift +if [[ $operation == "setup-user" ]]; then + echo "dispatch $operation HOME=$HOME" >>"$fixture/boot.log" +else + echo "dispatch $operation${*:+ $*}" >>"$fixture/boot.log" +fi +[[ ! -e $fixture/$operation-fail ]] || { echo "$operation: failed in the fixture" >&2; exit 1; } +case $operation in + setup-boot) + [[ -e $root/var/lib/omarchy/limine.enabled ]] || exit 0 + if [[ -e $fixture/limine-activation-fail ]]; then + echo "limine-boot: limine-update failed; activation failed" >&2 + exit 1 + fi + # A fixture holding setup-boot-leaf activates Limine with that script + # instead (the legacy suite's, which reads the real boot files). + [[ ! -f $fixture/setup-boot-leaf ]] || exec bash "$fixture/setup-boot-leaf" + echo "limine-boot activate" >>"$fixture/boot.log" + printf 'KERNEL_CMDLINE[default]="root=UUID=x"\n' >"$root/etc/default/limine" + # Killed half way through the switch, before the UKI exists. + if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "loader-leaf" && ! -e $fixture/killed-in-leaf ]]; then + : >"$fixture/killed-in-leaf" + kill -9 "$PPID" $$ + fi + limine-update + cp "$root/usr/share/limine/BOOTAA64.EFI" "$root/boot/efi/EFI/BOOT/BOOTAA64.EFI" + ;; + update-verify) + if [[ -e $fixture/boot-check-fail ]]; then + echo "Apple Silicon boot check: $(cat "$fixture/boot-check-fail")" >&2 + exit 1 + fi + ;; +esac diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-boot-update b/test/fixtures/mac-migrate/bin/omarchy-mac-boot-update new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-boot-update @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-esp b/test/fixtures/mac-migrate/bin/omarchy-mac-esp new file mode 100755 index 00000000000..f8734020def --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-esp @@ -0,0 +1,10 @@ +#!/bin/bash +# The system ESP is the fixture's /boot/efi, else its /boot, when listed as mounted. +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +if grep -Fxq "$root/boot/efi" "${MIGRATE_FIXTURE:?}/mounts"; then + echo /boot/efi +elif grep -Fxq "$root/boot" "$MIGRATE_FIXTURE/mounts"; then + echo /boot +else + exit 1 +fi diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-initramfs-hooks b/test/fixtures/mac-migrate/bin/omarchy-mac-initramfs-hooks new file mode 100755 index 00000000000..6d8d1174154 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-initramfs-hooks @@ -0,0 +1,11 @@ +#!/bin/bash +# The fixture's HOOKS file, else the real resolver over the fixture root's +# mkinitcpio.conf and drop-ins. +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +if [[ -f $fixture/hooks ]]; then + cat "$fixture/hooks" + exit 0 +fi +exec env OMARCHY_MKINITCPIO_CONF="$root/etc/mkinitcpio.conf" OMARCHY_MKINITCPIO_CONF_DIR="$root/etc/mkinitcpio.conf.d" \ + OMARCHY_MKINITCPIO_PRESET_DIR="$root/etc/mkinitcpio.d" OMARCHY_MKINITCPIO_KERNEL=linux-aurora "$root/usr/bin/omarchy-mac-initramfs-hooks" diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-limine-cmdline b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-cmdline new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-cmdline @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-limine-deploy b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-deploy new file mode 100755 index 00000000000..281290af939 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-deploy @@ -0,0 +1,4 @@ +#!/bin/bash +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +echo "omarchy-mac-limine-deploy" >>"${MIGRATE_FIXTURE:?}/boot.log" +cp "$root/usr/share/limine/BOOTAA64.EFI" "$root/boot/efi/EFI/BOOT/BOOTAA64.EFI" diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-setup-keyboard b/test/fixtures/mac-migrate/bin/omarchy-mac-setup-keyboard new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-setup-keyboard @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-pkg-defaults b/test/fixtures/mac-migrate/bin/omarchy-pkg-defaults new file mode 100755 index 00000000000..2623b3088d9 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-pkg-defaults @@ -0,0 +1,8 @@ +#!/bin/bash +# Stands in for the target runtime's command: the generic list's applications +# and the Apple Silicon additions (#13362's install/omarchy-apple-silicon.packages). +case ${1:-} in + generic) printf '%s\n' obs-studio zram-generator hyprland ;; + apple-silicon) printf '%s\n' omarchy-mac omarchy-mac-boot asahi-bless avd-fw libva-v4l2_request-avd widevine wf-recorder ;; + *) exit 2 ;; +esac diff --git a/test/fixtures/mac-migrate/bin/pacman b/test/fixtures/mac-migrate/bin/pacman new file mode 100755 index 00000000000..c03d9ff4709 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/pacman @@ -0,0 +1,399 @@ +#!/bin/bash +# A pacman stand-in for the migration fixtures. A package is "name version"; the +# local database is /local/packages and a sync database is +# /sync/.db, both lists of packages. Repositories are file:// +# directories holding .db. $MIGRATE_FIXTURE/conflicts lists +# "package conflicting-package..." for every repository. +# +# Legacy fixtures opt into more of pacman: +# - $MIGRATE_FIXTURE/files/ lists the paths package installs; +# /local/files records "package path" for what is installed. A +# transaction refuses to write over a path that exists unless a package it +# replaces owns it or --overwrite names it, then writes its files. A path in +# $MIGRATE_FIXTURE/backups is a backup= file: one no package owns is never a +# conflict; one whose contents are not a package's own copy stays as it is, +# and when no package owned it the package's copy lands as .pacnew. A +# --dbonly transaction records the paths and writes none, as pacman does. +# - With $MIGRATE_FIXTURE/verify-signatures, a sync database line may carry a +# third column, the key that signed the package; where the repository's +# SigLevel requires signatures, the key must be trusted in --gpgdir's keys. +# - $MIGRATE_FIXTURE/depends lists "package version dependency..." for -R. +# - With $MIGRATE_FIXTURE/kernel-hook, a transaction runs the kernel's install +# hook: the removed Apple kernel leaves /boot, and linux-aurora's image is +# copied there and its initramfs built. +set -euo pipefail + +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +config=$root/etc/pacman.conf +dbpath=$root/var/lib/pacman +gpgdir=$root/etc/pacman.d/gnupg +cachedirs=() overwrites=() ignored=() dbonly=0 ask="" format="" op="" flags="" args=() +while (( $# )); do + case $1 in + --config) config=$2; shift 2 ;; + --dbpath) dbpath=$2; shift 2 ;; + --cachedir) cachedirs+=("$2"); shift 2 ;; + --gpgdir) gpgdir=$2; shift 2 ;; + --overwrite) overwrites+=("$2"); shift 2 ;; + --ignore=*) IFS=, read -ra more <<<"${1#--ignore=}"; ignored+=("${more[@]}"); shift ;; + --logfile) shift 2 ;; + --noconfirm) shift ;; + --ask) ask=$2; shift 2 ;; + --dbonly) dbonly=1; shift ;; + --print-format) format=$2; shift 2 ;; + --*) echo "fake pacman: unknown option $1" >&2; exit 1 ;; + -Q* | -S* | -R*) op=${1:1:1}; flags=${1:2}; shift ;; + *) args+=("$1"); shift ;; + esac +done +files_db=$dbpath/local/files +[[ ! -d $fixture/files || -f $files_db ]] || : >"$files_db" + +# A path is written over only when nothing is there, a package the +# transaction replaces owns it, or an --overwrite glob matches it. +may_overwrite() { + local pattern + for pattern in ${overwrites[@]+"${overwrites[@]}"}; do + # shellcheck disable=SC2053 # a glob, as pacman matches it + [[ $1 == $pattern ]] && return 0 + done + return 1 +} + +remove_files() { # package [upgraded: an upgrade keeps backup= files for the new version] + local path + [[ -f $files_db ]] || return 0 + while read -r path; do + [[ -z ${2:-} ]] || ! grep -Fxq "$path" "$fixture/backups" 2>/dev/null || continue + [[ -d $root$path && ! -L $root$path ]] || rm -f "$root$path" + done < <(awk -v name="$1" '$1 == name { sub(/^[^ ]+ /, ""); print }' "$files_db") + awk -v name="$1" '$1 != name' "$files_db" >"$files_db.new" && mv "$files_db.new" "$files_db" +} + +# Repositories and their servers, Include files read under the fixture root. +repositories() { + awk -v root="$root" ' + function scan(file, line) { while ((getline line < file) > 0) handle(line); close(file) } + function handle(line, value) { + if (line ~ /^\[/) { name = line; gsub(/[][]/, "", name) } + else if (line ~ /^Include = /) { value = line; sub(/^Include = /, "", value); scan(root value) } + else if (line ~ /^Server = / && name != "options") { value = line; sub(/^Server = /, "", value); gsub(/\$repo/, name, value); print name, value } + } + { handle($0) }' "$config" +} + +declare -A version=() +while read -r name ver; do + [[ -z $name ]] || version[$name]=$ver +done <"$dbpath/local/packages" + +if [[ $op == "Q" && $flags == *l* ]]; then + if [[ $flags == *p* ]]; then + base=${args[0]##*/} + base=${base%%.pkg.tar.*} + base=${base%-*} + base=${base%-*} + [[ ! -f $fixture/files/${base%-*} ]] || cat "$fixture/files/${base%-*}" + elif [[ -f $files_db ]]; then + if [[ $flags == *q* ]]; then sed 's/^[^ ]* //' "$files_db"; else cat "$files_db"; fi + fi + exit 0 +fi + +if [[ $op == "R" ]]; then + for name in "${args[@]}"; do + [[ -n ${version[$name]:-} ]] || { echo "error: target not found: $name" >&2; exit 1; } + done + if [[ -f $fixture/depends ]]; then + while read -r name ver deps; do + [[ ${version[$name]:-} == "$ver" && " ${args[*]} " != *" $name "* ]] || continue + for dep in $deps; do + [[ " ${args[*]} " != *" $dep "* ]] || + { echo "error: failed to prepare transaction (could not satisfy dependencies)" >&2; echo ":: removing $dep breaks dependency '$dep' required by $name" >&2; exit 1; } + done + done <"$fixture/depends" + fi + if (( ! dbonly )); then + [[ ! -e $dbpath/db.lck ]] || { echo "error: failed to init transaction (unable to lock database)" >&2; exit 1; } + : >"$dbpath/db.lck" + echo "remove ${args[*]}" >>"$fixture/pacman.log" + for name in "${args[@]}"; do remove_files "$name"; done + elif [[ -f $files_db ]]; then + for name in "${args[@]}"; do + awk -v name="$name" '$1 != name' "$files_db" >"$files_db.new" && mv "$files_db.new" "$files_db" + done + fi + for name in "${args[@]}"; do unset "version[$name]"; done + for name in "${!version[@]}"; do + echo "$name ${version[$name]}" + done | LC_ALL=C sort >"$dbpath/local/packages" + if (( ! dbonly )); then + echo "hooks" >>"$fixture/pacman.log" + rm -f "$dbpath/db.lck" + fi + exit 0 +fi + +if [[ $op == "Q" ]]; then + if (( ${#args[@]} )); then + for name in "${args[@]}"; do + # Like pacman, a name no package has is answered by an installed provider + # ($MIGRATE_FIXTURE/provides lists "package provided-name..."). + if [[ -z ${version[$name]:-} && -f $fixture/provides ]]; then + provider=$(awk -v wanted="$name" '{ for (i = 2; i <= NF; i++) if ($i == wanted) print $1 }' "$fixture/provides" | + while read -r candidate; do [[ -z ${version[$candidate]:-} ]] || { echo "$candidate"; break; }; done) + name=${provider:-$name} + fi + [[ -n ${version[$name]:-} ]] || { echo "error: package '$name' was not found" >&2; exit 1; } + [[ $flags == *q* ]] && echo "$name" || echo "$name ${version[$name]}" + done + else + for name in $(printf '%s\n' "${!version[@]}" | LC_ALL=C sort); do + [[ $flags == *q* ]] && echo "$name" || echo "$name ${version[$name]}" + done + fi + exit 0 +fi +[[ $op == "S" ]] || { echo "fake pacman: unsupported operation" >&2; exit 1; } +# Groups: none in the fixtures. +[[ $flags != *g* ]] || exit 0 + +if [[ $flags == *y* ]]; then + mkdir -p "$dbpath/sync" + while read -r repo server; do + source_db=${server#file://}/$repo.db + [[ $server == file://* && -f $source_db ]] || { echo "error: failed to synchronize all databases ($repo)" >&2; exit 1; } + cp "$source_db" "$dbpath/sync/$repo.db" + if [[ -f $source_db.sig ]]; then + cp "$source_db.sig" "$dbpath/sync/$repo.db.sig" + else + rm -f "$dbpath/sync/$repo.db.sig" + fi + done < <(repositories) + [[ $flags == *u* || ${#args[@]} -gt 0 ]] || exit 0 +fi + +# A database signature reads "signed "; pacman rejects a +# database beside one that does not match it. +while read -r repo server; do + sig=$dbpath/sync/$repo.db.sig + [[ ! -f $sig || $(<"$sig") == "signed $(sha256sum "$dbpath/sync/$repo.db" | cut -d' ' -f1)" ]] || + { echo "error: database '$repo' is not valid (invalid or corrupted database (PGP signature))" >&2; exit 1; } +done < <(repositories) + +lookup() { # name [repo] -> "repo version" + local name=$1 wanted=${2:-} repo server + while read -r repo server; do + [[ -z $wanted || $repo == "$wanted" ]] || continue + [[ -f $dbpath/sync/$repo.db ]] || continue + awk -v repo="$repo" -v name="$name" '$1 == name { print repo, $2; found = 1; exit } END { exit !found }' "$dbpath/sync/$repo.db" && return 0 + done < <(repositories) + return 1 +} + +if [[ $flags == *l* ]]; then + while read -r repo server; do + [[ ${#args[@]} == 0 || $repo == "${args[0]}" ]] || continue + [[ -f $dbpath/sync/$repo.db ]] && awk -v repo="$repo" '{ print repo, $1, $2 }' "$dbpath/sync/$repo.db" + done < <(repositories) + exit 0 +fi + +newer() { + [[ $1 != "$2" && $(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1) == "$1" ]] +} + +# The SigLevel each repository answers to: its own, or the global one. +siglevel() { + awk -v wanted="$1" ' + /^\[/ { name = $0; gsub(/[][]/, "", name) } + /^SigLevel = / { value = $0; sub(/^SigLevel = /, "", value); if (name == "options") global = value; else level[name] = value } + END { print ((wanted in level) ? level[wanted] : global) }' "$config" +} + +verify_signatures() { + local name repo signer level + [[ -e $fixture/verify-signatures ]] || return 0 + for name in "${!changed[@]}"; do + repo=${changed[$name]} + level=" $(siglevel "$repo") " + [[ ! $level =~ \ (Package)?(Optional|Never|TrustAll)\ ]] || continue + signer=$(awk -v name="$name" -v ver="${version[$name]}" '$1 == name && $2 == ver { print $3; exit }' "$dbpath/sync/$repo.db") + if [[ -z $signer ]] || ! grep -Eq "^$signer [fu]\$" "$gpgdir/keys"; then + echo "error: $name: signature from \"${signer:-nobody}\" is unknown trust" >&2 + echo "error: failed to commit transaction (invalid or corrupted package (PGP signature))" >&2 + exit 1 + fi + done +} + +declare -A changed=() original=() +for name in "${!version[@]}"; do + original[$name]=${version[$name]} +done +if [[ $flags == *u* ]]; then + for name in "${!version[@]}"; do + [[ " ${ignored[*]} " != *" $name "* ]] || continue + found=$(lookup "$name") || continue + if newer "${found#* }" "${version[$name]}"; then + version[$name]=${found#* } + changed[$name]=${found% *} + fi + done +fi +for target in "${args[@]}"; do + name=${target#*/} repo="" + [[ $target != */* ]] || repo=${target%%/*} + found=$(lookup "$name" "$repo") || { echo "error: target not found: $target" >&2; exit 1; } + version[$name]=${found#* } + changed[$name]=${found% *} +done +provides() { # package name: $MIGRATE_FIXTURE/provides lists "package provided-name..." + [[ -f $fixture/provides ]] && awk -v p="$1" -v n="$2" '$1 == p { for (i = 2; i <= NF; i++) if ($i == n) found = 1 } END { exit !found }' "$fixture/provides" +} +# Two packages of the transaction in conflict: like pacman, keep the one that +# provides the other and drop the other from the targets. +drop() { # package kept + echo "warning: removing '$1' from target list because it conflicts with '$2'" >&2 + if [[ -n ${original[$1]:-} ]]; then version[$1]=${original[$1]}; else unset "version[$1]"; fi + unset "changed[$1]" +} +if [[ -f $fixture/conflicts ]]; then + while read -r name conflicting; do + [[ -n ${changed[$name]:-} ]] || continue + for other in $conflicting; do + if [[ -n ${changed[$other]:-} ]]; then + if provides "$other" "$name"; then + drop "$name" "$other" + continue + elif provides "$name" "$other"; then + # The dropped package, still installed, conflicts with the target. + drop "$other" "$name" + else + continue + fi + fi + [[ -n ${version[$other]:-} && -z ${changed[$other]:-} ]] || continue + [[ $ask == "4" ]] || { echo "error: unresolvable package conflicts detected" >&2; exit 1; } + unset "version[$other]" + done + done <"$fixture/conflicts" +fi + +if [[ $flags == *p* ]]; then + for name in $(printf '%s\n' "${!changed[@]}" | LC_ALL=C sort); do + line=${format//%r/${changed[$name]}} + line=${line//%n/$name} + echo "${line//%v/${version[$name]}}" + done + exit 0 +fi +if [[ $flags == *w* ]]; then + verify_signatures + for name in "${!changed[@]}"; do + file="$name-${version[$name]}-aarch64.pkg.tar.zst" + for dir in "${cachedirs[@]}"; do + [[ ! -f $dir/$file ]] || continue 2 + done + # A package whose archive the fixture built is that archive. + if [[ -f $fixture/archives/$name ]]; then + cp "$fixture/archives/$name" "${cachedirs[0]}/$file" + else + echo "fake" >"${cachedirs[0]}/$file" + fi + echo "download $name ${version[$name]}" >>"$fixture/pacman.log" + done + exit 0 +fi +if (( ! dbonly )); then + [[ ! -e $fixture/fail-transaction ]] || { echo "error: failed to commit transaction" >&2; exit 1; } + [[ ! -e $dbpath/db.lck ]] || { echo "error: failed to init transaction (unable to lock database)" >&2; exit 1; } + verify_signatures + if [[ -d $fixture/files ]]; then + declare -A owner=() + while read -r name path; do owner[$path]=$name; done <"$files_db" + clashes=() + for name in "${!changed[@]}"; do + [[ -f $fixture/files/$name ]] || continue + while read -r path; do + [[ -e $root$path || -L $root$path ]] || continue + [[ ! -d $root$path || -L $root$path ]] || continue + holder=${owner[$path]:-} + if [[ -n $holder && ( -n ${changed[$holder]:-} || -z ${version[$holder]:-} ) ]]; then + continue + fi + [[ -n $holder ]] || ! grep -Fxq "$path" "$fixture/backups" 2>/dev/null || continue + may_overwrite "$path" && [[ -z $holder ]] && continue + clashes+=("$name: $path exists in filesystem${holder:+ (owned by $holder)}") + done <"$fixture/files/$name" + done + if (( ${#clashes[@]} )); then + echo "error: failed to commit transaction (conflicting files)" >&2 + printf '%s\n' "${clashes[@]}" >&2 + exit 1 + fi + fi + : >"$dbpath/db.lck" + echo "transaction ${args[*]}" >>"$fixture/pacman.log" + # A package scriptlet that arms first boot, as a fresh image's would. + if [[ -e $fixture/scriptlet-arms-first-boot ]]; then + mkdir -p "$root/var/lib/omarchy/mac-first-boot" + : >"$root/var/lib/omarchy/mac-first-boot/pending" + fi +fi +if (( ! dbonly )) && [[ -d $fixture/files ]]; then + cp "$files_db" "$files_db.before" + for name in $(awk '{ print $1 }' "$files_db" | sort -u); do + [[ -n ${version[$name]:-} && -z ${changed[$name]:-} ]] || remove_files "$name" ${changed[$name]:+upgraded} + done + for name in "${!changed[@]}"; do + [[ -f $fixture/files/$name ]] || continue + while read -r path; do + mkdir -p "$(dirname "$root$path")" + echo "$name $path" >>"$files_db" + if [[ -f $root$path && $(<"$root$path") != "$name "* ]] && grep -Fxq "$path" "$fixture/backups" 2>/dev/null; then + [[ -n ${owner[$path]:-} ]] || echo "$name ${version[$name]}" >"$root$path.pacnew" + continue + fi + rm -f "$root$path" + echo "$name ${version[$name]}" >"$root$path" + # Killed part way through extraction: files on disk, none recorded yet. + if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "extraction" && ! -e $fixture/killed-in-extraction ]]; then + : >"$fixture/killed-in-extraction" + mv "$files_db.before" "$files_db" + kill -9 "$PPID" $$ + fi + done <"$fixture/files/$name" + done + rm -f "$files_db.before" +fi +if (( dbonly )) && [[ -d $fixture/files && -f $files_db ]]; then + awk 'NR == FNR { keep[$1]; next } $1 in keep' \ + <(echo "-"; for name in "${!version[@]}"; do [[ -n ${changed[$name]:-} ]] || echo "$name"; done) "$files_db" >"$files_db.new" + for name in "${!changed[@]}"; do + [[ ! -f $fixture/files/$name ]] || sed "s|^|$name |" "$fixture/files/$name" >>"$files_db.new" + done + mv "$files_db.new" "$files_db" +fi +for name in "${!version[@]}"; do + echo "$name ${version[$name]}" +done | LC_ALL=C sort >"$dbpath/local/packages" +if (( ! dbonly )); then + # Killed after its database write, before its hooks: the lock stays behind. + if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "transaction" && ! -e $fixture/killed-in-pacman ]]; then + : >"$fixture/killed-in-pacman" + kill -9 "$PPID" $$ + fi + if [[ -e $fixture/kernel-hook ]]; then + for name in linux-asahi linux-aurora; do + [[ -n ${version[$name]:-} ]] || rm -f "$root/boot/vmlinuz-$name" "$root/boot/initramfs-$name.img" + done + if [[ -n ${version[linux-aurora]:-} ]]; then + cp "$root"/usr/lib/modules/*-aurora/vmlinuz "$root/boot/vmlinuz-linux-aurora" + mkinitcpio -p linux-aurora >/dev/null + fi + fi + echo "hooks" >>"$fixture/pacman.log" + rm -f "$dbpath/db.lck" +fi diff --git a/test/fixtures/mac-migrate/bin/pacman-key b/test/fixtures/mac-migrate/bin/pacman-key new file mode 100755 index 00000000000..8741ddcab29 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/pacman-key @@ -0,0 +1,36 @@ +#!/bin/bash +# pacman-key for the fixtures: /keys lists "fingerprint validity". +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +gpgdir=$root/etc/pacman.d/gnupg +action="" args=() +while (( $# )); do + case $1 in + --gpgdir) gpgdir=$2; shift 2 ;; + --keyserver) shift 2 ;; + --populate | --recv-keys | --lsign-key | --delete) action=$1; shift ;; + *) args+=("$1"); shift ;; + esac +done +keys=$gpgdir/keys +set_key() { + grep -v "^$1 " "$keys" >"$keys.new" || true + [[ -z $2 ]] || echo "$1 $2" >>"$keys.new" + mv "$keys.new" "$keys" +} +if [[ $gpgdir == "$root/etc/pacman.d/gnupg" ]]; then + echo "pacman-key $action ${args[*]}" >>"$fixture/pacman.log" +else + echo "copy pacman-key $action ${args[*]}" >>"$fixture/pacman.log" +fi +case $action in + --populate) + for name in "${args[@]}"; do + while read -r fpr; do set_key "$fpr" f; done <"$root/usr/share/pacman/keyrings/$name-trusted" + done + ;; + --recv-keys) [[ -f $fixture/keyserver/${args[0]} ]] || exit 1; set_key "${args[0]}" - ;; + --lsign-key) grep -q "^${args[0]} " "$keys" || exit 1; set_key "${args[0]}" f ;; + --delete) set_key "${args[0]}" "" ;; +esac diff --git a/test/fixtures/mac-migrate/bin/repo-add b/test/fixtures/mac-migrate/bin/repo-add new file mode 100755 index 00000000000..83a338d5f81 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/repo-add @@ -0,0 +1,15 @@ +#!/bin/bash +# repo-add for the fixtures: indexes name-version-release-arch.pkg.tar.* files. +set -euo pipefail +[[ $1 == "-q" ]] && shift +db=$1 +shift +for file in "$@"; do + [[ $file == *.pkg.tar.* && $file != *.sig ]] || { echo "==> ERROR: '$file' is not a package file" >&2; exit 1; } + base=${file##*/} + base=${base%%.pkg.tar.*} + base=${base%-*} + release=${base##*-} + base=${base%-*} + echo "${base%-*} ${base##*-}-$release" +done >"${db%.db.tar.gz}.db" diff --git a/test/fixtures/mac-migrate/bin/sudo b/test/fixtures/mac-migrate/bin/sudo new file mode 100755 index 00000000000..0b830fd4640 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/sudo @@ -0,0 +1,3 @@ +#!/bin/bash +# Runtime leaves call sudo when they do not run as root; the fixture is theirs. +exec "$@" diff --git a/test/fixtures/mac-migrate/bin/systemctl b/test/fixtures/mac-migrate/bin/systemctl new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/systemctl @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/umount b/test/fixtures/mac-migrate/bin/umount new file mode 100755 index 00000000000..0505a22f5c0 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/umount @@ -0,0 +1,18 @@ +#!/bin/bash +# umount MOUNTPOINT: the ESP's link goes and the directory it covered comes back. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +target=${!#} +relative=${target#"$root"} +echo "umount $relative" >>"$fixture/boot.log" +[[ ! -e $fixture/umount-busy ]] || { echo "umount: $relative: target is busy." >&2; exit 32; } +grep -Fxq "$target" "$fixture/mounts" && [[ -L $target ]] || { echo "umount: $relative: not mounted" >&2; exit 32; } +rm "$target" +if [[ -d $fixture/covered/${relative//\//_} ]]; then + mv "$fixture/covered/${relative//\//_}" "$target" +else + mkdir -p "$target" +fi +grep -Fxv "$target" "$fixture/mounts" >"$fixture/mounts.new" || true +mv "$fixture/mounts.new" "$fixture/mounts" diff --git a/test/fixtures/mac-migrate/bin/update-grub b/test/fixtures/mac-migrate/bin/update-grub new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/update-grub @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/update-m1n1 b/test/fixtures/mac-migrate/bin/update-m1n1 new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/update-m1n1 @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/lib.sh b/test/fixtures/mac-migrate/lib.sh new file mode 100644 index 00000000000..59632f13c34 --- /dev/null +++ b/test/fixtures/mac-migrate/lib.sh @@ -0,0 +1,204 @@ +# Shared by the omarchy-mac-migrate suites (test/shell.d/mac-migrate-*-test.sh). +# +# Each case builds a fixture root (a Mac's pacman configuration, package +# database, keyring, /boot and ESP) and runs bin/omarchy-mac-migrate +# unprivileged against it, with pacman, the keyring, the boot tools, the new +# runtime's dispatcher and the device probes replaced by the stand-ins in +# test/fixtures/mac-migrate/bin. Candidate sets are signed with a disposable +# key by the real gpg; the archives preflight reads (the runtime and +# omarchy-mac-boot) are real tar files. +# shellcheck disable=SC2034 + +require_command gpg +require_command gpgv +require_command jq +require_command flock +require_command bsdtar + +# The failure's evidence follows its description. +fail() { + printf 'not ok - %s\n' "$1" >&2 + [[ -z ${2:-} ]] || printf '%s\n' "$2" >&2 + exit 1 +} + +tmp=$(mktemp -d) +trap 'for home in signer other subkey-home; do gpgconf --homedir "$tmp/$home" --kill gpg-agent 2>/dev/null; done; rm -rf "$tmp"' EXIT +stubs=$ROOT/test/fixtures/mac-migrate/bin +tool=$ROOT/bin/omarchy-mac-migrate +steps=(preflight backup keyring prefetch repositories transaction boot-chain loader defaults verify unpin reboot retire) +official=40DFB630FF42BCFFB047046CF0134EE680CAC571 + +make_key() { + mkdir -m 700 "$tmp/$1" + gpg --batch --homedir "$tmp/$1" --pinentry-mode loopback --passphrase '' \ + --quick-gen-key "Migration test $1" ed25519 sign 1d 2>/dev/null + gpg --batch --homedir "$tmp/$1" --with-colons --list-secret-keys 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }' +} +signer=$(make_key signer) +other=$(make_key other) +gpg --batch --homedir "$tmp/signer" --armor --export "$signer" >"$tmp/signer.asc" 2>/dev/null +gpg --batch --homedir "$tmp/other" --armor --export "$other" >"$tmp/other.asc" 2>/dev/null + +# make_archive NAME VERSION FILE: a package archive pacman and bsdtar can read, +# with the files a release ships that preflight looks for. Other packages are +# noise. +make_archive() { + local name=$1 version=$2 file=$3 dir + dir=$(mktemp -d) + printf 'pkgname = %s\npkgver = %s\n' "$name" "$version" >"$dir/.PKGINFO" + case $name in + omarchy-dev | omarchy) + install -D -m 755 /dev/null "$dir/usr/bin/omarchy-lifecycle-dispatch" + ;; + omarchy-mac-boot) + install -D -m 755 /dev/null "$dir/usr/lib/omarchy/mac-boot/setup-boot" + install -D -m 755 /dev/null "$dir/usr/lib/omarchy/mac-boot/update-verify" + install -D -m 755 /dev/null "$dir/usr/bin/omarchy-mac-esp" + ;; + esac + if [[ $name == omarchy-dev || $name == omarchy || $name == omarchy-mac-boot ]]; then + bsdtar -czf "$file" -C "$dir" .PKGINFO usr + else + head -c 512 /dev/urandom >"$file" + fi + rm -rf "$dir" +} + +# The packages of a fixture candidate set: the edge runtime pair (or SET_PAIR) +# and the Mac set. +set_packages() { + cat </dev/null + done < <(set_packages) + printf '%s\n' "${entries[@]}" | jq -s '{schema: 1, set: "apple-test-fixture", packages: .}' >"$dir/manifest.json.new" + jq --arg digest "$(jq -r '.packages[] | "\(.name) \(.version) \(.filename) \(.sha256)"' "$dir/manifest.json.new" | LC_ALL=C sort | sha256sum | cut -d' ' -f1)" \ + '.set_sha256 = $digest' "$dir/manifest.json.new" >"$dir/manifest.json" + rm "$dir/manifest.json.new" + resign_set "$dir" "$key_home" +} + +resign_set() { + local dir=$1 key_home=$2 fpr + fpr=$(gpg --batch --homedir "$key_home" --with-colons --list-secret-keys 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }') + jq -n --slurpfile manifest "$dir/manifest.json" --arg fpr "$fpr" --arg sha "$(sha256sum "$dir/manifest.json" | cut -d' ' -f1)" \ + '{schema: 1, manifest_sha256: $sha, set_sha256: $manifest[0].set_sha256, signer: {fingerprint: $fpr}, + signatures: [$manifest[0].packages[] | {file: .filename}]}' >"$dir/signing.json" + rm -f "$dir/signing.json.sig" + gpg --batch --homedir "$key_home" --detach-sign --no-armor -o "$dir/signing.json.sig" "$dir/signing.json" 2>/dev/null + gpg --batch --homedir "$key_home" --armor --export "$fpr" >"$dir/candidate-signing-key.asc" 2>/dev/null +} + +make_set "$tmp/set" "$tmp/signer" + +# repo DIR [NAME]: a file:// repository in $F/repos/DIR whose database is NAME.db. +repo() { + mkdir -p "$F/repos/$1" + cat >"$F/repos/$1/${2:-$1}.db" +} + +# archive NAME VERSION: the archive a repository target's NAME downloads as. +archive() { + mkdir -p "$F/archives" + make_archive "$1" "$2" "$F/archives/$1" +} + +# The ALARM repositories the core configuration names, empty unless given, and +# the mirror list they come from. +alarm_repos() { + local name + for name in core extra alarm aur; do + [[ -f $F/repos/$name/$name.db ]] || repo "$name" "$R/etc/pacman.d/mirrorlist" +} + +# The relations between the fixtures' packages, as pacman resolves them. +relations() { + printf 'linux-aurora linux-asahi\nm1n1-aurora m1n1\nlinux-aurora-headers linux-asahi-headers\nomarchy-dev omarchy\nomarchy-settings-dev omarchy-settings\n' >"$F/conflicts" + printf 'omarchy-dev omarchy\nomarchy-settings-dev omarchy-settings\n' >"$F/provides" +} + +migrate() { + OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F OMARCHY_MAC_MIGRATE_ASAHI_SERVER="file://$F/repos/asahi-alarm" \ + OMARCHY_MAC_MIGRATE_SERVER="file://$F/repos/official-@channel@" PATH="$stubs:$PATH" "$tool" "$@" +} + +# migrate_env VAR=VALUE... -- ARGS: migrate with extra environment. +migrate_env() { + local extra=() + while [[ $1 != "--" ]]; do + extra+=("$1") + shift + done + shift + env "${extra[@]}" OMARCHY_MAC_MIGRATE_ROOT="$R" MIGRATE_FIXTURE="$F" OMARCHY_MAC_MIGRATE_ASAHI_SERVER="file://$F/repos/asahi-alarm" \ + OMARCHY_MAC_MIGRATE_SERVER="file://$F/repos/official-@channel@" PATH="$stubs:$PATH" "$tool" "$@" +} + +reboot_into_aurora() { + echo boot-2 >"$R/proc/sys/kernel/random/boot_id" + echo 7.1.12-aurora >"$R/proc/sys/kernel/osrelease" +} + +state_dir() { + printf '%s\n' "$R/var/lib/omarchy-mac/migration" +} + +# Snapshot of the fixture a refused or failed run must leave as it was. +fixture_digest() { + (cd "$R" && find . -path ./var/tmp -prune -o -path ./run/lock -prune -o -path ./var/lib/omarchy-mac/migration/deferred -prune -o -type f -print0 | + LC_ALL=C sort -z | xargs -0 sha256sum) | sha256sum +} + +finish() { + local output + output=$(migrate run 2>&1) || fail "the resumed migration runs to its reboot" "$output" + if [[ ! -f $(state_dir)/complete ]]; then + reboot_into_aurora + output=$(migrate verify 2>&1) || fail "the migration finishes after its reboot" "$output" + fi + [[ -f $(state_dir)/complete ]] || fail "the migration completes" "$(cat "$(state_dir)/journal")" +} + +kill_after() { # step + local output + output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_AFTER="$1" -- run 2>&1) && fail "the run is killed after $1" "$output" + return 0 +} + +# refused DESCRIPTION REASON-PATTERN: the run defers (75) with nothing changed. +refused() { + local status=0 output digest + digest=$(fixture_digest) + output=$(migrate run 2>&1) || status=$? + (( status == 75 )) || fail "$1: preflight refuses" "status $status: $output" + grep -q -- "$2" <<<"$output" || fail "$1: the refusal says why" "$output" + [[ ! -e $(state_dir)/journal ]] || fail "$1: no migration is started" + [[ $(fixture_digest) == "$digest" ]] || fail "$1: nothing on the system changed" + ! grep -q '^transaction\|^pacman-key' "$F/pacman.log" || fail "$1: no transaction or change to the live keyring ran" +} diff --git a/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-initramfs-hooks b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-initramfs-hooks new file mode 100755 index 00000000000..7f7571223ea --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-initramfs-hooks @@ -0,0 +1,143 @@ +#!/bin/bash + +# omarchy:summary=Print the mkinitcpio HOOKS the Apple Silicon kernel's initramfs is built with +# omarchy:hidden=true + +# Resolves the configuration the way mkinitcpio -P does for the kernel's +# default preset: a default_config or ALL_config in the preset is passed as +# -c and disables the drop-ins, as does a -c in default_options, whose -A +# and -S add and skip hooks (parsed with mkinitcpio's option table); otherwise /etc/mkinitcpio.conf is joined by +# every /etc/mkinitcpio.conf.d/*.conf in version order (LC_ALL=C.UTF-8 +# sort -V, as mkinitcpio sorts them) and sourced as one file. Prints HOOKS on +# one line and exits 0, or exits 1 when the configuration cannot be read or +# sets no HOOKS, so a caller can tell "busybox" from "unknown". + +set -euo pipefail + +conf=${OMARCHY_MKINITCPIO_CONF:-/etc/mkinitcpio.conf} +conf_dir=${OMARCHY_MKINITCPIO_CONF_DIR:-$conf.d} +preset_dir=${OMARCHY_MKINITCPIO_PRESET_DIR:-/etc/mkinitcpio.d} +kernel=${OMARCHY_MKINITCPIO_KERNEL:-$(omarchy-mac-kernel 2>/dev/null || echo linux-asahi)} + +drop_ins=1 +add_hooks=() skip_hooks=() + +# mkinitcpio's own option table (its _opt_short and _opt_long): options that +# take a value, and the ones that decide the hooks. +short_with_value='AcDIgHkprStUdz' +short_flags='hnLMPRsVv' +long_options=(add: addhooks: include: config: generate: hookdir: hookhelp: help kernel: listhooks automods + moduleroot: nocolor nopost allpresets preset: remove skiphooks: save generatedir: builddir: version verbose + compress: uki: uefi: microcode: splash: kernelimage: uefistub: cmdline: osrelease: no-cmdline ukiconfig: no-ukify) + +hook_option() { + local option=$1 value=$2 list + IFS=, read -ra list <<<"$value" + case $option in + c | config) conf=$value; drop_ins=0 ;; + A | add | addhooks) add_hooks+=("${list[@]}") ;; + S | skiphooks) skip_hooks+=("${list[@]}") ;; + esac +} + +# Parses the preset's options the way parseopts does: bundled short flags, +# a short option's value attached or next, --long=value or --long value, +# and a unique prefix of a long option. Anything it cannot place fails. +# Words after -- are positional, which mkinitcpio ignores. +preset_option_args() { + local arg name value i char candidate matches + while (( $# )); do + arg=$1 + shift + if [[ $arg == -- ]]; then + break + elif [[ $arg == --?* ]]; then + name=${arg#--} + value="" + if [[ $name == *=* ]]; then + value=${name#*=} + name=${name%%=*} + fi + matches=() + for candidate in "${long_options[@]}"; do + if [[ ${candidate%:} == "$name" ]]; then + matches=("$candidate") + break + fi + if [[ ${candidate%:} == "$name"* ]]; then + matches+=("$candidate") + fi + done + (( ${#matches[@]} == 1 )) || return 1 + if [[ ${matches[0]} == *: ]]; then + if [[ $arg != *=* ]]; then + (( $# )) || return 1 + value=$1 + shift + fi + hook_option "${matches[0]%:}" "$value" + elif [[ $arg == *=* ]]; then + return 1 + fi + elif [[ $arg == -?* ]]; then + for (( i = 1; i < ${#arg}; i++ )); do + char=${arg:i:1} + if [[ $short_with_value == *"$char"* ]]; then + value=${arg:i+1} + if [[ -z $value ]]; then + (( $# )) || return 1 + value=$1 + shift + fi + hook_option "$char" "$value" + break + fi + if [[ $short_flags != *"$char"* ]]; then + return 1 + fi + done + fi + done +} +preset="$preset_dir/$kernel.preset" +if [[ -r $preset ]]; then + # The default preset's configuration, then its options as mkinitcpio + # appends them: a later -c wins, -A and -S add and skip hooks. + preset_out=$(bash -c '. "$1" >/dev/null 2>&1 || exit 1 + printf "%s\n" "${default_config:-${ALL_config:-}}" + if [[ "${default_options@a}" == *a* ]]; then printf "%s\n" "${default_options[@]}" + elif [[ -n ${default_options:-} ]]; then printf "%s\n" $default_options; fi' _ "$preset") || exit 1 + mapfile -t preset_args <<<"$preset_out" + if [[ -n ${preset_args[0]:-} ]]; then + conf=${preset_args[0]} + drop_ins=0 + fi + preset_option_args "${preset_args[@]:1}" || exit 1 +fi +[[ -r $conf ]] || exit 1 + +files=("$conf") +if (( drop_ins )) && [[ -d $conf_dir ]]; then + while IFS= read -r -d '' name; do + if [[ -r $conf_dir/$name ]]; then + files+=("$conf_dir/$name") + fi + done < <(LC_ALL=C.UTF-8 find "$conf_dir" -maxdepth 1 -xtype f -name '*.conf' -print0 | sed -z 's/.*\///' | LC_ALL=C.UTF-8 sort -zVu) +fi + +joined=$(mktemp) +trap 'rm -f "$joined"' EXIT +cat -- "${files[@]}" >"$joined" +# A scalar HOOKS is split on spaces, as mkinitcpio's arrayize_config does. +hooks_out=$(bash -c 'unset HOOKS; . "$1" >/dev/null 2>&1 || exit 1 + set -f; [[ ${HOOKS@a} == *a* ]] || IFS=" " read -r -a HOOKS <<<"$HOOKS" + printf "%s\n" "${HOOKS[@]}"' _ "$joined") || exit 1 +mapfile -t hooks <<<"$hooks_out" +effective=() +for hook in "${hooks[@]}" "${add_hooks[@]}"; do + if [[ -n $hook && " ${skip_hooks[*]} " != *" $hook "* ]]; then + effective+=("$hook") + fi +done +(( ${#effective[@]} )) || exit 1 +printf '%s\n' "${effective[*]}" diff --git a/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-limine-cmdline b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-limine-cmdline new file mode 100755 index 00000000000..439de9eda44 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-limine-cmdline @@ -0,0 +1,94 @@ +#!/bin/bash + +# omarchy:summary=Derive Limine's kernel command line from GRUB's defaults on an Apple Silicon Mac +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# /etc/default/grub stays the one place the encrypt flow, the owner's re-key +# and the console leaf write the kernel command line. This turns it into +# Limine's KERNEL_CMDLINE[default]: root=UUID= of the root filesystem, rw, one +# rootflags= carrying the subvolume a btrfs root mounts (fstab's subvol= or +# subvolid=; none on ext4 and other filesystems) and every flag GRUB's variables added, then +# GRUB_CMDLINE_LINUX and GRUB_CMDLINE_LINUX_DEFAULT without repeats. Both +# loaders boot the same line. Linked as /etc/boot/hooks/pre.d/20-omarchy-mac-cmdline +# it runs before every UKI rebuild; omarchy-mac-boot-update runs it too. + +set -uo pipefail +set -f + +grub_default=${OMARCHY_GRUB_DEFAULT:-/etc/default/grub} +limine_default=${OMARCHY_LIMINE_DEFAULT:-/etc/default/limine} +fstab=${OMARCHY_FSTAB:-/etc/fstab} + +[[ -f $grub_default && -f $limine_default ]] || exit 0 + +grub_value() { + sed -n "s/^$1=//p" "$grub_default" | tail -n 1 | sed -E "s/^\"(.*)\"$/\1/; s/^'(.*)'$/\1/" +} + +# The installed system's root filesystem, as fstab names it (also right from +# inside a chroot), else the mounted one, else the booted one (a snapshot +# boot runs on a tmpfs overlay with the fstab row neutralised). +root_uuid=$(awk '$1 !~ /^#/ && $2 == "/" && $1 ~ /^UUID=/ { sub(/^UUID=/, "", $1); print $1; exit }' "$fstab" 2>/dev/null) +[[ -n $root_uuid ]] || root_uuid=$(findmnt -no UUID / 2>/dev/null) +[[ -n $root_uuid ]] || root_uuid=$(grep -Eo '(^|[[:space:]])root=UUID=[^[:space:]]+' "${OMARCHY_CMDLINE:-/proc/cmdline}" 2>/dev/null | tail -n 1 | sed 's/.*root=UUID=//') +if [[ -z $root_uuid ]]; then + # 100 and above: the limine-entry-tool hook runner aborts the rebuild on + # it; anything lower is a warning it would build the UKI over. + echo "omarchy-mac-limine-cmdline: cannot tell the root filesystem UUID; $limine_default keeps its command line" >&2 + exit 100 +fi + +# The root filesystem the same way: fstab's row, else the mounted root. A +# snapshot boot (an overlay, no fstab row) is Omarchy's btrfs @. Only btrfs +# takes subvol=; ext4 refuses it and the boot stops in the emergency shell. +root_fstype="" root_options="" +read -r root_fstype root_options < <(awk '$1 !~ /^#/ && $2 == "/" { print $3, $4; exit }' "$fstab" 2>/dev/null) || true +[[ -n $root_fstype ]] || read -r root_fstype root_options < <(findmnt -no FSTYPE,OPTIONS / 2>/dev/null) || true +case $root_fstype in + '' | overlay | tmpfs) root_fstype=btrfs root_options=subvol=@ ;; +esac +# subvol= names it; an fstab that selects it by subvolid= keeps that. +rootflags=() +if [[ $root_fstype == btrfs ]]; then + selector=$(tr ',' '\n' <<<"$root_options" | sed -n -E 's/^subvol=\/*([^/].*)$/subvol=\1/p; s/^subvol=\/*$/subvol=\//p' | tail -n 1) + [[ -n $selector ]] || selector=$(tr ',' '\n' <<<"$root_options" | grep -E '^subvolid=[0-9]+$' | tail -n 1) + [[ -z $selector ]] || rootflags=("$selector") +fi +words=() +for word in $(grub_value GRUB_CMDLINE_LINUX) $(grub_value GRUB_CMDLINE_LINUX_DEFAULT); do + case $word in + root=* | rw | ro) ;; + rootflags=*) + IFS=, read -r -a flags <<<"${word#rootflags=}" + for flag in "${flags[@]}"; do + case $flag in + '' | subvol=* | subvolid=*) ;; + *) [[ " ${rootflags[*]-} " == *" $flag "* ]] || rootflags+=("$flag") ;; + esac + done + ;; + *) [[ " ${words[*]-} " == *" $word "* ]] || words+=("$word") ;; + esac +done + +cmdline="root=UUID=$root_uuid rw" +(( ${#rootflags[@]} == 0 )) || cmdline+=" rootflags=$(IFS=,; printf '%s' "${rootflags[*]}")" +[[ -z ${words[*]-} ]] || cmdline+=" ${words[*]}" + +# A command line that cannot be written must stop the rebuild (100 and +# above), or the UKI would be built with the previous one. +write_failed() { + echo "omarchy-mac-limine-cmdline: could not update $limine_default; $*" >&2 + exit 100 +} +tmp=$(mktemp) || write_failed "no temporary file" +trap 'rm -f "$tmp"' EXIT +awk -v line="KERNEL_CMDLINE[default]=\"$cmdline\"" ' + /^KERNEL_CMDLINE\[default\]/ { if (!done) { print line; done = 1 }; next } + { print } + END { if (!done) print line } +' "$limine_default" >"$tmp" || write_failed "could not stage the new command line" +if ! cmp -s "$tmp" "$limine_default"; then + install -m644 "$tmp" "$limine_default" || write_failed "could not install the new command line" +fi diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/90-omarchy-mac.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/90-omarchy-mac.conf new file mode 100644 index 00000000000..0457b32d8a1 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/90-omarchy-mac.conf @@ -0,0 +1,18 @@ +# Generated by the Omarchy Apple Silicon image builder. +_omarchy_asahi_hooks=() +_omarchy_asahi_added=false +for _omarchy_asahi_hook in "${HOOKS[@]}"; do + if [[ $_omarchy_asahi_hook == asahi ]]; then + _omarchy_asahi_added=true + fi + if [[ $_omarchy_asahi_hook == filesystems && $_omarchy_asahi_added == false ]]; then + _omarchy_asahi_hooks+=(asahi omarchy-vendorfw) + _omarchy_asahi_added=true + fi + _omarchy_asahi_hooks+=("$_omarchy_asahi_hook") +done +if [[ $_omarchy_asahi_added == false ]]; then + _omarchy_asahi_hooks+=(asahi omarchy-vendorfw) +fi +HOOKS=("${_omarchy_asahi_hooks[@]}") +unset _omarchy_asahi_hooks _omarchy_asahi_hook _omarchy_asahi_added diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/91-omarchy-mac-encrypt.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/91-omarchy-mac-encrypt.conf new file mode 100644 index 00000000000..306582fb00e --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/91-omarchy-mac-encrypt.conf @@ -0,0 +1,90 @@ +# Insert omarchy-mac-encrypt after vendorfw/block and sd-encrypt immediately +# before filesystems, each only if that hook is absent. 90-omarchy-mac.conf +# already put asahi and omarchy-vendorfw before filesystems; this drop-in is +# sourced after it. +# +# Both are systemd initrd units: the systemd hook replaces udev (mkinitcpio's +# stock HOOKS line) and keymap/consolefont become sd-vconsole. A HOOKS line +# carrying the busybox encrypt hook belongs to a Mac unlocked by cryptdevice=, +# which sd-encrypt cannot parse: that line is left exactly as it is. +_omarchy_mac_encrypt_hooks=() +_omarchy_mac_encrypt_have_systemd=false +_omarchy_mac_encrypt_have_vconsole=false +_omarchy_mac_encrypt_have_encrypt=false +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_encrypt_hook in + systemd) _omarchy_mac_encrypt_have_systemd=true ;; + sd-vconsole) _omarchy_mac_encrypt_have_vconsole=true ;; + encrypt) _omarchy_mac_encrypt_have_encrypt=true ;; + esac +done +if [[ $_omarchy_mac_encrypt_have_encrypt == false ]]; then +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_encrypt_hook in + udev) + if [[ $_omarchy_mac_encrypt_have_systemd == false ]]; then + _omarchy_mac_encrypt_hooks+=(systemd) + _omarchy_mac_encrypt_have_systemd=true + fi + ;; + keymap|consolefont) + if [[ $_omarchy_mac_encrypt_have_vconsole == false ]]; then + _omarchy_mac_encrypt_hooks+=(sd-vconsole) + _omarchy_mac_encrypt_have_vconsole=true + fi + ;; + *) _omarchy_mac_encrypt_hooks+=("$_omarchy_mac_encrypt_hook") ;; + esac +done +if [[ $_omarchy_mac_encrypt_have_systemd == false ]]; then + if [[ ${_omarchy_mac_encrypt_hooks[0]:-} == base ]]; then + _omarchy_mac_encrypt_hooks=(base systemd "${_omarchy_mac_encrypt_hooks[@]:1}") + else + _omarchy_mac_encrypt_hooks=(systemd "${_omarchy_mac_encrypt_hooks[@]}") + fi +fi +HOOKS=("${_omarchy_mac_encrypt_hooks[@]}") +_omarchy_mac_encrypt_hooks=() +_omarchy_mac_encrypt_have_ours=false +_omarchy_mac_encrypt_have_sd=false +_omarchy_mac_encrypt_added_ours=false +_omarchy_mac_encrypt_added_sd=false +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + if [[ $_omarchy_mac_encrypt_hook == omarchy-mac-encrypt ]]; then + _omarchy_mac_encrypt_have_ours=true + fi + if [[ $_omarchy_mac_encrypt_hook == sd-encrypt ]]; then + _omarchy_mac_encrypt_have_sd=true + fi +done +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + if [[ $_omarchy_mac_encrypt_hook == sd-encrypt && $_omarchy_mac_encrypt_have_ours == false && + $_omarchy_mac_encrypt_added_ours == false ]]; then + _omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt) + _omarchy_mac_encrypt_added_ours=true + fi + if [[ $_omarchy_mac_encrypt_hook == filesystems ]]; then + if [[ $_omarchy_mac_encrypt_have_ours == false && $_omarchy_mac_encrypt_added_ours == false ]]; then + _omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt) + _omarchy_mac_encrypt_added_ours=true + fi + if [[ $_omarchy_mac_encrypt_have_sd == false && $_omarchy_mac_encrypt_added_sd == false ]]; then + _omarchy_mac_encrypt_hooks+=(sd-encrypt) + _omarchy_mac_encrypt_added_sd=true + fi + fi + _omarchy_mac_encrypt_hooks+=("$_omarchy_mac_encrypt_hook") +done +if [[ $_omarchy_mac_encrypt_have_ours == false && $_omarchy_mac_encrypt_added_ours == false ]]; then + _omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt) +fi +if [[ $_omarchy_mac_encrypt_have_sd == false && $_omarchy_mac_encrypt_added_sd == false ]]; then + _omarchy_mac_encrypt_hooks+=(sd-encrypt) +fi +HOOKS=("${_omarchy_mac_encrypt_hooks[@]}") +fi +unset _omarchy_mac_encrypt_hooks _omarchy_mac_encrypt_hook \ + _omarchy_mac_encrypt_have_ours _omarchy_mac_encrypt_have_sd \ + _omarchy_mac_encrypt_added_ours _omarchy_mac_encrypt_added_sd \ + _omarchy_mac_encrypt_have_systemd _omarchy_mac_encrypt_have_vconsole \ + _omarchy_mac_encrypt_have_encrypt diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/93-omarchy-mac-plymouth.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/93-omarchy-mac-plymouth.conf new file mode 100644 index 00000000000..75cbbb50d83 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/93-omarchy-mac-plymouth.conf @@ -0,0 +1,18 @@ +# Plymouth draws the disk password prompt and the boot splash, as on x86 +# Omarchy. It goes right after systemd so its initrd units order correctly, +# only when the hook is installed, and never twice. +if [[ -f /usr/lib/initcpio/install/plymouth && " ${HOOKS[*]} " != *" plymouth "* ]]; then + _omarchy_mac_plymouth_hooks=() + _omarchy_mac_plymouth_added=false + for _omarchy_mac_plymouth_hook in "${HOOKS[@]}"; do + _omarchy_mac_plymouth_hooks+=("$_omarchy_mac_plymouth_hook") + if [[ $_omarchy_mac_plymouth_hook == systemd && $_omarchy_mac_plymouth_added == false ]]; then + _omarchy_mac_plymouth_hooks+=(plymouth) + _omarchy_mac_plymouth_added=true + fi + done + if [[ $_omarchy_mac_plymouth_added == true ]]; then + HOOKS=("${_omarchy_mac_plymouth_hooks[@]}") + fi + unset _omarchy_mac_plymouth_hooks _omarchy_mac_plymouth_added _omarchy_mac_plymouth_hook +fi diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/94-omarchy-mac-vconsole.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/94-omarchy-mac-vconsole.conf new file mode 100644 index 00000000000..9547df77813 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/94-omarchy-mac-vconsole.conf @@ -0,0 +1,46 @@ +# The disk passphrase prompt types with the owner's keyboard layout, as on +# x86 Omarchy: sd-vconsole loads KEYMAP on the console (systemd-ask-password) +# and /etc/vconsole.conf gives Plymouth its XKBLAYOUT. The aarch64 +# omarchy-settings drops upstream's omarchy_hooks.conf, so this drop-in +# carries its guard: a layout that does not type Latin letters stays out of +# the initramfs, because a Latin passphrase would be untypeable in it +# (upstream #6229). The prompt then uses the kernel's US map, which is what +# such a passphrase was typed with. +# +# A systemd HOOKS line gets sd-vconsole exactly once, after keyboard (or +# after systemd without one); keymap and consolefont are its busybox +# counterparts and never belong on such a line. A busybox line (a Mac +# unlocked by cryptdevice=, which 91 leaves alone) keeps its hooks; it only +# gets the file for Plymouth, as upstream does. +# No vconsole.conf is the kernel's US map: sd-vconsole stays, nothing to bundle. +_omarchy_mac_vconsole_latin=true +if [[ -f /etc/vconsole.conf ]]; then + _omarchy_mac_vconsole_layout=$(unset XKBLAYOUT; . /etc/vconsole.conf 2>/dev/null; printf '%s' "${XKBLAYOUT:-}") + case ${_omarchy_mac_vconsole_layout%%,*} in + af | am | ara | bd | bg | by | et | ge | gr | il | in | iq | ir | kg | kh | kz | la | lk | mk | mm | mn | mv | np | rs | ru | sy | th | tj | ua) + _omarchy_mac_vconsole_latin=false ;; + esac +fi + +if [[ " ${HOOKS[*]} " == *" systemd "* ]]; then + _omarchy_mac_vconsole_hooks=() + _omarchy_mac_vconsole_anchor=systemd + [[ " ${HOOKS[*]} " != *" keyboard "* ]] || _omarchy_mac_vconsole_anchor=keyboard + for _omarchy_mac_vconsole_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_vconsole_hook in + sd-vconsole | keymap | consolefont) continue ;; + esac + _omarchy_mac_vconsole_hooks+=("$_omarchy_mac_vconsole_hook") + if [[ $_omarchy_mac_vconsole_hook == "$_omarchy_mac_vconsole_anchor" && $_omarchy_mac_vconsole_latin == true ]]; then + _omarchy_mac_vconsole_hooks+=(sd-vconsole) + _omarchy_mac_vconsole_anchor= + fi + done + HOOKS=("${_omarchy_mac_vconsole_hooks[@]}") +fi + +if [[ $_omarchy_mac_vconsole_latin == true && -f /etc/vconsole.conf ]]; then + FILES+=(/etc/vconsole.conf) +fi +unset _omarchy_mac_vconsole_latin _omarchy_mac_vconsole_layout _omarchy_mac_vconsole_hooks \ + _omarchy_mac_vconsole_anchor _omarchy_mac_vconsole_hook diff --git a/test/fixtures/mac-migrate/runtime/install/config/locale.sh b/test/fixtures/mac-migrate/runtime/install/config/locale.sh new file mode 100644 index 00000000000..e70713fb358 --- /dev/null +++ b/test/fixtures/mac-migrate/runtime/install/config/locale.sh @@ -0,0 +1,50 @@ +# An image built from a distribution's root tarball rather than the ISO (Arch +# Linux ARM's ships LANG=C) never went through the ISO's locale step, so it +# runs non-UTF-8: byte-wise sorting, ASCII-only \u escapes, and any tool that +# reads the locale for its encoding. +# Root always writes the real files; the overrides are for unprivileged tests. +locale_conf=/etc/locale.conf +locale_gen=/etc/locale.gen +if (( EUID != 0 )); then + locale_conf=${OMARCHY_LOCALE_CONF:-$locale_conf} + locale_gen=${OMARCHY_LOCALE_GEN:-$locale_gen} +fi + +# Repair only the stock state -- an unset LANG, or the bare C/POSIX the image +# ships. Any named locale is somebody's choice, C.UTF-8 included, so leave it. +# A machine with no locale.conf at all reads as unset, not as a failure: +# under pipefail the missing file would otherwise abort the installer. +current=$(sed -n 's/^LANG=//p' "$locale_conf" 2>/dev/null | tail -1 | tr -d '"') || current="" + +case ${current:-C} in + C | POSIX) ;; + *) + echo "Leaving the locale as $current" + return 0 2>/dev/null || exit 0 + ;; +esac + +echo "Setting up locale (en_US.UTF-8)..." + +if ! locale -a 2>/dev/null | grep -qi "en_US.utf-\?8"; then + if grep -q '^#en_US.UTF-8' "$locale_gen" 2>/dev/null; then + sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' "$locale_gen" + elif ! grep -q '^en_US.UTF-8' "$locale_gen" 2>/dev/null; then + echo "en_US.UTF-8 UTF-8" >>"$locale_gen" + fi + + locale-gen >/dev/null 2>&1 +fi + +# Only LANG changes; LC_* lines somebody set stay. +if grep -q '^LANG=' "$locale_conf" 2>/dev/null; then + sed -i 's/^LANG=.*/LANG=en_US.UTF-8/' "$locale_conf" +else + echo "LANG=en_US.UTF-8" >>"$locale_conf" +fi + +# The session that ran this keeps its inherited LANG; everything after it here +# should see the new one. +export LANG=en_US.UTF-8 + +echo "Locale set to en_US.UTF-8" diff --git a/test/shell.d/mac-migrate-legacy-test.sh b/test/shell.d/mac-migrate-legacy-test.sh new file mode 100644 index 00000000000..8c4861e6b51 --- /dev/null +++ b/test/shell.d/mac-migrate-legacy-test.sh @@ -0,0 +1,894 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/test/fixtures/mac-migrate/lib.sh" + +# bin/omarchy-mac-migrate moves a legacy omarchy-mac Mac (the quattro fork) +# onto Omarchy's official edge: the omarchy-dev pair, the Mac packages and the +# Aurora chain. Legacy fixtures turn on the stand-in pacman's file ownership, +# signature trust and removal dependencies: a transaction refuses to write over +# a file it does not own, and a package from a repository that requires +# signatures must be signed by a key the keyring trusts. + +fork=FBD6874D423C418DDB6D143EECE19CDDE306DBD2 +alarm=1111111111111111111111111111111111111111 +asahi=2222222222222222222222222222222222222222 +checkout=/home/owner/.local/share/omarchy +# omarchy-mac-boot's HOOKS resolver, its Limine command line and its +# mkinitcpio drop-ins, as omacom/omarchy-mac-pkgs 4399105 ships them. +mac_boot=$ROOT/test/fixtures/mac-migrate/mac-boot + +# ships PACKAGE PATH...: the files a package installs. +ships() { + local name=$1 + shift + printf '%s\n' "$@" >"$F/files/$name" +} + +# owns PACKAGE PATH...: installed files and their owner. +owns() { + local name=$1 path + shift + for path; do + mkdir -p "$(dirname "$R$path")" + echo "$name" >"$R$path" + echo "$name $path" >>"$R/var/lib/pacman/local/files" + done +} + +# unowned CONTENT PATH...: files the checkout's setup wrote. +unowned() { + local content=$1 path + shift + for path; do + mkdir -p "$(dirname "$R$path")" + echo "$content" >"$R$path" + done +} + +# A legacy Mac on the Asahi kernel and GRUB, unencrypted, with the fork's +# repository, keyring and busybox HOOKS line; the signed candidate set as the +# administrator's target, with the edge repository for everything else. LAYOUT +# is checkout (a 3.x checkout upgraded to Quattro: no omarchy package, the +# checkout wired into /usr) or channel (the pair from an rc lane, a dev link to +# a checkout). +new_fixture() { + local name=$1 layout=$2 link + F=$tmp/$name + R=$F/root + rm -rf "$F" + mkdir -p "$R" "$F/files" + mkdir -p "$R/run/systemd/system" "$R/run/lock" "$R/var/tmp" "$R/proc/sys/kernel/random" "$R/etc/default" "$R/etc/omarchy-mac" \ + "$R/var/lib/pacman/local" "$R/var/lib/pacman/sync" "$R/var/cache/pacman/pkg" "$R/etc/pacman.d/gnupg" \ + "$R/usr/share/pacman/keyrings" "$R/usr/share/limine" "$R/boot/grub" "$R/boot/efi/EFI/BOOT" "$R/boot/efi/m1n1" \ + "$R/usr/lib/modules/6.19.1-asahi" "$R/usr/lib/modules/7.1.12-aurora" "$R/var/lib/omarchy" "$R/usr/bin" \ + "$R/var/cache/omarchy/channels/transaction.Stale01" "$R/etc/sudoers.d" "$R/etc/mkinitcpio.conf.d" + echo boot-1 >"$R/proc/sys/kernel/random/boot_id" + echo 6.19.1-asahi >"$R/proc/sys/kernel/osrelease" + echo linux-asahi >"$R/usr/lib/modules/6.19.1-asahi/pkgbase" + echo linux-aurora >"$R/usr/lib/modules/7.1.12-aurora/pkgbase" + echo "menuentry linux-asahi" >"$R/boot/grub/grub.cfg" + echo grub >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" + echo m1n1 >"$R/boot/efi/m1n1/boot.bin" + echo "limine 12.9" >"$R/usr/share/limine/BOOTAA64.EFI" + echo 'GRUB_CMDLINE_LINUX=""' >"$R/etc/default/grub" + mkdir -p "$R/etc/sddm.conf.d" + printf '[Autologin]\nUser=owner\nSession=omarchy.desktop\n' >"$R/etc/sddm.conf.d/autologin.conf" + for keyring in archlinuxarm asahi-alarm; do + : >"$R/usr/share/pacman/keyrings/$keyring.gpg" + done + echo "$alarm" >"$R/usr/share/pacman/keyrings/archlinuxarm-trusted" + echo "$asahi" >"$R/usr/share/pacman/keyrings/asahi-alarm-trusted" + printf '%s f\n%s f\n%s f\n' "$alarm" "$asahi" "$fork" >"$R/etc/pacman.d/gnupg/keys" + mkdir -p "$F/keyserver" + : >"$F/keyserver/$official" + : >"$R/var/lib/pacman/local/files" + + # The checkout, never written by the migration. + mkdir -p "$R$checkout/bin" "$R$checkout/.git" "$R$checkout/default/bash" + for name in omarchy-update omarchy-hw-apple omarchy-upgrade-to-quattro-mac; do + echo "checkout $name" >"$R$checkout/bin/$name" + done + echo "checkout env" >"$R$checkout/default/bash/env-bootstrap" + echo "4.0.3" >"$R$checkout/version" + + repo core <<<"pacman 7.0.0-1 $alarm" + printf 'hyprland 0.51-1 %s\nlimine 12.9.0-1 %s\n' "$alarm" "$alarm" | repo extra + printf 'linux-asahi 6.19.1-1 %s\nm1n1 1.5.0-1 %s\nuboot-asahi 2026.01-1 %s\nasahi-alarm-keyring 20250101-1 %s\n' "$asahi" "$asahi" "$asahi" "$asahi" | + repo asahi-alarm + sed "s/\$/ $official/" <<'EDGE' | repo omarchy +omarchy 4.0.4-1 +omarchy-settings 4.0.4-1 +omarchy-dev 4.0.0.r6713.ga85e29a-1 +omarchy-settings-dev 4.0.0.r6713.ga85e29a-1 +omarchy-mac 0.1.0-6 +omarchy-mac-boot 20260927-1 +linux-aurora 7.1.12.aurora2-10 +linux-aurora-headers 7.1.12.aurora2-10 +m1n1-aurora 1.6.1.aurora1-3 +uboot-asahi 2026.07.asahi2-4 +limine-mkinitcpio-hook 1.39.0-2 +omarchy-keyring 20260920-1 +ttf-jetbrains-mono-nerd-basic 3.4.0-2 +quickshell-git 0.2-1 +EDGE + sed "s/\$/ $fork/" <<'FORK' | repo omarchy-aarch64 +omarchy 4.0.3rc4-1 +omarchy-settings 4.0.3rc4-1 +omarchy-mac-keyring 20260914-2 +quickshell-git 0.1-1 +voxtype 1.0-1 +FORK + cp "$F/repos/omarchy-aarch64/omarchy-aarch64.db" "$R/var/lib/pacman/sync/" + alarm_repos + relations + echo "omarchy 4.0.3rc4-1 omarchy-mac-keyring omarchy-settings" >"$F/depends" + : >"$F/verify-signatures" + + ships omarchy-dev /usr/share/omarchy/bin/omarchy-update /usr/share/omarchy/default/bash/env-bootstrap /usr/bin/omarchy-update + ships omarchy-settings-dev /etc/sddm.conf.d/10-theme.conf /etc/profile.d/omarchy.sh /usr/share/uwsm/env.d/10-omarchy + echo /etc/sddm.conf.d/10-theme.conf >"$F/backups" + # The legacy detector alias belongs to omarchy-mac. + ships omarchy-mac /usr/bin/omarchy-hw-apple + ships omarchy-keyring /usr/share/pacman/keyrings/omarchy.gpg /usr/share/pacman/keyrings/omarchy-trusted + ships ttf-jetbrains-mono-nerd-basic /usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf + owns omarchy-mac-keyring /usr/share/pacman/keyrings/omarchy-mac.gpg /usr/share/pacman/keyrings/omarchy-mac-revoked + printf '%s:4:\n' "$fork" >"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" + echo "omarchy-mac-keyring /usr/share/pacman/keyrings/omarchy-mac-trusted" >>"$R/var/lib/pacman/local/files" + + if [[ $layout == "checkout" ]]; then + cat >"$R/var/lib/pacman/local/packages" <<'LOCAL' +asahi-alarm-keyring 20250101-1 +hyprland 0.50-1 +limine 12.9.0-1 +linux-asahi 6.19.1-1 +m1n1 1.5.0-1 +omarchy-mac-keyring 20260914-2 +pacman 7.0.0-1 +quickshell-git 0.1-1 +uboot-asahi 2026.01-1 +voxtype 1.0-1 +LOCAL + # What omarchy-upgrade-to-quattro-mac wired, and the files its setup wrote. + ln -s "$R$checkout" "$R/usr/share/omarchy" + for link in omarchy-update omarchy-hw-apple omarchy-upgrade-to-quattro-mac; do + ln -s "$R$checkout/bin/$link" "$R/usr/bin/$link" + done + printf 'export OMARCHY_PATH="%s"\n' "$checkout" >"$R/etc/omarchy.conf" + unowned "legacy theme" /etc/sddm.conf.d/10-theme.conf + unowned "HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck)" \ + /etc/mkinitcpio.conf.d/omarchy_hooks.conf + unowned "legacy profile" /etc/profile.d/omarchy.sh + unowned "legacy uwsm" /usr/share/uwsm/env.d/10-omarchy + else + cat >"$R/var/lib/pacman/local/packages" <<'LOCAL' +asahi-alarm-keyring 20250101-1 +hyprland 0.50-1 +limine 12.9.0-1 +linux-asahi 6.19.1-1 +m1n1 1.5.0-1 +omarchy 4.0.3rc4-1 +omarchy-keyring 20260801-1 +omarchy-mac-keyring 20260914-2 +omarchy-settings 4.0.3rc4-1 +pacman 7.0.0-1 +quickshell-git 0.1-1 +ttf-jetbrains-mono-nerd-basic 3.4.0-1 +uboot-asahi 2026.01-1 +voxtype 1.0-1 +LOCAL + owns omarchy /usr/share/omarchy/bin/omarchy-update /usr/share/omarchy/bin/omarchy-upgrade-to-quattro-mac \ + /usr/share/omarchy/default/bash/env-bootstrap /usr/bin/omarchy-update + owns omarchy-settings /etc/sddm.conf.d/10-theme.conf /etc/mkinitcpio.conf.d/omarchy_hooks.conf /etc/profile.d/omarchy.sh + owns omarchy-keyring /usr/share/pacman/keyrings/omarchy.gpg /usr/share/pacman/keyrings/omarchy-trusted + echo "$official" >"$R/usr/share/pacman/keyrings/omarchy-trusted" + owns ttf-jetbrains-mono-nerd-basic /usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf + unowned "legacy uwsm" /usr/share/uwsm/env.d/10-omarchy + # A developer's link to a checkout; root's sudo path runs it. + printf 'export OMARCHY_PATH="%s"\n' "$checkout" >"$R/etc/omarchy.conf" + echo "Defaults secure_path=\"$checkout/bin:/usr/local/sbin:/usr/local/bin:/usr/bin\"" >"$R/etc/sudoers.d/omarchy-dev-path" + fi + + cat >"$R/etc/pacman.conf" <"$R/etc/pacman.d/mirrorlist.asahi-alarm" + if [[ $layout == "channel" ]]; then + # rc5's strict fork repository, and [omarchy] as omarchy-upgrade-to-quattro writes it. + sed -i -e 's/^SigLevel = Optional TrustAll$/SigLevel = PackageRequired DatabaseRequired TrustedOnly/' -e '/^Usage = Sync$/d' \ + -e '/^\[omarchy\]$/,/^Server/s/^SigLevel = Required DatabaseOptional$/SigLevel = Optional TrustAll/' "$R/etc/pacman.conf" + fi + # Copies the fork's tools left: arm-package-sources' .bak, the Quattro upgrade's timestamped one. + printf '[omarchy-aarch64]\nSigLevel = Optional TrustAll\n' >"$R/etc/pacman.conf.bak" + printf '[omarchy]\nSigLevel = Optional TrustAll\n' >"$R/etc/pacman.conf.omarchy-upgrade-to-quattro.20260801000000.bak" + cp -r "$tmp/set" "$F/set" + cat >"$R/etc/omarchy-mac/migration-target" <"$F/platform" + echo "base asahi udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck" >"$F/hooks" + printf '%s\n' "$R/boot/efi" >"$F/mounts" + echo "/dev/nvme0n1p6[/@]" >"$F/root-source" + printf '/dev/nvme0n1p6 part btrfs\n/dev/nvme0n1 disk \n' >"$F/lsblk" + : >"$F/pacman.log" + : >"$F/boot.log" +} + +checkout_digest() { + (cd "$R$checkout" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) | sha256sum +} + +# Links as well as files: a refusal must leave the checkout wired as it was. +links_digest() { + (cd "$R" && find . -path ./var/tmp -prune -o -type l -print | LC_ALL=C sort | while read -r link; do + printf '%s -> %s\n' "$link" "$(readlink "$link")" + done) | sha256sum +} + +legacy_refused() { # description reason-pattern + local links + links=$(links_digest) + refused "$1" "$2" + [[ $(links_digest) == "$links" ]] || fail "$1: no link changed" +} + +# TrustAll anywhere pacman's configuration lives: pacman.conf, every file it +# includes and every copy of it beside it. +trustall_anywhere() { + local files=("$R/etc/pacman.conf" "$R"/etc/pacman.conf.*) path + while read -r path; do + files+=("$R$path") + done < <(sed -n 's/^Include = //p' "$R/etc/pacman.conf" | sort -u) + grep -l TrustAll "${files[@]}" 2>/dev/null || true +} + +# The core Apple Silicon configuration for SERVER, as the fixtures serve it. +core_conf() { + OMARCHY_MAC_MIGRATE_ROOT=$R fixture=1 bash -c 'source <(sed -n "/^core_pacman_conf() {/,/^}/p" "$1"); core_pacman_conf "$2"' _ "$ROOT/migrate/src/target.sh" "$1" | + sed "s|^Server = https://github.com/asahi-alarm.*|Server = file://FIXTURE/repos/asahi-alarm|" +} + +# Everything a finished conversion leaves, however it got there. +outcome() { + local state + state=$(state_dir) + cat "$R/var/lib/pacman/local/packages" + LC_ALL=C sort "$R/var/lib/pacman/local/files" + sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf" + sort "$R/etc/pacman.d/gnupg/keys" + (cd "$R" && find usr/bin usr/share/omarchy usr/share/uwsm etc/sddm.conf.d etc/mkinitcpio.conf.d etc/profile.d etc/sudoers.d \ + usr/share/pacman/keyrings var/cache/omarchy \( -type f -o -type l \) | LC_ALL=C sort | while read -r path; do + if [[ -L $path ]]; then echo "$path -> $(readlink "$path" | sed "s|$R|ROOT|")"; else echo "$path: $(head -c 80 "$path")"; fi + done) + cat "$R/etc/omarchy.conf" "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" "$R/etc/default/limine" + (cd "$R/etc" && ls -d pacman.conf*) + ls "$R/var/lib/pacman/sync" + [[ ! -e $R/var/lib/pacman/db.lck ]] && echo unlocked + sed -n 's/^target=//p' "$state/complete" + (cd "$state/backup" && find . -type f | LC_ALL=C sort) + sed "s|$R|ROOT|g" "$state/backup/converted/links" + ls "$state" + [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && echo "no unit" + checkout_digest +} + +# --- A checkout upgraded to Quattro ------------------------------------------------ + +new_fixture baseline checkout +before=$(checkout_digest) +output=$(migrate run 2>&1) || fail "a legacy checkout Mac migrates to its reboot" "$output" +grep -q "Reboot to finish the migration to candidate-set apple-test-fixture" <<<"$output" || fail "the run asks for a reboot" "$output" +state=$(state_dir) +[[ $(<"$state/plan/cohort") == "legacy" ]] || fail "the Mac is a legacy omarchy-mac install" "$(cat "$state/plan/cohort")" +pass "an unencrypted legacy Mac with the fork's busybox HOOKS line is not refused" + +expected_packages='asahi-alarm-keyring 20250101-1 +hyprland 0.51-1 +limine 12.9.0-1 +limine-mkinitcpio-hook 1.39.0-2 +linux-aurora 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +omarchy-dev 4.0.0.r7000.gabc-1.1 +omarchy-keyring 20260920-1 +omarchy-mac 0.1.0-11.1 +omarchy-mac-boot 20261004-1.1 +omarchy-settings-dev 4.0.0.r7000.gabc-1.1 +pacman 7.0.0-1 +quickshell-git 0.2-1 +uboot-asahi 2026.07.asahi2-4 +voxtype 1.0-1' +[[ $(cat "$R/var/lib/pacman/local/packages") == "$expected_packages" ]] || + fail "the checkout becomes the omarchy-dev pair, the fork's builds official ones, and the fork keyring goes" "$(cat "$R/var/lib/pacman/local/packages")" +[[ -d $R/usr/share/omarchy && ! -L $R/usr/share/omarchy ]] || fail "/usr/share/omarchy is the package's, not a link to the checkout" +[[ ! -L $R/usr/bin/omarchy-update ]] && grep -qx "omarchy-dev /usr/bin/omarchy-update" "$R/var/lib/pacman/local/files" || + fail "the commands are the package's" +[[ ! -e $R/usr/bin/omarchy-upgrade-to-quattro-mac ]] || fail "links to checkout commands no package ships are gone" +[[ ! -L $R/usr/bin/omarchy-hw-apple ]] && grep -qx "omarchy-mac /usr/bin/omarchy-hw-apple" "$R/var/lib/pacman/local/files" || + fail "the legacy detector alias is omarchy-mac's, not a link to the checkout" +[[ $(<"$R/etc/omarchy.conf") == 'export OMARCHY_PATH="/usr/share/omarchy"' ]] || fail "OMARCHY_PATH is the packaged tree" "$(cat "$R/etc/omarchy.conf")" +[[ $(checkout_digest) == "$before" ]] || fail "the checkout itself is untouched" +for path in /etc/profile.d/omarchy.sh /usr/share/uwsm/env.d/10-omarchy; do + grep -qx "omarchy-settings-dev $path" "$R/var/lib/pacman/local/files" || fail "omarchy-settings-dev owns $path" + [[ $(<"$R$path") == "omarchy-settings-dev 4.0.0.r7000.gabc-1.1" ]] || fail "the package's $path replaces the setup's" + [[ $(<"$state/backup/converted/files$path") == legacy* ]] || fail "the setup's own $path is kept in the backup" +done +[[ $(<"$R/etc/sddm.conf.d/10-theme.conf") == "legacy theme" && -f $R/etc/sddm.conf.d/10-theme.conf.pacnew ]] && + grep -qx "omarchy-settings-dev /etc/sddm.conf.d/10-theme.conf" "$R/var/lib/pacman/local/files" || + fail "a configuration file the package lists in backup= keeps its contents, and the package's lands as .pacnew" +grep -q "encrypt" "$R/etc/mkinitcpio.conf.d/omarchy_hooks.conf" || fail "a file no new package brings is left alone" +[[ $(sed "s|$R|ROOT|g" "$state/backup/converted/links") == "/usr/bin/omarchy-hw-apple ROOT$checkout/bin/omarchy-hw-apple +/usr/bin/omarchy-update ROOT$checkout/bin/omarchy-update +/usr/bin/omarchy-upgrade-to-quattro-mac ROOT$checkout/bin/omarchy-upgrade-to-quattro-mac +/usr/share/omarchy ROOT$checkout" ]] || fail "every link to the checkout is recorded" "$(cat "$state/backup/converted/links")" +[[ -f $state/backup/converted/files/etc/omarchy.conf ]] || fail "the checkout's omarchy.conf is kept" +grep -q "^transaction omarchy-mac-candidate/omarchy-dev omarchy-mac-candidate/omarchy-settings-dev omarchy-mac-candidate/omarchy-mac " "$F/pacman.log" || + fail "the omarchy-dev pair is named from the candidate set" "$(grep transaction "$F/pacman.log")" +grep -q "^transaction .* quickshell-git asahi-alarm-keyring omarchy-keyring$" "$F/pacman.log" || + fail "a fork build the official repository carries and the keyrings are named" "$(grep transaction "$F/pacman.log")" +grep -q "^voxtype 1.0-1$" "$state/plan/kept" && ! grep -q "omarchy-mac-keyring" "$state/plan/kept" || + fail "a fork build with no official one is kept and listed; the keyring is not" "$(cat "$state/plan/kept")" +pass "a checkout Mac is converted to the omarchy-dev pair, its checkout unwired and its unowned files backed up" + +conf=$(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") +[[ $conf == "$(core_conf "file://FIXTURE/repos/omarchy")" ]] || + fail "pacman.conf is the core Apple Silicon configuration: the fork repository, its TrustAll and [omarchy]'s own SigLevel are gone" \ + "$(diff <(core_conf "file://FIXTURE/repos/omarchy") <(echo "$conf"))" +[[ ! -e $R/var/lib/pacman/sync/omarchy-aarch64.db ]] || fail "the fork's sync database is gone" +! grep -q "^$fork " "$R/etc/pacman.d/gnupg/keys" || fail "the rc4 fork key is gone from the keyring" +grep -q "^$official f$" "$R/etc/pacman.d/gnupg/keys" || fail "the Omarchy key is trusted" +[[ ! -e $R/usr/share/pacman/keyrings/omarchy-mac.gpg && ! -e $R/usr/share/pacman/keyrings/omarchy-mac-trusted ]] || + fail "omarchy-mac-keyring is removed, so no populate trusts the fork key again" +[[ $(grep '^transaction \|^remove ' "$F/pacman.log" | cut -d' ' -f1 | xargs) == "transaction remove" ]] && grep -q "^remove omarchy-mac-keyring$" "$F/pacman.log" || + fail "the keyring is removed after the transaction that replaced what needed it" "$(cat "$F/pacman.log")" +pass "official trust only: TrustAll, the fork repository, key and keyring are gone" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the post-reboot verification completes the migration" "$output" +grep -q "The checkout at $R$checkout is no longer used" <<<"$output" || fail "retire names the unused checkout" "$output" +[[ -f $R/etc/sddm.conf.d/autologin.conf ]] || fail "an administrator's autologin is kept" +[[ -z $(ls "$R/var/cache/omarchy/channels") ]] || fail "the fork's channel transactions are retired" +[[ -z $(trustall_anywhere) ]] || fail "no TrustAll is left in pacman's configuration, its includes or copies of it" "$(trustall_anywhere)" +for file in pacman.conf.bak pacman.conf.omarchy-upgrade-to-quattro.20260801000000.bak; do + grep -q TrustAll "$state/backup/converted/files/etc/$file" || fail "the fork's $file is kept in the backup" +done +baseline=$(outcome) +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +[[ $(outcome) == "$baseline" ]] || fail "a second run changes nothing" +pass "after the reboot the fork's channel state and TrustAll copies are retired, and a second run changes nothing" + +! grep -q '^mount \|^umount \|^mkinitcpio ' "$F/boot.log" && [[ ! -e $R/etc/crypttab && ! -e $state/backup/boot-switch ]] && + [[ $(cat "$R/etc/default/grub") == 'GRUB_CMDLINE_LINUX=""' && $(cat "$F/mounts") == "$R/boot/efi" ]] || + fail "an unencrypted Mac with its ESP at /boot/efi has no boot switch to stage" "$(cat "$F/boot.log")" +grep -q "^dispatch setup-boot$" "$F/boot.log" && grep -q "^limine-boot activate$" "$F/boot.log" || + fail "the new runtime's setup-boot activates Limine" "$(cat "$F/boot.log")" +pass "an unencrypted legacy Mac keeps its layout and unlock: Limine is its only boot change" + +# --- Interruption at every checkpoint ------------------------------------------- + +interrupt() { # when point step + local when=$1 point=$2 step=$3 status=0 output last transactions=1 partial + new_fixture "kill-$when-$point" checkout + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $point" "status $status: $output" + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "$step done"* ]] || fail "killed $when $point, the journal ends with $step done" "$last" + else + [[ $last == "$step begin"* ]] || fail "killed $when $point, the journal ends with $step begun" "$last" + fi + finish + # pacman's own half-extracted files are backed up too, beside the originals. + partial='^\./converted/files/(usr/share/omarchy/|usr/bin/omarchy-)' + [[ $(outcome | grep -Ev "$partial") == "$(grep -Ev "$partial" <<<"$baseline")" ]] || + fail "killed $when $point, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $point == "extraction" || $(outcome) == "$baseline" ]] || fail "killed $when $point, the backup matches an uninterrupted one" + [[ $when$point == "midtransaction" || $when$point == "midextraction" ]] && transactions=2 + [[ $(grep -c '^transaction ' "$F/pacman.log") == "$transactions" && $(grep -c '^remove ' "$F/pacman.log") == 1 ]] || + fail "killed $when $point: $transactions package transaction(s) and one removal" "$(cat "$F/pacman.log")" + [[ $(grep '^transaction \|^remove \|^hooks' "$F/pacman.log" | tail -n 1) == "hooks" ]] || fail "killed $when $point: the last transaction's hooks ran" +} + +for step in "${steps[@]}"; do + interrupt after "$step" "$step" + interrupt during "$step" "$step" +done +for step in backup keyring prefetch repositories boot-chain loader defaults unpin reboot retire; do + interrupt mid "$step" "$step" +done +interrupt mid loader-leaf loader +for point in unwire convert extraction transaction removals; do + interrupt mid "$point" transaction +done +pass "a kill -9 at every checkpoint, the conversion's own included, resumes to the same end" + +# --- A channel Mac with a dev link ------------------------------------------------ + +new_fixture channel channel +before=$(checkout_digest) +finish +grep -q "^omarchy-dev 4.0.0.r7000.gabc-1.1$" "$R/var/lib/pacman/local/packages" && grep -q "^omarchy-settings-dev 4.0.0.r7000.gabc-1.1$" "$R/var/lib/pacman/local/packages" && + ! grep -q "^omarchy \|^omarchy-settings " "$R/var/lib/pacman/local/packages" || + fail "the lane's rc pair is replaced by the omarchy-dev pair" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^omarchy-keyring 20260920-1$" "$R/var/lib/pacman/local/packages" && grep -q "^ttf-jetbrains-mono-nerd-basic 3.4.0-2$" "$R/var/lib/pacman/local/packages" || + fail "the packages the checkout built move to their official builds" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^transaction .* quickshell-git omarchy-keyring ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring$" "$F/pacman.log" || + fail "the checkout's own builds are named" "$(grep transaction "$F/pacman.log")" +! grep -q "omarchy-mac-keyring" "$R/var/lib/pacman/local/packages" || fail "the keyring the rc pair depended on is removed" +! grep -q "omarchy-aarch64\|TrustedOnly" "$R/etc/pacman.conf" || fail "the rc5-style fork repository is gone too" +[[ -z $(trustall_anywhere) ]] || fail "the Quattro upgrade's TrustAll on [omarchy] is gone with every other" "$(trustall_anywhere)" +! grep -A2 '^\[omarchy\]$' "$R/etc/pacman.conf" | grep -q SigLevel || fail "[omarchy] inherits the global SigLevel" +! grep -q "^$fork " "$R/etc/pacman.d/gnupg/keys" || fail "the fork key is gone" +[[ ! -e $R/etc/sudoers.d/omarchy-dev-path && $(<"$R/etc/omarchy.conf") == 'export OMARCHY_PATH="/usr/share/omarchy"' ]] || + fail "a dev link to a fork checkout no longer runs as root or as Omarchy" +grep -qx "omarchy-settings-dev /usr/share/uwsm/env.d/10-omarchy" "$R/var/lib/pacman/local/files" || + fail "an unowned file the setup wrote is taken over" +[[ ! -e $R/etc/mkinitcpio.conf.d/omarchy_hooks.conf ]] || fail "the fork settings' busybox HOOKS line goes with the package" +[[ $(checkout_digest) == "$before" ]] || fail "the checkout is untouched" +pass "a channel Mac with a strict fork repository and a dev link converts to the omarchy-dev pair, keyring and link retired" + +# --- The channel the fork's lane names --------------------------------------------- + +# Without an administrator's target, the Mac follows the channel its +# [omarchy-aarch64] lane is named after. +lane_fixture() { # name layout channel + new_fixture "$1" "$2" + rm "$R/etc/omarchy-mac/migration-target" + sed -i "s|^Server = file://$F/repos/omarchy-aarch64$|Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/$3|" "$R/etc/pacman.conf" + cp -r "$F/repos/omarchy" "$F/repos/official-$3" +} + +lane_fixture lane-rc channel rc +archive omarchy 4.0.4-1 +archive omarchy-mac-boot 20260927-1 +output=$(migrate check 2>&1) || fail "an rc lane follows the rc channel" "$output" +grep -q "Ready: run moves this Mac (legacy, grub boot) onto repository file://$F/repos/official-rc (rc)" <<<"$output" || + fail "the rc lane's Mac moves to the rc channel" "$output" +grep -q "It installs: omarchy omarchy-settings omarchy-mac " <<<"$output" || fail "the rc channel keeps the stock pair" "$output" + +lane_fixture lane-edge channel edge +archive omarchy-dev 4.0.0.r6713.ga85e29a-1 +archive omarchy-mac-boot 20260927-1 +finish +grep -q "^transaction omarchy/omarchy-dev omarchy/omarchy-settings-dev omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook quickshell-git omarchy-keyring ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring$" "$F/pacman.log" || + fail "each official package is named in [omarchy]" "$(grep transaction "$F/pacman.log")" +grep -q "^omarchy-dev 4.0.0.r6713.ga85e29a-1$" "$R/var/lib/pacman/local/packages" && ! grep -q "^omarchy \|omarchy-mac-keyring" "$R/var/lib/pacman/local/packages" || + fail "the edge lane's Mac ends on the omarchy-dev pair, fork pair and keyring gone" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^download omarchy-dev \|^download omarchy-mac-boot " "$F/pacman.log" || fail "preflight reads the verified archives" +[[ $(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") == "$(core_conf "file://FIXTURE/repos/official-edge")" ]] || + fail "pacman.conf is the core configuration for the edge channel" "$(cat "$R/etc/pacman.conf")" +[[ $(sed -n 's/^target=//p' "$(state_dir)/complete") == "repository file://$F/repos/official-edge" ]] || fail "the edge channel is the migration's target" +pass "without an administrator's target, the fork lane's channel (stable, rc or edge) is the one this Mac moves to" + +# --- Refusals ------------------------------------------------------------------------ + +new_fixture refusals checkout +rm "$R/usr/share/omarchy" +legacy_refused "a 3.x checkout" "upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" +new_fixture refusals channel +sed -i 's/^\[options\]$/[options]\nIgnorePkg = omarchy omarchy-settings # omarchy-install-pair/' "$R/etc/pacman.conf" +legacy_refused "a pinned channel install" "holds back omarchy, which the migration changes" +new_fixture refusals channel +: >"$R/var/cache/omarchy/channels/transaction.Stale01/restore-sync" +legacy_refused "an unfinished channel switch" "owes its sync databases a restore" +new_fixture refusals channel +printf '%s:4:\n%s:4:\n' "$fork" 3333333333333333333333333333333333333333 >"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" +legacy_refused "a fork keyring with another key" "trusts 3333333333333333333333333333333333333333, a key this migration does not remove" +new_fixture refusals checkout +printf '\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///custom\n' >>"$R/etc/pacman.conf" +legacy_refused "a TrustAll repository the switch would keep" "\[custom\] accepts untrusted packages" +new_fixture refusals checkout +sed -i '/^\[omarchy-aarch64\]$/,/^Server/d' "$R/etc/pacman.conf" +printf '\nInclude = /etc/pacman.d/fork.conf\n' >>"$R/etc/pacman.conf" +printf '[omarchy-aarch64]\nSigLevel = Optional TrustAll\nServer = file://%s/repos/omarchy-aarch64\n' "$F" >"$R/etc/pacman.d/fork.conf" +legacy_refused "the fork repository an Include configures" "\[omarchy-aarch64\] is configured through an Include" +pass "preflight refuses pre-Quattro and mid-channel-switch legacy Macs, unknown fork keys, untrusted kept and included repositories, changing nothing" + +# --- Failures before the switch defer; after it they put things back --------------- + +# Before the repository switch a failure sets the attempt aside, nothing +# changed, and the next run starts over. +deferred() { # description reason-pattern + local status=0 conf packages + conf=$(cat "$R/etc/pacman.conf") + packages=$(cat "$R/var/lib/pacman/local/packages") + output=$(migrate run 2>&1) || status=$? + (( status == 75 )) && grep -q -- "$2" <<<"$output" || fail "$1: the migration defers" "status $status: $output" + [[ $(cat "$R/etc/pacman.conf") == "$conf" && $(cat "$R/var/lib/pacman/local/packages") == "$packages" ]] || + fail "$1: repositories and packages are untouched" + [[ -L $R/usr/share/omarchy ]] && grep -q "^$fork f$" "$R/etc/pacman.d/gnupg/keys" || fail "$1: the checkout and the fork key are untouched" + [[ ! -e $(state_dir)/journal && -n $(ls -d "$(state_dir)"/history/aborted-* 2>/dev/null) ]] || fail "$1: the attempt is set aside" + [[ $(migrate status) == *"The last run deferred: "*"$2"* ]] || fail "$1: status says why it deferred" "$(migrate status)" +} + +new_fixture fork-signed checkout +sed -i "s/^quickshell-git 0.2-1 .*/quickshell-git 0.2-1 $fork/" "$F/repos/omarchy/omarchy.db" +deferred "a package only the fork key signed" "cannot download and verify the target set" +grep -q "quickshell-git: signature from \"$fork\" is unknown trust" <<<"$output" || fail "the prefetch refuses the fork signature" "$output" +pass "packages are verified against the trust the switch leaves: one signed only by the fork key defers the migration" + +new_fixture owned checkout +echo "voxtype /usr/share/uwsm/env.d/10-omarchy" >>"$R/var/lib/pacman/local/files" +deferred "a file a kept package owns" "would overwrite /usr/share/uwsm/env.d/10-omarchy, which voxtype owns and keeps" +pass "a file another package keeps owning is never overwritten" + +new_fixture needs-keyring checkout +echo "voxtype 1.0-1 omarchy-mac-keyring" >>"$F/depends" +deferred "a kept package that needs the fork keyring" "rehearsed removal of omarchy-mac-keyring failed" +pass "the keyring's removal is rehearsed: a package still needing it defers the migration before any change" + +new_fixture prepare-fails checkout +kill_after repositories +mv "$(state_dir)/cache/pkg" "$F/pkg.moved" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "the archive of .* is not in the cache" <<<"$output" || fail "a missing archive stops the transaction" "status $status: $output" +[[ -L $R/usr/share/omarchy && -L $R/usr/bin/omarchy-update && $(<"$R/etc/omarchy.conf") == "export OMARCHY_PATH=\"$checkout\"" ]] || + fail "a conversion that cannot be prepared leaves the checkout wired" +mv "$F/pkg.moved" "$(state_dir)/cache/pkg" +finish +[[ -d $R/usr/share/omarchy && ! -L $R/usr/share/omarchy ]] || fail "the retried transaction converts the checkout" +pass "a conversion that cannot be prepared changes nothing, and the retry converts" + +new_fixture pruned checkout +echo cached >"$R/var/cache/pacman/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst" +kill_after prefetch +[[ $(stat -c %i "$(state_dir)/cache/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst") == $(stat -c %i "$R/var/cache/pacman/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst") ]] || + fail "an archive only pacman's cache holds is linked into the migration's" +rm "$R/var/cache/pacman/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst" +finish +pass "the archives the conversion reads survive pacman's cache being pruned" + +new_fixture pacman-fails channel +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && [[ -f $R/etc/sudoers.d/omarchy-dev-path && $(<"$R/etc/omarchy.conf") == "export OMARCHY_PATH=\"$checkout\"" ]] || + fail "a failed transaction gives a dev link back its OMARCHY_PATH and sudo path" "status $status: $output" +rm "$F/fail-transaction" +finish +[[ ! -e $R/etc/sudoers.d/omarchy-dev-path ]] || fail "the retry drops the dev link's sudo path" + +new_fixture pacman-fails checkout +kill_after repositories +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "the package transaction failed" <<<"$output" || fail "a failed transaction fails the step" "status $status: $output" +[[ $(readlink "$R/usr/share/omarchy") == "$R$checkout" && $(readlink "$R/usr/bin/omarchy-update") == "$R$checkout/bin/omarchy-update" ]] || + fail "the checkout's links come back when pacman fails" +grep -q "^IgnorePkg = " "$R/etc/pacman.conf" || fail "the guard stays while the transaction is owed" +rm "$F/fail-transaction" +finish +[[ -d $R/usr/share/omarchy && ! -L $R/usr/share/omarchy ]] || fail "the retried transaction converts the checkout" +pass "a failed transaction puts the checkout's links and a dev link's paths back, and the retry converts" + +# --- The boot switch -------------------------------------------------------------- + +luks_uuid=5b1f0c2e-8a44-4f1d-9d7e-3c2a1b0e9f11 +fs_uuid=0a1b2c3d-4e5f-4061-8a9b-c0d1e2f3a4b5 +converged_hooks="base systemd autodetect microcode modconf kms keyboard sd-vconsole block asahi omarchy-vendorfw omarchy-mac-encrypt sd-encrypt filesystems fsck" +fork_hooks="base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck" + +# The ESP mounted at /boot, as omarchy-system-boot-to-esp leaves it: GRUB, the +# Asahi kernel and its busybox image live on it, and the root's own /boot is an +# empty directory beneath. The fixture's mount stand-ins link a mountpoint to +# $F/esp. HOOKS and images come from omarchy-mac-boot's real resolver over +# mkinitcpio.conf and the drop-ins (the Apple boot package's and the fork's +# omarchy_hooks.conf), and the transaction runs the kernel's install hook. +esp_at_boot() { + local hooks=$1 + rm "$F/hooks" + : >"$F/kernel-hook" + install -m 755 "$mac_boot/bin/omarchy-mac-initramfs-hooks" "$mac_boot/bin/omarchy-mac-limine-cmdline" "$R/usr/bin/" + cp "$mac_boot"/mkinitcpio.conf.d/*.conf "$R/etc/mkinitcpio.conf.d/" + mkdir -p "$F/esp" "$F/covered" + mv "$R/boot/efi/EFI" "$R/boot/efi/m1n1" "$R/boot/grub" "$F/esp/" + rmdir "$R/boot/efi" + mv "$R/boot" "$F/covered/_boot" + ln -s "$F/esp" "$R/boot" + printf '%s\n' "$R/boot" >"$F/mounts" + echo UUID=4A1B-2C3D >"$F/esp-device" + echo "aurora kernel 7.1.12" >"$R/usr/lib/modules/7.1.12-aurora/vmlinuz" + echo "asahi kernel 6.19.1" >"$F/esp/vmlinuz-linux-asahi" + printf 'MODULES=(btrfs)\nHOOKS=(%s)\n' "$hooks" >"$R/etc/mkinitcpio.conf" + printf 'UUID=%s / btrfs rw,noatime,compress=zstd:3,subvol=/@ 0 0\nUUID=4A1B-2C3D /boot vfat rw,relatime,fmask=0022,dmask=0022 0 2\n' "$fs_uuid" >"$R/etc/fstab" + # The fork's line, which sorts after every Apple drop-in and sets HOOKS outright. + printf 'HOOKS=(%s)\n' "$fork_hooks" >"$R/etc/mkinitcpio.conf.d/omarchy_hooks.conf" + OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F PATH="$stubs:$PATH" mkinitcpio -p linux-asahi >/dev/null + : >"$F/boot.log" + # omarchy-mac-boot's setup-boot through the dispatcher: the menu's kernel line + # derived from GRUB's defaults by the real omarchy-mac-limine-cmdline, and a + # UKI carrying the kernel line and /boot's kernel and initramfs. It needs the + # ESP at /boot/efi. + cat >"$F/setup-boot-leaf" <<'LEAF' +root=$OMARCHY_MAC_MIGRATE_ROOT +[[ -d $root/boot/efi/EFI/BOOT ]] || { echo "limine-boot: the ESP is not mounted at /boot/efi" >&2; exit 1; } +echo "limine-boot activate" >>"$MIGRATE_FIXTURE/boot.log" +printf 'ESP_PATH="/boot/efi"\nKERNEL_CMDLINE[default]=""\n' >"$root/etc/default/limine" +OMARCHY_GRUB_DEFAULT=$root/etc/default/grub OMARCHY_LIMINE_DEFAULT=$root/etc/default/limine OMARCHY_FSTAB=$root/etc/fstab \ + "$root/usr/bin/omarchy-mac-limine-cmdline" || exit 1 +if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "loader-leaf" && ! -e $MIGRATE_FIXTURE/killed-in-leaf ]]; then + : >"$MIGRATE_FIXTURE/killed-in-leaf" + kill -9 "$PPID" $$ +fi +limine-update || exit 1 +{ sed -n 's/^KERNEL_CMDLINE\[default\]=//p' "$root/etc/default/limine"; cat "$root/boot/vmlinuz-linux-aurora" "$root/boot/initramfs-linux-aurora.img"; } \ + >"$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" +cp "$root/usr/share/limine/BOOTAA64.EFI" "$root/boot/efi/EFI/BOOT/BOOTAA64.EFI" +LEAF +} + +# An encrypted legacy Mac as the quattro guided installer (#155) left it: the +# root is LUKS2 opened as root by cryptdevice= from GRUB's defaults, and busybox +# encrypt is in mkinitcpio.conf's own HOOKS and the fork's omarchy_hooks.conf. +encrypted_fixture() { + new_fixture "$1" "${2:-checkout}" + esp_at_boot "base asahi udev autodetect microcode modconf kms keyboard keymap consolefont block encrypt filesystems fsck" + echo "$luks_uuid" >"$F/luks-uuid" + printf 'GRUB_DEFAULT=0\nGRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=%s:root:allow-discards loglevel=3 quiet splash"\nGRUB_CMDLINE_LINUX=""\n' \ + "$luks_uuid" >"$R/etc/default/grub" + printf 'menuentry Omarchy {\n linux /vmlinuz-linux-asahi root=UUID=%s rw rootflags=subvol=@ cryptdevice=UUID=%s:root:allow-discards\n initrd /initramfs-linux-asahi.img\n}\n' \ + "$fs_uuid" "$luks_uuid" >"$F/esp/grub/grub.cfg" + printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p6 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" + echo "/dev/mapper/root[/@]" >"$F/root-source" +} + +# GRUB's chain on the ESP still boots and unlocks: GRUB holds U-Boot's slot, +# its menu passes cryptdevice=, and every image on the ESP is busybox with +# encrypt. +grub_boots_esp() { + local image found=0 + [[ $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "grub" ]] && grep -q "cryptdevice=UUID=$luks_uuid:root" "$F/esp/grub/grub.cfg" || return 1 + for image in "$F"/esp/initramfs-linux-*.img; do + [[ -f $image ]] || continue + grep -qx hooks/encrypt "$image" && grep -qx init_functions "$image" || return 1 + found=1 + done + (( found )) +} + +# Everything the boot switch leaves, however it got there. +switch_outcome() { + outcome + cat "$R/etc/fstab" "$R/etc/default/grub" "$R/etc/mkinitcpio.conf" + cat "$R/etc/crypttab" 2>/dev/null || echo "no crypttab" + ls "$R/etc/mkinitcpio.conf.d" + (cd "$R/boot" && find . -type f | LC_ALL=C sort && cat initramfs-linux-aurora.img) + (cd "$F/esp" && find . -type f | LC_ALL=C sort && cat EFI/Linux/omarchy_linux-aurora.efi) + sed "s|$R|ROOT|" "$F/mounts" + [[ -L $R/boot/efi && ! -L $R/boot ]] && echo "the ESP at /boot/efi, the root's /boot beneath" + grep '^cryptsetup' "$F/pacman.log" | cut -d' ' -f2 | LC_ALL=C sort -u +} + +encrypted_fixture encrypted checkout +output=$(migrate run 2>&1) || fail "an encrypted legacy Mac migrates to its reboot" "$output" +grep -q "Reboot to finish" <<<"$output" || fail "the encrypted Mac's run asks for a reboot" "$output" +state=$(state_dir) +[[ $(<"$state/plan/adapter/unlock") == "busybox $luks_uuid 1" && $(<"$state/plan/esp") == "/boot" ]] || + fail "the plan records the busybox unlock and the ESP at /boot" "$(cat "$state/plan/adapter/unlock" "$state/plan/esp")" +pass "an encrypted legacy Mac (busybox encrypt, /boot on the ESP) is not refused" + +[[ $(sed "s|$R|ROOT|" "$F/mounts") == "ROOT/boot/efi" && -L $R/boot/efi && ! -L $R/boot ]] && + grep -qx "UUID=4A1B-2C3D /boot/efi vfat rw,relatime,fmask=0022,dmask=0022 0 2" "$R/etc/fstab" || + fail "the ESP moves from /boot to /boot/efi, in fstab and mounted" "$(cat "$R/etc/fstab" "$F/mounts")" +[[ $(<"$R/boot/vmlinuz-linux-aurora") == "aurora kernel 7.1.12" && -d $R/boot/grub ]] || + fail "the root's /boot gets the Aurora kernel and the directory update-grub needs" +[[ $(sed -n 's/^HOOKS //p' "$R/boot/initramfs-linux-aurora.img") == "$converged_hooks" ]] || + fail "the root's /boot gets the converged systemd image the Apple boot package composes" "$(cat "$R/boot/initramfs-linux-aurora.img")" +[[ $(cat "$R/etc/crypttab") == "root UUID=$luks_uuid none luks,discard" ]] || fail "crypttab names the root's LUKS partition, discards kept" "$(cat "$R/etc/crypttab")" +[[ $(cat "$R/etc/default/grub") == "GRUB_DEFAULT=0 +GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet splash\" +GRUB_CMDLINE_LINUX=\"rd.luks.name=$luks_uuid=root rd.luks.options=$luks_uuid=discard\"" ]] || + fail "GRUB's defaults trade cryptdevice= for rd.luks.name= and rd.luks.options=" "$(cat "$R/etc/default/grub")" +grep -qx "HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block filesystems fsck)" "$R/etc/mkinitcpio.conf" && + [[ ! -e $R/etc/mkinitcpio.conf.d/omarchy_hooks.conf && -f $state/backup/boot-switch/files/etc/mkinitcpio.conf.d/omarchy_hooks.conf ]] || + fail "busybox encrypt and asahi leave mkinitcpio.conf and the fork's omarchy_hooks.conf goes to the backup" "$(cat "$R/etc/mkinitcpio.conf")" +for path in etc/fstab etc/default/grub etc/mkinitcpio.conf; do + grep -q "cryptdevice\|encrypt\|/boot vfat" "$state/backup/boot-switch/files/$path" || fail "the switch keeps the original $path" +done +[[ -f $state/backup/boot-switch/absent/etc/crypttab ]] || fail "the switch records that crypttab did not exist" +pass "the ESP moves to /boot/efi and the unlock to crypttab, rd.luks.name= and the converged systemd image, each original kept" + +uki=$(cat "$F/esp/EFI/Linux/omarchy_linux-aurora.efi") +[[ $(head -n 1 <<<"$uki") == "\"root=UUID=$fs_uuid rw rootflags=subvol=@ rd.luks.name=$luks_uuid=root rd.luks.options=$luks_uuid=discard loglevel=3 quiet splash\"" ]] && + grep -qx "aurora kernel 7.1.12" <<<"$uki" && grep -qx "usr/lib/systemd/system-generators/systemd-cryptsetup-generator" <<<"$uki" || + fail "Limine's UKI boots Aurora with the systemd image and unlocks the root by rd.luks.name=, without cryptdevice=" "$uki" +[[ $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" && -e $R/var/lib/omarchy/limine.enabled ]] || fail "Limine takes U-Boot's slot" +grep -q "^HOOKS $fork_hooks$" "$F/esp/initramfs-linux-aurora.img" && grep -qx hooks/encrypt "$F/esp/initramfs-linux-aurora.img" || + fail "the transaction still built the busybox image GRUB boots" "$(cat "$F/esp/initramfs-linux-aurora.img")" +[[ $(grep -E '^(mkinitcpio|update-grub|boot-check|umount|mount|dispatch setup-boot|limine-boot)' "$F/boot.log" | tr '\n' '|') == \ + "boot-check pending --boot-chain|mkinitcpio -p linux-aurora|update-grub |boot-check pending --boot-chain linux-aurora|umount /boot|mount /boot/efi|mkinitcpio -p linux-aurora|dispatch setup-boot|limine-boot activate|boot-check pending --boot-chain linux-aurora|boot-check pending --boot-chain linux-aurora|" ]] || + fail "the busybox image and GRUB are rebuilt and checked, then the ESP moves and the new image is built, before setup-boot gives Limine the slot" "$(cat "$F/boot.log")" +[[ $(grep '^cryptsetup' "$F/pacman.log" | cut -d' ' -f2 | sort -u | xargs) == "luksHeaderBackup luksUUID" ]] || + fail "the LUKS header, keyslots and passphrase are never changed, only backed up and read" "$(grep cryptsetup "$F/pacman.log")" +pass "Limine's UKI unlocks the same LUKS root with its passphrase through sd-encrypt; the header is only backed up" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the encrypted Mac's migration completes after its reboot" "$output" +[[ -f $state/complete ]] || fail "the encrypted Mac's migration completes" +[[ ! -e $F/esp/vmlinuz-linux-aurora && ! -e $F/esp/initramfs-linux-aurora.img && ! -e $F/esp/vmlinuz-linux-asahi && ! -e $F/esp/grub ]] && + [[ -f $F/esp/m1n1/boot.bin && -f $F/esp/EFI/BOOT/BOOTAA64.EFI && -f $F/esp/EFI/Linux/omarchy_linux-aurora.efi ]] || + fail "retire removes the kernels and GRUB the moved ESP still carried, and keeps m1n1, Limine and the UKI" "$(cd "$F/esp" && find . -type f)" +tar -tf "$state/backup/esp.tar" | grep -q '^./grub/grub.cfg$' && tar -tf "$state/backup/esp.tar" | grep -q '^./vmlinuz-linux-asahi$' || + fail "the backup holds the ESP as it was, GRUB's chain included" +[[ ! -e $state/backup/boot.tar ]] || fail "an ESP at /boot is backed up once" +encrypted_baseline=$(switch_outcome) +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "a second run changes nothing" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +pass "after the verified reboot the moved ESP's old kernels and GRUB are retired, and a second run changes nothing" + +# A channel install, whose fork omarchy-settings owned omarchy_hooks.conf: the +# transaction takes it away with the package. +encrypted_fixture encrypted-channel channel +finish +[[ $(sed -n 's/^HOOKS //p' "$R/boot/initramfs-linux-aurora.img") == "$converged_hooks" ]] || + fail "the Apple drop-ins move the channel Mac's line to systemd and sd-encrypt too" "$(cat "$R/boot/initramfs-linux-aurora.img")" +[[ $(cat "$R/etc/crypttab") == "root UUID=$luks_uuid none luks,discard" && ! -e $R/etc/mkinitcpio.conf.d/omarchy_hooks.conf ]] || + fail "the channel Mac unlocks through crypttab with no busybox line left" +pass "an encrypted channel Mac switches its unlock the same way" + +# --- The boot switch cut short ------------------------------------------------------ + +switch_interrupt() { # when point step + local when=$1 point=$2 step=$3 status=0 output last + encrypted_fixture "switch-kill-$when-$point" + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $point" "status $status: $output" + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "$step done"* ]] || fail "killed $when $point, the journal ends with $step done" "$last" + else + [[ $last == "$step begin"* ]] || fail "killed $when $point, the journal ends with $step begun" "$last" + fi + if [[ ! -e $R/var/lib/omarchy/limine.enabled || $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "grub" ]]; then + grub_boots_esp || fail "killed $when $point before Limine took the slot, GRUB's chain still unlocks the root" "$(cd "$F/esp" && find . -type f)" + fi + finish + [[ $(switch_outcome) == "$encrypted_baseline" ]] || + fail "killed $when $point, the resumed switch ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +} + +for step in "${steps[@]}"; do + switch_interrupt after "$step" "$step" + switch_interrupt during "$step" "$step" +done +for point in backup transaction boot-chain loader defaults unpin reboot retire; do + switch_interrupt mid "$point" "$point" +done +for point in esp-fstab esp-unmounted unlock initramfs loader-leaf; do + switch_interrupt mid "$point" loader +done +pass "a kill -9 anywhere in the switch leaves GRUB's chain unlocking the root until Limine takes the slot, and resumes to the same end" + +# --- A failed stage leaves GRUB -------------------------------------------------- + +# Everything GRUB's chain and the root's configuration read, as before the stage. +before_stage() { + (cd "$F/esp" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) + (cd "$R" && sha256sum etc/fstab etc/default/grub etc/mkinitcpio.conf etc/mkinitcpio.conf.d/*) + cat "$R/etc/crypttab" 2>/dev/null || echo "no crypttab" + (cd "$R/boot" && find . | LC_ALL=C sort) + (cd "$F/covered/_boot" 2>/dev/null && find . | LC_ALL=C sort) + sed "s|$R|ROOT|" "$F/mounts" + [[ -L $R/boot ]] && echo "the ESP at /boot" +} + +stage_fails() { # description fixture-change reason-pattern + local status=0 output snapshot + encrypted_fixture "stage-fails-$1" + kill_after boot-chain + eval "$2" + snapshot=$(before_stage) + output=$(migrate run 2>&1) || status=$? + (( status == 1 )) && grep -q -- "$3" <<<"$output" && grep -q "GRUB is still the loader" <<<"$output" || + fail "$1: the loader step fails and says GRUB boots" "status $status: $output" + [[ $(before_stage) == "$snapshot" ]] || fail "$1: the stage is undone, the ESP back at /boot" "$(diff <(echo "$snapshot") <(before_stage))" + grub_boots_esp && [[ ! -e $R/var/lib/omarchy/limine.enabled ]] || fail "$1: GRUB's chain still unlocks the root" + [[ $(migrate status) == *"failed at loader"* ]] || fail "$1: status names the failed loader step" "$(migrate status)" +} + +stage_fails mkinitcpio ': >"$F/mkinitcpio-fail"' "mkinitcpio -p linux-aurora failed" +rm "$F/mkinitcpio-fail" +finish +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "the retried switch ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +stage_fails limine ': >"$F/limine-activation-fail"' "Limine could not be activated" +rm "$F/limine-activation-fail" +finish +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "the retried activation ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +stage_fails local-hooks 'printf "HOOKS+=(encrypt)\n" >"$R/etc/mkinitcpio.conf.d/99-local.conf"' "do not unlock the root through systemd" +# The ESP cannot leave /boot: nothing on it may be removed on the way back. +stage_fails umount-busy ': >"$F/umount-busy"' "cannot unmount the ESP from /boot" +[[ ! -e $(state_dir)/backup/boot-switch ]] || fail "an undone stage keeps no stale originals for the next" +rm "$F/umount-busy" +finish +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "the retried move ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +pass "a stage or activation that fails is undone, GRUB keeps booting the Mac, and the retry finishes" + +# --- An unencrypted Mac with its ESP at /boot --------------------------------------- + +new_fixture plain-esp-boot checkout +esp_at_boot "base asahi udev autodetect microcode modconf kms keyboard keymap consolefont block filesystems fsck" +hooks_before=$(OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F PATH="$stubs:$PATH" omarchy-mac-initramfs-hooks) +grub_before=$(cat "$R/etc/default/grub") +finish +[[ -L $R/boot/efi && ! -L $R/boot ]] && grep -q " /boot/efi vfat " "$R/etc/fstab" || fail "an unencrypted Mac's ESP moves to /boot/efi too" +[[ ! -e $R/etc/crypttab && $(cat "$R/etc/default/grub") == "$grub_before" ]] && ! grep -q '^cryptsetup' "$F/pacman.log" || + fail "an unencrypted Mac stays unencrypted: no crypttab, no LUKS, its kernel line kept" +[[ $(sed -n 's/^HOOKS //p' "$R/boot/initramfs-linux-aurora.img") == "$hooks_before" ]] || + fail "an unencrypted Mac keeps its HOOKS" "$(cat "$R/boot/initramfs-linux-aurora.img")" +[[ $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" ]] && grep -q "^\"root=UUID=$fs_uuid rw rootflags=subvol=@\"$" "$F/esp/EFI/Linux/omarchy_linux-aurora.efi" || + fail "an unencrypted Mac boots Aurora's UKI from Limine with no unlock" "$(cat "$F/esp/EFI/Linux/omarchy_linux-aurora.efi")" +pass "an unencrypted Mac with its ESP at /boot moves it and boots Limine, and stays unencrypted" + +# --- Refusals of an encrypted Mac ---------------------------------------------------- + +encrypted_fixture refusals +sed -i "s/:root:allow-discards/:cryptroot/" "$R/etc/default/grub" +legacy_refused "a mapping other than root" "do not pass the one cryptdevice=UUID=$luks_uuid:root" +encrypted_fixture refusals +sed -i "s/^GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"cryptkey=rootfs:\/key\"/" "$R/etc/default/grub" +legacy_refused "a key file" "found: cryptkey=rootfs:/key cryptdevice" +encrypted_fixture refusals +sed -i "s/$luks_uuid:root/0000-1111:root/" "$R/etc/default/grub" +legacy_refused "another LUKS partition" "do not pass the one cryptdevice=UUID=$luks_uuid:root" +encrypted_fixture refusals +sed -i 's/ encrypt / /' "$R/etc/mkinitcpio.conf" +legacy_refused "busybox encrypt only in a drop-in" "not in /etc/mkinitcpio.conf's own HOOKS" +encrypted_fixture refusals +printf 'root UUID=0000-1111 none luks\n' >"$R/etc/crypttab" +legacy_refused "crypttab naming another root" "/etc/crypttab names another root" +encrypted_fixture refusals +sed -i 's/loglevel=3/loglevel=$LEVEL/' "$R/etc/default/grub" +legacy_refused "a kernel line with shell expansion" "uses shell expansion" +encrypted_fixture refusals +: >"$R/var/lib/omarchy/limine.enabled" +printf 'KERNEL_CMDLINE[default]=""\n' >"$R/etc/default/limine" +legacy_refused "a Limine Mac with its ESP at /boot" "moves those only on a GRUB Mac" +encrypted_fixture refusals +sed -i '/ \/boot vfat /d' "$R/etc/fstab" +legacy_refused "an ESP at /boot fstab does not mount" "no single vfat line mounting it there" +new_fixture refusals checkout +printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p6 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" +echo "/dev/mapper/root[/@]" >"$F/root-source" +echo "$luks_uuid" >"$F/luks-uuid" +printf 'GRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=%s:root"\n' "$luks_uuid" >"$R/etc/default/grub" +printf 'HOOKS=(base udev block encrypt filesystems)\n' >"$R/etc/mkinitcpio.conf" +legacy_refused "an encrypted Mac whose ESP is at /boot/efi" "kernels are not on the ESP mounted at /boot" +pass "preflight refuses encrypted legacy Macs outside the guided installer's layout, changing nothing" diff --git a/test/shell.d/mac-migrate-mx-test.sh b/test/shell.d/mac-migrate-mx-test.sh new file mode 100644 index 00000000000..18204249bf8 --- /dev/null +++ b/test/shell.d/mac-migrate-mx-test.sh @@ -0,0 +1,685 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/test/fixtures/mac-migrate/lib.sh" + +# bin/omarchy-mac-migrate moves an mx-mac Mac, as the M1 Pro runs it, onto +# Omarchy's official packages: the fork's omarchy-dev pair and bundle, its +# signed [omarchy] and [omarchy-aurora] releases and keys, Aurora, Limine and +# an encrypted root. On edge the fork's omarchy-dev pair, which sorts above +# Omarchy's own dev builds, is downgraded to them by name in the one +# transaction. The fake pacman rejects a database beside a signature that does +# not match it, as pacman does. + +fork_key=C81AC3E2A99556F9B21D5FEA3DD49BC9F8360BDC +release_key=5983B1CA32CB778F4D74D24ECFF35022CA5B5959 +fork_dev=4.0.4.r7081.gca187b0-1 +official_dev=4.0.0.r6713.ga85e29a-1 + +# The signed candidate set: the edge pair and Mac set, a build below the fork's +# pinta and a package the Mac never had. +SET_EXTRA=$'pinta 3.1.2-1.1\navd-fw 0.1-1' make_set "$tmp/mx-set" "$tmp/signer" + +# A fork release's database is signed; the signature matches only that database. +sign_repo() { + local db=$F/repos/$1/$2.db + echo "signed $(sha256sum "$db" | cut -d' ' -f1)" >"$db.sig" +} + +# The fork's [omarchy] and [omarchy-aurora] release sections, as its channel +# updaters write them. +fork_pacman_conf() { + cat <"$R/etc/pacman.conf" + for name in omarchy-fork/omarchy omarchy-aurora/omarchy-aurora; do + cp "$F/repos/$name.db" "$F/repos/$name.db.sig" "$R/var/lib/pacman/sync/" + done + grep -q "^$fork_key " "$R/etc/pacman.d/gnupg/keys" || echo "$fork_key f" >>"$R/etc/pacman.d/gnupg/keys" +} + +# An mx-mac Mac as the M1 Pro runs it: the fork's runtime pair and bundle, +# Aurora and U-Boot from the fork's releases, Limine in the loader slot and the +# encrypted root unlocked by sd-encrypt. No omarchy-mac or omarchy-mac-boot is +# installed. Omarchy's edge [omarchy] carries the Mac set and its own dev pair; +# the administrator's target is the signed candidate set on edge. +new_fixture() { + F=$tmp/$1 + R=$F/root + rm -rf "$F" + mkdir -p "$R/run/systemd/system" "$R/run/lock" "$R/var/tmp" "$R/proc/sys/kernel/random" "$R/etc/default" "$R/etc/omarchy-mac" \ + "$R/var/lib/pacman/local" "$R/var/lib/pacman/sync" "$R/var/cache/pacman/pkg" "$R/etc/pacman.d/gnupg" \ + "$R/usr/share/pacman/keyrings" "$R/usr/share/limine" "$R/boot/grub" "$R/boot/efi/EFI/BOOT" "$R/boot/efi/EFI/Linux" "$R/boot/efi/m1n1" \ + "$R/usr/lib/modules/7.1.12-aurora" "$R/var/lib/omarchy/mac-first-boot" + echo boot-1 >"$R/proc/sys/kernel/random/boot_id" + echo 7.1.12-aurora >"$R/proc/sys/kernel/osrelease" + echo linux-aurora >"$R/usr/lib/modules/7.1.12-aurora/pkgbase" + echo "limine 12.9" >"$R/usr/share/limine/BOOTAA64.EFI" + cp "$R/usr/share/limine/BOOTAA64.EFI" "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" + echo uki >"$R/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" + printf '/+Omarchy\n //linux-aurora\n path: boot():/EFI/Linux/omarchy_linux-aurora.efi#abc\n' >"$R/boot/efi/limine.conf" + : >"$R/var/lib/omarchy/limine.enabled" + printf 'ESP_PATH="/boot/efi"\nKERNEL_CMDLINE[default]="root=UUID=x rd.luks.name=abc=root"\n' >"$R/etc/default/limine" + echo "menuentry linux-aurora" >"$R/boot/grub/grub.cfg" + echo m1n1 >"$R/boot/efi/m1n1/boot.bin" + echo "GRUB_CMDLINE_LINUX=\"rd.luks.name=abc=root\"" >"$R/etc/default/grub" + echo "root UUID=abc none luks" >"$R/etc/crypttab" + printf 'format=1\nsequence=57\ntag=asahi-quattro-ca187b0a\n' >"$R/var/lib/omarchy/asahi-quattro-release" + printf 'format=1\nsequence=58\ntag=asahi-quattro-0123abcd\n' >"$R/var/lib/omarchy/asahi-quattro-release.pending" + printf 'format=1\ntag=asahi-packages-stable-2949b88c\n' >"$R/var/lib/omarchy/asahi-package-repository" + printf 'format=1\nchannel=aurora\nrelease_tag=aurora-packages-3caea469\n' >"$R/var/lib/omarchy/aurora-target.descriptor" + printf 'format=1\nchannel=edge\nkernel=linux-aurora\n' >"$R/var/lib/omarchy/apple-silicon-channel" + printf 'format=1\nlane=edge\n' >"$R/var/lib/omarchy/apple-silicon-aurora-lane" + printf 'format=1\nencrypt=1\n' >"$R/var/lib/omarchy/mac-first-boot/install.conf" + for keyring in archlinuxarm asahi-alarm omarchy; do + : >"$R/usr/share/pacman/keyrings/$keyring.gpg" + done + echo 1111111111111111111111111111111111111111 >"$R/usr/share/pacman/keyrings/archlinuxarm-trusted" + echo 2222222222222222222222222222222222222222 >"$R/usr/share/pacman/keyrings/asahi-alarm-trusted" + echo "$official" >"$R/usr/share/pacman/keyrings/omarchy-trusted" + printf '%s f\n' 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 "$official" "$fork_key" "$release_key" \ + >"$R/etc/pacman.d/gnupg/keys" + mkdir -p "$F/keyserver" + : >"$F/keyserver/$official" + + cat >"$R/var/lib/pacman/local/packages" <"$R/var/cache/pacman/pkg/$file" + done + repo core <<<"pacman 7.0.0-1" + printf 'hyprland 0.51-1\nlimine 12.9.0-1\nquickshell 0.3.1-1\n' | repo extra + printf 'asahi-scripts 20260127.1-1\nasahi-alarm-keyring 20250101-1\n' | repo asahi-alarm + printf 'dotnet-runtime 9.0.8.sdk100-1\nhyprland 0.50-1\nlimine-mkinitcpio-hook 1.36.0-3\nmise 2026.9.4-1\nobs-studio 32.2.2-1\npinta 3.1.2-2\nuboot-asahi 2026.07.asahi2-3\n' | + repo omarchy-fork omarchy + printf 'linux-aurora 7.1.12.aurora2-7\nlinux-aurora-headers 7.1.12.aurora2-7\nm1n1-aurora 1.6.1.aurora1-2\n' | repo omarchy-aurora + sign_repo omarchy-fork omarchy + sign_repo omarchy-aurora omarchy-aurora + repo omarchy <"$F/provides" + cat >"$F/conflicts" <<'CONFLICTS' +omarchy omarchy-dev +omarchy-settings omarchy-settings-dev +quickshell quickshell-git +mise-bin mise +linux-aurora linux-asahi +m1n1-aurora m1n1 +linux-aurora-headers linux-asahi-headers +CONFLICTS + + cp -r "$tmp/mx-set" "$F/set" + cat >"$R/etc/omarchy-mac/migration-target" <"$F/channel" + echo apple-silicon >"$F/platform" + echo "base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck" >"$F/hooks" + printf '%s\n' "$R/boot/efi" >"$F/mounts" + echo /dev/mapper/root >"$F/root-source" + printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p6 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" + : >"$F/pacman.log" + : >"$F/boot.log" +} + +# The archives preflight reads from a repository target: both runtimes and +# omarchy-mac-boot, at the versions [omarchy] lists. +repository_archives() { + archive omarchy-dev "$official_dev" + archive omarchy 4.0.4-1 + archive omarchy-mac-boot 20260927-1 +} + +# repository_target CHANNEL: the official [omarchy] as the administrator's +# repository target for CHANNEL. +repository_target() { + printf 'format=1\ntype=repository\nchannel=%s\nserver=file://%s/repos/omarchy\n' "$1" "$F" >"$R/etc/omarchy-mac/migration-target" + repository_archives +} + +# Everything a finished migration leaves that must not depend on how it got +# there: packages, configuration, databases, keys, loader, records and backups. +outcome() { + local state + state=$(state_dir) + cat "$R/var/lib/pacman/local/packages" + sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf" + sort "$R/etc/pacman.d/gnupg/keys" + cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" "$R/etc/default/limine" "$R/etc/crypttab" "$R/boot/efi/limine.conf" + ls "$R/var/lib/omarchy" + ls "$R/var/lib/pacman/sync" + cat "$R/var/lib/pacman/sync/omarchy.db" + [[ ! -e $R/var/lib/pacman/db.lck ]] && echo unlocked + sed -n 's/^target=//p' "$state/complete" + (cd "$state/backup" && find . -type f | LC_ALL=C sort && sed 's/^[0-9a-f]* //' SHA256SUMS) + ls "$state" + [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && echo "no unit" +} + +# --- The whole transition -------------------------------------------------- + +new_fixture baseline +crypt_before=$(cat "$R/etc/crypttab" "$R/boot/efi/limine.conf") +digest=$(fixture_digest) +output=$(migrate check 2>&1) || fail "check passes on an mx-mac Mac ready to migrate" "$output" +grep -q "Ready: run moves this Mac (mx-mac, limine boot, encrypted) onto candidate-set apple-test-fixture .* (edge)" <<<"$output" || + fail "check plans the Mac with the mx-mac adapter" "$output" +[[ $(fixture_digest) == "$digest" && ! -e $(state_dir)/journal ]] || fail "check changes nothing" +output=$(migrate run 2>&1) || fail "an mx-mac Mac migrates to its reboot" "$output" +grep -q "Reboot to finish the migration to candidate-set apple-test-fixture" <<<"$output" || fail "the run asks for a reboot" "$output" +state=$(state_dir) +[[ $(cat "$state/plan/cohort") == "mx-mac" ]] || fail "the Mac is planned as mx-mac" "$(cat "$state/plan/cohort")" +grep -q "^ExecStart=/var/lib/omarchy-mac/migration/tool/omarchy-mac-migrate verify$" "$R/etc/systemd/system/omarchy-mac-migrate-verify.service" && + cmp -s "$tool" "$state/tool/omarchy-mac-migrate" || fail "the post-reboot unit runs the tool's own kept copy" +pass "an mx-mac Mac is planned by its own adapter and migrated up to its reboot" + +expected_packages="asahi-alarm-keyring 20250101-1 +dotnet-runtime-bin 10.0.401-2 +hyprland 0.51-1 +limine 12.9.0-1 +limine-mkinitcpio-hook 1.39.0-2 +linux-aurora 7.1.12.aurora2-11 +linux-aurora-headers 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +mise-bin 2026.9.12-1 +obs-studio 32.2.2-1 +omarchy-dev 4.0.0.r7000.gabc-1.1 +omarchy-keyring 20251027-1 +omarchy-mac 0.1.0-11.1 +omarchy-mac-boot 20261004-1.1 +omarchy-nvim 2026.9.21-1 +omarchy-settings-dev 4.0.0.r7000.gabc-1.1 +pacman 7.0.0-1 +pinta 3.1.2-1.1 +quickshell 0.3.1-1 +ttf-jetbrains-mono-nerd-basic 3.5.1-1 +uboot-asahi 2026.07.asahi2-4" +[[ $(cat "$R/var/lib/pacman/local/packages") == "$expected_packages" ]] || + fail "one transaction moves the dev pair and the bundle to official builds and Aurora to the target" "$(diff <(echo "$expected_packages") "$R/var/lib/pacman/local/packages")" +grep -qx "transaction omarchy-mac-candidate/omarchy-dev omarchy-mac-candidate/omarchy-settings-dev omarchy-mac-candidate/omarchy-mac omarchy-mac-candidate/omarchy-mac-boot omarchy-mac-candidate/linux-aurora omarchy-mac-candidate/linux-aurora-headers omarchy-mac-candidate/m1n1-aurora omarchy-mac-candidate/uboot-asahi omarchy-mac-candidate/limine-mkinitcpio-hook omarchy-mac-candidate/pinta dotnet-runtime-bin hyprland mise-bin omarchy-keyring omarchy-nvim quickshell ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring" "$F/pacman.log" || + fail "the target's packages come from the target, and each other fork build an official repository carries is named" "$(grep transaction "$F/pacman.log")" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "one package transaction" +[[ $(cat "$state/plan/allowed-removals") == $'dotnet-runtime\nmise\nquickshell-git' && + $(cat "$state/plan/removals") == "$(cat "$state/plan/allowed-removals")" ]] || + fail "only the fork builds official ones of another name replace may be removed, and each is removed by name if it survives" "$(cat "$state/plan/allowed-removals" "$state/plan/removals")" +grep -qx "remove dotnet-runtime" "$F/pacman.log" || fail "a fork build its counterpart does not conflict with is removed by name" "$(cat "$F/pacman.log")" +grep -qx "obs-studio 32.2.2-1" "$state/plan/kept" || fail "a fork build with no official one is kept and listed" "$(cat "$state/plan/kept")" +! grep -q "^avd-fw " "$R/var/lib/pacman/local/packages" || fail "a target package the Mac never had is not installed" +pass "one transaction replaces the fork's bundle, downgrades a higher fork build, keeps what has no official build and adds no package the Mac lacks" + +# omarchy-dev 4.0.4.r7081 (the fork's) sorts above the target's 4.0.0.r7000: the +# pair is named in the candidate repository and downgraded in place, never +# removed and reinstalled. +! grep -q "^omarchy-dev$\|^omarchy-settings-dev$" "$state/plan/allowed-removals" "$state/plan/removals" || + fail "the fork's dev pair is never planned for removal on edge" +! grep -q "^remove .*omarchy-dev\|^remove .*omarchy-settings-dev" "$F/pacman.log" && [[ ! -e $state/overwrite ]] || + fail "the dev pair changes in the transaction, with no removal after it and no overwrite" "$(cat "$F/pacman.log")" +pass "on edge the fork's higher omarchy-dev pair is downgraded by name to the target's in the one transaction" + +conf=$(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") +expected_conf=$(OMARCHY_MAC_MIGRATE_ROOT=$R fixture=1 bash -c 'source <(sed -n "/^core_pacman_conf() {/,/^}/p" "$1"); core_pacman_conf "file://FIXTURE/repos/omarchy"' _ "$ROOT/migrate/src/target.sh" | + sed "s|^Server = https://github.com/asahi-alarm.*|Server = file://FIXTURE/repos/asahi-alarm|") +[[ $conf == "$expected_conf" ]] || fail "pacman.conf is the core Apple Silicon configuration, without either fork section" "$(diff <(echo "$expected_conf") <(echo "$conf"))" +[[ ! -e $R/var/lib/pacman/sync/omarchy-aurora.db && ! -e $R/var/lib/pacman/sync/omarchy-aurora.db.sig ]] || fail "the fork's Aurora database is gone" +[[ ! -e $R/var/lib/pacman/sync/omarchy.db.sig ]] && cmp -s "$F/repos/omarchy/omarchy.db" "$R/var/lib/pacman/sync/omarchy.db" || + fail "the official [omarchy] database replaces the fork's, without the fork's signature beside it" +! grep -q "^$fork_key \|^$release_key " "$R/etc/pacman.d/gnupg/keys" || fail "the fork's package and release keys are deleted" "$(cat "$R/etc/pacman.d/gnupg/keys")" +grep -q "^$official f" "$R/etc/pacman.d/gnupg/keys" && ! grep -q "$signer" "$R/etc/pacman.d/gnupg/keys" || + fail "the Omarchy key stays trusted and the candidate key never enters pacman's keyring" +pass "no fork repository, signature or key is left, and the official database is the one pacman reads" + +! grep -q "update-grub" "$F/boot.log" || fail "a Limine Mac does not touch GRUB" "$(cat "$F/boot.log")" +[[ $(grep -E '^(update-m1n1|omarchy-mac-limine-cmdline|limine-update|dispatch setup-boot|limine-boot activate|dispatch setup-system|dispatch update-verify)' "$F/boot.log" | tr '\n' '|') == \ + "update-m1n1 |omarchy-mac-limine-cmdline |limine-update|dispatch setup-boot|limine-boot activate|limine-update|dispatch setup-system|dispatch update-verify|" ]] || + fail "m1n1 and the UKI are rebuilt, then the new setup-boot refreshes Limine, setup-system and update-verify run" "$(cat "$F/boot.log")" +[[ $(cat "$R/etc/crypttab" "$R/boot/efi/limine.conf") == "$crypt_before" && -e $R/var/lib/omarchy/limine.enabled ]] || + fail "the unlock settings and the Limine menu are unchanged" +grep -q "^cryptsetup luksHeaderBackup /dev/nvme0n1p6 " "$F/pacman.log" && [[ -f $state/backup/luks-header.img ]] || + fail "the LUKS header of the root partition is backed up" +tar -xOf "$state/backup/etc.tar" etc/pacman.conf | grep -q '^\[omarchy-aurora\]' || fail "the backup holds the fork's pacman.conf" +pass "encryption and Limine are kept: the UKI is rebuilt, setup-boot refreshes Limine, the LUKS header is backed up" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the post-reboot verification completes the migration" "$output" +grep -q "Kept, with no official build: obs-studio" <<<"$output" || fail "completion names what was kept" "$output" +for name in asahi-quattro-release asahi-quattro-release.pending asahi-package-repository aurora-target.descriptor apple-silicon-channel apple-silicon-aurora-lane; do + [[ ! -e $R/var/lib/omarchy/$name && -f $state/backup/mx-mac-state/$name ]] || fail "the fork updaters' $name is moved into the backup" +done +[[ $(stat -c %a "$state/backup/mx-mac-state") == 700 ]] || fail "the retired state is readable by root only" +[[ -e $R/var/lib/omarchy/mac-first-boot/install.conf ]] || fail "state no fork updater owns stays" +[[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service && ! -e $state/tool ]] || fail "the post-reboot unit and the tool's copy go" +[[ $(migrate status) == *"State: complete"* ]] || fail "status reports completion" +baseline=$(outcome) +pass "after the reboot, the bundle and channel updaters' state is retired into the backup" + +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +grep -q "Already migrated to candidate-set apple-test-fixture" <<<"$output" && [[ $(outcome) == "$baseline" ]] || + fail "a second run changes nothing" "$output" +pass "the migration is idempotent" + +# --- Interruption at every journal step --------------------------------------- + +interrupt() { # when step + local when=$1 step=$2 status=0 output last recorded transactions=1 + new_fixture "kill-$when-$step" + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $step" "status $status: $output" + case $step in + loader-leaf) recorded=loader ;; + removals) recorded=transaction ;; + mx-mac-retire) recorded=retire ;; + *) recorded=$step ;; + esac + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "$recorded done"* ]] || fail "the journal ends with $step done" "$last" + else + [[ $last == "$recorded begin"* ]] || fail "the journal ends with $step begun" "$last" + fi + finish + [[ $(outcome) == "$baseline" ]] || fail "killed $when $step, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $when$step == "midtransaction" ]] && transactions=2 + [[ $(grep -c '^transaction ' "$F/pacman.log") == "$transactions" ]] || fail "killed $when $step: $transactions package transaction(s)" "$(cat "$F/pacman.log")" + [[ $(grep '^transaction \|^hooks' "$F/pacman.log" | tail -n 1) == "hooks" ]] || fail "killed $when $step: the last transaction's hooks ran" + [[ $(grep -c '^remove dotnet-runtime$' "$F/pacman.log") == 1 ]] || fail "killed $when $step: the planned removal runs once" "$(cat "$F/pacman.log")" +} + +for step in "${steps[@]}"; do + interrupt after "$step" +done +pass "a kill -9 between any two journal steps resumes to the same end, with one transaction" +for step in "${steps[@]}"; do + interrupt during "$step" +done +pass "a kill -9 after any step's work but before its record resumes to the same end" +for step in backup keyring prefetch repositories transaction removals boot-chain loader loader-leaf defaults unpin reboot mx-mac-retire retire; do + interrupt mid "$step" +done +pass "a kill -9 in the middle of any step, the removals after the transaction and the adapter's retire included, resumes to the same end" + +# --- The fork moving under a migration ----------------------------------------- + +# The fork's omarchy update runs before the migration resumes: its channel +# updaters put the fork sections, databases and key back, and its bundle +# updater moves the runtime pair. +for step in keyring repositories; do + new_fixture "fork-update-$step" + kill_after "$step" + fork_update + sed -i 's/^omarchy-dev .*/omarchy-dev 4.0.4.r7090.g0123456-1/' "$R/var/lib/pacman/local/packages" + finish + [[ $(outcome) == "$baseline" ]] || fail "after the fork's update past $step, the migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "after $step: one transaction" + [[ $step != "repositories" ]] || grep -q " prefetch reset pacman.conf or a retired key came back after the repository switch" "$(state_dir)/journal" || + fail "the fork's update after the switch sends the migration back to rehearse" "$(cat "$(state_dir)/journal")" +done +pass "the fork's own update between steps is undone: the switch runs again and no fork section survives" + +new_fixture conf-only +kill_after repositories +fork_pacman_conf >"$R/etc/pacman.conf" +finish +grep -q " prefetch reset pacman.conf or a retired key came back after the repository switch" "$(state_dir)/journal" || fail "a rewritten pacman.conf alone is noticed" "$(cat "$(state_dir)/journal")" +[[ $(outcome) == "$baseline" ]] || fail "a rewritten pacman.conf is switched again" "$(diff <(echo "$baseline") <(outcome))" +pass "a channel updater's rewrite of pacman.conf after the switch is switched back before the transaction" + +new_fixture key-only +kill_after repositories +echo "$release_key f" >>"$R/etc/pacman.d/gnupg/keys" +finish +grep -q " prefetch reset pacman.conf or a retired key came back after the repository switch" "$(state_dir)/journal" || + fail "a fork key trusted again alone is noticed" "$(cat "$(state_dir)/journal")" +[[ $(outcome) == "$baseline" ]] || fail "a fork key trusted again is deleted again" "$(diff <(echo "$baseline") <(outcome))" +pass "a fork key trusted again after the switch is deleted again before the transaction" + +# --- The channel ------------------------------------------------------------------ + +# Without an administrator's target, an mx-mac Mac follows the channel the fork +# records (omarchy-apple-silicon-channel current), served from Omarchy's +# official repository for that channel. +channel_fixture() { # name channel + new_fixture "$1" + rm "$R/etc/omarchy-mac/migration-target" + echo "$2" >"$F/channel" +} + +for channel in rc stable; do + channel_fixture "channel-$channel" "$channel" + # Omarchy's rc and stable today: the runtime, no Mac packages. + printf 'omarchy 4.0.4-1\nomarchy-settings 4.0.4-1\nomarchy-keyring 20251027-1\n' | repo "official-$channel" omarchy + refused "an mx-mac Mac on $channel" "the $channel channel has no omarchy-mac for Apple Silicon yet" + output=$(migrate run 2>&1 || true) + grep -q "The $channel channel has no Mac release yet" <<<"$output" || fail "the $channel deferral says why" "$output" + [[ $(migrate status) == *"The last run deferred: "*"$channel channel has no omarchy-mac"* ]] || fail "status says why $channel deferred" "$(migrate status)" +done +pass "an mx-mac Mac on rc or stable defers with nothing changed while its channel has no Mac packages" + +channel_fixture channel-held held +refused "a held channel" "cannot tell which Omarchy channel this Mac follows" +channel_fixture channel-none edge +rm "$F/channel" +refused "no channel record" "cannot tell which Omarchy channel this Mac follows" +pass "an mx-mac Mac whose channel the fork cannot name defers with nothing changed" + +channel_fixture channel-edge edge +cp -r "$F/repos/omarchy" "$F/repos/official-edge" +repository_archives +output=$(migrate check 2>&1) || fail "an mx-mac Mac on edge is ready" "$output" +grep -q "Ready: run moves this Mac (mx-mac, limine boot, encrypted) onto repository file://$F/repos/official-edge (edge)" <<<"$output" || + fail "the fork's edge channel moves to Omarchy's edge repository" "$output" +finish +grep -qx "transaction omarchy/omarchy-dev omarchy/omarchy-settings-dev omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/linux-aurora-headers omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook dotnet-runtime-bin hyprland mise-bin omarchy-keyring omarchy-nvim pinta quickshell ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring" "$F/pacman.log" || + fail "every Mac package and the dev pair are named in the official [omarchy]" "$(grep transaction "$F/pacman.log")" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "one package transaction" +grep -qx "omarchy-dev $official_dev" "$R/var/lib/pacman/local/packages" && grep -qx "omarchy-settings-dev $official_dev" "$R/var/lib/pacman/local/packages" && + grep -qx "omarchy-mac-boot 20260927-1" "$R/var/lib/pacman/local/packages" || + fail "Omarchy's own dev pair replaces the fork's higher one" "$(cat "$R/var/lib/pacman/local/packages")" +! grep -q "^remove .*omarchy-dev" "$F/pacman.log" && ! grep -q "omarchy-dev" "$(state_dir)/plan/allowed-removals" || + fail "the dev pair is downgraded inside the transaction, never removed" "$(cat "$F/pacman.log")" +grep -q "^download omarchy-dev $official_dev$" "$F/pacman.log" && grep -q "^download omarchy-mac-boot 20260927-1$" "$F/pacman.log" || + fail "preflight reads the channel's signed runtime and boot archives" "$(cat "$F/pacman.log")" +grep -qx "Server = file://$F/repos/official-edge" "$R/etc/pacman.conf" || fail "[omarchy] is Omarchy's edge repository" "$(cat "$R/etc/pacman.conf")" +pass "with no administrator's target, an mx-mac Mac on edge moves to Omarchy's own dev pair, downgraded by name in one transaction" + +# --- Repository targets ------------------------------------------------------------ + +# On stable the fork's pair gives way to omarchy and omarchy-settings, whose +# conflicts remove it in the transaction. +new_fixture repository-stable +repository_target stable +finish +grep -qx "transaction omarchy/omarchy omarchy/omarchy-settings omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/linux-aurora-headers omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook dotnet-runtime-bin hyprland mise-bin omarchy-keyring omarchy-nvim pinta quickshell ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring" "$F/pacman.log" || + fail "a stable repository target names the Mac packages in the official [omarchy], not the fork's" "$(grep transaction "$F/pacman.log")" +grep -qx "omarchy 4.0.4-1" "$R/var/lib/pacman/local/packages" && ! grep -q "^omarchy-dev \|^omarchy-settings-dev " "$R/var/lib/pacman/local/packages" || + fail "the official runtime replaces the fork's dev pair" "$(cat "$R/var/lib/pacman/local/packages")" +[[ $(cat "$(state_dir)/plan/allowed-removals") == $'dotnet-runtime\nmise\nomarchy-dev\nomarchy-settings-dev\nquickshell-git' ]] || + fail "on stable the fork's pair may be removed" "$(cat "$(state_dir)/plan/allowed-removals")" +! grep -q "^remove .*omarchy-dev" "$F/pacman.log" || fail "the fork's pair leaves through omarchy's conflict" "$(cat "$F/pacman.log")" +pass "a stable repository target, whose [omarchy] has the fork section's name, replaces the fork's builds" + +# omacom's own omarchy-dev provides omarchy: once it is newer than the fork's, +# an upgrade would take it and pacman would drop the omarchy target. +new_fixture newer-official-dev +repository_target stable +sed -i 's/^omarchy-dev .*/omarchy-dev 4.0.5.r1-1/' "$F/repos/omarchy/omarchy.db" +printf 'omarchy-dev omarchy\n' >>"$F/provides" +finish +grep -qx "omarchy 4.0.4-1" "$R/var/lib/pacman/local/packages" && ! grep -q "^omarchy-dev " "$R/var/lib/pacman/local/packages" || + fail "the runtime pair still comes from the target when an official omarchy-dev is newer than the fork's" "$(cat "$R/var/lib/pacman/local/packages")" +pass "the planned removals stay out of the upgrade, so a newer official omarchy-dev cannot displace the omarchy target" + +# --- Commands that change hands -------------------------------------------------- + +# A real mx-mac Mac has no omarchy-mac-boot: its omarchy-dev owns five of the +# commands omarchy-mac-boot ships. The one transaction installs omarchy-mac-boot +# while the fork's omarchy-dev is replaced (on edge, by Omarchy's own, which +# ships none of them; on stable, through omarchy's conflict), so the commands +# change hands with nothing overwritten. +handover="/usr/bin/omarchy-apple-silicon-boot-check /usr/bin/omarchy-mac-boot-update /usr/bin/omarchy-mac-limine-active /usr/bin/omarchy-mac-limine-cmdline /usr/bin/omarchy-mac-limine-deploy" + +handover_fixture() { + local path + new_fixture "$1" + mkdir -p "$F/files" "$R/usr/bin" + : >"$R/var/lib/pacman/local/files" + for path in $handover; do + printf '%s\n' "$path" >>"$F/files/omarchy-mac-boot" + printf 'omarchy-dev %s\n' "$path" >>"$R/var/lib/pacman/local/files" + echo "omarchy-dev $fork_dev" >"$R$path" + done +} + +handed_over() { # version + local path + for path in $handover; do + [[ $(cat "$R$path") == "omarchy-mac-boot $1" ]] && grep -qx "omarchy-mac-boot $path" "$R/var/lib/pacman/local/files" && + ! grep -qx "omarchy-dev $path" "$R/var/lib/pacman/local/files" || + fail "$path moves from the fork's omarchy-dev to omarchy-mac-boot" "$(grep -F "$path" "$R/var/lib/pacman/local/files")" + done + [[ ! -e $(state_dir)/overwrite ]] && ! grep -q "^remove .*omarchy-dev" "$F/pacman.log" || + fail "omarchy-dev changes inside the transaction, with nothing overwritten" "$(cat "$F/pacman.log")" +} + +handover_fixture handover-edge +finish +handed_over 20261004-1.1 +grep -qx "omarchy-dev 4.0.0.r7000.gabc-1.1" "$R/var/lib/pacman/local/packages" || fail "the fork's omarchy-dev is downgraded in place" +handover_fixture handover-stable +repository_target stable +finish +handed_over 20260927-1 +pass "the commands the fork's omarchy-dev owned pass to omarchy-mac-boot inside the one transaction, without an overwrite" + +# Without omarchy's conflict, omarchy-dev would leave in the removals after the +# transaction, and pacman -R would delete the commands omarchy-mac-boot took over. +handover_fixture after-removal +repository_target stable +sed -i '/^omarchy omarchy-dev$/d' "$F/conflicts" +conf_before=$(cat "$R/etc/pacman.conf") +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would leave omarchy-dev to be removed after it, but the packages it installs also own /usr/bin/omarchy-apple-silicon-boot-check" <<<"$output" || + fail "a removal that would take handed-over files stops the rehearsal, deferred" "status $status: $output" +[[ $(cat "$R/etc/pacman.conf") == "$conf_before" ]] && grep -q "^omarchy-dev " "$R/var/lib/pacman/local/packages" && + ! grep -q "^transaction \|^remove " "$F/pacman.log" || fail "nothing changes before such a transaction" "$(cat "$F/pacman.log")" +[[ ! -e $(state_dir)/journal ]] || fail "the attempt is set aside, so the next run starts over" +pass "a planned removal that shares files with the packages installed defers before anything changes" + +# --- Refusals and failures ------------------------------------------------------- + +new_fixture refusals +printf '\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///custom\n' >>"$R/etc/pacman.conf" +refused "an unknown TrustAll repository" "\[custom\] accepts untrusted packages" +new_fixture refusals +echo "base udev autodetect microcode modconf kms keyboard keymap block encrypt filesystems fsck" >"$F/hooks" +refused "busybox encrypt" "busybox encrypt" +new_fixture refusals +: >"$R/var/lib/omarchy/mac-first-boot/pending" +refused "an unfinished first boot" "first boot has not finished" +new_fixture refusals +rm "$F/hooks" +refused "HOOKS that cannot be read" "cannot read the initramfs HOOKS" +new_fixture refusals +head -c 16 /dev/urandom >>"$F/set/$(jq -r '.packages[0].filename' "$F/set/manifest.json")" +refused "a tampered candidate package" "does not verify: omarchy-dev-.* is missing or changed" +new_fixture refusals +repository_target stable +sed -i '/^omarchy-mac /d' "$F/repos/omarchy/omarchy.db" +refused "a target without omarchy-mac" "the stable channel has no omarchy-mac for Apple Silicon yet" +new_fixture refusals +repository_target stable +printf 'packages=omarchy omarchy-mac omarchy-mac-boot linux-aurora\n' >>"$R/etc/omarchy-mac/migration-target" +refused "a target without the runtime pair" "the target has no omarchy-settings to replace the fork's runtime pair" +pass "preflight refuses legacy unlock, untrusted repositories, an unfinished first boot, unreadable HOOKS and an incomplete or unverifiable target, changing nothing" + +new_fixture weak-fork +sed -i '/^\[omarchy-aurora\]/,/^$/s/^SigLevel = .*/SigLevel = Optional TrustAll/' "$R/etc/pacman.conf" +finish +[[ $(outcome) == "$baseline" ]] || fail "a fork section the switch drops is not a refusal, whatever its SigLevel" "$(diff <(echo "$baseline") <(outcome))" +pass "the fork's own sections are retired, not refused" + +new_fixture removal +echo "omarchy-mac obs-studio" >>"$F/conflicts" +conf_before=$(cat "$R/etc/pacman.conf") +packages_before=$(cat "$R/var/lib/pacman/local/packages") +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove obs-studio; nothing was changed" <<<"$output" || fail "an unexpected removal stops the rehearsal, deferred" "status $status: $output" +[[ $(cat "$R/etc/pacman.conf") == "$conf_before" && $(cat "$R/var/lib/pacman/local/packages") == "$packages_before" ]] || + fail "the fork's repositories and packages are untouched after a failed rehearsal" +[[ ! -e $(state_dir)/journal && -n $(ls -d "$(state_dir)"/history/aborted-* 2>/dev/null) ]] || fail "the attempt is set aside, so the next run starts over" +pass "a rehearsal that would remove a kept fork build defers before the switch" + +new_fixture after-boundary +kill_after repositories +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && [[ -f $(state_dir)/journal ]] || fail "a failure after the switch is a failure, never a deferral" "status $status: $output" +rm "$F/fail-transaction" +finish +[[ $(outcome) == "$baseline" ]] || fail "the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" +pass "after the repository switch a failure stops the migration for the next run to resume" + +# --- Other mx-mac Macs ------------------------------------------------------------- + +# Omarchy's edge carries quickshell-git itself: a fork build moves to the +# official build of its own name before a renamed one. +new_fixture same-name +printf 'quickshell-git 0.3.0.r20.g28771c7-3\n' >>"$F/repos/omarchy/omarchy.db" +finish +grep -qx "quickshell-git 0.3.0.r20.g28771c7-3" "$R/var/lib/pacman/local/packages" && ! grep -q "^quickshell " "$R/var/lib/pacman/local/packages" || + fail "a fork build an official repository carries by its own name moves to that build" "$(cat "$R/var/lib/pacman/local/packages")" +! grep -qx "quickshell-git" "$(state_dir)/plan/allowed-removals" && grep -q "^transaction .* quickshell-git ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring$" "$F/pacman.log" || + fail "the official build of the same name is named and nothing is removed for it" "$(cat "$(state_dir)/plan/allowed-removals" "$F/pacman.log")" +pass "a fork build moves to the official build of its own name before a renamed counterpart" + +new_fixture grub +rm "$R/var/lib/omarchy/limine.enabled" "$R/etc/default/limine" "$R/boot/efi/limine.conf" "$R/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" +echo grub >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" +finish +grep -q "^update-grub" "$F/boot.log" && grep -q "^limine-boot activate" "$F/boot.log" && + [[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" && -e $R/var/lib/omarchy/limine.enabled ]] || + fail "an mx-mac Mac on GRUB is switched to Limine" "$(cat "$F/boot.log")" +[[ $(cat "$(state_dir)/plan/boot") == "grub" ]] || fail "the Mac is planned as a GRUB Mac" +pass "an mx-mac Mac that still boots GRUB is switched to Limine" + +# --- Omarchy's own dev channel ------------------------------------------------------ + +# omacom's omarchy-dev on a Mac that never ran the fork. +official_dev_fixture() { + new_fixture "$1" + rm -f "$R"/var/lib/omarchy/asahi-* "$R/var/lib/omarchy/aurora-target.descriptor" "$R"/var/lib/omarchy/apple-silicon-* \ + "$R/etc/omarchy-mac/migration-target" "$R"/var/lib/pacman/sync/omarchy-aurora.db* "$R/var/lib/pacman/sync/omarchy.db.sig" + cat >"$R/etc/pacman.conf" <&1) || fail "a Mac on Omarchy's dev channel is not refused" "$output" +grep -q "runs Omarchy's own packages (omarchy-dev from pkgs.omarchy.org): nothing to migrate" <<<"$output" && + [[ $(fixture_digest) == "$digest" && ! -e $(state_dir)/journal ]] || + fail "a Mac on Omarchy's dev channel has nothing to migrate and nothing changes" "$output" +official_dev_fixture official-dev-fork-key +echo "$fork_key f" >>"$R/etc/pacman.d/gnupg/keys" +cp -r "$F/repos/omarchy" "$F/repos/official-edge" +repository_archives +digest=$(fixture_digest) +output=$(migrate check 2>&1) || fail "omarchy-dev beside a retired key is migrated" "$output" +grep -q "Ready: run moves this Mac (tester, limine boot, encrypted) onto repository file://$F/repos/official-edge (edge)" <<<"$output" && + [[ $(fixture_digest) == "$digest" ]] || fail "omarchy-dev that is not the fork's, beside retired trust, is moved like a tester to its channel" "$output" +pass "omarchy-dev from Omarchy's own repositories is nothing to migrate; beside retired trust it is moved like a tester" + +# --- Migrations a converted Mac records as done ---------------------------------------- + +new_fixture settled +printf 'root:x:0:0::/root:/bin/bash\ntester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +migrations=$R/home/tester/.local/state/omarchy/migrations +mkdir -p "$migrations" +: >"$migrations/1790305681.sh" +printf '2026-09-20T10:00:00+10:00\thandled\tHyprland configuration replaced by the Quattro user transition\n' >"$migrations/1781063758.sh.skipped" +printf '2026-09-20T10:00:00+10:00\tskipped\tunsupported AUR browser replacement on Asahi\n' >"$migrations/1784510887.sh.skipped" +printf '2026-09-20T10:00:00+10:00\tskipped\tsystemd-oomd reclaim tuning is held until validated on Asahi\n' >"$migrations/1785424256.sh.skipped" +printf '2026-09-20T10:00:00+10:00\thandled\tnot one the adapter audited\n' >"$migrations/1786567036.sh.skipped" +finish +for name in 1781063758 1784476564 1785424256 1786391100 1789444024 1789158179 1789172112 1790327324; do + [[ -f $migrations/$name.sh ]] || fail "$name is recorded as done" "$(ls "$migrations")" +done +for name in 1784510887 1786567036; do + [[ ! -e $migrations/$name.sh ]] || fail "$name is left to run on the new packages" +done +grep -qx 'omarchy-mac-setup-keyboard 3' "$F/boot.log" || fail "mx-mac's keyboard migration names the generated keyboard line" "$(grep keyboard "$F/boot.log")" +grep -q "^dispatch setup-user HOME=$R/home/tester$" "$F/boot.log" || fail "the Mac user setup runs for the fork's user" "$(grep setup-user "$F/boot.log")" +pass "a converted Mac records the fork's handled migrations and those a fresh Mac image never runs as done, and leaves the rest to run" diff --git a/test/shell.d/mac-migrate-test.sh b/test/shell.d/mac-migrate-test.sh new file mode 100644 index 00000000000..6a932aaa463 --- /dev/null +++ b/test/shell.d/mac-migrate-test.sh @@ -0,0 +1,925 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/test/fixtures/mac-migrate/lib.sh" + +# bin/omarchy-mac-migrate moves a tester Mac (a quattro-upstream or convergence +# build, from a candidate set or the collaboration repository) onto Omarchy's +# official edge: the omarchy-dev pair, the Mac packages and the Aurora chain. + +retired=FBD6874D423C418DDB6D143EECE19CDDE306DBD2 + +# A GRUB tester on the Asahi kernel with an encrypted root: runtime and +# settings 4.0.2-2 and a newer cursor-bin from the unsigned collaboration +# repository, omarchy-mac above the candidate's release; the signed candidate +# set as the administrator's target. +new_fixture() { + local name=$1 + F=$tmp/$name + R=$F/root + rm -rf "$F" + mkdir -p "$R" + mkdir -p "$R/run/systemd/system" "$R/run/lock" "$R/var/tmp" "$R/proc/sys/kernel/random" "$R/etc/default" "$R/etc/omarchy-mac" \ + "$R/var/lib/pacman/local" "$R/var/lib/pacman/sync" "$R/var/cache/pacman/pkg" "$R/etc/pacman.d/gnupg" \ + "$R/usr/share/pacman/keyrings" "$R/usr/share/limine" "$R/boot/grub" "$R/boot/efi/EFI/BOOT" "$R/boot/efi/m1n1" \ + "$R/usr/lib/modules/6.19.1-asahi" "$R/usr/lib/modules/7.1.12-aurora" "$R/var/lib/omarchy/migrations" + echo boot-1 >"$R/proc/sys/kernel/random/boot_id" + echo 6.19.1-asahi >"$R/proc/sys/kernel/osrelease" + echo linux-asahi >"$R/usr/lib/modules/6.19.1-asahi/pkgbase" + echo linux-aurora >"$R/usr/lib/modules/7.1.12-aurora/pkgbase" + echo "menuentry linux-asahi" >"$R/boot/grub/grub.cfg" + echo grub >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" + echo m1n1 >"$R/boot/efi/m1n1/boot.bin" + echo "limine 12.9" >"$R/usr/share/limine/BOOTAA64.EFI" + echo "GRUB_CMDLINE_LINUX=\"rd.luks.name=abc=root\"" >"$R/etc/default/grub" + echo "root UUID=abc none" >"$R/etc/crypttab" + : >"$R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending" + for keyring in archlinuxarm asahi-alarm omarchy; do + : >"$R/usr/share/pacman/keyrings/$keyring.gpg" + done + echo 1111111111111111111111111111111111111111 >"$R/usr/share/pacman/keyrings/archlinuxarm-trusted" + echo 2222222222222222222222222222222222222222 >"$R/usr/share/pacman/keyrings/asahi-alarm-trusted" + : >"$R/usr/share/pacman/keyrings/omarchy-trusted" + printf '%s f\n%s f\n%s f\n' 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 "$retired" \ + >"$R/etc/pacman.d/gnupg/keys" + mkdir -p "$F/keyserver" + : >"$F/keyserver/$official" + + cat >"$R/var/lib/pacman/local/packages" <<'LOCAL' +asahi-alarm-keyring 20250101-1 +cursor-bin 3.20.17-1 +hyprland 0.50-1 +limine 12.9.0-1 +linux-asahi 6.19.1-1 +m1n1 1.5.0-1 +omarchy 4.0.2-2 +omarchy-mac 0.1.0-5.9 +omarchy-settings 4.0.2-2 +pacman 7.0.0-1 +uboot-asahi 2026.01-1 +widget-extra 1.0-1 +LOCAL + for file in omarchy-4.0.2-2-aarch64.pkg.tar.xz omarchy-settings-4.0.2-2-aarch64.pkg.tar.xz linux-asahi-6.19.1-1-aarch64.pkg.tar.zst \ + m1n1-1.5.0-1-aarch64.pkg.tar.zst uboot-asahi-2026.01-1-aarch64.pkg.tar.zst; do + echo cached >"$R/var/cache/pacman/pkg/$file" + done + repo core <<<"pacman 7.0.0-1" + printf 'hyprland 0.51-1\nlimine 12.9.0-1\n' | repo extra + printf 'linux-asahi 6.19.1-1\nm1n1 1.5.0-1\nuboot-asahi 2026.01-1\nasahi-alarm-keyring 20250101-1\n' | repo asahi-alarm + printf 'omarchy 4.0.2-1\nomarchy-settings 4.0.2-1\ncursor-bin 3.20.10-1\n' | repo omarchy-old + repo omarchy <<'EDGE' +omarchy 4.0.4-1 +omarchy-settings 4.0.4-1 +omarchy-dev 4.0.0.r6713.ga85e29a-1 +omarchy-settings-dev 4.0.0.r6713.ga85e29a-1 +omarchy-mac 0.1.0-6 +omarchy-mac-boot 20260927-1 +omarchy-keyring 20251027-1 +linux-aurora 7.1.12.aurora2-11 +linux-aurora-headers 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +uboot-asahi 2026.07.asahi2-4 +limine-mkinitcpio-hook 1.39.0-2 +cursor-bin 3.20.10-1 +EDGE + printf 'omarchy 4.0.2-2\nomarchy-settings 4.0.2-2\ncursor-bin 3.20.17-1\nwidget-extra 1.0-1\n' | repo omarchy-aarch64 + cp "$F/repos/omarchy-aarch64/omarchy-aarch64.db" "$R/var/lib/pacman/sync/" + alarm_repos + relations + + cat >"$R/etc/pacman.conf" <"$R/etc/omarchy-mac/migration-target" <"$F/platform" + echo "base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck" >"$F/hooks" + printf '%s\n' "$R/boot/efi" >"$F/mounts" + echo /dev/mapper/root >"$F/root-source" + printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p5 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" + : >"$F/pacman.log" + : >"$F/boot.log" +} + +# Everything a finished migration leaves that must not depend on how it got +# there: packages, configuration, keys, loader, records and backups. +outcome() { + local state + state=$(state_dir) + cat "$R/var/lib/pacman/local/packages" + sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf" + sort "$R/etc/pacman.d/gnupg/keys" + cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" "$R/etc/default/limine" + ls "$R/var/lib/omarchy" + ls "$R/var/lib/pacman/sync" + [[ ! -e $R/var/lib/pacman/db.lck ]] && echo unlocked + sed -n 's/^target=//p' "$state/complete" + (cd "$state/backup" && find . -type f | LC_ALL=C sort && sed 's/^[0-9a-f]* //' SHA256SUMS) + ls "$state" + [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && echo "no unit" +} + +# --- The whole transition -------------------------------------------------- + +new_fixture baseline +output=$(migrate run 2>&1) || fail "a tester migrates to its reboot" "$output" +grep -q "Reboot to finish the migration to candidate-set apple-test-fixture" <<<"$output" || fail "the run asks for a reboot" "$output" +[[ $(migrate status) == *"State: waiting for a reboot"* ]] || fail "status reports the pending reboot" "$(migrate status)" +grep -q "^systemctl enable omarchy-mac-migrate-verify.service" "$F/boot.log" || fail "the post-reboot check is enabled" +state=$(state_dir) +grep -q "^ExecStart=/var/lib/omarchy-mac/migration/tool/omarchy-mac-migrate verify$" "$R/etc/systemd/system/omarchy-mac-migrate-verify.service" && + cmp -s "$tool" "$state/tool/omarchy-mac-migrate" || fail "the unit runs the tool's own kept copy" +output=$(migrate verify 2>&1) || fail "verify before the reboot waits" "$output" +[[ ! -f $state/complete ]] || fail "nothing completes before the reboot" +pass "a GRUB tester is migrated up to its reboot, which it waits for" + +expected_packages='asahi-alarm-keyring 20250101-1 +cursor-bin 3.20.10-1 +hyprland 0.51-1 +limine 12.9.0-1 +limine-mkinitcpio-hook 1.39.0-2 +linux-aurora 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +omarchy-dev 4.0.0.r7000.gabc-1.1 +omarchy-keyring 20251027-1 +omarchy-mac 0.1.0-11.1 +omarchy-mac-boot 20261004-1.1 +omarchy-settings-dev 4.0.0.r7000.gabc-1.1 +pacman 7.0.0-1 +uboot-asahi 2026.07.asahi2-4 +widget-extra 1.0-1' +[[ $(cat "$R/var/lib/pacman/local/packages") == "$expected_packages" ]] || + fail "the transaction swaps the runtime for the omarchy-dev pair, replaces every same-name build, even higher ones, and swaps the Asahi kernel and m1n1" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^transaction omarchy-mac-candidate/omarchy-dev omarchy-mac-candidate/omarchy-settings-dev omarchy-mac-candidate/omarchy-mac " "$F/pacman.log" || + fail "targets are named in the candidate repository" "$(grep transaction "$F/pacman.log")" +grep -q "^transaction .* cursor-bin asahi-alarm-keyring omarchy-keyring$" "$F/pacman.log" || + fail "a collaboration build the official repositories carry and the keyrings are named too" "$(grep transaction "$F/pacman.log")" +! grep -q "headers" "$F/pacman.log" || fail "no kernel headers are installed where there were none" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "one package transaction" +grep -q "^widget-extra 1.0-1$" "$state/plan/kept" || fail "a build with no official counterpart is kept and listed" +pass "one transaction moves to the omarchy-dev pair and replaces same-name candidates, including higher-versioned ones, and keeps what has no official build" + +conf=$(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") +expected_conf=$(OMARCHY_MAC_MIGRATE_ROOT=$R fixture=1 bash -c 'source <(sed -n "/^core_pacman_conf() {/,/^}/p" "$1"); core_pacman_conf "file://FIXTURE/repos/omarchy"' _ "$ROOT/migrate/src/target.sh" | + sed "s|^Server = https://github.com/asahi-alarm.*|Server = file://FIXTURE/repos/asahi-alarm|") +[[ $conf == "$expected_conf" ]] || fail "pacman.conf is the core Apple Silicon configuration for the target" "$(diff <(echo "$expected_conf") <(echo "$conf"))" +! grep -q 'candidate\|IgnorePkg\|TrustAll\|omarchy-aarch64' "$R/etc/pacman.conf" || fail "no candidate repository, pin, guard or collaboration repository stays" +[[ ! -e $R/var/lib/pacman/sync/omarchy-aarch64.db && ! -e $R/var/lib/pacman/sync/omarchy-mac-candidate.db ]] || + fail "the retired and candidate databases are gone" +grep -q "^pacman-key --populate archlinuxarm asahi-alarm omarchy" "$F/pacman.log" || fail "the installed keyrings are populated" +grep -q "^pacman-key --recv-keys $official" "$F/pacman.log" && grep -q "^$official f" "$R/etc/pacman.d/gnupg/keys" || + fail "the missing Omarchy key is fetched by fingerprint and trusted" +! grep -q "^$retired " "$R/etc/pacman.d/gnupg/keys" || fail "the retired fork key is deleted" +! grep -q "$signer" "$R/etc/pacman.d/gnupg/keys" || fail "the candidate key never enters pacman's keyring" +pass "the core pacman configuration, official trust bootstrapped by fingerprint, legacy trust and candidates gone" + +for file in installed etc.tar boot.tar esp.tar luks-header.img packages/omarchy-4.0.2-2-aarch64.pkg.tar.xz \ + packages/linux-asahi-6.19.1-1-aarch64.pkg.tar.zst packages/m1n1-1.5.0-1-aarch64.pkg.tar.zst SHA256SUMS; do + [[ -f $state/backup/$file ]] || fail "the backup holds $file" +done +grep -q "^omarchy-mac 0.1.0-5.9$" "$state/backup/packages.missing" || fail "an uncached package is listed as missing from the backup" +(cd "$state/backup" && sha256sum -c --quiet SHA256SUMS) || fail "the backup's digests verify" +[[ $(stat -c %a "$state/backup") == 700 ]] || fail "the backup is readable by root only" +tar -xOf "$state/backup/esp.tar" ./EFI/BOOT/BOOTAA64.EFI | grep -qx grub || fail "the ESP backup predates the switch" +grep -q "^cryptsetup luksHeaderBackup /dev/nvme0n1p5 " "$F/pacman.log" || fail "the LUKS header of the root partition is backed up" +pass "packages, /etc, /boot, the ESP and the LUKS header are backed up before anything changes" + +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" && -e $R/var/lib/omarchy/limine.enabled ]] || + fail "Limine takes the loader slot" +[[ $(grep -E '^(update-m1n1|update-grub|boot-check pending --boot-chain linux-aurora|dispatch setup-boot|limine-boot activate|dispatch setup-system|dispatch update-verify)' "$F/boot.log" | tr '\n' '|') == \ + "update-m1n1 |update-grub |boot-check pending --boot-chain linux-aurora|dispatch setup-boot|limine-boot activate|boot-check pending --boot-chain linux-aurora|dispatch setup-system|boot-check pending --boot-chain linux-aurora|dispatch update-verify|" ]] || + fail "m1n1 and U-Boot are rebuilt and checked, the new runtime's setup-boot activates Limine, then setup-system and update-verify run" "$(cat "$F/boot.log")" +pass "the boot chain is rebuilt and checked, and the new runtime's setup-boot, setup-system and update-verify run before the reboot" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the post-reboot verification completes the migration" "$output" +[[ -f $state/complete && ! -e $state/reboot-pending && ! -e $state/cache ]] || fail "completion retires the working state" +grep -q "^boot-check --boot-chain linux-aurora$" "$F/boot.log" || fail "the booted chain passes the full boot check" +[[ $(grep -c '^dispatch update-verify' "$F/boot.log") == 2 ]] || fail "update-verify runs again after the reboot" +grep -q "^systemctl disable omarchy-mac-migrate-verify.service" "$F/boot.log" && [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service && ! -e $state/tool ]] || + fail "the post-reboot unit and the tool's copy go" +[[ ! -e $R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending ]] || fail "the collaboration repository's marker is retired" +[[ $(migrate status) == *"State: complete"* ]] || fail "status reports completion" +baseline=$(outcome) +pass "after the reboot, Aurora through Limine is verified and compatibility state is retired" + +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +grep -q "Already migrated to candidate-set apple-test-fixture" <<<"$output" || fail "a second run says it is done" "$output" +[[ $(outcome) == "$baseline" ]] || fail "a second run changes nothing" +rm -rf "$state" +output=$(migrate run 2>&1) || fail "a Mac already on the target set passes" "$output" +grep -q "already runs the target set" <<<"$output" && [[ ! -e $state/journal ]] || fail "a Mac already on the target set is left alone" "$output" +pass "the migration is idempotent" + +new_fixture check +digest=$(fixture_digest) +output=$(migrate check 2>&1) || fail "check passes on a Mac ready to migrate" "$output" +grep -q "Ready: run moves this Mac (tester, grub boot, encrypted) onto candidate-set apple-test-fixture" <<<"$output" || + fail "check says what run would do" "$output" +[[ $(fixture_digest) == "$digest" && ! -e $(state_dir)/journal ]] && ! grep -q '^transaction\|^pacman-key' "$F/pacman.log" || + fail "check changes nothing" +pass "check runs preflight alone and changes nothing" + +# --- Interruption at every journal step --------------------------------------- + +interrupt() { # when step + local when=$1 step=$2 status=0 output last transactions=1 + new_fixture "kill-$when-$step" + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $step" "status $status: $output" + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "${step%-leaf} done"* ]] || fail "the journal ends with $step done" "$last" + else + [[ $last == "${step%-leaf} begin"* ]] || fail "the journal ends with $step begun" "$last" + fi + finish + [[ $(outcome) == "$baseline" ]] || fail "killed $when $step, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $when$step == "midtransaction" ]] && transactions=2 + [[ $(grep -c '^transaction ' "$F/pacman.log") == "$transactions" ]] || fail "killed $when $step: $transactions package transaction(s)" "$(cat "$F/pacman.log")" + [[ $(tail -n 1 < <(grep '^transaction \|^hooks' "$F/pacman.log")) == "hooks" ]] || fail "killed $when $step: the last transaction's hooks ran" +} + +for step in "${steps[@]}"; do + interrupt after "$step" +done +pass "a kill -9 between any two journal steps resumes to the same end, with one transaction" +for step in "${steps[@]}"; do + interrupt during "$step" +done +pass "a kill -9 after any step's work but before its record resumes to the same end, with one transaction" +for step in backup keyring prefetch repositories transaction boot-chain loader loader-leaf defaults unpin reboot retire; do + interrupt mid "$step" +done +pass "a kill -9 in the middle of any step resumes to the same end; pacman killed before its hooks runs the transaction again" + +# After the repository switch a boot resumes the migration: the fork's own +# update may be gone with its packages. +for step in repositories transaction loader; do + new_fixture "boot-resumes-$step" + kill_after "$step" + [[ -f $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && grep -q "^systemctl enable omarchy-mac-migrate-verify" "$F/boot.log" || + fail "after $step the unit that resumes at boot is armed" + output=$(migrate verify 2>&1) || fail "after $step a boot resumes the migration" "$output" + grep -q "Reboot to finish" <<<"$output" || fail "after $step the boot's run reaches the reboot" "$output" +done +new_fixture boot-before-switch +kill_after prefetch +[[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] || fail "before the switch no unit is armed" +output=$(migrate verify 2>&1) && [[ -z $output ]] || fail "before the switch a boot does nothing" "$output" +pass "from the repository switch on, the next boot resumes an unfinished migration" + +# --- The guard while the transaction is pending ---------------------------------- + +new_fixture guard +kill_after repositories +guarded=$(cat "$R/etc/pacman.conf") +grep -q "^# omarchy-mac-migrate: a migration to Omarchy's packages is in progress." <<<"$guarded" || + fail "the switched configuration carries the guard" "$guarded" +ignore=$(sed -n 's/^IgnorePkg = //p' <<<"$guarded") +for name in omarchy-dev omarchy-settings-dev omarchy-mac omarchy-mac-boot linux-aurora m1n1-aurora uboot-asahi limine-mkinitcpio-hook omarchy omarchy-settings linux-asahi m1n1 limine asahi-scripts mkinitcpio; do + [[ " $ignore " == *" $name "* ]] || fail "the guard holds $name back" "$ignore" +done +pass "between the switch and the end of setup, a plain pacman -Syu leaves every package the migration changes alone" +for step in transaction boot-chain loader defaults verify; do + new_fixture "guard-$step" + kill_after "$step" + grep -q "^IgnorePkg = " "$R/etc/pacman.conf" || fail "after $step the guard stays" +done +new_fixture guard-unpin +kill_after unpin +! grep -q "IgnorePkg" "$R/etc/pacman.conf" || fail "after unpin the guard is gone" +pass "the guard stays through setup and verification and goes once they pass" + +# A test image's pin stays until the transaction is done, then goes with the guard. +new_fixture pinned-image +pin_mark="# Test image only (omarchy-mac-installer image-builder): keeps the candidate set's runtime," +sed -i "/^\[options\]$/a $pin_mark\n# whose version sorts below the channel's. Keep these three lines.\nIgnorePkg = omarchy omarchy-mac omarchy-settings" "$R/etc/pacman.conf" +kill_after repositories +grep -Fq "$pin_mark" "$R/etc/pacman.conf" && grep -q "^IgnorePkg = omarchy omarchy-mac omarchy-settings$" "$R/etc/pacman.conf" || + fail "the test image's pin stays beside the guard" "$(cat "$R/etc/pacman.conf")" +finish +! grep -q "IgnorePkg\|Test image only" "$R/etc/pacman.conf" || fail "the pin goes once the migration is set up" "$(cat "$R/etc/pacman.conf")" +pass "a test image's pin stays until the new packages are set up, and goes with the guard" + +# An administrator's own options and repositories stay. +new_fixture admin-options +sed -i '/^\[options\]$/a IgnorePkg = firefox\nNoExtract = usr/share/help/*' "$R/etc/pacman.conf" +printf '\n[custom]\nServer = file://%s/repos/custom\n' "$F" >>"$R/etc/pacman.conf" +repo custom <<<"widget-custom 1.0-1" +finish +grep -qx "IgnorePkg = firefox" "$R/etc/pacman.conf" && grep -qx "NoExtract = usr/share/help/\*" "$R/etc/pacman.conf" && + grep -qx "\[custom\]" "$R/etc/pacman.conf" || fail "an administrator's options and repositories are kept" "$(cat "$R/etc/pacman.conf")" +[[ $(grep -c '^IgnorePkg' "$R/etc/pacman.conf") == 1 ]] || fail "only the administrator's IgnorePkg stays" +pass "an administrator's own options and repositories stay in the core configuration" + +# --- Preflight refusals --------------------------------------------------------- + +new_fixture refusals +sed -i '/^omarchy /d' "$R/var/lib/pacman/local/packages" +refused "Omarchy 3.x" "upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" +new_fixture refusals +echo "base udev autodetect microcode modconf kms keyboard keymap block encrypt filesystems fsck" >"$F/hooks" +refused "busybox encrypt" "busybox encrypt" +new_fixture refusals +printf '\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///custom\n' >>"$R/etc/pacman.conf" +refused "an unknown TrustAll repository" "\[custom\] accepts untrusted packages" +new_fixture refusals +printf '\n[custom]\nInclude = /etc/pacman.d/custom\n' >>"$R/etc/pacman.conf" +printf 'SigLevel = Optional TrustAll\nServer = file:///custom\n' >"$R/etc/pacman.d/custom" +refused "a TrustAll repository an Include configures" "\[custom\] accepts untrusted packages" +new_fixture refusals +sed -i '/^\[options\]$/a IgnorePkg = omarchy-mac*' "$R/etc/pacman.conf" +refused "an administrator's pin on a target" "holds back omarchy-mac, which the migration changes" +new_fixture refusals +sed -i '/^\[options\]$/a NoExtract = usr/bin/omarchy-lifecycle-*' "$R/etc/pacman.conf" +refused "a NoExtract that drops the dispatcher" "NoExtract or NoUpgrade (usr/bin/omarchy-lifecycle-\*) .* keeps usr/bin/omarchy-lifecycle-dispatch" +new_fixture refusals +sed -i '/^\[options\]$/a Include = /etc/pacman.d/extra-options' "$R/etc/pacman.conf" +refused "an Include in [options]" "an Include in \[options\]" +new_fixture refusals +: >"$R/var/lib/pacman/db.lck" +refused "a pacman lock" "pacman is busy" +new_fixture refusals +echo "/boot/initramfs-linux-asahi.img does not hold the 6.19.1 modules" >"$F/boot-check-fail" +refused "incoherent boot files" "boot files are not coherent" +grep -q "^boot-check pending --boot-chain$" "$F/boot.log" || fail "preflight checks the installed boot chain, not the running kernel" "$(cat "$F/boot.log")" +new_fixture refusals +mkdir -p "$R/var/lib/omarchy/mac-first-boot" +: >"$R/var/lib/omarchy/mac-first-boot/pending" +refused "an unfinished first boot" "first boot has not finished" +new_fixture refusals +echo "linux-aurora 7.1.11-1" >>"$R/var/lib/pacman/local/packages" +refused "two kernels" "expected one Apple kernel" +new_fixture refusals +rm "$R/etc/default/grub" +refused "no GRUB defaults" "no /etc/default/grub" +new_fixture refusals +rm "$R/usr/share/pacman/keyrings/asahi-alarm.gpg" +refused "no Asahi keyring" "asahi-alarm-keyring is not installed" +new_fixture refusals +rmdir "$R/run/systemd/system" +refused "an image build" "not a booted system" +new_fixture refusals +mkdir -p "$R/sys/class/power_supply/macsmc-battery" "$R/sys/class/power_supply/macsmc-ac" +echo Battery >"$R/sys/class/power_supply/macsmc-battery/type" +echo 12 >"$R/sys/class/power_supply/macsmc-battery/capacity" +echo Mains >"$R/sys/class/power_supply/macsmc-ac/type" +echo 0 >"$R/sys/class/power_supply/macsmc-ac/online" +refused "a low battery" "battery is below 30%" +echo 1 >"$R/sys/class/power_supply/macsmc-ac/online" +output=$(migrate run 2>&1) || fail "a low battery on the charger migrates" "$output" +new_fixture refusals +: >"$F/df-low" +refused "no space" "needs .* MiB free" +new_fixture refusals +: >"$F/mounts" +refused "no system ESP" "system ESP is not mounted at /boot/efi" +new_fixture refusals +head -c 16 /dev/urandom >>"$F/set/$(jq -r '.packages[2].filename' "$F/set/manifest.json")" +refused "a tampered candidate package" "does not verify: omarchy-mac-.* is missing or changed" +new_fixture refusals +sed -i "s/^fingerprint=.*/fingerprint=$other/" "$R/etc/omarchy-mac/migration-target" +refused "a set signed by another key" "does not verify: its key is not $other" +new_fixture refusals +resign_set "$F/set" "$tmp/other" +refused "a set re-signed by an untrusted key" "does not verify: its key is not $signer" +new_fixture refusals +resign_set "$F/set" "$tmp/other" +cat "$tmp/signer.asc" >>"$F/set/candidate-signing-key.asc" +refused "a receipt signed by another key in the key file" "does not verify: signing.json is not signed by $signer" +new_fixture refusals +cat "$tmp/other.asc" >>"$F/set/candidate-signing-key.asc" +package=$(jq -r '.packages[1].filename' "$F/set/manifest.json") +rm "$F/set/$package.sig" +gpg --batch --homedir "$tmp/other" --detach-sign --no-armor -o "$F/set/$package.sig" "$F/set/$package" 2>/dev/null +refused "a package signed by another key in the key file" "does not verify: $package is not signed by $signer" +new_fixture refusals +jq '.packages |= map(select(.name != "uboot-asahi"))' "$F/set/manifest.json" >"$F/manifest" && mv "$F/manifest" "$F/set/manifest.json" +refused "a changed manifest" "signing.json does not bind this manifest" +new_fixture refusals +rm "$F/keyserver/$official" +refused "an unreachable Omarchy key" "cannot fetch and trust the Omarchy packaging key" +pass "preflight refuses unsupported cohorts, legacy unlock, untrusted or pinning configurations, busy or incoherent systems, low power or space and unverifiable sets, changing nothing" + +# A key whose signing subkey made the signatures is named by its primary fingerprint. +mkdir -m 700 "$tmp/subkey-home" +gpg --batch --homedir "$tmp/subkey-home" --pinentry-mode loopback --passphrase '' --quick-gen-key "Migration test subkey" ed25519 cert 1d 2>/dev/null +subkey_primary=$(gpg --batch --homedir "$tmp/subkey-home" --with-colons --list-secret-keys 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }') +gpg --batch --homedir "$tmp/subkey-home" --pinentry-mode loopback --passphrase '' --quick-add-key "$subkey_primary" ed25519 sign 1d 2>/dev/null +new_fixture subkey +rm -rf "$F/set" +make_set "$F/set" "$tmp/subkey-home" +sed -i "s/^fingerprint=.*/fingerprint=$subkey_primary/" "$R/etc/omarchy-mac/migration-target" +output=$(migrate run 2>&1) || fail "a set signed by the pinned key's signing subkey verifies" "$output" +grep -q "Reboot to finish" <<<"$output" || fail "the subkey-signed set migrates to its reboot" "$output" +pass "signatures count only when the pinned key (or its signing subkey) made them, whatever else the key file holds" + +new_fixture elsewhere +echo generic-aarch64 >"$F/platform" +output=$(migrate run 2>&1) || fail "another platform is a no-op" "$output" +grep -q "Not an Apple Silicon Mac" <<<"$output" && [[ ! -e $(state_dir) ]] || fail "another platform is left alone" "$output" +if OMARCHY_MAC_MIGRATE_ROOT="" "$tool" run 2>/dev/null; then fail "a normal user without a fixture root is refused"; fi +pass "other platforms and unprivileged callers change nothing" + +new_fixture target-trust +chmod 666 "$R/etc/omarchy-mac/migration-target" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "refusing the target" <<<"$output" && [[ ! -e $(state_dir)/journal ]] || fail "a target others can write is refused, deferred with nothing changed" "$output" +chmod 644 "$R/etc/omarchy-mac/migration-target" +chmod 777 "$F/set" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) || fail "a candidate directory others can write is refused" "$output" +pass "target files and candidate sets must be writable by their owner only" + +# --- The channel ------------------------------------------------------------------ + +# Without an administrator's target, the Mac follows the channel its own +# configuration names, here the collaboration repository's edge lane. +channel_fixture() { + new_fixture "$1" + rm "$R/etc/omarchy-mac/migration-target" + sed -i "s|^Server = file://$F/repos/omarchy-aarch64$|Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/${2:-edge}|" "$R/etc/pacman.conf" +} + +channel_fixture no-channel +sed -i "s|^Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/.*|Server = https://example.org/elsewhere|" "$R/etc/pacman.conf" +refused "an unknown channel" "cannot tell which Omarchy channel this Mac follows" +channel_fixture stable stable +# Omarchy's stable channel today: no Mac packages. +printf 'omarchy 4.0.4-1\nomarchy-settings 4.0.4-1\nomarchy-keyring 20251027-1\n' | repo official-stable omarchy +refused "the stable channel without Mac packages" "the stable channel has no omarchy-mac for Apple Silicon yet" +output=$(migrate run 2>&1 || true) +grep -q "The stable channel has no Mac release yet" <<<"$output" || fail "the deferral says why" "$output" +[[ $(migrate status) == *"The last run deferred: "*"stable channel has no omarchy-mac"* ]] || fail "status says why it deferred" "$(migrate status)" +channel_fixture edge-lane edge +cp -r "$F/repos/omarchy" "$F/repos/official-edge" +archive omarchy-dev 4.0.0.r6713.ga85e29a-1 +archive omarchy-mac-boot 20260927-1 +output=$(migrate check 2>&1) || fail "an edge lane follows the edge channel" "$output" +grep -q "onto repository file://$F/repos/official-edge (edge)" <<<"$output" || fail "the edge lane's Mac moves to the edge channel" "$output" +pass "a Mac whose channel cannot be told, or has no Mac release, defers with nothing changed" + +# A repository target whose archives are not ready for Macs yet. +repository_fixture() { + new_fixture "$1" + printf 'format=1\ntype=repository\nchannel=edge\nserver=file://%s/repos/omarchy\n' "$F" >"$R/etc/omarchy-mac/migration-target" + archive omarchy-dev 4.0.0.r6713.ga85e29a-1 + archive omarchy-mac-boot 20260927-1 +} +repository_fixture edge-old-runtime +make_archive omarchy-mac-boot 20260927-1 "$F/archives/omarchy-mac-boot" +printf 'pkgname = omarchy-dev\npkgver = 4.0.0.r6713.ga85e29a-1\n' >"$tmp/PKGINFO" && bsdtar -czf "$F/archives/omarchy-dev" -C "$tmp" --transform 's|PKGINFO|.PKGINFO|' PKGINFO 2>/dev/null || + (mkdir -p "$tmp/old" && cp "$tmp/PKGINFO" "$tmp/old/.PKGINFO" && bsdtar -czf "$F/archives/omarchy-dev" -C "$tmp/old" .PKGINFO) +refused "a runtime without the dispatcher" "has no omarchy-lifecycle-dispatch" +repository_fixture edge-old-boot +mkdir -p "$tmp/oldboot/usr/lib/omarchy-mac/boot" "$tmp/oldboot/usr/lib/omarchy/mac-boot" +printf 'pkgname = omarchy-mac-boot\npkgver = 20260927-1\n' >"$tmp/oldboot/.PKGINFO" +: >"$tmp/oldboot/usr/lib/omarchy-mac/boot/migrate-engine.sh" +: >"$tmp/oldboot/usr/lib/omarchy/mac-boot/setup-boot" +: >"$tmp/oldboot/usr/lib/omarchy/mac-boot/update-verify" +bsdtar -czf "$F/archives/omarchy-mac-boot" -C "$tmp/oldboot" .PKGINFO usr +refused "an omarchy-mac-boot with its own migration engine" "not built from omacom/omarchy-mac-pkgs yet" +repository_fixture edge-ready +output=$(migrate run 2>&1) || fail "a ready repository target migrates" "$output" +grep -q "^omarchy-dev 4.0.0.r6713.ga85e29a-1$" "$R/var/lib/pacman/local/packages" && grep -q "^omarchy-mac-boot 20260927-1$" "$R/var/lib/pacman/local/packages" || + fail "the official builds replace higher-versioned tester builds" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^transaction omarchy/omarchy-dev omarchy/omarchy-settings-dev omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook cursor-bin asahi-alarm-keyring omarchy-keyring$" "$F/pacman.log" || + fail "each official package is named in [omarchy]" "$(grep transaction "$F/pacman.log")" +grep -q "^download omarchy-dev \|^download omarchy-mac-boot " "$F/pacman.log" || fail "preflight reads the verified archives" +pass "a channel is ready for Macs only when its signed archives carry the dispatcher and a boot package without its own migration engine" + +# --- Failures after preflight -------------------------------------------------- + +new_fixture removal +echo "omarchy-mac-boot widget-extra" >>"$F/conflicts" +conf_before=$(cat "$R/etc/pacman.conf") +packages_before=$(cat "$R/var/lib/pacman/local/packages") +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove widget-extra; nothing was changed" <<<"$output" || fail "an unexpected removal stops the rehearsal, deferred" "status $status: $output" +[[ $(cat "$R/etc/pacman.conf") == "$conf_before" && $(cat "$R/var/lib/pacman/local/packages") == "$packages_before" ]] || + fail "the repositories and packages are untouched after a failed rehearsal" +[[ ! -e $(state_dir)/journal && -n $(ls -d "$(state_dir)"/history/aborted-* 2>/dev/null) ]] || fail "the attempt is set aside, so the next run starts over" +[[ $(migrate status) == *"would also remove widget-extra"* ]] || fail "status says why it deferred" "$(migrate status)" +pass "a rehearsal that would remove more than the plan allows defers before the switch, and the next run starts over" + +new_fixture after-boundary +kill_after repositories +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && [[ -f $(state_dir)/journal ]] || fail "a failure after the switch is a failure, never a deferral" "status $status: $output" +rm "$F/fail-transaction" +finish +pass "after the repository switch a failure stops the migration for the next run to resume" + +new_fixture loader +: >"$F/limine-activation-fail" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "Limine could not be activated; GRUB is still the loader" <<<"$output" || fail "a failed Limine activation fails the step" "$output" +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "grub" && ! -e $R/var/lib/omarchy/limine.enabled ]] || fail "a failed activation leaves GRUB as the loader" +rm "$F/limine-activation-fail" +finish +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" ]] || fail "the retried loader step activates Limine" +pass "a failed Limine activation leaves GRUB active, and the retry finishes" + +new_fixture update-verify +kill_after defaults +echo "the UKI does not hold the 7.1.12 modules" >"$F/update-verify-fail" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "update-verify does not pass" <<<"$output" || fail "a failing update-verify stops before the reboot" "$output" +! grep -q "^systemctl enable omarchy-mac-migrate-verify.service$" <(grep -A100 'dispatch update-verify' "$F/boot.log" | tail -n +2) || true +grep -q "^IgnorePkg" "$R/etc/pacman.conf" || fail "the guard stays while verification fails" +rm "$F/update-verify-fail" +finish +pass "update-verify must pass before the reboot is offered, and after it" + +new_fixture aborted-boot +output=$(migrate run 2>&1) || fail "the migration reaches its reboot" "$output" +echo boot-2 >"$R/proc/sys/kernel/random/boot_id" +status=0 +output=$(migrate verify 2>&1) || status=$? +(( status == 1 )) && grep -q "this boot runs 6.19.1-asahi, not linux-aurora 7.1.12-aurora" <<<"$output" || fail "a boot of the old kernel fails verification" "$output" +[[ ! -f $(state_dir)/complete && -e $(state_dir)/reboot-pending ]] || fail "an unverified boot retires nothing" +pass "a reboot that did not come up on Aurora is not accepted" + +new_fixture busy +kill_after backup +printf 'format=1\ntype=repository\nchannel=stable\nserver=file://%s/repos/omarchy\n' "$F" >"$F/stable-target" +output=$(migrate run --target "$F/stable-target" 2>&1) && fail "another target is refused while one is in progress" "$output" +grep -q "a migration to candidate-set apple-test-fixture .* is in progress" <<<"$output" || fail "the refusal names the migration in progress" "$output" +pass "a migration in progress keeps its target" + +new_fixture first-boot +: >"$F/scriptlet-arms-first-boot" +finish +[[ ! -e $R/var/lib/omarchy/mac-first-boot/pending ]] || fail "a first-boot marker armed by the transaction is removed" +pass "fresh-image first boot is never armed on an existing Mac" + +new_fixture locked +kill_after keyring +journal_before=$(cat "$(state_dir)/journal") +exec 8>"$R/run/lock/omarchy-mac-migrate.lock" +flock -n 8 +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "another migration run is in progress" <<<"$output" && [[ $(cat "$(state_dir)/journal") == "$journal_before" && -d $(state_dir)/backup ]] || + fail "a run that cannot take the lock before the switch defers and leaves the state alone" "status $status: $output" +exec 8>&- +kill_after repositories +exec 8>"$R/run/lock/omarchy-mac-migrate.lock" +flock -n 8 +status=0 +output=$(migrate verify 2>&1) || status=$? +(( status == 1 )) || fail "past the switch, a run that cannot take the lock fails" "status $status: $output" +exec 8>&- +finish +pass "a second run never touches the state of the run holding the lock" + +# --- The tool that resumes ---------------------------------------------------------- + +new_fixture handover +kill_after transaction +copy=$(state_dir)/tool/omarchy-mac-migrate +cmp -s "$tool" "$copy" || fail "the migration keeps a copy of the tool" +sed 's/^tool_version=1$/tool_version=99/' "$tool" >"$F/newer" && chmod 755 "$F/newer" +output=$(OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F OMARCHY_MAC_MIGRATE_ASAHI_SERVER="file://$F/repos/asahi-alarm" PATH="$stubs:$PATH" "$F/newer" run 2>&1) || + fail "a newer tool resumes the migration" "$output" +cmp -s "$F/newer" "$copy" || fail "a newer tool of the same journal format becomes the kept copy" +new_fixture handback +kill_after transaction +sed -i 's/^tool_version=1$/tool_version=99/' "$(state_dir)/tool/omarchy-mac-migrate" +output=$(migrate run 2>&1) || fail "an older tool hands the migration to the copy that started it" "$output" +grep -q "Resuming with the tool this migration started with (version 99)" <<<"$output" || fail "the hand-over is said" "$output" +pass "a migration resumes with the tool that started it, unless a newer one of the same journal format takes over" + +# --- The system moving under a migration -------------------------------------- + +# omarchy update runs pacman -Syu before the migration resumes. +for step in prefetch repositories; do + new_fixture "moved-$step" + kill_after "$step" + sed -i 's/^hyprland .*/hyprland 0.52-1/' "$R/var/lib/pacman/local/packages" + finish + grep -q "^hyprland 0.52-1$" "$R/var/lib/pacman/local/packages" || fail "after $step, the upgrade in between is kept" + grep -q " prefetch reset " "$(state_dir)/journal" || fail "after $step, the changed system is rehearsed again" "$(cat "$(state_dir)/journal")" + [[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "after $step, one transaction" +done +pass "an update between the rehearsal and the transaction sends the migration back to rehearse, instead of sticking" + +new_fixture frozen-databases +kill_after keyring +printf 'hyprland 0.53-1\nlimine 12.9.0-1\n' >"$F/repos/extra/extra.db" +finish +grep -q "^hyprland 0.51-1$" "$R/var/lib/pacman/local/packages" || fail "the transaction installs what preflight read, not a newer sync" +pass "the transaction installs the set preflight qualified, from the databases it froze" + +new_fixture snapshot +kill_after keyring +echo "widget-conflict 1.0-1" >>"$R/var/lib/pacman/local/packages" +echo "omarchy-mac-boot widget-conflict" >>"$F/conflicts" +sed -i '/^widget-extra /d' "$R/var/lib/pacman/local/packages" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove widget-conflict; nothing was changed" <<<"$output" || + fail "a package installed after preflight is still guarded against removal" "$output" +! grep -q "remove widget-extra" <<<"$output" || fail "a package removed after preflight is not reported" "$output" +pass "the removal guard compares against what the rehearsal started from" + +new_fixture held-lock +kill_after repositories +: >"$R/var/lib/pacman/db.lck" +mkdir -p "$R/proc/4242/fd" +ln -s "$R/var/lib/pacman/db.lck" "$R/proc/4242/fd/3" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "pacman is running (process 4242)" <<<"$output" && [[ -e $R/var/lib/pacman/db.lck ]] || + fail "a lock another package manager holds is left alone" "$output" +rm -rf "$R/proc/4242" +finish +pass "a held pacman lock stops the transaction; a stale one is cleared" + +new_fixture resynced +kill_after repositories +printf 'hyprland 0.53-1\nlimine 12.9.0-1\n' >"$R/var/lib/pacman/sync/extra.db" +finish +grep -q "^hyprland 0.51-1$" "$R/var/lib/pacman/local/packages" || fail "a sync after the rehearsal does not change what the transaction installs" +new_fixture interrupted-then-moved +output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_MID=transaction -- run 2>&1) && fail "pacman is killed after its database write" +echo "late-extra 1.0-1" >>"$R/var/lib/pacman/local/packages" +finish +grep -q " prefetch reset " "$(state_dir)/journal" || fail "the changed packages are rehearsed again" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 2 && $(grep '^transaction \|^hooks' "$F/pacman.log" | tail -n 1) == "hooks" ]] || + fail "a transaction killed before its hooks runs again after a new rehearsal" "$(cat "$F/pacman.log")" +pass "the transaction uses the rehearsed databases, and a killed one runs again even after a new rehearsal" + +new_fixture frozen-set +kill_after preflight +head -c 16 /dev/urandom >>"$F/set/$(jq -r '.packages[3].filename' "$F/set/manifest.json")" +finish +mv "$F/set" "$F/set.gone" +output=$(migrate status) && [[ $output == *"State: complete"* ]] || fail "status needs no candidate set" +pass "after preflight only the verified copy of the set is used, and the original may change or go" + +new_fixture enable +: >"$F/systemctl-fail" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "cannot install omarchy-mac-migrate-verify.service" <<<"$output" || fail "a unit that cannot be enabled fails the run" "$output" +rm "$F/systemctl-fail" +finish +pass "the unit that resumes the migration must be enabled before the switch goes ahead" + +# --- A fresh install's defaults ---------------------------------------------------- + +user_unit() { # name target + mkdir -p "$R/usr/lib/systemd/user" + printf '[Unit]\nDescription=%s\n\n[Install]\nWantedBy=%s\n' "$1" "$2" >"$R/usr/lib/systemd/user/$1" +} + +# Two users: one who has used Omarchy, with a unit enabled, one masked and one +# installed and turned off; one account Omarchy never ran for. +new_fixture defaults +printf 'root:x:0:0::/root:/bin/bash\ntester:x:1000:1000::/home/tester:/bin/bash\nguest:x:1001:1001::/home/guest:/bin/bash\n' >"$R/etc/passwd" +home=$R/home/tester +mkdir -p "$home/.local/state/omarchy" "$home/.config/systemd/user/graphical-session.target.wants" "$R/home/guest" +for unit in bt-agent.service omarchy-sleep-lock.service omarchy-migrate-notify.service omarchy-fcitx5.service; do + user_unit "$unit" graphical-session.target +done +user_unit omarchy-recover-internal-monitor.service graphical-session-pre.target +ln -s /usr/lib/systemd/user/bt-agent.service "$home/.config/systemd/user/graphical-session.target.wants/bt-agent.service" +ln -s /dev/null "$home/.config/systemd/user/omarchy-fcitx5.service" +echo "zram-generator 1.2-1" >>"$R/var/lib/pacman/local/packages" +echo "avd-fw 0.1-1" >>"$F/repos/asahi-alarm/asahi-alarm.db" +echo "libva-v4l2_request-avd 1.0-1" >>"$F/repos/omarchy/omarchy.db" +echo "obs-studio 32.0-1" >>"$F/repos/extra/extra.db" +kill_after preflight +# The target's omarchy brings units this Mac never had. +user_unit omarchy-brightness-keyboard-auto.service graphical-session.target +user_unit omarchy-crash-watch.service graphical-session.target +user_unit owed.service graphical-session.target +output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_MID=defaults -- run 2>&1) && fail "the run is killed in the middle of its defaults" +grep -q "Installing the default packages a fresh install has: avd-fw libva-v4l2_request-avd" <<<"$output" || fail "the missing Apple defaults are named" "$output" +grep -q "No repository carries these default packages, so they stay missing: .*widevine" <<<"$output" || + fail "defaults no repository carries are named, not fatal" "$output" +finish +[[ $(grep -c '^transaction avd-fw libva-v4l2_request-avd$' "$F/pacman.log") == 1 ]] || + fail "the missing Apple defaults are installed once, across a resumed step" "$(cat "$F/pacman.log")" +! grep -q "obs-studio\|zram-generator" <(grep '^transaction' "$F/pacman.log") || fail "the base list's applications and installed defaults are left alone" +grep -q "^dispatch setup-system" "$F/boot.log" || fail "the Mac services a fresh install enables are set up through the dispatcher" +wants=$home/.config/systemd/user/graphical-session.target.wants +for unit in omarchy-brightness-keyboard-auto.service omarchy-crash-watch.service owed.service; do + [[ $(readlink "$wants/$unit") == "/usr/lib/systemd/user/$unit" ]] || fail "a unit new to this Mac is enabled as first run does: $unit" "$(ls -la "$wants")" +done +[[ ! -e $wants/omarchy-sleep-lock.service && ! -L $wants/omarchy-sleep-lock.service ]] || fail "a unit the Mac had and the user turned off stays off" +[[ $(readlink "$home/.config/systemd/user/omarchy-fcitx5.service") == /dev/null && ! -L $wants/omarchy-fcitx5.service ]] || fail "a masked unit stays masked" +[[ $(readlink "$wants/bt-agent.service") == /usr/lib/systemd/user/bt-agent.service ]] || fail "an enabled unit is left as it is" +[[ ! -e $R/home/guest/.config ]] || fail "an account Omarchy never ran for is left alone" +[[ $(grep -c "^dispatch setup-user HOME=$home$" "$F/boot.log") -ge 1 ]] && ! grep -q "setup-user HOME=$R/home/guest\|setup-user HOME=$R/root" "$F/boot.log" || + fail "the Mac user setup runs through the dispatcher for each Omarchy user only" "$(grep setup-user "$F/boot.log")" +pass "a migrated Mac gains the Apple defaults, the Mac services and the user units a fresh install has, keeping every choice made" + +new_fixture defaults-failing +echo "avd-fw 0.1-1" >>"$F/repos/asahi-alarm/asahi-alarm.db" +: >"$F/setup-system-fail" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "setup-system (omarchy-lifecycle-dispatch) could not set up the Mac's services" <<<"$output" || fail "a failed system setup fails the step" "$output" +[[ $(migrate status) == *"failed at defaults"* ]] || fail "status names the failed defaults" "$(migrate status)" +rm "$F/setup-system-fail" +finish +[[ $(grep -c '^transaction avd-fw$' "$F/pacman.log") == 1 ]] || fail "the retry installs nothing twice" "$(cat "$F/pacman.log")" +pass "a failed defaults step stops before the reboot and is retried" + +first_run_units=$(git -C "$ROOT" show 69d80cccd:install/user/first-run/enable-user-units.sh 2>/dev/null | sed -n '/systemctl --user enable --now/,/[^\\]$/p' | grep -o '[a-z0-9-]*\.service' | xargs || true) +engine_units=$(sed -n 's/^fresh_user_units="\(.*\)"$/\1/p' "$ROOT/migrate/src/engine.sh") +if [[ -n $first_run_units ]]; then + [[ $first_run_units == "$engine_units" ]] || fail "the migration enables the user units upstream's first run enables" "first run: $first_run_units; migration: $engine_units" + pass "the migration's user units are upstream first run's" +fi + +# --- Repairs the runtime's Mac migrations made -------------------------------------- + +broadcom_block="# Broadcom's firmware supplicant and authenticator fail the WPA four-way +# handshake on Apple hardware, which surfaces as a rejected password. Disable +# both so wpa_supplicant performs the handshake instead. +options brcmfmac feature_disable=0x82000" + +# Two Omarchy users, one from quattro-upstream and one from mx-mac; alarm still +# in wheel beside the owner; the Intel Broadcom block after an owner's line; LANG=C. +repairs_fixture() { + new_fixture "$1" + printf 'root:x:0:0::/root:/bin/bash\nalarm:x:1000:1000::/home/alarm:/bin/bash\ntester:x:1001:1001::/home/tester:/bin/bash\nother:x:1002:1002::/home/other:/bin/bash\n' >"$R/etc/passwd" + printf 'root:x:0:\nwheel:x:998:alarm,tester\nalarm:x:1000:\n' >"$R/etc/group" + mkdir -p "$R/home/tester/.local/state/omarchy/migrations" "$R/home/other/.local/state/omarchy/migrations" "$R/home/alarm" "$R/etc/modprobe.d" + : >"$R/home/tester/.local/state/omarchy/migrations/1789132067.sh" + : >"$R/home/other/.local/state/omarchy/migrations/1790305681.sh" + printf 'options brcmfmac roamoff=1\n%s\n' "$broadcom_block" >"$R/etc/modprobe.d/brcmfmac.conf" + echo LANG=C >"$R/etc/locale.conf" + printf '#en_US.UTF-8 UTF-8\n#de_DE.UTF-8 UTF-8\n' >"$R/etc/locale.gen" + mkdir -p "$R/usr/share/omarchy/install/config" + # The target runtime's leaf (omacom/omarchy #13362 69d80cccd). + cp "$ROOT/test/fixtures/mac-migrate/runtime/install/config/locale.sh" "$R/usr/share/omarchy/install/config/locale.sh" + cat >"$R/usr/share/omarchy/install/config/snapper.sh" <<'LEAF' +# Stands in for the runtime's Snapper leaf: its exit status is the fixture's. +echo "snapper-leaf OMARCHY_PATH=$OMARCHY_PATH" >>"$MIGRATE_FIXTURE/boot.log" +exit "$(cat "$MIGRATE_FIXTURE/snapper-status" 2>/dev/null || echo 0)" +LEAF +} + +repaired_names="1789146110 1789148088 1789158179 1789172112 1790327324" + +repairs_fixture repairs +kill_after preflight +output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_MID=broadcom -- run 2>&1) && fail "the run is killed in the middle of the Broadcom repair" +[[ -f $R/var/lib/omarchy/migrations/1789172112-initramfs-pending ]] || fail "the rebuild is owed before the Broadcom block goes" +finish +[[ $(<"$R/etc/modprobe.d/brcmfmac.conf") == "options brcmfmac roamoff=1" ]] || fail "only the Broadcom block goes" "$(cat "$R/etc/modprobe.d/brcmfmac.conf")" +[[ ! -e $R/var/lib/omarchy/migrations/1789172112-initramfs-pending && $(grep -c '^omarchy-mac-boot-update' "$F/boot.log") == 1 ]] || + fail "the boot image is rebuilt once, across the interrupted repair" "$(cat "$F/boot.log")" +[[ $(grep '^wheel:' "$R/etc/group") == "wheel:x:998:tester" ]] || fail "alarm leaves wheel beside another administrator" "$(cat "$R/etc/group")" +[[ $(<"$R/etc/locale.conf") == "LANG=en_US.UTF-8" ]] && grep -qx 'en_US.UTF-8 UTF-8' "$R/etc/locale.gen" || fail "a C locale becomes en_US.UTF-8" "$(cat "$R/etc/locale.conf" "$R/etc/locale.gen")" +grep -qx "snapper-leaf OMARCHY_PATH=$R/usr/share/omarchy" "$F/boot.log" || fail "the Snapper leaf runs" "$(cat "$F/boot.log")" +grep -qx 'omarchy-mac-setup-keyboard 3' "$F/boot.log" || fail "mx-mac's history names the generated keyboard line" "$(grep keyboard "$F/boot.log")" +for user in tester other; do + for name in $repaired_names; do + [[ -f $R/home/$user/.local/state/omarchy/migrations/$name.sh ]] || fail "$user has the repaired migration $name recorded as done" + done + [[ -f $R/home/$user/.local/state/omarchy/migrations/1785424256.sh ]] || fail "$user has systemd-oomd's migration settled (off on Macs)" + [[ ! -e $R/home/$user/.local/state/omarchy/migrations/1790347292.sh ]] || fail "the retired platform migration is not recorded" +done +[[ ! -e $R/home/alarm/.local ]] || fail "an account Omarchy never ran for gets no records" +pass "the engine removes the Broadcom block, retires alarm from wheel, sets the locale, runs Snapper, hands over the keyboard, and settles those migrations" + +# --- User setup that fails stays pending ------------------------------------------ + +new_fixture user-pending +printf 'tester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +home=$R/home/tester +mkdir -p "$home/.local/state/omarchy" "$home/.config/systemd/user" +kill_after preflight +user_unit omarchy-crash-watch.service graphical-session.target +chmod 555 "$home/.config/systemd/user" +: >"$F/setup-user-fail" +output=$(migrate run 2>&1) || fail "user setup that fails does not stop the migration" "$output" +grep -q "Could not apply omarchy-crash-watch.service for tester" <<<"$output" && grep -q "Could not apply setup-user for tester" <<<"$output" || + fail "each failed item is reported" "$output" +[[ $(migrate status) == *"User setup pending"*"tester setup-user"* ]] || fail "status lists pending user setup" "$(migrate status)" +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "verify completes the migration with user setup pending" "$output" +[[ -f $(state_dir)/complete && -s $(state_dir)/user-pending ]] || fail "the migration completes while user setup stays pending" +[[ -f $R/etc/systemd/system/omarchy-mac-migrate-verify.service && -x $(state_dir)/tool/omarchy-mac-migrate ]] || + fail "the unit and the tool's copy stay while user setup is pending" +chmod 755 "$home/.config/systemd/user" +rm "$F/setup-user-fail" +output=$(migrate verify 2>&1) || fail "a boot retries pending user setup" "$output" +[[ ! -e $(state_dir)/user-pending && ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service && ! -e $(state_dir)/tool ]] || + fail "once nothing is pending the unit and the tool's copy go" +pass "a user's unit or setup that fails stays pending, runs again at each boot until it succeeds, then releases the unit" + +# --- A tester already on Aurora and Limine ------------------------------------ + +# A converged test image (the M1 and M2 today): Aurora, m1n1-aurora, Limine in +# the slot, candidate builds, an unencrypted root. +new_fixture limine +sed -i -e 's/^linux-asahi .*/linux-aurora 7.1.12.aurora2-9/' -e 's/^m1n1 .*/m1n1-aurora 1.6.1.aurora1-2/' "$R/var/lib/pacman/local/packages" +echo 7.1.12-aurora >"$R/proc/sys/kernel/osrelease" +rm "$R/boot/grub/grub.cfg" +: >"$R/var/lib/omarchy/limine.enabled" +printf 'KERNEL_CMDLINE[default]="root=UUID=x"\n' >"$R/etc/default/limine" +echo "limine 12.8" >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" +echo /dev/nvme0n1p5 >"$F/root-source" +printf '/dev/nvme0n1p5 part btrfs\n/dev/nvme0n1 disk \n' >"$F/lsblk" +finish +grep -q "^linux-aurora 7.1.12.aurora2-11$" "$R/var/lib/pacman/local/packages" || fail "the Aurora kernel moves to the target's build" +grep -q "^omarchy-mac-limine-cmdline" "$F/boot.log" && grep -q "^dispatch setup-boot" "$F/boot.log" && grep -q "^limine-boot activate" "$F/boot.log" || + fail "a Limine Mac rebuilds its menu and UKI, then the new setup-boot refreshes Limine" "$(cat "$F/boot.log")" +! grep -q "update-grub" "$F/boot.log" || fail "a Limine Mac does not touch GRUB" "$(cat "$F/boot.log")" +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" ]] || fail "the slot holds the packaged Limine" +[[ ! -e $(state_dir)/backup/luks-header.img ]] && ! grep -q cryptsetup "$F/pacman.log" || fail "an unencrypted root has no header to back up" +pass "a Limine tester on an unencrypted root keeps Limine, rebuilds its UKI and deploys the packaged loader" + +# --- The build ------------------------------------------------------------------ + +"$ROOT/migrate/build" --check || fail "bin/omarchy-mac-migrate is built from migrate/src" +pass "the committed tool is what migrate/src builds" + +# --- Fork leftovers ------------------------------------------------------------ + +leftover_copy() { # name: the bytes a fork wrote, from the tool itself + bash -c "source <(sed -n '/^leftover() {/,/^}/p' \"\$1\"); leftover \"\$2\"" _ "$ROOT/migrate/src/repairs.sh" "$1" +} +new_fixture leftovers +printf 'tester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +home=$R/home/tester +policies=$home/.config/wireplumber/wireplumber.conf.d +mkdir -p "$home/.local/state/omarchy" "$policies" "$R/etc/NetworkManager/conf.d" "$R/etc/modprobe.d" "$R/etc/systemd/system/suspend.target.wants" +leftover_copy wifi_backend.conf >"$R/etc/NetworkManager/conf.d/wifi_backend.conf" +printf 'options appledrm show_notch=0\n' >"$R/etc/modprobe.d/asahi-notch.conf" +leftover_copy omarchy-wifi-resume-fix.service >"$R/etc/systemd/system/omarchy-wifi-resume-fix.service" +ln -s /etc/systemd/system/omarchy-wifi-resume-fix.service "$R/etc/systemd/system/suspend.target.wants/omarchy-wifi-resume-fix.service" +leftover_copy asahi-headset-mic.conf >"$policies/asahi-headset-mic.conf" +leftover_copy asahi-audio-no-suspend-overlay.conf >"$policies/asahi-audio-no-suspend.conf" +finish +[[ ! -e $R/etc/NetworkManager/conf.d/wifi_backend.conf && -f $R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired ]] || + fail "a byte-identical Wi-Fi backend copy retires" +[[ $(<"$R/etc/modprobe.d/asahi-notch.conf") == "options appledrm show_notch=0" && ! -e $R/etc/modprobe.d/asahi-notch.conf.omarchy-mac-retired ]] || + fail "an edited copy stays" +[[ ! -e $R/etc/systemd/system/omarchy-wifi-resume-fix.service && + $(readlink "$R/etc/systemd/system/suspend.target.wants/omarchy-wifi-resume-fix.service") == /usr/lib/systemd/system/omarchy-wifi-resume-fix.service ]] || + fail "the fork's resume unit retires and its enablement points at the vendor unit" +[[ ! -e $policies/asahi-headset-mic.conf && -f $policies/asahi-headset-mic.conf.omarchy-mac-retired && + ! -e $policies/asahi-audio-no-suspend.conf && -f $policies/asahi-audio-no-suspend.conf.omarchy-mac-retired ]] || + fail "the user's copied WirePlumber policies retire, either revision of mx-mac's speaker policy" "$(ls -la "$policies")" +pass "the fork's byte-identical leftovers retire, keeping a backup; edited copies stay" +new_fixture leftovers-backup +mkdir -p "$R/etc/NetworkManager/conf.d" +leftover_copy wifi_backend.conf >"$R/etc/NetworkManager/conf.d/wifi_backend.conf" +printf 'administrator backup\n' >"$R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "wifi_backend.conf.omarchy-mac-retired differs" <<<"$output" || fail "a different backup stops the step and says why" "$output" +[[ $(<"$R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired") == "administrator backup" ]] || fail "the administrator's backup is kept" +rm "$R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired" +finish +pass "a backup that differs is never overwritten: the step stops until it is moved" From fe6b2e7cf6a5e8a617075207d5d3bdd93e40f864 Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Sun, 4 Oct 2026 13:04:16 +1000 Subject: [PATCH 2/7] Move quattro Macs onto Omarchy's official packages on their next update A migration marks the Mac; the next omarchy update runs omarchy-mac-migrate before any fork step and stops there for the reboot, or updates the Mac as before while its channel has no Mac release. The README gives testers the one-line command. --- README.md | 14 +++++ bin/omarchy-update | 21 +++++++ migrations/1791080196.sh | 14 +++++ test/shell.d/mac-move-migration-test.sh | 81 +++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 migrations/1791080196.sh create mode 100644 test/shell.d/mac-move-migration-test.sh diff --git a/README.md b/README.md index 3960e3846e4..e133bd3e074 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,20 @@ in one command, full-disk encryption included. Already running Omarchy 3.x? This page is the fresh install — to upgrade in place, see [docs/upgrade-to-quattro.md](docs/upgrade-to-quattro.md). +## Moving to Omarchy's official packages + +Omarchy's own packages now support Apple Silicon, so this fork's quattro line ends. Macs move onto the official packages of the channel they follow (on edge: `omarchy-dev`, `omarchy-mac`, `omarchy-mac-boot` and the Aurora kernel) with `omarchy-mac-migrate`, which keeps the encryption, snapshots and data, changes nothing until a check passes, and resumes if it is cut short. + +- **Macs installed from this repository:** nothing to do. One update marks the Mac; the next `omarchy update` moves it and asks for a reboot. A channel without a Mac release yet (stable and rc today) keeps updating as before until it has one. +- **Test images and other Macs:** run this on the Mac, then reboot when it says so: + + +```bash +d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "a387acd49605155b3e8e39a561affc3e4afc493096c3a4c3c9f513c976a15631 $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run +``` + +`sudo omarchy-mac-migrate check` says what a run would do without changing anything, and `sudo omarchy-mac-migrate status` where a run stands. How it works: [migrate/README.md](migrate/README.md). + --- ## Before you begin diff --git a/bin/omarchy-update b/bin/omarchy-update index 14b044656cb..225e97c3a02 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -38,6 +38,27 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start + # omarchy-mac's last quattro release: once migration 1791080196 marked this + # Mac, the update moves it onto Omarchy's official packages before any fork + # step, and stops there; from the next boot on, omarchy update is Omarchy's + # own. A move that cannot start yet (75) leaves the Mac on the fork and updates + # it as before; one that failed part way stops the update, and the next update + # or boot resumes it. + if [[ -e ${OMARCHY_MAC_MOVE_MARKER:-/var/lib/omarchy/migrations/1791080196} ]]; then + move_status=0 + sudo "$OMARCHY_PATH/bin/omarchy-mac-migrate" run || move_status=$? + if (( move_status == 0 )); then + echo -e "\e[32m\nThis Mac now runs Omarchy's official packages. Reboot to finish the move.\e[0m" + omarchy-update-stay-awake stop + trap - EXIT + exit 0 + elif (( move_status == 75 )); then + echo "The move onto Omarchy's official packages cannot start yet; updating this Mac as before." >&2 + else + (exit "$move_status") + fi + fi + if [[ -z ${OMARCHY_UPDATE_CHANNEL:-} ]]; then omarchy-update-dev omarchy-update-keyring diff --git a/migrations/1791080196.sh b/migrations/1791080196.sh new file mode 100644 index 00000000000..e460cfed66d --- /dev/null +++ b/migrations/1791080196.sh @@ -0,0 +1,14 @@ +echo "Move this Mac onto Omarchy's official packages on the next update" + +# omarchy-mac's quattro line ends here: Omarchy's own packages now carry the +# Mac (omarchy-mac and omarchy-mac-boot, from omacom/omarchy-mac-pkgs). This +# marks the Mac, machine-wide; the next omarchy update moves it with +# omarchy-mac-migrate before any fork step, and stops there for the reboot. +# Until the Mac's channel has a Mac release, those updates leave it as it is and +# update it as before. Once a run marked it, other accounts skip this. +marker="${OMARCHY_MAC_MOVE_MARKER:-/var/lib/omarchy/migrations/1791080196}" +[[ ! -e $marker ]] || exit 0 +omarchy-hw-apple || exit 0 + +sudo install -Dm644 /dev/null "$marker" +echo "The next omarchy update moves this Mac onto Omarchy's official packages, once its channel has a Mac release." diff --git a/test/shell.d/mac-move-migration-test.sh b/test/shell.d/mac-move-migration-test.sh new file mode 100644 index 00000000000..8d97c67aec0 --- /dev/null +++ b/test/shell.d/mac-move-migration-test.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Migration 1791080196 marks an Apple Silicon Mac for the move onto Omarchy's +# official packages, once and machine-wide; omarchy update then runs +# omarchy-mac-migrate before any fork step and stops once it moved the Mac. +migration=$ROOT/migrations/1791080196.sh +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" +printf '#!/bin/bash\n[[ $(cat "$FIXTURE/platform") == apple ]]\n' >"$tmp/bin/omarchy-hw-apple" +cat >"$tmp/bin/sudo" <<'SH' +#!/bin/bash +echo "sudo $*" >>"$FIXTURE/ran" +exec "$@" +SH +chmod 755 "$tmp/bin"/* +marker=$tmp/state/1791080196 + +run_migration() { + rm -f "$tmp/ran" + FIXTURE=$tmp PATH="$tmp/bin:$PATH" OMARCHY_PATH=$ROOT OMARCHY_MAC_MOVE_MARKER=$marker bash -euo pipefail "$migration" +} + +[[ $(stat -c %a "$migration") == 644 ]] || fail "the migration is mode 644" +head -n 1 "$migration" | grep -q '^echo ' || fail "the migration starts with an echo" + +echo generic >"$tmp/platform" +run_migration >/dev/null || fail "another platform: the migration completes" +[[ ! -e $tmp/ran && ! -e $marker ]] || fail "another platform: nothing runs" +pass "anything but an Apple Silicon Mac completes the migration and marks nothing" + +echo apple >"$tmp/platform" +run_migration >/dev/null || fail "a Mac: the migration completes" +[[ -e $marker ]] && grep -q "^sudo install -Dm644 /dev/null $marker$" "$tmp/ran" || fail "a Mac is marked as root" "$(cat "$tmp/ran" 2>/dev/null)" +run_migration >/dev/null && [[ ! -e $tmp/ran ]] || fail "another account: nothing runs once the Mac is marked" +pass "a Mac is marked for the move once, machine-wide" + +# The move comes first in omarchy update, before any fork update, and the +# update stops once it has run. +update=$ROOT/bin/omarchy-update +move=$(grep -n 'sudo "$OMARCHY_PATH/bin/omarchy-mac-migrate" run || move_status' "$update" | cut -d: -f1) +dev=$(grep -n '^ omarchy-update-dev$' "$update" | cut -d: -f1) +system=$(grep -n '^ omarchy-update-system-pkgs$' "$update" | cut -d: -f1) +[[ -n $move && -n $dev && -n $system ]] && (( move < dev && move < system )) || fail "omarchy update moves a marked Mac before any fork update" "move $move dev $dev system $system" +grep -q '/var/lib/omarchy/migrations/1791080196' "$update" || fail "omarchy update moves only a marked Mac" +awk -v from="$move" 'NR > from && /move_status == 0/ { found = 1 } found && /exit 0/ { ok = 1; exit } END { exit !ok }' "$update" || + fail "a moved Mac's update stops there" +awk -v from="$move" 'NR > from && /move_status == 75/ { found = 1 } found && /updating this Mac as before/ { ok = 1; exit } END { exit !ok }' "$update" || + fail "a deferred move lets the fork update go on" +pass "omarchy update moves a marked Mac before any fork update, stops once it moved, and goes on when the move defers" + +# The update hook's three outcomes, run for real against a stand-in tool. +for outcome in 0 75 1; do + work=$tmp/update-$outcome + mkdir -p "$work/bin" "$work/omarchy/bin" + printf '#!/bin/bash\necho "migrate $*" >>"%s/ran"\nexit %s\n' "$work" "$outcome" >"$work/omarchy/bin/omarchy-mac-migrate" + for command in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot \ + omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook \ + omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart; do + printf '#!/bin/bash\n[[ $1 == held ]] && exit 0\necho "%s $*" >>"%s/ran"\n' "$command" "$work" >"$work/bin/$command" + done + printf '#!/bin/bash\nexec "$@"\n' >"$work/bin/sudo" + chmod 755 "$work/bin"/* "$work/omarchy/bin"/* + : >"$work/marker" + status=0 + OMARCHY_UPDATE_LOGGED=1 OMARCHY_MAC_MOVE_MARKER=$work/marker OMARCHY_PATH=$work/omarchy PATH="$work/bin:$PATH" \ + bash "$update" -y >"$work/out" 2>&1 || status=$? + case $outcome in + 0) (( status == 0 )) && ! grep -q '^omarchy-update-dev\|^omarchy-update-system-pkgs' "$work/ran" && grep -q "now runs Omarchy's official packages" "$work/out" || + fail "a moved Mac's update stops before any fork step" "$(cat "$work/ran" "$work/out")" ;; + 75) (( status == 0 )) && grep -q '^omarchy-update-system-pkgs' "$work/ran" && grep -q '^omarchy-migrate' "$work/ran" || + fail "a deferred move updates the Mac as before" "$(cat "$work/ran" "$work/out")" ;; + 1) (( status != 0 )) && ! grep -q '^omarchy-update-system-pkgs' "$work/ran" || + fail "a failed move stops the update" "$(cat "$work/ran" "$work/out")" ;; + esac +done +pass "omarchy update stops after a move, goes on after a deferral and stops on a failure" From 10dc3a0a891757eddc79222b95f486350a9d66f6 Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Sun, 4 Oct 2026 13:30:52 +1000 Subject: [PATCH 3/7] Keep a deferral a deferral until the repository switch writes The boundary is recorded right before the switch's first write, so a reset or a refusal before it still defers. A second run never touches the state of the run holding the lock. Preflight trusts no retired key, refuses NoExtract or NoUpgrade patterns and IgnorePkg globs that hold back what the move installs, and checks a busybox-encrypted Mac keeps its unlock under the new drop-ins. Fork leftovers the Mac packages no longer retire are retired here. The update hook stops the fork update only after a move. --- README.md | 2 +- bin/omarchy-mac-migrate | 40 +++++++++++++++++++++++-- bin/omarchy-update | 9 +++--- migrate/src/engine.sh | 22 ++++++++++++-- migrate/src/payload.sh | 18 +++++++++++ test/shell.d/mac-migrate-legacy-test.sh | 4 +-- test/shell.d/mac-migrate-mx-test.sh | 2 +- test/shell.d/mac-migrate-test.sh | 18 +++++++++-- test/shell.d/mac-move-migration-test.sh | 16 +++++++--- 9 files changed, 113 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e133bd3e074..1fb6d3fd3b4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Omarchy's own packages now support Apple Silicon, so this fork's quattro line en ```bash -d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "a387acd49605155b3e8e39a561affc3e4afc493096c3a4c3c9f513c976a15631 $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run +d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "879faa6520e0e6d6dbdd7f9b7d086e34ab5f5822ab171c10db21a338042d72cc $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run ``` `sudo omarchy-mac-migrate check` says what a run would do without changing anything, and `sudo omarchy-mac-migrate status` where a run stands. How it works: [migrate/README.md](migrate/README.md). diff --git a/bin/omarchy-mac-migrate b/bin/omarchy-mac-migrate index 99ac7dbbebc..00c7f191503 100755 --- a/bin/omarchy-mac-migrate +++ b/bin/omarchy-mac-migrate @@ -148,9 +148,10 @@ die() { } # The repository switch is the first change that cannot be left in place: from -# its start on, the migration only goes forward. +# the boundary its step records just before it writes, the migration only +# goes forward. before_boundary() { - [[ -f $journal ]] && ! awk '$2 == "repositories" { found = 1 } END { exit !found }' "$journal" + [[ -f $journal ]] && ! awk '$3 == "boundary" { found = 1 } END { exit !found }' "$journal" } abort_migration() { @@ -159,6 +160,11 @@ abort_migration() { install -d -m 700 "$destination" mv "$journal" "$plan" "$state/format" "$destination/" 2>/dev/null rm -rf "$cache" "$backup" "$set_copy" "$start" "$expected" "$state/installed.now" "$state/tool" + if [[ -f $verify_unit_file ]]; then + systemctl disable "$verify_unit" >/dev/null 2>&1 || true + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$1" | durable_write "$state/deferred" 2>/dev/null || true say "Nothing on this Mac changed; the next run starts the migration over." >&2 } @@ -748,6 +754,15 @@ preflight() { elif [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( ! staged )); then reasons+=("the root unlocks through busybox encrypt, which only the legacy omarchy-mac migration moves") fi + # Until the loader step moves the unlock, the image GRUB boots is built from + # the new packages' HOOKS drop-ins: they must keep busybox encrypt. + if [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( staged )); then + if ! problem=$(future_hooks "$resolved" "$transaction" "$work/db"); then + reasons+=("cannot tell the HOOKS the new packages give: $problem") + elif [[ " $problem " != *" encrypt "* ]]; then + reasons+=("the new packages' HOOKS drop-ins would drop busybox encrypt before the boot switch moves the unlock: $problem") + fi + fi # Installed boot files, not the running kernel: an update that just replaced # the kernel leaves a reboot pending, and the migration replaces it anyway. if ! check_output=$(boot_check_pending 2>&1); then @@ -781,6 +796,7 @@ preflight() { "${cohort//-/_}_plan" "$installed" "$work" "$luks" "$hooks" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" fi + gpgconf --homedir "$gpgdir" --kill all >/dev/null 2>&1 || true gpgdir="" if already_on_target "$installed" "$resolved" "$targets_file" "$future"; then say "This Mac already runs the target set ($target_id): nothing to migrate." @@ -1136,6 +1152,8 @@ step_repositories() { # resumes whatever is left. keep_tool && write_verify_unit && systemctl enable "$verify_unit" >/dev/null 2>&1 || die "cannot install $verify_unit, which resumes the migration at boot" + # The boundary: recorded before the first change that is not set aside. + before_boundary && journal_write repositories "boundary" if ! cmp -s "$plan/pacman.guarded.conf" "$pacman_conf"; then durable_write "$pacman_conf" 644 <"$plan/pacman.guarded.conf" || die "cannot write $pacman_conf" fi @@ -2114,6 +2132,24 @@ fetch_payload() { printf '%s\n' "$version" } +# The HOOKS the Mac's mkinitcpio configuration gives once the transaction has +# put the new settings and boot packages' drop-ins in place. +future_hooks() { + local resolved=$1 conf=$2 db=$3 dir=$work/future-conf.d name archive pair + rm -rf "$dir" + install -d -m 700 "$dir" + [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } + pair=$(channel_pair "$target_channel") + for name in "${pair#* }" omarchy-mac-boot; do + archive=$(fetch_archive "$resolved" "$name" "$conf" "$db") || { echo "$archive"; return 1; } + install -d -m 700 "$work/future-root-$name" + # A package without drop-ins extracts nothing. + bsdtar -xpf "$archive" -C "$work/future-root-$name" --include 'etc/mkinitcpio.conf.d/*' 2>/dev/null || true + [[ ! -d $work/future-root-$name/etc/mkinitcpio.conf.d ]] || cp -a "$work/future-root-$name/etc/mkinitcpio.conf.d/." "$dir/" + done + OMARCHY_MKINITCPIO_CONF_DIR=$dir omarchy-mac-initramfs-hooks 2>/dev/null || { echo "the HOOKS composer failed"; return 1; } +} + # --- repairs.sh ------------------------------------------------------------ # Official migrations a migrated Mac records as done, and the repairs a fresh diff --git a/bin/omarchy-update b/bin/omarchy-update index 225e97c3a02..d764971576a 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -42,19 +42,20 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # Mac, the update moves it onto Omarchy's official packages before any fork # step, and stops there; from the next boot on, omarchy update is Omarchy's # own. A move that cannot start yet (75) leaves the Mac on the fork and updates - # it as before; one that failed part way stops the update, and the next update - # or boot resumes it. + # it as before, and so does a run that found nothing to move; one that failed + # part way stops the update, and the next update or boot resumes it. if [[ -e ${OMARCHY_MAC_MOVE_MARKER:-/var/lib/omarchy/migrations/1791080196} ]]; then move_status=0 sudo "$OMARCHY_PATH/bin/omarchy-mac-migrate" run || move_status=$? - if (( move_status == 0 )); then + move_state=${OMARCHY_MAC_MOVE_STATE:-/var/lib/omarchy-mac/migration} + if (( move_status == 0 )) && [[ -e $move_state/reboot-pending || -e $move_state/complete ]]; then echo -e "\e[32m\nThis Mac now runs Omarchy's official packages. Reboot to finish the move.\e[0m" omarchy-update-stay-awake stop trap - EXIT exit 0 elif (( move_status == 75 )); then echo "The move onto Omarchy's official packages cannot start yet; updating this Mac as before." >&2 - else + elif (( move_status != 0 )); then (exit "$move_status") fi fi diff --git a/migrate/src/engine.sh b/migrate/src/engine.sh index 6d3562070d6..3fc17c130b3 100644 --- a/migrate/src/engine.sh +++ b/migrate/src/engine.sh @@ -90,9 +90,10 @@ die() { } # The repository switch is the first change that cannot be left in place: from -# its start on, the migration only goes forward. +# the boundary its step records just before it writes, the migration only +# goes forward. before_boundary() { - [[ -f $journal ]] && ! awk '$2 == "repositories" { found = 1 } END { exit !found }' "$journal" + [[ -f $journal ]] && ! awk '$3 == "boundary" { found = 1 } END { exit !found }' "$journal" } abort_migration() { @@ -101,6 +102,11 @@ abort_migration() { install -d -m 700 "$destination" mv "$journal" "$plan" "$state/format" "$destination/" 2>/dev/null rm -rf "$cache" "$backup" "$set_copy" "$start" "$expected" "$state/installed.now" "$state/tool" + if [[ -f $verify_unit_file ]]; then + systemctl disable "$verify_unit" >/dev/null 2>&1 || true + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$1" | durable_write "$state/deferred" 2>/dev/null || true say "Nothing on this Mac changed; the next run starts the migration over." >&2 } @@ -690,6 +696,15 @@ preflight() { elif [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( ! staged )); then reasons+=("the root unlocks through busybox encrypt, which only the legacy omarchy-mac migration moves") fi + # Until the loader step moves the unlock, the image GRUB boots is built from + # the new packages' HOOKS drop-ins: they must keep busybox encrypt. + if [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( staged )); then + if ! problem=$(future_hooks "$resolved" "$transaction" "$work/db"); then + reasons+=("cannot tell the HOOKS the new packages give: $problem") + elif [[ " $problem " != *" encrypt "* ]]; then + reasons+=("the new packages' HOOKS drop-ins would drop busybox encrypt before the boot switch moves the unlock: $problem") + fi + fi # Installed boot files, not the running kernel: an update that just replaced # the kernel leaves a reboot pending, and the migration replaces it anyway. if ! check_output=$(boot_check_pending 2>&1); then @@ -723,6 +738,7 @@ preflight() { "${cohort//-/_}_plan" "$installed" "$work" "$luks" "$hooks" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" fi + gpgconf --homedir "$gpgdir" --kill all >/dev/null 2>&1 || true gpgdir="" if already_on_target "$installed" "$resolved" "$targets_file" "$future"; then say "This Mac already runs the target set ($target_id): nothing to migrate." @@ -1078,6 +1094,8 @@ step_repositories() { # resumes whatever is left. keep_tool && write_verify_unit && systemctl enable "$verify_unit" >/dev/null 2>&1 || die "cannot install $verify_unit, which resumes the migration at boot" + # The boundary: recorded before the first change that is not set aside. + before_boundary && journal_write repositories "boundary" if ! cmp -s "$plan/pacman.guarded.conf" "$pacman_conf"; then durable_write "$pacman_conf" 644 <"$plan/pacman.guarded.conf" || die "cannot write $pacman_conf" fi diff --git a/migrate/src/payload.sh b/migrate/src/payload.sh index 22b6cf0e99f..2d2fee2a15b 100644 --- a/migrate/src/payload.sh +++ b/migrate/src/payload.sh @@ -54,3 +54,21 @@ fetch_payload() { { echo "the unpacked omarchy-mac-boot is writable by others"; return 1; } printf '%s\n' "$version" } + +# The HOOKS the Mac's mkinitcpio configuration gives once the transaction has +# put the new settings and boot packages' drop-ins in place. +future_hooks() { + local resolved=$1 conf=$2 db=$3 dir=$work/future-conf.d name archive pair + rm -rf "$dir" + install -d -m 700 "$dir" + [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } + pair=$(channel_pair "$target_channel") + for name in "${pair#* }" omarchy-mac-boot; do + archive=$(fetch_archive "$resolved" "$name" "$conf" "$db") || { echo "$archive"; return 1; } + install -d -m 700 "$work/future-root-$name" + # A package without drop-ins extracts nothing. + bsdtar -xpf "$archive" -C "$work/future-root-$name" --include 'etc/mkinitcpio.conf.d/*' 2>/dev/null || true + [[ ! -d $work/future-root-$name/etc/mkinitcpio.conf.d ]] || cp -a "$work/future-root-$name/etc/mkinitcpio.conf.d/." "$dir/" + done + OMARCHY_MKINITCPIO_CONF_DIR=$dir omarchy-mac-initramfs-hooks 2>/dev/null || { echo "the HOOKS composer failed"; return 1; } +} diff --git a/test/shell.d/mac-migrate-legacy-test.sh b/test/shell.d/mac-migrate-legacy-test.sh index 8c4861e6b51..97d445155e2 100644 --- a/test/shell.d/mac-migrate-legacy-test.sh +++ b/test/shell.d/mac-migrate-legacy-test.sh @@ -407,7 +407,7 @@ interrupt() { # when point step if [[ $when == "after" ]]; then [[ $last == "$step done"* ]] || fail "killed $when $point, the journal ends with $step done" "$last" else - [[ $last == "$step begin"* ]] || fail "killed $when $point, the journal ends with $step begun" "$last" + [[ $last == "$step begin"* || $last == "repositories boundary" ]] || fail "killed $when $point, the journal ends with $step begun" "$last" fi finish # pacman's own half-extracted files are backed up too, beside the originals. @@ -775,7 +775,7 @@ switch_interrupt() { # when point step if [[ $when == "after" ]]; then [[ $last == "$step done"* ]] || fail "killed $when $point, the journal ends with $step done" "$last" else - [[ $last == "$step begin"* ]] || fail "killed $when $point, the journal ends with $step begun" "$last" + [[ $last == "$step begin"* || $last == "repositories boundary" ]] || fail "killed $when $point, the journal ends with $step begun" "$last" fi if [[ ! -e $R/var/lib/omarchy/limine.enabled || $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "grub" ]]; then grub_boots_esp || fail "killed $when $point before Limine took the slot, GRUB's chain still unlocks the root" "$(cd "$F/esp" && find . -type f)" diff --git a/test/shell.d/mac-migrate-mx-test.sh b/test/shell.d/mac-migrate-mx-test.sh index 18204249bf8..589cc66f1f5 100644 --- a/test/shell.d/mac-migrate-mx-test.sh +++ b/test/shell.d/mac-migrate-mx-test.sh @@ -355,7 +355,7 @@ interrupt() { # when step if [[ $when == "after" ]]; then [[ $last == "$recorded done"* ]] || fail "the journal ends with $step done" "$last" else - [[ $last == "$recorded begin"* ]] || fail "the journal ends with $step begun" "$last" + [[ $last == "$recorded begin"* || $last == "repositories boundary" ]] || fail "the journal ends with $step begun" "$last" fi finish [[ $(outcome) == "$baseline" ]] || fail "killed $when $step, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" diff --git a/test/shell.d/mac-migrate-test.sh b/test/shell.d/mac-migrate-test.sh index 6a932aaa463..905d684e47c 100644 --- a/test/shell.d/mac-migrate-test.sh +++ b/test/shell.d/mac-migrate-test.sh @@ -266,7 +266,7 @@ interrupt() { # when step if [[ $when == "after" ]]; then [[ $last == "${step%-leaf} done"* ]] || fail "the journal ends with $step done" "$last" else - [[ $last == "${step%-leaf} begin"* ]] || fail "the journal ends with $step begun" "$last" + [[ $last == "${step%-leaf} begin"* || $last == "repositories boundary" ]] || fail "the journal ends with $step begun" "$last" fi finish [[ $(outcome) == "$baseline" ]] || fail "killed $when $step, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" @@ -703,13 +703,27 @@ pass "after preflight only the verified copy of the set is used, and the origina new_fixture enable : >"$F/systemctl-fail" +conf_before=$(cat "$R/etc/pacman.conf") status=0 output=$(migrate run 2>&1) || status=$? -(( status == 1 )) && grep -q "cannot install omarchy-mac-migrate-verify.service" <<<"$output" || fail "a unit that cannot be enabled fails the run" "$output" +(( status == 75 )) && grep -q "cannot install omarchy-mac-migrate-verify.service" <<<"$output" && [[ $(cat "$R/etc/pacman.conf") == "$conf_before" ]] || + fail "a unit that cannot be enabled stops the run before the switch, deferred" "status $status: $output" +[[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] || fail "the deferred attempt takes its unit with it" rm "$F/systemctl-fail" finish pass "the unit that resumes the migration must be enabled before the switch goes ahead" +# A reset back to prefetch before the switch is still before it. +new_fixture reset-before-switch +kill_after prefetch +sed -i 's/^hyprland .*/hyprland 0.52-1/' "$R/var/lib/pacman/local/packages" +echo "omarchy-mac-boot widget-extra" >>"$F/conflicts" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove widget-extra" <<<"$output" && [[ ! -e $(state_dir)/journal ]] || + fail "a refusal after a reset before the switch defers" "status $status: $output" +pass "the boundary is the switch's first write, not its step's start: a reset and a refusal before it still defer" + # --- A fresh install's defaults ---------------------------------------------------- user_unit() { # name target diff --git a/test/shell.d/mac-move-migration-test.sh b/test/shell.d/mac-move-migration-test.sh index 8d97c67aec0..5e048bb3d08 100644 --- a/test/shell.d/mac-move-migration-test.sh +++ b/test/shell.d/mac-move-migration-test.sh @@ -54,10 +54,12 @@ awk -v from="$move" 'NR > from && /move_status == 75/ { found = 1 } found && /up pass "omarchy update moves a marked Mac before any fork update, stops once it moved, and goes on when the move defers" # The update hook's three outcomes, run for real against a stand-in tool. -for outcome in 0 75 1; do +for outcome in 0 75 1 nothing; do work=$tmp/update-$outcome mkdir -p "$work/bin" "$work/omarchy/bin" - printf '#!/bin/bash\necho "migrate $*" >>"%s/ran"\nexit %s\n' "$work" "$outcome" >"$work/omarchy/bin/omarchy-mac-migrate" + code=$outcome + [[ $outcome != nothing ]] || code=0 + printf '#!/bin/bash\necho "migrate $*" >>"%s/ran"\nexit %s\n' "$work" "$code" >"$work/omarchy/bin/omarchy-mac-migrate" for command in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot \ omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook \ omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart; do @@ -67,9 +69,15 @@ for outcome in 0 75 1; do chmod 755 "$work/bin"/* "$work/omarchy/bin"/* : >"$work/marker" status=0 - OMARCHY_UPDATE_LOGGED=1 OMARCHY_MAC_MOVE_MARKER=$work/marker OMARCHY_PATH=$work/omarchy PATH="$work/bin:$PATH" \ + mkdir -p "$work/state" + if [[ $outcome == 0 ]]; then + : >"$work/state/reboot-pending" + fi + OMARCHY_UPDATE_LOGGED=1 OMARCHY_MAC_MOVE_MARKER=$work/marker OMARCHY_MAC_MOVE_STATE=$work/state OMARCHY_PATH=$work/omarchy PATH="$work/bin:$PATH" \ bash "$update" -y >"$work/out" 2>&1 || status=$? case $outcome in + nothing) (( status == 0 )) && grep -q '^omarchy-update-system-pkgs' "$work/ran" || + fail "a run that moved nothing lets the fork update go on" "$(cat "$work/ran" "$work/out")" ;; 0) (( status == 0 )) && ! grep -q '^omarchy-update-dev\|^omarchy-update-system-pkgs' "$work/ran" && grep -q "now runs Omarchy's official packages" "$work/out" || fail "a moved Mac's update stops before any fork step" "$(cat "$work/ran" "$work/out")" ;; 75) (( status == 0 )) && grep -q '^omarchy-update-system-pkgs' "$work/ran" && grep -q '^omarchy-migrate' "$work/ran" || @@ -78,4 +86,4 @@ for outcome in 0 75 1; do fail "a failed move stops the update" "$(cat "$work/ran" "$work/out")" ;; esac done -pass "omarchy update stops after a move, goes on after a deferral and stops on a failure" +pass "omarchy update stops after a move, goes on after a deferral or a run that moved nothing, and stops on a failure" From 75f00e2ce7a87d1114714e5ffa4d4839544c5511 Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Sun, 4 Oct 2026 14:09:39 +1000 Subject: [PATCH 4/7] Hold the boot tools preflight runs, and the resume unit, to the migration Preflight runs only regular files from the verified omarchy-mac-boot, never a link, and refuses a boot package without them. Its HOOKS preview drops the drop-ins of the packages the transaction replaces. A user setup that succeeds on a retry no longer releases the unit while the migration is still under way, and a migration past its switch resumes whatever a detector says. --- README.md | 2 +- bin/omarchy-mac-migrate | 29 ++++++++++++++++++++++++----- migrate/src/engine.sh | 11 +++++++---- migrate/src/payload.sh | 15 +++++++++++++++ migrate/src/users.sh | 3 ++- test/fixtures/mac-migrate/lib.sh | 4 +++- test/shell.d/mac-migrate-test.sh | 29 +++++++++++++++++++++++++++++ 7 files changed, 81 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1fb6d3fd3b4..733249fc768 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Omarchy's own packages now support Apple Silicon, so this fork's quattro line en ```bash -d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "879faa6520e0e6d6dbdd7f9b7d086e34ab5f5822ab171c10db21a338042d72cc $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run +d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "1f7904534956071e8ed01a627e4b0473d927ecfa193a0afb2cd98371767f8426 $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run ``` `sudo omarchy-mac-migrate check` says what a run would do without changing anything, and `sudo omarchy-mac-migrate status` where a run stands. How it works: [migrate/README.md](migrate/README.md). diff --git a/bin/omarchy-mac-migrate b/bin/omarchy-mac-migrate index 00c7f191503..4e6584944a3 100755 --- a/bin/omarchy-mac-migrate +++ b/bin/omarchy-mac-migrate @@ -1550,10 +1550,13 @@ migrate_run() { esac done - platform=$(hardware_platform) || die "cannot determine the hardware platform" - if [[ $platform != "apple-silicon" ]]; then - say "Not an Apple Silicon Mac: nothing to migrate." - return 0 + # A migration past its switch is this Mac's, whatever a detector says now. + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + if [[ $platform != "apple-silicon" ]]; then + say "Not an Apple Silicon Mac: nothing to migrate." + return 0 + fi fi take_lock if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then @@ -2129,9 +2132,18 @@ fetch_payload() { { echo "the archive is not omarchy-mac-boot $version"; return 1; } [[ -z $(find "$dir" ! -type l \( ! -uid "$EUID" -o -perm /022 \) -print -quit) ]] || { echo "the unpacked omarchy-mac-boot is writable by others"; return 1; } + # Its commands, and nothing a link could point elsewhere, run first. + [[ -z $(find "$dir/usr/bin" -type l -print -quit 2>/dev/null) ]] || { echo "omarchy-mac-boot's commands include a link"; return 1; } + for name in $payload_commands; do + [[ -f $dir/usr/bin/$name && ! -L $dir/usr/bin/$name && -x $dir/usr/bin/$name ]] || + { echo "omarchy-mac-boot $version has no $name"; return 1; } + done printf '%s\n' "$version" } +# What preflight runs from the target's omarchy-mac-boot. +payload_commands="omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-esp omarchy-mac-kernel" + # The HOOKS the Mac's mkinitcpio configuration gives once the transaction has # put the new settings and boot packages' drop-ins in place. future_hooks() { @@ -2139,6 +2151,12 @@ future_hooks() { rm -rf "$dir" install -d -m 700 "$dir" [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } + # The drop-ins of the packages the transaction replaces go with them. + for name in omarchy omarchy-settings omarchy-dev omarchy-settings-dev omarchy-mac-boot; do + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null + done | sed -n 's|^/etc/mkinitcpio.conf.d/\([^/][^/]*\)$|\1|p' | while IFS= read -r name; do + rm -f -- "$dir/$name" + done pair=$(channel_pair "$target_channel") for name in "${pair#* }" omarchy-mac-boot; do archive=$(fetch_archive "$resolved" "$name" "$conf" "$db") || { echo "$archive"; return 1; } @@ -2646,7 +2664,8 @@ retry_user_pending() { # waiting for its reboot. retry_user_pending_now() { [[ -s $user_pending ]] || return 0 - if retry_user_pending && [[ ! -e $reboot_pending ]]; then + # A migration still under way keeps the unit that resumes it. + if retry_user_pending && [[ ! -e $reboot_pending ]] && ! past_boundary; then release_verify_unit fi } diff --git a/migrate/src/engine.sh b/migrate/src/engine.sh index 3fc17c130b3..6dca1d16cf7 100644 --- a/migrate/src/engine.sh +++ b/migrate/src/engine.sh @@ -1492,10 +1492,13 @@ migrate_run() { esac done - platform=$(hardware_platform) || die "cannot determine the hardware platform" - if [[ $platform != "apple-silicon" ]]; then - say "Not an Apple Silicon Mac: nothing to migrate." - return 0 + # A migration past its switch is this Mac's, whatever a detector says now. + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + if [[ $platform != "apple-silicon" ]]; then + say "Not an Apple Silicon Mac: nothing to migrate." + return 0 + fi fi take_lock if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then diff --git a/migrate/src/payload.sh b/migrate/src/payload.sh index 2d2fee2a15b..667dcb83fe6 100644 --- a/migrate/src/payload.sh +++ b/migrate/src/payload.sh @@ -52,9 +52,18 @@ fetch_payload() { { echo "the archive is not omarchy-mac-boot $version"; return 1; } [[ -z $(find "$dir" ! -type l \( ! -uid "$EUID" -o -perm /022 \) -print -quit) ]] || { echo "the unpacked omarchy-mac-boot is writable by others"; return 1; } + # Its commands, and nothing a link could point elsewhere, run first. + [[ -z $(find "$dir/usr/bin" -type l -print -quit 2>/dev/null) ]] || { echo "omarchy-mac-boot's commands include a link"; return 1; } + for name in $payload_commands; do + [[ -f $dir/usr/bin/$name && ! -L $dir/usr/bin/$name && -x $dir/usr/bin/$name ]] || + { echo "omarchy-mac-boot $version has no $name"; return 1; } + done printf '%s\n' "$version" } +# What preflight runs from the target's omarchy-mac-boot. +payload_commands="omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-esp omarchy-mac-kernel" + # The HOOKS the Mac's mkinitcpio configuration gives once the transaction has # put the new settings and boot packages' drop-ins in place. future_hooks() { @@ -62,6 +71,12 @@ future_hooks() { rm -rf "$dir" install -d -m 700 "$dir" [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } + # The drop-ins of the packages the transaction replaces go with them. + for name in omarchy omarchy-settings omarchy-dev omarchy-settings-dev omarchy-mac-boot; do + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null + done | sed -n 's|^/etc/mkinitcpio.conf.d/\([^/][^/]*\)$|\1|p' | while IFS= read -r name; do + rm -f -- "$dir/$name" + done pair=$(channel_pair "$target_channel") for name in "${pair#* }" omarchy-mac-boot; do archive=$(fetch_archive "$resolved" "$name" "$conf" "$db") || { echo "$archive"; return 1; } diff --git a/migrate/src/users.sh b/migrate/src/users.sh index c5de340c4e0..ca154e9f684 100644 --- a/migrate/src/users.sh +++ b/migrate/src/users.sh @@ -110,7 +110,8 @@ retry_user_pending() { # waiting for its reboot. retry_user_pending_now() { [[ -s $user_pending ]] || return 0 - if retry_user_pending && [[ ! -e $reboot_pending ]]; then + # A migration still under way keeps the unit that resumes it. + if retry_user_pending && [[ ! -e $reboot_pending ]] && ! past_boundary; then release_verify_unit fi } diff --git a/test/fixtures/mac-migrate/lib.sh b/test/fixtures/mac-migrate/lib.sh index 59632f13c34..038dc5f76ac 100644 --- a/test/fixtures/mac-migrate/lib.sh +++ b/test/fixtures/mac-migrate/lib.sh @@ -54,7 +54,9 @@ make_archive() { omarchy-mac-boot) install -D -m 755 /dev/null "$dir/usr/lib/omarchy/mac-boot/setup-boot" install -D -m 755 /dev/null "$dir/usr/lib/omarchy/mac-boot/update-verify" - install -D -m 755 /dev/null "$dir/usr/bin/omarchy-mac-esp" + for command in omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-esp omarchy-mac-kernel; do + install -D -m 755 /dev/null "$dir/usr/bin/$command" + done ;; esac if [[ $name == omarchy-dev || $name == omarchy || $name == omarchy-mac-boot ]]; then diff --git a/test/shell.d/mac-migrate-test.sh b/test/shell.d/mac-migrate-test.sh index 905d684e47c..a2bc9c92c5b 100644 --- a/test/shell.d/mac-migrate-test.sh +++ b/test/shell.d/mac-migrate-test.sh @@ -517,6 +517,17 @@ printf 'pkgname = omarchy-mac-boot\npkgver = 20260927-1\n' >"$tmp/oldboot/.PKGIN : >"$tmp/oldboot/usr/lib/omarchy/mac-boot/update-verify" bsdtar -czf "$F/archives/omarchy-mac-boot" -C "$tmp/oldboot" .PKGINFO usr refused "an omarchy-mac-boot with its own migration engine" "not built from omacom/omarchy-mac-pkgs yet" +repository_fixture edge-linked-tool +mkdir -p "$tmp/linkboot/usr/lib/omarchy/mac-boot" "$tmp/linkboot/usr/bin" +printf 'pkgname = omarchy-mac-boot\npkgver = 20260927-1\n' >"$tmp/linkboot/.PKGINFO" +: >"$tmp/linkboot/usr/lib/omarchy/mac-boot/setup-boot" +: >"$tmp/linkboot/usr/lib/omarchy/mac-boot/update-verify" +for command in omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-kernel; do + install -m 755 /dev/null "$tmp/linkboot/usr/bin/$command" +done +ln -s /tmp/elsewhere "$tmp/linkboot/usr/bin/omarchy-mac-esp" +bsdtar -czf "$F/archives/omarchy-mac-boot" -C "$tmp/linkboot" .PKGINFO usr +refused "an omarchy-mac-boot whose boot tool is a link" "omarchy-mac-boot's commands include a link" repository_fixture edge-ready output=$(migrate run 2>&1) || fail "a ready repository target migrates" "$output" grep -q "^omarchy-dev 4.0.0.r6713.ga85e29a-1$" "$R/var/lib/pacman/local/packages" && grep -q "^omarchy-mac-boot 20260927-1$" "$R/var/lib/pacman/local/packages" || @@ -871,6 +882,24 @@ output=$(migrate verify 2>&1) || fail "a boot retries pending user setup" "$outp fail "once nothing is pending the unit and the tool's copy go" pass "a user's unit or setup that fails stays pending, runs again at each boot until it succeeds, then releases the unit" +# A user's setup that succeeds on a retry before the reboot step keeps the unit +# that resumes the migration. +new_fixture user-pending-mid +printf 'tester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +mkdir -p "$R/home/tester/.local/state/omarchy" +: >"$F/setup-user-fail" +kill_after defaults +grep -q setup-user "$(state_dir)/user-pending" || fail "the failed setup-user is pending" +rm "$F/setup-user-fail" +: >"$F/update-verify-fail" +status=0 +output=$(migrate verify 2>&1) || status=$? +(( status == 1 )) && [[ ! -e $(state_dir)/user-pending && -f $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] || + fail "a boot that retries user setup and then fails keeps the unit that resumes the migration" "status $status: $output" +rm "$F/update-verify-fail" +finish +pass "the unit that resumes a migration stays until the migration no longer needs it" + # --- A tester already on Aurora and Limine ------------------------------------ # A converged test image (the M1 and M2 today): Aurora, m1n1-aurora, Limine in From 0c4894dd46169152c43fdab3098913a03d87c5df Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Sun, 4 Oct 2026 14:36:42 +1000 Subject: [PATCH 5/7] Preview the HOOKS even when the boot package is not installed yet pacman -Qlq of a package that is not installed fails, which under pipefail made the preview fail and an encrypted legacy Mac defer for good. The fixture pacman now fails the same way. The boot unit also resumes a migration past its switch whatever a detector says. --- README.md | 2 +- bin/omarchy-mac-migrate | 8 +++++--- migrate/src/engine.sh | 6 ++++-- migrate/src/payload.sh | 2 +- test/fixtures/mac-migrate/bin/pacman | 13 +++++++++++-- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 733249fc768..855d84a0163 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Omarchy's own packages now support Apple Silicon, so this fork's quattro line en ```bash -d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "1f7904534956071e8ed01a627e4b0473d927ecfa193a0afb2cd98371767f8426 $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run +d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "07c01b67cc55e44576e1981da0b9ffd574f8f0d484cb9367f5dd9134fb43c25c $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run ``` `sudo omarchy-mac-migrate check` says what a run would do without changing anything, and `sudo omarchy-mac-migrate status` where a run stands. How it works: [migrate/README.md](migrate/README.md). diff --git a/bin/omarchy-mac-migrate b/bin/omarchy-mac-migrate index 4e6584944a3..c2691a09fc8 100755 --- a/bin/omarchy-mac-migrate +++ b/bin/omarchy-mac-migrate @@ -1612,9 +1612,11 @@ archive_state() { # Run by omarchy-mac-migrate-verify.service at boot: continues a migration that # is waiting for, or past, its reboot, and does nothing otherwise. migrate_verify() { - platform=$(hardware_platform) || die "cannot determine the hardware platform" - [[ $platform == "apple-silicon" ]] || return 0 past_boundary || [[ -s $user_pending ]] || return 0 + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + [[ $platform == "apple-silicon" ]] || return 0 + fi take_lock if past_boundary; then hand_over "${original_args[@]}" @@ -2153,7 +2155,7 @@ future_hooks() { [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } # The drop-ins of the packages the transaction replaces go with them. for name in omarchy omarchy-settings omarchy-dev omarchy-settings-dev omarchy-mac-boot; do - LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null || true done | sed -n 's|^/etc/mkinitcpio.conf.d/\([^/][^/]*\)$|\1|p' | while IFS= read -r name; do rm -f -- "$dir/$name" done diff --git a/migrate/src/engine.sh b/migrate/src/engine.sh index 6dca1d16cf7..8ad95c2b07e 100644 --- a/migrate/src/engine.sh +++ b/migrate/src/engine.sh @@ -1554,9 +1554,11 @@ archive_state() { # Run by omarchy-mac-migrate-verify.service at boot: continues a migration that # is waiting for, or past, its reboot, and does nothing otherwise. migrate_verify() { - platform=$(hardware_platform) || die "cannot determine the hardware platform" - [[ $platform == "apple-silicon" ]] || return 0 past_boundary || [[ -s $user_pending ]] || return 0 + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + [[ $platform == "apple-silicon" ]] || return 0 + fi take_lock if past_boundary; then hand_over "${original_args[@]}" diff --git a/migrate/src/payload.sh b/migrate/src/payload.sh index 667dcb83fe6..94f474c0825 100644 --- a/migrate/src/payload.sh +++ b/migrate/src/payload.sh @@ -73,7 +73,7 @@ future_hooks() { [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } # The drop-ins of the packages the transaction replaces go with them. for name in omarchy omarchy-settings omarchy-dev omarchy-settings-dev omarchy-mac-boot; do - LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null || true done | sed -n 's|^/etc/mkinitcpio.conf.d/\([^/][^/]*\)$|\1|p' | while IFS= read -r name; do rm -f -- "$dir/$name" done diff --git a/test/fixtures/mac-migrate/bin/pacman b/test/fixtures/mac-migrate/bin/pacman index c03d9ff4709..6442c7e2025 100755 --- a/test/fixtures/mac-migrate/bin/pacman +++ b/test/fixtures/mac-migrate/bin/pacman @@ -95,8 +95,17 @@ if [[ $op == "Q" && $flags == *l* ]]; then base=${base%-*} base=${base%-*} [[ ! -f $fixture/files/${base%-*} ]] || cat "$fixture/files/${base%-*}" - elif [[ -f $files_db ]]; then - if [[ $flags == *q* ]]; then sed 's/^[^ ]* //' "$files_db"; else cat "$files_db"; fi + else + # Like pacman, a named package that is not installed is an error. + for name in ${args[@]+"${args[@]}"}; do + [[ -n ${version[$name]:-} ]] || { echo "error: package '$name' was not found" >&2; exit 1; } + done + if [[ -f $files_db ]]; then + if (( ${#args[@]} )); then + awk 'NR == FNR { want[$0]; next } $1 in want' <(printf '%s\n' "${args[@]}") "$files_db" | + if [[ $flags == *q* ]]; then sed 's/^[^ ]* //'; else cat; fi + elif [[ $flags == *q* ]]; then sed 's/^[^ ]* //' "$files_db"; else cat "$files_db"; fi + fi fi exit 0 fi From 30bf0be474b69249c15672763ea5d190e9c08426 Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Sun, 4 Oct 2026 14:39:48 +1000 Subject: [PATCH 6/7] Give CI bsdtar for the migration tests --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d576b6d194c..75a13101b12 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -97,7 +97,7 @@ jobs: - name: Install test dependencies run: | sudo apt-get update - sudo apt-get install -y python3-yaml jq lua5.4 imagemagick libxkbcommon-tools ripgrep desktop-file-utils pacman-package-manager + sudo apt-get install -y python3-yaml jq lua5.4 imagemagick libxkbcommon-tools ripgrep desktop-file-utils pacman-package-manager libarchive-tools sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua if ! command -v magick >/dev/null; then sudo ln -sf "$(command -v convert)" /usr/local/bin/magick From 2976e70d81700161ccf9ae9bef03bdab5d42e2cb Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Sun, 4 Oct 2026 18:52:43 +1000 Subject: [PATCH 7/7] Exempt omarchy-mac-migrate from the command-helper style check It runs as root in an empty environment and across the move to another runtime, so it cannot depend on the runtime's helpers. --- test/shell.d/bin-style-test.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/shell.d/bin-style-test.sh b/test/shell.d/bin-style-test.sh index 5cbb9ffe514..ad3259b4183 100644 --- a/test/shell.d/bin-style-test.sh +++ b/test/shell.d/bin-style-test.sh @@ -4,8 +4,11 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +# omarchy-mac-migrate is exempt: it runs as root in an empty environment and +# carries on across the move to another runtime, so it cannot rely on these +# helpers being installed. raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \ - | rg -v '/omarchy-(cmd-|pkg-|upgrade-to-quattro|mac-setup$|system-(btrfs-migrate|boot-to-esp)$)' || true) + | rg -v '/omarchy-(cmd-|pkg-|upgrade-to-quattro|mac-setup$|mac-migrate$|system-(btrfs-migrate|boot-to-esp)$)' || true) [[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks" pass "bin commands use command helpers"