System Image Build
How the flashable SD artifact (.img) is constructed end-to-end: pipeline choice, base OS pinning, stage layout, partition table, build-host setup, and CI integration. Read this if you are about to add a new system service, change a device-tree overlay, or reshape the SD partitions; engineers tweaking a single systemd unit should also skim this for stage placement.
boot-and-systemd.md— what runs after the image boots.device-tree-overlays.md— overlays baked into the image.update-system.md— how the field-deployed.kn86fwpayload is constructed from a built slot tarball.release-setup.md— CI release pipeline (currently emulator-only; system-image CI will graft onto the same workflow surface).../../adr/ADR-0011-device-firmware-update-system.md— partition layout and update model this image must satisfy.
Pipeline choice: pi-gen
Section titled “Pipeline choice: pi-gen”The system image is built with pi-gen (the Raspberry Pi Foundation’s official Raspberry Pi OS image generator), targeting the Lite arm64 flavor. We do not use debootstrap directly. Reasons:
- Stage management. pi-gen ships a stage-numbered overlay system (
stage0/stage1/stage2/…) that maps cleanly onto our base/runtime/firmware layering. Each stage is a directory of shell hooks + aprerun.sh/run.sh/run-chroot.shtriplet; adding “the KN-86 nOSh runtime” is one new stage directory. - Pi-firmware bundling. pi-gen embeds the vendor-supplied VideoCore GPU bootloader (
bootcode.bin,start*.elf,fixup*.dat) and the Pi-flavored kernel into the image with the right partition layout. Doing this from rawdebootstrapmeans re-implementing the work pi-gen already did upstream. trybootsupport. pi-gen produces images that work with the Pi firmware’s native A/Btrybootflow, which is the path ADR-0011 commits to. No bootloader replacement, no GRUB, no u-boot.- Read-only-root option. pi-gen’s
stage2/stage3hooks include built-in support for overlayroot/overlayfs configuration (kiosk-mode.md). We don’t have to roll our own.
Fallback: if pi-gen ever blocks us (e.g., a pi-gen regression that drags in a package we can’t take, or a base-image bug), debootstrap + manual partition assembly is the escape hatch — but it is a ~1-week setback, not a parallel maintained pipeline.
Base Debian release pinning
Section titled “Base Debian release pinning”The base OS is Raspberry Pi OS Lite (arm64) built on Debian 13 “trixie” (released March 2026), per ../../adr/ADR-0026-pi-os-trixie-base-pin.md. Exact pinning — including the snapshot.debian.org timestamp and the pi-gen tag — is TBD pending bring-up. Once Stage 0 (Bring-up) of ../hardware/build-specification.md §4 lands a known-good kernel + Raspberry Pi OS Lite (arm64) trixie combination that survives the 50-cycle USB-MSC regression test in ADR-0011 §Risks #1, that triplet (kernel version, pi-gen tag, snapshot.debian.org timestamp) becomes the v1 pin and is committed to tools/sd-provision/pi-gen-pin.env. Do not bump the pin without re-running the 50-cycle test.
Stage definitions
Section titled “Stage definitions”The KN-86 image extends pi-gen with three project-specific stages on top of the upstream base stages:
| Stage | Source | Purpose |
|---|---|---|
stage0 | pi-gen upstream | Minimal Debian bootstrap (debootstrap, locale, base packages). |
stage1 | pi-gen upstream | First-boot init, fstab, raspberrypi-bootloader, kernel install. |
stage2-lite | pi-gen upstream | Pi OS Lite — no desktop. |
stage-kn86-base | this repo | Hardening, package pruning, kiosk-user creation (nosh:nosh), polkit lockdown, journald → volatile, getty disable on tty2–6. See kiosk-mode.md. |
stage-kn86-runtime | this repo | nOSh binary + assets installed under /opt/nosh/. systemd units copied into /etc/systemd/system/. udev rules (cartridge USB-MSC subscriber per update-system.md §SD partition layout). See boot-and-systemd.md. |
stage-kn86-firmware | this repo | Device-tree overlays for SSD1322 OLED, USB hub topology, UART0 to Pico 2 (device-tree-overlays.md). config.txt snippets. Pico 2 firmware UF2 deposited at /lib/firmware/kn86-pico.uf2. CIPHER-LINE bezel/tty assets. |
Each stage-kn86-* lives at tools/sd-provision/pi-gen-stages/<stage>/ in this repo and is symlinked into the pi-gen working tree at build time by the provisioning script.
Output .img layout
Section titled “Output .img layout”The output of pi-gen + our stage overlays is a single .img written with the six-partition A/B layout from ADR-0011 §SD partition layout. Do not invent alternative partition tables — that table is the source of truth. Quick reference:
| # | Device | FS | Size | Purpose |
|---|---|---|---|---|
| p1 | /dev/mmcblk0p1 | FAT32 | ~64 MB | Common boot region (autoboot.txt, bootcode, shared stage-1) |
| p2 | /dev/mmcblk0p2 | FAT32 | ~256 MB | Bootfs slot A (kernel, cmdline.txt, config.txt, initramfs, dtbs) |
| p3 | /dev/mmcblk0p3 | FAT32 | ~256 MB | Bootfs slot B |
| p4 | /dev/mmcblk0p4 | ext4 | (rest)/2 | Rootfs slot A |
| p5 | /dev/mmcblk0p5 | ext4 | (rest)/2 | Rootfs slot B |
| p6 | /dev/mmcblk0p6 | ext4 | ~512 MB | /home/shared — Universal Deck State, cart save passthrough cache. Never written by the image build or the flasher. |
A first-boot install populates slot A only (p2 + p4). Slot B (p3 + p5) is left empty until the first successful field update. p6 is created empty and is owned by nOSh’s deck-state writer at runtime.
Build-host setup
Section titled “Build-host setup”pi-gen is Linux x86_64 only. From an Apple Silicon Mac, run the build inside a --platform=linux/amd64 Docker container; without the platform flag, docker run silently uses aarch64 and the build will fail in subtle places.
# From repo root, on a Linux x86_64 host (or in an amd64 container):cd tools/sd-provision./build-image.sh \ --pi-gen-tag $(grep '^PI_GEN_TAG=' pi-gen-pin.env | cut -d= -f2) \ --output build/kn86-v$(git describe --tags).imgCross-building from macOS:
docker run --rm -it --platform=linux/amd64 \ -v "$PWD":/work -w /work \ debian:trixie \ /work/tools/sd-provision/build-image.shBuild duration: ~30–45 min on a fresh Docker layer cache, ~10 min warm. Output is a single .img of the full SD layout, plus a .kn86fw payload built from the slot-A bootfs+rootfs (update-system.md).
SSH authorized_keys provisioning (GWP-351)
Section titled “SSH authorized_keys provisioning (GWP-351)”Dev-mode SSH is key-only — PasswordAuthentication no,
PermitRootLogin no, ChallengeResponseAuthentication no
(see kiosk-mode.md “Recovery / dev-mode toggle”).
The kn86 user’s authorized_keys file is baked into the image at
build time, controlled by an env var:
# Wrapper contract: caller sets KN86_AUTHORIZED_KEYS_FILE to a path on# the build host containing one or more public keys (concatenated in# OpenSSH authorized_keys format).export KN86_AUTHORIZED_KEYS_FILE=~/.ssh/kn86-deckline-fleet.pub./tools/sd-provision/build-image.sh ...stage-kn86-base/00-kn86-base/02-run.sh reads that env var. If set,
it installs the file to /home/kn86/.ssh/authorized_keys (mode
0600, owner 1000:1000 to match the cloud-init kn86 user). If unset,
the image ships with no authorized keys and dev-mode SSH is locked
out until somebody mounts the SD on a host machine and writes a key
in directly.
CI release pipelines should treat the public-key file as a secret artifact (it isn’t sensitive, but mistakes are easier to fix when the path is treated formally). The matching private key is held by the operator running the converger over SSH.
Reproducibility
Section titled “Reproducibility”The build is reproducible to the level pi-gen offers — any two runs against the same pi-gen-pin.env + the same source tree produce byte-identical .img files modulo a build timestamp embedded in /etc/kn86-build-id. We do not currently strip the timestamp; if reproducibility-by-hash becomes a release-CI requirement, that’s a one-line patch to the stage-kn86-runtime/run.sh hook.
First-boot behavior
Section titled “First-boot behavior”On the very first boot of a freshly flashed SD card, a one-time provisioning job runs and then permanently disables itself.
Partition resize is disabled
Section titled “Partition resize is disabled”Pi-gen’s stage1 normally installs init_resize.sh and wires it into
cmdline.txt via init= so the rootfs expands to fill the SD card on
first boot. This mechanism is disabled for the KN-86. The six-partition
layout from ADR-0011 is fixed-size; partition resizing would destroy it.
The stage-kn86-runtime/00-kn86-runtime/01-run.sh hook:
- Strips any
init=/usr/lib/raspi-config/init_resize.shparam fromcmdline.txt. - Replaces the resize script body with a no-op stub so that a stale reference (from a pi-gen stage we do not control) is harmless.
Target SD size: 16 GB. On a larger card the extra space is unused (available as unallocated); on a smaller card the image will not flash because the partition sizes are hard-coded. Minimum SD size is determined by the sum of all six partition sizes in the ADR-0011 layout.
kn86-firstboot.service
Section titled “kn86-firstboot.service”A systemd oneshot unit (kn86-firstboot.service) is installed and enabled
by stage-kn86-runtime. It runs early in the sysinit.target graph
(before network.target), gated by:
ConditionPathExists=!/var/lib/kn86/firstboot.doneIf the sentinel file is present the unit exits immediately — this is the normal case on every boot after the first.
On first boot the unit invokes /opt/nosh/bin/kn86-firstrun.sh, which:
-
Regenerates
/etc/machine-idusingsystemd-firstboot --machine-id. The SD image ships with a fixed machine-id baked in; each physical device must have a unique one so that journald andsd_id128_get_machine()calls return device-specific values. -
Writes
/etc/kn86-build-id(if absent). The authoritative value is embedded in the image bystage-kn86-runtime/01-run.shfrom theKN86_BUILD_IDenvironment variable at build time. A timestamp-based fallback (local-YYYYMMDDTHHMMSSZ) is written only when the build omitted the env var (local test builds). -
Touches
/var/lib/kn86/firstboot.doneto set the idempotency sentinel. -
Disables
kn86-firstboot.serviceviasystemctl disableso the unit is removed from thesysinit.targetwants graph for all subsequent boots.
Build-time env vars
Section titled “Build-time env vars”| Variable | Default | Effect |
|---|---|---|
KN86_BUILD_ID | local-<timestamp> | Embedded in /etc/kn86-build-id at image-build time. Set by CI to v<version>-<git-sha>. |
KN86_BUILD_MODE | prod | dev drops quiet from cmdline.txt (see boot-and-systemd.md “Cmdline.txt baseline”). |
KN86_OVERLAYROOT | 0 | 1 prepends init=/init-overlay to cmdline.txt. |
Locale (GWP-350)
Section titled “Locale (GWP-350)”System locale is pinned to en_US.UTF-8 explicitly in
stage-kn86-base/00-kn86-base/01-run-chroot.sh:
sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.genlocale-gen en_US.UTF-8update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8Pi OS Lite ships with a C default; nOSh’s UTF-8 strings, journald,
and any future apt prompts work better against an explicit
en_US.UTF-8. The locales package is preinstalled via
stage-kn86-base/00-kn86-base/00-packages.
Console keymap is intentionally NOT configured. Pi OS Lite’s
keyboard-setup.service is masked by kiosk-mode.md
“Disabling unused services” and nOSh owns evdev directly — the Linux
console keymap is dead code for production-mode users. The custom
QMK keymap that ships on the KB2040 (ADR-0024)
is the operative keymap; the Linux side just receives raw HID
scancodes and hands them to nOSh.
Hostname (GWP-348)
Section titled “Hostname (GWP-348)”Hostnames are per-device, derived from the BCM2837 SoC serial:
kn86-XXXXXX where XXXXXX = lowercase hex, last 6 chars of /proc/cpuinfo SerialExample: kn86-9a3f12. The full SoC serial is 16 hex chars; the
last 6 give us ~16M-device uniqueness, which is more than enough
across any plausible KN-86 fleet, without leaking the full serial in
mDNS / ARP / dhcp-client-id.
Implementation:
/opt/nosh/bin/kn86-hostname-set— idempotent shell script. Reads/proc/cpuinfo, takes the last 6 chars, callshostnamectl set-hostname kn86-XXXXXX. Bails early if the current hostname already starts withkn86-./etc/systemd/system/kn86-hostname.service— oneshot, guarded byConditionFirstBoot=yes, runs the script beforenetwork-pre.target. Enabled at image-build time (stage-kn86-runtime/00-kn86-runtime/02-run-chroot.sh).
The unit fires once on the very first boot of a freshly-flashed
image and is a no-op on every subsequent boot. If a parallel
first-boot orchestrator (firstrun.sh from GWP-356) wants to call
/opt/nosh/bin/kn86-hostname-set directly, both call paths are
idempotent and safe to invoke in either order.
The cloud-init seed user (kn86, set by Pi Imager) creates the host
with whatever hostname the imager dialog asked for — typically
deckline or raspberrypi. The kn86-hostname unit overwrites that
on first boot.
CI integration
Section titled “CI integration”Today the release CI in release-setup.md builds emulator binaries only. System-image CI will graft onto the same release.yml workflow as a new job (Build (kn86-image)) that runs the tools/sd-provision/build-image.sh script in an ubuntu-latest runner with --platform=linux/amd64 (GitHub Actions runners are already x86_64), uploads the .img and .kn86fw as release assets to the private monorepo only (no public mirror — system images carry kernel + closed-source firmware blobs), and runs the SHA-256 verification step that the existing kn86fw builder performs.
Trigger model: same tag schema as the emulator (v0.2.0, v0.2.0-rc1). The image-build job is gated on the tools/sd-provision/pi-gen-pin.env file existing (a missing pin is a release blocker — see “Base Debian release pinning” above).