diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 3e7bf63ee..13a37f08f 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -848,7 +848,7 @@ drive_provision_owner() { capture_console "success-provision-07-review" press ret - # Finalization (offline Node unpack, LUKS re-key on encrypted installs) + # Finalization (user setup, LUKS re-key on encrypted installs) # runs before SDDM appears. capture_console "success-provision-08-finalizing" } diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 899e7f8da..6a764f5d1 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -312,20 +312,6 @@ if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then fi fi -# Node.js binary for offline mise install, matched to the target architecture. -NODE_DIST_URL="https://nodejs.org/dist/latest" -NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt") -node_platform=linux-x64 -[[ $OMARCHY_ARCH == aarch64 ]] && node_platform=linux-arm64 -if ! IFS=$'\t' read -r NODE_FILENAME NODE_SHA < <(select_node_release "$node_platform" <<<"$NODE_SHASUMS"); then - echo "ERROR: could not find Node.js $node_platform release metadata" >&2 - exit 1 -fi -curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME" -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/" - # 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 @@ -362,6 +348,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$live_packages_file" if [[ -d /omarchy-source ]]; then base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) setup_form=/omarchy-source/install/provisioning/setup-form.sh + mise_work=/omarchy-source/install/user/mise-work.sh else # Pull the same package lists out of the freshly-downloaded Omarchy runtime # package so we don't need a local checkout in the non-local-source path. @@ -383,6 +370,8 @@ else # actionable error below. bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh + bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/user/mise-work.sh 2>/dev/null || true + mise_work=/tmp/omarchy-pkglists/usr/share/omarchy/install/user/mise-work.sh # The aarch64 list, each platform's own and the x86_64-only one. Runtimes # from before the platform lists ship none, so each is optional. for platform_list in 'omarchy-aarch64*.packages' omarchy-x86_64-only.packages; do @@ -506,6 +495,25 @@ if [[ ! -f $setup_form ]]; then fi cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh" +# Omarchy installs Node lazily through mise since omacom/omarchy#15876. A +# runtime from before it unpacks a bundled Node tarball during setup and stops +# the install without one, so it still gets the build for its architecture. +# Remove this once every channel's runtime installs Node lazily. +if grep -qF /opt/packages "$mise_work" 2>/dev/null; then + NODE_DIST_URL="https://nodejs.org/dist/latest" + NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt") + node_platform=linux-x64 + [[ $OMARCHY_ARCH == aarch64 ]] && node_platform=linux-arm64 + if ! IFS=$'\t' read -r NODE_FILENAME NODE_SHA < <(select_node_release "$node_platform" <<<"$NODE_SHASUMS"); then + echo "ERROR: could not find Node.js $node_platform release metadata" >&2 + exit 1 + fi + curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME" + 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/" +fi + # Collect every package we want available in the offline mirror. declare -a all_packages mapfile -t all_packages < <( 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 0d71690cd..1a3053f63 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -1099,8 +1099,8 @@ def _debug_run(ctx: InstallContext, cmd: list[str]) -> None: # ───────────────────────────────────────────────────────────────────────────── # Target setup phases: # 1. point the target at the offline pacman.conf -# 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman -# and bundled language runtimes +# 2. bind-mount the offline mirror into /mnt for target pacman, and the +# bundled Node for a runtime from before Omarchy installed Node lazily # 3. arch-chroot as root → omarchy-apply-system --first-install # 4. arch-chroot as user → omarchy-provision-user --first-install # ───────────────────────────────────────────────────────────────────────────── @@ -1113,8 +1113,10 @@ def _prepare_target_setup(ctx: InstallContext) -> None: bind_mounts = [ ("/var/cache/omarchy/mirror/offline", "/var/cache/omarchy/mirror/offline"), - ("/opt/packages", "/opt/packages"), ] + # build-iso.sh bundles Node only for a runtime whose setup still unpacks it. + if Path("/opt/packages").is_dir(): + bind_mounts.append(("/opt/packages", "/opt/packages")) ctx.state.setdefault("bind_mounts", []) mounted = set(ctx.state["bind_mounts"]) for src, dst in bind_mounts: @@ -1250,12 +1252,10 @@ def run_system_finalizer(ctx: InstallContext) -> None: # stage_provisioning_state: produce the on-disk "provisioning state" the runtime's first-boot # setup (omarchy-provision-owner) and factory reset (omarchy-system-factory-reset) consume. # -# Every install stashes the bundled Node tarball in /var/lib/omarchy/provisioning/ -# so a later factory reset can finalize the new owner's user offline. deferred-provisioning -# installs additionally arm the first-boot setup service and, on encrypted -# targets, stage the throwaway LUKS passphrase: the keyfile embedded in the -# initramfs auto-unlocks boot during the provisioning window, and first-boot setup -# re-keys the volume to the owner's password and removes it. +# Deferred-provisioning installs arm the first-boot setup service and, on +# encrypted targets, stage the throwaway LUKS passphrase: the keyfile embedded +# in the initramfs auto-unlocks boot during the provisioning window, and +# first-boot setup re-keys the volume to the owner's password and removes it. # # Runs before finalize_limine_boot so the cryptkey cmdline drop-in and the # keyfile land in the final UKI build. @@ -1263,18 +1263,14 @@ def run_system_finalizer(ctx: InstallContext) -> None: PROVISION_STATE_DIR = "var/lib/omarchy/provisioning" PROVISION_KEYFILE = "etc/omarchy/provisioning.key" -NODE_PACKAGES_DIR = Path("/opt/packages") def stage_provisioning_state(ctx: InstallContext) -> None: - # World-readable: first-boot finalization reads the Node tarball as the - # new user. The only secret inside (luks-key) is itself 0600 root. + # The only secret inside (luks-key) is itself 0600 root. provisioning_dir = ctx.target / PROVISION_STATE_DIR provisioning_dir.mkdir(parents=True, exist_ok=True) provisioning_dir.chmod(0o755) - _stage_node_tarball(ctx, provisioning_dir) - if not ctx.defer_provisioning: return @@ -1303,31 +1299,6 @@ def stage_provisioning_state(ctx: InstallContext) -> None: _stage_provisioning_luks_unlock(ctx, provisioning_dir) -# Node.js names its builds after the CPU: build-iso.sh bundles the one the -# live ISO (and so the target) runs on. -def _node_platform() -> str: - return "linux-arm64" if platform.machine() == "aarch64" else "linux-x64" - - -def _stage_node_tarball(ctx: InstallContext, provisioning_dir) -> None: - tarballs = sorted(NODE_PACKAGES_DIR.glob(f"node-v*-{_node_platform()}.tar.gz")) - if not tarballs: - # Hard error on every install, not just deferred-provisioning installs: the stash is what lets a - # later factory reset finalize the next owner offline, and an ISO - # build always bundles the tarball — its absence means a broken build. - raise RuntimeError( - f"no bundled Node tarball in {NODE_PACKAGES_DIR} — first-boot setup " - "and factory reset could not finalize a user offline" - ) - - packages_dir = provisioning_dir / "packages" - packages_dir.mkdir(parents=True, exist_ok=True) - target_tarball = packages_dir / tarballs[0].name - if not target_tarball.exists(): - info("› stashing Node tarball for offline first-boot setup") - shutil.copy2(tarballs[0], target_tarball) - - def _provision_install_encrypted(ctx: InstallContext) -> bool: if _storage_intent(ctx).get("luks_uuid"): return True @@ -2034,9 +2005,6 @@ def _validate_provisioning_state(ctx: InstallContext) -> None: if not link.is_symlink(): raise RuntimeError("deferred-provisioning install but omarchy-provision-owner.service is not enabled") - if not list((provisioning_dir / "packages").glob("node-v*.tar.gz")): - raise RuntimeError(f"deferred-provisioning install but no Node tarball staged in {provisioning_dir / 'packages'}") - if _provision_install_encrypted(ctx): for required in (provisioning_dir / "luks-key", ctx.target / PROVISION_KEYFILE): if not required.exists(): diff --git a/test/arm64-install b/test/arm64-install index cdb5596c9..40cfee996 100755 --- a/test/arm64-install +++ b/test/arm64-install @@ -174,8 +174,9 @@ check() { # description, then a command run in the guest that must succeed check "it booted through Limine's unified kernel image" \ "echo $GUEST_PASSWORD | sudo -S -p '' sh -c 'ls /boot/EFI/Linux/omarchy_*.efi'" check "the Omarchy runtime is installed" "pacman -Q $RUNTIME_PACKAGE" - check "Node came from the ISO, for this architecture" \ - 'node=$(ls ~/.local/share/mise/installs/node/*/bin/node | head -n 1); file -bL "$node" | grep -q aarch64' + # A runtime from before lazy Node unpacked the one the ISO bundled for it. + check "Node waits for its first use, or came from the ISO for this architecture" \ + 'node=$(ls ~/.local/share/mise/installs/node/*/bin/node 2>/dev/null | head -n 1); if [ -n "$node" ]; then file -bL "$node" | grep -q aarch64; else [ -x ~/.local/share/mise/shims/node ]; fi' check "packages come from Omarchy's mirror and nowhere else" \ "grep -q '^Server.*omarchy\.org' /etc/pacman.d/mirrorlist && ! grep '^Server' /etc/pacman.d/mirrorlist | grep -vq 'omarchy\.org'" if [[ -n ${PLATFORM:-} ]]; then diff --git a/test/unit/test_n1x_platform.py b/test/unit/test_n1x_platform.py index 0c26feb73..06ccb8f80 100644 --- a/test/unit/test_n1x_platform.py +++ b/test/unit/test_n1x_platform.py @@ -122,29 +122,6 @@ def test_mirror_lines_are_valid_json_in_the_heredoc(self): self.assertEqual(servers[0]["url"], first_url) -class NodeTarballTest(unittest.TestCase): - def test_stages_the_tarball_for_the_running_cpu(self): - for machine, bundled in [("aarch64", "node-v26.10.0-linux-arm64.tar.gz"), ("x86_64", "node-v26.10.0-linux-x64.tar.gz")]: - with self.subTest(machine=machine), tempfile.TemporaryDirectory() as tmp: - packages, provisioning = Path(tmp) / "packages", Path(tmp) / "provisioning" - packages.mkdir() - (packages / "node-v26.10.0-linux-arm64.tar.gz" if machine == "x86_64" else packages / "node-v26.10.0-linux-x64.tar.gz").write_text("other cpu") - (packages / bundled).write_text("node") - with mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", packages), \ - mock.patch.object(phases_impl.platform, "machine", return_value=machine): - phases_impl._stage_node_tarball(None, provisioning) - self.assertEqual([p.name for p in (provisioning / "packages").iterdir()], [bundled]) - - def test_missing_tarball_for_the_cpu_fails(self): - with tempfile.TemporaryDirectory() as tmp: - packages = Path(tmp) - (packages / "node-v26.10.0-linux-x64.tar.gz").write_text("x86") - with mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", packages), \ - mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), \ - self.assertRaisesRegex(RuntimeError, "no bundled Node tarball"): - phases_impl._stage_node_tarball(None, packages / "provisioning") - - class N1xLimineContractTest(unittest.TestCase): @staticmethod def validate(conf, arm_platform): diff --git a/test/unit/test_provisioning_state.py b/test/unit/test_provisioning_state.py index 6375dfccb..40522e743 100644 --- a/test/unit/test_provisioning_state.py +++ b/test/unit/test_provisioning_state.py @@ -184,19 +184,6 @@ def setUp(self): info_patch.start() self.addCleanup(info_patch.stop) - # The fixtures are x86_64 ones; the Node build chosen follows the host. - machine_patch = mock.patch.object(phases_impl.platform, "machine", return_value="x86_64") - machine_patch.start() - self.addCleanup(machine_patch.stop) - - # A fake bundled Node tarball on the "live ISO". - self.packages = Path(self.tmp.name) / "opt-packages" - self.packages.mkdir() - (self.packages / "node-v24.0.0-linux-x64.tar.gz").write_bytes(b"node") - node_patch = mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", self.packages) - node_patch.start() - self.addCleanup(node_patch.stop) - def install_runtime_provisioning_support(self): service = self.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" service.parent.mkdir(parents=True, exist_ok=True) @@ -208,11 +195,11 @@ def install_runtime_provisioning_support(self): def provisioning_dir(self): return self.target / "var/lib/omarchy/provisioning" - def test_normal_install_stages_only_the_node_tarball(self): + def test_normal_install_arms_nothing(self): ctx = make_ctx(self.target, defer_provisioning=False) phases_impl.stage_provisioning_state(ctx) - self.assertTrue((self.provisioning_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists()) + self.assertEqual(list(self.provisioning_dir().iterdir()), []) self.assertFalse((self.provisioning_dir() / "pending").exists()) self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists()) @@ -221,22 +208,6 @@ def test_deferred_provisioning_against_runtime_without_support_fails_clearly(sel with self.assertRaisesRegex(RuntimeError, "does not ship"): phases_impl.stage_provisioning_state(ctx) - def test_deferred_provisioning_without_node_tarball_fails(self): - for tarball in self.packages.glob("*.tar.gz"): - tarball.unlink() - self.install_runtime_provisioning_support() - ctx = make_ctx(self.target) - with self.assertRaisesRegex(RuntimeError, "Node tarball"): - phases_impl.stage_provisioning_state(ctx) - - def test_missing_node_tarball_fails_normal_installs_too(self): - # The stash is what makes a later factory reset work offline. - for tarball in self.packages.glob("*.tar.gz"): - tarball.unlink() - ctx = make_ctx(self.target, defer_provisioning=False) - with self.assertRaisesRegex(RuntimeError, "Node tarball"): - phases_impl.stage_provisioning_state(ctx) - def test_deferred_provisioning_arms_first_boot_setup(self): self.install_runtime_provisioning_support() ctx = make_ctx(self.target)