diff --git a/guest/README.md b/guest/README.md index c903fc5..e47f492 100644 --- a/guest/README.md +++ b/guest/README.md @@ -62,6 +62,15 @@ direct-boot kernel and matching headers are held, while the packaged repository. A separate migration channel is required before those Try-Omarchy-specific revisions can advance on an existing disk without reset. +The kernel reboot check recognizes package-owned `modules.builtin` metadata as +well as `vmlinuz` under `/usr/lib/modules//`. Arch Linux ARM does not +place `vmlinuz` there, so requiring that file alone produces a false kernel-update +prompt after every no-op update. A matching release suppresses that prompt; +unrecognized layouts report that kernel reboot status could not be determined +instead of claiming either a match or an update. Other reboot and service-restart +reasons still apply. This check ships as the `update-restart-arm-kernel` reviewed +backport, with a fixture from the pinned upstream command for regression tests. + The factory includes the pinned upstream `omarchy-dns` and `omarchy-theme-browser` sudoers drop-ins, owned by `try-omarchy-runtime` with root ownership and mode `0440`. These grant wheel users passwordless access diff --git a/guest/patches/omarchy/update-restart-arm-kernel.patch b/guest/patches/omarchy/update-restart-arm-kernel.patch new file mode 100644 index 0000000..524fb97 --- /dev/null +++ b/guest/patches/omarchy/update-restart-arm-kernel.patch @@ -0,0 +1,35 @@ +diff --git a/bin/omarchy-update-restart b/bin/omarchy-update-restart +--- a/bin/omarchy-update-restart ++++ b/bin/omarchy-update-restart +@@ -9,20 +9,28 @@ confirm_reboot() { + } + + running_kernel=$(uname -r) +-kernel_updated=true ++kernel_status=unknown + +-for kernel in /usr/lib/modules/*/vmlinuz; do ++# Arch Linux ARM installs its image outside the modules directory. Its ++# package-owned modules.builtin identifies the installed kernel release too. ++# No recognized package-owned marker means unknown, not an updated kernel. ++for kernel in /usr/lib/modules/*/vmlinuz /usr/lib/modules/*/modules.builtin; do + if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then ++ kernel_status=changed + installed_kernel=$(basename "$(dirname "$kernel")") + + if [[ $installed_kernel == $running_kernel ]]; then +- kernel_updated=false ++ kernel_status=matched + break + fi + fi + done + +-if [[ $kernel_updated == "true" ]]; then ++if [[ $kernel_status == unknown ]]; then ++ echo "Unable to determine kernel reboot status: no recognized package-owned kernel files found." >&2 ++fi ++ ++if [[ $kernel_status == changed ]]; then + confirm_reboot "Linux kernel has been updated. Reboot?" + elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then diff --git a/guest/spec.json b/guest/spec.json index 29f4785..45ca5fd 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -380,6 +380,20 @@ } ] }, + { + "id": "update-restart-arm-kernel", + "description": "Recognize package-owned ARM kernel module metadata and explicitly distinguish matching, changed, and unknown kernel reboot states.", + "reference": "https://github.com/basecamp/omarchy/blob/346e69e1cec6c4e8924531874af6ba010a1bc99e/bin/omarchy-update-restart", + "patch": "patches/omarchy/update-restart-arm-kernel.patch", + "patchSha256": "e6cf6d70a8fdfb9e027f28a2c115918e4b8db4d827b249a2941ceb64975c7a47", + "targets": [ + { + "path": "bin/omarchy-update-restart", + "beforeSha256": "7bc95f8a0a3ae35cfd81453e08194f5660c27cf59528c7d95b4345f362030b60", + "afterSha256": "c791af27312db4cf4f11ec4d112a3e4f50654d82143368166bf385c25f92f5d9" + } + ] + }, { "id": "pkg-add-aarch64-unavailable", "description": "Refuse known aarch64-unavailable packages from omarchy-pkg-add with the shared message.", diff --git a/guest/tests/fixtures/omarchy-update-restart b/guest/tests/fixtures/omarchy-update-restart new file mode 100644 index 0000000..05f7ba0 --- /dev/null +++ b/guest/tests/fixtures/omarchy-update-restart @@ -0,0 +1,51 @@ +#!/bin/bash + +# omarchy:summary=Prompt for required reboot or service restarts after updates + +echo + +confirm_reboot() { + gum confirm "$1" && { omarchy-system-reboot; exit 0; } +} + +running_kernel=$(uname -r) +kernel_updated=true + +for kernel in /usr/lib/modules/*/vmlinuz; do + if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then + installed_kernel=$(basename "$(dirname "$kernel")") + + if [[ $installed_kernel == $running_kernel ]]; then + kernel_updated=false + break + fi + fi +done + +if [[ $kernel_updated == "true" ]]; then + confirm_reboot "Linux kernel has been updated. Reboot?" +elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then + confirm_reboot "Updates require reboot. Ready?" +fi + +running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null) +if [[ $running_hyprland == *"(deleted)"* ]]; then + confirm_reboot "Hyprland has been updated. Reboot?" +fi + +for file in "$HOME"/.local/state/omarchy/restart-*-required; do + if [[ -f $file ]]; then + filename=$(basename "$file") + service=$(echo "$filename" | sed 's/restart-\(.*\)-required/\1/') + echo "Restarting $service" + omarchy-state clear "$filename" + omarchy-restart-"$service" + fi +done + +# Updates routinely replace the shell's QML, and a stale process can lazy-load +# new files into old code. A restart failure (locked session, ssh, TTY) only +# prints its reason: the next update or login gets a fresh shell anyway. +echo -e "\e[32m\nRestarting shell\e[0m" +echo "All plugins have been reloaded" +omarchy-restart-shell || true diff --git a/guest/tests/test_update_restart_arm_kernel.py b/guest/tests/test_update_restart_arm_kernel.py new file mode 100644 index 0000000..0a80803 --- /dev/null +++ b/guest/tests/test_update_restart_arm_kernel.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +GUEST = Path(__file__).resolve().parents[1] + + +class UpdateRestartARMKernelTests(unittest.TestCase): + def run_restart(self, markers=(), *, reboot_required=False, deleted_hyprland=False): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = GUEST / "tests/fixtures/omarchy-update-restart" + spec = json.loads((GUEST / "spec.json").read_text()) + backport = next(b for b in spec["authenticity"]["backports"] + if b["id"] == "update-restart-arm-kernel") + self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest(), + backport["targets"][0]["beforeSha256"]) + patch = GUEST / backport["patch"] + self.assertEqual(hashlib.sha256(patch.read_bytes()).hexdigest(), + backport["patchSha256"]) + result = subprocess.run( + ["patch", "--silent", "-o", "-", str(source), str(patch)], + capture_output=True, check=True, + ) + self.assertEqual(hashlib.sha256(result.stdout).hexdigest(), + backport["targets"][0]["afterSha256"]) + # Redirect only the filesystem boundary; execute the complete patched + # command with stubbed external effects, including restart fallthrough. + script = result.stdout.decode().replace("/usr/lib/modules/", f"{root}/modules/") + for release, name, owned in markers: + marker = root / "modules" / release / name + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() + if owned: + marker.with_name(name + ".owned").touch() + state = root / ".local/state/omarchy" + state.mkdir(parents=True) + (state / "restart-test-required").touch() + if reboot_required: + (state / "reboot-required").touch() + stubs = ''' +uname() { echo 7.2.5-1-aarch64-ARCH; } +pacman() { [[ $1 == -Qo && -f $2.owned ]]; } +gum() { echo "PROMPT: $2"; return 1; } +omarchy-system-reboot() { echo UNEXPECTED_REBOOT; } +pgrep() { echo 123; } +readlink() { [[ $DELETED_HYPRLAND == 1 ]] && echo '/usr/bin/Hyprland (deleted)'; } +omarchy-state() { :; } +omarchy-restart-test() { echo SERVICE_RESTARTED; } +omarchy-restart-shell() { echo SHELL_RESTARTED; } +''' + completed = subprocess.run( + ["bash", "-c", stubs + script], capture_output=True, text=True, + env={**os.environ, "HOME": str(root), + "DELETED_HYPRLAND": str(int(deleted_hyprland))}, + check=True, + ) + self.assertIn("SERVICE_RESTARTED", completed.stdout) + self.assertIn("SHELL_RESTARTED", completed.stdout) + self.assertNotIn("UNEXPECTED_REBOOT", completed.stdout) + expected_unknown = not any(owned for _, _, owned in markers) + self.assertEqual( + "Unable to determine kernel reboot status" in completed.stderr, + expected_unknown, + ) + return completed.stdout + + def test_matching_arm_modules_without_vmlinuz_do_not_prompt(self): + output = self.run_restart([("7.2.5-1-aarch64-ARCH", "modules.builtin", True)]) + self.assertNotIn("PROMPT:", output) + + def test_newer_installed_arm_kernel_prompts(self): + output = self.run_restart([("7.2.6-1-aarch64-ARCH", "modules.builtin", True)]) + self.assertIn("PROMPT: Linux kernel has been updated.", output) + + def test_vmlinuz_layout_still_detects_matches_and_changes(self): + for release, updated in [("7.2.5-1-aarch64-ARCH", False), ("7.2.6-1-aarch64-ARCH", True)]: + with self.subTest(release=release): + output = self.run_restart([(release, "vmlinuz", True)]) + self.assertEqual("PROMPT: Linux kernel" in output, updated) + + def test_matching_arm_kernel_wins_over_other_installed_kernel(self): + output = self.run_restart([ + ("7.2.6-1-aarch64-ARCH", "vmlinuz", True), + ("7.2.5-1-aarch64-ARCH", "modules.builtin", True), + ]) + self.assertNotIn("PROMPT:", output) + + def test_unknown_and_unowned_layouts_do_not_claim_an_update(self): + for markers in [[], [("7.2.6-1-aarch64-ARCH", "modules.builtin", False)]]: + with self.subTest(markers=markers): + self.assertNotIn("PROMPT:", self.run_restart(markers)) + + def test_stale_unowned_matching_modules_do_not_hide_a_change(self): + output = self.run_restart([ + ("7.2.5-1-aarch64-ARCH", "modules.builtin", False), + ("7.2.6-1-aarch64-ARCH", "modules.builtin", True), + ]) + self.assertIn("PROMPT: Linux kernel has been updated.", output) + + def test_other_reboot_reasons_survive_unknown_kernel_layout(self): + output = self.run_restart(reboot_required=True, deleted_hyprland=True) + self.assertIn("PROMPT: Updates require reboot.", output) + self.assertIn("PROMPT: Hyprland has been updated.", output) + self.assertNotIn("PROMPT: Linux kernel", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/guest/tests/verify.py b/guest/tests/verify.py index ff3d7ec..d8616cf 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -241,6 +241,7 @@ def main() -> None: "notification-hover-close", "notification-screen-privacy", "update-free-space-message", + "update-restart-arm-kernel", "pkg-add-aarch64-unavailable", "pkg-aur-add-aarch64-unavailable", "dropbox-aarch64-unavailable",