Compare commits

...
10 Commits
Author SHA1 Message Date
Kros DaiandClaude Opus 5.5 e3de148897 ✅ test(region): run timezone tests when invoked directly
- ✅ the new class sat below `unittest.main()`, so running the file directly exited before defining it; suite discovery still found it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdXqTwm63rRMwotZ1xqLC8
2026-10-06 21:10:59 -04:00
Kros DaiandClaude Opus 5.5 d2e37df6a4 ✨ feat(region): preselect the profile timezone in setup
- ✨ regional ISOs pass their profile's timezone to the shared setup form, so an offline China install opens on `Asia/Shanghai`
- ✅ cover the payload copy and the configurator hand-off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdXqTwm63rRMwotZ1xqLC8
2026-10-06 20:57:31 -04:00
Kros DaiandClaude Opus 5.5 81106d04e8 📝 docs(iso): scope regional builds to the architecture
- 📝 describe the cn build as repository defaults plus optional new-user staging, with locale and input to follow

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

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

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

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

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

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

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

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 20:57:29 -04:00
8 changed files with 458 additions and 13 deletions

No files matched your search

+14
View File
@@ -28,6 +28,20 @@ Despite the local folder name, the first argument is the Omarchy source checkout
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
### Regional builds
`--region cn` adds China bootstrap defaults independently of the channel. The default, `--region global`, preserves the existing package set, mirrors, cache paths, and artifact names. Supported country profiles use lowercase ISO 3166-1 alpha-2 codes; currently only `cn` is implemented.
For an unpublished regional runtime, use matching local source and package-recipe checkouts:
```bash
./bin/omarchy-iso-make --region cn --no-boot-offer --local-source /path/to/omarchy /path/to/omarchy-pkgs
```
China builds keep the channel mirror first, append the USTC Arch mirror, and add ArchLinuxCN with its verified keyring. These are a practical bridge until Omarchy runs its own mirror in China; USTC is rolling, not a matching stable/RC snapshot, so fallback version compatibility is not guaranteed. The region architecture also stages optional new-user defaults before user creation, including deferred setup, without touching existing homes. The China profile ships repository defaults and preselects `Asia/Shanghai` as the timezone; language and input-method defaults come as separate changes. See `docs/regions.md` in the companion runtime checkout.
Non-global cache and ISO names gain a `-cn` suffix. A runtime without the selected profile fails the build. These builds require an x86_64 Arch container environment; packaging checks on another architecture do not substitute for a fresh build, offline installation, and graphical acceptance run.
## Autoinstall ## 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. The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
+36 -8
View File
@@ -6,8 +6,17 @@ NO_CACHE=""
KEEP_PKG_CACHE="" KEEP_PKG_CACHE=""
LOCAL_OMARCHY_PATH="" LOCAL_OMARCHY_PATH=""
LOCAL_PKGS_PATH="" LOCAL_PKGS_PATH=""
OMARCHY_REGION="${OMARCHY_REGION:-global}"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in 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)
NO_CACHE=1 NO_CACHE=1
shift shift
@@ -57,12 +66,17 @@ while [[ $# -gt 0 ]]; do
;; ;;
*) *)
echo "Unknown option: $1" echo "Unknown option: $1"
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]" echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--region global|cn] [--local-source <omarchy-checkout> <pkgs-checkout>]"
exit 1 exit 1
;; ;;
esac esac
done 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 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_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; } [[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; }
@@ -116,6 +130,10 @@ BUILD_SCRIPT="builder/build-iso.sh"
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
BUILD_RELEASE_PATH="$BUILD_ROOT/release" BUILD_RELEASE_PATH="$BUILD_ROOT/release"
mkdir -p "$BUILD_RELEASE_PATH" 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_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
@@ -125,10 +143,11 @@ DOCKER_ARGS=(
--privileged --privileged
-e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF" -e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF"
-e "OMARCHY_MIRROR=$OMARCHY_MIRROR" -e "OMARCHY_MIRROR=$OMARCHY_MIRROR"
-e "OMARCHY_REGION=$OMARCHY_REGION"
-e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}" -e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}"
-e "HOST_UID=$(id -u)" -e "HOST_UID=$(id -u)"
-e "HOST_GID=$(id -g)" -e "HOST_GID=$(id -g)"
-v "$BUILD_RELEASE_PATH/:/out/" -v "$BUILD_OUT/:/out/"
-v "$BUILD_ROOT/$BUILD_SCRIPT:/$BUILD_SCRIPT:ro" -v "$BUILD_ROOT/$BUILD_SCRIPT:/$BUILD_SCRIPT:ro"
-v "$BUILD_ROOT/archiso:/archiso:ro" -v "$BUILD_ROOT/archiso:/archiso:ro"
-v "$BUILD_ROOT/builder:/builder:ro" -v "$BUILD_ROOT/builder:/builder:ro"
@@ -146,10 +165,14 @@ if [[ -d /var/cache/pacman/pkg ]]; then
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg") DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
fi fi
# Mount the build cache directory where packages are downloaded. Channels use # Channels and regions must not share an offline mirror cache. Keep the
# different package snapshots and must never share an offline mirror cache. # existing global cache path and artifact suffix unchanged.
region_suffix=""
if [[ $OMARCHY_REGION != "global" ]]; then
region_suffix="-$OMARCHY_REGION"
fi
if [[ -z "$NO_CACHE" ]]; then if [[ -z "$NO_CACHE" ]]; then
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy" OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}${region_suffix}/airootfs/var/cache/omarchy"
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR" mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy") DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
else else
@@ -168,9 +191,14 @@ fi
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT "${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1) built_isos=("$BUILD_OUT"/*.iso)
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso" if (( ${#built_isos[@]} != 1 )) || [[ ! -f ${built_isos[0]} ]]; then
mv -f "$latest_iso" "$iso_ref" echo "Error: expected exactly one ISO in $BUILD_OUT" >&2
exit 1
fi
built_iso_name=$(basename "${built_isos[0]}" .iso)
iso_ref="$BUILD_RELEASE_PATH/$built_iso_name-${OMARCHY_ISO_REF}${region_suffix}.iso"
mv -f "${built_isos[0]}" "$iso_ref"
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
echo echo
+27 -2
View File
@@ -4,6 +4,11 @@ set -e
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" 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 # Edge, dev, and local-source ISOs install the dev packages explicitly. Those
# package recipes track the quattro branch. This avoids relying on pacman's # package recipes track the quattro branch. This avoids relying on pacman's
@@ -67,6 +72,7 @@ cp -r /configs/* "$build_cache_dir/"
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror" echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror"
echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref" echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref"
echo "$OMARCHY_REGION" > "$build_cache_dir/airootfs/root/omarchy_region"
cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <<EOF cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <<EOF
OMARCHY_RUNTIME_PACKAGE=$OMARCHY_RUNTIME_PACKAGE OMARCHY_RUNTIME_PACKAGE=$OMARCHY_RUNTIME_PACKAGE
OMARCHY_SETTINGS_PACKAGE=$OMARCHY_SETTINGS_PACKAGE OMARCHY_SETTINGS_PACKAGE=$OMARCHY_SETTINGS_PACKAGE
@@ -80,6 +86,7 @@ if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
echo "built_at=$(date -Is)" echo "built_at=$(date -Is)"
echo "ref=$OMARCHY_ISO_REF" echo "ref=$OMARCHY_ISO_REF"
echo "mirror=$OMARCHY_MIRROR" echo "mirror=$OMARCHY_MIRROR"
echo "region=$OMARCHY_REGION"
echo "runtime_package=$OMARCHY_RUNTIME_PACKAGE" echo "runtime_package=$OMARCHY_RUNTIME_PACKAGE"
echo "settings_package=$OMARCHY_SETTINGS_PACKAGE" echo "settings_package=$OMARCHY_SETTINGS_PACKAGE"
echo "nvim_package=$OMARCHY_NVIM_PACKAGE" echo "nvim_package=$OMARCHY_NVIM_PACKAGE"
@@ -139,6 +146,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# already in the mirror and we filter them out below. Without it, pacman -Syw # 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. # pulls the published omarchy* from the network mirror like any other package.
if [[ -d /omarchy-source ]]; then if [[ -d /omarchy-source ]]; then
runtime_root=/omarchy-source
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
setup_form=/omarchy-source/install/provisioning/setup-form.sh setup_form=/omarchy-source/install/provisioning/setup-form.sh
else else
@@ -155,6 +163,14 @@ else
fi fi
mkdir -p /tmp/omarchy-pkglists 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 bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
runtime_root=/tmp/omarchy-pkglists/usr/share/omarchy
if [[ $OMARCHY_REGION != "global" ]]; then
if ! bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists \
"usr/share/omarchy/default/regions/$OMARCHY_REGION" usr/share/omarchy/bin/omarchy-apply-pacman; then
echo "ERROR: runtime lacks region support; publish a matching runtime or use --local-source." >&2
exit 1
fi
fi
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages) 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 # 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 # it can't find, so asking for this alongside the package lists would abort the
@@ -168,6 +184,15 @@ mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[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" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
# Work on writable copies, leaving the mounted runtime and ISO configs intact.
online_config=/tmp/omarchy-pacman-online.conf
cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config"
bash /builder/prepare-region.sh "$OMARCHY_REGION" "$runtime_root" "$build_cache_dir/airootfs" "$online_config"
base_pkg_lists=(
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
)
# The configurator's setup form comes from the runtime this ISO bundles, so the # 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 # 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 # disagree. A runtime predating the split ships no such file, which would leave
@@ -232,7 +257,7 @@ fi
mkdir -p /tmp/offlinedb mkdir -p /tmp/offlinedb
download_offline_packages() { download_offline_packages() {
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \ pacman --config "$online_config" --noconfirm -Syw \
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed "${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
} }
@@ -250,7 +275,7 @@ fi
# newest version of every cached package name) removes packages that have left # newest version of every cached package name) removes packages that have left
# the lists or dependency closure, such as an old Electron major version. # the lists or dependency closure, such as an old Electron major version.
if ! resolved_package_files="$( if ! resolved_package_files="$(
pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \ pacman --config "$online_config" --noconfirm \
--dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}" --dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}"
)"; then )"; then
echo "ERROR: could not resolve the package files required by the offline mirror" >&2 echo "ERROR: could not resolve the package files required by the offline mirror" >&2
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Add the selected runtime's regional payload to the ISO. Repository defaults
# stay in the runtime package, not in a second set of ISO-specific templates.
set -euo pipefail
region="$1"
runtime="$2"
iso_root="$3"
online_config="$4"
case "$region" in
global) exit 0 ;;
cn) ;;
*) echo "Unsupported region: $region" >&2; exit 1 ;;
esac
profile="$runtime/default/regions/$region"
if [[ ! -f $runtime/bin/omarchy-apply-pacman || ! -f $profile/packages ||
! -f $profile/pacman/pacman.conf.append || ! -f $profile/pacman/mirrorlist.append ]]; then
echo "Runtime does not support region '$region'; publish a matching runtime or use --local-source." >&2
exit 1
fi
# This is the target package list used both for download resolution and by the
# installer. Merely putting extra packages in the cache would not install them.
payload="$iso_root/usr/share/omarchy-iso"
cp -a "$profile" "$payload/region"
printf '\n' >> "$payload/omarchy-base.packages"
cat "$profile/packages" >> "$payload/omarchy-base.packages"
# Only the community repository is needed for regional package downloads.
# Keep the channel's build mirrors intact; target mirrors are restored later.
printf '\n' >> "$online_config"
cat "$profile/pacman/pacman.conf.append" >> "$online_config"
# The keyring package must be signed by a key already trusted by Arch. Do not
# bootstrap trust from an unchecked keyserver response or disable signatures.
# https://github.com/archlinuxcn/repo#usage
pacman --config "$online_config" --noconfirm -Sy --needed archlinuxcn-keyring
pacman-key --populate archlinuxcn
+11 -1
View File
@@ -24,6 +24,16 @@ if [[ ! -r $SETUP_FORM ]]; then
fi fi
source "$SETUP_FORM" source "$SETUP_FORM"
# A regional ISO carries its profile here (builder/prepare-region.sh copies it);
# the form preselects the profile's timezone when there is no geo guess, which
# is every offline install.
REGION_TIMEZONE="${REGION_TIMEZONE:-/usr/share/omarchy-iso/region/timezone}"
region_timezone() {
if [[ -r $REGION_TIMEZONE ]]; then
head -n 1 "$REGION_TIMEZONE"
fi
}
# Partition creation helpers, shared with test/partition-numbering-test.sh so # Partition creation helpers, shared with test/partition-numbering-test.sh so
# the numbering logic can be exercised without a real disk. # the numbering logic can be exercised without a real disk.
DISK_PARTITIONING="${DISK_PARTITIONING:-/usr/share/omarchy-iso/disk-partitioning.sh}" DISK_PARTITIONING="${DISK_PARTITIONING:-/usr/share/omarchy-iso/disk-partitioning.sh}"
@@ -270,7 +280,7 @@ user_form() {
omarchy_prompt_identity || return $? omarchy_prompt_identity || return $?
omarchy_prompt_hostname || return $? omarchy_prompt_hostname || return $?
omarchy_prompt_timezone || return $? omarchy_prompt_timezone "$(region_timezone)" || return $?
} }
# Returns 0 on confirmed account details, or as soon as the keyboard screen # Returns 0 on confirmed account details, or as soon as the keyboard screen
@@ -39,6 +39,36 @@ from .keyboard import configure_keyboard
from .ui import error, info from .ui import error, info
ISO_REGION_FILE = Path("/root/omarchy_region")
ISO_REGION_PAYLOAD = Path("/usr/share/omarchy-iso/region")
def _iso_region() -> str:
region = ISO_REGION_FILE.read_text().strip() if ISO_REGION_FILE.exists() else "global"
if region not in {"global", "cn"}:
raise RuntimeError(f"Unsupported ISO region: {region}")
return region
def _stage_region_defaults(target: Path) -> None:
"""Seed the target before useradd, including deferred first-boot users.
Region is a bootstrap default, not a locale or a user-level policy.
Existing home directories are never rewritten by this step.
"""
region = _iso_region()
if region != "global":
if not ISO_REGION_PAYLOAD.is_dir():
raise RuntimeError(f"Missing ISO payload for region: {region}")
skel = ISO_REGION_PAYLOAD / "skel"
if skel.is_dir():
shutil.copytree(skel, target / "etc/skel", dirs_exist_ok=True)
marker = target / "etc/omarchy/region"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(region + "\n")
marker.chmod(0o644)
# Package targets are written by builder/build-iso.sh. Stable ISOs use the # 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 # stable package names, while dev/local-source ISOs install the dev package
# names explicitly instead of relying on provides=omarchy resolution. # names explicitly instead of relying on provides=omarchy resolution.
@@ -287,6 +317,7 @@ def arch_install_system(ctx: InstallContext) -> None:
_install_early_packages(installer) _install_early_packages(installer)
_configure_limine_boot(ctx, installer, config) _configure_limine_boot(ctx, installer, config)
_stage_region_defaults(ctx.target)
info("› creating user (with /etc/skel populated)") info("› creating user (with /etc/skel populated)")
if config.auth_config and config.auth_config.users: if config.auth_config and config.auth_config.users:
installer.create_users(config.auth_config.users) installer.create_users(config.auth_config.users)
@@ -1135,6 +1166,13 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
def run_system_finalizer(ctx: InstallContext) -> None: def run_system_finalizer(ctx: InstallContext) -> None:
# The runtime package transaction has installed the regional keyring from
# the ISO. Initialize the target's trust database before enabling the online
# community repository; the build container's GPG state is not inherited.
if _iso_region() == "cn":
_run_target_setup_command(ctx, ["pacman-key", "--init"])
_run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", "archlinuxcn"])
if ctx.defer_provisioning: if ctx.defer_provisioning:
cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"]
else: else:
+4 -2
View File
@@ -93,6 +93,7 @@ class KernelSelectionTest(unittest.TestCase):
events = [] events = []
installer = mock.MagicMock() installer = mock.MagicMock()
installer.minimal_installation.side_effect = lambda **kwargs: events.append("base") installer.minimal_installation.side_effect = lambda **kwargs: events.append("base")
installer.create_users.side_effect = lambda users: events.append("user")
def install_packages(packages): def install_packages(packages):
events.append(packages) events.append(packages)
@@ -102,7 +103,7 @@ class KernelSelectionTest(unittest.TestCase):
installer.add_additional_packages.side_effect = install_packages installer.add_additional_packages.side_effect = install_packages
config = types.SimpleNamespace( config = types.SimpleNamespace(
kernels=kernels, locale_config=None, mirror_config=None, kernels=kernels, locale_config=None, mirror_config=None,
swap=None, auth_config=None, app_config=None, timezone=None, swap=None, auth_config=types.SimpleNamespace(users=["alice"]), app_config=None, timezone=None,
ntp=False, hostname="test", pacman_config="/etc/pacman.conf", ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
) )
ctx = types.SimpleNamespace( ctx = types.SimpleNamespace(
@@ -116,6 +117,7 @@ class KernelSelectionTest(unittest.TestCase):
unmount = stack.enter_context(mock.patch.object(phases_impl, "_unmount_offline_package_cache")) 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, "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, "_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, "_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, "is_pre_mount", return_value=True, create=True))
stack.enter_context(mock.patch.object(phases_impl.arch, "root_user", return_value=None, create=True)) stack.enter_context(mock.patch.object(phases_impl.arch, "root_user", return_value=None, create=True))
@@ -128,7 +130,7 @@ class KernelSelectionTest(unittest.TestCase):
phases_impl.arch_install_system(ctx) phases_impl.arch_install_system(ctx)
expected = ["base", [f"{kernel}-headers" for kernel in kernels]] expected = ["base", [f"{kernel}-headers" for kernel in kernels]]
if not fail_headers: if not fail_headers:
expected += ["early", ["omarchy"]] expected += ["early", "region", "user", ["omarchy"]]
self.assertEqual(events, expected) self.assertEqual(events, expected)
unmask.assert_called_once_with(ctx) unmask.assert_called_once_with(ctx)
unmount.assert_called_once_with(ctx) unmount.assert_called_once_with(ctx)
+287
View File
@@ -0,0 +1,287 @@
"""Region bootstrap contracts without Docker, pacman, or a running guest."""
import os
import re
import shutil
import subprocess
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
sys.modules.setdefault("orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter"))
from orchestrator import phases_impl
class RegionTargetTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.target = self.root / "target"
self.marker = self.root / "omarchy_region"
self.payload = self.root / "payload"
for name, value in (("ISO_REGION_FILE", self.marker), ("ISO_REGION_PAYLOAD", self.payload)):
patch = mock.patch.object(phases_impl, name, value)
patch.start()
self.addCleanup(patch.stop)
def test_global_does_not_need_a_profile_or_change_user_defaults(self):
phases_impl._stage_region_defaults(self.target)
self.assertEqual((self.target / "etc/omarchy/region").read_text(), "global\n")
self.assertFalse((self.target / "etc/skel").exists())
def test_cn_seeds_skel_without_touching_existing_users_or_locale(self):
self.marker.write_text("cn\n")
profile = self.payload / "skel/.config/example/defaults"
profile.parent.mkdir(parents=True)
profile.write_text("regional defaults\n")
existing = self.target / "home/alice/.config/example/defaults"
existing.parent.mkdir(parents=True)
existing.write_text("personal settings\n")
skel = self.target / "etc/skel"
skel.mkdir(parents=True)
(skel / ".bashrc").write_text("original\n")
(self.target / "etc/locale.conf").write_text("LANG=en_US.UTF-8\n")
phases_impl._stage_region_defaults(self.target)
self.assertEqual((skel / ".config/example/defaults").read_text(), "regional defaults\n")
self.assertEqual((skel / ".bashrc").read_text(), "original\n")
self.assertEqual(existing.read_text(), "personal settings\n")
self.assertEqual((self.target / "etc/locale.conf").read_text(), "LANG=en_US.UTF-8\n")
self.assertEqual((self.target / "etc/omarchy/region").read_text(), "cn\n")
def test_bad_region_and_missing_payload_fail_before_target_changes(self):
for region in ("CN", "chn", "zz", "../cn", "", "cn"):
with self.subTest(region=region):
self.marker.write_text(region)
with self.assertRaises(RuntimeError):
phases_impl._stage_region_defaults(self.target)
self.assertFalse(self.target.exists())
def test_cn_keyring_is_initialized_before_finalization_for_both_install_modes(self):
self.marker.write_text("cn\n")
for deferred in (False, True):
ctx = types.SimpleNamespace(target=self.target, username="alice", defer_provisioning=deferred)
with mock.patch.object(phases_impl, "_run_target_setup_command") as run, \
mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \
mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"):
phases_impl.run_system_finalizer(ctx)
commands = [call.args[1] for call in run.call_args_list]
self.assertEqual(commands[:2], [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]])
expected = ["--defer-provisioning"] if deferred else ["--install-user", "alice"]
self.assertEqual(commands[2], ["/usr/bin/omarchy-apply-system", *expected, "--first-install"])
def test_keyring_failure_prevents_enabling_online_repositories(self):
self.marker.write_text("cn\n")
ctx = types.SimpleNamespace(target=self.target, username="alice", defer_provisioning=False)
with mock.patch.object(phases_impl, "_run_target_setup_command", side_effect=RuntimeError("keyring failed")) as run:
with self.assertRaisesRegex(RuntimeError, "keyring failed"):
phases_impl.run_system_finalizer(ctx)
self.assertEqual(run.call_count, 1)
class RegionBuildTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.runtime = self.root / "runtime"
self.profile = self.runtime / "default/regions/cn"
(self.profile / "pacman").mkdir(parents=True)
(self.runtime / "bin").mkdir()
(self.runtime / "bin/omarchy-apply-pacman").touch()
(self.profile / "packages").write_text("archlinuxcn-keyring\nexample-regional-package\n")
(self.profile / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n")
(self.profile / "pacman/mirrorlist.append").write_text("Server = https://mirrors.ustc.edu.cn/archlinux/$repo/os/$arch\n")
(self.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.config = self.root / "pacman.conf"
shutil.copyfile(ROOT / "configs/pacman-online-stable.conf", self.config)
self.original_config = self.config.read_bytes()
self.bin = self.root / "bin"
self.bin.mkdir()
self.log = self.root / "commands"
for command in ("pacman", "pacman-key"):
stub = self.bin / command
stub.write_text('#!/bin/bash\nprintf "%s %s\\n" "${0##*/}" "$*" >> "$TEST_LOG"\nif [[ ${0##*/} == pacman ]]; then exit "${TEST_PACMAN_STATUS:-0}"; fi\n')
stub.chmod(0o755)
def prepare(self, region, **env):
return subprocess.run(
["bash", str(ROOT / "builder/prepare-region.sh"), region, str(self.runtime), str(self.iso), str(self.config)],
env={**os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", "TEST_LOG": str(self.log), **env},
capture_output=True, text=True,
)
def test_cn_adds_real_install_targets_and_verifies_keyring_first(self):
result = self.prepare("cn")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.packages.read_text(), "base\n\narchlinuxcn-keyring\nexample-regional-package\n")
self.assertEqual(self.config.read_bytes(), self.original_config + b"\n[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n")
self.assertEqual((self.payload / "region/packages").read_text(), "archlinuxcn-keyring\nexample-regional-package\n")
self.assertEqual((self.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, "_package_targets", return_value={"runtime": "omarchy-dev", "settings": "omarchy-settings-dev", "nvim": "omarchy-nvim"}):
self.assertEqual(phases_impl._runtime_package_list(None), ["omarchy-dev", "base", "archlinuxcn-keyring", "example-regional-package"])
def test_global_is_unchanged_without_region_support_in_runtime(self):
shutil.rmtree(self.runtime)
result = self.prepare("global")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.packages.read_text(), "base\n")
self.assertEqual(self.config.read_bytes(), self.original_config)
self.assertFalse(self.log.exists())
self.assertFalse((self.payload / "region").exists())
def test_missing_runtime_support_fails_before_package_operations(self):
(self.runtime / "bin/omarchy-apply-pacman").unlink()
result = self.prepare("cn")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--local-source", result.stderr)
self.assertFalse(self.log.exists())
self.assertEqual(self.config.read_bytes(), self.original_config)
def test_profile_without_user_defaults_is_supported(self):
result = self.prepare("cn")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.payload / "region/skel").exists())
def test_untrusted_keyring_aborts_without_importing_keys(self):
result = self.prepare("cn", TEST_PACMAN_STATUS="1")
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("pacman-key", self.log.read_text())
class RegionMakeTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
(self.root / "bin").mkdir()
shutil.copyfile(ROOT / "bin/omarchy-iso-make", self.root / "bin/omarchy-iso-make")
for directory in ("configs/airootfs/root", "configs/airootfs/usr/local/bin", "stubs"):
(self.root / directory).mkdir(parents=True)
(self.root / "configs/profiledef.sh").touch()
self.log = self.root / "docker-args"
stubs = {
"git": '#!/bin/bash\nexit 0\n',
"sudo": '#!/bin/bash\necho "unexpected sudo" >&2\nexit 99\n',
"docker": '''#!/bin/bash
if [[ $1 == version ]]; then exit 0; fi
printf '%s\\n' "$@" > "$TEST_LOG"
for arg in "$@"; do
if [[ $arg == *:/out/ ]]; then touch "${arg%:/out/}/omarchy-test-x86_64.iso"; fi
done
''',
}
for name, content in stubs.items():
stub = self.root / "stubs" / name
stub.write_text(content)
stub.chmod(0o755)
def make(self, *args):
env = {key: value for key, value in os.environ.items() if not key.startswith("OMARCHY_")}
return subprocess.run(
["bash", "bin/omarchy-iso-make", "--keep-pkg-cache", "--no-boot-offer", *args],
cwd=self.root, env={**env, "HOME": str(self.root), "PATH": f"{self.root / 'stubs'}:{env['PATH']}", "TEST_LOG": str(self.log), "TEST_ROOT": str(self.root)},
capture_output=True, text=True,
)
def test_region_is_independent_of_channel_and_names_cache_and_artifact(self):
for flags, channel, ref in (([], "stable", "quattro"), (["--rc"], "rc", "rc"), (["--edge"], "edge", "edge"), (["--dev"], "edge", "dev")):
with self.subTest(channel=ref):
release = self.root / "release"
if release.exists():
shutil.rmtree(release)
result = self.make(*flags, "--region", "cn")
self.assertEqual(result.returncode, 0, result.stderr)
args = self.log.read_text().splitlines()
self.assertIn("OMARCHY_REGION=cn", args)
self.assertIn(f"OMARCHY_MIRROR={channel}", args)
self.assertIn(f"OMARCHY_ISO_REF={ref}", args)
self.assertIn(f"{self.root}/.cache/omarchy/iso_{channel}-cn/airootfs/var/cache/omarchy:/var/cache/airootfs/var/cache/omarchy", args)
self.assertTrue((release / f"omarchy-test-x86_64-{ref}-cn.iso").exists())
def test_default_preserves_existing_global_cache_and_name(self):
result = self.make()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("OMARCHY_REGION=global", self.log.read_text().splitlines())
self.assertIn("/iso_stable/", self.log.read_text())
self.assertTrue((self.root / "release/omarchy-test-x86_64-quattro.iso").exists())
def test_build_renames_only_its_own_iso(self):
release = self.root / "release"
release.mkdir()
other = release / "omarchy-other-x86_64.iso"
other.touch()
result = self.make("--region", "cn")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(other.exists())
self.assertTrue((release / "omarchy-test-x86_64-quattro-cn.iso").exists())
self.assertEqual(list(release.glob(".build-*")), [])
def test_local_source_keeps_region_and_mounts_both_checkouts(self):
for name in ("runtime", "packages"):
(self.root / name).mkdir()
result = self.make("--region", "cn", "--local-source", str(self.root / "runtime"), str(self.root / "packages"))
self.assertEqual(result.returncode, 0, result.stderr)
args = self.log.read_text().splitlines()
self.assertIn("OMARCHY_REGION=cn", args)
self.assertIn("OMARCHY_ISO_REF=local", args)
self.assertIn(f"{self.root}/runtime:/omarchy-source:ro", args)
self.assertIn(f"{self.root}/packages:/omarchy-pkgs:ro", args)
self.assertTrue((self.root / "release/omarchy-test-x86_64-local-cn.iso").exists())
def test_invalid_regions_fail_without_starting_build(self):
for flags in (("--region",), ("--region", "chn"), ("--region", "CN"), ("--region", "zz"), ("--region", "../cn")):
with self.subTest(flags=flags):
result = self.make(*flags)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.log.exists())
self.assertFalse((self.root / "release").exists())
class ConfiguratorRegionTimezoneTest(unittest.TestCase):
"""The configurator hands the ISO's regional timezone to the setup form."""
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):
return subprocess.run(
["bash", "-c", f"set -euo pipefail\n{self.helper}\nregion_timezone"],
env={**os.environ, "REGION_TIMEZONE": str(self.file)},
capture_output=True, text=True, check=True,
).stdout
def test_global_iso_offers_no_timezone(self):
self.assertEqual(self.region_timezone(), "")
def test_regional_iso_offers_its_profile_timezone(self):
self.file.write_text("Asia/Shanghai\n")
self.assertEqual(self.region_timezone(), "Asia/Shanghai\n")
def test_user_form_passes_it_to_the_timezone_prompt(self):
self.assertIn('omarchy_prompt_timezone "$(region_timezone)"', self.configurator)
if __name__ == "__main__":
unittest.main()