From 77c6bf0a11ecf180451ada513cf3cb5e8eab4ed5 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 25 Jun 2026 11:57:13 -0700 Subject: [PATCH] docs(nixos): add Migration context and developer NixOS page Net-new docs on main for the Raspberry Pi OS -> NixOS migration, filling the gap the unmerged NixOS PR (#379) leaves on main and built to merge cleanly when it lands. - docs/ax/migration/CONTEXT.md: glossary for the new Migration context (in-place migration, migration gate, migration enable, pre-flight checks, migration tarball, migration initramfs, preserved data, migration progress display). Reserves "migration" for the OS-level event and renames the per-version data fixups to "post-update actions". - docs/ax/migration.md: architecture deep-dive of the RAM-staged initramfs reflash, end to end. - docs/source/nixos.rst: dev-focused guide page covering in-place migration, image build, and system update; wired into the "For developers" toctree. - CONTEXT-MAP.md: platform-lifecycle grouping; Migration (live) + NixOS (forward-referenced to #379) and their relationships. Cross-links to docs/ax/nixos/ and nixos/RELEASE.md are intentional forward references that resolve when #379 lands. Sphinx strict build (-W) passes clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT-MAP.md | 8 +++ docs/ax/migration.md | 103 +++++++++++++++++++++++++++++++ docs/ax/migration/CONTEXT.md | 81 +++++++++++++++++++++++++ docs/source/index.rst | 1 + docs/source/nixos.rst | 114 +++++++++++++++++++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 docs/ax/migration.md create mode 100644 docs/ax/migration/CONTEXT.md create mode 100644 docs/source/nixos.rst diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 6e37815b7..7538ab4c1 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -11,6 +11,11 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - [UI](./docs/ax/ui/CONTEXT.md) — the on-device menu system: menu tree, screen modules, the navigation stack and key dispatch, marking menus. - [Camera](./docs/ax/camera/CONTEXT.md) — captures frames and decides exposure: the three exposure regimes, the auto-exposure controllers, and zero-match recovery. +The remaining contexts own the platform lifecycle rather than a slice of the runtime — how a PiFinder is built, converted, and updated: + +- [Migration](./docs/ax/migration/CONTEXT.md) — the one-time, on-device conversion of a deployed Raspberry Pi OS PiFinder to NixOS; reflashes the SD card in place and hands off to NixOS. +- NixOS — how a NixOS PiFinder is built, published, and updated over the air (binary cache, release channels, on-device upgrade). Lives at `docs/ax/nixos/CONTEXT.md`, added by PR #379. + ## Relationships - **Positioning → Catalog**: Catalog reads RA/Dec/Alt/Az from `shared_state.solution()` to compute visibility and "near me" lists. @@ -20,6 +25,8 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - **Catalog ↔ Positioning**: Catalog supplies the `(RA, Dec)` target for the alignment flow that calibrates `solve_pixel` in Positioning. - **Equipment → Catalog**: the active telescope's flip/flop flags and the active eyepiece's true field of view orient and scale the POSS/SDSS object image in `cat_images.get_display_image`. - **Positioning → Equipment**: the object-image baseline rotation combines the active telescope's flip/flop with the live solve **roll** from `shared_state` (see [ADR 0003](./docs/adr/0003-object-image-orientation.md)). +- **NixOS → Migration**: a release cuts two sibling artifacts from one closure — Migration consumes the **migration tarball** (`pifinder-migration-vX.Y.Z.tar.zst`); a fresh card is flashed from the **SD image** (`pifinder-vX.Y.Z.img.zst`). +- **Migration → NixOS**: once a device is reflashed, Migration is done forever; every later update is a NixOS **system update** through the release channels. Companion architecture docs live next to each `CONTEXT.md`: - [`docs/ax/catalog.md`](./docs/ax/catalog.md) @@ -28,3 +35,4 @@ Companion architecture docs live next to each `CONTEXT.md`: - [`docs/ax/equipment.md`](./docs/ax/equipment.md) - [`docs/ax/ui.md`](./docs/ax/ui.md) - [`docs/ax/camera.md`](./docs/ax/camera.md) +- [`docs/ax/migration.md`](./docs/ax/migration.md) diff --git a/docs/ax/migration.md b/docs/ax/migration.md new file mode 100644 index 000000000..a330e44ae --- /dev/null +++ b/docs/ax/migration.md @@ -0,0 +1,103 @@ +# Migration architecture: Raspberry Pi OS → NixOS + +Companion to [`migration/CONTEXT.md`](./migration/CONTEXT.md): how a deployed PiFinder converts itself, in place, from a Raspberry Pi OS install to NixOS. The whole mechanism lives on `main`; the artifact it consumes (the **migration tarball**) and everything that happens after the reboot belong to the [NixOS](./nixos/CONTEXT.md) context. + +The defining constraint: a running OS cannot reformat the SD card it booted from. The migration sidesteps this by doing the destructive work from a throwaway **initramfs** that lives entirely in RAM, so the card it rewrites is not the card it is running from. + +## 1. The flow at a glance + +``` + Raspberry Pi OS initramfs (RAM) NixOS + ─────────────── ─────────────── ───── + Software screen /init runs: + ├─ migration gate (auto), or save WiFi + PiFinder_data →RAM + └─ migration enable (7×SQUARE) copy tarball →RAM + │ format p1 (FAT) + p2 (ext4) + ▼ unpack tarball → p2, boot → p1 + UIMigrationConfirm (warn, size) restore WiFi + user data + │ expand + resize p2 + ▼ reboot ─────────────────────► first boot + UIMigrationProgress + │ sys_utils.start_nixos_migration() + ▼ + nixos_migration.sh: + pre-flight → download → verify + build initramfs → /boot + edit config.txt → reboot ──────────► (kernel loads initramfs) +``` + +Two scripts and one calculator do the work, all under `python/scripts/`: + +- `nixos_migration.sh` — runs **on Raspberry Pi OS**: validate, download, stage the initramfs, arrange for the next boot to use it, reboot. +- `nixos_migration_init.sh` — the initramfs `/init`: runs **from RAM** and performs the actual reflash. +- `nixos_migration_calc.py` — the single source of truth for **pre-flight checks** (eligibility). + +The UI side is in `python/PiFinder/ui/software.py`; the launch shim is `sys_utils.start_nixos_migration`. + +## 2. Triggering: gate vs. enable + +The Software screen (`UISoftware`) reaches the migration two ways, both ending in the same confirm → progress flow: + +- **Migration gate** — `get_release_version()` fetches `migration_gate.json` from the `release` branch. If `nixos_for_everyone` is true, it builds the version info from the gate's `nixos_url` and triggers automatically. This is the fleet-wide rollout lever: one edit to one file on `release` opens (or closes) the migration for every device that checks in. +- **Migration enable** — pressing **SQUARE** seven times on the Software screen calls `_trigger_migration` directly, regardless of the gate. The early-adopter path. + +`_trigger_migration` pushes `UIMigrationConfirm` (a warning screen showing the target version and the download size, `migration_size_mb`), and only on explicit confirmation does it push `UIMigrationProgress`, whose `_start_migration` calls `sys_utils.start_nixos_migration(version_info)`. + +`start_nixos_migration` refuses to proceed without a SHA-256 for the tarball (from `migration_sha256_url` or a hardcoded `migration_sha256`) — there is no "migrate an unverified blob" path. It then launches `nixos_migration.sh` in the background, passing the URL, the checksum, a progress file, and the live **display class and resolution** so the initramfs knows which panel to draw on. + +## 3. Pre-flight: an all-or-nothing gate + +`nixos_migration_calc.py` is the one place that decides whether a device may migrate. `nixos_migration.sh` runs it first (`--json`); a non-zero exit aborts before anything is downloaded or touched. `all_ok` is the AND of every check: + +| Check | Requirement | Why | +| ----- | ----------- | --- | +| Model | `Raspberry Pi 4` | The NixOS image and initramfs paths are built and tested for the Pi 4. | +| RAM | ≥ 1800 MB | The whole migration runs from RAM — tarball + backups + tools must fit. | +| SD size | ≥ 16 GB | The NixOS closure needs the room. | +| SD layout | stock 2-partition (`/dev/mmcblk0p1` + `p2`, root on `p2`) | The initramfs hardcodes these device paths and blindly extends `p2`. | +| Free space | ≥ 1.5 GB | Room to download the tarball onto the running system first. | +| WiFi mode | `Client` | The migration must reach the network for the download (Access Point mode can't). | +| Display | `DisplaySSD1351` 128×128 or `DisplaySSD1333` 176×176 | The **migration progress display** renderer supports only these. | + +Note the display list is the *initramfs renderer's* capability, not the main app's — a panel the app supports but the progress binary can't draw on still fails pre-flight. + +## 4. Staging the initramfs (still on Raspberry Pi OS) + +`nixos_migration.sh` downloads the tarball to `~pifinder/pifinder-nixos-migration.tar.zst` (reusing a cached copy if its checksum matches), verifies the SHA-256, then assembles a minimal initramfs in `/tmp/nixos_initramfs`: + +- **busybox** (sh, mount, dd, tar, cp…) plus the dynamically-linked filesystem tools it can't supply: `e2fsck`, `resize2fs`, `mke2fs`/`mkfs.ext4`, `mkfs.vfat`, `sfdisk`, `zstd` — each copied with its shared libraries, plus the dynamic linker. +- The static `migration_progress` binary and the **SPI kernel modules** (`spi-bcm2835`, `spidev`, decompressed if shipped `.ko.xz`/`.gz`/`.zst`) so the OLED can be driven with no userland. +- `nixos_migration_init.sh` as `/init`, and a `migration_meta` file recording the tarball path/size, the `PiFinder_data` path, and the display class/resolution. + +It `cpio | gzip`s this into `/boot/initramfs-migration.gz`, drops a `/boot/nixos_migration` flag file (which survives the root reformat because it lives on the FAT boot partition), backs up `config.txt` to `config.txt.premigration`, and appends `initramfs initramfs-migration.gz followkernel`. The **commit point** is this `config.txt` edit: after the reboot, the kernel loads our initramfs instead of the normal root. + +## 5. The reflash (initramfs, from RAM) + +`nixos_migration_init.sh` runs as PID 1 with no real root filesystem. Its sequence: + +1. **Bring up the panel.** A single long-lived `migration_progress --serve` reads stage updates from a FIFO held open on fd 3, so the OLED redraws in place and never blanks between stages (a fresh process per stage would re-assert the panel reset line). `trap '' PIPE` ensures a dead display process can never abort the migration — the display is best-effort, the migration is not. +2. **Validate.** Confirm the `/boot/nixos_migration` flag, re-read `migration_meta`, and check available RAM against tarball size + a 150 MB margin. +3. **Stage to RAM.** Mount the old root read-only and copy out the **preserved data**: WiFi (`wpa_supplicant.conf` and any NetworkManager `system-connections`) and `PiFinder_data` (root-level files, `obslists/`, with `pifinder.log` truncated to its last 1000 lines so a giant log can't exhaust RAM). The old `/etc/hostname` is bridged into `PiFinder_data/hostname` (Pi OS keeps it in `/etc`; NixOS reads it from `PiFinder_data`). A second RAM check covers tarball + backup + 150 MB before anything destructive. +4. **Copy the tarball to RAM and unmount the old root.** From here the old system is gone. +5. **Repartition and format.** Expand `p2` to fill the disk **before** formatting (`sfdisk -N 2 --no-reread` then `blockdev --rereadpt`) — re-reading the partition table *after* a freshly-written FAT can corrupt it. Then `mkfs.vfat -F32 -n FIRMWARE` on `p1` and `mkfs.ext4 -L NIXOS_SD` on `p2`. +6. **Unpack.** `zstd -d | tar x` the tarball onto the new ext4 root, hoist `rootfs/`'s contents to the partition root, and copy the `boot/` tree onto the FAT partition — verifying `extlinux/extlinux.conf` actually landed, since that file is what U-Boot reads. `chown -R 0:0` the `/nix/store` while the root is still writable: NetworkManager refuses to load plugin files not owned by root, so a non-root store silently kills WiFi on NixOS. +7. **Restore.** Rewrite each preserved WiFi network as a NetworkManager keyfile (SSID hex-encoded, PSK keyfile-escaped, filename sanitized so a hostile SSID can't escape the directory), restore `PiFinder_data`, and `chown` it to `1000:100` — the `pifinder` user's UID/GID on NixOS (different from Raspberry Pi OS). +8. **Finalize.** `e2fsck -f` + `resize2fs` the root, re-verify `extlinux.conf` survived, and `reboot -f` into NixOS. + +## 6. Failure handling and the point of no return + +Before step 5, every failure is safe: `nixos_migration.sh`'s `ERR` trap and the initramfs `fail()` write a `FAILED` status and stop, and the original `config.txt.premigration` is still recoverable. After the partitions are formatted, there is no rollback — a failure there leaves a half-written card. This asymmetry is *why* pre-flight and the two RAM checks are strict and run entirely before the first destructive step: the design front-loads everything that can refuse the migration so that the irreversible part rarely runs on a device that can't finish it. On a hard failure the initramfs drops to a shell (and shows `FAILED` on the OLED) rather than rebooting into nothing. Recovery from a botched card is out-of-band: reflash from an **image build** (`.img.zst`) or the user's own backup. + +## 7. Artifacts and the hand-off + +The migration consumes `pifinder-migration-vX.Y.Z.tar.zst` — `boot/` + `rootfs/`, self-contained, unpacked directly onto the card (independent of the Attic binary cache; see the NixOS context's [ADR 0001](./nixos/adr/0001-attic-binary-cache.md)). This is **not** the `pifinder-vX.Y.Z.img.zst` **SD image** used to flash a blank card; both are cut from the same release closure but do different jobs. Once the device reboots into NixOS, Migration is finished forever and all further updates flow through the NixOS context's **system update** channels. + +## 8. Gotchas + +- **Tarball ≠ image.** `…-migration-….tar.zst` is unpacked in place by an existing device; `….img.zst` is flashed onto a blank card. Never conflate them. +- **One-way.** There is no in-product route back to Raspberry Pi OS. The only "undo" is reflashing a card. +- **The progress display is a separate static binary** precisely because the app — and then the OS — is gone during the wipe. It is not the PiFinder UI. +- **Expand before format.** Reordering steps 5's expand/format corrupts the FAT partition. +- **Root-owned `/nix/store` or no WiFi.** NetworkManager silently refuses a non-root-owned store; the initramfs normalizes ownership, with a boot-time service as backstop on the NixOS side. +- **UID/GID changes across the boundary** — `pifinder` is `1000:100` on NixOS — so restored data is re-chowned, not copied verbatim. +- **Pre-flight display support is the initramfs renderer's, not the app's.** They are checked independently. diff --git a/docs/ax/migration/CONTEXT.md b/docs/ax/migration/CONTEXT.md new file mode 100644 index 000000000..faa2aa0d2 --- /dev/null +++ b/docs/ax/migration/CONTEXT.md @@ -0,0 +1,81 @@ +# Migration + +The Migration context owns the one-time, on-device conversion of a deployed PiFinder from its Raspberry Pi OS install to NixOS. It runs on the old system, reflashes the SD card in place, and hands the device off to the **NixOS** context, which owns everything afterwards. It is a bridge, used once per device and never again. + +> Companion architecture doc: [`../migration.md`](../migration.md). +> Downstream context: [`../nixos/CONTEXT.md`](../nixos/CONTEXT.md) (added by PR #379) owns image build and every update *after* migration. + +## Language + +### The event + +**In-place migration**: +The one-time conversion of a running Raspberry Pi OS PiFinder into a NixOS PiFinder, performed on the device itself by reflashing its own SD card. Destructive and one-way: both partitions are reformatted, and there is no in-product path back to Raspberry Pi OS. +_Avoid_: "upgrade", "update" (those are the NixOS context's recurring **system update**, not this one-shot conversion); "install" (that is a fresh card from an **image build**). + +**Post-update actions**: +The small, idempotent, per-version data and config fixups run after a Raspberry Pi OS *code* update (the `migration_source/v*.sh` scripts, stamped under `PiFinder_data/migrations/`). A legacy Raspberry Pi OS concept, unrelated to the conversion above and retired on NixOS. +_Avoid_: "migrations", "data migrations" — renamed precisely so "Migration" can mean only the OS-level event. + +### Starting it + +**Migration gate**: +The fleet-wide rollout switch (`migration_gate.json`, key `nixos_for_everyone`) fetched from the `release` branch. When open, eligible devices are offered the migration automatically; the maintainer controls the whole fleet by editing this one file. +_Avoid_: "killswitch" in prose (it is the rollout *control*, not only an off switch), "feature flag". + +**Migration enable**: +The manual, per-device opt-in — a 7× SQUARE gesture on the Software screen — that turns the migration on for one device regardless of the **migration gate**. The path for early adopters before the gate opens. +_Avoid_: "unlock" (too generic, and the NixOS update screen has its own 7× SQUARE gesture that reveals the **unstable** channel — a different screen and a different effect), "secret menu". + +**Pre-flight checks**: +The all-or-nothing eligibility test run before a migration may proceed (`nixos_migration_calc.py`): correct Pi model, enough RAM, an SD card of supported size and the stock two-partition layout, free space, WiFi in **Client mode**, and a display the **migration progress display** can drive. Any single failure blocks migration. +_Avoid_: "requirements check" (fine in user prose, but "pre-flight" is the canonical term), "validation". + +### Doing it + +**Migration tarball**: +The payload a migration consumes: a `.tar.zst` (`pifinder-migration-vX.Y.Z.tar.zst`) holding the new system's `boot/` and `rootfs/` trees. Self-contained — it is unpacked directly onto the SD card and does not depend on the binary cache. **Not** a flashable disk image. +_Avoid_: "image", "the img" (an **image build** produces `pifinder-vX.Y.Z.img.zst`, a different artifact for a different job — see Flagged ambiguities), "the closure". + +**Migration initramfs**: +The minimal RAM-resident environment the device reboots into to perform the reflash. Because it lives entirely in RAM, it can reformat the very SD card the old OS booted from: it stages **preserved data** to RAM, formats both partitions, unpacks the **migration tarball**, restores the preserved data, then reboots into NixOS. +_Avoid_: "recovery image", "rescue mode", "the installer". + +**Preserved data**: +The state carried across the wipe in RAM and written back onto the new system: the device's WiFi credentials (rewritten into NetworkManager keyfiles for NixOS) and its `PiFinder_data` (observations, configuration, observing lists, hostname). Everything else on the old card is discarded. +_Avoid_: "backup" (nothing is kept off-device; it is a transient in-RAM carry-over), "user data" alone (WiFi credentials are preserved too). + +**Migration progress display**: +The standalone OLED renderer (`migration_progress`) that shows progress during the window when the normal PiFinder UI does not exist — after the old OS is gone and before NixOS boots. Why it must be its own self-contained binary, not the app. +_Avoid_: "splash", "the UI" (the app is not running at this point). + +### Cross-context terms + +- **Image build** / **SD image** — owned by [NixOS](../nixos/CONTEXT.md) (added by PR #379): produces `pifinder-vX.Y.Z.img.zst`, the flashable disk image for a *fresh* SD card. Migration consumes the sibling **migration tarball** instead; both are cut from the same release. +- **System update** / **channels** — owned by [NixOS](../nixos/CONTEXT.md): every update *after* a device is on NixOS. Migration is the one-time entry into that world and owns none of it. +- **Client mode** — the WiFi state (versus Access Point mode) required by **pre-flight checks**, because the migration must reach the network to fetch the tarball. + +## Flagged ambiguities + +- **"Migration"** is overloaded in the tree. The `migration_source/`, `pifinder_post_update.sh`, and `PiFinder_data/migrations/` machinery are **post-update actions** (per-version Raspberry Pi OS fixups), *not* this context. Reserve capital-M **Migration** for the OS-level in-place conversion; say **post-update actions** for the rest. +- **Tarball vs image.** `pifinder-migration-vX.Y.Z.tar.zst` (**migration tarball**, unpacked in place by an existing device) and `pifinder-vX.Y.Z.img.zst` (**SD image**, flashed onto a blank card) are different artifacts with different jobs. Never call the tarball "the image". +- **The 7× SQUARE gesture** means two different things on two different screens: on the Raspberry Pi OS Software screen it is **migration enable**; on the NixOS update screen it reveals the **unstable** update channel. They share only the gesture. +- **"Migrate the WiFi"** is a sub-step of the larger event — the init script rewriting `wpa_supplicant` entries into NetworkManager keyfiles, part of **preserved data**. Don't let it stand in for the whole **in-place migration**. + +## Example dialogue + +> **Dev:** A user on 2.6.0 wants NixOS but the update screen shows nothing. Bug? +> +> **Domain:** Expected. The **migration gate** is still closed, so nobody's offered it automatically yet. They can opt in per-device with **migration enable** — 7× SQUARE on the Software screen — if their PiFinder passes **pre-flight checks**. +> +> **Dev:** What if it's a Pi 3 or the card's been repartitioned? +> +> **Domain:** Pre-flight blocks it. It's all-or-nothing: wrong model, too little RAM, a non-stock partition layout, WiFi not in **Client mode**, or an unsupported display all stop the migration before anything is touched. +> +> **Dev:** And once it starts — do they lose their observations and WiFi? +> +> **Domain:** No. Both are **preserved data**: staged to RAM by the **migration initramfs**, then written back after the card is reformatted and the **migration tarball** is unpacked. WiFi credentials are rewritten as NetworkManager keyfiles on the way. +> +> **Dev:** Can they go back to Raspberry Pi OS if they don't like it? +> +> **Domain:** Not from inside the product — the migration is one-way. Recovery means reflashing a card from an **image build** (or their own backup). After migration the device is a NixOS PiFinder and updates through the **NixOS** context's **system update** channels from then on. diff --git a/docs/source/index.rst b/docs/source/index.rst index 81e6d3a80..0f94e9173 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -94,4 +94,5 @@ Join the `PiFinder Discord server `_ for support dev_guide dev_arch + nixos api diff --git a/docs/source/nixos.rst b/docs/source/nixos.rst new file mode 100644 index 000000000..17b0541ca --- /dev/null +++ b/docs/source/nixos.rst @@ -0,0 +1,114 @@ +.. _nixos: + +NixOS: Migration, Images & Releases +=================================== + +Starting with v3.0.0, the PiFinder ships as an **immutable NixOS image** rather +than a Raspberry Pi OS install with the app cloned on top. This page is the +developer/maintainer map to that change. It does not repeat the deep reference +material — it points at it — and it deliberately leaves the *end-user* +walkthroughs (flashing a card, driving the update screen) to the user guide. + +There are three distinct things to keep separate, and they have separate names: + +- **In-place migration** — the one-time, on-device conversion of an existing + Raspberry Pi OS PiFinder to NixOS. +- **Image build** — producing the writeable ``.img`` that flashes a *fresh* SD + card. +- **System update** — swapping the running NixOS system for a newer prebuilt one + pulled from the project's binary cache. + +A fourth term clears up an old overload: the per-version Raspberry Pi OS data +fixups (``migration_source/v*.sh``) are **post-update actions**, not migrations. +"Migration" now means only the OS-level conversion above. + +Where the detail lives +---------------------- + +This page is an index. The authoritative documentation is the in-repo reference +layer (see :ref:`dev_guide:Reference documentation and AI assistant skills`): + +- ``docs/ax/migration/CONTEXT.md`` and ``docs/ax/migration.md`` — the **Migration** + context: the in-place conversion, end to end. Lives on ``main`` (the migration + code is already merged). +- ``docs/ax/nixos/CONTEXT.md`` and ``docs/ax/nixos.md`` — the **NixOS** context: + build, publish, channels, on-device upgrade and rollback. Arrives with the + NixOS system itself (PR #379). +- ``nixos/RELEASE.md`` — the release-engineering runbook: the build → image → + tarball → cache → tag → manifest flow, plus "cutting a release" and hotfixes. +- ``docs/ax/nixos/adr/`` — decisions behind the cache and the channel/rollback + model. + +In-place migration (Raspberry Pi OS → NixOS) +-------------------------------------------- + +A deployed PiFinder converts itself by reflashing its own SD card. Because a +running OS cannot reformat the card it booted from, the work is done from a +throwaway initramfs that lives entirely in RAM; it saves the device's WiFi and +``PiFinder_data`` to RAM, formats both partitions, unpacks the new system, and +restores that state. The conversion is **destructive and one-way** — there is no +in-product path back to Raspberry Pi OS. + +It is reached from the on-device **Software** screen two ways: + +- **Migration gate** — the maintainer opens it fleet-wide by setting + ``nixos_for_everyone`` in ``migration_gate.json`` on the ``release`` branch. + Eligible devices are then offered it automatically. +- **Migration enable** — an individual user opts in early by pressing **SQUARE** + seven times on the Software screen, regardless of the gate. + +Either way the device first runs **pre-flight checks** (``nixos_migration_calc.py``): +a Pi 4, ≥ 1800 MB RAM, a ≥ 16 GB card in the stock two-partition layout, free +space, WiFi in **Client mode**, and a display the progress renderer supports. +The checks are all-or-nothing and run before anything is downloaded or touched. + +The code is all under ``python/scripts/`` (``nixos_migration.sh``, +``nixos_migration_init.sh``, ``nixos_migration_calc.py``) with the UI in +``python/PiFinder/ui/software.py``. For the full sequence — staging the +initramfs, the reflash, data preservation, and the point of no return — read +``docs/ax/migration.md``. + +To exercise the trigger path without a real device, the migration UIs are built +in the smoke harness (``tests/test_software.py``); the destructive initramfs +stage only runs on a real Pi 4. + +Building an SD card image +------------------------- + +A fresh card is flashed from ``pifinder-vX.Y.Z.img.zst``, a writeable disk image +published as a GitHub Release asset. It is built from the same release closure as +the running system — the release workflow runs ``nix build .#images.pifinder`` +and the **migration tarball** (``pifinder-migration-vX.Y.Z.tar.zst``, consumed by +the in-place migration above) is extracted from that same image. The two release +assets are therefore always in lock-step: one flashes a blank card, the other +converts an existing one. + +The image and the flake that produces it arrive with PR #379. The build steps +and asset list are documented in ``nixos/RELEASE.md`` (see *Artifacts* and +*Release flow*). + +Updating a NixOS PiFinder +------------------------- + +On NixOS, an update is not ``git pull`` — the device **downloads a prebuilt +system from the project's binary cache and switches to it**; it never compiles +anything. The on-device Software screen offers three **channels**: + +- **stable** — official releases. The default for ordinary use. +- **beta** — pre-releases cut from the development branch, curated with notes. +- **unstable** — the live development tip plus individual open pull requests, + each installable before merge. Hidden until unlocked (7× SQUARE on the update + screen — a *different* gesture from migration enable, on a different screen). + +Each channel entry names a version and a signed ``/nix/store`` path; the device +resolves it against the ``cache.pifinder.eu`` Attic cache and activates it. A bad +build is recovered by reinstalling an earlier stable/beta version (their closures +are retained in the cache forever), by an automatic single-generation rollback on +a failed boot, or by yanking a bad release. The mechanics — channels, the +manifest, the two caches and their retention, rollback and yank — are specified +in ``docs/ax/nixos/CONTEXT.md`` and +``docs/ax/nixos/adr/0002-update-channels-and-rollback.md``; the release-cutting +procedure is in ``nixos/RELEASE.md``. + +This whole path — the channel UI, the cache, the manifest, and the on-device +updater — lands with PR #379.