diff --git a/README.md b/README.md index 645697f..421ffc2 100644 --- a/README.md +++ b/README.md @@ -26,26 +26,12 @@ For local development, build the ISO from sibling checkouts: Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo. -A local-source install pins `omarchy-dev`, `omarchy-settings-dev` and `rime-ice-installer`, since the published builds share their version number but lack the local changes. `IgnorePkg` in `/etc/pacman.conf` makes updates skip them, and a `pre-refresh-pacman` hook (in the user's `~/.config/omarchy/hooks` and in `/etc/skel`) puts that line back after `omarchy refresh pacman`. `/etc/pacman.d/hooks/00-omarchy-pin-local-builds.hook` aborts any transaction that would still replace them. Remove all three to take the published packages. +A local-source install pins `omarchy-dev` and `omarchy-settings-dev`, since the published builds share their version number but lack the local changes. `IgnorePkg` in `/etc/pacman.conf` makes updates skip them, and a `pre-refresh-pacman` hook (in the user's `~/.config/omarchy/hooks` and in `/etc/skel`) puts that line back after `omarchy refresh pacman`. `/etc/pacman.d/hooks/00-omarchy-pin-local-builds.hook` aborts any transaction that would still replace them. Remove all three to take the published packages. The configurator asks for the region on its first screen. China reruns the installer in cage + foot, a console that draws Chinese: it opens on a bilingual region screen, keeps a US keyboard, and every later screen and the install progress read in Chinese. Choosing Global there returns to the VT, which applies the chosen keymap; without a working display China continues in English on the VT. The Chinese text lives in `OMARCHY_ZH` tables beside the English it replaces. Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. -### Regional builds - -Every ISO bundles the China profile from its runtime, so either region can be picked on the first screen. `--region cn` only preselects China there and adds a `-cn` suffix to the artifact and offline cache; the default, `--region global`, keeps the existing names. Supported country profiles use lowercase ISO 3166-1 alpha-2 codes; currently only `cn` is implemented. - -For an unpublished regional runtime, use matching local source and package-recipe checkouts: - -```bash -./bin/omarchy-iso-make --region cn --no-boot-offer --local-source /path/to/omarchy /path/to/omarchy-pkgs -``` - -A China install puts each channel server's `.omarchy.cn` twin first, keeps `.omarchy.org` behind it, appends the USTC Arch mirror, and adds ArchLinuxCN with its verified keyring. USTC is rolling, not a matching stable/RC snapshot, so that last fallback's version compatibility is not guaranteed. The region architecture also stages optional new-user defaults before user creation, including deferred setup, without touching existing homes. China preselects `Asia/Shanghai` as the timezone. See `docs/regions.md` in the companion runtime checkout. - -Non-global cache and ISO names gain a `-cn` suffix. A runtime without the selected profile fails the build. These builds require an x86_64 Arch container environment; packaging checks on another architecture do not substitute for a fresh build, offline installation, and graphical acceptance run. - ## Autoinstall The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry. @@ -66,7 +52,7 @@ These are the configurator's own output files, so the way to get a starting set | `authorized_keys` | No | SSH public keys in sshd's own format, one per line | | `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot | -Both required files must be present or the installer falls back to the configurator. `user_configuration.json` may set `omarchy_install.region` to `global` (the default) or `cn`, the region the configurator asks for on its first screen. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. +Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. @@ -80,6 +66,8 @@ When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs. +The region follows `timezone`, exactly as in an interactive install: a timezone listed in one of the runtime's region profiles (`default/regions/*/timezones`, such as `Asia/Shanghai` for `cn`) selects that profile's mirrors and repositories, and anything else is global. Set `omarchy_install.region` in `user_configuration.json` to pick one explicitly, for example `"cn"` on a deferred-provisioning install, which carries `UTC` until first boot. Every ISO bundles every profile's packages, so a regional install still needs no network. + ### Building the drive ```bash diff --git a/bin/omarchy-iso-make b/bin/omarchy-iso-make index b38001c..5dbbeff 100755 --- a/bin/omarchy-iso-make +++ b/bin/omarchy-iso-make @@ -6,17 +6,8 @@ NO_CACHE="" KEEP_PKG_CACHE="" LOCAL_OMARCHY_PATH="" LOCAL_PKGS_PATH="" -OMARCHY_REGION="${OMARCHY_REGION:-global}" while [[ $# -gt 0 ]]; do case $1 in - --region) - OMARCHY_REGION="${2:-}" - if [[ $OMARCHY_REGION != "global" && $OMARCHY_REGION != "cn" ]]; then - echo "Error: --region requires global or cn (ISO 3166-1 alpha-2)." >&2 - exit 1 - fi - shift 2 - ;; --no-cache) NO_CACHE=1 shift @@ -66,17 +57,12 @@ while [[ $# -gt 0 ]]; do ;; *) echo "Unknown option: $1" - echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--region global|cn] [--local-source ]" + echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source ]" exit 1 ;; esac done -if [[ $OMARCHY_REGION != "global" && $OMARCHY_REGION != "cn" ]]; then - echo "Error: unsupported region '$OMARCHY_REGION'. Expected global or cn." >&2 - exit 1 -fi - if [[ -n $LOCAL_OMARCHY_PATH ]]; then [[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; } [[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; } @@ -130,10 +116,6 @@ BUILD_SCRIPT="builder/build-iso.sh" BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") BUILD_RELEASE_PATH="$BUILD_ROOT/release" mkdir -p "$BUILD_RELEASE_PATH" -# Each build writes into its own directory, so concurrent builds (such as a -# global and a regional one) never pick up and rename each other's ISO. -BUILD_OUT=$(mktemp -d "$BUILD_RELEASE_PATH/.build-XXXXXX") -trap 'rm -rf "$BUILD_OUT"' EXIT OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" @@ -143,11 +125,10 @@ DOCKER_ARGS=( --privileged -e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF" -e "OMARCHY_MIRROR=$OMARCHY_MIRROR" - -e "OMARCHY_REGION=$OMARCHY_REGION" -e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}" -e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)" - -v "$BUILD_OUT/:/out/" + -v "$BUILD_RELEASE_PATH/:/out/" -v "$BUILD_ROOT/$BUILD_SCRIPT:/$BUILD_SCRIPT:ro" -v "$BUILD_ROOT/archiso:/archiso:ro" -v "$BUILD_ROOT/builder:/builder:ro" @@ -165,14 +146,10 @@ if [[ -d /var/cache/pacman/pkg ]]; then DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg") fi -# Channels and regions must not share an offline mirror cache. Keep the -# existing global cache path and artifact suffix unchanged. -region_suffix="" -if [[ $OMARCHY_REGION != "global" ]]; then - region_suffix="-$OMARCHY_REGION" -fi +# Mount the build cache directory where packages are downloaded. Channels use +# different package snapshots and must never share an offline mirror cache. if [[ -z "$NO_CACHE" ]]; then - OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}${region_suffix}/airootfs/var/cache/omarchy" + OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy" mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR" DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy") else @@ -191,14 +168,9 @@ fi "${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT -built_isos=("$BUILD_OUT"/*.iso) -if (( ${#built_isos[@]} != 1 )) || [[ ! -f ${built_isos[0]} ]]; then - echo "Error: expected exactly one ISO in $BUILD_OUT" >&2 - exit 1 -fi -built_iso_name=$(basename "${built_isos[0]}" .iso) -iso_ref="$BUILD_RELEASE_PATH/$built_iso_name-${OMARCHY_ISO_REF}${region_suffix}.iso" -mv -f "${built_isos[0]}" "$iso_ref" +latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1) +iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso" +mv -f "$latest_iso" "$iso_ref" if [[ -z "${NO_BOOT_OFFER:-}" ]]; then echo diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 71f194f..d25b745 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -4,11 +4,6 @@ set -e OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" -OMARCHY_REGION="${OMARCHY_REGION:-global}" -case "$OMARCHY_REGION" in - global|cn) ;; - *) echo "Unsupported region: $OMARCHY_REGION" >&2; exit 1 ;; -esac # Edge, dev, and local-source ISOs install the dev packages explicitly. Those # package recipes track the quattro branch. This avoids relying on pacman's @@ -35,7 +30,7 @@ pacman --noconfirm -Sy archlinux-keyring # so this container can be months behind the mirror it installs from. A plain # -Sy install is then a partial upgrade — new packages linked against a glibc # the container doesn't have yet. -pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli go +pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli # Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo # during the build without keyserver lookups). @@ -75,7 +70,6 @@ cp -r /configs/* "$build_cache_dir/" mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror" echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref" -echo "$OMARCHY_REGION" > "$build_cache_dir/airootfs/root/omarchy_region" cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" < "$build_cache_dir/airootfs/usr/share/omarchy-iso/pinned-packages" fi @@ -127,20 +120,6 @@ echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c - mkdir -p "$build_cache_dir/airootfs/opt/packages/" cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/" -# Rime Ice and Wanxiang for offline China installs. -rime_dir="$build_cache_dir/airootfs/opt/packages/rime-ice" -mkdir -p "$rime_dir" -bundle_release_asset() { - local url digest - read -r url digest < <(curl -fsSL "https://api.github.com/repos/$1/releases/tags/$2" | - jq -r --arg name "$3" '.assets[] | select(.name == $name) | "\(.browser_download_url) \(.digest)"') || - { echo "ERROR: $3 is missing from the $1 $2 release" >&2; exit 1; } - curl -fsSL "$url" -o "$rime_dir/$3" - echo "${digest#sha256:} $rime_dir/$3" | sha256sum -c - -} -bundle_release_asset iDvel/rime-ice nightly full.zip -bundle_release_asset amzxyz/RIME-LMDG LTS wanxiang-lts-zh-hans.gram - # Packages installed into the live ISO environment itself (NOT the target system). # The selected omarchy-settings package is needed here so its post_install hook # drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the @@ -167,7 +146,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64" # already in the mirror and we filter them out below. Without it, pacman -Syw # pulls the published omarchy* from the network mirror like any other package. if [[ -d /omarchy-source ]]; then - runtime_root=/omarchy-source + regions_source=/omarchy-source/default/regions base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) setup_form=/omarchy-source/install/provisioning/setup-form.sh else @@ -184,18 +163,23 @@ else fi mkdir -p /tmp/omarchy-pkglists bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages - runtime_root=/tmp/omarchy-pkglists/usr/share/omarchy - if ! bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists \ - usr/share/omarchy/default/regions/cn usr/share/omarchy/bin/omarchy-apply-pacman; then - echo "ERROR: runtime lacks region support; publish a matching runtime or use --local-source." >&2 - exit 1 - fi base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages) # Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member # it can't find, so asking for this alongside the package lists would abort the # build here (set -e) with a bare "Not found in archive" instead of the # actionable error below. bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true + # Region profiles live under default/, which the settings package ships, + # not the runtime. Its own cache dir keeps the runtime lookup above from + # ever matching it. + settings_cache_dir="$bootstrap_cache_dir/settings" + mkdir -p "$settings_cache_dir" + pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_SETTINGS_PACKAGE" --cachedir "$settings_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null + regions_source=$(find "$settings_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_SETTINGS_PACKAGE-*.pkg.tar.zst" | sort | head -1) + if [[ -z $regions_source ]]; then + echo "ERROR: downloaded package for $OMARCHY_SETTINGS_PACKAGE not found in $settings_cache_dir" >&2 + exit 1 + fi setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh fi @@ -203,16 +187,6 @@ mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" -# Work on writable copies, leaving the mounted runtime and ISO configs intact. -online_config=/tmp/omarchy-pacman-online.conf -cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config" -# Every ISO offers China on its first screen, whatever region it preselects. -bash /builder/prepare-region.sh cn "$runtime_root" "$build_cache_dir/airootfs" "$online_config" -base_pkg_lists=( - "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" - "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" -) - # The configurator's setup form comes from the runtime this ISO bundles, so the # installer and the first-boot setup that finishes a deferred install can never # disagree. A runtime predating the split ships no such file, which would leave @@ -232,6 +206,24 @@ if [[ ! -f $setup_form ]]; then fi cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh" +# Every region's packages ride in the offline mirror, and its community +# repositories join a writable copy of the online config to download them. +online_config=/tmp/omarchy-pacman-online.conf +cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config" +bash /builder/prepare-regions.sh "$regions_source" \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config" + +# Profiles ship in the settings package, but the finalizer that applies them +# ships in the runtime. A runtime without it would ignore the region marker, +# leaving regional targets with the keyring but none of the repositories. +if [[ -n ${omarchy_pkg:-} && -d $build_cache_dir/airootfs/usr/share/omarchy-iso/regions ]]; then + runtime_listing=$(bsdtar -tf "$omarchy_pkg") + if ! grep -qx 'usr/bin/omarchy-apply-pacman' <<<"$runtime_listing"; then + echo "ERROR: $OMARCHY_SETTINGS_PACKAGE ships region profiles, but $OMARCHY_RUNTIME_PACKAGE lacks omarchy-apply-pacman to apply them." >&2 + exit 1 + fi +fi + # Collect every package we want available in the offline mirror. declare -a all_packages mapfile -t all_packages < <( @@ -239,6 +231,9 @@ mapfile -t all_packages < <( cat "$build_cache_dir/packages.x86_64" grep -hv '^#\|^$' "${base_pkg_lists[@]}" grep -hv '^#\|^$' /builder/archinstall.packages + # Downloaded but not installed: the installer adds a region's packages + # only to targets in that region. + grep -hsv '^#\|^$' "$build_cache_dir"/airootfs/usr/share/omarchy-iso/regions/*/packages || true # Always include the selected Omarchy packages so the target install can # find the runtime and companion packages in the offline mirror. printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE" @@ -271,8 +266,7 @@ if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then grep -Fxv \ -e "$OMARCHY_RUNTIME_PACKAGE" \ -e "$OMARCHY_SETTINGS_PACKAGE" \ - -e "$OMARCHY_NVIM_PACKAGE" \ - -e rime-ice-installer || true + -e "$OMARCHY_NVIM_PACKAGE" || true ) fi @@ -309,7 +303,7 @@ mapfile -t required_package_files <<< "$resolved_package_files" # that the local build left exactly one file for each selected package name. if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then for local_package_name in \ - "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE" rime-ice-installer; do + "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do local_package_file="" for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do [[ -f $candidate && $candidate != *.sig ]] || continue diff --git a/builder/build-omarchy-packages.sh b/builder/build-omarchy-packages.sh index f81d8d5..5f7173f 100755 --- a/builder/build-omarchy-packages.sh +++ b/builder/build-omarchy-packages.sh @@ -40,7 +40,6 @@ packages=( "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_NVIM_PACKAGE" - rime-ice-installer ) # Local-source packages must replace every cached build of the same package, diff --git a/builder/prepare-region.sh b/builder/prepare-region.sh deleted file mode 100644 index 9172d7e..0000000 --- a/builder/prepare-region.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash - -# Add the selected runtime's regional payload to the ISO. Repository defaults -# stay in the runtime package, not in a second set of ISO-specific templates. -set -euo pipefail - -region="$1" -runtime="$2" -iso_root="$3" -online_config="$4" - -case "$region" in - global) exit 0 ;; - cn) ;; - *) echo "Unsupported region: $region" >&2; exit 1 ;; -esac - -profile="$runtime/default/regions/$region" -if [[ ! -f $runtime/bin/omarchy-apply-pacman || ! -f $profile/packages || - ! -f $profile/pacman/pacman.conf.append || ! -f $profile/pacman/mirrorlist.append ]]; then - echo "Runtime does not support region '$region'; publish a matching runtime or use --local-source." >&2 - exit 1 -fi - -# Offline mirror only; the installer adds them when China is picked. -payload="$iso_root/usr/share/omarchy-iso" -cp -a "$profile" "$payload/region" -printf '\n' >> "$payload/omarchy-other.packages" -cat "$profile/packages" >> "$payload/omarchy-other.packages" - -# Only the community repository is needed for regional package downloads. -# Keep the channel's build mirrors intact; target mirrors are restored later. -printf '\n' >> "$online_config" -cat "$profile/pacman/pacman.conf.append" >> "$online_config" - -# The keyring package must be signed by a key already trusted by Arch. Do not -# bootstrap trust from an unchecked keyserver response or disable signatures. -# https://github.com/archlinuxcn/repo#usage -pacman --config "$online_config" --noconfirm -Sy --needed archlinuxcn-keyring -pacman-key --populate archlinuxcn diff --git a/builder/prepare-regions.sh b/builder/prepare-regions.sh new file mode 100644 index 0000000..c668048 --- /dev/null +++ b/builder/prepare-regions.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Ship every region profile from the bundled runtime, so one ISO serves every +# region. The installer picks a profile from the target's timezone; this only +# stages the profiles and makes their packages downloadable. Repository +# defaults stay in the runtime, not in a second set of ISO-specific templates. +set -euo pipefail + +regions_source="$1" +iso_payload="$2" +online_config="$3" + +# The source is a checkout's default/regions, or the published settings +# package that ships default/. One predating region profiles ships none; the +# ISO then installs global targets only, exactly as before. Anything else that +# fails here, such as an unreadable package, aborts the build. +regions="$regions_source" +if [[ -f $regions_source ]]; then + member=usr/share/omarchy/default/regions + listing=$(bsdtar -tf "$regions_source") + if grep -q "^$member/" <<<"$listing"; then + extracted=$(mktemp -d) + trap 'rm -rf "$extracted"' EXIT + bsdtar -xf "$regions_source" -C "$extracted" "$member" + regions="$extracted/$member" + fi +fi +if [[ ! -d $regions ]]; then + echo "Runtime ships no region profiles; every install will be global." + exit 0 +fi + +cp -a "$regions" "$iso_payload/regions" + +for profile in "$regions"/*/; do + # Only the community repositories are needed to download regional packages. + # Keep the channel's build mirrors intact; target mirrors are set later by + # the runtime's own pacman finalizer. + printf '\n' >> "$online_config" + cat "$profile/pacman/pacman.conf.append" >> "$online_config" + + # Each keyring package must be signed by a key Arch already trusts. Do not + # bootstrap trust from an unchecked keyserver response or disable signatures. + # https://github.com/archlinuxcn/repo#usage + mapfile -t keyring_packages < <(grep -hv '^#\|^$' "$profile/packages" | grep -- '-keyring$' || true) + if (( ${#keyring_packages[@]} > 0 )); then + pacman --config "$online_config" --noconfirm -Sy --needed "${keyring_packages[@]}" + pacman-key --populate "${keyring_packages[@]%-keyring}" + fi +done diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 6a9e704..ef44b26 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -145,7 +145,8 @@ if [[ -f /root/defer-provisioning ]] || export OMARCHY_UI_DEFER_PROVISIONING=yes fi -if [[ -n ${OMARCHY_CJK_CONSOLE:-} && "$(jq -r '.omarchy_install.region // "global"' /root/user_configuration.json)" == "cn" ]]; then +# Only Chinese reaches the install from the CJK console; English returns to the VT. +if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then export OMARCHY_INSTALL_LANG=zh fi diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 863fa81..e2dbe6d 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -91,13 +91,6 @@ OMARCHY_ZH+=( ["Everything will be overwritten. There is no recovery possible."]="磁盘上的一切都会被覆盖,无法恢复。" ["Confirm overwriting %s"]="确认覆盖 %s" ) -# China preselects the bundled cn profile's timezone when geo guessing fails. -REGION_TIMEZONE="${REGION_TIMEZONE:-/usr/share/omarchy-iso/region/timezone}" -region_timezone() { - if [[ $region == "cn" ]]; then - head -n 1 "$REGION_TIMEZONE" - fi -} # Partition creation helpers, shared with test/partition-numbering-test.sh so # the numbering logic can be exercised without a real disk. @@ -370,7 +363,7 @@ user_form() { omarchy_prompt_identity || return $? omarchy_prompt_hostname || return $? - omarchy_prompt_timezone "$(region_timezone)" || return $? + omarchy_prompt_timezone || return $? } # Returns 0 on confirmed account details, or as soon as the keyboard screen @@ -390,19 +383,25 @@ user_step() { continue fi + # The timezone selects the region (mirrors and repositories), using the + # same lookup as the orchestrator. Global installs get no extra row. + region=$(cd /usr/share/omarchy-iso 2>/dev/null && python -m orchestrator.region "$timezone") || region="global" + region_row="" + if [[ $region != "global" ]]; then + region_row="\n$(omarchy_text Region),$region" + fi + # Add manual padding since gum table -p doesn't respect padding - local skipped region_label=Global + local skipped skipped=$(omarchy_text "[Skipped]") - [[ $region == "cn" ]] && region_label=China echo -e "$(omarchy_text Field),$(omarchy_text Value) $(omarchy_text Username),$username $(omarchy_text Password),$(printf "%${#password}s" | tr ' ' '*') $(omarchy_text "Full name"),${full_name:-$skipped} $(omarchy_text "Email address"),${email_address:-$skipped} $(omarchy_text Hostname),$hostname -$(omarchy_text Timezone),$timezone -$(omarchy_text Keyboard),$keyboard -$(omarchy_text Region),$(omarchy_text "$region_label")" | +$(omarchy_text Timezone),$timezone$region_row +$(omarchy_text Keyboard),$keyboard" | gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" echo @@ -907,7 +906,6 @@ run_partition_execute() { "input_method": "${keyboard_input_method:-none}", "input_xkb_layout": "${keyboard_xkb_layout:-}", "mode": "protected", - "region": "$region", "target_mount": "/mnt", "boot": { "esp_mount": "$esp_mount_in_target", @@ -1141,8 +1139,8 @@ select_installation() { defer_provisioning=false install_target="full_disk" -# A rerun arrives with the region chosen; otherwise the ISO's default. -region="${OMARCHY_INSTALL_REGION:-$(<"${ISO_REGION_FILE:-/root/omarchy_region}")}" +# A rerun arrives with the console language chosen; otherwise Global. +region="${OMARCHY_INSTALL_REGION:-global}" # Let the live VT reach its settled width before the first screen so the logo # isn't measured against the transient 80-column boot console. @@ -1256,7 +1254,6 @@ cat <<-_EOF_ >user_configuration.json "input_method": "${keyboard_input_method:-none}", "input_xkb_layout": "${keyboard_xkb_layout:-}", "mode": "full_disk", - "region": "$region", "defer_provisioning": $defer_provisioning, "target_mount": "/mnt", "boot": { diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 62e51da..994697e 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any from .keyboard import validate_input_selection +from .region import GLOBAL, resolve_region @dataclass @@ -29,6 +30,7 @@ class InstallContext: arch_config_path: Path omarchy_install: dict[str, Any] defer_provisioning: bool = False + region: str = GLOBAL target: Path = Path("/mnt") omarchy_path: Path = Path("/usr/share/omarchy") @@ -61,6 +63,11 @@ class InstallContext: validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", "")) + # Resolved before any disk is touched, so an autoinstall naming a + # region this ISO lacks fails without wiping anything. + region = resolve_region(omarchy_install, user_configuration.get("timezone")) + omarchy_install["region"] = region + # Deferred provisioning: the whole system installs but user creation is deferred to # first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by # an `defer-provisioning` marker file on an autoinstall drive, which also replaces the @@ -69,12 +76,6 @@ class InstallContext: defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None omarchy_install["defer_provisioning"] = defer_provisioning - # Autoinstall configs written before regions existed install Global. - region = omarchy_install.get("region", "global") - if region not in ("global", "cn"): - raise RuntimeError(f"omarchy_install.region must be global or cn, not {region!r}") - omarchy_install["region"] = region - if creds_path.exists(): user_credentials = json.loads(creds_path.read_text()) elif defer_provisioning: @@ -131,6 +132,7 @@ class InstallContext: arch_config_path=arch_config_path, omarchy_install=omarchy_install, defer_provisioning=defer_provisioning, + region=region, state_dir=state_dir, ) disk_config = user_configuration.get("disk_config", {}) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 66b16c5..8f9a742 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -36,35 +36,10 @@ from . import archinstall_adapter as arch from .command import capture, capture_identifier, require_text from .context import InstallContext from .keyboard import configure_keyboard +from .region import GLOBAL, region_keyrings, region_packages from .ui import error, info -ISO_REGION_PAYLOAD = Path("/usr/share/omarchy-iso/region") - - -def _region_packages(ctx: InstallContext) -> list[str]: - if ctx.omarchy_install["region"] != "cn": - return [] - lines = (ISO_REGION_PAYLOAD / "packages").read_text().splitlines() - return [s for s in (raw.strip() for raw in lines) if s and not s.startswith("#")] - - -def _stage_region_defaults(ctx: InstallContext) -> None: - """Seed the target before useradd, including deferred first-boot users. - - Region is a bootstrap default, not a locale or a user-level policy. - Existing home directories are never rewritten by this step. - """ - region = ctx.omarchy_install["region"] - skel = ISO_REGION_PAYLOAD / "skel" - if region == "cn" and skel.is_dir(): - shutil.copytree(skel, ctx.target / "etc/skel", dirs_exist_ok=True) - marker = ctx.target / "etc/omarchy/region" - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(region + "\n") - marker.chmod(0o644) - - # Package targets are written by builder/build-iso.sh. Stable ISOs use the # stable package names, while dev/local-source ISOs install the dev package # names explicitly instead of relying on provides=omarchy resolution. @@ -313,7 +288,6 @@ def arch_install_system(ctx: InstallContext) -> None: _install_early_packages(installer) _configure_limine_boot(ctx, installer, config) - _stage_region_defaults(ctx) info("› creating user (with /etc/skel populated)") if config.auth_config and config.auth_config.users: installer.create_users(config.auth_config.users) @@ -325,6 +299,11 @@ def arch_install_system(ctx: InstallContext) -> None: info("› installing Omarchy runtime + omarchy-base.packages") installer.add_additional_packages(_runtime_package_list(ctx)) + if region_pkgs := region_packages(ctx.region): + info(f"› installing {ctx.region} region packages: {', '.join(region_pkgs)}") + installer.add_additional_packages(region_pkgs) + _write_region_marker(ctx) + # Tailscale is bundled in the offline mirror but only installed # when an autoinstall drive staged an auth key; must happen here, # while the mirror is still bind-mounted, not in the phase that @@ -755,6 +734,17 @@ def _unmask_mkinitcpio_pacman_hooks( info(f"warning: failed to restore pacman hook mask for {name}: {exc}") +def _write_region_marker(ctx: InstallContext) -> None: + """Tell the runtime's pacman finalizer which profile to apply. Global + installs write nothing, so they stay identical to a region-less install.""" + if ctx.region == GLOBAL: + return + marker = ctx.target / "etc/omarchy/region" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(ctx.region + "\n") + marker.chmod(0o644) + + def _runtime_package_list(ctx: InstallContext) -> list[str]: """Selected Omarchy runtime package + every package in the ISO-bundled base package list that isn't already installed early.""" @@ -774,10 +764,6 @@ def _runtime_package_list(ctx: InstallContext) -> list[str]: continue if s not in already_installed and s not in pkgs: pkgs.append(s) - pkgs += [p for p in _region_packages(ctx) if p not in pkgs] - # Local builds live only in this ISO's mirror; a later region switch could not fetch them. - if PINNED_PACKAGES.exists(): - pkgs += [p for p in PINNED_PACKAGES.read_text().split() if p not in already_installed and p not in pkgs] return pkgs @@ -1129,7 +1115,6 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str f"OMARCHY_USER_NAME={ctx.full_name}", f"OMARCHY_USER_EMAIL={ctx.email}", f"OMARCHY_MIRROR={mirror_channel}", - f"OMARCHY_REGION={ctx.omarchy_install['region']}", f"OMARCHY_ISO_REF={_iso_ref()}", f"OMARCHY_RUNTIME_PACKAGE={_omarchy_runtime_package()}", f"OMARCHY_SETTINGS_PACKAGE={_omarchy_settings_package()}", @@ -1167,12 +1152,13 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str def run_system_finalizer(ctx: InstallContext) -> None: - # The runtime package transaction has installed the regional keyring from - # the ISO. Initialize the target's trust database before enabling the online - # community repository; the build container's GPG state is not inherited. - if ctx.omarchy_install["region"] == "cn": + # The region's keyring packages are installed by now, but the build + # container's GPG state is not inherited. Initialize the target's trust + # database with only this region's keys before the finalizer enables its + # online repositories; global installs never trust them. + if keyrings := region_keyrings(ctx.region): _run_target_setup_command(ctx, ["pacman-key", "--init"]) - _run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", "archlinuxcn"]) + _run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", *keyrings]) if ctx.defer_provisioning: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] @@ -1414,7 +1400,6 @@ def run_chroot_finalizer(ctx: InstallContext) -> None: ) - PINNED_PACKAGES = Path("/usr/share/omarchy-iso/pinned-packages") PIN_HOOK_SCRIPT = """#!/bin/bash diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py new file mode 100644 index 0000000..9ff8159 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py @@ -0,0 +1,68 @@ +"""Region selection: which of the runtime's region profiles a target gets. + +The builder copies every profile from the bundled runtime's default/regions/ +into REGIONS_DIR, and their packages into the offline mirror. Nobody picks a +region by hand: the timezone the user already chose selects it, and an +autoinstall config may name one explicitly in omarchy_install.region. Run as +`python -m orchestrator.region ` to print the region the installer +would use, which is how the configurator fills its summary. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REGIONS_DIR = Path("/usr/share/omarchy-iso/regions") +GLOBAL = "global" + + +def _entries(path: Path) -> list[str]: + if not path.is_file(): + return [] + lines = (line.strip() for line in path.read_text().splitlines()) + return [line for line in lines if line and not line.startswith("#")] + + +def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) -> str: + regions_dir = regions_dir or REGIONS_DIR + if timezone and regions_dir.is_dir(): + for profile in sorted(regions_dir.iterdir()): + if timezone in _entries(profile / "timezones"): + return profile.name + return GLOBAL + + +def resolve_region(omarchy_install: dict, timezone: str | None, regions_dir: Path | None = None) -> str: + regions_dir = regions_dir or REGIONS_DIR + region = omarchy_install.get("region") + if region is None or region == "": + return region_for_timezone(timezone, regions_dir) + # Profiles are named by ISO 3166-1 alpha-2 code; checking the shape first + # keeps a path like ".." from passing the directory test. + if not isinstance(region, str) or ( + region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir()) + ): + raise RuntimeError(f"Unsupported region: {region!r}") + return region + + +def region_packages(region: str, regions_dir: Path | None = None) -> list[str]: + if region == GLOBAL: + return [] + return _entries((regions_dir or REGIONS_DIR) / region / "packages") + + +def region_keyrings(region: str, regions_dir: Path | None = None) -> list[str]: + """Pacman keyrings the profile ships, named the Arch way: -keyring + installs /usr/share/pacman/keyrings/.gpg.""" + return [ + package.removesuffix("-keyring") + for package in region_packages(region, regions_dir) + if package.endswith("-keyring") + ] + + +if __name__ == "__main__": + print(region_for_timezone(sys.argv[1] if len(sys.argv) > 1 else None)) diff --git a/test/unit/test_kernel_selection.py b/test/unit/test_kernel_selection.py index 98c023a..8c952bf 100644 --- a/test/unit/test_kernel_selection.py +++ b/test/unit/test_kernel_selection.py @@ -93,7 +93,6 @@ class KernelSelectionTest(unittest.TestCase): events = [] installer = mock.MagicMock() installer.minimal_installation.side_effect = lambda **kwargs: events.append("base") - installer.create_users.side_effect = lambda users: events.append("user") def install_packages(packages): events.append(packages) @@ -103,12 +102,13 @@ class KernelSelectionTest(unittest.TestCase): installer.add_additional_packages.side_effect = install_packages config = types.SimpleNamespace( kernels=kernels, locale_config=None, mirror_config=None, - swap=None, auth_config=types.SimpleNamespace(users=["alice"]), app_config=None, timezone=None, + swap=None, auth_config=None, app_config=None, timezone=None, ntp=False, hostname="test", pacman_config="/etc/pacman.conf", ) ctx = types.SimpleNamespace( state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None}, target=Path("/unused"), tailscale_authkey_path=None, omarchy_install={}, + region="global", ) for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks", "_configure_limine_boot", "_write_pre_mounted_fstab"): @@ -117,7 +117,6 @@ class KernelSelectionTest(unittest.TestCase): unmount = stack.enter_context(mock.patch.object(phases_impl, "_unmount_offline_package_cache")) stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True)) stack.enter_context(mock.patch.object(phases_impl, "_install_early_packages", side_effect=lambda inst: events.append("early"))) - stack.enter_context(mock.patch.object(phases_impl, "_stage_region_defaults", side_effect=lambda target: events.append("region"))) stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"])) stack.enter_context(mock.patch.object(phases_impl.arch, "is_pre_mount", return_value=True, create=True)) stack.enter_context(mock.patch.object(phases_impl.arch, "root_user", return_value=None, create=True)) @@ -131,7 +130,7 @@ class KernelSelectionTest(unittest.TestCase): phases_impl.arch_install_system(ctx) expected = ["base", [f"{kernel}-headers" for kernel in kernels]] if not fail_headers: - expected += ["early", "region", "user", ["omarchy"]] + expected += ["early", ["omarchy"]] self.assertEqual(events, expected) unmask.assert_called_once_with(ctx) unmount.assert_called_once_with(ctx) diff --git a/test/unit/test_pin_local_packages.py b/test/unit/test_pin_local_packages.py index 5ce4f32..b878410 100644 --- a/test/unit/test_pin_local_packages.py +++ b/test/unit/test_pin_local_packages.py @@ -16,7 +16,7 @@ sys.modules.setdefault( ) from orchestrator import phases_impl # noqa: E402 -LINE = "IgnorePkg = omarchy-dev omarchy-settings-dev rime-ice-installer" +LINE = "IgnorePkg = omarchy-dev omarchy-settings-dev" CONF = "[options]\nHoldPkg = pacman glibc\n\n[core]\nInclude = /etc/pacman.d/mirrorlist\n" @@ -26,7 +26,7 @@ class PinLocalPackagesTest(unittest.TestCase): (root / "etc/pacman.conf").write_text(CONF) pinned_file = root / "pinned-packages" if pinned: - pinned_file.write_text("omarchy-dev\nomarchy-settings-dev\nrime-ice-installer\n") + pinned_file.write_text("omarchy-dev\nomarchy-settings-dev\n") ctx = types.SimpleNamespace(target=root, username="test", defer_provisioning=defer) with mock.patch.object(phases_impl, "PINNED_PACKAGES", pinned_file), \ mock.patch.object(phases_impl.subprocess, "run") as run: @@ -43,7 +43,7 @@ class PinLocalPackagesTest(unittest.TestCase): self.assertIn(f'line="{LINE}"', hook.read_text()) self.assertTrue(hook.stat().st_mode & 0o111) guard = (root / "etc/pacman.d/hooks/00-omarchy-pin-local-builds.hook").read_text() - for package in ("omarchy-dev", "omarchy-settings-dev", "rime-ice-installer"): + for package in ("omarchy-dev", "omarchy-settings-dev"): self.assertIn(f"Target = {package}\n", guard) self.assertIn("AbortOnFail", guard) skel = root / "etc/skel/.config/omarchy/hooks/pre-refresh-pacman.d/pin-local-packages" @@ -83,36 +83,5 @@ class PinLocalPackagesTest(unittest.TestCase): self.assertFalse((root / "etc/pacman.d").exists()) -class RuntimePackagesTest(unittest.TestCase): - def runtime_packages(self, pinned: str | None) -> list[str]: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - pinned_file = root / "pinned-packages" - if pinned is not None: - pinned_file.write_text(pinned) - base = root / "omarchy-base.packages" - base.write_text("# base\nfcitx5\ngit\n") - real_path = phases_impl.Path - - def path(arg, *rest): - if arg == "/usr/share/omarchy-iso/omarchy-base.packages": - return base - return real_path(arg, *rest) - - with mock.patch.object(phases_impl, "PINNED_PACKAGES", pinned_file), \ - mock.patch.object(phases_impl, "Path", side_effect=path), \ - mock.patch.object(phases_impl, "_early_packages", return_value=["git"]), \ - mock.patch.object(phases_impl, "_package_targets", return_value={ - "runtime": "omarchy-dev", "settings": "omarchy-settings-dev", "nvim": "omarchy-nvim"}): - return phases_impl._runtime_package_list(types.SimpleNamespace(omarchy_install={"region": "global"})) - - def test_local_builds_install_on_every_region(self): - packages = self.runtime_packages("omarchy-dev\nomarchy-settings-dev\nrime-ice-installer\n") - self.assertEqual(packages, ["omarchy-dev", "fcitx5", "rime-ice-installer"]) - - def test_published_builds_add_nothing(self): - self.assertEqual(self.runtime_packages(None), ["omarchy-dev", "fcitx5"]) - - if __name__ == "__main__": unittest.main() diff --git a/test/unit/test_region.py b/test/unit/test_region.py index 308aad5..6119bc9 100644 --- a/test/unit/test_region.py +++ b/test/unit/test_region.py @@ -1,14 +1,13 @@ -"""Region: the install-time choice, the bundled cn profile, and regional builds.""" +"""The target's region comes from its timezone, using the runtime's profiles.""" import json import os -import re -import shutil import subprocess import sys import tempfile import types import unittest +from contextlib import ExitStack from pathlib import Path from unittest import mock @@ -18,411 +17,219 @@ sys.modules.setdefault( "orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter"), ) -from orchestrator import context, phases_impl # noqa: E402 +from orchestrator import context, phases_impl, region # noqa: E402 -def load_context(root: Path, omarchy_install: dict | None) -> context.InstallContext: - config = {"kernels": ["linux-omarchy"]} - if omarchy_install is not None: - config["omarchy_install"] = omarchy_install - config_path = root / "config.json" - creds_path = root / "creds.json" - config_path.write_text(json.dumps(config)) - creds_path.write_text(json.dumps({"users": [{"username": "test"}]})) - with mock.patch.dict(os.environ, { - "OMARCHY_INSTALL_CONFIG": str(config_path), - "OMARCHY_INSTALL_CREDS": str(creds_path), - "OMARCHY_INSTALL_STATE_DIR": str(root / "state"), - }, clear=True): - return context.InstallContext.from_env() +def write_profiles(root: Path) -> Path: + regions = root / "regions" + cn = regions / "cn" + (cn / "pacman").mkdir(parents=True) + (cn / "timezones").write_text("# China\nAsia/Shanghai\n\n Asia/Urumqi \n") + (cn / "packages").write_text("# keyring first\narchlinuxcn-keyring\nextra-tool\n") + (cn / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://example.invalid/$arch\n") + for channel in ("stable", "rc", "edge"): + (cn / f"pacman/mirrorlist-{channel}.prepend").write_text(f"Server = https://{channel}.example.invalid/$repo/os/$arch\n") + return regions -class RegionTest(unittest.TestCase): - def test_context_reads_region(self): - for omarchy_install, expected in [ - (None, "global"), - ({"mode": "full_disk"}, "global"), - ({"mode": "full_disk", "region": "global"}, "global"), - ({"mode": "full_disk", "region": "cn"}, "cn"), - ]: - with self.subTest(omarchy_install=omarchy_install), tempfile.TemporaryDirectory() as tmp: - ctx = load_context(Path(tmp), omarchy_install) - self.assertEqual(ctx.omarchy_install["region"], expected) - - def test_context_rejects_unknown_region(self): - for region in ("China", "mars", ""): - with self.subTest(region=region), tempfile.TemporaryDirectory() as tmp: - with self.assertRaisesRegex(RuntimeError, "omarchy_install.region must be global or cn"): - load_context(Path(tmp), {"mode": "full_disk", "region": region}) - - def test_target_setup_receives_region(self): - for region in ("global", "cn"): - with self.subTest(region=region), tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - ctx = types.SimpleNamespace( - target=root / "mnt", - log_path=root / "install.log", - omarchy_install={"region": region}, - username="test", - full_name="", - email="", - state={"omarchy_start_time": "now", "omarchy_start_epoch": 0, - "omarchy_finalizer_header_written": True}, - ) - with mock.patch.object(phases_impl, "_prepare_target_setup"), \ - mock.patch.object(phases_impl.subprocess, "run") as run: - phases_impl._run_target_setup_command(ctx, ["/usr/bin/omarchy-apply-system"]) - chroot = next(call.args[0] for call in run.call_args_list if call.args[0][0] == "arch-chroot") - self.assertIn(f"OMARCHY_REGION={region}", chroot) - - def run_keyboard_form(self, script: str, preselect: str = "global", **env: str) -> subprocess.CompletedProcess: - configurator = (ROOT / "configs/airootfs/root/configurator").read_text() - language = re.search(r"^apply_region_language\(\) \{\n.*?^\}", configurator, re.M | re.S) - form = re.search(r"^keyboard_form\(\) \{\n.*?^\}", configurator, re.M | re.S) - init = re.search(r'^region="\$\{OMARCHY_INSTALL_REGION:-.*\}"$', configurator, re.M) - self.assertIsNotNone(language) - self.assertIsNotNone(form) - self.assertIsNotNone(init) - harness = "\n".join([ - "set -euo pipefail", - "OMARCHY_FORM_BACK=1; OMARCHY_FORM_SIGNAL=130; defer_provisioning=false", - "clear_logo() { :; }; say() { :; }; abort() { exit 99; }; tty() { echo /dev/pts/0; }", - "omarchy_text() { printf '%s' \"$1\"; }", - "answers=(" + script + "); step=0", - "answer() { local a=${answers[step]}; step=$((step + 1)); [[ $a == ok:* ]] && eval \"${a#ok:}\" && return 0; return ${a#rc:}; }", - "omarchy_prompt_region() { echo \"region-preselect=$region\"; answer; }", - "omarchy_prompt_keyboard() { echo keyboard-prompt; answer; }", - "confirm_prepare_for_another_owner() { answer; }", - init.group(), - language.group(), - form.group(), - "keyboard_form", - 'echo "region=$region defer=$defer_provisioning keyboard=${keyboard:-} lang=${OMARCHY_INSTALL_LANG:-}"', - ]) - with tempfile.TemporaryDirectory() as tmp: - iso_region = Path(tmp) / "omarchy_region" - iso_region.write_text(preselect + "\n") - return subprocess.run(["bash", "-c", harness], capture_output=True, text=True, - env={"PATH": os.environ["PATH"], "ISO_REGION_FILE": str(iso_region), **env}) - - def lines(self, result: subprocess.CompletedProcess) -> list[str]: - return [line for line in result.stdout.splitlines() if line] - - def test_ctrl_c_before_choosing_defers_as_global(self): - out = self.run_keyboard_form("rc:130 ok:true") - self.assertIn("region=global defer=true", out.stdout) - - def test_back_from_keyboard_keeps_the_chosen_region(self): - out = self.run_keyboard_form("ok:region=cn rc:1 ok:true ok:keyboard=us") - self.assertEqual(self.lines(out), [ - "region-preselect=global", - "keyboard-prompt", - "region-preselect=cn", - "keyboard-prompt", - "region=cn defer=false keyboard=us lang=", - ]) - - def test_a_cn_iso_preselects_china(self): - out = self.run_keyboard_form("ok:true", preselect="cn", OMARCHY_CJK_RELAUNCH="10") - self.assertEqual(out.returncode, 10) - self.assertEqual(self.lines(out), ["region-preselect=cn"]) - - def test_china_on_the_vt_hands_over_to_the_cjk_console(self): - out = self.run_keyboard_form("ok:region=cn", OMARCHY_CJK_RELAUNCH="10") - self.assertEqual(out.returncode, 10) - self.assertEqual(self.lines(out), ["region-preselect=global"]) - - def test_cjk_console_reads_chinese_with_a_us_keyboard(self): - out = self.run_keyboard_form("ok:true", OMARCHY_CJK_CONSOLE="1", OMARCHY_VT_RETURN="11", - OMARCHY_INSTALL_REGION="cn") - self.assertEqual(out.returncode, 0) - self.assertEqual(self.lines(out), [ - "region-preselect=cn", - "region=cn defer=false keyboard=us lang=zh", - ]) - - def test_global_in_the_cjk_console_returns_to_the_vt(self): - out = self.run_keyboard_form("ok:region=global", OMARCHY_CJK_CONSOLE="1", OMARCHY_VT_RETURN="11", - OMARCHY_INSTALL_REGION="cn") - self.assertEqual(out.returncode, 11) - self.assertNotIn("keyboard-prompt", out.stdout) - - -class RegionTargetTest(unittest.TestCase): +class RegionSelectionTest(unittest.TestCase): def setUp(self): tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) self.root = Path(tmp.name) - self.target = self.root / "target" - self.payload = self.root / "payload" - patch = mock.patch.object(phases_impl, "ISO_REGION_PAYLOAD", self.payload) - patch.start() - self.addCleanup(patch.stop) + self.regions = write_profiles(self.root) + patcher = mock.patch.object(region, "REGIONS_DIR", self.regions) + patcher.start() + self.addCleanup(patcher.stop) - def ctx(self, region, deferred=False): - return types.SimpleNamespace(target=self.target, username="alice", defer_provisioning=deferred, - omarchy_install={"region": region}) + def test_timezone_selects_region(self): + for timezone, expected in [ + ("Asia/Shanghai", "cn"), + ("Asia/Urumqi", "cn"), + ("Asia/Hong_Kong", "global"), + ("America/New_York", "global"), + ("UTC", "global"), + ("# China", "global"), + ("", "global"), + (None, "global"), + ]: + with self.subTest(timezone=timezone): + self.assertEqual(region.region_for_timezone(timezone), expected) - def test_global_does_not_change_user_defaults(self): - (self.payload / "skel").mkdir(parents=True) - (self.payload / "skel/.regional").write_text("cn only\n") - phases_impl._stage_region_defaults(self.ctx("global")) - self.assertEqual((self.target / "etc/omarchy/region").read_text(), "global\n") - self.assertFalse((self.target / "etc/skel").exists()) + def test_iso_without_profiles_is_global(self): + self.assertEqual(region.region_for_timezone("Asia/Shanghai", self.root / "missing"), "global") + self.assertEqual(region.resolve_region({}, "Asia/Shanghai", self.root / "missing"), "global") - def test_cn_seeds_skel_without_touching_existing_users_or_locale(self): - profile = self.payload / "skel/.config/example/defaults" - profile.parent.mkdir(parents=True) - profile.write_text("regional defaults\n") - existing = self.target / "home/alice/.config/example/defaults" - existing.parent.mkdir(parents=True) - existing.write_text("personal settings\n") - skel = self.target / "etc/skel" - skel.mkdir(parents=True) - (skel / ".bashrc").write_text("original\n") - (self.target / "etc/locale.conf").write_text("LANG=en_US.UTF-8\n") + def test_autoinstall_region_overrides_timezone(self): + self.assertEqual(region.resolve_region({"region": "cn"}, "UTC"), "cn") + self.assertEqual(region.resolve_region({"region": "global"}, "Asia/Shanghai"), "global") + self.assertEqual(region.resolve_region({}, "Asia/Shanghai"), "cn") + for bad in ("us", "CN", "..", "cn/../cn", True, 1, ["cn"]): + with self.subTest(region=bad), self.assertRaises(RuntimeError): + region.resolve_region({"region": bad}, "UTC") - phases_impl._stage_region_defaults(self.ctx("cn")) - self.assertEqual((skel / ".config/example/defaults").read_text(), "regional defaults\n") - self.assertEqual((skel / ".bashrc").read_text(), "original\n") - self.assertEqual(existing.read_text(), "personal settings\n") - self.assertEqual((self.target / "etc/locale.conf").read_text(), "LANG=en_US.UTF-8\n") - self.assertEqual((self.target / "etc/omarchy/region").read_text(), "cn\n") + def test_profile_packages_and_keyrings(self): + self.assertEqual(region.region_packages("cn"), ["archlinuxcn-keyring", "extra-tool"]) + self.assertEqual(region.region_keyrings("cn"), ["archlinuxcn"]) + self.assertEqual(region.region_packages("global"), []) + self.assertEqual(region.region_keyrings("global"), []) - def test_cn_without_user_defaults_only_records_the_region(self): - phases_impl._stage_region_defaults(self.ctx("cn")) - self.assertEqual((self.target / "etc/omarchy/region").read_text(), "cn\n") - self.assertFalse((self.target / "etc/skel").exists()) + def from_env(self, config: dict) -> context.InstallContext: + config_path = self.root / "config.json" + creds_path = self.root / "creds.json" + config_path.write_text(json.dumps(config)) + creds_path.write_text(json.dumps({"users": [{"username": "test"}]})) + with mock.patch.dict(os.environ, { + "OMARCHY_INSTALL_CONFIG": str(config_path), + "OMARCHY_INSTALL_CREDS": str(creds_path), + "OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"), + }, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"): + return context.InstallContext.from_env() - def test_cn_keyring_is_initialized_before_finalization_for_both_install_modes(self): - for deferred in (False, True): - ctx = self.ctx("cn", deferred) - with mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ - mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ - mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + def test_context_resolves_region_before_install(self): + self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn") + self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global") + # Deferred installs carry UTC until first boot, so they are global + # unless the autoinstall config names a region. + self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global") + self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn") + with self.assertRaises(RuntimeError): + self.from_env({"omarchy_install": {"region": "zz"}}) + + def test_marker_is_written_only_for_regional_targets(self): + target = self.root / "target" + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global")) + self.assertFalse((target / "etc/omarchy/region").exists()) + + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn")) + marker = target / "etc/omarchy/region" + self.assertEqual(marker.read_text(), "cn\n") + self.assertEqual(marker.stat().st_mode & 0o777, 0o644) + + def test_region_packages_install_while_the_offline_mirror_is_mounted(self): + for selected, expected_packages in [("global", []), ("cn", [["archlinuxcn-keyring", "extra-tool"]])]: + with self.subTest(region=selected), ExitStack() as stack: + events = [] + installer = mock.MagicMock() + installer.add_additional_packages.side_effect = lambda packages: events.append(packages) + config = types.SimpleNamespace( + kernels=["linux-omarchy"], locale_config=None, mirror_config=None, + swap=None, auth_config=None, app_config=None, timezone=None, + ntp=False, hostname="test", pacman_config="/etc/pacman.conf", + ) + ctx = types.SimpleNamespace( + state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None}, + target=self.root / "target", tailscale_authkey_path=None, omarchy_install={}, + region=selected, + ) + for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks", + "_unmask_mkinitcpio_pacman_hooks", "_configure_limine_boot", + "_write_pre_mounted_fstab", "_install_early_packages"): + stack.enter_context(mock.patch.object(phases_impl, name)) + stack.enter_context(mock.patch.object( + phases_impl, "_unmount_offline_package_cache", side_effect=lambda _: events.append("unmount"))) + stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True)) + stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"])) + for name, value in (("is_pre_mount", True), ("root_user", None)): + stack.enter_context(mock.patch.object(phases_impl.arch, name, return_value=value, create=True)) + stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True)) + opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True)) + opened.return_value.__enter__.return_value = installer + + phases_impl.arch_install_system(ctx) + + self.assertEqual(events, [["linux-omarchy-headers"], ["omarchy"], *expected_packages, "unmount"]) + self.assertEqual((ctx.target / "etc/omarchy/region").exists(), selected != "global") + + def test_finalizer_trusts_only_the_selected_region(self): + for selected, expected in [ + ("global", []), + ("cn", [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]), + ]: + with self.subTest(region=selected), \ + mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ + mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ + mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + ctx = types.SimpleNamespace(region=selected, defer_provisioning=True, target=Path("/unused")) phases_impl.run_system_finalizer(ctx) commands = [call.args[1] for call in run.call_args_list] - self.assertEqual(commands[:2], [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]) - expected = ["--defer-provisioning"] if deferred else ["--install-user", "alice"] - self.assertEqual(commands[2], ["/usr/bin/omarchy-apply-system", *expected, "--first-install"]) - - def test_keyring_failure_prevents_enabling_online_repositories(self): - ctx = self.ctx("cn") - with mock.patch.object(phases_impl, "_run_target_setup_command", side_effect=RuntimeError("keyring failed")) as run: - with self.assertRaisesRegex(RuntimeError, "keyring failed"): - phases_impl.run_system_finalizer(ctx) - self.assertEqual(run.call_count, 1) - - def test_global_trusts_no_regional_keyring(self): - with mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ - mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ - mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): - phases_impl.run_system_finalizer(self.ctx("global")) - self.assertNotIn("pacman-key", [call.args[1][0] for call in run.call_args_list]) + self.assertEqual(commands[:-1], expected) + self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system") -class RegionBuildTest(unittest.TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.root = Path(tmp.name) - self.runtime = self.root / "runtime" - self.profile = self.runtime / "default/regions/cn" - (self.profile / "pacman").mkdir(parents=True) - (self.runtime / "bin").mkdir() - (self.runtime / "bin/omarchy-apply-pacman").touch() - (self.profile / "packages").write_text("archlinuxcn-keyring\nexample-regional-package\n") - (self.profile / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n") - (self.profile / "pacman/mirrorlist.append").write_text("Server = https://mirrors.ustc.edu.cn/archlinux/$repo/os/$arch\n") - (self.profile / "timezone").write_text("Asia/Shanghai\n") - self.iso = self.root / "iso" - self.payload = self.iso / "usr/share/omarchy-iso" - self.payload.mkdir(parents=True) - self.packages = self.payload / "omarchy-base.packages" - self.packages.write_text("base\n") - self.other = self.payload / "omarchy-other.packages" - self.other.write_text("other\n") - self.config = self.root / "pacman.conf" - shutil.copyfile(ROOT / "configs/pacman-online-stable.conf", self.config) - self.original_config = self.config.read_bytes() - self.bin = self.root / "bin" - self.bin.mkdir() - self.log = self.root / "commands" - for command in ("pacman", "pacman-key"): - stub = self.bin / command - stub.write_text('#!/bin/bash\nprintf "%s %s\\n" "${0##*/}" "$*" >> "$TEST_LOG"\nif [[ ${0##*/} == pacman ]]; then exit "${TEST_PACMAN_STATUS:-0}"; fi\n') +class PrepareRegionsTest(unittest.TestCase): + def run_prepare(self, regions: Path, root: Path) -> tuple[Path, Path, str]: + payload = root / "payload" + payload.mkdir() + online = root / "online.conf" + online.write_text("[core]\nInclude = /etc/pacman.d/mirrorlist\n") + stubs = root / "bin" + stubs.mkdir() + log = root / "calls.log" + for name in ("pacman", "pacman-key"): + stub = stubs / name + stub.write_text(f'#!/bin/bash\necho "{name} $*" >> "{log}"\n') stub.chmod(0o755) - - def prepare(self, region, **env): - return subprocess.run( - ["bash", str(ROOT / "builder/prepare-region.sh"), region, str(self.runtime), str(self.iso), str(self.config)], - env={**os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", "TEST_LOG": str(self.log), **env}, - capture_output=True, text=True, + subprocess.run( + ["bash", str(ROOT / "builder/prepare-regions.sh"), str(regions), str(payload), str(online)], + env={**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}"}, + check=True, capture_output=True, text=True, ) + return payload, online, log.read_text() if log.exists() else "" - def test_cn_bundles_its_packages_installs_them_only_for_china_and_verifies_keyring_first(self): - result = self.prepare("cn") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(self.packages.read_text(), "base\n") - self.assertEqual(self.other.read_text(), "other\n\narchlinuxcn-keyring\nexample-regional-package\n") - self.assertEqual(self.config.read_bytes(), self.original_config + b"\n[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n") - self.assertEqual((self.payload / "region/packages").read_text(), "archlinuxcn-keyring\nexample-regional-package\n") - self.assertEqual((self.payload / "region/timezone").read_text(), "Asia/Shanghai\n") - self.assertEqual(self.log.read_text().splitlines(), [f"pacman --config {self.config} --noconfirm -Sy --needed archlinuxcn-keyring", "pacman-key --populate archlinuxcn"]) - with mock.patch.object(phases_impl, "Path", return_value=self.packages), \ - mock.patch.object(phases_impl, "ISO_REGION_PAYLOAD", self.payload / "region"), \ - mock.patch.object(phases_impl, "_package_targets", return_value={"runtime": "omarchy-dev", "settings": "omarchy-settings-dev", "nvim": "omarchy-nvim"}): - for region, extra in (("global", []), ("cn", ["archlinuxcn-keyring", "example-regional-package"])): - ctx = types.SimpleNamespace(omarchy_install={"region": region}) - self.assertEqual(phases_impl._runtime_package_list(ctx), ["omarchy-dev", "base", *extra]) + def test_ships_every_profile_and_bootstraps_its_keyring(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + regions = write_profiles(root) + payload, online, calls = self.run_prepare(regions, root) + self.assertEqual((payload / "regions/cn/timezones").read_text(), (regions / "cn/timezones").read_text()) + self.assertIn("[archlinuxcn]", online.read_text()) + self.assertTrue(online.read_text().startswith("[core]\n")) + self.assertEqual(calls.splitlines(), [ + f"pacman --config {online} --noconfirm -Sy --needed archlinuxcn-keyring", + "pacman-key --populate archlinuxcn", + ]) - def test_global_is_unchanged_without_region_support_in_runtime(self): - shutil.rmtree(self.runtime) - result = self.prepare("global") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(self.other.read_text(), "other\n") - self.assertEqual(self.config.read_bytes(), self.original_config) - self.assertFalse(self.log.exists()) - self.assertFalse((self.payload / "region").exists()) + def settings_package(self, root: Path, with_regions: bool) -> Path: + tree = root / "pkgroot" + (tree / "usr/share/omarchy/default/pacman").mkdir(parents=True) + if with_regions: + write_profiles(root / "src") + (root / "src/regions").rename(tree / "usr/share/omarchy/default/regions") + package = root / "omarchy-settings-1-1-any.pkg.tar.zst" + subprocess.run(["bsdtar", "--zstd", "-cf", str(package), "-C", str(tree), "usr"], check=True) + return package - def test_missing_runtime_support_fails_before_package_operations(self): - (self.runtime / "bin/omarchy-apply-pacman").unlink() - result = self.prepare("cn") - self.assertNotEqual(result.returncode, 0) - self.assertIn("--local-source", result.stderr) - self.assertFalse(self.log.exists()) - self.assertEqual(self.config.read_bytes(), self.original_config) + def test_published_build_reads_profiles_from_the_settings_package(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=True), root) + self.assertEqual((payload / "regions/cn/packages").read_text(), "# keyring first\narchlinuxcn-keyring\nextra-tool\n") + self.assertIn("[archlinuxcn]", online.read_text()) + self.assertIn("pacman-key --populate archlinuxcn", calls) - def test_profile_without_user_defaults_is_supported(self): - result = self.prepare("cn") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse((self.payload / "region/skel").exists()) + def test_settings_package_without_profiles_builds_a_global_iso(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=False), root) + self.assertFalse((payload / "regions").exists()) + self.assertEqual(calls, "") - def test_untrusted_keyring_aborts_without_importing_keys(self): - result = self.prepare("cn", TEST_PACMAN_STATUS="1") - self.assertNotEqual(result.returncode, 0) - self.assertNotIn("pacman-key", self.log.read_text()) + def test_unreadable_settings_package_fails_the_build(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + package = root / "omarchy-settings-1-1-any.pkg.tar.zst" + package.write_text("not an archive") + with self.assertRaises(subprocess.CalledProcessError): + self.run_prepare(package, root) - -class RegionMakeTest(unittest.TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.root = Path(tmp.name) - (self.root / "bin").mkdir() - shutil.copyfile(ROOT / "bin/omarchy-iso-make", self.root / "bin/omarchy-iso-make") - for directory in ("configs/airootfs/root", "configs/airootfs/usr/local/bin", "stubs"): - (self.root / directory).mkdir(parents=True) - (self.root / "configs/profiledef.sh").touch() - self.log = self.root / "docker-args" - stubs = { - "git": '#!/bin/bash\nexit 0\n', - "sudo": '#!/bin/bash\necho "unexpected sudo" >&2\nexit 99\n', - "docker": '''#!/bin/bash -if [[ $1 == version ]]; then exit 0; fi -printf '%s\\n' "$@" > "$TEST_LOG" -for arg in "$@"; do - if [[ $arg == *:/out/ ]]; then touch "${arg%:/out/}/omarchy-test-x86_64.iso"; fi -done -''', - } - for name, content in stubs.items(): - stub = self.root / "stubs" / name - stub.write_text(content) - stub.chmod(0o755) - - def make(self, *args): - env = {key: value for key, value in os.environ.items() if not key.startswith("OMARCHY_")} - return subprocess.run( - ["bash", "bin/omarchy-iso-make", "--keep-pkg-cache", "--no-boot-offer", *args], - cwd=self.root, env={**env, "HOME": str(self.root), "PATH": f"{self.root / 'stubs'}:{env['PATH']}", "TEST_LOG": str(self.log), "TEST_ROOT": str(self.root)}, - capture_output=True, text=True, - ) - - def test_region_is_independent_of_channel_and_names_cache_and_artifact(self): - for flags, channel, ref in (([], "stable", "quattro"), (["--rc"], "rc", "rc"), (["--edge"], "edge", "edge"), (["--dev"], "edge", "dev")): - with self.subTest(channel=ref): - release = self.root / "release" - if release.exists(): - shutil.rmtree(release) - result = self.make(*flags, "--region", "cn") - self.assertEqual(result.returncode, 0, result.stderr) - args = self.log.read_text().splitlines() - self.assertIn("OMARCHY_REGION=cn", args) - self.assertIn(f"OMARCHY_MIRROR={channel}", args) - self.assertIn(f"OMARCHY_ISO_REF={ref}", args) - self.assertIn(f"{self.root}/.cache/omarchy/iso_{channel}-cn/airootfs/var/cache/omarchy:/var/cache/airootfs/var/cache/omarchy", args) - self.assertTrue((release / f"omarchy-test-x86_64-{ref}-cn.iso").exists()) - - def test_default_preserves_existing_global_cache_and_name(self): - result = self.make() - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("OMARCHY_REGION=global", self.log.read_text().splitlines()) - self.assertIn("/iso_stable/", self.log.read_text()) - self.assertTrue((self.root / "release/omarchy-test-x86_64-quattro.iso").exists()) - - def test_build_renames_only_its_own_iso(self): - release = self.root / "release" - release.mkdir() - other = release / "omarchy-other-x86_64.iso" - other.touch() - result = self.make("--region", "cn") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(other.exists()) - self.assertTrue((release / "omarchy-test-x86_64-quattro-cn.iso").exists()) - self.assertEqual(list(release.glob(".build-*")), []) - - def test_local_source_keeps_region_and_mounts_both_checkouts(self): - for name in ("runtime", "packages"): - (self.root / name).mkdir() - result = self.make("--region", "cn", "--local-source", str(self.root / "runtime"), str(self.root / "packages")) - self.assertEqual(result.returncode, 0, result.stderr) - args = self.log.read_text().splitlines() - self.assertIn("OMARCHY_REGION=cn", args) - self.assertIn("OMARCHY_ISO_REF=local", args) - self.assertIn(f"{self.root}/runtime:/omarchy-source:ro", args) - self.assertIn(f"{self.root}/packages:/omarchy-pkgs:ro", args) - self.assertTrue((self.root / "release/omarchy-test-x86_64-local-cn.iso").exists()) - - def test_invalid_regions_fail_without_starting_build(self): - for flags in (("--region",), ("--region", "chn"), ("--region", "CN"), ("--region", "zz"), ("--region", "../cn")): - with self.subTest(flags=flags): - result = self.make(*flags) - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.log.exists()) - self.assertFalse((self.root / "release").exists()) - - - -class ConfiguratorRegionTimezoneTest(unittest.TestCase): - """The configurator hands the cn timezone to the setup form when China is chosen.""" - - def setUp(self): - configurator = (ROOT / "configs/airootfs/root/configurator").read_text() - helper = re.search(r"^region_timezone\(\) \{\n.*?^\}", configurator, re.M | re.S) - self.assertIsNotNone(helper) - self.helper = helper.group(0) - self.configurator = configurator - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.file = Path(tmp.name) / "timezone" - - def region_timezone(self, region): - self.file.write_text("Asia/Shanghai\n") - return subprocess.run( - ["bash", "-c", f"set -euo pipefail\nregion={region}\n{self.helper}\nregion_timezone"], - env={**os.environ, "REGION_TIMEZONE": str(self.file)}, - capture_output=True, text=True, check=True, - ).stdout - - def test_global_offers_no_timezone(self): - self.assertEqual(self.region_timezone("global"), "") - - def test_china_offers_the_profile_timezone(self): - self.assertEqual(self.region_timezone("cn"), "Asia/Shanghai\n") - - def test_user_form_passes_it_to_the_timezone_prompt(self): - self.assertIn('omarchy_prompt_timezone "$(region_timezone)"', self.configurator) + def test_runtime_without_profiles_builds_a_global_iso(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload, online, calls = self.run_prepare(root / "missing", root) + self.assertFalse((payload / "regions").exists()) + self.assertNotIn("archlinuxcn", online.read_text()) + self.assertEqual(calls, "") if __name__ == "__main__":