From 1c912658aa7f2defc7de34a9bf40d4d06b4c1e91 Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Thu, 8 Oct 2026 15:03:31 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat(build):=20bundle=20every?= =?UTF-8?q?=20region=20profile=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :sparkles: one iso serves every region, so regional packages ride in the offline repo without being installed by default - :lock: verify each regional keyring through arch's trust chain before downloading its packages Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g --- builder/build-iso.sh | 18 ++++++++++++++++-- builder/prepare-regions.sh | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 builder/prepare-regions.sh diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 86ffae4..17a50f7 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -142,6 +142,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64" # already in the mirror and we filter them out below. Without it, pacman -Syw # pulls the published omarchy* from the network mirror like any other package. if [[ -d /omarchy-source ]]; then + runtime_root=/omarchy-source base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) setup_form=/omarchy-source/install/provisioning/setup-form.sh else @@ -164,6 +165,9 @@ else # 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 + # Likewise optional: a runtime predating region profiles ships none. + bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/default/regions 2>/dev/null || true + runtime_root=/tmp/omarchy-pkglists/usr/share/omarchy setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh fi @@ -190,6 +194,13 @@ 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 "$runtime_root/default/regions" \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config" + # Collect every package we want available in the offline mirror. declare -a all_packages mapfile -t all_packages < <( @@ -197,6 +208,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" @@ -235,7 +249,7 @@ fi mkdir -p /tmp/offlinedb download_offline_packages() { - pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \ + pacman --config "$online_config" --noconfirm -Syw \ "${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed } @@ -253,7 +267,7 @@ fi # newest version of every cached package name) removes packages that have left # the lists or dependency closure, such as an old Electron major version. if ! resolved_package_files="$( - pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \ + pacman --config "$online_config" --noconfirm \ --dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}" )"; then echo "ERROR: could not resolve the package files required by the offline mirror" >&2 diff --git a/builder/prepare-regions.sh b/builder/prepare-regions.sh new file mode 100644 index 0000000..e7743b0 --- /dev/null +++ b/builder/prepare-regions.sh @@ -0,0 +1,37 @@ +#!/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="$1" +iso_payload="$2" +online_config="$3" + +# A runtime predating region profiles ships none; the ISO then installs +# global targets only, exactly as before. +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 From e121561851eeeb4a20a6d62004387651facb9c48 Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Thu, 8 Oct 2026 15:03:31 -0400 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20feat(install):=20pick=20the=20r?= =?UTF-8?q?egion=20from=20the=20timezone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :sparkles: the timezone the user already chose selects the region, so no new prompt is needed - :lock: populate only the selected region's keys so global installs never trust them - :white_check_mark: cover the mapping, autoinstall override, marker, keyring setup, and builder staging Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g --- configs/airootfs/root/configurator | 10 +- .../share/omarchy-iso/orchestrator/context.py | 8 + .../omarchy-iso/orchestrator/phases_impl.py | 25 +++ .../share/omarchy-iso/orchestrator/region.py | 66 +++++++ test/unit/test_kernel_selection.py | 1 + test/unit/test_region.py | 166 ++++++++++++++++++ 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py create mode 100644 test/unit/test_region.py diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 8e18b59..0d5bbde 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -290,6 +290,14 @@ 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="\nRegion,$region" + fi + # Add manual padding since gum table -p doesn't respect padding echo -e "Field,Value Username,$username @@ -297,7 +305,7 @@ Password,$(printf "%${#password}s" | tr ' ' '*') Full name,${full_name:-[Skipped]} Email address,${email_address:-[Skipped]} Hostname,$hostname -Timezone,$timezone +Timezone,$timezone$region_row Keyboard,$keyboard" | gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 436bfa8..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 @@ -125,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 e30e8c7..83b8487 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -36,6 +36,7 @@ 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 @@ -298,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 @@ -728,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.""" @@ -1135,6 +1152,14 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str def run_system_finalizer(ctx: InstallContext) -> None: + # 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", *keyrings]) + if ctx.defer_provisioning: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] else: 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..ea168d2 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py @@ -0,0 +1,66 @@ +"""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 not 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 region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir()): + raise RuntimeError(f"Unsupported region: {region}") + 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 a366a2e..8c952bf 100644 --- a/test/unit/test_kernel_selection.py +++ b/test/unit/test_kernel_selection.py @@ -108,6 +108,7 @@ class KernelSelectionTest(unittest.TestCase): 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"): diff --git a/test/unit/test_region.py b/test/unit/test_region.py new file mode 100644 index 0000000..6a9bbcd --- /dev/null +++ b/test/unit/test_region.py @@ -0,0 +1,166 @@ +"""The target's region comes from its timezone, using the runtime's profiles.""" + +import json +import os +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 context, phases_impl, region # noqa: E402 + + +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") + (cn / "pacman/mirrorlist.append").write_text("Server = https://example.invalid/$repo/os/$arch\n") + return regions + + +class RegionSelectionTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.regions = write_profiles(self.root) + patcher = mock.patch.object(region, "REGIONS_DIR", self.regions) + patcher.start() + self.addCleanup(patcher.stop) + + 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_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_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"): + with self.subTest(region=bad), self.assertRaises(RuntimeError): + region.resolve_region({"region": bad}, "UTC") + + 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 from_env(self, config: dict) -> context.InstallContext: + config_path = self.root / "config.json" + creds_path = self.root / "creds.json" + config_path.write_text(json.dumps(config)) + creds_path.write_text(json.dumps({"users": [{"username": "test"}]})) + with mock.patch.dict(os.environ, { + "OMARCHY_INSTALL_CONFIG": str(config_path), + "OMARCHY_INSTALL_CREDS": str(creds_path), + "OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"), + }, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"): + return context.InstallContext.from_env() + + def test_context_resolves_region_before_install(self): + self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn") + self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global") + # Deferred installs carry UTC until first boot, so they are global + # unless the autoinstall config names a region. + self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global") + self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn") + with self.assertRaises(RuntimeError): + self.from_env({"omarchy_install": {"region": "zz"}}) + + def test_marker_is_written_only_for_regional_targets(self): + target = self.root / "target" + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global")) + self.assertFalse((target / "etc/omarchy/region").exists()) + + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn")) + marker = target / "etc/omarchy/region" + self.assertEqual(marker.read_text(), "cn\n") + self.assertEqual(marker.stat().st_mode & 0o777, 0o644) + + def test_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[:-1], expected) + self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system") + + +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) + 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_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_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__": + unittest.main() From 9dcfdb035a64fa69aa1e523a1597af04c9d9284d Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Thu, 8 Oct 2026 15:03:31 -0400 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=93=9D=20docs(autoinstall):=20explain?= =?UTF-8?q?=20region=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :memo: unattended installs need to know how to pick a region without a timezone Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1ec23ea..aa8ef7d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,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 From 27b7e76c5ab51c19cd5a741475117efaec2faa05 Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Thu, 8 Oct 2026 15:32:46 -0400 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20fix(build):=20read=20region?= =?UTF-8?q?=20profiles=20from=20settings=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :bug: default/ ships in omarchy-settings, so published builds silently bundled no regions - :white_check_mark: cover the published package path, a package without profiles, and an unreadable one Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g --- builder/build-iso.sh | 18 +++++++--- builder/prepare-regions.sh | 19 ++++++++-- test/unit/test_region.py | 74 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 17a50f7..5007aaf 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -142,7 +142,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 @@ -165,9 +165,17 @@ else # 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 - # Likewise optional: a runtime predating region profiles ships none. - bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/default/regions 2>/dev/null || true - runtime_root=/tmp/omarchy-pkglists/usr/share/omarchy + # 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 @@ -198,7 +206,7 @@ cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh" # 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 "$runtime_root/default/regions" \ +bash /builder/prepare-regions.sh "$regions_source" \ "$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config" # Collect every package we want available in the offline mirror. diff --git a/builder/prepare-regions.sh b/builder/prepare-regions.sh index e7743b0..c668048 100644 --- a/builder/prepare-regions.sh +++ b/builder/prepare-regions.sh @@ -6,12 +6,25 @@ # defaults stay in the runtime, not in a second set of ISO-specific templates. set -euo pipefail -regions="$1" +regions_source="$1" iso_payload="$2" online_config="$3" -# A runtime predating region profiles ships none; the ISO then installs -# global targets only, exactly as before. +# 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 diff --git a/test/unit/test_region.py b/test/unit/test_region.py index 6a9bbcd..6119bc9 100644 --- a/test/unit/test_region.py +++ b/test/unit/test_region.py @@ -7,6 +7,7 @@ import sys import tempfile import types import unittest +from contextlib import ExitStack from pathlib import Path from unittest import mock @@ -26,7 +27,8 @@ def write_profiles(root: Path) -> Path: (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") - (cn / "pacman/mirrorlist.append").write_text("Server = https://example.invalid/$repo/os/$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 @@ -62,7 +64,7 @@ class RegionSelectionTest(unittest.TestCase): 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"): + for bad in ("us", "CN", "..", "cn/../cn", True, 1, ["cn"]): with self.subTest(region=bad), self.assertRaises(RuntimeError): region.resolve_region({"region": bad}, "UTC") @@ -104,6 +106,41 @@ class RegionSelectionTest(unittest.TestCase): 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", []), @@ -153,6 +190,39 @@ class PrepareRegionsTest(unittest.TestCase): "pacman-key --populate archlinuxcn", ]) + 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_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_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_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) + def test_runtime_without_profiles_builds_a_global_iso(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From abc1a7716d452f9bfa49985c9ecde52cd293688c Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Thu, 8 Oct 2026 15:32:46 -0400 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20fix(install):=20reject=20non?= =?UTF-8?q?-string=20region=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :bug: a boolean or number in user_configuration.json crashed with a traceback instead of a configuration error - :white_check_mark: pin regional packages inside the offline mirror mount window Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g --- .../airootfs/usr/share/omarchy-iso/orchestrator/region.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py index ea168d2..9ff8159 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py @@ -37,12 +37,14 @@ def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) - 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 not 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 region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir()): - raise RuntimeError(f"Unsupported region: {region}") + 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 From 31cca48c14f9f7aa59fc20b5ca3e84dc06a8f7fb Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Thu, 8 Oct 2026 16:08:59 -0400 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=90=9B=20fix(build):=20refuse=20profi?= =?UTF-8?q?les=20the=20runtime=20cannot=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - :bug: profiles and the finalizer ship in separate packages, so a skewed pair would give regional targets a keyring without the repositories Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g --- builder/build-iso.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 5007aaf..de1f7f1 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -209,6 +209,17 @@ 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 < <(