Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

boot2deb turns a laptop, SBC, tablet, or other device into a Debian device. It is a Rust-native, typed, testable builder that resolves a build from layered TOML config — an arch ← soc ← boot-method ← device hardware stack plus an orthogonal kernel axis — and drives the whole pipeline: kernel, u-boot, media-accel userspace, ffmpeg, the Debian rootfs, and a bootable disk image, all from a single committed lockfile.

The image assembly is pure Rust: GPT partitioning, ext4 formatting, and .xz or .gz compression with no C dependencies and no sudo. The Debian bootstrap, every compile, and every .deb archiving run in rootless, in-process user-namespace roots, so an x86_64 host builds an arm64 image without root and without fakeroot at all.

Your host supplies no compiler and no packaging tool. Both are packages of a provisioned Debian root, resolved from the build’s own mirror list and sha256-pinned in that root’s manifest — so what compiled and archived an image is stated by its lock rather than by whichever gcc and dpkg your distribution happens to ship. What is left on the host is git, unprivileged user namespaces, an unprivileged overlay for a build that compiles, and qemu-user for one that assembles a foreign-architecture image. boot2deb does not need to run on a Debian-family machine.

Not every board needs every stage. A build compiles a kernel only if the board needs one of its own, and builds a bootloader only if the board’s firmware is ours to make. The Turing RK1 does both — a patched mainline kernel, and u-boot written into the disk’s raw gap. The ASUS C201 Chromebook does neither: Debian’s own kernel runs it, its firmware lives in an SPI chip, and what boot2deb produces for it is a signed kernel in a ChromeOS partition. Its lock, correspondingly, pins nothing from git. The model states what is true of each board rather than making them look alike.

Where to start

  • Getting started — install the prerequisites and build your first image.
  • Adapting a shipped recipe — change the suite, the features, or the localization of an image that already builds.
  • Moving a board to a newer kernel — measure whether a patch series survives a kernel you have not adopted, then adopt it.
  • Authoring a recipe — name a build point of your own, and say what it has been taken through.
  • Turing RK1 — the shipped RK3588 configuration, and how to flash it.
  • ASUS Chromebook C201 — the shipped RK3288 Chromebook: a ChromeOS-firmware board, and a kernel that comes from Debian. Its two siblings, the C100P and the Chromebit CS10, are each a device file and nothing else.
  • Config model — how a build is described across its axes, and how the layers resolve.
  • CLI — the command reference.
  • Overlays — keep your own boards and retunings out-of-tree.
  • Adding a board — bring up a new device.
  • Adding a patch — get a patch into a build.

Getting started

This gets you from a clone of the repo to a built image. It uses the shipped turing-rk1/forky recipe as the running example; other boards build the same way with their own recipe name. Flashing and board-specific notes live on each board’s page — for the RK1, see Turing RK1.

Which track are you on? This is the shipped-recipe trackdoctor then build, for a recipe that already ships a committed lock (like turing-rk1/forky). To change what a shipped recipe builds, continue with Adapting a shipped recipe. Bringing up a new board, or authoring a patch, is the longer bring-up track: see Adding a board and Adding a patch.

The build is rootless: it uses no sudo and no loop devices. You only need root to install host packages and (on some hosts) to enable unprivileged user namespaces once.

What you need

  • A Linux host, x86_64 or arm64. An x86_64 desktop building the arm64 image is the common case and fully supported — it cross-builds under qemu-user. Debian and Ubuntu are the primary targets; Fedora and Arch work too (doctor knows their package names). macOS can run the read-only commands but cannot build.

  • A recent stable Rust toolchain, installed via rustup.

  • boot2deb on your PATH. From a clone of this repo:

    cd boot2deb
    cargo install --path crates/cli    # the crate is boot2deb-cli; the binary is boot2deb
    

    Every command on this site is written as boot2deb …, which is also how the tool writes its own hints — so anything it suggests can be pasted back. Developing from a checkout without installing? Prefix each command with cargo run -p boot2deb-cli --. Either way, run it from inside boot2deb/ (or pass --root <dir>), since the config root defaults to the current directory.

  • Disk and time. A cold build bootstraps a Debian rootfs, and — for a board that needs one — compiles a kernel and a bootloader. Budget a few GB of scratch space and tens of minutes the first time; later builds reuse cached trees. A board that compiles nothing (the C201) is much cheaper: it is a rootfs bootstrap and an image assembly.

Let doctor find what’s missing

Rather than hand-installing a package list, run doctor. It probes for every tool the build needs and, for anything absent, prints the exact install command for your distro — so you never guess a package name. doctor itself needs nothing but Rust, so it is the first thing to run after cloning:

boot2deb doctor turing-rk1/forky

Run it bare — boot2deb doctor — and it checks the requirements every board shares (user namespaces and the vendored apt trust anchors) without needing a recipe chosen yet.

It reports your host arch, the two cross answers, and one line per requirement:

host arch : x86_64
target    : turing-rk1/forky (arch arm64)
toolchain : cross — the build root carries a toolchain emitting arm64
execution : emulated — needs qemu-user binfmt for arm64 maintainer scripts and sandbox compiles

  ok      git                          /usr/bin/git
  ok      unprivileged user namespaces unshare --map-root-user --map-auto works
  MISSING qemu-aarch64-static          run target binaries under binfmt — sudo apt install qemu-user-static
  ...

result    : all required host tools present

Run the install lines it reports, then re-run doctor until it prints all required host tools present. Because the list is generated from the build’s own requirements, it is always current — this page does not repeat the package names, so there is nothing here to drift out of date.

The list is short, and that is the design rather than an omission. Every compiler, packaging tool and build dependency a build runs is a package of a provisioned Debian root, resolved from your build’s own mirror list and sha256-pinned in that root’s manifest — so it is an input your lock names, not a fact about your machine. There is no host gcc, no make, no dpkg, no fakeroot in the list, because none of them is what compiles or archives anything. What remains is the handful of things no root can carry:

GroupWhat it coversWhen
Provisioned rootsunprivileged user namespaces with a subuid/subgid range — every root is bootstrapped and entered in-process, so this is the whole requirementalways
Sourcesgit, which clones your pinned trees and applies the patch series before there is a root for them to enteronly if the recipe compiles something
Build rootsan unprivileged overlay whose upper layer sits on the work dir’s filesystem, which is how each compile root layers a stage’s build dependenciesonly if the recipe compiles something
Image assemblytar and cp. No filesystem tooling — the rootfs ext4 is formatted and then scanned back in pure Rust; e2fsck is an optional independent cross-check when present, and the image’s provenance records whether it ranonly if the recipe assembles an image
Emulationqemu-<arch>-static + a registered binfmt handler, so the target’s maintainer scripts runonly if the recipe assembles an image and this host cannot execute target binaries
Trying the imageqemu-system-aarch64 / qemu-system-arm, which boot2deb try boots the finished image under. Optional — no build stage invokes it, and doctor reports it as absent rather than missingonly if you run try

doctor asks only for what your recipe will actually invoke, so the table above is a superset. doctor turing-rk1/media-accel-forky on an x86_64 host wants every row; doctor asus-c201/forky wants no git and no overlay at all, because that board installs Debian’s kernel and boots its own firmware and so compiles nothing. That is deliberate: a requirement you do not need is somewhere a requirement you do need can hide.

Note that the last row’s two conditions are both real, and each one alone would over-ask. Emulation is about running target binaries, which only the image path does — the rootfs runs the target’s maintainer scripts, and the media-accel packages compile in a target-arch sandbox. A bootloader-only build like rk3576-generic/loader compiles in a host-arch root and executes nothing foreign, so it needs no qemu even though it builds for arm64. And an arm64 host runs armhf binaries directly (its kernel is built with CONFIG_COMPAT=y), so building the armhf C201 image there needs no qemu-arm either. Any x86_64 host building an arm64 or armhf image needs both.

The target-arch sandbox is not a cross-only concern. Packages like ffmpeg-rk and librga2 are built inside a userland bootstrapped for the target suite, never on your host, even when your host arch already matches the target. Their runtime Depends are derived from the libraries present at build time, so building them against your host’s libraries would stamp your host’s package names and versions into a .deb bound for a Debian forky image. That sandbox runs entirely in-process through unprivileged user namespaces — it needs no external sandbox tool — but it does run on every host, same-arch included, so those namespaces are a hard requirement even when nothing is cross.

The roots a build provisions

A build stands up as many as four Debian roots, each for one job, each bootstrapped and entered in-process through unprivileged user namespaces. They live in your work dir and boot2deb clean --sandbox reclaims them.

RootArchitectureWhat it holdsWhat runs in it
sandbox/cross-<arch>-<suite>-<digest>/host’sa cross toolchain emitting the target’s objects, plus each stage’s build deps layered on (~800 MB)the kernel, u-boot and out-of-tree module compiles — and the kernel’s own make bindeb-pkg, which packages itself
sandbox/build-<arch>-<suite>-<digest>/target’sbuild-essential, dpkg-dev, debhelperthe media-accel .debs (ffmpeg-rk, librga2, MPP)
sandbox/package-<arch>-<suite>-<digest>/host’sdpkg and xz-utils and nothing else (~130 MB)archiving the u-boot and kmod .debs, which boot2deb stages itself
the rootfstarget’syour image’s solved package setthe image itself

The two host-arch roots are host-arch on purpose. Neither compiling a freestanding kernel nor archiving a staged tree needs to link against the target’s libraries, so both run natively — which is what keeps a multi-minute kernel build and a hundred-megabyte xz off qemu-user entirely. The target-arch sandbox is the one that genuinely cannot be: dpkg-shlibdeps derives each media-accel .deb’s runtime Depends from the libraries present at build time.

Each root is provisioned for your build’s suite — the image’s, or for a bootloader-only build the board’s declared default — and each publishes a sha256-pinned manifest of its own packages beside your image, so “what compiled this” and “what archived this” are answered by name and version rather than by a --version line off your PATH. See Reproducibility.

There is no fakeroot anywhere in boot2deb, in any root or on your host. Every root maps you to uid 0, and uid 0 is what a Debian packaging tool actually wants: your staged tree is already root:root where dpkg-deb archives it, and dpkg-buildpackage picks no gain-root command at all. Nothing is faked because nothing needs to be.

The user-namespace check (common blocker on Ubuntu 24.04)

The rootless rootfs bootstrap, the sandbox, and the ext4 image staging all need unprivileged user namespaces with a subuid/subgid range for your user, which some hosts disable by default. doctor tests this by actually creating them (with the subuid mapping), and if it fails it prints the fix for your host. The usual cases:

  • Ubuntu 24.04+ ships an AppArmor restriction on by default:
    sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
    
  • Debian with namespaces disabled:
    sudo sysctl -w kernel.unprivileged_userns_clone=1
    
  • Either way, kernel.max_user_namespaces (or user.max_user_namespaces) must be at least 2. Every launch holds two nested user namespaces — the sandbox’s own, and the one its command enters so the kernel locks the sandbox’s mount flags — and each is charged against that ceiling. A host set to 1 creates one namespace perfectly well and fails every build at launch, so doctor probes both.
  • Your user needs a subuid/subgid range (usually present by default):
    sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
    

sysctl -w lasts until reboot; drop the same line in /etc/sysctl.d/ to make it persist.

On a build that assembles an image for an architecture your host cannot execute, doctor also checks that the qemu-<arch> binfmt handler is registered and enabled with the F (fix-binary) flag — the rootfs bootstrap relies on it. Installing qemu-user-static (with binfmt-support / systemd’s binfmt) normally registers this; doctor warns if the flag is missing.

The overlay check

Every stage that compiles gets a build root: the shared base plus that stage’s own build-dependencies, layered on with an unprivileged overlay and discarded afterwards. doctor probes whether your host can establish one, and prints the directory it probed.

That is why the overlay is a requirement of compiling rather than of every build. A board that installs Debian’s kernel and boots its own firmware layers nothing and needs only user namespaces — and so does a rebuild whose artifacts all restore from the cache, which never stands a build root up at all.

The probe is pointed at the work dir’s filesystem, not at /tmp, because that is where the overlay’s upper layer goes and the two can answer differently. An unprivileged overlay records its whiteouts in user.* extended attributes: every on-disk filesystem (ext4, xfs, btrfs) holds them, but tmpfs only gained them in Linux 6.6. So a host with a tmpfs /tmp on an older kernel would fail a /tmp probe and still build fine with its work dir on disk. If you build with --work-dir, pass the same path to doctor so it asks about the filesystem you will actually use.

The mount itself needs Linux 5.11 or later, which is where overlay in a user namespace arrived.

Build

With doctor green:

boot2deb build turing-rk1/forky

This resolves the recipe’s committed lockfile and runs the pipeline end to end. For turing-rk1/forky that is: compile the kernel and u-boot, bootstrap the Debian rootfs, and assemble a bootable disk image. A recipe runs only the stages it hasturing-rk1/media-accel-forky adds the Rockchip media userspace and ffmpeg on top of those, while build asus-c201/forky compiles nothing at all, so it is a rootfs bootstrap and an image assembly and nothing else.

The build reads only the lock, so it consults no network for its pins and is reproducible from what is committed. A patch series, where a recipe has one, is fetched automatically at its pinned commit if the config root’s sibling ../patches checkout is not already present — you do not need to clone it separately. That holds for both patch axes: the repo comes from the lock’s own pin, so a u-boot-only recipe such as rk3576-generic/loader fetches its series the same way a kernel recipe does.

The rootfs bootstrap is content-cached, so a rebuild whose solved package set is unchanged skips the multi-minute bootstrap. To force a clean rootfs, add --refresh-rootfs. To build a single stage, pass --stage (kernel, dtb, kmod, uboot, userspace, ffmpeg, rootfs, image) — see the CLI reference.

What you get

Artifacts land under the recipe’s work dir, build/turing-rk1/forky/artifacts/:

  • turing-rk1-forky.img.xz — the compressed bootable image.
  • turing-rk1-forky.provenance.toml — exactly what went into the image: the resolved pins, package count, toolchain identity, the archives the rootfs resolved against, and the first-boot credential.
  • turing-rk1-forky.plan — the package set as an installable document: every version and sha256, plus the state of each archive they came from. boot2deb reproduce replays it to rebuild this exact userland later. See Reproducibility.

Every artifact is named for the whole build point — device and recipe (turing-rk1/forkyturing-rk1-forky) — so several recipes can share one --out-dir, and an image copied to a flashing host still says what it is.

The build prints the exact paths on its final lines, including the credential:

compressed    : .../build/turing-rk1/forky/artifacts/turing-rk1-forky.img.xz
first-boot pw : <generated>  (user debian, expired — change at first login)
provenance    : .../build/turing-rk1/forky/artifacts/turing-rk1-forky.provenance.toml

Note the first-boot password down. It is unique per image, shown once here, and stored only in the provenance file — it exists nowhere on the running system in recoverable form. It is also expired, so the first login has to replace it.

If you would rather not transcribe a password at a console, authorize your SSH key in the recipe instead and ssh debian@<board> works on the first boot. That, and the sudo policy the image ships with, are in The account, sudo, and SSH keys.

Next: flash the image. That step is board-specific — for the RK1, see Turing RK1.

Producing images

boot2deb press turns a build’s artifacts into the file you hand a flasher: one master, many cards, each optionally stamped with its own identity.

boot2deb press turing-rk1/forky card.img

The command resolves the recipe exactly as build does and derives the rest from it: a combined build presses one whole-disk image, a u-boot deliverable presses its standalone boot image, and a --layout split build is two files for two media — --boot-out for the eMMC/SPI half, --rootfs-out for the OS disk. A split build pressed with one positional output is an error naming both flags, not a wrong file.

A plain press streams the build’s compressed artifact into the output — pressing a card never costs a rebuild — and then verifies the file it wrote: the bytes are re-read against the digest computed while streaming, and the partition table is read back and compared entry-for-entry against the one the artifact carries. What you hand the flasher is what the build made. --dry-run prints what would be pressed, including the medium size the image needs, without writing anything.

Flashing the pressed file

boot2deb does not write devices. The pressed image is an ordinary raw disk image; write it with whatever flasher you trust:

  • pyrographer — a flasher with a plan→confirm→write gate, device safety checks, and native rockusb.

  • Plain dd, the universal fallback:

    lsblk    # confirm the device first; dd overwrites it whole
    sudo dd if=card.img of=/dev/sdX bs=4M status=progress conv=fsync
    
  • A board’s own route — the Turing Pi BMC (tpi flash -n 2 -l -i card.img or the web UI), a vendor tool — anything that writes a raw image.

Boards’ pages say which media each board boots from; build also prints the matching dd line for the artifact it just made.

Per-unit personalization

One image, six boards, no collisions:

boot2deb press turing-rk1/forky rk1-03.img \
    --hostname rk1-03 --ssh-key "$(cat ~/.ssh/id_ed25519.pub)"

Every image carries a 1 MiB FAT partition labeled b2d-seed holding a seed.txt of key=value lines. press regenerates the whole partition with what you name — never edits it in place — and the device applies it once at first boot:

keyflagwhat the device does
hostname=--hostnamehostnamectl plus /etc/hosts
authorized_key=--ssh-key (repeatable)appends to the default account’s authorized_keys
wifi_ssid=--wifi-ssidwrites a NetworkManager connection profile and joins the network
wifi_psk=--wifi-pskthe WPA passphrase for wifi_ssid; omit it for an open network
static_ip=--static-ippins a static IPv4 (address/prefix[,gateway[,dns...]]) on the connection the seed sets up

The Wi-Fi keys are the canonical per-site values that must never sit in a committed recipe. They apply on images that carry NetworkManager — every Wi-Fi-capable board’s does — and degrade to a logged skip elsewhere; like every seed key, they are plain text in the seed partition, which personalizes a unit rather than keeping secrets. An image pressed with no keys carries the empty template and behaves exactly as an unpersonalized image always has.

static_ip= follows the connection the other keys define: seeded together with wifi_ssid it makes that Wi-Fi profile static, and seeded alone it pins the first wired interface — through NetworkManager where the image carries it, through dhcpcd.conf where it carries dhcpcd, with a logged skip where it carries neither. Fields beyond the address are optional: no gateway field means no default route is written, no DNS fields mean no resolvers are — the key personalizes exactly what it names. Only the syntax is validated at press time (IPv4 dotted quads, a /1/32 prefix); the address plan is yours.

Because the seed is FAT, an operator can also edit it with no tooling at all: plug the card into any laptop, open seed.txt on the B2D-SEED volume, change the hostname, eject. The file documents its own keys.

To re-personalize a pressed file without re-pressing it:

boot2deb seed rk1-03.img --hostname rk1-04

seed takes no recipe — the seed partition is found by its GPT label, so the file is the whole input. With no keys it resets the seed to the empty template. It refuses block devices (boot2deb does not write them); a card that is already written is re-personalized by editing seed.txt directly.

The first-boot password stays per image, not per unit: boards pressed from one streamed artifact share that build’s expired password, and --ssh-key is the answer to a fleet. (A press with additions re-assembles, and so draws a fresh password of its own — printed when it happens.)

Tree additions

What belongs to a unit or a site, rather than to the recipe? A recipe describes every board of a kind; press stamps one card. Additions put arbitrary files into the pressed image’s filesystem:

boot2deb press turing-rk1/forky site.img \
    --copy site.conf:/etc/myapp/site.conf \
    --deb ~/build/myapp_1.2_arm64.deb
  • --copy SRC:DEST (repeatable) — a host file placed at an absolute path in the tree: a site config, a one-off script, a data file. Mode 0644 (0755 when the source is executable), owner root; missing parent directories are created. Copying over a shipped file replaces it.
  • --copy-tree DIR (repeatable) — a whole directory that mirrors the target rootfs: DIR/etc/myapp/site.conf lands at /etc/myapp/site.conf. See A directory that mirrors the rootfs.
  • --deb PATH (repeatable) — a local .deb staged into /var/lib/boot2deb/firstboot-debs/, installed at first boot with dpkg -i (alphabetical). The honest caveat: dpkg -i resolves nothing, so a dependency not already in the image leaves the package unconfigured until apt-get -f install can run — which the hook attempts only if the board has network by then. The use case is the locally-built, self-contained deb you are iterating on.
  • --embed-image — see Installing to internal storage.

A directory that mirrors the rootfs

Per-site customization of any size is a directory, not a stack of flags:

boot2deb press turing-rk1/forky rk1-03.img --copy-tree ./site
site/
  etc/
    myapp/
      site.conf              ->  /etc/myapp/site.conf
      node.conf.tmpl         ->  /etc/myapp/node.conf   (expanded, see below)
    current.conf -> /etc/myapp/site.conf   (stays a symlink)
  usr/local/bin/
    site-hello               ->  /usr/local/bin/site-hello  (0755, it is executable)

Every regular file and symlink under DIR is placed at its corresponding absolute path. Directories are not placed as entries of their own — the parents each file needs are created root-owned 0755, so your site tree’s umask never reaches the image. Files land 0644, or 0755 when they are executable on the host, exactly as --copy does; a symlink is recorded as a symlink and never followed, so a link pointing outside the tree lands as the link it is.

The refusals are per file and name what they found: a destination the reserved set owns (/etc/shadow, /etc/boot2deb/image.toml), a path under /dev, or an entry that is neither a regular file, a symlink, nor a directory — a device node, FIFO, socket, or hard link is refused rather than silently dropped. A directory that names no files at all is an error too, since a --copy-tree that quietly added nothing would leave you with a plain streamed image.

There is no patch.sh, and that is deliberate: --copy-tree places files, and anything needing logic rather than placement belongs in a feature, where it runs with package resolution and maintainer scripts behind it.

Files that depend on the image: *.tmpl

A copy is byte-for-byte, which cannot express a file whose content depends on the image it lands in. A source named *.tmpl is a template: its {{image.<name>}} references are expanded at press time and it lands without the suffix.

# site/etc/myapp/node.conf.tmpl        # /etc/myapp/node.conf, in the image
node_id  = {{image.hostname}}          node_id  = rk1-03
root_dev = PARTUUID={{image.rootfs_partuuid}}
                                       root_dev = PARTUUID=16e51e55-5916-...
built    = {{image.recipe}}            built    = turing-rk1/forky

The point of it is the identifiers. rootfs_partuuid, rootfs_uuid and the rest are derived by boot2deb from the recipe, so without a template naming one in a config file would mean pressing the image, reading its GPT back, editing, and pressing again. Everything else in the set is a convenience.

The vocabulary is the image’s identity — every name is a field of the /etc/boot2deb/image.toml the image carries, or one of the identifiers stamped into its GPT and superblock — and it is closed:

referencewhat it expands to
{{image.hostname}}the name this unit will answer to: the --hostname seed key when the press names one, else the recipe’s
{{image.device}}board slug (turing-rk1)
{{image.description}}the board’s human-readable description
{{image.arch}}Debian architecture (arm64)
{{image.soc}}SoC slug (rk3588)
{{image.boot_method}}boot method (rockchip-rkbin)
{{image.suite}}Debian suite (forky)
{{image.layout}}combined or split
{{image.kernel}}kernel definition id (rk3588-mainline-7.2)
{{image.recipe}}the build point pressed (turing-rk1/forky)
{{image.rootfs_partuuid}}the rootfs partition’s PARTUUID, hyphenated — the form root=PARTUUID= and /etc/fstab take
{{image.rootfs_uuid}}the rootfs ext4 superblock UUID, hyphenated — the form UUID= takes
{{image.seed_partuuid}}the seed partition’s PARTUUID, hyphenated
{{image.disk_guid}}the GPT header’s disk GUID, hyphenated

A name outside the set is an error at press time, listing the whole vocabulary — never an empty string in a shipped config that only fails on the board. Names are checked when the flags are parsed, so a typo fails before any artifact is read.

{{ is claimed only when image. follows it, so a file that carries braces of its own — a Go, Helm, or Jinja template shipped as data — passes through untouched. The flip side is that a mistyped namespace ({{iamge.suite}}) is literal text rather than an error, which is why press reports the reference count per template: expecting four expansions and being told three is how you find it.

template -> /etc/myapp/node.conf (3 reference(s))

--copy honours the same suffix — --copy node.conf.tmpl:/etc/myapp/node.conf expands — but the destination there is exactly what you named; the suffix decides a destination only in a tree, where the destination is derived. To ship a file that really is called *.tmpl, name it foo.tmpl.tmpl: it expands and lands as foo.tmpl. A template must be UTF-8 and is read whole to be parsed, so it is capped at 1 MiB — drop the suffix to copy a large file verbatim.

A press with additions cannot stream: it re-assembles the image from the build’s kept artifacts (the rootfs tar, the boot payloads), merging the additions into the filesystem before it is formatted. The build must have run on this machine; the recipe’s artifacts are read, never modified. Under a fit-sized recipe the filesystem grows to hold whatever was added; under a fixed image_size a press that does not fit fails in the format. The re-assembled rootfs passes the same verification a build’s does (the in-process scan, plus e2fsck -fn where the host has it).

Additions do not re-run package resolution or maintainer scripts. Anything that needs those is a build — the recipe and feature path exists for it.

What a pressed image says about itself

A pressed image with additions is derived, not canonical. The recipe’s artifacts and their provenance stay untouched; the pressed file records its own ancestry in /etc/boot2deb/image.toml as a [pressed] table — the source artifact stem and what was added, by kind and destination, never by content. A tree’s entries are recorded there one destination at a time, exactly as a --copy is, and a template by the name it landed under rather than the one it was authored as. reproduce reproduces builds, not pressings. The seed partition is not summarized there: it is self-describing, and boot2deb seed can rewrite it later without touching the filesystem.

Installing to internal storage

Some boards boot from removable media but live on internal storage — boot a Chromebook from SD, install to its eMMC. --embed-image carries the recipe’s own compressed artifact inside the pressed image, at /var/lib/boot2deb/install/:

boot2deb press asus-c201/forky card.img --embed-image

On the booted board, boot2deb-install-to (in every image) writes the embedded artifact to the internal disk, wrapping the documented dd procedure with the checks that matter: the target must be a whole disk, must not be the disk the system is running from, must have nothing mounted — and the confirmation requires typing the device’s name.

sudo boot2deb-install-to /dev/mmcblk0

The pressed card is a derived copy that carries the artifact; the embedded image is the artifact itself, byte for byte. --embed-image needs a combined-layout recipe built with compression on (the default).

A split build

boot2deb press turing-rk1/forky --layout split \
    --boot-out emmc-boot.img --rootfs-out nvme.img

The boot image goes to the medium the board boots from (eMMC or SPI), the rootfs image to whatever disk the OS lives on. The seed — and any addition — rides with the rootfs, so personalization lands on the disk the OS reads; the boot image is streamed unchanged.

Upgrading the kernel

This page is about the kernel on a board that is already running. Moving a recipe to a newer kernel version — re-pinning the tag, and measuring whether the patch series survives the bump — is Moving a board to a newer kernel.

On a ChromeOS-firmware board — the C201, the C100P, the Chromebit, and every other board using the depthcharge boot method — upgrading the kernel is apt upgrade, and it is atomic and reversible. If a new kernel does not boot, the firmware puts the old one back on its own. You do not have to do anything, and you do not need a USB stick.

That is worth stating plainly, because on these boards it is not obvious. The kernel is not a file in /boot that a bootloader reads. It is a vboot-signed blob written raw into a partition, and changing it means re-signing it and rewriting that partition — which is exactly the operation you cannot afford to get wrong, because it is the only thing standing between the board and a firmware screen.

How it works

The image ships two ChromeOS kernel slots, KERN-A and KERN-B. The kernel lives in one of them; the other is empty. Both are ordinary GPT partitions, and the firmware picks between them using three fields in each partition’s GPT attribute bits:

fieldmeaning
priorityboot order among candidates; 0 means never boot
triesattempts remaining, decremented before each attempt
successfulknown-good; stops the firmware spending tries

A slot is a boot candidate while priority > 0 and (successful or tries > 0).

When a kernel package is installed — by apt, or by dpkg -i on a .deb you built yourself — Debian’s depthcharge-tools package runs its /etc/kernel/postinst.d hook, which:

  1. rebuilds the kernel FIT (kernel + device tree + initramfs) and signs it,
  2. writes it into the slot the board is not currently booted from,
  3. marks that slot highest-priority with tries = 1, successful = 0.

On the next boot the firmware spends that single try on the new slot. If the system comes up, depthcharge-tools.service runs depthchargectl bless, which sets successful and commits the upgrade. If the system never comes up, the try is already spent, the slot stops being a candidate, and the firmware falls back to the other slot — which still holds the kernel that worked, still marked successful.

So a failed kernel upgrade costs you one reboot. Nothing else.

The spare slot is the whole mechanism. An image with only one kernel slot cannot do any of this: the only slot is the one you are running from, so an upgrade has to overwrite the running kernel in place, and a kernel that does not come up leaves the board with nothing to boot and no way in but external media.

Doing it

Nothing special:

sudo apt update && sudo apt upgrade

If the upgrade includes a kernel you will see depthchargectl run in the output. Reboot when it finishes. That is the entire procedure.

Checking which slot you are on

sudo depthchargectl list

That prints every ChromeOS kernel partition on the disk with its size and its S/P/T (successful / priority / tries) attributes. A healthy board after a committed upgrade looks like two slots, both S=1, the running one at the higher priority.

The board also tells the running system which slot it booted from: the firmware substitutes that partition’s PARTUUID into kern_guid= on the kernel command line.

grep -o 'kern_guid=[^ ]*' /proc/cmdline

That value is what depthchargectl uses to know which slot it must not overwrite.

Rolling back on purpose

If a kernel boots but is bad in some way you only notice later, mark it unbootable and reboot — the firmware falls back to the other slot:

sudo depthchargectl bless --bad      # zero the running slot's attributes
sudo reboot

Then pin or downgrade the kernel package so the next apt upgrade does not simply put it back.

When the write fails: the payload ceiling

The signed blob must fit its kernel slot — 16 MiB on stock firmware (a board page may list roomier firmware series). depthchargectl builds the new image first and writes second, so when nothing it tries fits under the ceiling, it fails without touching the slot:

Couldn't build a small enough image for this board

Nothing is broken when this prints. The slot still holds the payload the board booted from and the board keeps booting — but the change that triggered the rebuild has not reached the slot, and nothing will until the payload fits again.

The payload is kernel + device tree + initramfs, and the part that grows is the initramfs. Under the stock ceiling the image ships it deliberately small — an explicit module list (MODULES=list) and xz compression, which leaves about 2 MB of headroom. A board with a roomier firmware buffer spends that margin instead: boot2deb resolve shows which compressor a build gets on its initramfs line, and a board at COMPRESS=zstd has slot to spare and is unlikely to meet this failure at all.

What spends that headroom is initramfs-tools hooks, and the one that spends it all at once is plymouth. Desktop metapackages (cinnamon-desktop-environment, task-gnome-desktop, and the rest) pull plymouth in through Recommends, desktop-base registers a graphical boot theme, and plymouth’s hook then copies the splash daemon, its renderers, the theme, and the text plugin with its whole font stack into every initramfs built afterwards. That is several MB, and the next slot write — a kernel upgrade, or any package that triggers update-initramfs — fails as above.

On a board at the stock ceiling the cost buys nothing anyway. Its initramfs carries no DRM modules, so plymouth cannot draw before the root pivot regardless — it says so itself during the rebuild:

W: plymouth: not including drm modules since MODULES=list

That warning is about plymouth’s own hook declining to add modules, so it prints on any MODULES=list board. It only means “plymouth is dead weight” where nothing else put a DRM module in the initramfs; a board that carries the display stack in its module list has one either way.

Remove it:

sudo apt purge 'plymouth*'

The purge re-triggers the initramfs rebuild and the slot write; watch the depthchargectl run in the output succeed. Nothing depends on plymouth — desktops only recommend it — and no configuration keeps an installed plymouth out of the initramfs (its initramfs-tools fragment overrides any admin setting), so removing the package is the supported answer. If the original failure aborted an apt run partway, finish it first with sudo dpkg --configure -a.

If the write still fails, something else grew the initramfs. List its contents by size and look for what does not belong:

lsinitramfs -l /boot/initrd.img-$(uname -r) | sort -k5 -rn | head -20

A healthy initramfs for these boards is 7–8 MB compressed (ls -lh /boot/initrd.img-*).

Does it differ with a compiled kernel?

No — the mechanism is identical. The hook that re-signs and writes a slot is triggered by the kernel package’s own maintainer script, not by apt, so it fires for any linux-image .deb that gets configured, however it arrived. A boot2deb-compiled kernel and Debian’s stock linux-image-armmp take exactly the same path through the same tool, and both get the same A/B safety.

What differs is delivery, and only that:

where the kernel comes fromhow you upgrade
distro kernel (debian-armmp)the Debian mirrorapt upgrade
compiled kernelboot2deb’s --stage kernel outputcopy the .deb to the board, dpkg -i

A compiled kernel is not on any mirror, so nothing will ever offer it to you — you deliver the .deb yourself. Once dpkg configures it, the slot is written and the reboot is as safe as any other.

The upgrade unit is the .deb, not the signed blob. It is tempting to think of the signed kernel partition as the thing you ship, and it is not. The signed blob contains the kernel, the device tree, and the initramfs — but not the kernel modules, which live on the root filesystem in /lib/modules/<version> and are where Wi-Fi, graphics and sound actually come from. A kernel written without its modules boots into a system with no drivers. The .deb carries both, which is why it is what you move around.

The signed blob is also specific to the image it was built from: the rootfs PARTUUID is baked into the signature, and every device keeps the PARTUUID its image was stamped with. A blob signed for one image’s PARTUUID cannot find root on a disk flashed from a different image.

The other boards

On a rockchip-rkbin board — the Turing RK1, the H96 — none of this applies. There the kernel is an ordinary file in /boot, the bootloader is u-boot reading extlinux.conf, and a kernel upgrade rewrites that config file. It is simpler, and it has no rollback: the boot configuration is a file, so a bad kernel is fixed by editing it back, which needs a keyboard and a screen or a serial console.

Locale, timezone, and keyboard

There are two ways to set these, and both are supported on purpose:

  • Before a build — declare them in the layered config. They are resolved, recorded in the image’s provenance, and baked in. Nothing asks a question at boot.
  • On a running imagedpkg-reconfigure the relevant package, exactly as on any Debian system, with no network. This works because the image already ships locales, keyboard-configuration, and console-setup, and because the locales are already compiled onto the disk.

The second is the reason the first is not enough. A pre-built image is something you hand to someone else; they should not have to rebuild it — or get it onto a network — to type on a German keyboard.

The knobs

fieldlayerdefaultwhat it sets
localebase.tomlC.UTF-8LANG in /etc/locale.conf
locales_generatebase.toml17 widely-spoken localesextra locales compiled into the image
timezonebase.tomlUTCthe /etc/localtime symlink
keymapdevices/<board>.tomlnone/etc/default/keyboard (the XKB variables)

Each is overridable in a recipe. resolve and doctor additionally take --locale, --locale-gen (repeatable), --timezone, and --keymap, so you can see what a different choice resolves to before committing it to config:

boot2deb resolve asus-c201/forky \
    --locale de_DE.UTF-8 --timezone Europe/Berlin --keymap de

build takes none of them, and that is the design: an image’s localization comes from the config its lock was resolved against, so changing what an image ships means changing base.toml or the recipe — not a flag at build time. resolve closes the loop rather than leaving you to find that out: when an override it accepts is one build does not, it prints the recipe to write, with the keys already filled in.

note: --locale, --timezone, --keymap are resolve-only — `build` reads those axes from
the config its lock was resolved against, not from a flag. To build this point, write it
down: recipes/asus-c201/<leaf>.toml with
    device   = "asus-c201"
    locale   = "de_DE.UTF-8"
    timezone = "Europe/Berlin"
    keymap   = "de"
then `boot2deb update asus-c201/<leaf>` to pin it.

resolve shows what a build will bake in:

locale       : C.UTF-8 (generated: C.UTF-8, en_US.UTF-8, en_GB.UTF-8, de_DE.UTF-8, ...)
timezone     : UTC
keymap       : us [pc105]

Why the locale and the keymap live on different layers

The locale and the timezone are distro policy: no board has an opinion about them, so they sit in base.toml.

A keymap is different — whether a console keymap configures anything at all is a property of the hardware. The C201 and the C100P are laptops with keyboards under the user’s hands and a US layout; the Turing RK1 and the H96 are headless, and a layout declared for a console nobody types at is a claim the config cannot back. So keymap sits on the device, and a headless board simply omits it: boot2deb then writes no /etc/default/keyboard and Debian’s own default (pc105 / us) stands.

The Chromebit CS10 shows what the field is really asking. It has no keyboard at all, and it declares keymap = "us" anyway — because it is not headless: it drives an HDMI console, and a USB keyboard is the only way to type at it. The question is “does a console layout configure anything here?”, not “does the board ship keys”. It does, so it answers.

You can still pass --keymap to a headless board. console-setup ships on every image, so a keymap is always actionable — plugging a USB keyboard into the RK1’s HDMI console is a real thing to do. A headless board just has no reason to default one.

Why the default locale is C.UTF-8 and not en_US.UTF-8

C.UTF-8 is a complete UTF-8 locale built into glibc. It is also neutral: this project targets no one country, and a US locale is not a better default than any other.

en_US.UTF-8 is nevertheless generated into every image, and that is not a contradiction — see the next section.

Which languages ship

Every image carries these compiled, in addition to the system locale:

en_US en_GB de_DE fr_FR es_ES it_IT nl_NL pt_BR pl_PL uk_UA ru_RU vi_VN ja_JP ko_KR zh_CN zh_HK zh_TW — all .UTF-8.

It is a set of widely-spoken languages, not a complete one, and it is deliberately not just English. Two reasons it can afford to be this wide:

  • glibc’s locale archive shares data aggressively. Measured on forky/arm64, /usr/lib/locale/locale-archive is 2.9 MiB with C.UTF-8 + en_US.UTF-8 alone, and 19.2 MiB with the full set — about 1 MiB per added language, not the several MiB a standalone locale suggests.
  • A locale can only be compiled at build time. locale-gen runs during the image build; no package a user installs later will generate one for them. So a first-run desktop wizard offers exactly the languages the image chose, and a graphical installer that lists one language is showing the truth about the image, not a bug in the desktop.

Anything outside the set is still one dpkg-reconfigure locales away with no network — see Adding a language.

The Setting locale failed warning

SSH into a fresh board and you may see:

perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
	LANGUAGE = (unset),
	LC_ALL = (unset),
	LANG = "en_US.UTF-8"
    are supported and installed on your system.

Nothing on the image is broken. Debian’s stock openssh-server ships AcceptEnv LANG LC_*, so your client forwards its own LANG into the session. If that locale was never generated on the target, setlocale() fails and every Perl-based tool says so.

That is one of the reasons en_US.UTF-8 leads locales_generate: it makes the common client’s forwarded locale resolve.

The shipped set covers most clients, but it is not a general fix — a client forwarding a locale outside it still warns, and chasing every locale by pre-generating it is whack-a-mole. The actual fix is that the locale is changeable, which is what the rest of this page is about. To silence it for one session without changing anything:

LANG=C.UTF-8 ssh board

Do not “fix” this by removing AcceptEnv LANG LC_* from sshd_config. It is standard Debian behaviour, and silently dropping it surprises anyone who relies on it.

Changing them on a running image, offline

All three are the ordinary Debian commands. None of them needs the network, because the packages and the locale data are already on the disk.

Locale. dpkg-reconfigure is the authoritative path — it generates the locale and sets the default:

sudo dpkg-reconfigure locales     # tick the locales to generate, then pick the default

localectl also works on a boot2deb image, and it is worth knowing why: Debian builds systemd-localed with locale-gen support, so localectl set-locale will add the locale to /etc/locale.gen and run locale-gen itself — but only if /usr/sbin/locale-gen exists, i.e. only if the locales package is installed. On an image without it, localectl would set a LANG naming a locale that was never generated. boot2deb ships locales, so:

sudo localectl set-locale LANG=de_DE.UTF-8

is safe here. Reconnect for it to take effect on your session.

Timezone. Either command works; both write the /etc/localtime symlink, which is the only thing that reads as the system timezone (forky’s tzdata no longer keeps an /etc/timezone file at all):

sudo timedatectl set-timezone America/New_York
sudo dpkg-reconfigure tzdata      # the menu-driven equivalent

The zone decides how the clock is rendered, not whether it is right. These boards have no battery-backed RTC, so the clock is stale for the first seconds of every boot — see The clock and time sync for what the image does about that and how to point it at a different NTP server.

Console keymap.

sudo dpkg-reconfigure keyboard-configuration   # then: sudo setupcon

setupcon applies the new layout to the current console without a reboot.

Why dpkg-reconfigure opens on the right values

boot2deb writes /etc/locale.gen, /etc/locale.conf, /etc/default/keyboard, and the /etc/localtime symlink before the packages that own them are configured, not after. Debian’s locales, keyboard-configuration, and tzdata each seed their debconf answers from those exact files when they install, so the shipped files, the debconf database, and the console-setup cached keymap all agree.

The practical consequence: dpkg-reconfigure locales on the running board opens with your locales already ticked and your default already selected — not Debian’s. Had the files been written after the packages, they would still be correct on disk, and debconf would still be holding Debian’s defaults underneath them.

Adding a language

Two places to do it, depending on whether you want it on this board or on every image you build.

On a running board, no network needed:

sudo dpkg-reconfigure locales        # tick the extra languages, keep or change the default

Tick as many as you like and leave the default alone if you only want the language available — a desktop’s language picker reads the generated set, not the default. The new locales are compiled on the spot.

In an image, so every board built from it ships the language: edit locales_generate in base.toml (all images) or in the recipe (that build point only). There is no build-time flag for it, deliberately — see The knobs. Each entry is a full locale name with its codesetsv_SE.UTF-8, not sv_SE; resolution rejects the bare form rather than let locale-gen fail mid-build. The system locale is always generated whether or not it appears in the list, so it never needs repeating.

Valid names come from /usr/share/i18n/SUPPORTED on any Debian system — it lists language_TERRITORY.codeset pairs, not language names, so search by the two-letter language code and take the .UTF-8 line:

grep '^sv_.*UTF-8' /usr/share/i18n/SUPPORTED    # sv_FI.UTF-8, sv_SE.UTF-8

Budget roughly 1 MiB of image per added language, and check the result with resolve before building:

boot2deb resolve h96-max-m9/forky | grep locale

There is no locales-all option here, and that is on purpose: it carries every locale Debian has for 231 MiB installed.

Notes for the curious

  • /etc/locale.conf, not /etc/default/locale. Debian makes the latter a symlink to the former, and systemd-tmpfiles re-asserts that link with a forcing rule (L+) — so a regular file written at /etc/default/locale is deleted and replaced by the symlink on the next boot. Writing the symlink’s target satisfies every reader: pam_env through the link, systemd/localectl directly, and the locales package, whose config script reads that path to learn the current default.
  • The system locale is always generated, even C.UTF-8, which glibc would provide ungenerated. The locales package builds the choice list that dpkg-reconfigure locales offers for the default locale out of /etc/locale.gen — so a system locale missing from that file is one the user cannot see or re-select on the board.
  • Not locales-all. It carries every locale Debian has, at 231 MiB installed. The three packages boot2deb ships cost about 44 MiB installed (measured on forky/arm64), plus 19.2 MiB for the generated locale archive — call it ~63 MiB on the image. On a 2 GiB rootfs that is about 3%, and it compresses well into the shipped .img.xz.

The clock and time sync

None of the boards here has a battery-backed real-time clock. That is the hardware, not a gap in the config, and it decides how every image handles time: the clock is wrong for the first few seconds of every boot, and everything that cares about a date has to wait for it.

This page is about when the clock is right. For the timezone it is rendered in, see Locale, timezone, and keyboard.

What a board with no RTC does at boot

The kernel starts at the epoch. systemd then advances the clock to the later of its own compiled-in epoch and the mtime of /var/lib/systemd/timesync/clock, a file systemd-timesyncd rewrites periodically while it runs. So a board comes up at roughly whenever it was last powered on — a plausible time, and as stale as the gap since then. Seconds later, once the network is up, timesyncd reaches an NTP server and steps the clock to the real one.

The window between those two events is short and entirely real. Run apt update inside it and you get:

E: Release file for http://deb.debian.org/debian/dists/forky/InRelease is not valid yet
   (invalid for another 2h 51min 12s). Updates for this repository will not be applied.

or a TLS certificate that is “not yet valid”, or gpg reporting a signature made in the future. Nothing is broken. The clock is simply behind, and every one of those checks is a date comparison against it. Wait a few seconds and run it again.

What the image does about it

Debian already orders its maintenance jobs behind time-sync.targetapt-daily, apt-daily-upgrade, logrotate, man-db, fstrim, e2scrub_all, dpkg-db-backup, and anacron all declare After=time-sync.target. On a stock system that ordering is inert, because nothing reaches that target unless systemd-time-wait-sync is enabled. Every boot2deb image enables it, so the ordering means what it says: those jobs do not run until the clock is trustworthy.

The image also bounds the wait at 45 seconds, which matters more than enabling it. The stock unit ships TimeoutStartSec=infinity, and Debian’s anacron.service is both After=time-sync.target and Before=multi-user.target — so with no reachable time source, the unmodified unit leaves multi-user.target, graphical.target, and timers.target permanently inactive, and systemctl is-system-running stuck at starting forever. Logins still work, which is what makes it a bad failure: sshd, getty, and a display manager are all pulled in by their targets rather than ordered behind them, so the board looks fine while no timer ever fires again.

With the bound, a board that cannot find a time source gives up and finishes booting. Measured on the H96 MAX M9: 13.7 s to graphical.target with NTP reachable, 50.9 s with it unreachable, and no failed units either way.

The drop-in that does this ships in the base overlay at base/overlay/etc/systemd/system/systemd-time-wait-sync.service.d/bounded.conf.

Choosing a time server

fieldlayerdefaultwhat it sets
ntp_serversbase.tomlemptyNTP= in /etc/systemd/timesyncd.conf.d/10-boot2deb.conf

Empty is the default and writes no configuration at all. Debian compiles its own pool into systemd as FallbackNTP, which is correct on any network with a route out and assumes nothing about where a board is plugged in. timedatectl show-timesync on a stock image shows exactly that:

FallbackNTPServers: 0.debian.pool.ntp.org 1.debian.pool.ntp.org ...
ServerName:         2.debian.pool.ntp.org

Naming servers sets NTP=, which timesyncd prefers, and leaves the fallback pool alone. A configured server is therefore a preference rather than a commitment: an image built for one network still finds the time on another. That is deliberate, and it is why this never writes FallbackNTP=.

Two reasons to set it:

  • An isolated network, where the public pool cannot be reached at all. Without a server it can reach, a board never syncs — it boots, waits its 45 seconds, and carries on with a stale clock.
  • A LAN time source on any network. It answers in about a millisecond instead of an internet round trip, which shortens the window this page opens with.

In base.toml, for every image:

ntp_servers = ["ntp.lan", "192.168.1.1"]

or in a recipe, for one build point only. A recipe’s list replaces the base list rather than adding to it, so ntp_servers = [] in a recipe is how one point opts back out to the Debian pool.

resolve and doctor take --ntp-server (repeatable) so you can see what a choice resolves to before writing it down:

boot2deb resolve h96-max-m9/forky --ntp-server ntp.lan
timezone     : UTC
ntp servers  : ntp.lan

An unconfigured image prints (Debian fallback pool) there rather than an empty line — “no servers configured” and “no time source” are different things, and only the first is true.

Like the localization flags, build takes none of these: an image’s time config comes from the config its lock was resolved against, not from a flag at build time.

What resolution checks

Each entry must be a bare host — a hostname or an IP address, with no scheme, no port, and no whitespace. timesyncd parses NTP= by splitting on spaces, so an entry with a space in it silently becomes two servers, and one with a scheme or a port becomes a server the resolver can never answer. All of these are rejected at resolve time rather than at boot:

ntp://ntp.example.org      a scheme
ntp.example.org:123        a port (timesyncd always uses 123)
[fd00::1]                  the bracketed form is for URLs
ntp.a ntp.b                whitespace: two servers in one entry

IPv6 goes in unbracketed: fd00::1.

Changing it on a running image

The ordinary systemd path, no rebuild needed:

sudo timedatectl show-timesync        # what it is asking now
sudoedit /etc/systemd/timesyncd.conf.d/10-boot2deb.conf
sudo systemctl restart systemd-timesyncd

To force a resync immediately:

sudo systemctl restart systemd-timesyncd
timedatectl                            # "System clock synchronized: yes"

To set the clock by hand on a board that has no time source at all, turn NTP off first — timesyncd will otherwise overwrite you the moment it succeeds:

sudo timedatectl set-ntp false
sudo timedatectl set-time '2026-08-06 12:00:00'

That does not persist across a power cycle. Nothing does, without an RTC.

Notes for the curious

  • A drop-in, not an edit. /etc/systemd/timesyncd.conf is a systemd conffile. Rewriting it would make every future systemd upgrade prompt about a modified conffile on a running board, so the config goes in timesyncd.conf.d/ instead.
  • - before ExecStart. The bounded wait runs timeout 45 … with a leading -, which tells systemd to ignore the exit status. Without it, a timed-out oneshot counts as a failed unit and systemctl is-system-running reports degraded on every offline boot. The boot is released either way — ordering is satisfied when a job finishes, not when it succeeds, and time-sync.target only Wants the service.
  • DHCP does not help here. Option 42 is the standard way for a network to advertise a time server, but NetworkManager — which manages the interface on these images — has no path to feed it to timesyncd, and most home routers do not advertise it anyway. Hence a config key rather than “let the network say”.
  • Enabled after the packages, not before. The .wants symlink is written by the build’s customize step rather than staged in the base overlay, because the unit ships inside the systemd package: a symlink laid down before that package installs is one deb-systemd-helper may still have an opinion about when it applies the unit’s preset.

The account, sudo, and SSH keys

Every image carries one account — debian — and three settings decide who can use it and what it costs them to become root. All three are config, resolved before the build and recorded in the image’s provenance.

The knobs

fieldlayerdefaultwhat it sets
sudobase.tomlnopasswd/etc/sudoers.d/debian — whether sudo prompts
first_boot_password_lengthbase.toml12length of the generated per-image password
ssh_authorized_keysbase.tomlnone~debian/.ssh/authorized_keys

Each is overridable in a recipe. resolve and doctor additionally take --sudo and --password-length, so you can see what a choice resolves to before writing it down:

boot2deb resolve turing-rk1/forky --sudo password --password-length 16

build takes none of them: an image’s access rules come from the config its lock was resolved against, so changing them means changing base.toml or the recipe. resolve prints the recipe to write, with the keys already filled in.

ssh_authorized_keys has no flag at all, deliberately — see below.

The password

Each built image gets its own randomly generated password for debian, printed on the build’s last lines and stored in the .provenance.toml beside the image. It is expired, so the first login has to replace it.

first-boot pw : 7kQmR3xLpAvB  (user debian, expired — change at first login)

Three facts about the base image decide how much that password is guarding: openssh-server is installed and enabled, a DHCP client brings the board onto the network before anyone has logged in, and sudo defaults to nopasswd. So the printed password is root, on whatever network the board is plugged into, from the moment it powers on.

Expiry does not change that. A login against an expired account is permitted and is then required to set the new password. That protects against a credential nobody ever rotates; it does nothing against someone reaching the board before its owner does — whoever logs in first chooses the new password and keeps the account. Length is what covers that window, which is why it is a validated range rather than a preference.

Choosing a length

The alphabet is 56 symbols — mixed case and digits, with 0/O/o and 1/l/I removed so the value transcribes cleanly at a console — so each character is about 5.8 bits. The accepted range is 8 to 64, and the default is 12.

There are two different attacks, and they have very different reach:

  • Guessing at the login. Bounded by what an sshd on one of these boards will service — tens of attempts per second, not thousands. Even 8 characters (~46 bits) is far out of reach here. This is why 8 is the floor rather than a recommendation.
  • Attacking the hash offline. /etc/shadow travels inside the image file, and the password appears in the .provenance.toml beside it. Anyone holding a copy of either can attack the hash with no rate limit and no board involved. 8 characters is merely expensive against that; 12 (~70 bits) is out of reach.

So the length that matters is the one for an image you might copy, publish, or hand to someone else — and 12 is chosen for that case. Shortening to 8 suits an image one operator flashes and boots directly, where the hash never leaves the build host.

Before shortening it: the friction here is typing a password at a console, and an authorized key removes that friction entirely without giving anything up. Reach for a key first.

Authorizing an SSH key

List the public key — the .pub file’s contents — in ssh_authorized_keys:

ssh_authorized_keys = [
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBl5Nn9... operator@workstation",
]

With a key in place you never type the generated password: ssh debian@<board> works on the first boot, and the password stays as the console fallback for the day the key is on a laptop you do not have.

The entries are key material, not paths. A path would resolve differently on another machine, and the point of writing a key down is that every build of that recipe carries it. Public keys are not secret, so they belong in config alongside everything else that describes the image.

Three places to put them, depending on scope:

  • A recipe — this build point authorizes these keys.
  • A base.toml overlay — your keys on every image you build, without editing the shipped tree. See Overlays.
  • The shipped base.toml — only if the config root is your own fork.

A fourth, per unit rather than per image: boot2deb press --ssh-key writes the key into the pressed file’s seed partition, and the device appends it to authorized_keys at first boot — how six boards from one image get six different keys (or the same key without rebuilding). See Producing images.

A recipe’s list replaces the base list rather than adding to it, so a recipe for an image you intend to hand to someone else can authorize nobody with ssh_authorized_keys = [].

What is checked, and what is not

Each entry is validated at resolve, because sshd reports a line it cannot parse only in its own log — on a board that may have no console. The failure to avoid is an image whose key silently does not work.

An entry must be one line holding a key type, a base64 blob, and an optional comment. The blob is decoded far enough to confirm its own embedded type name agrees with the declared one, which catches the common paste accidents: a key wrapped by a mail client, a truncated copy, or a blob under the wrong type name.

Two rejections are worth knowing about:

  • Private key material is refused by name. A -----BEGIN OPENSSH PRIVATE KEY----- block here means id_ed25519 was reached for instead of id_ed25519.pub, and the consequence would be a private key baked into every copy of the image. Authorize the .pub.
  • Options prefixes (restrict, command="…", from="…") are refused. sshd accepts them, but their syntax is quoted and comma-separated, and a builder that half understood it would write a weaker restriction than the author wrote. Write a bare key; add options on the running board if you need them.

ssh-dss is not an accepted type: OpenSSH removed DSA support, so such a line could never authenticate anything.

The file is written with mode 0600 inside a 0700 ~/.ssh, both owned by debian, which is what sshd’s default StrictModes requires before it will read a key at all.

Choosing a sudo policy

sudo = "nopasswd" gives debian root with no prompt; sudo = "password" prompts for the account’s own password.

nopasswd is the default because these are single-operator boards: the account’s password was just set at first login, and re-typing it to reach root adds nothing that the login did not already decide. It is also what makes an unattended first-boot setup script work without embedding a password in it.

Choose password for a board that is shared between people, that runs anything reachable from beyond a trusted network, or whose console someone else can walk up to. The tradeoff is narrow but real: under nopasswd, anything that can log in is root, so the password and the keys above are the whole boundary.

It is also one line to change on a running board, so this is a default rather than a commitment:

sudo sh -c 'echo "debian ALL=(ALL) ALL" > /etc/sudoers.d/debian'

Note that passwd root does not change it. The rule belongs to debian, not to root — root ships locked, and Debian’s convention is to leave it that way and reach root through sudo.

What the provenance records

The .provenance.toml beside each image carries the full access picture in [credentials] — the generated password, the sudo policy, and every authorized key:

[credentials]
user = "debian"
password = "7kQmR3xLpAvB"
note = "expired at first login (passwd -e); unique per built image"
sudo = "nopasswd"
authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBl5Nn9... operator@workstation"]

That file holds a live credential — treat it as sensitive. The image’s own /etc/boot2deb/image.toml deliberately carries none of this: it is readable by anyone holding the disk, and an image that inventoried its own access rules would hand a reader the list of what to go after.

Because the password is fresh per build, it is also the one thing that puts a built image’s /etc/shadow outside the byte-reproducibility claim. The keys and the sudo policy are ordinary resolved config, so they are part of what a rebuild reproduces — and part of the rootfs cache key, so adding a key or tightening sudo rebuilds the rootfs rather than reusing the tree that had the old rules.

Data volumes

A data volume is a second disk the image mounts for data and nothing else. No part of the boot lives on it, so reflashing the OS costs nothing but the OS: the new image finds the volume by label and adopts it.

Why a board would want one

Most single-board machines have one medium their flashing route can write and one medium with room on it, and they are not the same medium.

A Turing RK1 in a Turing Pi carrier is the clearest case. The BMC writes the module’s eMMCtpi flash, the web UI, and gadget mode all target it, because the loader the BMC streams into the module speaks eMMC and nothing else. The M.2 NVMe is invisible to that path. Putting the root filesystem on the NVMe therefore means getting an image onto a disk the management network cannot reach, which is a real errand: flash a whole OS to eMMC, boot it, copy the image across, dd it to the NVMe, then put a plain bootloader back on the eMMC. The alternative is opening the case to get at the M.2 slot with an adapter.

Put the OS on the eMMC and the NVMe becomes data-only, and the errand disappears — along with a second problem you may not have noticed you had. Reimaging no longer destroys the data. With OS and data sharing one disk, every reinstall means re-copying the library; with them split, tpi flash a new image and the library is still there.

The same shape fits any board whose flashing route writes one medium: a Chromebook’s internal eMMC plus an SD card, an SBC’s SD card plus a USB disk.

If you do want root on the NVMe, that is the split layout plus a bootloader that can write the disk — see Turing RK1.

Declaring one

No shipped recipe declares a data volume, and that is deliberate. Where the data lives is a property of one installation, not of a board or of an application: two people running the same media server on the same board may keep the library on an M.2 disk, on an external USB drive, or on network storage, with root on either medium. A recipe that guessed would send first boot looking for hardware the operator does not have.

So it is something you add to your own recipe — copy a shipped one and extend it, as in Adapting a shipped recipe. Two halves, and a build fails if it has only one: the data-volume feature carries the first-boot hook, and the recipe’s [[data_volumes]] says what to mount where.

features = ["jellyfin", "media-accel-rockchip", "data-volume"]

[[data_volumes]]
match  = { kind = "nvme" }   # or { device = "/dev/nvme0n1" }
label  = "b2d-data"          # the identity that survives a reimage
mount  = "/srv"
fstype = "ext4"              # optional, the default
create = "if-blank"          # optional, the default; or "never"
fieldmeaning
match{ kind = "nvme" | "sata" | "usb" | "mmc" } for the single disk of that transport, or { device = "/dev/..." } for an exact one. A transport matching more than one disk is refused on the board rather than guessed.
labelFilesystem label, and the LABEL= the fstab entry mounts by. At most 16 bytes. This is the volume’s identity — a later image with a different one sees a foreign disk and refuses it.
mountAbsolute path, never /.
fstypeext4.
createif-blank (default) to format a genuinely blank disk; never to only ever adopt a volume you prepared yourself.

How a disk is matched

kind names the bus, not the device-node spelling, because the spelling does not separate the cases that matter. A SATA disk and a USB disk are both /dev/sd*: a machine with an internal SSD and a drive somebody plugged in shows two devices no name pattern can tell apart, and writing the wrong one is the accident worth designing out. So sata and usb are separate kinds, matched on the transport the kernel reports (lsblk -o TRAN), and a /dev/sd* whose transport cannot be read is skipped rather than guessed at.

A disk must satisfy both the transport and the expected node name. That second test is not redundant — on a board with eMMC, lsblk reports mmcblk0boot0 and mmcblk0boot1 as whole disks (TYPE=disk), and those are the read-only eMMC boot hardware partitions. The mmcblk<n> pattern excludes them, and read-only disks are skipped besides. mmc is also the one kind that tolerates an unreported transport, because the block driver commonly reports none and mmcblk<n> is unambiguous on its own.

The disk holding root is never a candidate whatever match says. If a board’s transport is genuinely ambiguous, name the disk: match = { device = "/dev/sda" }.

boot2deb resolve prints what it resolved, so you can see what first boot will look at before you flash:

data volume  : b2d-data on nvme -> /srv (ext4, create if blank)

What first boot does, and does not do

Per volume, it stops at the first step that applies:

  1. Adopt — a filesystem already carries the label. Mount it. Never format. This is the path every boot of a reflashed board takes.
  2. Create — the matched disk is genuinely blank and create = "if-blank". Write a GPT with one partition, mkfs with the label, mount.
  3. Refuse — anything else. Log what was found and leave the disk alone.

“Genuinely blank” is three independent checks: no partition table, no filesystem signature, and no partitions the kernel already sees. The disk holding root is never a candidate whatever match says.

Step 3 is the point of the feature, not a safety net bolted onto it. A volume this image did not create is evidence of data someone wants, so it is left alone and the reason is logged. If that is still more latitude than you want, create = "never" removes the blank-disk case too.

The /etc/fstab entry is written at build time, so every boot after the first mounts the volume with no hook involved. It carries nofail and a short device timeout: a data disk that is absent, dead, or slow to enumerate must never be the reason a system will not boot. A node with no second disk boots normally and simply has an empty mount point.

Renaming a volume

The label is the identity, so changing it in a recipe without changing it on the disk means the next image refuses the volume — safe, but not what you meant. Relabel the disk on the board in the same change:

sudo umount /srv
sudo e2label /dev/nvme0n1p1 new-label

Accelerated Jellyfin

turing-rk1/jellyfin-forky and turing-rk1/jellyfin-trixie build a Turing RK1 image running the Jellyfin media server, transcoding video on the board’s hardware encoder.

boot2deb build turing-rk1/jellyfin-forky

Flash it the way you would any other RK1 image — see Turing RK1 — and Jellyfin comes up on port 8096 with the transcode settings already filled in. There is nothing to configure to get hardware encoding; there is one setting you should not change, described below.

What is accelerated, and what is not

Encoding runs on the VEPU580. Decoding and scaling run on the CPU.

That is the whole shape of it, and it is not the shape Jellyfin’s dashboard implies, so it is worth being plain about. Jellyfin offers an “Enable hardware decoding for” list alongside the hardware-encoding switch; on this image that list is deliberately empty.

The reason is that the two halves of Rockchip’s stack are in different states on a mainline kernel. The *_rkmpp encoders talk to mpp_service and work. The *_rkmpp decoders expect an MPP decode client, and mainline does not provide one — rkvdec is a V4L2 stateless driver instead. The decoders are still compiled in, so they appear in ffmpeg -hwaccels, and Jellyfin’s capability probe reads exactly that list and concludes hardware decoding is available. It is not. Turning a codec on in that list makes Jellyfin emit -hwaccel rkmpp, the decoder fails to open, and the stream fails — FFmpeg does not fall back to software when a decoder cannot open, and Jellyfin does not retry without it.

So: leave Playback → Transcoding → Enable hardware decoding for empty. Everything else in that page is yours to tune.

On this board that is also the faster arrangement rather than a concession. RGA scaling only pays for itself on frames already held in an MPP context, which on a software-decode path they never are; the round trip to and from the 2D engine costs more than swscale saves. Eight Cortex cores decode and scale, and the encoder — the part that actually would not keep up in software — is in hardware.

FFmpeg on this image can decode in hardware, with -hwaccel v4l2request. Jellyfin cannot be pointed at it: its acceleration type is a fixed list with no v4l2request in it.

What the image sets up for you

SettingValueWhy
FFmpeg path/opt/ffmpeg-rk/bin/ffmpegThe only build here that can reach the VEPU580
Hardware accelerationrkmppThe only type that reaches h264_rkmpp / hevc_rkmpp
Hardware encodingon
Hardware decoding(empty)See above — leave it empty
Tone mappingoffThis FFmpeg is built without OpenCL

These are written to /etc/jellyfin/encoding.xml before first boot. They are starting values: Jellyfin rewrites that file on every start, so from first boot onward the dashboard is what governs. To change the defaults for the next image, edit features/jellyfin-rockchip/overlay-pre/etc/jellyfin/encoding.xml in your config tree.

HEVC output is left off, as it is in stock Jellyfin — whether your clients can play HEVC is a fact about your household, not about the board. hevc_rkmpp is there and works if you turn it on.

Jellyfin’s bundled FFmpeg is not installed

The image installs jellyfin-server and jellyfin-web, not the jellyfin metapackage, so jellyfin-ffmpeg never lands. It would be a second complete FFmpeg that cannot reach the hardware, and it pulls its own pocket’s library versions in behind it.

The consequence is worth knowing: there is no fallback encoder, and on this application no encoder means no server. Jellyfin validates the FFmpeg path during startup and exits if the binary does not run — it does not start with transcoding switched off. So if you point it at a path that does not exist, the service dies at boot. Check with journalctl -u jellyfin; the giveaway is Failed to find valid ffmpeg. If you want the bundled build available as a safety net, add jellyfin-ffmpeg7 to a copy of the jellyfin feature’s package list.

Set the path in the dashboard, not on the command line. The image ships a jellyfin.service drop-in that clears the --ffmpeg= argument Debian normally passes, precisely so that Jellyfin reads the path from its config — which is what Dashboard > Playback > Transcoding > FFmpeg path edits. Putting a path back on the command line (by editing /etc/default/jellyfin-encoder) would override that field and leave the dashboard silently ineffective.

Keeping it updated

Jellyfin’s own apt repository stays configured on the running system — the image writes its sources.list.d entry and keyring — so apt upgrade picks up Jellyfin releases the ordinary way. Debian’s mirrors are there too. Nothing about this image requires a reflash to take a security update to the server.

The exception is ffmpeg-rk. It is built from source, pinned by commit in the recipe’s lock, and comes from no repository, so apt upgrade will never move it. It is also the component that parses untrusted media. Moving it means re-pinning and rebuilding:

boot2deb outdated turing-rk1/jellyfin-forky   # what has moved upstream
boot2deb update   turing-rk1/jellyfin-forky   # re-pin
boot2deb build    turing-rk1/jellyfin-forky

Where the media lives

The recipes declare no data volume, on purpose — an RK1 running Jellyfin might keep its library on an M.2 disk, an external drive, or network storage, and the recipe cannot know which. To attach one, add the data-volume feature and a [[data_volumes]] block to your own copy of the recipe; see Data volumes.

Checking it is working

Play something that must be transcoded — a file in a codec the client cannot take, or with subtitles burned in — and look at the FFmpeg command Jellyfin logged:

sudo grep -h "ffmpeg" /var/log/jellyfin/*.log | tail -1

You want to see /opt/ffmpeg-rk/bin/ffmpeg, -c:v hevc_rkmpp or h264_rkmpp, and no -hwaccel rkmpp. If -hwaccel rkmpp is there, a codec got re-enabled in the hardware-decoding list.

Every run also prints mpp_platform: client N driver is not ready! for a handful of N. That is normal: libmpp probes for vendor client types a mainline kernel does not have. The ones that matter (RKVENC, RKVENC_CCU, RKVDEC, JPEG_DEC) are present.

Status

Both recipes are experimental, and the gap is Jellyfin rather than the hardware underneath it.

The transcode path itself is measured on a boot2deb-built RK1 image. h264_rkmpp and hevc_rkmpp produce correct streams — every frame of a 90-frame clip in both codecs, from software frames and through hwupload alike, verified against a stock FFmpeg on another machine rather than against the build that produced them. Hardware decode through -hwaccel v4l2request cuts decode CPU cost by 53x at 1080p and up to 143x at 4K, and HEVC decode is bit-exact against software.

What has not been done is driving that path from Jellyfin on the board: playing a file through the server and confirming the transcode it launches is the accelerated one. Until that happens, treat the settings above as configured rather than proven. See the support matrix for what each recipe has been taken through.

Adapting a shipped recipe

Every shipped recipe is a point across the build axes — device, kernel, u-boot series, suite, features, layout — and adapting one means naming a different point. Most of what people want from an image is reachable without writing a file at all, and the rest is a few lines of TOML in a directory of your own.

This tutorial works up the four levels of change, cheapest first. It assumes you have already built a shipped recipe once (Getting started).

What you want to changeHowWhat it writes
The artifact’s geometry or where it lands (--layout, --image-size, --out-dir)flags on buildnothing — the lock is untouched
Which features are in the imageupdate <recipe> --feature …, then build the variant referencea variant lock beside the recipe
Adding an app or capability of your owna features/<name>.toml of your own — no codeyour feature, selectable at once
Suite, kernel, u-boot series, board profile, locale, timezone, keymapa recipe file of your ownyour recipe plus its lock
Who can log in: SSH keys, the sudo policy, the generated password’s lengtha recipe file of your own, or a base.toml in your overlay for every imageyour recipe plus its lock
A hardware fact of the boarda device file, usually extends anotheryour device plus a recipe

Where your files go

Put anything you author in a directory of your own and pass --overlay:

mkdir -p ~/my-boards/recipes/asus-c201
boot2deb --overlay ~/my-boards resolve asus-c201/forky

An overlay holds the same devices/ socs/ kernels/ features/ recipes/ structure as the shipped root, wins over it name-for-name, and takes the locks that update writes — so there is nothing to fork and nothing of yours to rebase when the shipped tree moves. See Overlays. The rest of this page assumes --overlay ~/my-boards wherever you author something; drop it if you are contributing the result back in-tree.

Level 1: change the artifact, not the build

build re-reads the lock for every pinned input, but the image’s shape is not a pinned input, so two axes are overridable at build time:

# A bootloader-only image plus a separate rootfs image, for a two-medium install.
boot2deb build turing-rk1/forky --layout split

# A bigger artifact. The rootfs grows to fill its medium on first boot regardless, so
# this bounds the file you flash, not the installed system.
boot2deb build turing-rk1/forky --image-size 4G

# Keep the raw image beside the .xz, and put artifacts somewhere else.
boot2deb build turing-rk1/forky --keep-raw --out-dir /mnt/scratch

Artifacts are named for the whole build point — device and recipe (turing-rk1/forkyturing-rk1-forky.img.xz, turing-rk1-forky-rootfs.tar, turing-rk1-forky-idbloader.img) — so several recipes can share one --out-dir without one build’s rootfs or bootloader being folded into another’s image.

Nothing here is recorded as a new point: the lock, the pins, and the support claim all still describe the recipe you named. --stage narrows the run to one stage, which is how you iterate on the image assembly without recompiling a kernel — see the CLI reference.

Level 2: compose features

Features are the one axis you can select without authoring anything. update pins the selection as a variant of the recipe, and the variant is then a build reference like any other:

boot2deb list-features
boot2deb update turing-rk1/forky --feature media-accel-rockchip --feature jellyfin
boot2deb build  turing-rk1/forky+media-accel-rockchip+jellyfin

The variant gets its own lock, its own solved package manifest, and its own work directory and image identity, so it never lands on the recipe’s artifacts. Three things worth knowing before you rely on it:

  • The selection replaces the recipe’s features list, it does not add to it. Name everything you want.
  • Order matters and is preserved — kconfig fragments and patch series compose in selection order, so a+b and b+a are two different builds and two different references.
  • A variant carries no support claim. The claim belongs to the recipe; a different feature set is a different build. Variants appear in neither list-recipes nor the support matrix.

Reference: A feature selection is a build point.

Authoring a feature of your own

A feature is entirely configuration. Drop a features/<name>.toml into your overlay and it is selectable immediately — it appears in list-features, and <recipe>+<name> resolves. There is nothing to register and no code to change.

# ~/my-boards/features/navidrome.toml
description = "Navidrome music server"
packages    = ["navidrome"]

[[apt_sources]]
name       = "navidrome"
uri        = "https://apt.example.com/debian"
suite      = "trixie"
components = ["main"]
signed_by  = "navidrome.gpg"
boot2deb build turing-rk1/forky+navidrome --overlay ~/my-boards

Config files ride alongside in features/<name>/overlay/, laid into the rootfs after every package so they win over what the packages shipped. Use features/<name>/overlay-pre/ for the rarer case where a package’s own maintainer scripts have to see the file while they run — see Overlays.

Four things a feature can declare beyond its packages:

  • [[apt_sources]] for an app that is not in the Debian mirror. Its signing key must be vendored as blobs/keyrings/<signed_by> in your overlay or the shipped tree; resolution refuses the build if it is missing rather than trusting the repo blindly. The source stays configured on the device, so apt upgrade tracks the vendor’s releases.
  • requires_soc / requires_arch to gate a feature on hardware it needs. Empty means any, which is right for a portable application.
  • conflicts for features that cannot coexist. The check is symmetric, so declaring it on either side is enough.
  • caveats for what the feature does not deliver. These follow the feature into every recipe that composes it and print at the end of a build, which is what a limitation caused by a capability should do.

The one thing you cannot do from config is teach the recipe schema a new block: the [[data_volumes]] list is paired with the data-volume feature by name in the builder, and it is the only feature the code knows by name. Everything else about the axis is discovered from the directory.

Level 3: author your own recipe

Any axis that is not a feature — the suite, the kernel definition, the u-boot series, the depthcharge board profile, the locale, timezone, and keymap — is pinned by the recipe file, so changing one means a recipe of your own. That is deliberately cheap: a recipe states its deltas from the device’s defaults and nothing else.

This is also what resolve tells you to do. Preview a choice with the matching flag and it prints the recipe to write, with the keys already filled in:

boot2deb resolve asus-c201/forky --suite trixie --keymap de

The worked example is the ASUS C201 on trixie, localized for Germany. ~/my-boards/recipes/asus-c201/trixie-de.toml:

device   = "asus-c201"
suite    = "trixie"
locale   = "de_DE.UTF-8"
timezone = "Europe/Berlin"
keymap   = "de"

Five lines is the whole file. Everything else — the kernel (Debian’s own linux-image-armmp for this board), the boot method, the layout, the image size — comes from devices/asus-c201.toml. Resolve it to see the point in full, including which values came from where:

boot2deb --overlay ~/my-boards resolve asus-c201/trixie-de
device       : asus-c201 — ASUS Chromebook C201 (RK3288, google,veyron-speedy)
kernel       : debian-armmp (distro-package)
suite        : trixie
locale       : de_DE.UTF-8 (generated: de_DE.UTF-8, en_US.UTF-8, en_GB.UTF-8, fr_FR.UTF-8, ...)
timezone     : Europe/Berlin
keymap       : de [pc105]
board profile: speedy
...

resolve also runs the coherence preflight — image geometry, fragment existence, feature compatibility, apt keyrings — so a green resolve means the point is buildable, not merely parseable. Then pin it and build:

boot2deb --overlay ~/my-boards update asus-c201/trixie-de
boot2deb --overlay ~/my-boards build  asus-c201/trixie-de

update writes trixie-de.lock next to your recipe, inside your overlay. It needs no --kernel-ref here because this board installs Debian’s kernel: there is no git ref to resolve, and the exact package version is pinned by the solved package manifest instead. A board that compiles a kernel wants --kernel-ref <tag> on its first update, after which update inherits the previous ref.

Which locale keys to set where is its own topic — the layered defaults, and what each key does to the running system, are on Locale, timezone, and keyboard.

Level 4: adapt the board itself

If what you need to change is a hardware fact — a different DTB, different DRAM timing, a peripheral enabled for bring-up — that is the device layer, not the recipe. A board that is another board with one difference uses extends:

# ~/my-boards/devices/my-rk1-variant.toml
extends     = "turing-rk1"
description = "Turing RK1 with a different DDR fitting"
hostname    = "rk1-variant"

[rkbin]
tpl = "rk3588_ddr_lp4_2112MHz_lp5_2400MHz_v1.19.bin"

extends inherits the parent device’s keys and its overlay/ file tree, so the parent’s driver tuning, units, and keymaps reach your image, and you override any single file by shipping your own copy at the same path. Most arrays replace rather than append across the merge, so restate any list you extend — the exceptions are the five that describe the board (caveats, expect, nonfree_firmware_packages, packages, exclude), which accumulate. Details: A variant board extends another.

Reach for a variant device only when the difference needs a device-layer field — a device tree, a DTB name, the DRAM blob. A capability whose whole expression is packages, kernel config, and a patch series is a feature instead, and features compose a-la-carte where a variant device does not.

Beyond that — a board that is genuinely new, a SoC that is not here yet, a device tree that is not upstream — is the bring-up track: Adding a board, which starts with boot2deb new-device scaffolding the files for you.

When the change is worth a claim

A recipe is the unit that carries a [support] claim, and a claim is a statement about hardware. If you have booted your adapted image and want to say so — in your own overlay or in a contribution — see Authoring a recipe.

Moving a board to a newer kernel

A board that compiles its own kernel is pinned to an exact tag, and that tag goes stale. This tutorial moves the Turing RK1 forward, in the two shapes that job takes:

  • Within the trackv7.2 to a later 7.2.y point release. The patch series already claims the version, so this is a re-pin and a rebuild.
  • Across a version boundary — 7.2 to 7.3. The series makes no claim about 7.3 yet, so the first move is measuring whether it would survive, without changing a pin.

The second shape is the interesting one, and the order it runs in is the point: find out, then adopt. Nothing mutates a lock or a series until the answer is in.

This is the build side. Upgrading the kernel on a board that is already running is a different job with a different mechanism — see Upgrading the kernel.

What a kernel bump touches

Four things, in different repos, and they move at different rates:

ThingWhereMoves when
The pinned tagthe recipe’s .lockevery bump
The kernel definition (track, fragments, series)kernels/<id>.tomlat a version boundary — a kernel definition is version-coupled
The series envelope and per-patch rangesthe patches repowhen a boundary is measured
The [support] claimthe recipewhen the moved pins are re-earned on hardware

The device’s supported_kernels list gates which definitions a board may resolve, so a new definition is also one line there.

Shape 1: within the track

kernels/rk3588-mainline-7.2.toml tracks 7.2.y, and series/rk3588-accel.toml declares applies_to_kernel = ">=7.1.5, <7.3". A later 7.2 point release is inside both, so if the series holds, nothing in the config or the series changes — only the pin.

# 1. Re-pin. This is the only command that consults upstream.
boot2deb update turing-rk1/forky --kernel-ref v7.2.1

# 2. Does the series still apply to the new tree? (--kernel-src makes the fetch
#    near-instant if you have a checkout; omit it and the tree is auto-fetched.)
boot2deb verify-patches turing-rk1/forky --kernel-src ../linux

# 3. Does the .config still generate cleanly on the patched tree?
boot2deb verify-config turing-rk1/forky

# 4. What will actually recompile? Offline, reads the lock and the build stamps.
boot2deb why-rebuild turing-rk1/forky

# 5. Build.
boot2deb build turing-rk1/forky

Step 1 says two things worth reading rather than scrolling past. When a recipe claims validated, moving its pins retires the evidence that claim rested on, and update says so:

  warning: recipe 'turing-rk1/forky' claims support = "validated" as of 2026-07-16, but
  this update moved its pins:
    kernel v7.2 (8d3ae59288f1) -> v7.2.1 (155b42bec9cb)
  that claim now describes a combination nothing has booted — re-validate on hardware and
  update the date, or set status = "expected" until you do
  note: this re-pin changes the generated support matrix — regenerate it:
    boot2deb support-matrix --markdown > docs/src/reference/support-matrix.md

Both are advisory — the lock is written either way — and both name work that belongs at the end of this tutorial, in Closing the loop.

Deciding whether the validated claim survives means knowing what the re-pin moved, which is what diff is for. Keep the old lock before step 1 and compare against it afterwards:

cp recipes/turing-rk1/forky.lock /tmp/old.lock       # before step 1
boot2deb diff /tmp/old.lock turing-rk1/forky         # after it

It names the kernel ref and commit that moved, the kconfig symbols the fragment sets now request differently (with the fragment behind each), and — where the patches commit moved too — the individual patch files that were added, removed, or rewritten. That is the evidence the claim is re-earned or retired on. See Comparing two build points.

If you would rather not touch the lock until you know the answer, step 2 can come first: verify-patches turing-rk1/forky --kernel v7.2.1 --kernel-path ../linux measures a version the lock does not name and leaves the lock alone. That is the candidate path below, and it works inside the envelope as well as outside it.

A patch that fails at step 2, or a kconfig symbol that has been renamed out from under a fragment at step 3, turns this shape into the next one: the series has hit a boundary, even inside its declared envelope.

Shape 2: across a version boundary

Step 1: get a tree at the candidate version

The lock pins no commit for a kernel it does not name, so the candidate has to come from a checkout you point at:

git -C ../linux fetch --tags origin
git -C ../linux checkout v7.3

A release candidate is a legitimate answer here — measuring v7.3-rc5 before 7.3 exists is exactly what this path is for.

Step 2: measure, and change nothing

boot2deb verify-patches turing-rk1/forky \
    --kernel v7.3 --kernel-path ../linux --keep-going

--kernel verifies against a kernel the lock does not pin and leaves the lock alone. Three rules differ from the locked path, and each of them exists so the question can be asked at all:

  • The declared envelope does not gate the run. The series still says <7.3 — refusing the candidate on that basis would answer the question by assuming it. The run reports that the kernel is outside the envelope and measures it anyway, and what git am does is the answer. A clean result is the evidence for widening the envelope, not a claim that it already covers 7.3.
  • A release candidate is matched as its base release, because by semver 7.3.0-rc3 satisfies neither <7.3 nor >=7.3 and a release-only range would reject every RC. The build path stays release-strict; this path does not.
  • --keep-going reports every failure in one pass. One boundary usually spawns adjacent ones — a reworked patch shifts the context every later patch applies against — so stopping at the first turns the survey into serial discovery. Each failing patch is skipped so the rest still get measured, which makes the report a map of the damage rather than a final verdict.

Per-entry kernels ranges still narrow the series on this path, so a patch already marked obsolete at the candidate drops out instead of counting as a failure.

Step 3: act on the report

Every failure is one of three things, and each has its own encoding in the series manifest:

Upstreamed. The patch is in the new kernel already; the failure is the code being there twice. Give the entry an upper bound rather than deleting it — an older kernel still needs it. This is what happened to the Verisilicon IOMMU at 7.2:

kernel = [
  { path = "media-accel/kernel/050-av1-iommu-v14-curated.patch", kernels = "<7.2" },
]

Read what upstream took, not just that it applied: 050’s driver, binding and DT node landed but its CONFIG_VSI_IOMMU=m defconfig line did not, so dropping the patch silently stopped building the driver until a kconfig fragment picked the symbol up. A patch that is partly absorbed is the dangerous shape, because nothing in the apply path reports it.

Reworked. The patch is still needed but no longer applies. Rebase it, keep both versions, and give them complementary ranges — one list then builds both generations correctly from a single checkout, which a list mutated in place cannot do. The RK3588 RGA device-tree wiring is the worked example: 7.1 describes one RGA core and 7.2 describes all three, so the patch that points them at the out-of-tree driver reads differently on each:

kernel = [
  { path = "media-accel/kernel/072-rk3588-rga-dts-7.1.patch", kernels = "<7.2" },
  { path = "media-accel/kernel/072-rk3588-rga-dts-7.2.patch", kernels = ">=7.2" },
]

Regenerate a rebased patch with git format-patch rather than hand-editing the diff — git am --3way needs the index lines a hand-written hunk does not carry.

Obsolete. Nothing needs it any more, at any version. Retire the entry and its file: an old lock names an old patches commit, whose tree still contains both.

Verify with plain git am, not git am --3way, before believing a clean run. A shallow build tree holds only the blobs of the commit it is at, so a patch that needs a three-way merge against a previous generation’s file resolves in a full checkout and hard-fails in a build. Adding --3way is what hides that difference.

Re-run step 2 after each round. When it comes back clean, and only then, widen the envelope:

applies_to_kernel = ">=7.1.5, <7.4"

That edit is what turns a measurement into a claim, which is why it comes last. Commit it in the patches repo — and push it, because a pin naming an unpushed commit resolves against your checkout and nowhere else:

git -C ../patches add -A && git -C ../patches commit -m "rk3588-accel: extend to 7.3"
git -C ../patches push

Step 4: write the kernel definition

A kernel definition owns everything version-coupled, so 7.3 is a new file rather than an edit — kernels/rk3588-mainline-7.3.toml:

flavor           = "mainline"
source           = "linux-stable"
track            = "7.3.y"
base_defconfig   = "defconfig"
config_fragments = ["base/debian-arm64", "soc/rk3588", "accel/full"]
patch_series     = ["rk3588-accel"]
patches_url      = "https://github.com/gregordinary/patches.git"
supported_socs   = ["rk3588"]

The series name is unchanged — series names are semantic, never version-suffixed, so the kernel definitions referencing them stay stable. Then let the board resolve it, in devices/turing-rk1.toml:

supported_kernels = ["rk3588-mainline-7.2", "rk3588-mainline-7.3"]
default_kernel    = "rk3588-mainline-7.2"        # still the one with board evidence

Check the point resolves before pinning anything — --kernel selects the definition for a resolve without touching a file:

boot2deb resolve turing-rk1/forky --kernel rk3588-mainline-7.3

Step 5: adopt it in a recipe

update has no --kernel flag: which definition a recipe pins is the recipe’s own kernel field, not a per-run choice, so adopting 7.3 is a recipe edit. Do it in a new leaf rather than in forky.toml, and the board keeps a validated recipe while the new one is unproven — recipes/turing-rk1/forky-7.3.toml:

device   = "turing-rk1"
kernel   = "rk3588-mainline-7.3"
suite    = "forky"
features = []
layout   = "combined"

[support]
status = "experimental"     # nothing has booted this yet
date   = "2026-08-21"       # the day the claim was last assessed

Then run the same sequence as shape 1 against the new leaf, on the locked path this time — no --kernel, because the lock now names 7.3 itself:

boot2deb update         turing-rk1/forky-7.3 --kernel-ref v7.3
boot2deb verify-patches turing-rk1/forky-7.3 --kernel-src ../linux
boot2deb verify-config  turing-rk1/forky-7.3
boot2deb build          turing-rk1/forky-7.3

If you skipped ahead and pinned 7.3 before widening the envelope, nothing is broken: the envelope check is pure metadata, so update reports the mismatch and keeps going (pinning is the first step of adopting), while build refuses before cloning anything and names the verify-patches --kernel line to run instead. That is the cheap check telling you the series makes no claim about your kernel, ahead of the expensive one that tells you whether it would have worked anyway.

Step 6: the kernel config

A version bump moves kconfig too: symbols get renamed, split, or absorbed, and a fragment naming one that no longer exists silently stops setting anything. verify-config reports the merge, and with a reference config it asserts byte-identical CONFIG_* parity:

boot2deb verify-config turing-rk1/forky-7.3 \
    --reference-config build/turing-rk1/forky/linux/.config

Comparing the new kernel’s generated config against the old one’s is the fastest way to see what the bump changed on its own. Expect differences and read them; the ones that matter are options you asked for and did not get.

Step 7: boot it

Everything so far is a build-time claim. Flash the image, boot the board, and check the hardware the kernel is there for — for the RK1, see Turing RK1.

Closing the loop

A kernel bump is not finished when the image builds:

  1. Re-earn the claim. Set status = "validated" and the date on which an image from these pins booted. A claim is per-pin: it cannot be re-dated for pins that moved, only re-earned. Until then, expected is the honest status.
  2. Regenerate the matrix, which is generated from the locks and therefore stale the moment a pin moves:
    boot2deb support-matrix --markdown > docs/src/reference/support-matrix.md
    
  3. Check the pins are durable. verify-sources grades every pin in the lock — a tag is durable, a branch tip is ephemeral, a force-pushed branch is orphaned:
    boot2deb verify-sources turing-rk1/forky-7.3
    
  4. Retire the old definition once the new one is validated: delete the superseded recipe leaf and kernel file, and drop the id from supported_kernels. Old locks name old commits in the patches repo, so retiring the config does not strand a build that was already pinned.

Keeping both kernel definitions is worth it only while a board genuinely supports both.

Authoring a recipe

A recipe is a name for a buildable point, and little more: it names a device, states whichever axes differ from that device’s defaults, and optionally declares what the point has been taken through. Everything else — the hardware facts, the kernel, the boot chain — belongs to the layers underneath it.

This tutorial is about the recipe file itself. Bringing up the board under it is Adding a board; changing an existing point without authoring anything is Adapting a shipped recipe.

First: does this need to be a recipe?

Two things that look like recipes are not:

  • A different feature selection is a build point, not a file. update <recipe> --feature a --feature b pins it as a variant with its own lock, and build <recipe>+a+b builds it. The legal selections grow exponentially in the number of features, and almost none of them are anybody’s curated point.
  • A different image geometry is a build flag (--layout, --image-size).

Author a recipe when a point is worth claiming — something you have booted, or intend to support, or want to hand someone else by name. Use a variant for everything else.

The file

recipes/<device>/<leaf>.toml. Recipes group under their device’s folder, so a board’s whole matrix sits together, and the reference you build is that path without the extension: recipes/turing-rk1/media-accel-forky.toml builds as turing-rk1/media-accel-forky.

Only device is required. Every other axis falls back to the device layer, so state a field when you mean to differ from the board’s default — an omitted axis reads as “whatever this board does”, which stays correct when the board’s default moves. The full set of axes a recipe may pin:

device       = "<device>"        # required
kernel       = "<kernel-id>"     # omit -> device default_kernel
uboot_series = "<series>"        # omit -> device default_uboot_series
suite        = "forky"           # omit -> device default_suite
features     = []                # omit -> a plain base image
layout       = "combined"        # omit -> device default_layout
image_size   = "2G"              # omit -> device image_size
locale       = "de_DE.UTF-8"     # omit -> base locale
locales_generate = []            # omit -> base locales_generate (replaces, not adds)
timezone     = "Europe/Berlin"   # omit -> base timezone
keymap       = "de"              # omit -> device keymap
sudo         = "nopasswd"        # omit -> base sudo ("nopasswd" | "password")
first_boot_password_length = 12   # omit -> base length (8..=64)
ssh_authorized_keys = []         # omit -> base keys (replaces, not adds)

Real recipes are much shorter than that, because most of it is the board’s default already. recipes/turing-rk1/forky.toml states five fields; the C201 on trixie with German localization states five.

kernel and uboot_series must name one of the device’s supported_kernels / supported_uboot_series — a recipe selects among what the board declares it can run, it does not widen it. list-kernels and list-features enumerate the valid values.

A recipe whose deliverable is a bootloader

Not every recipe produces a disk image. deliverable = "uboot" produces a bootloader and nothing else — for an RK3576 board, the maskrom-streamable images from --stage uboot. Such a recipe names no suite, kernel, layout, or image size; resolution ignores those axes and the lock records no rootfs:

device       = "rk3576-generic"
deliverable  = "uboot"
uboot_series = "rk3576-util"

That is the whole file for a recovery-and-bring-up tool that works on any RK3576 board. See The bootloader is its own axis and RK3576 u-boot images.

Naming the leaf

The leaf drops the device prefix its folder already carries, and names the axis that makes the point distinct. The shipped tree uses four conventions, in rough order of how often they come up:

LeafNamesExample
the suitea plain image on that Debian releaseturing-rk1/forky, asus-c201/trixie
a capability, plus the suite where the board ships more than onewhat the image can doturing-rk1/media-accel-forky, h96-max-m9/media-accel
the deliverablea deliverable = "uboot" toolrk3576-generic/loader, h96-max-m9/util
the productan image built around one applicationturing-rk1/jellyfin-forky

Do not put a version in a leaf name if the version is already pinned elsewhere: the lock holds the exact tag, and a leaf named after it goes stale on the next bump. A leaf naming a kernel generation the board supports alongside another is different, and reasonable (asus-c201/mainline-forky is the C201 on a compiled mainline kernel rather than Debian’s).

The support claim

A recipe may carry the one thing no lock can know — whether a human booted the result:

[support]
status = "validated"     # validated | expected | experimental
date   = "2026-07-16"
StatusWhat it asserts
validatedAn image built from this recipe booted on the hardware.
expectedDerived from a validated sibling, differing only along an axis not expected to change the outcome; never built, or built and never booted.
experimentalUnder active bring-up. It may not build.

Three properties make the claim mean something:

  • It is per recipe, not per device, because it varies within a device: one build point can be booted while another — a different kernel, suite, or feature set — was never built.
  • It is per pin. The date is when the claim was last established, and the generated support matrix sets it beside the exact pins the lock records. Re-pinning under a validated claim retires the evidence, and update says so at the moment both locks exist to compare. A claim cannot be re-dated for pins that moved — it has to be re-earned by booting an image.
  • Absent means no claim made, not a fourth status. That is the honest state for a recipe you authored against your own board, and support-matrix reports unclaimed recipes separately rather than dropping them.

Every recipe boot2deb ships declares a claim, and a test enforces it.

Bring the recipe up

Whether the recipe lives in your --overlay or in-tree, the sequence is the same, and each step fails with a typed error before any compile starts:

# 1. Is it a coherent build point? Also runs the geometry / fragment / keyring preflight.
boot2deb resolve <recipe>

# 2. Is the host equipped to build it? Prints install commands for your distro.
boot2deb doctor <recipe>

# 3. Resolve upstream refs + hash blobs into the lock. The only command that touches
#    the network for pins; --kernel-ref is required on the first update of a recipe
#    that compiles a kernel.
boot2deb update <recipe> --kernel-ref <tag>

# 4-5. Do the patch series apply, and does the kernel .config generate?
boot2deb verify-patches <recipe>
boot2deb verify-config  <recipe>

# 6. Build.
boot2deb build <recipe>

Adding a board runs the same six steps after writing the device file, so a new board and a new recipe share one bring-up loop.

update writes recipes/<device>/<leaf>.lock beside the recipe — into your overlay when that is where the recipe lives. Commit the lock. It is what makes the point reproducible: build reads only the lock, so it consults no network for its pins.

If you are contributing it in-tree

Three things beyond the file itself:

  1. Declare a [support] claim. A shipped recipe without one fails the test that enforces it.
  2. Regenerate the support matrix, which is generated from the recipes and their locks and is compared by a test:
    boot2deb support-matrix --markdown > docs/src/reference/support-matrix.md
    
  3. Check the pins are durable — a lock naming an unpushed commit builds for you and nobody else:
    boot2deb verify-sources <recipe>
    

If the recipe is the board’s first, give the board a page under Boards as well: flashing is inherently per-board, and it is the one thing no config file can state.

Turing RK1

The Turing RK1 is an RK3588 compute module that seats in a Turing Pi 2 cluster board. boot2deb ships it as a small family of recipes over one hardware base — kernel v7.2 (linux-stable), u-boot v2026.07, and the RGA / VEPU / VDPU (and NPU) drivers carried in-kernel via the rk3588-accel patch series. It is a supported configuration in its own right and a good starting point for any RK3588 board.

The variants differ along two independent axes — the Debian suite, and whether the Rockchip media userspace is built in:

RecipeSuiteMedia userspace
turing-rk1/forkyforky— (base)
turing-rk1/trixietrixie— (base)
turing-rk1/media-accel-forkyforkyffmpeg-rk + MPP + RGA + Vulkan
turing-rk1/media-accel-trixietrixieffmpeg-rk + MPP + RGA + Vulkan
turing-rk1/jellyfin-forkyforkyffmpeg-rk + MPP + RGA, plus Jellyfin
turing-rk1/jellyfin-trixietrixieffmpeg-rk + MPP + RGA, plus Jellyfin

The jellyfin-* pair is the media-server build — media-accel plus the Jellyfin server, pre-pointed at ffmpeg-rk; see Accelerated Jellyfin. turing-rk1/util is not an image along these axes at all but a u-boot-only recovery tool — see Writing the NVMe from u-boot.

Every variant carries the same accel kernel: the VEPU / VDPU / RGA and NPU drivers are present in all of them, because the patches and kconfig live on the kernel axis. A base image simply omits the Rockchip media userspace — the hardware blocks are there but dark. A media-accel image adds the media-accel-rockchip feature, which builds and installs ffmpeg-rk, librockchip-mpp1, and librga2 on top. The split is deliberate: because the kernel already carries the capability, those debs can equally be installed onto a running base image later. forky is the RK1’s validated suite.

Two RGA drivers exist; this image builds the out-of-tree one

Kernel 7.2 added an in-tree V4L2 driver for the RK3588’s RGA3 cores, so from that release the SoC has two drivers to choose between, and the choice is a kconfig one — ROCKCHIP_MULTI_RGA for the vendor driver, VIDEO_ROCKCHIP_RGA for the in-tree one. These images build the vendor driver and leave the in-tree one unset, for reasons that are about what reaches FFmpeg rather than about code quality:

  • The ABI. librga, and therefore scale_rkrga, vpp_rkrga and overlay_rkrga, speak the vendor /dev/rga interface. The in-tree driver exposes V4L2 video nodes, and FFmpeg ships no V4L2 mem2mem filter to drive them.
  • The cores. The in-tree driver deliberately exposes one core, to avoid an ABI break when multi-core scheduling is added later. The vendor driver schedules across all three.
  • 10-bit. The in-tree driver implements scaling and colour conversion only; 10-bit YUV is on its own list of what is not done yet. A 10-bit HEVC decode on this hardware produces NV15, and converting that is the one job nothing else on the board does.

If you want a kernel with no out-of-tree code and can live without the FFmpeg filter path, the in-tree driver is a supported thing to switch to: unset CONFIG_ROCKCHIP_MULTI_RGA and set CONFIG_VIDEO_ROCKCHIP_RGA in an overlay fragment, and drop media-accel/kernel/072 from the series so the device-tree nodes keep their mainline compatibles.

The media-accel-* pair also carries the vulkan feature: Mesa’s Vulkan drivers and the loader, which is what makes the Vulkan filters ffmpeg-rk is already built with actually open. On a box driven from the command line those are the fastest scale, tone-map and composite route the hardware has — scale_vulkan measures 53.8 dB against swscale at 18x the efficiency and 2.8x the speed. It costs about 305 MiB installed, two thirds of which is the LLVM that Mesa’s software rasterizer pulls in rather than the Mali driver itself. The jellyfin-* pair deliberately does not carry it: Jellyfin’s HardwareAccelerationType is a closed enum with no Vulkan member, so the server can never emit a Vulkan filter and the packages would be reachable only from a shell. Add it there with turing-rk1/forky+media-accel-rockchip+jellyfin+jellyfin-rockchip+vulkan if you want the command line too — with --image-size 3G, since a feature selection takes the device’s 2G and cannot carry the jellyfin-* recipes’ own larger volume.

Every image here ships a redistributable FFmpeg; see The FFmpeg a build ships is redistributable for the opt-in nonfree flavour and why no hardware path depends on it.

Build the base image as in Getting started:

boot2deb build turing-rk1/forky

or, for a ready hardware-transcode host, the media-accel variant:

boot2deb build turing-rk1/media-accel-forky

Either produces a whole-disk image (GPT, u-boot in the reserved gap ahead of the first partition, then the ext4 rootfs), so a single write lays down everything, bootloader included. Artifacts are named for the whole build point, so turing-rk1/forky writes build/turing-rk1/forky/artifacts/turing-rk1-forky.img.xz and the media-accel variant writes turing-rk1-media-accel-forky.img.xz. The flashing and boot notes below use turing-rk1/forky; they are identical for any variant (the bootloader and disk layout do not change), so substitute your recipe name throughout.

Flash

Press the built artifact into a verified, optionally personalized raw image first — one master, one file per unit:

boot2deb press turing-rk1/forky rk1-03.img \
    --hostname rk1-03 --ssh-key "$(cat ~/.ssh/id_ed25519.pub)"

The RK1 is a compute module, not a board you plug a card reader into, so the usual write path is the Turing Pi’s BMC, which writes the module’s eMMC. Both BMC routes take the raw file press produces:

tpi flash -n 2 -l -i rk1-03.img       # the tpi CLI, node 1-4

or the BMC web UI’s flash upload. For a removable or NVMe/USB medium you write on another machine — the same image boots from any medium the board scans, since u-boot discovers its root device at runtime — use any flasher, dd included:

lsblk    # confirm the device; dd overwrites it whole
sudo dd if=rk1-03.img of=/dev/sdX bs=4M status=progress conv=fsync

See Producing images for verification, the seed keys, and per-site additions. The tpi CLI and web UI evolve; see Turing Pi’s flashing docs for the current specifics.

Streaming to the eMMC over USB mass storage

tpi flash stages the whole image on the BMC before it writes. The BMC has another mode that does not:

tpi advanced msd --node 2

That reboots the node into USB mass-storage mode, after which its eMMC is an ordinary SCSI disk on the BMC — no custom firmware, no u-boot in the loop, no UART. Allow about ten seconds for it to enumerate, then find it by the vendor string the RK1’s eMMC reports rather than by guessing a letter:

ssh root@<bmc> 'grep -l Rockchip /sys/block/*/device/vendor'
# /sys/block/sda/device/vendor   ->  the node's eMMC is /dev/sda

With the disk present, the image streams straight through with nothing staged on either machine:

xzcat rk1-03.img.xz | ssh root@<bmc> 'dd of=/dev/sda bs=4M conv=fsync'

conv=sparse is worth knowing about here and worth understanding before you use it: it makes dd seek over runs of NULs instead of writing them, which is a real saving across a USB mass-storage link, because most of a fresh image is zeros. The catch is that seeking leaves whatever was there before — so it is a faster write, not a clean one. On a node whose eMMC has held another system, the stale bytes can include an old backup GPT or a filesystem signature that blkid will still find. Use it on a disk you do not mind reading as half-overwritten, and leave it off when you want the medium to say exactly what the image says.

The same mode is the route by which an already-flashed node can be edited in place rather than reflashed — mount the exposed rootfs on the BMC and change what you need, which is how a boot2deb seed key can be applied after the fact. Whether that half works is a property of the BMC firmware rather than of this mode: it needs ext4 in the BMC’s kernel and enough userland to be useful. One command tells you before you plan around it:

ssh root@<bmc> 'grep -w ext4 /proc/filesystems && command -v mount'

The write half needs neither, so it stands on its own where the mount half does not.

This exposes the eMMC and nothing else, exactly as tpi flash does. The M.2 disk stays invisible to the BMC in this mode as in every other; see Writing the NVMe from u-boot and Installing to the NVMe from the booted node for the two routes that reach it.

u-boot on eMMC, OS on a separate disk

A common RK1 setup keeps only u-boot on the eMMC and runs the OS from an NVMe or USB disk. The builder produces the two pieces for this directly.

The whole split at once — build the split layout, which emits two images instead of one:

boot2deb build turing-rk1/forky --layout split
  • turing-rk1-forky-boot.img — u-boot only (idbloader + u-boot.itb at their offsets, no GPT), for the eMMC.
  • turing-rk1-forky-rootfs.img — GPT + rootfs, for the NVMe/USB disk.

press emits the same pair as personalized copies — press turing-rk1/forky --layout split --boot-out emmc.img --rootfs-out nvme.img --hostname rk1-03 — with the seed riding the rootfs half.

Just the bootloader — if you only need the eMMC u-boot image (e.g. to re-flash the bootloader across nodes) without building a whole OS, the u-boot stage emits it on its own:

boot2deb build turing-rk1/forky --stage uboot

This writes turing-rk1-forky-boot.img (a few MiB, gap-sized) alongside the raw turing-rk1-forky-idbloader.img and turing-rk1-forky-u-boot.itb. Flash the -boot.img to the eMMC with tpi/web UI; write the rootfs image to the target disk.

Because tpi/web UI flash the eMMC only, the rootfs image goes onto the NVMe/USB disk by another route. The bootloader itself is the shortest one — see below.

Installing to the NVMe from the booted node

The shortest way onto the M.2 disk uses no serial console and no bootloader prompt at all: flash the eMMC with the BMC — which it can do in one step — carrying the image inside itself, boot that, and let the node write its own NVMe.

boot2deb press turing-rk1/forky rk1-03.img --embed-image \
    --hostname rk1-03 --ssh-key "$(cat ~/.ssh/id_ed25519.pub)"
tpi flash -n 2 -l -i rk1-03.img

--embed-image carries the recipe’s own compressed artifact inside the pressed image at /var/lib/boot2deb/install/. Power the node on, let first boot finish, then from an ssh session:

sudo boot2deb-install-to /dev/nvme0n1

boot2deb-install-to ships in every image. It refuses anything that is not a whole disk, refuses the disk the system is running from, refuses a disk with anything mounted on it, and requires you to type the device’s name — then writes the embedded artifact and syncs. Power off, and the node boots from the NVMe; the rootfs grows to fill it on that first boot. Re-running it is safe and still writes, so an interrupted write is repaired by repeating it.

That is one BMC flash and one ssh session. The eMMC keeps a complete, bootable copy of the same system, which is a useful thing to have on a node whose OS disk you are about to replace.

Writing the NVMe from u-boot

The BMC writes eMMC and nothing else: the loader it streams into the module speaks eMMC, so the M.2 disk is invisible to tpi flash, to the web UI, and to gadget mode. The RK1’s own u-boot has no such limit — it enumerates the disk over pcie3x4 — so the shipped bootloader carries the two commands that let a host reach it.

This route needs a UART session and interrupting the boot countdown, so reach for it when you want the disk written from outside the node — a bare M.2 with no system on it yet, or a node whose OS will not boot. To install onto the NVMe of a node that boots, the previous section does it with no console at all. Build the tool variant for the full set:

boot2deb build turing-rk1/util --stage uboot     # writes turing-rk1-util-boot.img

Flash that to the eMMC with tpi, open the node’s UART, and interrupt the countdown. Two routes from the prompt:

Export the disk to the BMC. ums presents any block device u-boot can see as USB mass storage, so with the node’s USB in device mode the BMC sees the NVMe as a normal disk:

=> nvme scan
=> ums 0 nvme 0

then, from your machine, stream the image through the BMC — nothing is staged on the node or the BMC:

xzcat turing-rk1-forky-rootfs.img.xz | ssh root@<bmc> 'dd of=/dev/sdX bs=4M'

Or pull the image in over the network and let u-boot write it. This needs a gzip image, since u-boot has no xz decompressor:

boot2deb build turing-rk1/forky --layout split --compress gz
=> dhcp
=> tftp ${loadaddr} turing-rk1-forky-rootfs.img.gz
=> gzwrite nvme 0 ${loadaddr} ${filesize}

gzwrite decompresses and writes in one pass. Hash the image first with md5sum if the link is one you do not trust — several GiB over TFTP has no integrity check of its own. Images at or above 4 GiB uncompressed need gzwrite’s explicit outsize argument; the shipped recipes are well under it.

Either way the eMMC still needs a bootloader afterwards. boot2deb build turing-rk1/forky --stage uboot emits the shipping one, which also carries ums — so a node that boots from NVMe keeps a route back to its disks without reflashing the tool.

Or keep the OS on eMMC and use the NVMe for data

Often the better answer, and it makes the whole errand above unnecessary: flash the entire system to the eMMC — which the BMC can do in one step — and let the M.2 disk hold data only. Reimaging then never touches the data, because the new image finds the volume by label and adopts it.

The RK1’s 29 GB eMMC has room for any of the shipped images several times over, so nothing is given up by keeping the OS there. No shipped recipe assumes this layout — where the data lives is an installation’s choice, not the board’s — so you add it to your own recipe. See Data volumes.

Serial console

To watch u-boot and the kernel come up, open the node’s UART from the BMC:

tpi uart --node <n> get
# or, on the BMC directly:
picocom /dev/ttyS<n> -b 115200

On BMC firmware 2.1.0 and newer the node number maps 1:1 to the ttyS number (node 1 → ttyS1, node 2 → ttyS2, …). On 2.0.5 and older the mapping was offset (node 1 → ttyS2, node 2 → ttyS1, …), so check your firmware version. The baud rate is 115200. See Turing Pi’s UART docs.

Forcing one boot from a chosen medium

A node carrying a system on both its eMMC and its M.2 disk boots whichever its boot_targets list reaches first. To boot the other one once — to check that a freshly written eMMC copy comes up, say, without disturbing a node that normally runs from NVMe — override the list at the prompt instead of writing it:

tpi uart --node 2 get          # watch this while the node powers on
tpi power on --node 2

Interrupt the countdown at Hit any key to stop autoboot: with any key, then:

=> printenv boot_targets        # the shipped order, whatever it is on your build
=> setenv boot_targets mmc0     # or nvme0, or "mmc0 nvme0" to try both
=> boot

setenv without saveenv lives until the next reset, so this cannot strand the node: power-cycle it and the shipped order is back, unchanged. That is the whole reason to prefer it over editing the environment.

Driving that conversation for you — powering the node and answering the prompt in one command — is device tooling rather than an image builder’s job, and boot2deb does not do it; the same boundary that keeps it from writing devices.

First boot

Power the node on. On first boot the image:

  • regenerates its SSH host keys, and
  • grows the rootfs to fill the whole medium (the 2 GB image expands to the disk’s capacity), online, in the same boot — no reboot involved.

Log in as user debian with the password the build printed. It is expired, so you are required to set a new one immediately. The debian account has passwordless sudo, and the hostname is turing-rk1.

That is a booted Debian system. The kernel’s transcode devices come up on every variant — check for /dev/dri and /dev/rga. A media-accel image also installs the ffmpeg-rk userspace, so you can exercise the rkmpp / rkrga paths directly; on a base image the blocks are present but idle until you install the media-accel debs (or build a turing-rk1/media-accel-* image).

Running the accelerated FFmpeg

ffmpeg-rk installs under /opt/ffmpeg-rk and is on PATH as ffmpeg-rk (and ffprobe-rk). The suffix is deliberate: the build ships the same library sonames as Debian’s own FFmpeg, so it is kept out of the system’s paths and out of the loader’s search path, and the plain ffmpeg name stays with the distro package.

ffmpeg-rk -hide_banner -filters  | grep rkrga      # scale_rkrga, vpp_rkrga, overlay_rkrga
ffmpeg-rk -hide_banner -encoders | grep rkmpp      # h264_rkmpp, hevc_rkmpp

Hardware decode is reached with -hwaccel v4l2request, not with the *_rkmpp decoders — those are compiled in but do not open on a mainline kernel, where rkvdec is a V4L2 stateless driver rather than an MPP service. A transcode that scales looks like this, and scales on the CPU:

ffmpeg-rk -hwaccel v4l2request -i in.mkv \
          -vf "hwdownload,format=nv12,scale=1280:720" \
          -c:v hevc_rkmpp out.mp4

Both of those limits are stated as caveats on the media-accel-rockchip feature, so they print at the end of a build of any recipe composing it; see Support matrix.

H96 MAX M9

The H96 MAX M9 (and the M9S, the same board) is an Android TV box built on the Rockchip RK3576 — octa-core (4x Cortex-A72 + 4x Cortex-A53), Mali-G52 MC3, LPDDR4X, eMMC 5.1, Gigabit ethernet, and HDMI. boot2deb turns it into a mainline Debian box: kernel v7.2, u-boot v2026.07, no vendor BSP.

It is a cheap and widely available RK3576 board, which makes it a practical target — and an awkward one. There is no SD slot (the pads are depopulated), no reset button, and no exposed serial header until you open it, so most of the work of supporting it is having a bootloader that can recover the board without a cable. That is what the RK3576 u-boot images exist for.

Recipes

RecipeDeliverableStatus
h96-max-m9/forkyWhole-disk Debian image (forky)expected — the board has booted this configuration, but not at this pin
h96-max-m9/media-accelThe same image plus HW video decode and the RGA 2D acceleratorexperimental
h96-max-m9/utilu-boot only — the recovery tool, with this board’s ethernetbuilds; ethernet validated

The base image carries the NPU — see The NPU below.

Build the shipped image as in Getting started:

boot2deb build h96-max-m9/forky

That writes build/h96-max-m9/forky/artifacts/h96-max-m9-forky.img.xz — GPT, u-boot in the raw gap ahead of the first partition, then the ext4 rootfs, so one write lays down everything.

Flash

The box boots from eMMC and has no card slot, so every path goes over USB through the USB 3.0 Type-A port on the rear panel — that connector is the SoC’s drd0 controller, wired as a USB device. A plain USB-A-to-USB-A cable to your laptop is what talks to it.

Two routes, in order of preference:

1. From a running u-boot (ums), no vendor tooling. Stream the util image into RAM over maskrom (see The maskrom loader), then at the u-boot prompt:

ums 0 mmc 0

The eMMC appears on your laptop as a USB block device. Press the image into a verified raw file, then write it with any flasher — confirm the device with lsblk first, since the eMMC reports as an ordinary fixed disk:

boot2deb press h96-max-m9/forky h96.img --hostname h96-01
sudo dd if=h96.img of=/dev/sdX bs=4M status=progress conv=fsync

See Producing images for verification and the per-unit seed keys (--wifi-ssid joins this box to your network at first boot).

2. Over rockusb, with the board in maskrom mode (below): load the build’s own maskrom loader (*-maskrom.bin, from build --stage uboot), then write the raw pressed image from sector 0:

rkdeveloptool db build/h96-max-m9/forky/artifacts/h96-max-m9-forky-maskrom.bin
rkdeveloptool wl 0 h96.img

rkdeveloptool takes the raw file press produces (not the .img.xz). This route is also the fallback if you have no u-boot on the board yet; a failed db almost always means no board in maskrom mode on USB.

Reading the eMMC back over rockusb does not work — the read path truncates at 32 MiB. Use ums (above) or dd from a booted system.

Getting into maskrom

Maskrom is the BootROM’s USB download mode, and it is the entry that depends on nothing already working on the board.

  • The floor, on any firmware: short the two eMMC test pads (clock and ground, on the solder side next to the EMMC silkscreen) at power-on. The BootROM cannot clock data out of the flash and falls through to USB device mode. The status LED glowing dim rather than bright confirms it.
  • Once the board runs boot2deb’s u-boot: press the recessed button in the AV jack before connecting power. Our u-boot carries the download-key patches, so the button drops straight into maskrom.

The AV-jack button behaves differently on factory firmware depending on the build — some reach loader mode, the reference unit’s newer firmware boots Android recovery instead — which is exactly why the pad short is the documented floor.

Serial console

The UART is on an unpopulated 3-pin header inside the case, and it runs at 1500000 baud, not 115200 — a Rockchip default that will otherwise look like a dead port.

You do not need it. The display u-boot the image ships with drives the HDMI console and a USB keyboard on the drd1 (USB 2.0) port, so the u-boot prompt, the boot menu, and a rescue-stick boot are all reachable on the television.

First boot

Power on. The image regenerates its SSH host keys and grows the rootfs to fill the eMMC, online, in the same boot. Log in as debian with the password the build printed; it is expired, so you set a new one immediately. The account has passwordless sudo and the hostname is h96-max-m9.

Hardware status

Validated on the reference unit (8 GB / 128 GB) running a boot2deb image:

SubsystemState
Boot to HDMI login, eMMCworks
Ethernet (GMAC0)works
eMMC (HS400-ES)works
HDMI videoworks — up to 340 MHz TMDS, so 1080p60, 1440p60 and 4K30
HDMI hotplug + EDIDworks — unplug/replug re-reads EDID, desktop included
GPU (Mali-G52 / panfrost) + Mesa GLworks — GL 3.1 / GLES 3.1, full desktop composites on it
Wi-Fi, 2.4 + 5 GHzworks (AIC8800D80)
Bluetoothworks — hci0 up, LE + classic scan, A2DP audio
Suspend / resume (s2idle)works
USB 2.0 hostworks
Bundled remoteworks, zero-config
IR receiverworks — NEC decoded to input events
HDMI-CECworks — the box wakes, switches to and standbys a TV; opt-in, see below
NPU (rocket)device works — jobs compute bit-exact; no userspace for this SoC yet
HDMI audioworks
S/PDIF (optical)works
Analog audio (3.5 mm)fixed in tree — the DAC is on sdo2; end-to-end confirmation on a shipped image still owed
HW video decode, HEVCworks — 1080p and 4K on the VDPU383, bit-exact against software
HW video decode, H.264decodes, but not reliably — see below before using it
HW video encodeno mainline driver
RGA 2D acceleratorworks — both RGA2 cores, over DMA-BUF only; see below
SD cardabsent — the slot is depopulated
USB 3.0 SuperSpeedblue port only, and unconfirmed — enabled in tree, never yet trained on hardware; the black ports cannot, see below

Things the board needs that are worth knowing about:

  • Wi-Fi is an out-of-tree module. The AIC8800D80 has no mainline driver, so boot2deb builds one from a pinned upstream repo as a .deb through the kmods layer — declared by the device as device_kmods = ["aic8800"], not carried as a kernel patch series, so an RK3576 board without the chip gets a lean kernel.
  • Bluetooth audio needs libspa-0.2-bluetooth installed alongside a desktop. The image ships bluez, so hci0 comes up on its own and a headset pairs — but sound only reaches it through PipeWire’s Bluetooth plugin, which wireplumber and pipewire-pulse merely Suggest. No desktop metapackage brings it in, and a paired headset with no sink is what its absence looks like. Images are headless and carry no PipeWire, so install it when you install the desktop.
  • cpuidle.off=1 is in the kernel command line. A core suspended into the DT CPU_SLEEP state can miss its wakeup on this platform’s BL31. It is a board-level workaround, stated in devices/h96-max-m9.toml with the condition to drop it.
  • Only the blue port beside HDMI can ever carry SuperSpeed, and whether it does is not yet settled. That port is drd0, and it runs SuperSpeed on the usbdp PHY with snps,dis_rxdet_inp3_quirk — dwc3’s receiver-detection workaround is what turned SS training into a -62/-71 SetAddress loop, and suppressing it is the lever this board has. No SuperSpeed device has trained on it yet, so treat the port as high speed until you have measured otherwise. The black ports are drd1, and they cannot: they sit behind an internal 1a86:8091 4-port USB 2.0 hub that also carries the bundled remote’s receiver, and drd1’s own SuperSpeed lane reaches no connector, so its SS phy is left off the controller entirely.
  • 4K60 is not reachable on any dw-hdmi-qp board, this one included, and you see it in the mode list rather than as a failure: on a 4K60 display the connector offers 3840x2160 at 30, 29.97, 25, 24 and 23.98 Hz with 30 preferred, 2560x1440 at 60, and 1920x1080 at 60 — no 4K60 entry at all, because the driver rejects the mode before userspace sees it. The bridge rejects every mode above 340 MHz TMDS because it has no SCDC/scrambling support, so 4K30 (297 MHz) is the ceiling even when the display advertises 4K60. This is upstream behaviour, not a board or device-tree limitation.
  • The 3.5 mm analog jack needed both a device-tree and a kernel fix. Its DAC sits on SAI1’s sdo2, and reaching it takes rockchip,sai-tx-route = <0 1 0> — in SAI_PATH_SEL, TX field x selects which stream drives SDO port x, so the third entry is the one that matters. The upstream SAI driver programmed that register only at probe and the value did not survive to the running device, which no in-tree board could notice because they all describe the identity mapping the register already holds. The rk3576-fixes series carries the driver fix, as patch 103. Verified at the register level by driving each SDO port in turn and listening; the device tree alone cannot fix this board.

Hardware video decode and RGA

h96-max-m9/media-accel adds the media-accel-v4l2 feature on top of the base image: ffmpeg-rk built with V4L2-request decode, and librga2 with the out-of-tree RGA driver the feature’s own patch series and kconfig fragment bring in. The base image carries neither — the VDPU383 driver is on the SoC layer either way, but nothing in a base image can drive it.

boot2deb build h96-max-m9/media-accel

This is a decode-and-2D capability, not a transcode one. There is no mainline encoder for the RK3576, so an ffmpeg that decodes in hardware still encodes on the CPU.

Decode: drm_prime is not optional

ffmpeg-rk -hwaccel v4l2request -hwaccel_output_format drm_prime -i in.mkv -f null -

Both flags matter, and the second one is the difference between an accelerator and a regression. -hwaccel v4l2request alone leaves the frames going back to system memory one at a time, and at 1080p the download costs more than software decoding the stream would have: the hardware path ends up slower than no hardware path. With -hwaccel_output_format drm_prime the decoded frames stay in DMA-BUF handles, and 4K HEVC runs at real time for about 1/130th of the CPU.

So anything that consumes the output has to speak DMA-BUF — a KMS plane, a GL or Vulkan importer, or librga. A filter chain that cannot takes the download and the loss with it.

H.264 decode is not reliable on this SoC

HEVC is bit-exact against the software decoder on every run. H.264 is not: roughly one decode session in three to six comes out visibly wrong, and when it does the whole session is wrong — it diverges from the first frame at about 17 dB PSNR rather than glitching in places. Re-running the same file usually succeeds, which is what makes it easy to miss.

The fault is latched when the decoder powers up, not accumulated during a decode, so no warm-up or sacrificial first frame avoids it; holding the block resumed across a batch is clean, and letting it power-gate between decodes is not. It is specific to this SoC’s decoder generation — the same clips on an RK3588 are correct in every session.

Until it is fixed, do not rely on hardware H.264 here. The software decoder is correct and quick enough on eight cores — about 160 fps at 1080p and 40 at 4K — and HEVC in hardware is unaffected. This is stated as a caveat on the SoC, so it prints at the end of any build for this board and appears in the support matrix.

RGA: pass it DMA-BUF file descriptors

Both RGA2 cores work, and librga2 is installed for programs that speak its API. ffmpeg is not one of them here: scale_rkrga and vpp_rkrga need MPP, which needs a vendor kernel framework mainline does not have, so this ffmpeg scales on CPU or GPU and RGA is reached directly.

Import buffers with importbuffer_fd, never wrapbuffer_virtualaddr. The virtual-address path builds an IOMMU mapping per call over ordinary process memory and faults roughly a third of jobs — the job times out after a second, the core is soft reset, and the destination is left untouched. It is also about seven times slower when it does work. The same operations over DMA-BUF run clean on both cores.

One consequence worth knowing if a client dies without a message: librga does not fail gracefully when it cannot open /dev/rga or a DMA-BUF heap — it segfaults. The image ships udev rules that make both accessible, so a segfault on a board where they were removed is a permissions problem rather than a library bug.

10-bit stops at the decoder

10-bit content decodes in hardware, but the VDPU383 writes NV15 — packed 10-bit 4:2:0 — and nothing downstream in this image can take it. Vulkan has no such format, and neither Mesa nor ffmpeg’s filters can import it. A 10-bit transcode therefore converts on the CPU. RGA can convert NV15 to P010 for a program that drives it directly, and the display controller scans NV15 out unconverted, so playback straight to a KMS plane is unaffected.

HDMI-CEC

CEC is a control channel inside the HDMI cable, and this box drives it both ways: it can wake a television, claim its input and put it back into standby, and the television’s own remote can drive the box. The kernel side is present in every image for this board — DRM_DW_HDMI_QP_CEC and MEDIA_CEC_RC are set at the SoC layer — so /dev/cec0 exists on a fresh boot and the adapter comes up as Playback Device 1 at physical address 1.0.0.0.

None of it acts until you ask. Three units ship installed but disabled, because a box plugged into a computer monitor, or into a television whose owner would rather it kept to itself, must not start sending CEC messages on its own:

UnitWhat it sendsWhen
cec-tv-onIMAGE_VIEW_ON, then ACTIVE_SOURCE — wakes the TV and switches it to this inputat boot, and on resume from sleep
cec-tv-standbya directed STANDBY to the TVat shutdown and reboot
cec-passthroughnothing; runs cec-follower so the TV remote’s keys arrive as input eventscontinuously

Box drives TV, which is what most people want:

sudo systemctl enable --now cec-tv-on.service
sudo systemctl enable cec-tv-standby.service

--now on the first one wakes the TV immediately, which doubles as the test that it works. The second deliberately has no --now: its work runs at stop, so it fires when the box goes down, not when you enable it.

Enabling cec-tv-on is also what makes the TV follow the box into sleep. The h96-cec hook in /usr/lib/systemd/system-sleep/ reads that one unit for both directions — with it enabled, a POWER press standbys the TV on the way down and wakes it on the way back up. cec-tv-standby covers only shutdown and reboot, so enabling that one on its own leaves suspend untouched: the box sleeps, the HDMI signal simply stops, and the television shows its own “No Signal” instead of going dark. Without CEC that is the whole story of what a suspend looks like on screen — the TV was never in standby, it just lost its source.

For the other direction, the TV’s remote driving the box, enable cec-passthrough and cec-tv-on. The dependency is not incidental: a television forwards USER_CONTROL_PRESSED only to whatever it believes is the active source, so a box that never announced itself receives nothing.

Once cec-tv-on has run the adapter stays configured, so the bus is visible from the box:

cec-ctl -d /dev/cec0 -S

A television that implements CEC answers as 0.0.0.0: TV with its vendor ID. Everything else on the cable is listed too, which is worth reading before enabling cec-tv-standby: the standby it sends is directed at the TV rather than broadcast, precisely so a games console or receiver sharing the bus is not put to sleep along with it.

Two results that look like faults and are not:

  • A physical address is not evidence of CEC support. Physical Address: 1.0.0.0 is derived from a mandatory EDID field, so it is reported even when nothing at the other end speaks the protocol. The real test is whether messages are acknowledged; Tx, Not Acknowledged (4), Max Retries means nothing is driving the CEC line at all. Computer monitors generally do not, including ones from vendors whose televisions do.
  • Some queries go unanswered. GIVE_OSD_NAME and GIVE_CEC_VERSION time out against televisions that omit them, and a CEC 2.0 REPORT_FEATURES can come back unrecognized-op. Those are the sink’s omissions — the transmits themselves are acknowledged, so nothing local is wrong.

The box appears in the TV’s device list as H96 MAX M9. The wrappers in /usr/lib/h96/ take OSD_NAME and CEC_DEV from the environment if a different name or a second adapter is wanted; CEC caps the name at 14 characters.

Sleep and the POWER key

The remote’s POWER key suspends the box, and the same key wakes it. That is the shipped default rather than systemd’s, and the reason is that the alternative is not recoverable here: the box has no power button, and once it is off the remote’s receiver is unpowered, so HandlePowerKey=poweroff would leave cycling the supply as the only way back. To choose poweroff or ignore anyway, see /usr/lib/h96/power-profiles/README on the board.

Sleep is suspend-to-idle, pinned by 50-h96-s2idle.conf. The SoC also offers deep, and selects it by default, but nothing resumes from it — the box powers down and needs its supply cycled — so the image names freeze explicitly rather than letting systemd write mem. Anything that asks logind to suspend, a desktop’s idle timer included, takes that path.

The television does not follow the box unless you tell it to. Suspending stops the HDMI signal and nothing more, which a TV shows as “No Signal” while staying on; putting it into standby alongside the box is one systemctl enable cec-tv-on.service away, and HDMI-CEC covers what that turns on.

Waking is out of band: the receiver drives gpio0 PD3, the gpio-keys POWER input, which is a wakeup-source in the always-on power domain. The bundled remote is the only wake source the box has out of the box. Its receiver cannot wake anything over USB — it leaves the remote-wakeup bit clear in its configuration descriptor, so the kernel creates no power/wakeup node for it — and there is no RTC on this board, so there is no rtcwake either.

A USB keyboard or mouse can wake it if its receiver does set that bit. 70-h96-usb-wakeup.rules arms every HID device that has a power/wakeup node, which skips the bundled receiver and catches a wakeup-capable one. What is armed on a running box:

grep . /sys/bus/usb/devices/*/power/wakeup

The NPU

The RK3576 carries an RKNN neural accelerator, and mainline drives it — through the in-tree rocket DRM-accel driver, no vendor RKNPU2 stack. Every image for this board has it: the shipped h96-max-m9/forky binds rocket on 27700000.npu and presents /dev/accel/accel0.

Jobs submitted to it compute correctly: an int8 convolution is bit-exact against a CPU model on this silicon, and multi-task row-windowed programs stay bit-exact submitted back to back with no gap.

The image supplies the device, not a runtime, and there is no released userspace for this SoC yet. Nothing in Debian opens an accel node at all, and the NPU’s register program is SoC-specific — the RK3576 map is shifted and re-packed relative to the RK3588, so a userspace written for the RK3588 does not run here. rocket-userspace is the library to watch: it is bit-exact on the RK3588 today and names the RK3576 as its next target, but its machine parameters (CBUF size, core count, datatype set) have to be confirmed on this part before it drives this board. Until then, driving the NPU here means writing your own regcmd encoder against /dev/accel.

Two properties of the board’s DTS are load-bearing and fail in ways that do not point at themselves — both power domains (NPU0 and NPU1) on the one core node, or the driver’s own domain attach loses to the device core’s and there is no /dev/accel; and regulator-always-on on vdd_npu_s0, or the rail is torn down as unused ~33 s into boot, long after a successful probe. The board .dts states both with the reasoning.

That always-on rail is the standing cost of carrying the NPU in the base image: it holds a supply up on a board that may never open the accel node.

  • RK3576 u-boot images — the loader / display / util split, what each can do, and why the u-boot variant is its own axis.
  • The maskrom loader — streaming a u-boot into RAM over the BootROM download protocol.
  • Support matrix — every recipe with the exact pins its lock records.

Rockchip RK3576 EVB1 v10

Rockchip’s own RK3576 reference board. boot2deb ships it for one reason: its device tree is already in mainline, so a build of rk3576-evb1-v10/forky exercises the whole RK3576 chain — the kernel builds its DTB from its own tree, u-boot builds against the DDR loader and BL31 — with no board device tree of ours in the picture.

That makes it the SoC-path validator. When an RK3576 build breaks, building this recipe answers whether the fault is in the SoC layer (kernel definition, kconfig fragments, patch series, rkbin blobs) or in a particular board’s .dts. Compare it with the H96 MAX M9, which carries a device tree of its own.

boot2deb build rk3576-evb1-v10/forky

The board defaults to the loader u-boot series — the minimal rockusb image, the dev-board bring-up posture — rather than the display u-boot a consumer board ships. The display and util variants are both in its supported_uboot_series, so a build can select either:

boot2deb build rk3576-evb1-v10/forky --uboot-series rk3576-util

See RK3576 u-boot images for what each series carries and The maskrom loader for streaming one into RAM.

Status

The image builds; nobody has booted it on the hardware. It is expected in the support matrix — every axis it resolves is shared with the H96 MAX M9, which is validated, and it differs only in using an upstream DTB instead of a board one. If you have this board, a boot report is the one thing that would move it.

rk3576-generic

devices/rk3576-generic.toml is not a board. It is a tool host: it exists so the SoC-generic u-boot images — rk3576-generic/loader and rk3576-generic/util — have somewhere to live, since their payload builds from the rk3576-generic control DTB and is byte-identical on any RK3576 board. Those recipes are u-boot-only deliverables (deliverable = "uboot"): they name no suite, no kernel, and no image.

Reach for them when you are bringing up an RK3576 board boot2deb has no device file for yet — they will flash and drive it before anything board-specific exists.

ASUS Chromebook C201

The asus-c201/forky recipe builds a bootable Debian forky image for the ASUS Chromebook C201/C201PA (google,veyron-speedy) — an RK3288 Veyron Chromebook, and the first 32-bit Arm board and first ChromeOS-firmware board boot2deb supports.

RecipeKernelStatus
asus-c201/forkyDebian’s armmp, installed from the archivevalidated
asus-c201/trixiethe same, on the stable suiteexpected
asus-c201/mainline-forkycompiled here from mainline 7.2.yexpected
asus-c201/libre-forkythe same, from GNU Linux-libre — no blobsexpected

The first two are the board as Debian ships it. The third is the seam for changing the kernel — see A kernel of your own below. The fourth is that same kernel deblobbed, in an image with no nonfree firmware at all — see Without the blobs.

boot2deb build asus-c201/forky

That produces build/asus-c201/forky/artifacts/asus-c201-forky.img.xz — a whole-disk image carrying a signed kernel partition and the ext4 rootfs, so one write lays down everything the firmware needs.

What is unusual about this board

Almost nothing is built. The RK3288 and all ten Veyron boards are upstream, Debian’s own armhf kernel runs them, and the bootloader is not ours to make — so asus-c201/forky compiles neither a kernel nor a bootloader, and its lock pins nothing from git:

[rootfs]
suite = "forky"
manifest = "forky.pkgs.lock"

That is the whole lock. Every package in the image — the kernel included — is pinned by name, version, and sha256 in the manifest beside it.

The boot payload is the kernel. ChromeOS firmware (coreboot + depthcharge, in the board’s SPI flash) does not read a bootloader from a disk offset. It scans every boot medium’s GPT for a partition of the ChromeOS kernel type, orders the candidates by attribute bits in the partition entry, and loads a vboot-signed FIT out of the winner. The signature is built by depthchargectl inside the rootfs, deliberately: that is the same packaged tool, reading the same /etc/fstab, that re-signs and rewrites a kernel partition when apt upgrades the kernel on the running board.

So the image carries three partitions: two ChromeOS kernel slots and the rootfs.

#partitioncontents
1KERN-A, 16 MiB @ 12 MiBthe signed kernel
2KERN-B, 16 MiB @ 28 MiBempty, priority 0 — the upgrade spare
3rootfs, ext4 @ 44 MiBgrown to fill the medium on first boot

The empty second slot is not waste. It is what makes a kernel upgrade on this board atomic and reversible: apt writes the slot the board is not running from, and if the new kernel fails to boot, the firmware falls back to the old one by itself. See Upgrading the kernel.

A kernel of your own

asus-c201/mainline-forky is the same board and the same suite, with the kernel compiled here from mainline 7.2.y instead of installed from the archive. Its kconfig comes from the fragments — a Debian-parity baseline plus the RK3288 slice — on top of the in-tree multi_v7_defconfig, and the rk3288-fixes patch series rides along. Everything else is identical: the same signed-kernel boot payload, the same board profiles, the same combined layout.

boot2deb build asus-c201/mainline-forky

That series currently carries one patch, and it is a good illustration of why the recipe exists. RK3288’s crypto/rk_crypto hashes do not inherit their fallback’s statesize, so any user of the shash export/import path gets a truncated state; the fix is four lines, and nothing about it is board-specific — it is simply not in a released kernel yet. A compiled recipe is where a fix like that lives until it is.

Being ahead of the archive is the other half of it, and 7.2 is a good example on this board specifically. analogix_dp — the eDP bridge the panel hangs off — fixed a shift mismatch between the pre-emphasis and voltage-swing values it programs during link training, which is on the one path between this machine and a picture. And the in-tree V4L2 RGA driver, which the RK3288 does use, took the rework that carried RGA3 support into mainline: on the RGA2 side that means colorimetry is announced and synchronised rather than ignored, offsets are computed from the stride instead of the width, strides are aligned to four bytes, odd frame sizes are refused for YUV, and the scaling factor is range-checked. Debian’s armhf kernel reaches all of that too, on Debian’s schedule; this recipe reaches it when you rebuild.

The trade is what you would expect. This recipe compiles a kernel, so a cold build provisions a cross root and takes real time, where asus-c201/forky is a rootfs bootstrap and an image assembly. It also takes on the maintenance Debian was doing for you: a 7.2.y point release is an update --kernel-ref and a rebuild, not an apt upgrade. Take it when you need a kernel change; stay on asus-c201/forky when you do not.

Without the blobs: GNU Linux-libre

asus-c201/libre-forky is asus-c201/mainline-forky with one thing changed: the kernel comes from the GNU Linux-libre tree instead of linux-stable. Same 7.2 release, same multi_v7_defconfig base, same fragments, same rk3288-fixes series — the source is deblobbed, and nothing else differs. The two locks are worth a diff; the kernel’s three lines are all of it.

boot2deb build asus-c201/libre-forky              # stock firmware, or libreboot
boot2deb build asus-c201-libreboot/libre-forky    # libreboot, 32 MiB slots

Choosing that kernel is what makes the whole image free, not just the kernel. The kernel definition carries libre = true, and resolution reads it:

  • the SoC layer’s firmware-brcm80211 is dropped from the package set,
  • its overlay-nonfree/ tree — the two vendored Broadcom blobs — is not laid in,
  • /etc/apt/sources.list offers main alone, so the running board is not one apt install away from putting the firmware back by accident.

boot2deb resolve asus-c201/libre-forky prints a libre line when this is in effect.

Moving it to a newer release works like any compiled recipe, with one difference worth knowing: linux-libre publishes its trees under a tag namespace and appends -gnu to the version, so the ref is sources/v7.2-gnu rather than v7.2.

boot2deb update asus-c201/libre-forky --kernel-ref sources/v7.2-gnu

What stops working

One part, and it is the radio. The BCM4354 is the only thing on this board that cannot run without firmware, and linux-libre removes both loaders that would fetch it: brcmfmac’s firmware request and btbcm’s patchram filename. Both drivers still build and still load — they log that the firmware is not Free and stop.

HardwareOn linux-libreWhy
Wi-Fi (BCM4354, SDIO)does not workbrcmfmac needs brcmfmac4354-sdio.bin + the board NVRAM
Bluetooth (BCM4354, uart0)does not workbtbcm needs the BCM4354.hcd patchram
Wi-Fi via AR9271 USB adapterworksath9k_htc; its firmware is Free (Debian main)
Display — eDP panel + HDMIworksrockchip-drm / analogix_dp, no firmware
GPU (Mali-T764)workspanfrost; Midgard needs no firmware, unlike the CSF parts
Video decodeworkshantro_vpu / rockchip_vdec, stateless V4L2, no firmware
Audio (max98090)worksno firmware
eMMC / microSD / USBworksdw_mmc, dwc2, EHCI/OHCI, no firmware
Keyboard, trackpad, ECworkscros_ec over SPI, no firmware
Crypto engineworksin-SoC, rk3288-fixes applies unchanged

Nothing else on the board loads firmware, so nothing else changes. The RK3288 also has no loadable CPU microcode, and on a unit running libreboot the boot firmware is already free — which is what makes this board a sensible target for the exercise in the first place.

Getting online without the internal radio

Plug in an AR9271 USB adapter. Its firmware comes from the open-ath9k-htc-firmware project, is Free, and ships in Debian main as firmware-ath9k-htc — which is on every C201 image, libre or not, so the adapter works the moment it is plugged in and nmtui treats it like any other interface. This is the same adapter PrawnOS uses on these machines for the same reason.

Bluetooth has no equivalent packaged answer: a USB adapter that needs no firmware at all (some CSR-class dongles) works with the bluez the image already ships, but most modern ones want a blob. Nothing on the image will load one.

Board profiles

A depthcharge board profile describes the firmware a unit runs, not the board model. The C201 has two, and each is a whole shipped build point:

buildprofilepayloadinitramfsboots on
asus-c201/forkyspeedy16 MiB slotsxz, no display stackstock firmware and libreboot
asus-c201-libreboot/forkyspeedy-libreboot32 MiB slotszstd, display stack includedlibreboot only

Both are confirmed on the hardware. The stock profile is the default deliberately: a stock-profile image boots on either firmware, while the reverse is not true — depthcharge-tools sets hwid-match = None on the libreboot profile, so a running depthchargectl on a libreboot unit resolves back to plain speedy and applies the stock constraints.

The libreboot build exists for the payload headroom: libreboot’s depthcharge buffers a 32 MiB kernel (CONFIG_KERNEL_SIZE = 0x2000000) where the stock firmware buffers 16. Taking that headroom needs a matching kernel partition, not just the profile, so the two travel together on devices/asus-c201-libreboot.toml: it extends = "asus-c201" and states only the profile and kpart_size = "32MiB". Everything else — the DTB, the kernel set, the keymap — is the C201’s and is inherited.

What the headroom is spent on is the boot you watch. A signed payload holds the kernel and the initramfs in one fixed budget, and at 16 MiB that budget dictates both halves of a slow, blind boot: the initramfs is compressed with xz, which is the smallest and by some way the slowest to decompress, and it carries no display driver, so nothing can draw until the real root is mounted and udev loads one. The board sits on the firmware’s blank screen for the whole of it.

At 32 MiB neither constraint is worth keeping. Resolution picks zstd for the initramfs (visible as the initramfs line in boot2deb resolve), and the device’s overlay-pre/ tree adds the display stack — rockchipdrm, panel-simple, pwm_bl, pwm-rockchip — to the initramfs module list. The panel then lights during the initramfs rather than after it, which shortens the blank screen and, more usefully, means an initramfs that fails says so on the panel instead of hanging silently.

The wider slots move the rootfs from 44 MiB to 76 MiB into the image, which is the whole cost. Build it like any other recipe:

boot2deb build asus-c201-libreboot/forky

Flash and boot

Press the image — with the install payload embedded, if the card’s job is to put the OS on the internal eMMC — and write it to a microSD card or USB stick:

boot2deb press asus-c201/forky card.img --embed-image --hostname c201-01
sudo dd if=card.img of=/dev/sdX bs=4M status=progress conv=fsync

press verifies the file it wrote, --hostname/--ssh-key personalize the unit, and --embed-image carries the compressed artifact for installing to the eMMC later — see Producing images. Confirm the device with lsblk first; dd overwrites it whole.

The unit must be in developer mode. Then, from a full power-off, boot the medium with Ctrl+U at the developer-mode screen.

  • On libreboot, Ctrl+U works as-is.
  • On stock firmware, external boot must first be enabled once, from a ChromeOS shell: crossystem dev_boot_usb=1.

If a boot fails, the board tells you by rebooting: the signed command line carries panic=30, so a kernel panic or an initramfs that gives up on root returns to the firmware splash about 30 seconds later. A board that never reboots therefore means the kernel never reached the initramfs at all — which on a machine with no serial console is the single most useful thing a failed boot can say. A panic also writes a full dmesg to BOOT2DEB-PANIC.txt on every ext4 partition it can reach.

Expect white screen before the display comes up, and how much depends on the build. The asus-c201 image leaves the DRM stack out of the initramfs to keep the signed payload under its 16 MiB ceiling, so the console appears only once the real root is mounted: about 5 seconds from eMMC, about 8 from USB, the difference being the time the initramfs spends enumerating the stick. The asus-c201-libreboot image carries the DRM stack, so the panel lights during the initramfs instead.

Installing to the eMMC

The board has 16 GB of internal eMMC, and the image is a whole-disk image, so putting the OS there is one command from a card pressed with --embed-image:

sudo boot2deb-install-to /dev/mmcblk0
sudo reboot                 # Ctrl+D boots the eMMC, Ctrl+U the card

The helper finds the embedded artifact, refuses the disk the system is running from and anything mounted, and asks you to type the target’s name before it writes. A card pressed without --embed-image can still do it by hand: copy the .img.xz over and xzcat it into dd yourself.

The eMMC needs no kernel patch, contrary to the usual advice. The Veyron eMMC ships with its primary GPT deliberately corrupted — ChromeOS marks it IGNOREME and uses the secondary, and a stock kernel cannot read a table like that. That only bites if you keep the factory GPT; writing a whole-disk image lays down a fresh, valid one.

Keyboard

A laptop, so it declares a console keymap — keymap = "us", the layout the C201PA ships. The RK1 and the H96 are headless and declare none.

Note that a USB keyboard is not an option at the firmware screens on this board: CONFIG_LP_USB_HID is not set in its libpayload, so depthcharge reads Ctrl+U from the EC keyboard and nothing else. (The Chromebit, which has no EC, is the one board in the family built the other way.)

There are two ways to get another layout, and neither is a build flag — an image’s keymap comes from the config its lock was resolved against:

  • Change it on the running board, offline, like any Debian system:

    sudo dpkg-reconfigure keyboard-configuration && sudo setupcon
    
  • Bake it into an image by writing a recipe that sets keymap. resolve shows what a choice resolves to before you commit it, and names the file to write:

    boot2deb resolve asus-c201/forky --keymap gb
    

    See Adapting a shipped recipe and Locale, timezone, and keyboard.

Getting online

There is no ethernet port, so Wi-Fi is the only way onto the network and joining one is the first thing to do after logging in:

sudo nmtui        # pick "Activate a connection", choose the network, enter the key

NetworkManager owns the interfaces (the base layer’s dhcpcd is excluded here, so the two do not fight over the NIC), and it remembers the network, so this is a one-time step. nmcli device wifi list and nmcli device wifi connect <ssid> --ask do the same job without the interface.

Wi-Fi needs two Broadcom blobs Debian does not ship; they are vendored in the SoC layer’s overlay-nonfree/ tree and are already in the image. Scanning shows randomized, locally-administered MAC addresses — that is NetworkManager, not a fault.

On a libre-forky image the internal radio does not come up at all, by construction; use an AR9271 USB adapter instead — see Without the blobs.

Audio

The image comes up with working speakers. That takes a little doing, because the max98090 codec starts in a state where two separate things are in the way: its amplifiers are muted, and the DAPM mixers that feed them have their DAC input switches open, so there is no route from the DAC to the speakers to unmute in the first place. Clearing only the mutes — which is what reaching for the obvious Speaker control does — still leaves the board silent.

The device’s first-boot.d/20-audio hook closes the routing switches, unmutes both amplifiers, sets sane volumes, and runs alsactl store. alsa-utils replays the result on every later boot, so this happens once and then it is simply the board’s mixer state. Adjust it like any other Debian system:

alsamixer && sudo alsactl store

Bluetooth

The Wi-Fi and Bluetooth halves of the BCM4354 arrive on different buses: Wi-Fi over SDIO, Bluetooth over uart0, which the device tree wires as brcm,bcm43540-bt. The kernel loads the Bluetooth patchram this device vendors alongside the Wi-Fi NVRAM, and the image ships bluez so there is a host stack to use it.

btsdio is blacklisted. The BCM4354’s SDIO side also advertises a Bluetooth function, and if btsdio claims it, Wi-Fi does not survive suspend and resume.

Bluetooth audio takes one package beyond that, if you install a desktop. Sound reaches a headset through PipeWire’s Bluetooth plugin, libspa-0.2-bluetooth, and both wireplumber and pipewire-pulse merely Suggest it — so no desktop metapackage pulls it in, and its absence looks like a headset that pairs and connects but offers nothing to play to. Install it alongside the desktop. Images are headless and carry no PipeWire, so it is not in the image.

Display

An eDP panel and a real HDMI port, both driven by mainline rockchip-drm.

HDMI does 4K30 (3840x2160 at a 297 MHz pixel clock) and cannot do 4K60. That is the hardware: the RK3288 caps TMDS at 340 MHz, its HDMI PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path to 4K60 either. Nothing in the image configures any of this — the ceilings are constants in the driver, and the kernel Debian ships already supports everything the SoC can do.

One quirk is worth knowing if a 4K display comes up showing only part of the picture. The RK3288 has two display controllers, and the smaller one (VOPL) tops out at 2560x1600 while advertising the same maximum as the larger one. Which controller the HDMI encoder lands on is decided at runtime by DRM, not by configuration. dmesg | grep -i vop says which one it got.

Status

A boot2deb-built image boots this board. Confirmed end to end on a libreboot unit, from USB via Ctrl+U: forky comes up, runs first boot, and reaches a login prompt. The per-image password works and is changed at first login; nmtui joins Wi-Fi.

The root= baked into the signed kernel names the rootfs PARTUUID the image was built with, and the device keeps that identity for life — first boot grows the partition but never rewrites its PARTUUID, so the signature stays valid with nothing to re-sign. KERN-A ships signed and correct; the empty KERN-B spare is first populated by a kernel upgrade, which writes the slot it is not running from and leaves the proven one as the fallback.

The compiled-kernel recipe boots too. An asus-c201/mainline-forky image came up on the same unit with no self-test failures, no warnings, and taint 0 — so a mainline 7.1.y kernel and the rk3288-fixes patch are both proven on silicon. A later build of it also booted from USB and installed cleanly to internal eMMC, which exercises the whole image path rather than just the boot. That was v7.1.3; the recipe now pins v7.2, which is why its claim reads expected rather than validated — that kernel has not been on the board.

Expect a white screen of a few seconds after Ctrl+U before the boot messages appear — measured at about 5 seconds from eMMC and about 8 from USB. That is normal and not a fault: this image carries no display driver in the initramfs, so the panel holds the firmware’s last frame until the kernel’s DRM stack loads from the mounted root.

Audio is confirmed on hardware: the internal speakers and volume control work out of the box. Bluetooth ships configured — the kernel log shows the radio initialize and load its patchram — but has not yet been exercised against a device.

Stock-firmware hardware is untested. The stock speedy profile is what the image ships by default and there is good reason to expect it to work — the profile is depthcharge-tools’ own stock definition, the same one postmarketOS and Arch Linux ARM use on these boards, and a libreboot unit boots it — but no one has yet booted a boot2deb image on a C201 running its factory firmware. Treat it as high-confidence, not proven, and note the extra crossystem dev_boot_usb=1 step above.

Wi-Fi needs two Broadcom blobs Debian does not ship (a board NVRAM file and a Bluetooth patchram); they are vendored in the SoC layer’s overlay-nonfree/ tree, since it is the same radio module on every Broadcom board in the family. See socs/rk3288/README.md for their provenance and why Debian’s and ChromiumOS’s copies are the wrong module.

The linux-libre recipes have not been on the board. They resolve, their kconfig merges clean, and the rk3288-fixes series applies to the deblobbed tree unchanged — all checked — but no image built from asus-c201/libre-forky has booted yet, which is why both libre claims read expected. The dead radio is by construction and is not what that claim is about.

The family

The depthcharge boot method is not C201-specific, and that is the point of it. Two siblings ship already — the C100P and the Chromebit CS10 — and each is a device file and nothing else: no overlay, no engine change, no kernel. Their device trees are upstream and everything that makes a Veyron boot lives on the shared layers.

The same method reaches the seven remaining Veyron boards and the RK3399 gru Chromebooks — which are easier than this one: arm64, a 32 MiB budget, and firmware that loads a FIT ramdisk without the DTB patching. Doing the hard 32-bit case first is what makes those nearly free.

ASUS Chromebook Flip C100P

The asus-c100p/forky recipe builds a bootable Debian forky image for the ASUS Chromebook Flip C100P/C100PA (google,veyron-minnie) — the 10.1“ convertible of the RK3288 Veyron family. asus-c100p/trixie is the same board on the stable suite.

boot2deb build asus-c100p/forky

That produces build/asus-c100p/forky/artifacts/asus-c100p-forky.img.xz — a whole-disk image carrying two ChromeOS kernel slots and the ext4 rootfs, so one write lays down everything the firmware needs. The kernel is in the first slot; the second ships empty, and is what lets a later kernel upgrade roll itself back if the new kernel does not boot. See Upgrading the kernel.

A C201 that folds

Structurally this board is the C201. It includes the same rk3288-veyron-chromebook.dtsi, so the EC keyboard, the trackpad, the microSD slot, the max98090 codec and the eDP panel are all identical, and all of them — along with the Broadcom radio, the initramfs and the network stack — are inherited from the shared layers. Its device file states a boot method, a board profile, a DTB and a few defaults, and ships no overlay.

Its own hardware deltas are four, and only the last two matter to you:

C201C100P
panel1366x768 innolux,n116bge1280x800 auo,b101ean01
extra inputvolume buttons, and a touchscreen
touchscreennoneElan ekth3500 on i2c3
battery gaugesbs-batteryti,bq27500

Board profiles

One: minnie, which depthcharge-tools carries as a first-class board. There is no libreboot port for it — the family’s only libreboot profile is the C201’s — so this board runs stock ChromeOS firmware and the crossystem step below is required.

Flash and boot

Press the image — --embed-image if the card will install the OS to the internal eMMC — and write it to a microSD card or a USB stick (the C100P has both, and two USB ports, so unlike the Chromebit nothing here needs a hub):

boot2deb press asus-c100p/forky card.img --embed-image
sudo dd if=card.img of=/dev/sdX bs=4M status=progress conv=fsync

press verifies the file it wrote and --hostname/--ssh-key personalize the unit — see Producing images. Confirm the device with lsblk first; dd overwrites it whole.

The unit must be in developer mode: power off, hold Esc + Refresh and briefly press Power, keep Esc+Refresh held until the recovery screen appears, then Ctrl+D and Enter to confirm. It wipes and transitions; allow 15 to 20 minutes.

Then, once, from a ChromeOS shell (Ctrl+Alt+T, then shell):

sudo crossystem dev_boot_usb=1 dev_boot_signed_only=0

Reboot and press Ctrl+U at the “OS verification is OFF” screen. Ctrl+U covers the SD card as well as USB.

The convertible’s side buttons do nothing here. The recovery combo is read by the EC from the built-in keyboard, and the volume-button sequence Google documents belongs to detachables and tablets, which this firmware is not built as. Use Esc+Refresh+Power.

A USB keyboard will not help you at these screens either: CONFIG_LP_USB_HID is not set in this board’s libpayload, so depthcharge reads the EC keyboard and nothing else. (The Chromebit, which has no EC, is the one board in the family built the other way.)

If a boot fails, the board tells you by rebooting: the signed command line carries panic=30, so a kernel panic or an initramfs that gives up on root returns to the firmware splash about 30 seconds later. A board that never reboots means the kernel never reached the initramfs at all. A panic also writes a full dmesg to BOOT2DEB-PANIC.txt on every ext4 partition it can reach.

Expect a few seconds of white screen on a healthy boot before the display comes up: the image leaves the DRM stack out of the initramfs to keep the signed payload under its 16 MiB ceiling, so the console appears only once the real root is mounted. The same arrangement on the C201 takes about 5 seconds from eMMC and about 8 from USB, the difference being the time the initramfs spends enumerating the stick.

Installing to the eMMC

The board has 16 GB of internal eMMC, and the image is a whole-disk image, so putting the OS there is one command from a card pressed with --embed-image:

sudo boot2deb-install-to /dev/mmcblk0    # the eMMC — the one with mmcblk0boot0 beside it
sudo reboot                              # Ctrl+D boots the eMMC, Ctrl+U the card

The helper finds the embedded artifact, refuses the disk the system is running from and anything mounted, and asks you to type the target’s name before it writes. Without --embed-image the manual form is the same write: copy the .img.xz to the booted board and xzcat asus-c100p-forky.img.xz | sudo dd of=/dev/mmcblk0 bs=4M conv=fsync.

This needs no kernel patch, contrary to the usual advice. The Veyron eMMC ships with its primary GPT deliberately corrupted — ChromeOS marks it IGNOREME and uses the secondary, and a stock kernel cannot read a table like that. That only bites if you keep the factory GPT. Writing a whole-disk image lays down a fresh, valid one over the top, which a stock kernel reads like any other.

The touchscreen and the battery gauge do not work

Both are gaps in Debian’s kernel configuration, not in the hardware, the boot path, or this board’s device tree. The C100P needs two drivers that Debian’s armhf kernel does not build, in forky (7.1.3) and trixie (6.12.94) alike:

whatdriverDebian armhf
Elan ekth3500 touchscreenelants_i2c# CONFIG_TOUCHSCREEN_ELAN is not set
ti,bq27500 fuel gaugebq27xxx_battery_i2c# CONFIG_BATTERY_BQ27XXX_I2C is not set

So a C100P image comes up with a working keyboard, trackpad, panel, HDMI, Wi-Fi, Bluetooth and audio — and no touch input and no battery percentage.

Neither is a near miss you can work around in config. The modules are simply absent from the kernel Debian ships. Note the trackpad is unaffected and does work: it is a different Elan driver (elan_i2c, CONFIG_MOUSE_ELAN_I2C=m), and the similar names are the only thing the two have in common. The C201 is unaffected by both gaps — its battery is an SBS one, which Debian does build, and it has no touchscreen.

Keyboard

A laptop, so it declares a console keymap — keymap = "us", the layout the C100PA ships.

There are two ways to get another layout, and neither is a build flag — an image’s keymap comes from the config its lock was resolved against:

  • Change it on the running board, offline, like any Debian system:

    sudo dpkg-reconfigure keyboard-configuration && sudo setupcon
    
  • Bake it into an image by writing a recipe that sets keymap. resolve shows what a choice resolves to before you commit it, and names the file to write:

    boot2deb resolve asus-c100p/forky --keymap gb
    

    See Adapting a shipped recipe and Locale, timezone, and keyboard.

Getting online

There is no ethernet port, so Wi-Fi is the only way onto the network:

sudo nmtui        # pick "Activate a connection", choose the network, enter the key

The radio is the family’s Broadcom BCM4354 and needs two blobs Debian does not ship; they are vendored on the SoC layer and are already in the image. Bluetooth works as it does on the C201 — the BCM4354’s Bluetooth half is on uart0, the kernel loads the vendored patchram, and bluez is installed to use it. btsdio is blacklisted, because if it claims the BCM4354’s SDIO Bluetooth function, Wi-Fi does not survive suspend and resume. Bluetooth audio takes libspa-0.2-bluetooth on top, for the same reason as on the C201: PipeWire’s Bluetooth plugin is only a Suggests of wireplumber and pipewire-pulse, so a desktop install does not bring it in and a headset pairs with no sink to play to.

Audio

The same max98090 as the C201, so the same first-boot fixup applies unchanged. The codec comes up with its amplifiers muted and the DAPM mixers that feed them holding their DAC input switches open — so there is no route from the DAC to the speakers to unmute in the first place, and clearing only the obvious Speaker control leaves the board silent.

The SoC layer’s first-boot.d/20-audio hook closes the routing switches, unmutes both amplifiers, sets sane volumes, and runs alsactl store; alsa-utils replays the result on every later boot. Adjust it like any other Debian system:

alsamixer && sudo alsactl store

Display

A 1280x800 eDP panel and a micro-HDMI port, both driven by mainline rockchip-drm.

The panel’s backlight has one quirk worth knowing if you write to it directly: its PWM duty must be at least 1%, so the device tree starts its brightness scale at 3, not 0. A userspace policy that writes 0 to turn the backlight down is doing something this panel does not accept.

HDMI does 4K30 and cannot do 4K60 — the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path. Nothing in the image configures any of this.

Like the C201, this board lights two display controllers, and the smaller one (VOPL) tops out at 2560x1600 while advertising the same maximum as the larger. Which one the HDMI encoder lands on is decided at runtime by DRM, not by configuration; dmesg | grep -i vop says which it got. That is the thing to check if a 4K display comes up showing only part of the picture.

Status

Not yet booted on hardware. The image builds, and everything it is made of is shared with a board that does boot: the C100P resolves to the same rootfs, the same boot method, the same signed-payload flow, the same initramfs and the same Debian kernel as the C201, which is confirmed booting to a login prompt. It differs from it in a DTB, a depthcharge profile and a hostname.

Of the family’s three boards this is the one most likely to boot first time — it is the C201 in a different case, and unlike the Chromebit it has a card slot, a keyboard and two USB ports, so there is nothing unusual about getting an image into it. The known gaps are the touchscreen and the battery gauge, above; audio and Bluetooth ship configured and are unverified here.

The family

The depthcharge boot method is not board-specific, and this is what that buys: the C100P is a device file and nothing else. No overlay, no kernel, no engine change — its device tree is upstream and everything that makes a Veyron boot lives on the shared layers. The same holds for the Chromebit CS10, and for the seven Veyron boards not yet written.

ASUS Chromebit CS10

The asus-chromebit-cs10/forky recipe builds a bootable Debian forky image for the ASUS Chromebit CS10 (google,veyron-mickey) — an RK3288 Veyron, like the C201 and the C100P, but in an HDMI stick rather than a laptop. asus-chromebit-cs10/trixie is the same board on the stable suite.

boot2deb build asus-chromebit-cs10/forky

That produces build/asus-chromebit-cs10/forky/artifacts/asus-chromebit-cs10-forky.img.xz — a whole-disk image carrying two ChromeOS kernel slots and the ext4 rootfs, so one write lays down everything the firmware needs. The kernel is in the first slot; the second ships empty, and is what lets a later kernel upgrade roll itself back if the new kernel does not boot. See Upgrading the kernel.

What is unusual about this board

Its device tree tells you the whole story in one line. rk3288-veyron-mickey.dts includes rk3288-veyron.dtsi directly and never picks up rk3288-veyron-chromebook.dtsi — the file that gives every other board in the family its SD slot, its ChromeOS EC, its keyboard, its trackpad, its eDP panel and its max98090 codec. The Chromebit has none of those, and that single omission is what the board is:

Chromebitthe Veyron laptops
removable storagenone — no SD slotmicroSD
internal storage16 GB eMMC (mmcblk0)16 GB eMMC
keyboardnone — USB onlyEC keyboard on spi0
USB portsonetwo, plus the card slot
displayHDMI onlyeDP panel + HDMI
audioHDMI only (ROCKCHIP-HDMI)max98090 (ROCKCHIP-MAX98090-HDMI)
battery / lidnone — mains onlyboth

Everything else is the family’s and is inherited unchanged: the Broadcom radio, the initramfs, the network stack, the boot method. So for all that it is the odd one out, the board’s device file states a boot method, a board profile, a DTB and a handful of defaults — and ships no overlay at all.

Board profiles

One: mickey, which depthcharge-tools carries as a first-class board. There is no libreboot port for the Chromebit — the family’s only libreboot profile is the C201’s — so this board always runs stock ChromeOS firmware, and the crossystem step below is not optional the way it is on a libreboot C201.

You will need a USB hub

The Chromebit has one USB 2.0 port, and installing needs two things in it at once: a keyboard, to press Ctrl+U, and the stick you are booting. So a hub is not a convenience here, it is a prerequisite.

It does work, and not by luck. The Chromebit is the one board in the Veyron family whose firmware is built with USB HIDCONFIG_DRIVER_INPUT_USB=y in depthcharge, and CONFIG_LP_USB_HID=y plus CONFIG_LP_USB_HUB=y in its libpayload — because it is the one board with no keyboard to read instead. A keyboard behind a hub is precisely the topology this firmware was compiled for. (The inverse is also true and worth knowing: CONFIG_LP_USB_HID is not set on the C201 or the C100P, so on those boards a USB keyboard does nothing at the firmware screens.)

Use a self-powered hub, with its own wall supply. ASUS’s own documentation warns that anything drawing more than 500 mA should not hang off this port directly, and a keyboard plus a flash drive on a bus-powered hub is a real brown-out risk.

Flash and boot

Press the image and write it to a USB stick. A stick-shaped computer has no Ethernet, so this is the board where the Wi-Fi seed keys earn their keep — the unit joins your network on its first boot:

boot2deb press asus-chromebit-cs10/forky stick.img \
    --hostname cs10-01 --wifi-ssid "your network" --wifi-psk '...'
sudo dd if=stick.img of=/dev/sdX bs=4M status=progress conv=fsync

press verifies the file it wrote — see Producing images. Confirm the device with lsblk first; dd overwrites it whole.

Entering developer mode. The Chromebit has no power button — it boots the instant DC is applied — so the usual “hold keys and tap power” does not exist here. The sequence is:

  1. Connect HDMI, then the powered hub, then a wired USB keyboard into the hub. Leave the barrel jack unplugged.
  2. Press and hold the recovery button — a pinhole on the underside, opposite the HDMI connector; use a paperclip — and, still holding it, plug in the DC barrel jack. Release when the screen changes.
  3. The recovery screen appears. Press Ctrl+D on the USB keyboard. There is no on-screen prompt for it.
  4. Confirm by pressing the recovery button again with the paperclip — on a device with no keyboard the firmware treats that button as Enter — then Ctrl+D to reboot.
  5. It wipes and transitions to developer mode. Allow 10 to 15 minutes.

Enabling external boot. From a ChromeOS shell (Ctrl+Alt+T, then shell), once:

sudo crossystem dev_boot_usb=1 dev_boot_signed_only=0

Then reboot and press Ctrl+U at the “OS verification is OFF” screen. A 2.4 GHz keyboard with its own USB receiver works as well as a wired one; a Bluetooth keyboard does not, because the firmware has no Bluetooth stack.

If a boot fails, the board tells you by rebooting: the signed command line carries panic=30, so a kernel panic or an initramfs that gives up on root returns to the firmware splash about 30 seconds later. A board that never reboots means the kernel never reached the initramfs at all — which on a machine with no serial console is the single most useful thing a failed boot can say. A panic also writes a full dmesg to BOOT2DEB-PANIC.txt on every ext4 partition it can reach.

Expect several seconds of blank HDMI on a healthy boot before the console appears: the standard image leaves the DRM stack out of the initramfs to keep the signed payload under its 16 MiB ceiling, so the display comes up only once the real root is mounted.

Installing to the eMMC

Running from a USB 2.0 stick works but is slow, and it means the hub stays plugged in forever. The board has 16 GB of eMMC, and once the OS is on it the Chromebit boots with no keyboard, no stick and no hub — Ctrl+D at the developer screen, or just wait out the 30-second timeout.

The image is a whole-disk image, so installing it is one command. Boot the USB stick, join Wi-Fi, get the same .img.xz onto the running system (scp it from the build host, or keep a copy on the stick — first boot grows the rootfs, so there is room), and write it to the internal eMMC:

lsblk                       # the eMMC is mmcblk0 — it is the one with mmcblk0boot0 beside it
xzcat asus-chromebit-cs10-forky.img.xz | sudo dd of=/dev/mmcblk0 bs=4M status=progress conv=fsync
sudo reboot                 # then Ctrl+D to boot the eMMC, or wait out the timeout

First boot on the eMMC then does what it did on the stick: it grows the rootfs to fill the device. That is all it changes — identity is stamped at build time and nothing on the device rewrites it.

Both installs therefore carry the same rootfs PARTUUID. That matters less here than it would elsewhere, because nothing on this boot path is automatic: Ctrl+D and Ctrl+U each name a medium explicitly, and the firmware verifies and loads that medium’s own signed kernel. You get the kernel you asked for.

The one thing the keypress does not cover is the root= lookup that kernel then performs, which resolves the PARTUUID by scanning attached disks. With the stick still plugged in it can land on the other medium’s rootfs — the same build either way, so it boots normally, but you may be writing to the disk you thought you had left behind. Pull the stick once the eMMC is written and the question does not arise.

Why this needs no kernel patch, contrary to the usual advice. The Veyron eMMC ships with its primary GPT deliberately corrupted — ChromeOS marks it IGNOREME and uses the secondary — and a stock Linux kernel cannot read a partition table like that. Every guide therefore tells you an eMMC install needs a patched kernel. That is true only if you keep the factory GPT. Writing a whole-disk image does not: it lays down a fresh, valid GPT over the top, and a stock kernel reads that one like any other. (postmarketOS does exactly this on the C201’s eMMC and boots from it.)

Keyboard

A board with no keyboard still declares a console keymap, and it is not a contradiction. The question keymap answers is “does a console layout configure anything here?” — not “does the board have keys”. The Chromebit drives an HDMI console that a USB keyboard is the only way to type at, so a layout means exactly what it means on a laptop; it just describes a keyboard you bring. The default is us.

There are two ways to get another layout, and neither is a build flag — an image’s keymap comes from the config its lock was resolved against:

  • Change it on the running board, offline, like any Debian system:

    sudo dpkg-reconfigure keyboard-configuration && sudo setupcon
    
  • Bake it into an image by writing a recipe that sets keymap. resolve shows what a choice resolves to before you commit it, and names the file to write:

    boot2deb resolve asus-chromebit-cs10/forky --keymap gb
    

    See Adapting a shipped recipe and Locale, timezone, and keyboard.

This has no bearing on the firmware screens. Depthcharge reads Ctrl+U with its own USB HID driver and its own fixed layout, long before Linux exists.

See Locale, timezone, and keyboard.

Getting online

There is no ethernet port, so Wi-Fi is the only way onto the network:

sudo nmtui        # pick "Activate a connection", choose the network, enter the key

The radio is the family’s Broadcom BCM4354 and needs two blobs Debian does not ship; they are vendored on the SoC layer and are already in the image. Bluetooth works the same way as on the laptops — the BCM4354’s Bluetooth half is on uart0, the kernel loads the vendored patchram, and bluez is installed to use it. Bluetooth audio takes libspa-0.2-bluetooth on top: PipeWire’s Bluetooth plugin is only a Suggests of wireplumber and pipewire-pulse, so a desktop install does not bring it in and a headset pairs with no sink to play to.

Audio and display

Both come out of the HDMI connector and nothing else does.

Audio is HDMI only. The Chromebit’s sound node wires straight to the HDMI codec with no audio-codec phandle, so the machine driver builds a different card entirely: ALSA shows ROCKCHIP-HDMI, not the ROCKCHIP-MAX98090-HDMI the laptops get. There is no max98090, no headset codec, and nothing to unmute — the family’s 20-audio first-boot hook probes for the codec’s mixer controls, does not find them, and exits without touching anything. That is the correct outcome, not a failure.

Display is HDMI only, and simpler than on the laptops. The Chromebit lights one display controller (vopb); there is no eDP, no panel and no backlight. That also means it is free of the trap the C201 has, where two controllers advertise the same maximum and DRM picks between them at runtime.

HDMI does 4K30 and cannot do 4K60. That is the silicon: the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path either. Nothing in the image configures any of this.

Status

Not yet booted on hardware. The image builds, and everything it is made of is shared with a board that does boot: the same boot method, the same signed-payload flow, the same initramfs, the same radio and the same Debian kernel as the C201, which is confirmed booting to a login prompt. What is untested is this board’s own firmware, and its DTB.

Two things to know before you try it, because they are the reported failure modes:

  • USB boot on the Chromebit has been reported to fail — a 2015 review could not boot a stick at all, and there is an unanswered forum thread where Ctrl+U flashes black on a postmarketOS image. Both are most consistent with malformed boot media (a kernel partition that is not in ChromeOS format, or one whose GPT attribute bits are unset) rather than a firmware limit; boot2deb’s payload is built by depthchargectl against the board’s own profile and its attribute bits are asserted at build time. Expect it to work, but this is the board’s open question.
  • No one has confirmed an eMMC install on a Chromebit. The one public attempt fails with “Primary GPT header is being ignored”, which is the factory GPT being preserved — the thing writing a whole-disk image does not do. The reasoning in Installing to the eMMC above is sound and the same flow works on the C201, but it has not been run on this board.

Audio, Bluetooth and HDMI ship configured and are unverified here.

The family

The Chromebit is the awkward member of the family and it still costs exactly one file. It ships no overlay and needed no change to the engine — everything that makes a Veyron boot lives on the SoC and boot-method layers, and a board that is a stick rather than a laptop inherits all of it unchanged. The same holds for the C100P, and for the seven Veyron boards not yet written.

Config model

A build is a single point across the axes a user selects:

device × kernel × u-boot × suite × layout, plus composable features

  • device — the target hardware. It resolves through a layered hardware stack (see below).

  • kernel — an orthogonal axis that owns everything version-coupled: its source refs, .config fragments, and patch series. A device declares which kernels it supports and a default; override with --kernel (values from list-kernels). Some kernels are not built at all.

  • suite — the Debian suite (e.g. forky, trixie); override with --suite. The image’s sources.list carries the pockets that suite actually publishes, so a released suite gets -security and -updates alongside its base and sid gets neither.

    Like the kernel, the suite is a closed set per board: a device declares supported_suites and a default_suite, and anything else is a resolve-time error naming the valid list. A suite is a claim about the board as much as about Debian — the DT, the firmware, and the driver its Wi-Fi part needs all have to exist in that suite’s kernel — so an RK3576 board on bookworm is caught at resolve rather than minutes into a bootstrap. A board whose config is genuinely suite-agnostic declares supported_suites = ["*"], which is the whole list or none of it: mixing the wildcard with named codenames states two different claims and is rejected.

  • u-boot — the bootloader’s own axis, off the kernel entirely: a device declares supported_uboot_series and a default, and a recipe or --uboot-series picks one. Selecting a series applies its uboot-scope patches over the compiled u-boot and leaves the kernel tree untouched, so a bootloader variant costs a series rather than a whole kernel definition. See The bootloader is its own axis. Empty on a board whose u-boot ships pristine, or whose firmware is not ours to build.

  • layout — how the disk image is packaged: combined (one whole-disk image, boot payload and rootfs on a single medium) or split (separate bootloader and rootfs images for a two-medium install); override with --layout. Only a boot method that has a bootloader can split it off; the combination is rejected at resolve for one that does not.

  • features — a list of composable add-ins stacked onto the base image: a capability feature that provides a hardware stack (media-accel-rockchip, the RK35xx HW-transcode userspace) or an application feature that installs an app (jellyfin). A capability feature reaches the kernel as well as the rootfs — see A feature can reach the kernel. Features are the knob the RK1 recipes differ by over one shared device and kernel: turing-rk1/forky (base) selects none, turing-rk1/media-accel-forky adds the capability, and turing-rk1/jellyfin-forky adds the app plus the glue that points it at that capability. Override with --feature (repeatable; values from list-features).

Two more knobs round out a build without being headline axes: --boot-method (a device property, rarely overridden) and --image-size. The depthcharge board profile is a third — see Board profiles — and, like the localization axes, it is resolved from config rather than set at build time.

The system locale, timezone, and console keymap are resolved the same way, and are split across two layers for a reason: see Locale, timezone, and keyboard. The NTP servers an image prefers resolve alongside them, from base.toml — see The clock and time sync.

Kernels are compiled, or installed

A kernel definition’s flavor decides what shape it has, because the two kinds of kernel have almost nothing in common:

  • mainline / vendor — compiled from source. The definition owns a source ref, a base defconfig, a fragment list, and a patch series, and the build clones the tree, applies the series, merges the config, and runs make bindeb-pkg. The lock pins the exact commit.

  • distro-package — installed from the Debian mirror. The definition owns nothing but a package name (linux-image-armmp); Debian owns the source, the config, and the patches. There is no compile node, no fragment merge, no patch series, and no [kernel] table in the lock — the exact version and hash are pinned in the solved package manifest, alongside every other package in the image.

This is not a shortcut. For a board whose SoC and device tree are fully upstream — every Veyron Chromebook, for instance — compiling a kernel would add a cross-build and a maintenance burden to arrive at a worse version of what apt already ships: one that stops receiving Debian’s security updates on the running board. Where Debian’s kernel runs the hardware, using it is the right answer, and the model says so rather than pretending otherwise.

One definition then serves every suite, because the suite picks the version: asus-c201/forky and asus-c201/trixie name the same debian-armmp kernel and resolve 7.1.x and 6.12.x respectively.

A distro kernel rejects the two device fields it could never act on — device_dts and device_config_fragments are compile inputs, and a board that declared them with a kernel that compiles nothing would read as configured and boot as broken.

A deblobbed kernel decides the whole image

A compiled kernel may declare libre = true, which says its source is GNU Linux-libre: every nonfree-firmware loader has been removed from the tree, so no driver it builds can read a blob. kernels/rk3288-libre-7.2.toml is the shipped one.

That is a property of the source, but it decides things well outside the kernel, so resolution propagates it to the whole build rather than leaving each layer to restate it. Three subtractions follow, and only subtractions — a build on any other kernel resolves exactly as it would if this axis did not exist:

WhatWhere it is declaredWhat libre does
Firmware packagesnonfree_firmware_packages on the SoC and device layersleft out of the merged package set
Vendored blobsan overlay-nonfree/ tree beside a SoC or device layernot laid into the rootfs
Apt componentsthe image resolves and ships main alone, not main contrib non-free non-free-firmware

Nonfree firmware is declared by the two hardware layers because which blobs a build needs is a fact about the silicon and the board — not about the distro substrate, the bootloader, or a userspace feature. It sits on the SoC layer when it identifies a part every board on that SoC carries, and moves down to a device when boards differ.

The package half is a subtraction from the include set rather than an entry in exclude: the firmware is unreachable, not unwanted, and excluding it by name would also forbid apt from pulling it in as some other package’s dependency.

The cross-build sandbox deliberately keeps the full component set. It is a build environment that is never shipped, and narrowing the toolchain a libre image is compiled by would not change a byte of what is in it.

Boot methods describe different things

A boot method is not a set of options on a common shape — the shapes genuinely differ — so boot-methods/<method>.toml is a variant per method, and the file’s own name selects it. A field belonging to another method is an unknown field: a parse error naming the file, not a value quietly carried into a build with nowhere to put it.

  • rockchip-rkbin — we compile the bootloader. The layer carries the u-boot source and ref and the raw-gap offsets (idbloader_offset, uboot_itb_offset, rootfs_offset); the device carries uboot_defconfig and inherits an rkbin blob set (ATF + DDR TPL) from its SoC. The payloads land outside any partition, in the gap ahead of the rootfs.

  • depthcharge — we compile no bootloader at all. The firmware is the board’s own (coreboot in an SPI chip), and what it loads is the kernel itself, vboot-signed and wrapped in a FIT, from a ChromeOS kernel partition it finds by scanning each medium’s GPT for a type GUID. The layer carries those partitions’ geometry and the GPT attribute bits that make the firmware boot one of them (priority / tries / successful), plus the command line to bake into the signature. The device carries a board profile.

    kpart_slots is the field worth understanding. It is how many kernel partitions the image lays down, back to back, and it is 2: the first carries the signed kernel, the second ships empty at priority 0. That spare is what makes an on-device kernel upgrade atomic — the upgrade writes the slot the board is not booted from, so a kernel that fails to come up leaves the previous one intact for the firmware to fall back to. At one slot there is no fallback and a bad upgrade needs external media to recover. See Upgrading the kernel.

Because the requirements are method-scoped, a board is only ever asked for fields its own boot method reads: the C201 declares no uboot_defconfig and no rkbin blobs, and omitting them is not an error — omitting its [depthcharge] block is.

Board profiles

A depthcharge board profile is depthcharge-tools’ codename for a firmware behaviour set — its payload ceiling, and whether it loads a FIT ramdisk or needs the initramfs address patched into every DTB. It describes the firmware a unit runs, not the board model: the same C201 takes one profile on stock firmware and another with libreboot installed. So the device declares a default and the profiles it supports, and a recipe’s board (or resolve --board) selects among them.

The default is deliberately the stock profile: a stock-profile image boots on stock firmware and on a unit running libreboot, while the reverse is not true.

The profile decides what goes into the signed kernel partition, so — like the locale and the keymap — it is config the image is resolved from, not a flag applied to a finished lock. build therefore takes no --board: selecting a non-default profile means a recipe that pins it. resolve --board previews one and names the file to write.

A profile also bounds the payload its firmware will accept, and a bound the partition cannot hold buys nothing — so a device built for a wider profile states the matching kpart_size in its own [depthcharge] block, and resolution derives the rootfs offset from kpart_offset + slots x kpart_size so the partitions cannot disagree. devices/asus-c201-libreboot.toml is that pairing: it extends = "asus-c201" and states only board = "speedy-libreboot" and kpart_size = "32MiB".

The slot size then decides more than the layout. The signed payload holds the kernel and the initramfs in one budget, so kpart_size also picks the initramfs compressor: a narrow slot takes xz for its size, a roomy one takes zstd for its speed, and boot2deb resolve prints which on its initramfs line. Derived rather than authored, so widening a slot cannot leave a board paying a decompression cost it no longer owes.

Patch series belong to the kernel

A patch series (e.g. rk3588-accel) is the ordered patch series applied to the source trees before they compile. It is a property of the kernel definition, not a user-selected axis: a kernel names its series via patch_series in kernels/<id>.toml, and there is deliberately no --series flag, because a series that applies to one kernel version does not apply to another — so the series is version-coupled to the kernel that owns it. Series live in a separate patches repo, not in this one. Authoring workflow: Adding a patch.

The lock’s [patches] block records the series plus the same three fields every other pinned source carries — where it came from, the ref that was resolved, and the exact commit:

[patches]
series = "rk3588-accel"
source  = "https://github.com/gregordinary/patches.git"
ref     = "main"
commit  = "527d03d54ea68a375b814ccb3314901530cb8b32"

The commit is the reproducibility pin; the ref is the human-legible half, so “this image used patches v1.3.0” reads without decoding a SHA. Until the series has a release tag, main is the honest value — it says the pin came from the tip of development rather than implying a release nobody cut.

source earns its place independently of tags. verify-sources grades every pin’s durability, and this axis needs it most: update takes the patches commit from a local checkout’s HEAD rather than resolving a remote ref, so it is the pin likeliest to name something that exists nowhere else — a series committed locally and not yet pushed pins fine and then fails for everyone. A kernel definition that names a patch_series must therefore also name a patches_url; resolution rejects one without the other.

Two ranges, not one

A series declares an overall applies_to_kernel envelope, and each entry in a scope list may narrow itself further inside it:

applies_to_kernel = ">=7.0, <7.4"      # the envelope

kernel = [
  "media-accel/kernel/040-vdpu381-multicore-v1-curated.patch",             # no range = always
  { path = "media-accel/kernel/050-av1-iommu-v14.patch", kernels = "<7.2" },
  { path = "media-accel/kernel/050-av1-iommu-v15.patch", kernels = ">=7.2" },  # reworked at 7.2
  { path = "rocket/084-rocket-drv-fix-bo-mm-uaf.patch", kernels = "<7.3" },    # upstreamed in 7.3
]

The envelope gates the build; the per-entry ranges select which patches that build actually applies. Both are declared intent — the git am pass is the enforcement.

applies_to_kernel governs the kernel-family scopes (kernel, ffmpeg, userspace). The uboot scope has its own envelope, applies_to_uboot, matched against the pinned u-boot tag — the two axes move independently, so a series that patches both makes a separate claim about each. u-boot’s zero-padded vYYYY.MM tags are accepted on both sides of a range, so applies_to_uboot = ">=2026.01, <2027.01" reads the way the tags do. A scope whose envelope is omitted claims every version, which is the shape every shipped u-boot series takes: each is written for the one u-boot generation its board runs.

This shape exists because the patch series changes discontinuously while kernels move continuously. A kernel bump where everything still applies changes nothing here except the envelope: no copied lists, no forked series. When one patch does break, the boundary is expressed on that patch alone, and the version-insensitive majority stay bare strings. Upstreaming gets a first-class encoding too — an upper bound reading “needed until mainline absorbed it.”

Because both alternatives live in one list, a single repo checkout still builds 7.1 and 7.2 correctly; a flat list mutated in place would lose that.

Fork a new series name only when the series shape diverges enough that one list is confusing. Series names stay semantic, never version-suffixed, so the kernel definitions referencing them stay stable.

An entry whose range no longer overlaps the envelope is unreachable by construction — no kernel the series admits can select it. That is mechanically decidable rather than a judgement call, so it is reported as a lint rather than left to a cleanup someone has to remember. Retiring such an entry, file included, is safe: an old lock names an old patches commit whose tree still contains both.

A kernel may apply no series at all — a stock mainline kernel whose SoC is fully upstream, or a vendor tree that already ships its patches. It writes patch_series = "none", and then the build never reads the patches repo: nothing is fetched, nothing is applied, verify-patches reports there is nothing to verify (on a recipe whose u-boot axis is also bare), and the lock omits its [patches] block entirely rather than pinning a commit the build never consumes. Such a board builds on a machine with no patches checkout.

The bootloader is its own axis

A board’s u-boot is not one thing. The same silicon support can be packaged as a minimal image that only flashes the board over USB, as the bootloader an OS image ships with, or as a recovery tool with a boot menu and diagnostics. Those differ by a patch series over the same u-boot tag — so the bootloader gets its own axis, sitting beside the kernel rather than under it:

# devices/<board>.toml
supported_uboot_series = ["rk3576-display", "h96-max-m9-util"]
default_uboot_series    = "rk3576-display"

A series is a patch series in the same patches repo the kernel series lives in, selected per recipe (uboot_series = "...") or per invocation (--uboot-series), and validated against the device’s supported set exactly as the kernel axis is. The repo it is fetched from is the boot method’s patches_url/patches_ref, and the resolved commit lands in the lock’s [uboot_patches] block — a full pin like every other fetched source, graded by verify-sources and recorded in each image’s provenance manifest.

Everything the kernel axis gets, this axis gets: verify-patches dry-runs the series against the pinned u-boot, patch import names the recipes an import into it invalidates, and the series’ applies_to_uboot envelope gates the build the way applies_to_kernel gates the kernel one.

A board whose u-boot ships pristine simply declares no series — the RK1 does — or, if it lists some and wants none for this build, selects "none", the same sentinel the kernel axis spells as patch_series = "none". Either way the build fetches nothing and the lock omits [uboot_patches] entirely. Declaring series but no default, with none selected, is a config error rather than a silent fallback to pristine.

A recipe whose deliverable is the bootloader

Because the axis is independent, a recipe can name a bootloader and nothing else:

# recipes/rk3576-generic/util.toml
device        = "rk3576-generic"
deliverable   = "uboot"
uboot_series = "rk3576-util"

deliverable = "uboot" means the artifact is the bootloader alone. Such a build resolves no kernel, no suite, no features, and no rootfs, and its lock records only the u-boot pins. Setting a rootfs axis on one — --suite, --feature, --image-size, a locale — is an error, not a value quietly dropped: there is nothing for it to change, and accepting it would be indistinguishable from acting on it.

The deliverable only exists where the boot method builds a bootloader of ours. A depthcharge board’s firmware is its own, so deliverable = "uboot" on one is rejected at resolution.

A device may exist purely to home such recipes. rk3576-generic is not a board: the SoC-generic u-boot images build from a control DTB that is identical on every RK3576 board, so they live on a tool host rather than being duplicated per board. See RK3576 u-boot images for the worked example.

Out-of-tree modules are their own layer

Some hardware is driven by a module that lives in nobody’s kernel tree — a Wi-Fi part whose vendor maintains its own repo, say. That is not a patch series: it is a fifth source tree, fetched from a third-party repo at a commit boot2deb pins. It gets its own config layer, kmods/<name>.toml:

description = "AICSemi AIC8800 SDIO Wi-Fi (radxa-pkg tracking fork)"

git    = "https://github.com/radxa-pkg/aic8800.git"
ref    = "main"
subdir = "src/SDIO/driver_fw/driver/aic8800"

repo_patches  = ["fix-sdio-firmware-path.patch"]   # the fetched repo's own quilt
local_patches = ["0001-sdio-linux-7.1.patch"]      # ours, kmods/aic8800/patches/
make_args     = ["CONFIG_FDRV_NO_REG_SDIO=y"]
modules       = ["aic8800_bsp", "aic8800_fdrv"]

A board opts in by name only:

device_kmods = ["aic8800"]

The build fetches the repo at the locked commit, applies repo_patches then local_patches (both git apply -p1 unified diffs, not a git am series), builds the modules against that board’s freshly compiled kernel with make M=<subdir>, and ships them as <name>-modules-<kver>. Firmware named in the layer becomes a separate Architecture: all <name>-firmware deb, so two coexisting kernels never collide over one firmware path. boot2deb list-kmods prints what is available.

Why not the patches repo. That repo is scoped to the four trees boot2deb pins itself — kernel, u-boot, ffmpeg, userspace — and its series carry kernel-version envelopes, because a kernel patch’s applicability is keyed to a kernel version. A kmod’s patches are keyed to a driver revision instead, and a lock carries exactly one patches pin, so routing a kmod tweak through it would couple that tweak to every kernel, u-boot, and ffmpeg series pinned in the same lock.

No per-board overrides. A device names a kmod; it cannot retune one. The deb is <name>-modules-<kver> and the artifact-cache node is kmod:<name>, and a local patch does not move the upstream commit the version is built from — so two boards overriding, say, make_args under one name would put different content behind one key. A board that needs different build flags authors its own kmods/<name>.toml; a distinct name is a distinct cache node, correct by construction. An out-of-tree overlay can still retune a shipped kmod (or replace one of its patch files), because a kmod merges across the search path like every other layer.

The hardware stack

The device’s hardware properties resolve by merging four TOML layers, lowest to highest precedence:

arches  ←  socs  ←  boot-methods  ←  devices

Each layer states only its deltas. A value lives at the lowest layer that fully determines it — for example, the DDR TPL blob is board-memory-specific, so it lives at the device layer, not the soc layer. The kernel axis is resolved separately and merged in, since a kernel’s refs and fragments are coupled to its version rather than to the hardware.

The config layers are the top-level directories:

arches/  socs/  boot-methods/  devices/  kernels/  kmods/  features/  recipes/

with vendored bootloader blobs under blobs/<soc>/, kernel .config fragments under fragments/, each kmod’s own patches under kmods/<name>/patches/, and the resolved exact pins in recipes/<device>/<leaf>.lock.

Media-accel sources ride the feature, not the SoC

The [[userspace]] entries and the [ffmpeg] source stanza at the soc layer are optional. They provide the trees a requires_media_accel feature compiles, and they are copied into a build only when a selected feature declares it. A recipe that builds no transcode stack carries no such sources and skips the userspace/ffmpeg compile nodes entirely; a SoC that never transcodes declares neither. Selecting a requires_media_accel feature on a SoC that lacks them is a resolve-time error, so the coupling is checked, not assumed.

A tree is a value, not a field. [[userspace]] is an array, one entry per tree the part has, because which trees exist is the SoC’s statement about its own hardware — so a fourth tree, or a different family’s stack entirely, is a file edit rather than a schema change. The build stage, the lock, the plan nodes and the CLI all loop over whatever is declared. This is the shape kmods/<name>.toml already has for out-of-tree drivers, and it is there for the same reason.

An absent tree is a statement about the hardware rather than an omission:

RK3588RK3576
mppyesno — no vendor mpp_service in a mainline kernel
librgayesyes
libmaliyes — CSF GPU, no mainline driverno — panfrost, so Mesa from the mirror
[ffmpeg.rockchip]yes — the rkmpp/rkrga graftno — the base tree builds unmodified

Each entry says what the tree is and how the rest of the build relates to it, rather than leaving those as rules in code:

[[userspace]]
name            = "librga"
git             = "https://github.com/tsukumijima/librga-rockchip.git"
ref             = "master"
debs            = ["librga2", "librga-dev"]   # what its packaging produces
links           = ["librga2", "librga-dev"]   # what a consumer links against
ffmpeg_flag     = "--enable-rkrga"            # the ./configure flag it earns
ffmpeg_requires = ["mpp"]                     # and what that flag needs alongside it

Three more keys shape a tree that needs them: patched = true marks the one tree that takes the series’ userspace scope (the MPP CMA fix), optional = true means it is skipped unless a build names it with --userspace <name>, and build_deps / targets_filter carry the extra development packages a tree’s probes need and a filter over a vendor variant matrix.

That set is the capability statement the build reads, not just provenance: ffmpeg’s ./configure surface is derived from it, so a SoC declaring no MPP is never asked for --enable-rkmpp (and never build-depends on a librockchip-mpp-dev nothing produces). ffmpeg_requires is what makes the rkrga rule data rather than a special case — its filters allocate RKMPP frames and ffmpeg’s own ./configure rejects --enable-rkrga without --enable-rkmpp, so on the RK3576 librga2 still ships for programs that speak the API directly and the produced ffmpeg carries no librga NEEDED entry at all. The lock mirrors the declared set one-for-one, as [[userspace]] entries keyed by name.

A feature can reach the kernel

A capability is often not purely userspace. A hardware-accel provider whose driver is out-of-tree has to patch and configure the kernel for the hardware to exist at all, so alongside its packages and overlay a feature may declare:

patch_series   = ["rk3576-rga"]      # series that add the driver to the tree
config_fragments = ["accel/rk3576-rga"]  # kconfig that compiles it

Both are needed together — a fragment can only turn on code the tree contains. They compose after the kernel’s own patch_series/config_fragments and the device’s device_patch_series/device_config_fragments, so a feature gets the last word on a symbol the layers below it also set, matching the way its packages stack last in the rootfs merge.

Putting them on the feature rather than the kernel layer is what keeps the opt-in and the thing opted into in one place: an RK3576 build that did not select media-accel-v4l2 does not carry a large out-of-tree driver it has no consumer for.

Both fields require a compiled kernel. A distro-package kernel merges no kconfig and applies no series, so selecting such a feature against one is a resolve-time error naming the feature — otherwise the capability would install its userspace against hardware support that was never built.

A feature can require another, by capability

Two features can be individually valid and useless together, or useless apart. The model has both gates, and they are opposites:

# features/media-accel-rockchip.toml — a provider
conflicts = ["media-accel-v4l2"]     # these two cannot coexist
provides  = ["ffmpeg"]               # what this supplies to the selection

# features/jellyfin.toml — a consumer
requires_capability = ["ffmpeg"]     # something in the selection must supply it

conflicts is symmetric — declaring it on either side is enough — and rejects a selection holding both. requires_capability rejects a selection holding a consumer and no provider:

$ boot2deb resolve turing-rk1/forky+jellyfin
error: feature 'jellyfin' requires capability 'ffmpeg', which no selected feature
       provides — add one of 'media-accel-rockchip', 'media-accel-v4l2'

That composition would otherwise build a perfectly good image whose Jellyfin exits at startup, because the feature installs no FFmpeg and the application treats a missing encoder as fatal. It is the cheapest class of error to catch at resolve: nothing about the failure is visible until the board boots.

A capability is a free-form name, not a feature name, and that is the whole point. Both media-accel-rockchip (RK3588) and media-accel-v4l2 (RK3576) declare provides = ["ffmpeg"], so jellyfin composes with whichever matches the SoC while naming neither — and a provider for a future platform satisfies it with no edit to the consumer. Names are matched literally; a misspelling on either side surfaces as this same error, which reports when no feature in the tree provides the capability at all.

list-features shows both sides, which is where a rejected composition sends you:

$ boot2deb list-features
ffmpeg-nonfree        soc=any     arch=any    needs=ffmpeg
jellyfin              soc=any     arch=arm64  needs=ffmpeg
media-accel-rockchip  soc=rk3588  arch=any    conflicts=media-accel-v4l2 provides=ffmpeg
media-accel-v4l2      soc=rk3576  arch=any    conflicts=media-accel-rockchip provides=ffmpeg

This validates a composition; it does not complete one. Nothing is added to the selection to satisfy a requirement — the recipe still names every feature explicitly. Provider auto-resolution stays a non-goal: the builder tells you the composition is incomplete and which features would complete it, and you choose.

The FFmpeg a build ships is redistributable

Every recipe here builds FFmpeg with --enable-gpl --enable-version3 and nothing that forfeits redistribution, so the ffmpeg-rk .deb and any image holding it may be passed on.

The other flavour is available and is a feature, not a flag:

$ boot2deb build turing-rk1/media-accel-forky                            # free
$ boot2deb build turing-rk1/forky+media-accel-rockchip+ffmpeg-nonfree    # nonfree

ffmpeg-nonfree installs no packages. It sets one axis on the build — FFmpeg’s --enable-nonfree, which admits encoders whose licence terms cannot be combined with the GPL, of which FDK-AAC is the one this tree has a use for — and requires_capability = ["ffmpeg"] makes selecting it without a provider the resolve-time error above rather than a flag nobody reads. Note the second form names the whole selection: a + suffix replaces a recipe’s feature list, it does not add to it, so the nonfree variant of a recipe with features of its own is spelled against a base recipe that has none.

The two are separate builds all the way down. The flavour moves the ./configure flags and the ffmpeg stage’s build root together, both of which the artifact cache keys on, so neither flavour can ever be served from the other’s cache; and each variant reference gets its own lock, work directory and artifact path. What a finished image was built with is recorded in its provenance manifest, under [image] features, alongside every other axis of the build point.

Because a [support] claim says a configuration is fit to publish, a recipe that both declares one and selects ffmpeg-nonfree is rejected at resolution. Reach the flavour as a variant reference; a variant carries no claim and appears in no support matrix.

Nothing on the hardware path depends on the choice. Audio is CPU work on these boards either way, and FDK-AAC’s advantages — bitrates below about 96 kbps, and the HE-AAC profiles — sit outside the 128-384 kbps range a media server transcodes to, which FFmpeg’s own native aac encoder covers.

A board device tree that is not yet upstream

A device normally names an in-tree DTB with kernel_dtb, and the kernel’s own tree builds it. A freshly-supported SoC often has every driver upstream but none of its boards, so a device may instead carry its device-tree sources in device_dts — the board .dts plus any board-specific .dtsi, as config-root-relative paths resolved along the overlay search path like a fragment or blob:

kernel_dtb = "rockchip/rk3576-h96-max-m9.dtb"
device_dts = ["devices/h96-max-m9/dts/rk3576-h96-max-m9.dts"]

The kernel stage copies them into arch/<arch>/boot/dts/<dt_dir>/ after the clone and git am, then teaches that directory’s Makefile to build the DTB, so bindeb-pkg ships it in the linux-image deb like any in-tree board — and a forked board .dts’s #include "<soc>.dtsi" resolves for free. Each source is content-hashed into the kernel tree’s signature, so editing the .dts rebuilds. Resolution checks that kernel_dtb is actually built by one of the listed sources, and that each entry is a contained relative .dts/.dtsi path.

device_dts adds a new board device tree. Editing an existing upstream .dts is a patch’s job, and a source that would overwrite an in-tree file is refused. For the edit → reflash loop, build <recipe> --stage dtb rebuilds just that DTB in seconds.

Extra kernel arguments per board

A board that needs boot-time kernel parameters — a workaround for an output the kernel cannot drive, an idle state the platform firmware mishandles — declares them once at the device layer:

kernel_cmdline = "drm_kms_helper.fbdev_emulation=0 video=HDMI-A-1:d cpuidle.off=1"

The value is appended to the boot path’s generated command line: the extlinux path ships it in /etc/boot2deb/board.conf (as EXTL_CMD_LINE, which mk_extlinux reads on every kernel install), the depthcharge path appends it to the boot method’s signing cmdline. Base arguments stay generated — root= in particular is derived from /etc/fstab on the device and is rejected here, as is anything the shell would interpret when sourcing board.conf. A board with no entry gets the generated command line alone.

Every build gates its console

Among the generated base arguments is loglevel=4, on every board and both boot paths: the console shows KERN_ERR and worse, and everything else stays in the kernel ring buffer where dmesg and journalctl -k still show it.

This exists because a single chatty driver can otherwise print faster than a login can be typed, which costs you the console exactly when a first boot needs it. Out-of-tree vendor drivers are the usual source: a bare printk() carries no severity, so it lands at KERN_WARNING however trivial the message, and such calls are typically ungated by any of the driver’s own debug knobs — lowering a driver’s debug level does not reach them. Gating the console bounds every driver at once, including the ones nothing else can quiet.

A board that wants a louder console appends its own loglevel= to kernel_cmdline. Device arguments are appended after the generated ones and the kernel takes the last value, so the board wins:

kernel_cmdline = "loglevel=7"

A variant board extends another

Sometimes two devices are the same board with one difference: a block enabled for bring-up, a different DTB, a different memory fitting. The difference is real enough to need its own device — device_dts and the DTB name are device-layer fields — but everything else is the same hardware. Such a device names its parent and states only its deltas:

extends = "h96-max-m9"

description = "H96 MAX M9 (RK3576) TV box -- 16 GB fitting"
hostname    = "h96-max-m9-16g"
kernel_dtb  = "rockchip/rk3576-h96-max-m9-16g.dtb"
device_dts  = [
    "devices/h96-max-m9/dts/rk3576-h96-max-m9.dts",
    "devices/h96-max-m9-16g/dts/rk3576-h96-max-m9-16g.dts",
]

The parent is merged under the child by the same rules the overlay search path uses: tables merge key-by-key, and a scalar or array is replaced wholesale, not concatenated. So a variant that wants to add one entry to an inherited list restates the list — which is why the example above restates the parent’s device_dts source alongside its own wrapper. Chains are walked to the base-most device, and a cycle is a named error rather than a hang.

A package that only exists in some suites

Most packages are the same in every suite and are written as a bare name. A few are not: Debian splits, renames and drops binary packages between releases, so a layer that names one unconditionally is right on the suites it was written against and wrong on the rest. An entry in any packages or nonfree_firmware_packages list may therefore name the suites it applies to:

packages = [
    "network-manager",
    # nmtui left `network-manager` for a package of its own at 1.56.0-4, so forky and
    # sid need it named and trixie (1.52.1-1) must not — that archive has no such
    # package, and its `network-manager` already ships the binary.
    { name = "network-manager-tui", suites = ["forky", "sid"] },
    "wpasupplicant",
]

An entry that does not apply contributes nothing and does not reserve its name, which is what lets a rename be written as two entries over disjoint suites — on any one build exactly one of them applies:

{ name = "libv4l-0",    suites = ["bookworm"] },
{ name = "libv4l-0t64", suites = ["trixie", "forky"] },

The suites are enumerated, not bounded. A range (since = "forky") would read closer to the intent and would need no edit when a suite is added — and that is the hazard rather than the convenience, because it silently extends the claim to every future suite, and whether a package still exists in one is a fact only that archive can answer. Enumerating forces the claim to be restated when a suite is added, and verify-packages then checks each claim against the archive it is about. It also means boot2deb owns no release sequence: there is no ordering to maintain and no special case for sid.

The price of enumerating is that a misspelt suite is silent — the entry never applies, so the package goes missing with nothing said. verify-packages pays it back by reporting a suites name that no recipe in the tree builds:

note : network-manager-tui in soc 'rk3288' names suite 'forkey', which no recipe in
       this tree builds — check the spelling

Debian’s symbolic names (sid, unstable, testing, stable, oldstable) are exempt: they are permanent fixtures of the archive, so naming one is never a typo even where no recipe builds it yet. An entry naming an empty suites list is refused outright at resolution — config that can never take effect is a mistake, not a no-op.

exclude takes plain names only. An include has to name a package the archive carries, so which suite is being built decides whether the name is right; an exclude names something that must not be installed and is satisfied just as well by a suite that never had it, so excluding a name a suite does not carry is already a no-op.

Five arrays are the exception and accumulate: caveats, expect, nonfree_firmware_packages, packages and exclude. Each level’s entries are concatenated base-most first and de-duplicated, so a variant inherits its parent’s and adds its own. The line is between describing or supplying the running system and selecting a build input. A variant is the same hardware, so it is bound by everything its parent said about that hardware: a caveat cannot be un-said, a runtime check that held on the parent holds here, a radio that needed firmware still needs it, and a board package the parent installs is one this board wants too. Replacing any of them would let a variant that adds one entry silently drop every entry it inherits — a support claim that is wrong, or a self-test that passes while testing less than the parent’s.

Everything else selects, and a variant makes its own selection: device_kmods and device_patch_series choose which drivers and series the kernel is built with, extra_debs pins exact bytes (two pins of one package would be a conflict, not a sum), and every supported_* list names the alternatives a build may pick from. A value that is not an array at any level of the chain is an error naming the file that holds it — including an ancestor’s, which last-wins alone would have swallowed.

Reach for this only when the difference genuinely needs a device tree or another device-layer field. A capability whose whole expression is packages, kernel config, and a patch series is a feature instead — features compose a-la-carte, where a variant device does not.

The parent’s assets come too: its overlay/ tree is laid into the rootfs before the variant’s, so the variant inherits the parent board’s runtime config — driver tuning in modprobe.d, systemd units, keymaps — and can override any file of it by shipping its own copy at the same path. This is the half a hand-copied variant cannot express: TOML keys can be duplicated by hand, but a device’s overlay tree is found by the device’s name, so a variant with no tree of its own would otherwise get none at all and still build a plausible image.

The two merge axes compose. The extends chain is flattened first, then the search path merges over the result, so an out-of-tree overlay can retune the parent — and have it reach every variant — or retune one variant alone.

An image size can be stated or measured

image_size is normally a size — "2G", "8G" — chosen by whoever added the board, and it is the whole-disk size of the artifact rather than of the installed system: the rootfs grows to fill its medium on first boot.

Picking that number is real friction on a new board, so it can also be measured:

image_size = "fit+20%"   # the smallest image holding the rootfs, a fifth of it free
image_size = "fit+512M"  # ... with 512 MiB free instead

fit sizes the filesystem to its contents. The size a rootfs needs is not a formula — how much room a filesystem has left depends on its group count, its inode tables, the descriptor blocks it reserves to grow into, and the journal its size earns, and every one of those follows from the size — so it is found by placing the rootfs into candidate geometries through the format’s own placement pass. The size that comes back is one that formats, and one ext4 block less is not. The disk is then laid out around it: boot region, that filesystem, backup GPT, nothing over.

The slack is written rather than defaulted. A fitted filesystem with nothing free is the smallest one that holds the rootfs, which boots into a full disk, so a bare fit is refused and names the two forms; fit+0% is accepted, because an explicit zero is a decision. A share is capped at 90% and a byte slack at 64 GiB — past either you have named a size, and naming it is faster than searching for it. Both are checked by resolve, offline, rather than discovered mid-build — and by update and build, which run the same gate before anything is committed or compiled.

fit is opt-in per board and nothing else changes: the shipped recipes all carry a hand-picked size, and a stated size lays out the disk first and formats into it exactly as before.

Explicit over derived

Several device values are redundant with a value the resolver could derive: default_kernel must also appear in supported_kernels; boot_method in supported_boot_methods; kernel_dtb repeats the SoC’s dt_dir prefix; default_suite appears on both the device and any recipe that pins it. These are kept explicit on purpose: every value a board contributes is visible in its own file and greppable across the tree, which matters more in a small hand-authored config repo than saving a few lines. The redundancy is not unchecked — resolution rejects a default_kernel outside supported_kernels, a boot_method outside supported_boot_methods, and so on — so a drifted duplicate fails fast rather than silently. boot2deb new-device emits these values for you, so the boilerplate is paid by the generator, not the author.

A value that becomes a file or a line is checked at resolve

Most config values are copied verbatim into something with a grammar of its own — a file name, a shell-sourced line, an /etc/hosts entry, an apt source line. In every such case the shape is checked where the value is authored, so a config that could not produce a working image fails resolve rather than producing an image that is quietly wrong:

ValueAccepted shapeBecause it becomes
device slug (devices/<slug>.toml)a host name, as belowthe file, the recipe folder, the overlay tree, the work dir — and the default hostname
hostnameRFC 1123 host name: [A-Za-z0-9-] labels joined by ., ≤ 63 per label and ≤ 64 total, no leading/trailing -all of /etc/hostname, and the name half of an /etc/hosts line
apt_sources.nameportable file stem: [A-Za-z0-9._-], not ./..sources.list.d/<name>.list and <name>.gpg in the image
apt_sources.signed_bythe same, a bare file namea lookup in blobs/keyrings/
apt_sources.uri / suite / componentsnon-empty, no whitespace or [/]; the URI http(s)positional fields of one apt source line
kernel_cmdlineno root=, nothing a shell would interpreta line of /etc/boot2deb/board.conf, sourced at each kernel install
depthcharge.boardbare identifiera key in /etc/depthcharge-tools/config, written through a quoted heredoc
locale / timezone / keymapsee Locale, timezone, and keyboard/etc/locale.gen, the /etc/localtime target, shell-sourced /etc/default/keyboard
ntp_serversa bare host per entry: hostname or IP, no scheme, port, or whitespace — see The clock and time syncthe space-separated NTP= line of a timesyncd.conf.d drop-in
ssh_authorized_keysone line per entry: a known key type, a base64 blob whose own embedded type name agrees with it, an optional comment. Private key material and options prefixes are refused — see The account, sudo, and SSH keysa line of ~debian/.ssh/authorized_keys, written through a quoted heredoc

The rule these share is that a value is rejected, never repaired. A hostname with a space in it is not trimmed and an out-of-set source name is not folded to a legal neighbour: the mapping that would do the folding is not one-to-one, so two names the config states as different repositories could land on one file, and the one that lost would be missing from the finished image with its packages already installed. Failing at the point of authorship is the only outcome that cannot silently change what was asked for.

apt_sources.name doubles as the key that de-duplicates sources across features. Two features naming the same repository collapse to one entry; the same name with different settings is an error, since the solve could not tell which repo to activate.

A board’s slug is a host name. That is the one entry above whose rule comes from somewhere other than the file it is written into. hostname defaults to the slug, and nearly every board keeps that default, so the two are the same string in practice — which means a slug outside the host-name shape would make the default a value no image could carry. Holding the slug to the tighter rule is what makes the default correct by construction. A board that wants a different network name simply states hostname (rk3576-evb1-v10 comes up as rk3576-evb1); what it cannot do is inherit an invalid one. In practice the rule costs nothing: it rules out _, a leading or trailing -, a doubled or trailing ., and names over 64 characters, and the house style is already lowercase-and-hyphens.

The alternative — letting the slug be looser and repairing the derived hostname — is the same trap as the source names above, and one Debian falls into on our behalf if we let it: hostname(5) says systemd filters invalid characters out of /etc/hostname rather than refusing them. An unrepaired my_board would therefore boot as myboard while the /etc/hosts entry generated beside it still said my_board, so every lookup of the machine’s own name would miss. Rejecting the slug is what keeps those two files describing the same host.

A value whose wrongness is invisible is bounded, not advised

The table above is about shapes — values that must parse as something. One value is range-checked instead, for a different reason: first_boot_password_length is accepted only between 8 and 64.

The usual argument against a hard bound is that the author knows their own situation better than the config layer does. It does not hold here, because this is the one setting whose effect cannot be observed anywhere. A board booted with an 8-character credential looks exactly like one booted with a 20-character credential; nothing on the running system, in the image, or in a test reveals how much entropy the login had. Every other setting announces a bad value eventually — a wrong timezone shows a wrong clock, a malformed key fails a login — so advice is enough for them, and a bound is what covers this one. The account, sudo, and SSH keys explains where the two ends of the range come from.

Recipes and the lock

A recipe (recipes/<device>/<leaf>.toml) pins one buildable point: it names the device and, optionally, the kernel, suite, features, layout, and image size (each omitted axis falls back to the device default). Its lock (recipes/<device>/<leaf>.lock) holds the exact resolved pins: for every git source, the repo URL it was pinned from plus the ref and commit, blob content hashes, and the solved rootfs manifest digest.

Recipes group under their device’s folder, so a board’s whole matrix — every suite and variant — sits together; the reference you build is that path without the extension (turing-rk1/media-accel-forky), the leaf dropping the device prefix the folder already carries.

A lock records what the build depends on, and nothing else. Each table is present only when the build actually has that dependency: [kernel] when a kernel is compiled, [uboot] and [blobs] when a bootloader is, [patches] when a series is applied, [[userspace]]/[ffmpeg] when the media-accel stack is. Pinning a commit nothing consumes would record provenance for a dependency that does not exist — and would make update demand a checkout the build never reads. Taken to its limit, a board that installs Debian’s kernel and boots its own firmware has a lock with exactly one table:

[rootfs]
suite = "forky"
manifest = "forky.pkgs.lock"

That is the whole truth about what it depends on, and the package manifest beside it pins every one of those packages by name, version, and sha256.

The split between the two is what makes a build reproducible:

  • update is the only command that consults upstream. It resolves refs to commits, hashes blobs, and writes the lock.
  • build reads only the lock. It touches no network for its pins, so the same lock always produces the same inputs. Before building it checks the lock against a fresh resolution on every axis the lock records from config — the source repos, blob file names, kernel id, suite, patch series, extra debs — and refuses on drift, so a config edit after update (say a boot-method flip to a different u-boot repo) is a named error rather than a build against stale pins.

See the CLI reference for the commands that operate on these.

Re-pinning: constraints move, hand-pins stay

The config layers declare a constraintuboot_ref = "v2026.07" on the boot method, a [[userspace]] entry’s ref on the SoC — and the lock records the exact pin resolved from it. An update given no per-tree ref flag re-reads the constraint, so editing one and re-pinning carries every recipe that resolves through that layer with it:

# after bumping uboot_ref in boot-methods/rockchip-rkbin.toml
boot2deb update turing-rk1/forky
#   bumped   u-boot v2026.04 -> v2026.07 (config constraint)

Every pin moved this way is named in the output, so a propagated bump is visible where it happens rather than surfacing later as an unexplained ref change in a lock diff.

The exception is a lock pinned to a bare commit sha. No config layer authors one — a 40-hex ref only ever arrives through an explicit --<tree>-ref — so it is a deliberate hand-pin, and re-reading the constraint over it would discard that choice and float the tree back to a branch tip. Those pins stay put until a flag moves them, which is what lets a tree sit on a fixed commit while its constraint says master.

The kernel has no constraint to follow: a kernel definition declares a track, not a concrete ref, so an omitted --kernel-ref re-pins the previous lock’s ref. Only a first update, with no lock to inherit from, must supply one.

A recipe declares what it has been taken through

A recipe may carry a [support] claim — validated, expected, or experimental, plus the YYYY-MM-DD the claim was last established:

[support]
status = "validated"
date   = "2026-07-16"

The claim is per recipe, not per device, because it varies within a device: a board can have one build point booted and another — a different kernel, suite, or feature set — never built. It is optional, and absent means no claim made, which is the honest state for a recipe you authored against your own board. Every recipe boot2deb ships declares one.

This is the declared half of the project’s support story. The support matrix is the generated half: it reads the pins from each recipe’s lock and sets them beside the claim, so the table cannot describe a combination the build would not produce. The two are kept honest at the one moment they can be driven apart — update warns when it moves the pins out from under a validated claim, since moving them retires the evidence the claim rested on.

A status says how far, not how much

validated means an image built from this recipe booted. It does not mean everything on the board works, and a claim that leaves that unsaid overstates itself. What a build point does not do is a caveats list, on the layer that owns the limitation:

# socs/rk3576.toml — true of the part, so of every board on it
caveats = [
  "HDMI tops out at 4K30 and cannot reach 4K60: the dw-hdmi-qp bridge has no SCDC/scrambling support ...",
]

# devices/h96-max-m9.toml — true of this board
caveats = [
  "No port on the box delivers USB 3.0. The blue port beside HDMI is capped to high speed ...",
]

# features/media-accel-rockchip.toml — true wherever this capability is selected
caveats = [
  "Scaling inside a hardware transcode runs on the CPU. The RGA filters accept only frames carrying an RKMPP hardware context ...",
]

# recipes/asus-c201/libre-forky.toml — true of this build point alone
[support]
status  = "expected"
date    = "2026-08-04"
caveats = [
  "The internal BCM4354 Wi-Fi and Bluetooth do not work. linux-libre removes brcmfmac's firmware request ...",
]

Resolution concatenates the four in that order — silicon, board, features, build point — and de-duplicates them, so a board with three recipes states its limitations once and each recipe adds only what is its own. Each entry keeps the layer that stated it, which is what tells a reader whether another board would do better, or whether dropping a feature would.

A capability’s limits belong to the capability, not to whichever recipe named it first: every recipe composing the feature inherits them, and a limit stated once cannot fall out of step across recipes that all have it. Reserve a recipe’s own [support].caveats for what is true of that build point alone.

They are printed by resolve, at the end of a build, and in the support matrix, which groups the two hardware scopes under the board — they hold for every recipe on it — and the feature and recipe ones under each recipe, since those depend on what that recipe selected.

caveats accumulates down an extends chain rather than being replaced by it — one of the five arrays that do. A variant shares its parent’s hardware, so last-wins there would let a variant that adds one caveat silently drop every limitation it inherits. A variant cannot un-say one of its parent’s; a board that genuinely lacks the limitation is its own device rather than a variant.

A caveat is a sentence, not a code, so the only rule at load is that it is non-empty and carries no ragged whitespace. Where a limitation is mechanically checkable it belongs in the board’s selftest expectations instead, where it fails rather than merely informs; a caveat that could have been an [[expect]] entry is a check nobody runs. Caveats are for what cannot be asked of the running system. The [[expect]] array itself — which layers take one, the check kinds, and how the checks reach the image — is the on-image self-test.

A feature selection is a build point, not a new recipe

The feature axis is a list, so the number of legal selections grows exponentially in the number of features — and most of them are nobody’s curated point. “The shipped H96 image, plus hardware decode” is a perfectly reasonable thing to want and a poor reason to author a file.

So a build point is a recipe plus a feature selection, written as a reference:

h96-max-m9/forky                        the recipe as authored
h96-max-m9/forky+media-accel-v4l2       that recipe, with this feature selected
turing-rk1/forky+media-accel-rockchip+jellyfin

Everything but the features comes from the recipe, so a selection cannot drift from the board it names. The selection replaces the recipe’s own features list rather than adding to it, which is the same thing --feature has always meant for resolve. Both spellings work everywhere, and mean the same point:

boot2deb update h96-max-m9/forky --feature media-accel-v4l2
boot2deb build  h96-max-m9/forky+media-accel-v4l2

A variant is locked like anything else. update writes recipes/h96-max-m9/forky+media-accel-v4l2.lock beside the recipe’s own, with its own solved package manifest, and build compiles it in its own work directory under a distinct image identity — so two selections can coexist without one landing on the other’s artifacts. Every lock-reading command takes the reference, so why-rebuild, verify-patches, verify-sources, and clean all work on a variant unchanged. A variant’s first update inherits the recipe’s pins, so it starts from the same kernel, u-boot, and blob commits the recipe was pinned at.

Three things follow from a variant being a build point rather than a recipe:

  • It carries no support claim. The claim belongs to the recipe, and a different feature set is a different build. list-recipes and the support matrix show only authored recipes; a variant appears in neither.
  • Feature order is significant, so it is preserved. config_fragments and patch_series compose in selection order, so a later feature wins a kconfig conflict. Two orderings of one set are two references — sorting them into one name would give two materially different builds a single identity.
  • A selection with no lock is an error, not an implicit update. build reads locks; it never resolves one. The error names the update line to run.

Curate a recipe when a point is worth claiming — something you have booted, or intend to support. Use a variant for everything else.

Crates

The builder is a Rust workspace of three crates:

crates/core     typed model, layer resolution + validation, patch-series / lock /
                kconfig formats (pure, deterministic, unit-tested — no Linux host)
crates/engine   Linux side effects: git shell-outs, the lock resolver, the patch
                verify gate, kernel-config generation, the compile stages (kernel /
                u-boot / userspace / ffmpeg), the rootfs + image nodes, and the host
                preflight behind `doctor`
crates/cli      the boot2deb binary

core is pure and testable without a Linux host; all side effects (the filesystem, subprocesses, the network) live in engine.

What the base image contains

Every boot2deb image is the same substrate plus what its layers add. This page is the substrate: what is on a board before any SoC, device, or feature package stacks on top, and why each thing is there.

How the set is built

Three inputs, in order:

  1. Debian’s required + important priority set. This is the base system — a working Debian userland with apt, systemd, bash, less, nano, vim-tiny, procps, fdisk, iputils-ping, and tzdata.
  2. The packages list in base.toml, below.
  3. The layers: archessocsboot-methodsdevices, then any features the recipe names. Wi-Fi tooling, Mesa, bluez, and the media stack all arrive this way, because they are claims about hardware rather than about Debian.

Two properties of step 1 decide most of step 2, and they are worth stating plainly because they differ from what apt install does on a desktop:

  • Recommends are never installed. Resolution follows Depends only.
  • Priority standard is not part of the base set.

So anything an ordinary Debian install would have reached by either route is absent unless base.toml names it. ca-certificates is the case that shows why this matters: it is priority standard, and nothing Depends on it — libcurl merely Recommends it — so without an explicit entry an image would ship curl and wget that cannot fetch an https URL.

What base.toml adds

Sizes are installed size on forky/arm64, including each package’s own dependency closure where that closure is the interesting part. They are hand-totalled from the archives’ own Installed-Size figures, and boot2deb size is how to re-check one against a build that exists — --by source groups a package with the rest of its source’s output, which is the closure most of these parentheses are describing.

packageswhy
initramfs-tools, dbus, dhcpcd, libpam-systemd, systemd-timesyncd, sudoboot, session, clock, privilege. A board with no RTC gets its time from the network.
openssh-server, openssh-clientRemote access both ways. The server’s Depends already pull openssh-sftp-server, so scp/sftp to a board work without the client; the client is what provides scp, sftp, ssh-keygen, and ssh-copy-id from it. The server is enabled, so a booted board is reachable before anyone has logged in — see The account, sudo, and SSH keys.
ca-certificates, curl, wget, rsyncFetching and moving files, https included.
bind9-dnsutils (~6 MiB with bind9-libs)dig and host, for triaging a board whose network is the thing that is wrong.
unzip, zip, xz-utils, zstdArchives.
pciutils, usbutilsHardware inventory. lsusb earns its place ahead of lspci on these boards: an SBC’s peripherals are far more often on USB than on PCIe.
htop, lsof, psmisc, xxd, file (~10.6 MiB)Looking at a running system, and at bytes. Nearly all of file’s cost is libmagic-mgc, the compiled magic database.
bash-completion, man-db (~9.2 MiB)Shell usability. man-db pulls groff-base, bsdextrautils, and libpipeline1; it buys the ~3.9 MiB of manual pages every other package already puts on the image and that nothing else can read.
locales, keyboard-configuration, console-setup (~44 MiB)What makes a pre-built image reconfigurable with no network. See Locale, timezone, and keyboard.

isc-dhcp-client is excluded; dhcpcd is the DHCP client, and boards whose SoC layer brings NetworkManager exclude dhcpcd in turn.

What is deliberately absent

  • locales-all — 231 MiB installed. The 17 generated locales cost 19.2 MiB instead.
  • A desktop, a display manager, or a browser. Images are headless by default; a desktop is apt install away, and the image ships the locale data one needs to open on something other than English.
  • avahi-daemon / mDNS. It would open a listener on the LAN, which is not a default an appliance image should make for its owner.
  • unattended-upgrades. An image that silently changes itself is not one whose provenance record still describes it.
  • Storage and hardware tooling with no hardware to point atsmartmontools, nvme-cli, mtd-utils. Board-specific tools belong on the board’s layer, as i2c-tools and ir-keytable are.
  • Development toolchains. Nothing on an image builds software; that happens in the builder’s sandbox.

Adding to it yourself

There is no --package flag, for the same reason there is no build-time locale flag: an image’s contents come from the config its lock was resolved against, so a package that is on the image is a package the config named and the manifest can pin.

The question is which layer the claim belongs to — base.toml for anything true of every Debian image, the SoC or device layer for anything true of the hardware, and a feature for anything a recipe should be able to opt into. To add packages without editing the shipped tree at all, put your own layer in an overlay directory and pass --overlay; a same-named layer is deep-merged over the shipped one. See Config model and Overlays.

On a running board it is just Debian: sudo apt install tmux.

CLI

This page explains what the commands are for. For the exhaustive list of every flag on every command — generated from the binary, so it cannot drift — see Every flag, or run boot2deb <command> --help.

The binary is boot2deb, installed with cargo install --path crates/cli (see Getting started); working from a checkout without installing, prefix each command with cargo run -p boot2deb-cli --. It defaults --root ., so run it from inside boot2deb/ (or pass --root).

Five global flags apply to every command: --root <dir> (the config root), --overlay <dir> (an out-of-tree config overlay, repeatable — see Overlays), --json (machine-readable output), and --quiet/--verbose.

--root moves everything, not just where config is read from. A run’s durable state is anchored to the config root: the build scratch (<root>/build/<recipe>), the artifact, patches, extra-deb, and verify-tree caches (<root>/cache/...), and the default patches checkout (the root’s sibling ../patches). So --root boot2deb why-rebuild turing-rk1/forky from the parent directory inspects the same trees a run from inside boot2deb/ builds into. An explicit --work-dir or --patches-path is taken as given, relative to the current directory.

How much a build prints

A build’s event stream carries two very different volumes: what each stage decided (tens of lines) and what its subprocesses printed (tens of thousands). The default shows the first, so a tens-of-minutes kernel compile stays readable:

levelwhat you see
--quietartifact paths and errors only — what the command produced, nothing about getting there
(default)step boundaries, coarse progress, each stage’s own decisions, artifacts, errors
--verbosethe above plus every line make, git, and dpkg-buildpackage emit

Reach for --verbose when a stage fails or hangs: it is the level that shows what the failing subprocess actually said. When what it said is not enough, shell puts you inside the root it said it in.

At the default level a build ends with where its time went:

timing:
kernel     12m04s  built
uboot      3.0s    restored
userspace  1m00s   partly restored
rootfs     4m31s   built
image      1m12s   built
total      18m02s

The second column is what makes the first readable — a three-second kernel step is the artifact cache answering, not a fast compiler. restored means every one of that step’s outputs came back from the cache and nothing was compiled; partly restored is a step whose outputs cache one at a time (the userspace stage builds several .debs, each with its own signature) where some were restored and some were built.

total is the command’s own wall clock, so it exceeds the sum of the rows by whatever the build does outside any step. The summary is suppressed under --quiet and under --json, where the same numbers ride on each step_finished event.

A build that wrote an image closes with what to do with it:

next: write the image to /dev/sdX — confirm the device with `lsblk` first, since dd
      overwrites it whole
      xzcat build/turing-rk1/forky/artifacts/turing-rk1-forky.img.xz | sudo dd \
        of=/dev/sdX bs=4M status=progress conv=fsync

The paths are the files the run actually produced, so a --compress none build hints the raw .img and a split build hints both halves with the medium each goes to. The pipe matches the container: xzcat for a .xz and zcat for a .gz, and where a build asked for both (--compress xz,gz) the hint names the one asked for first. /dev/sdX is a placeholder in every case — a build cannot know which disk is meant, and a real device node in a copy-pasteable dd line is how the wrong disk gets overwritten. Boards with a flashing route of their own (the RK1’s tpi, a Chromebook’s recovery media) document it on their board page.

Machine-readable output

--json gives a machine form to the commands a script consumes:

command--json form
list-*one JSON array; an unreadable entry rides along as {"name", "error"}
resolvethe fully resolved build as one JSON document. Everything only an image has — the kernel, suite, rootfs set, localization, account, out-of-tree modules, media-accel sources — is nested under image, which is absent on a deliverable = "uboot" recipe
doctorhost facts, every check with its status and remedy, the trust anchors, and a result
verify-patchesper axis: how many patches applied, and every one that did not
verify-configthe merge or parity verdict, with each differing CONFIG_*
verify-sourcesper pin: its durability class and the detail behind it
verify-packagesthe present / provided / missing split, plus the names this build produces itself
verify-imageevery checked image invariant with its detail and verdict, plus a result
diffevery section’s comparison as one document, with the per-series patch-file deltas under patch_files
outdatedper recipe, every pin with its verdict flattened in — status plus the newer release or moved tip behind it
sizethe whole rollup: every row with its weight and package count, plus the totals. Untruncated, whatever --top says
buildNDJSON — one object per line, tagged by its event field (step_started, progress, log, artifact, step_finished, error), with every produced artifact’s path on an artifact event and each step’s duration_ms + outcome (built/restored/mixed) on its step_finished

Errors are still plain text on stderr, and the exit code is the result either way. --quiet/--verbose do not apply under --json: the stream is the record of the build, and a filtered record would be a wrong one.

A command with no machine form — update, clean, why-rebuild, new-device, support-matrix, patch import, sbomrejects --json rather than ignoring it, naming the structured route to the same information where one exists. A global flag that silently did nothing would be a trap for exactly the scripted caller it exists for.

The two commands that split reproducibility from upstream are update (the only one that consults the network) and build (reads only the lock). See Config model for that split.

Inspection

boot2deb list-devices
boot2deb list-recipes
boot2deb list-kernels
boot2deb list-features
boot2deb list-kmods
boot2deb support-matrix
boot2deb resolve turing-rk1/forky
boot2deb resolve turing-rk1 --suite trixie --layout split
boot2deb doctor turing-rk1/forky
  • list-devices / list-recipes enumerate the buildable targets; list-recipes shows each recipe’s support claim and flags any recipe with no committed lock as not-yet-buildable (run update).

  • support-matrix prints that claim beside the exact pins the recipe’s lock records — board, suite, kernel, patch series — so “which patch series worked with which kernel, on what board” is answerable without decoding a SHA. --markdown emits the docs page verbatim; regenerate it after changing a claim or re-pinning a lock, and a test fails if the committed page is stale.

  • list-kernels / list-features enumerate the valid values for the --kernel and --feature overrides — name, version/compatibility, and (for kernels) the patch series — so the override knobs are discoverable without reading the TOML tree.

  • list-kmods enumerates the out-of-tree kernel-module sets a device’s device_kmods may name, with the driver ref each tracks and the modules it ships. Unlike the two above this is not an override: it is what a new board consults to find out whether the driver for its Wi-Fi part is already declared, before writing a second declaration of it.

  • resolve prints the fully merged build point without building, and runs the same local preflight_config coherence check the build does (geometry, fragment-file existence, feature compatibility, apt keyrings). Every selectable axis (--kernel, --suite, --feature, --layout, --boot-method, --board, --image-size, --locale, --locale-gen, --timezone, --keymap) can be overridden, so you can see what a choice resolves to before committing it to config.

    It accepts a wider set than any command that can build the result, and says so when that matters: an override build does not take closes the printout with the recipe file to write, ready to paste.

    note: --suite is resolve-only — `build` reads that axis from the config its lock was
    resolved against, not from a flag. To build this point, write it down:
    recipes/turing-rk1/<leaf>.toml with
        device = "turing-rk1"
        suite  = "trixie"
    then `boot2deb update turing-rk1/<leaf>` to pin it.
    

    --boot-method is the one axis a recipe cannot express — how a board boots is a property of the hardware — so its note names a device file instead. See Adapting a shipped recipe.

  • doctor reports the host’s tool-presence preflight and, for anything missing, the exact per-distro install command. With a target it asks only for what that build will invoke: a board that installs Debian’s kernel and boots its own firmware compiles nothing, so it is not told to install a cross compiler — which keeps a genuinely missing tool from getting lost among requirements that do not apply. Bare, it runs the requirements every board shares (user namespaces, the .deb packaging tools, the vendored apt trust anchors), so it is useful before a recipe is chosen. Either way a missing required tool is a non-zero exit, so it gates CI. See Getting started.

  • cli-reference prints Every flag — the whole argument surface, generated from the command tree. --markdown regenerates the committed page, and a test fails when it goes stale.

  • completions <shell> and man print a shell completion script and the boot2deb(1) man page on stdout, generated from the same command tree. They install nothing: where those files belong is the packager’s call.

    boot2deb completions bash > ~/.local/share/bash-completion/completions/boot2deb
    boot2deb man > ~/.local/share/man/man1/boot2deb.1
    

Scaffolding

# Interactive on a terminal: menus over the valid SoC / boot-method / kernel / feature
# choices, then writes devices/<name>.toml + recipes/<name>/<suite>.toml.
boot2deb new-device my-board

# Scriptable: take every value from flags (required: --soc), no prompts.
boot2deb new-device my-board --soc rk3588 \
  --feature media-accel-rockchip --non-interactive

# Scaffold into your own overlay tree instead of the shipped root:
boot2deb --overlay ~/my-boards new-device my-board --soc rk3588

new-device generates a device (and, unless --no-recipe, a matching recipe) from the typed model. It offers only valid choices — the closed Soc/BootMethod/Layout enums, the kernels whose supported_socs include the chosen SoC, and the features compatible with the SoC/arch — fills every derivable value, and leaves the four values it cannot validate (uboot_defconfig, kernel_dtb, and the [rkbin] atf/tpl blobs) as best-effort suggestions marked # TODO:. It writes into the highest-precedence --overlay when one is given (the third-party path), else the primary root, then resolve-checks the result and prints exactly which values you still have to research. It refuses to overwrite an existing file without --force.

The generated files resolve immediately (proving the layer composition); the # TODO: values are the ones that fail late — at the u-boot or kernel build — if left wrong, so verify them before update/build. See Adding a board.

update

boot2deb update turing-rk1/forky --kernel-ref v7.1.1

Resolves upstream refs to commits and hashes the vendored blobs, writing recipes/<device>/<leaf>.lock. This is the only command that consults upstream; build reads only the lock, so a build is reproducible from its committed pins.

  • --feature <name>, repeatable, pins a feature selection as a variant of the recipe — everything but the features comes from the recipe, and the lock lands beside it as <leaf>+<feature>...lock. A variant’s first update inherits the recipe’s pins, so it needs no --kernel-ref.
boot2deb update turing-rk1/forky --feature media-accel-rockchip --feature jellyfin
# wrote recipes/turing-rk1/forky+media-accel-rockchip+jellyfin.lock

build

boot2deb build turing-rk1/forky

Builds the recipe from its lock: compiles the kernel, u-boot, userspace, and ffmpeg, bootstraps the rootfs, and writes the bootable disk image. Notable flags:

  • --feature <name>, repeatable, selects which lock to build — the one update --feature pinned. It does not re-resolve one, so a selection that was never pinned is an error naming the update line to run. Naming the reference directly is equivalent:

    boot2deb build turing-rk1/forky+media-accel-rockchip+jellyfin
    

    A variant builds in its own work directory under its own image identity, so it never lands on the recipe’s artifacts.

  • --stage <node> runs a single node — kernel, dtb, kmod, uboot, userspace, ffmpeg, rootfs, or image; the default builds everything. kmod builds the board’s out-of-tree module .debs (its device_kmods) against an existing kernel tree, so a driver bump need not rebuild the kernel. A --stage uboot run also emits a standalone, directly-flashable <point>-boot.img (see below). Asking for a node this recipe does not have--stage kernel on a board that installs Debian’s kernel — is an error naming why, not a silent no-op.

  • --layout combined|split overrides the image packaging. combined is one whole-disk image; split emits a bootloader-only image and a separate rootfs image for a two-medium install. This is lock-independent — it changes only how the image is packaged, not any pinned source. Only a boot method that has a bootloader can split it off.

  • --image-size <size> overrides the image size the same way. The rootfs grows to fill its medium on first boot, so this bounds the artifact, not the installed system. It also takes the measured form — --image-size fit+20% builds the smallest image that holds the rootfs with a fifth of it free, which is the quickest way to find out how large a new board’s image actually needs to be. See An image size can be stated or measured.

  • --refresh-rootfs forces a clean rootfs bootstrap instead of restoring the content cache; --no-artifact-cache forces every compile node to rebuild instead of restoring stored .debs (see Two caches).

  • --kernel-src, --uboot-src, --ffmpeg-base-src, --userspace-src, --kmod-src redirect where a tree is cloned from, without changing what is built: the commit still comes from the lock, so a local checkout holding it makes the fetch near-instant and produces the same result. A SoC declares several userspace trees and a board several out-of-tree modules, so those two name the one they apply to — --userspace-src mpp=../mpp-rockchip, --kmod-src aic8800=../aic8800, both repeatable. A name the recipe does not build is an error rather than a silently ignored flag.

build takes no --kernel, --suite, --board, --locale, --timezone, or --keymap. Those axes come from the config the recipe’s lock was resolved against, not from a flag: resolve accepts them so you can see what a choice resolves to, and then says so — naming the recipe file to write if you want to build it. See Adapting a shipped recipe for that path, and Locale, timezone, and keyboard for the localization axes in particular.

Two caches, and what each one keys on

Nothing about a rebuild is obvious from the outside, so it is worth knowing which of the two caches answers which question. why-rebuild reports both, per node.

The rootfs cache keys on the solved package set. A rebuild whose solve is unchanged restores the bootstrapped tree instead of re-running the multi-minute bootstrap. Because the key is the solved set and not the requested one, a moved mirror resolves new versions and rebuilds automatically — a hit is never stale. The unique per-image first-boot password is applied on restore rather than cached, so every image still gets its own credential. The rest of the account policy — the sudo drop-in and the authorized keys — is part of the tree and part of the key, so authorizing a key or tightening sudo rebuilds rather than restoring a tree with the old rules. --refresh-rootfs forces a clean bootstrap.

The artifact cache keys on each compile node’s full set of output-determining inputs — the source pins and patch series, the kconfig fragments’ contents, the defconfig, the identity of the root the stage compiled in, and the build-dependencies it layered over that root. On a hit, build restores that node’s stored .debs and skips the compile entirely: the single largest lever there is, since it is the difference between restoring a file and a 30-minute kernel cross-compile or a 70-minute emulated ffmpeg build.

It lives at <root>/cache/artifacts, outside any recipe’s work dir — so it survives clean, and is shared across work dirs and recipes. A freshly cloned checkout with no build tree at all can still restore every .deb and compile nothing. clean --artifacts empties it (for every recipe, since the store is shared); --no-artifact-cache on a build ignores it and stores nothing.

Because the key covers every input that can change the output, a hit is sound: two builds that would produce different .debs cannot share an entry.

Rebuilding only the board DTB

build <recipe> --stage dtb compiles just the board’s device tree in the already-cloned, already-patched kernel tree and stages the .dtb — seconds rather than a full kernel build. It is the bring-up loop for a board carrying its own device_dts source: edit the .dts, rebuild the DTB, reflash. The result is byte-identical to the DTB a full --stage kernel ships inside the linux-image deb.

Standalone bootloader image

build <recipe> --stage uboot writes <point>-boot.img next to the raw <point>-idbloader.img and <point>-u-boot.itb, where <point> is the build point with its / flattened (turing-rk1/forkyturing-rk1-forky): a small, GPT-less image holding just the bootloader at its offsets. It needs no rootfs, so you can produce a flashable eMMC/SPI bootloader image without building a whole OS. The split layout emits the same image as part of a full build. See Turing RK1 for the eMMC-plus-NVMe workflow this serves.

try

# Boot the built image twice under QEMU and assert the userland works —
# multi-user with no failed units, the generated password logs in, first-boot
# completes and does not re-run, and the on-image selftest passes.
boot2deb try turing-rk1/forky

The step between build and press: it catches the image that flashes fine and is quietly broken — a userland fault, a brick-on-second-boot — while the fix is still a rebuild rather than a reflash-and-serial-console session. The board kernel is not booted (the guest runs the suite’s generic kernel as a fixture) and no board hardware exists under -M virt, so this tests the userland and only the userland. Trying an image before flashing has the full contract, including what the two boots each assert and the fixture kernel’s mechanics.

press

# Produce the distributable image file, verified.
boot2deb press turing-rk1/forky card.img

# The same, personalized per unit and previewed first.
boot2deb press turing-rk1/forky rk1-03.img --hostname rk1-03 \
    --ssh-key "$(cat ~/.ssh/id_ed25519.pub)" --dry-run

# Per-site additions re-assemble the image from the kept artifacts.
boot2deb press asus-c201/forky card.img --embed-image \
    --copy site.conf:/etc/myapp/site.conf

What a press produces is derived from the resolved build, not from flags: a combined build is one file, a u-boot deliverable is its boot image, and a split build refuses a single positional output and names --boot-out + --rootfs-out. A plain press streams the existing artifact and verifies the file it wrote (digest re-read + partition-table compare); a press with --copy/--deb/--embed-image re-assembles the image from the kept rootfs tar. boot2deb does not write devices — hand the file to dd or a real flasher. Producing images is the full story, including the seed keys and the pressed-image provenance marker.

seed

# Re-personalize an already-pressed image file without re-pressing it.
boot2deb seed rk1-03.img --hostname rk1-04 --wifi-ssid lab --wifi-psk '...'

No recipe: the seed partition is found by its GPT label, so the file is the whole input. With no keys the seed resets to the empty template. Files only — a card that is already written is re-personalized by editing seed.txt on its B2D-SEED volume directly.

shell

# A shell in the root the kernel compiles in.
boot2deb shell turing-rk1/forky --stage kernel

# Or one command in it, non-interactively.
boot2deb shell turing-rk1/forky --stage kernel -- make olddefconfig

When a compile fails, --verbose shows you what it printed. shell is the other way in: it stands the stage’s root up and hands you a prompt inside it, with the same base tree, the same layered build-dependencies, the same mounts, the same environment and the same identity map the compile had. You start in the stage’s own tree — make re-runs verbatim, ARCH and CROSS_COMPILE are already set for the kbuild stages, and you are root, as every command in these roots is.

--stage names the root, and is required — the point is entering a particular one:

--stagethe rootlayered with
kernelthe host-arch cross rootthe kernel stage’s build-deps
ubootthe same cross rootthe u-boot stage’s build-deps
kmodthe same cross rootthe kernel’s, which is what an out-of-tree module build needs
userspacethe target-arch build sandboxthe userspace stage’s shared build-deps, plus each named tree’s own (--userspace <name>)
ffmpegthe same target-arch sandboxthe suite’s codec libraries plus this build’s own librga/MPP .debs, so run --stage userspace first
packagingthe host-arch packaging rootnothing — it is never layered

The work dir is bound at its host path, so every stage’s tree, scratch and output is there and edits you make inside are on the host when you leave — as are the config root’s kernel fragments and board device trees, which the kernel stage binds the same way. Everything else you write goes into the session’s own overlay and is gone when you exit. The root has no network, exactly as a compile does not: everything a build root needs is resolved before it is entered.

Two things to know about what you are entering. The layer is re-staged, not reattached: a build root is discarded when its stage ends, so what you get is the root that stage’s declaration produces and not the failed run’s writable layer — what the compile wrote into the work dir is still there, what it wrote into /usr is not. And the session’s layer is staged under its own name, so opening a shell while a build of the same recipe is running does not disturb it.

The session’s exit status is boot2deb’s own, so shell <recipe> --stage kernel -- make foo in a script reports what make reported. It needs a terminal: shell relays yours to a pseudoterminal inside the sandbox, and refuses rather than starting a session with nothing on one end. tty, who, and GPG_TTY have no answer inside — the terminal is allocated on the host, so it has no device node in the sandbox — while everything else a terminal does, including full-screen programs, job control, and running tmux, works.

If the root has never been provisioned in this work dir, the first shell bootstraps it, which is the same minutes a first build would spend. Later ones reuse the tree.

reproduce

boot2deb reproduce turing-rk1/forky --from ./published

Rebuilds an image from the plan document a previous build published, rather than resolving the archive afresh. It takes every build flag and runs the same pipeline; what differs is the rootfs, which installs the plan’s exact package set by the digests the plan records — reading neither a Release nor a package index.

The lock pins sources, patches, and the builder. It cannot pin which package versions the archive served, so the same lock a month later resolves a different userland. The plan pins exactly that, and it is written beside every image as <point>.plan:

turing-rk1-forky.img.xz
turing-rk1-forky.provenance.toml
turing-rk1-forky.plan          <- the document reproduce replays
turing-rk1-forky.pkgs.lock

It is deb822 — the archive’s own control format — so it reviews as a diff. Each stanza names a package’s version, architecture, sha256, pool path, and which archive it came from; a leading stanza per archive records the mirror that answered, the suite and components, the sha256 of the release body that was verified, its Date and Valid-Until, and the fingerprint of the key that verified it.

--from names the directory holding that document; it defaults to this build point’s own output directory, so re-running a build to check that it is reproducible needs no flag. The provenance manifest beside it is read for one advisory line — which boot2deb produced the image, and how the running checkout compares. That is advice and never a gate: a stamped commit is the commit at which the build worked, never the commit past which it breaks.

This moves the trust anchor, deliberately. An ordinary build’s package digests come from an index whose own digest a signed release vouched for, so they chain to the archive signature. A replay never reads that index, so the digests chain to the plan document instead. Each .deb is still verified against the digest the plan records — a mirror serving different bytes is caught — but nothing re-checks that the plan describes a set the archive ever offered. That trade is right for reproducing a published image and wrong for a routine build, which is why it is reachable only through this command; build has no flag for it.

A recipe that compiles its own packages replays only if those compiles are byte-reproducible. The plan pins the sha256 of the kernel .deb — and, on a media-accel recipe, of ffmpeg-rk, librockchip-mpp1 and librga2 — that the original build produced, because those install from the build’s own local pool like any other package. Replay them and either the digests match, which proves the whole image reproduced, or the install fails naming the package that drifted. The second outcome is the honest one: it says this recipe is not yet reproducible, rather than quietly producing a different image. A recipe that installs Debian’s own kernel and compiles nothing has no such dependency.

Pair it with a snapshot pin for the strongest form. The plan says which versions; the lock’s snapshot.debian.org timestamp keeps those versions fetchable after they rotate off the live mirror. See Reproducibility.

Verification

Four read-only commands catch config mistakes before any compile — each exits non-zero on failure, so they gate CI as well as an interactive bring-up. They share the reproducibility split: every one reads the recipe’s lock for its pins, and any that needs a source tree auto-fetches it at the locked commit into a durable cache, so all four work on a fresh clone with no hand-cloned trees.

Which verify when

What changed / what you want to be sure ofCommand
Imported or edited a patch — does the series still apply to the pinned kernel and u-boot (and ffmpeg/userspace)?verify-patches
Edited a .config fragment or the base defconfig — does the kernel .config still generate cleanly (and match a reference)?verify-config
A lock is old — are its pinned commits still fetchable upstream, or has a branch moved out from under them?verify-sources
Added a package to a layer, a feature, or a recipe — does the suite you build against actually carry it?verify-packages
A build finished — is the image it produced internally consistent, before you flash it?verify-image

The first verify-patches or verify-config on a cold cache clones the kernel, and linux-stable is large. If you already have a local checkout, point --kernel-src at it (a git URL or path holding the locked commit) to make the fetch near-instant; --ffmpeg-base-src and --userspace-src do the same for the other trees. verify-sources never clones — it only queries the remotes.

verify-packages clones nothing either. It runs the read half of a package resolve — the archive’s Release and its package indexes, and then stops — against the same archives a build would use: the mirror the lock’s snapshot pins (or the live one), plus every repository the selected features contribute. One pass answers every name at once, which is why it is cheap enough to run per board over every recipe.

It is worth having as its own command because the resolver cannot answer it. A recipe naming a package the suite does not carry fails at resolve time — deep in a build, after every compile node has already run — and fails badly: a top-level include naming nothing makes the whole set unsatisfiable, so the error says the set could not be resolved and never which names were the problem.

Two kinds of name are reported rather than failed. A package the build produces — anything a requires_media_accel feature contributes, which comes from the SoC’s source trees through the build’s own local pool — is set aside, since the archives are rightly silent about it. And a name something else Provides is listed with its providers, because apt then has a choice the recipe did not make.

Once every name is accounted for, it asks the second question: does the set close? A package being in the archive says nothing about its dependencies being there, and the difference matters more than it sounds. A package whose dependency is absent still installs — dpkg configures with --force-depends — so the build succeeds, the image flashes, and what breaks is apt on the running board, for every package rather than the one at fault. Resolving the closure here is what turns that into a line of output before anything compiles:

UNSATISFIED: jellyfin-server requires libicu76, and no configured archive offers it
             and no base layer supplies it

Every refusal is reported, not just the first, because the list is what a user has to correct. The closure runs only when the name check passed: a name the archive does not carry refuses its own dependency group as well, and reporting that twice would bury whatever else was found.

The same blind spot applies here as above, and the check accounts for it rather than crying wolf. A dependency satisfied by a package this build produces, or by one of the recipe’s pre-built [[extra_debs]], cannot be seen by a resolve — neither is in an archive, and the local pool does not exist until a build runs. Such a refusal is reported as a note and does not fail the recipe:

local : jellyfin-server requires libicu76 — supplied by this build, not by an archive

An extra_debs name comes from its filename (<package>_<version>_<arch>.deb), because reading it out of the file would mean downloading and unpacking every pin — which is the one thing this command promises not to do. A filename that does not follow the convention explains nothing, and the refusal it would have covered is reported: the safe direction for a heuristic. Only the name is matched, never a version constraint — a local .deb is pinned by digest and this cannot know what version is inside it, so the constraint stays the build’s problem.

A resolution stopped that way has no closure size, and the output says that instead of printing zero. Under --json, closure.installed is null in that case, refusals carries what the recipe must correct, and supplied_locally carries what the check could not see.

The free prerequisite: does the series even claim this version?

Before any of those, there is a question that needs no source tree at all — whether each composed series’ declared envelope admits the version being pinned. It is pure metadata, so update and build both ask it for free:

  • update says so at pin time and keeps going, because pinning the new version is the first step of adopting it. Bumping onto a kernel the series predates is exactly the routine move that hits this.
  • build refuses, before cloning anything. The compile nodes ask the same question, but only once the tree is on disk — a minute of network for an answer that was already in the manifests.

Each axis is asked about its own version: applies_to_kernel against the pinned kernel tag, applies_to_uboot against the pinned u-boot tag. A u-boot series makes no claim about a kernel, so the two never gate each other.

On the kernel axis both name the verify-patches --kernel line to run next. That ordering is the point: the cheap check tells you a series makes no claim about your kernel, and the expensive one tells you whether it would have worked anyway.

note: kernel v7.2-rc5 is outside series 'rk3588-accel' (declared >=7.0, <7.2) — a build
      will refuse it. Measure it first, which needs no re-pin:
  boot2deb verify-patches turing-rk1/forky --kernel v7.2-rc5 --kernel-path <checkout> --keep-going
then widen applies_to_kernel in the series if it comes back clean, or retire the
patches it names.

u-boot has no --kernel equivalent — there is no “verify against a u-boot the lock does not pin” mode — so its advisory points straight at the claim:

note: u-boot v2027.04 is outside series 'rk3576-display' (declared >=2026.01, <2027.01) —
      a build will refuse it. Verify it first, which needs no re-pin:
  boot2deb verify-patches h96-max-m9/forky --keep-going
then widen applies_to_uboot in the series if it comes back clean, or retire the patches
it names.

u-boot’s vYYYY.MM tags are zero-padded, and both sides of a range accept that spelling: applies_to_uboot = ">=2026.01, <2027.01" matches the tag v2026.04 as written.

verify-patches

# Dry-run every locked patch series against its source tree with `git am --3way`,
# hard-erroring on the first patch that does not apply. Omit the checkouts and each
# tree is auto-fetched at its pin.
boot2deb verify-patches turing-rk1/forky

# Both patch axes are covered. A u-boot-only recipe verifies its u-boot series...
boot2deb verify-patches rk3576-generic/loader

# ...and a recipe carrying both reports each at its own version:
#   kernel series applies (4 patches) against rk3576-mainline-7.2 @ v7.2
#   uboot  series applies (6 patches) against u-boot @ v2026.04
boot2deb verify-patches rk3576-evb1-v10/forky

# Fast path when you already have a local kernel checkout:
boot2deb verify-patches turing-rk1/forky --kernel-src ../linux

# "Would this series survive 7.2?" -- asked against a kernel you have not adopted,
# reporting every boundary at once rather than stopping at the first.
boot2deb verify-patches turing-rk1/forky \
    --kernel v7.2 --kernel-path ../linux --keep-going

Asking about a kernel you have not adopted

--kernel <version> verifies against a kernel the lock does not pin, and leaves the lock alone. That ordering matters: without it, finding out whether a series survives a new kernel means re-pinning to that kernel first — mutating state before knowing whether the answer is yes, which is backwards for the one command whose job is finding out.

Because the lock pins no commit for a kernel it does not name, a candidate needs --kernel-path pointing at a checkout already at that version. Three rules shift on this path:

  • The declared envelope does not gate the run. A series is asked about 7.2 exactly while its applies_to_kernel still says <7.2, so refusing an out-of-envelope candidate would answer the question by assuming it — the only way past would be to widen the claim first, which is the very thing being tested. So the run reports that the kernel is outside the envelope and measures it anyway, and what git am does is the answer. A clean result is the evidence for widening the envelope, not a claim that it already covers that kernel. On the locked path an out-of-envelope kernel stays a hard error: there the series really would be applied to a kernel it makes no claim about. Per-entry kernels ranges still narrow the series, so a patch already marked obsolete at the candidate drops out rather than counting as a failure.
  • A release candidate is answerable. By semver’s rule 7.2.0-rc3 satisfies neither <7.2 nor >=7.2, so a release-only range rejects every RC. That strictness is right for a build — a series’ envelope is a claim about released kernels — but wrong here, where an RC is exactly the tree you want to measure. On the candidate path an RC is matched as its base release; the build path stays release-strict.
  • --keep-going reports every failure in one pass. A single boundary frequently spawns adjacent ones: reworking a patch shifts the context every later patch applies against. Stopping at the first turns that into serial discovery — fix, re-run, find the next, re-run. Each failing patch is skipped so the rest still get measured, which means a batch report shows the shape of the damage rather than a final verdict; a rework can still change what comes after it.

--kernel-path / --uboot-path / --ffmpeg-path / --userspace-path are all optional: an omitted tree is auto-fetched at its locked commit (ffmpeg and userspace only when the series carries patches for that scope). The --kernel-src / --uboot-src / --ffmpeg-base-src / --userspace-src flags (the first three the same names and meaning as build’s; the last names the patched tree’s source) override the fetch source — a git URL or local path used in place of the configured upstream — while the tree still lands at exactly the locked commit; they are consulted only on the first materialization and ignored when the matching --*-path is given. The patches checkout is resolved the way build does: an explicit --patches-path, else ../patches if present, else an auto-fetch at the pinned commit from the repo the lock’s pin names.

--kernel is kernel-axis only. A recipe that pins no kernel patch series rejects it rather than quietly verifying its u-boot series and reporting a green that answers nothing.

verify-config

# Generate the kernel .config (base defconfig + fragments, via merge_config.sh) on the
# patched kernel tree and report the merge. Omit --kernel-path and the tree is fetched
# and the kernel patch series applied for you.
boot2deb verify-config turing-rk1/forky

# Assert byte-identical CONFIG_* parity against a reference config as well:
boot2deb verify-config turing-rk1/forky --reference-config /path/to/.config

--kernel-path is optional; omitted, the kernel is auto-fetched at its pin and the kernel patch series applied before the config run. --kernel-src supplies a local fetch source the same way as verify-patches. With --reference-config, the run additionally fails on any CONFIG_* difference from the reference.

verify-image

# Hold a finished image to the invariants that are checkable without a board.
boot2deb verify-image turing-rk1/forky
boot2deb verify-image turing-rk1/forky --out-dir /path/to/artifacts

The off-board half of the hardware gate, and the last thing worth running before a flash. Per image it checks that the artifact set is present, that the plan document parses and its digest matches what the provenance manifest records, that [[archives]] is well formed (the mirror plus the build’s own pool, the pool marked local and carrying no mirror URL, since a per-run path is not portable provenance), that the ext4 filesystem is exactly its GPT partition, and — for a fitted --image-size — that the slack the recipe asked for actually survived into the shipped filesystem.

The filesystem/partition check is the one that matters most: larger and it will not mount at all, smaller and the difference is wasted. It is checked on every image, not only the fitted one, because it is the invariant the fit ordering exists to preserve.

Every structure is read by the code that wrote it — the same Rust GPT and ext4 readers the image node uses — so the check cannot drift from the build by parsing the same bytes differently. Read-only and no root: only the head of the artifact is decompressed, so a compressed multi-gigabyte image costs a few hundred kilobytes. A failing invariant exits non-zero, and --json gives the whole run as one document.

verify-sources

# Survey the durability of every source pin in the lock: for each, probe its configured
# upstream and report whether the commit is a durable tag, an ephemeral branch, or
# ORPHANED (no longer re-fetchable). Read-only: `git ls-remote` plus a bounded ancestry
# check -- no build, no checkout, no hardware.
boot2deb verify-sources turing-rk1/forky

verify-sources answers “will this lock still build a year from now?” An orphaned pin (a branch force-pushed, a tag deleted upstream) exits non-zero, so a periodic run catches a lock rotting before a build needs it. Capture a snapshot (build --save-snapshot) to make the rootfs solve durable the same way.

It reads the same ref advertisement as outdated and answers the other half of the question: this one is about whether a pin can still be fetched, that one about whether something newer exists. Neither implies the other.

patch import

patch import fetches a patch, normalizes it to canonical git am-ready mbox, and slots it into a series — the first step of the patch-authoring loop. It is documented with its full workflow (commit, re-pin, verify) on Adding a patch:

boot2deb patch import https://patchwork.kernel.org/project/linux-rockchip/patch/NNNN/mbox/ \
  --series rk3588-accel --scope kernel

Comparing two build points

# Two recipes.
boot2deb diff turing-rk1/forky turing-rk1/media-accel-forky

# One recipe against an older copy of its own lock — git supplies the older one.
git show HEAD~5:recipes/turing-rk1/media-accel-forky.lock > /tmp/old.lock
boot2deb diff /tmp/old.lock turing-rk1/media-accel-forky

# Two shipped images, from the provenance manifests beside them.
boot2deb diff a/turing-rk1-forky.provenance.toml b/turing-rk1-forky.provenance.toml

Each side is a recipe name, a path to a .lock, or a path to a .provenance.toml, and the two sides need not be the same kind. Everything it reads is a document the build already wrote, so it runs offline and builds nothing.

Six sections, in the order they answer the question:

sectionwhat it compares
packagesthe solved manifest: added, removed, re-versioned, and rebuilt (same version, different .deb)
kernelthe pin — id, flavor, clone URL, ref, commit — and the requested kconfig, symbol by symbol
patchesseries membership, each axis’s ref and commit, and the patch files behind a moved commit
sourcesevery other pinned tree: u-boot, MPP, RGA, Mali, ffmpeg, each out-of-tree module
blobsthe rkbin pins, by sha256
builderwhich boot2deb ran, the host it cross-compiled from, and the archive state the rootfs resolved against

Narrow it with --section (repeatable); --json gives the whole report as one document, with the patch-file deltas under patch_files.

Unavailable is not unchanged. A section neither side records says so rather than reporting agreement:

builder: not compared — neither side records a provenance manifest, which is where
the builder and archive state are recorded

Which side is silent is named when only one is, so you know whether to go find the other document or accept that it does not exist.

Two sections answer more when you name a recipe than when you name a document. The kconfig delta is one: a fragment set is resolved from the config tree, and no document a build writes names it — so diff reads the fragments a recipe’s kernel merges and reports each differing symbol with the fragment that set it, which diffing two generated .config files cannot do. A distro-package kernel merges no fragments at all, and that section reports itself unavailable rather than claiming every symbol the other side enables is new.

The patch-file delta is the other reach outside those documents: it resolves a moved patches-repo commit into named files by reading the patches repo.

patches:
  kernel:
    commit  adfdc19d7caf -> 659033b7e543
    rk3588-accel:
      +  rocket/088-rocket-drv-reset-before-iommu-detach.patch
      ~  media-accel/kernel/060-vepu580-rcawston-v3.patch

+ added, - removed, ~ rewritten under an unchanged name — the last being the case a membership comparison calls identical. It needs a patches checkout carrying both commits (--patches-path, else the config root’s sibling ../patches); without one it reports “the commit moved, and here is why the files could not be listed” rather than failing the rest of the comparison.

That section is what turns deciding whether a validated support claim survives a kernel or patches bump from hand work into reading a list.

Bill of materials

# From a recipe's own published build.
boot2deb sbom turing-rk1/forky --format spdx --out turing-rk1-forky.spdx.json

# From an image someone handed you, by the provenance manifest that shipped with it.
boot2deb sbom ./turing-rk1-forky.provenance.toml --format cyclonedx

# Or write it as part of the build. Off by default; repeatable for both formats.
boot2deb build turing-rk1/forky --sbom spdx --sbom cyclonedx

SPDX 2.3 and CycloneDX 1.6, both JSON, both from one internal model, so the two documents state the same facts. What is in them:

componenthow it is identified
every installed packagename, exact version, sha256, and a pkg:deb/debian/... purl — carrying &upstream=<source> where the source package is named separately
every pinned source treethe ref and the exact commit — kernel, u-boot, the patch series, and the media-accel trees
every rkbin blobits sha256, which is the only identity it has
every externally-fetched .debits URL and sha256

The upstream qualifier is what ties the several binary packages of one source back to the thing that was built — libsystemd0, libsystemd-shared and systemd are one source package, and nothing else in the document says so. It comes from the published plan beside the image, and it is emitted only where the source name differs from the binary package’s own, because that absence is how the ecosystem spells “the source carries this name”. An image handed over without its plan simply carries no attribution: the SBOM is complete without it, and a warning says what is missing rather than the command failing.

The one distinction worth reading is between what the image contains and what it was generated from. A kernel source tree is compiled into the image, not installed in it; SPDX says so with CONTAINS and GENERATED_FROM, and CycloneDX — which has one relationship kind — carries it in the component type and description instead.

Licenses are NOASSERTION, deliberately. boot2deb records no per-package license, and synthesizing one by reading /usr/share/doc/*/copyright out of the rootfs would produce a field that looks authoritative and is not. An honest absence is worth more to a compliance scan than a wrong SPDX identifier.

The document is reproducible. Its identity — the SPDX documentNamespace and the CycloneDX serialNumber — is derived from the solved manifest’s digest, so two SBOMs of one package set are byte-identical rather than differing in a random UUID. The only field the image’s own content does not determine is the creation timestamp, which both formats require; set SOURCE_DATE_EPOCH and the whole document is byte-stable.

It reads a published build, not a recipe’s lock. A lock says what an image would be made of; only a build says what one is — so sbom <recipe> reads the .provenance.toml and .pkgs.lock beside that recipe’s image, and says so if no build has produced them yet.

Where the size went

# The heaviest packages in a recipe's published image.
boot2deb size turing-rk1/forky

# Rolled up by the source package that produced them, all rows.
boot2deb size turing-rk1/forky --by source --top 0

# Debian's packages against the ones this build compiled into its own pool.
boot2deb size turing-rk1/forky --by archive

# Or from a plan someone handed you with an image.
boot2deb size ./turing-rk1-forky.plan --json

Answers “why is this image 2.1 GiB” from the plan document a build published — the one file that carries each package’s Installed-Size and Source.

size build/turing-rk1/forky/artifacts/turing-rk1-forky.plan — by source package

  1. systemd       21.7 MiB   35.5%     3 pkg
  2. glibc         19.2 MiB   31.4%     1 pkg
  3. file          10.6 MiB   17.3%     1 pkg

total 61.2 MiB across 8 packages in 6 source packages

Three axes, because three are answerable from a plan:

--byone row perwhat it is for
package (default)binary packagewhat is biggest
sourcesource packageattributing a source’s several outputs to the thing that was built
archiverepositoryseparating what Debian shipped from what this build compiled

A fourth — which config layer asked for a package — is not answerable, and the command does not pretend otherwise. The plan records the repository a package came from, not the layer that named it, and most of an image is transitive dependencies no layer named at all.

The figures are the archives’ own estimates, not measurements. Installed-Size is what each package’s builder computed over a staged tree, in the kibibytes Debian Policy defines it in. It counts no filesystem overhead, no inode shared by a hard link, and nothing the image gains after dpkg — the initramfs, the /boot artifacts an install hook produces, the ext4 metadata. So the total is smaller than the image, and the report is for comparing rows against each other rather than for predicting a card’s occupancy. Policy also permits a package to state no size at all; those are counted apart rather than folded in as zero, and the report says how many there were.

--top truncates the table and never the totals, so a partial view still states what it is a view of; --top 0 shows every row. --json prints the whole report — a consumer that asked for structure can slice it itself.

What has moved upstream

# Every recipe in the tree, in one pass.
boot2deb outdated

# Or just the ones you are about to touch.
boot2deb outdated turing-rk1/forky h96-max-m9/forky

outdated is the read-only sibling of update: it says what a re-pin would find, without writing a lock. For each git source pin it reports one of

statusmeaning
currentthe pinned tag is the newest comparable release, or the pinned branch is still at the pinned commit
behindnewer releases exist — the next one in the pin’s own line, and the newest upstream
tip-movedthe pin names a branch whose tip has moved, with both commits
unknownnothing could be compared, and why — a bare-commit pin, a ref the remote no longer advertises, or a remote that could not be reached
recipe                        axis     status     detail
turing-rk1/forky              kernel   behind     v7.1.6 -> v7.1.9 (3 newer in this line); newest upstream v7.3 (7 newer)
turing-rk1/forky              u-boot   behind     v2026.04 -> v2026.07 (1 newer release, none in this line)
turing-rk1/forky              patches  tip-moved  branch main: tip moved 659033b7e543 -> ed52b7fa4a3d

Two figures because they are different moves. The in-line bump — the next stable point release — usually keeps the patch series inside its declared applies_to_kernel envelope and the kernel config where it was. The newest upstream release usually does not, and is the one that wants a verify-patches run before it is pinned. A pin that is itself at the newest release in its line reports only the wider move.

A release pin is never offered a prerelease: v7.3-rc1 is not an upgrade from v7.1.6, it is a different question. Nor is a pin compared across naming schemes — the Linux-libre sources/v7.1.6-gnu trees and upstream’s own v7.1.6 live in the same repo and their versions interleave, so a survey that mixed them would offer to swap a board’s whole firmware posture as a point release. The rule is that the pin states its own scheme and only tags spelled the same way are candidates, which is why no per-axis list of version patterns exists to fall out of date.

Being behind is not a failure. outdated always exits zero; it is a survey, and whether to move is a decision with hardware evidence behind it. Its neighbour verify-sources is the gate, and it asks the opposite question — not “is there something newer” but “is what we pinned still fetchable at all”. A pin can be a durable tag and nine releases behind, or an ephemeral branch tip and current.

Cost is one git ls-remote per distinct remote, not per pin: the shipped recipes share a kernel repo and a patches repo, so surveying the whole tree is a handful of round-trips and a few seconds. Nothing is fetched and nothing is written.

What it does not cover is the pins that have no upstream ref to move: the rkbin blobs and any extra_debs are content-pinned by sha256 and read from the config tree, and the apt archive is pinned by the solved manifest rather than by a ref. Those move only when someone changes the config, which diff shows.

Rebuild planning and cleanup

# Explain, offline, what the next build will actually redo.
boot2deb why-rebuild turing-rk1/forky

# Remove a recipe's build scratch to reclaim disk or force a clean rebuild. --dry-run
# previews; --cache / --sandbox / --build-roots clean only that subtree.
boot2deb clean turing-rk1/forky --dry-run

# Drop the provisioned build roots (sparing the packaging root) so the next build
# provisions them against the archive as it stands now.
boot2deb clean turing-rk1/forky --build-roots

# Sweep the caches every recipe shares — no recipe to name. The routine one drops
# the auto-fetched checkouts nothing pins any more, and verify-config's scratch.
boot2deb clean --verify-trees --kconfig --dry-run

why-rebuild answers the question that decides how long a build takes, and it answers it for both caches. Per compile node it reports:

  • whether the cloned-and-patched source tree is reused or rebuilt, naming the pinned input that moved when it will rebuild; and
  • whether the artifact cache already holds that node’s output, in which case the compile is skipped entirely.

The two are independent, and the second dominates. A node can rebuild its tree and still compile nothing — the artifact store lives outside the work dir, so a fresh clone with no tree at all can restore every .deb. Each verdict is computed by calling the same function the build keys its own decision on, so the prediction cannot drift from what happens next. It runs no build and touches no network.

why-rebuild turing-rk1/forky (work .../build/turing-rk1/forky)
  kernel             rebuild  (kernel.commit: kc1 → kc2)  [artifact cache hit — compile skipped]
  uboot              reuse
note: the per-node verdict is the *source tree*: whether the clone and patch run again.
      The compile itself is governed by the artifact cache ...

Pass --no-artifact-cache to see the prediction for a build that will not use the store, and --patches-path / --userspace <name> to match a build that will use those.

clean removes only directories build created: every work dir is stamped with a .boot2deb-work marker, and an unmarked target is refused — so a mistyped --work-dir cannot recursively delete an arbitrary tree. --force overrides the check for a directory you are sure about.

--build-roots is the narrow selector, and the one to reach for when a build fails with the <stage> build root does not satisfy its own dependencies. A build root is provisioned once and cached; the packages layered over it are resolved against the archive as it stands when the build runs. The base’s cache key covers the mirrors it bootstrapped from and the package set it bootstrapped with — not the versions those resolved to — so nothing invalidates the tree when the archive moves underneath it, and an aged base cannot be told from a current one by inspection. Dropping it is what clears the skew.

It sweeps the provisioned build roots, the .lock and .pkgs files beside each, and the overlay layers staged over them, and it spares the packaging root. That root is never layered — its contents are fixed at bootstrap — so it has no skew to hit, and --sandbox, which takes it too, charges a second bootstrap for nothing. The two are mutually exclusive for that reason: they answer opposite questions about the packaging root. Preview either with --dry-run to see the trees and their sizes.

Sweeping the shared caches

Four durable stores live under <root>/cache, outside every work dir, and they are what a checkout accumulates over months rather than over one build. Because they are shared, the selectors that name them take no recipe:

selectorstorewhat makes it reclaimable
--verify-treescache/verify-trees, cache/patchesthe checkouts no lock pins
--kconfigcache/kconfigall of it — verify-config scratch
--artifactscache/artifactsall of it — every entry is a compile away
--all-cachescache/ entireall of it, pinned checkouts included

--verify-trees is the routine one, and the only selector that prunes within a store rather than emptying it. Both auto-fetch caches are keyed on the commit they hold, so liveness is decidable: a checkout whose commit no recipes/*/*.lock names can only ever be re-fetched, never read back from, and is dead. Re-pinning a kernel therefore strands the old tree the moment update writes the new commit, and this is what collects it. The still-pinned checkouts stay — they are what makes verify-patches and verify-config start instantly. Because a narrower pinned set would delete a live tree, a lock that will not parse aborts the sweep instead of narrowing it: nothing is removed until every lock in the config tree has been read.

The one narrowing that rule cannot catch is a missing --overlay. The locks are read from the search paths, so a sweep invoked without the overlays your builds use never sees their pins and calls their checkouts dead. Pass the same --overlay flags you build with; the run reports how many locks it read, so a count short of the tree you know is the signal that something was left out.

--kconfig empties verify-config’s scratch, one work dir per recipe. Each holds a provisioned cross root and an out-of-tree kbuild output dir, both re-created on the next run, and each is a base that ages against the archive exactly as a build root does — so dropping them costs a re-provision and buys back the largest of the four stores after the artifact cache.

--artifacts empties the durable artifact store; since it is shared, that clears cached outputs for every recipe, and the next build of each recompiles.

--all-caches takes the whole tree — the three above, the pinned checkouts, and the pre-built extra_debs store. Everything there is reclaimable by construction, but re-earning it costs a full re-fetch and a cache-cold rebuild, so it is the answer to “I need the disk back”, not to routine housekeeping.

Every flag

The complete argument surface, generated from the command tree — so it is exhaustive by construction, and a test fails when this page and the binary disagree. For what the flags mean together, read CLI; this page is the index, not the explanation.

Every flag is also --help-discoverable: boot2deb <command> --help.

Global

Accepted by every subcommand.

flagvaluewhat it does
--root<ROOT> (default .)Config root (the boot2deb repo dir holding devices/, socs/, …)
--overlay<OVERLAY>, repeatableOut-of-tree overlay directory holding your own devices/, socs/, kernels/, features/, or recipes/ files. Repeatable; later overlays win, and any overlay wins over the shipped root — a same-named layer is deep-merged last-wins, a new-named one adds a target. Fragments/blobs/overlay trees an overlay ships are resolved along the same path
--jsonMachine-readable output: list-*, resolve, doctor, and the verify-* commands print a JSON document; build streams NDJSON events (one JSON object per line, tagged by its event field, artifacts included) instead of the human rendering. A command with no machine form rejects the flag rather than ignoring it. Errors still go to stderr as text
--quiet, -qPrint only what a command produced — artifact paths and errors — and none of its progress. Conflicts with --verbose; ignored under --json, where the stream is the record
--verbose, -vPrint every line the build’s subprocesses emit (make, git, dpkg-buildpackage) as well as the step boundaries and each stage’s own decisions. The default shows the latter only, which keeps a tens-of-minutes compile readable; reach for this when a stage fails or hangs

list-devices

List available devices

This command takes no flags of its own.

list-recipes

List available recipes

This command takes no flags of its own.

list-kernels

List available kernel definitions (the --kernel override’s valid values)

This command takes no flags of its own.

list-features

List available rootfs features (the --feature override’s valid values)

This command takes no flags of its own.

list-kmods

List available out-of-tree kernel-module sets (a device’s device_kmods entries)

This command takes no flags of its own.

support-matrix

Print the support matrix: each shipped recipe’s support claim joined to the exact pins its lock records

flagvaluewhat it does
--markdownEmit the docs/src/reference/support-matrix.md page verbatim, for regenerating it after a claim changes or a lock is re-pinned

cli-reference

Print the complete flag reference: every command’s positional arguments and flags, generated from this command tree so it cannot drift from the binary. --help answers this per command; this answers it for all of them at once

flagvaluewhat it does
--markdownEmit the docs/src/reference/cli-flags.md page verbatim, for regenerating it after a flag is added, removed, or re-described

completions

Print a shell completion script on stdout, for the shell named. Install it where your shell looks (e.g. boot2deb completions bash > \ ~/.local/share/bash-completion/completions/boot2deb); boot2deb writes no files itself, since where they belong is the packager’s call

argumentrequiredwhat it is
shellyesShell to generate for

This command takes no flags of its own.

man

Print the boot2deb(1) man page (roff) on stdout, e.g. boot2deb man > /usr/share/man/man1/boot2deb.1

This command takes no flags of its own.

new-device

Scaffold a new devices/<name>.toml (and, by default, a matching recipe) from the typed model: it offers the valid SoC/boot-method/kernel/feature choices, fills every derivable value, and marks the researched values (kernel_dtb, uboot_defconfig, the rkbin blobs) with # TODO: comments. Interactive on a terminal; drive it with flags for scripting. Writes into the highest-precedence --overlay when one is given, else the primary root

argumentrequiredwhat it is
nameyesDevice name — the devices/<name>.toml (and recipe) file stem
flagvaluewhat it does
--description<DESCRIPTION>Board description. Prompted if omitted on a terminal
--soc<SOC>SoC (e.g. rk3588). Must already have a socs/<soc>.toml. Prompted if omitted on a terminal; required otherwise
--boot-method<BOOT_METHOD>Boot method (e.g. rockchip-rkbin). Prompted/defaulted if omitted
--kernel<KERNEL>Kernel definition id (e.g. rk3588-mainline-7.2). Must support the chosen SoC. Prompted/defaulted if omitted
--suite<SUITE>Default Debian suite. Prompted/defaulted (forky) if omitted
--layout<LAYOUT>Default image layout (combined | split). Prompted/defaulted if omitted
--hostname<HOSTNAME>Default image hostname. Defaults to the device name
--image-size<IMAGE_SIZE>Default image size (e.g. 2G). Prompted/defaulted if omitted
--feature<FEATURES>, repeatableA feature the scaffolded recipe selects (repeatable). Must be compatible with the chosen SoC/arch. Prompted from the compatible set on a terminal
--no-recipeDo not scaffold a recipe — write only the device file
--forceOverwrite existing files instead of refusing
--non-interactiveNever prompt; take every value from flags/defaults. Implied when stdin is not a terminal

resolve

Resolve a device or recipe to a complete build (no build work)

argumentrequiredwhat it is
targetyesDevice name (e.g. turing-rk1) or recipe name (e.g. turing-rk1/forky)
flagvaluewhat it does
--kernel<KERNEL>Kernel definition id (list-kernels shows the valid values); default: the recipe/device default_kernel. Must be one of the device’s supported_kernels
--uboot-series<UBOOT_SERIES>u-boot patch series (e.g. rk3576-display); default: the recipe/device default_uboot_series. Must be one of the device’s supported_uboot_series
--suite<SUITE>Debian suite the image is built for (e.g. forky, trixie); default: the recipe/device default_suite. Re-pinning it for a build is update’s job — here it resolves a different build point
--layout<LAYOUT>Image packaging: combined (one whole-disk image) or split (a bootloader-only image plus a separate rootfs image, for a two-medium install); default: the recipe/device default_layout
--boot-method<BOOT_METHOD>How the board boots: rockchip-rkbin (u-boot compiled into a raw gap) or depthcharge (a signed ChromeOS kernel partition); default: the device’s own. Must be one of the device’s supported_boot_methods
--board<BOARD>Depthcharge board profile (e.g. speedy-libreboot). A profile describes the firmware a unit runs, not the board model — so a unit with replacement firmware may take a different one. Must be in the device’s supported_boards; ignored by boot methods with no board profile
--feature<FEATURES>, repeatableRootfs feature add-in, repeatable (--feature media-accel-rockchip). When any is given, replaces the recipe’s feature list
--image-size<IMAGE_SIZE>Total image size (e.g. 4G); default: the recipe/device image_size. The rootfs grows to fill its medium on first boot, so this bounds the artifact, not the installed system
--locale<LOCALE>System locale — the image’s LANG (e.g. de_DE.UTF-8); default: the recipe/base locale. Always generated into the image, so it is safe to name a locale nothing else lists
--locale-gen<LOCALES_GENERATE>, repeatableExtra locale to generate into the image, repeatable (--locale-gen fr_FR.UTF-8). When any is given, replaces the base locales_generate list; the system locale is generated regardless
--timezone<TIMEZONE>System timezone (e.g. America/New_York); default: the recipe/base timezone
--ntp-server<NTP_SERVERS>, repeatableNTP server the image prefers, repeatable (--ntp-server ntp.lan); default: the recipe/base ntp_servers. When any is given, replaces that list. Debian’s fallback pool is kept either way, so this sets a preference rather than the only source — worth setting for a board that boots on a network the public pool cannot be reached from
--keymap<KEYMAP>Console keyboard layout (e.g. gb); default: the recipe/device keymap, and none at all on a headless board. Sets XKBLAYOUT; the model, variant, and options keep their defaults — set those in the device’s [keymap] table
--sudo<SUDO>What sudo asks of the default account: nopasswd (root with no prompt) or password (prompts for the account’s own); default: the recipe/base sudo
--password-length<PASSWORD_LENGTH>Length of the generated per-image first-boot password; default: the recipe/base first_boot_password_length. Shorter is friendlier to transcribe at a console and weaker in exactly one way — an attack on the password hash inside a shared image — so authorize an SSH key (ssh_authorized_keys) rather than shortening this if the goal is to stop typing it

doctor

Preflight the host: arch/OS facts, and whether every tool a build needs is present — with the exact per-distro install command for anything missing. With a target it asks only for what that recipe will invoke; bare, it runs the requirements every board shares. A missing required tool is a non-zero exit

argumentrequiredwhat it is
targetnoDevice/recipe to preflight. Omit to check only the requirements no board can opt out of (user namespaces, the .deb packaging tools, the vendored apt trust anchors) — the answerable half before a board is chosen
flagvaluewhat it does
--work-dir<WORK_DIR>Scratch dir the target would build in; default: <root>/build/<target>. Only the overlay check reads it — it probes the filesystem that dir lands on, so checking a build you will run with --work-dir needs the same path here
--kernel<KERNEL>Kernel definition id (list-kernels shows the valid values); default: the recipe/device default_kernel. Must be one of the device’s supported_kernels
--uboot-series<UBOOT_SERIES>u-boot patch series (e.g. rk3576-display); default: the recipe/device default_uboot_series. Must be one of the device’s supported_uboot_series
--suite<SUITE>Debian suite the image is built for (e.g. forky, trixie); default: the recipe/device default_suite. Re-pinning it for a build is update’s job — here it resolves a different build point
--layout<LAYOUT>Image packaging: combined (one whole-disk image) or split (a bootloader-only image plus a separate rootfs image, for a two-medium install); default: the recipe/device default_layout
--boot-method<BOOT_METHOD>How the board boots: rockchip-rkbin (u-boot compiled into a raw gap) or depthcharge (a signed ChromeOS kernel partition); default: the device’s own. Must be one of the device’s supported_boot_methods
--board<BOARD>Depthcharge board profile (e.g. speedy-libreboot). A profile describes the firmware a unit runs, not the board model — so a unit with replacement firmware may take a different one. Must be in the device’s supported_boards; ignored by boot methods with no board profile
--feature<FEATURES>, repeatableRootfs feature add-in, repeatable (--feature media-accel-rockchip). When any is given, replaces the recipe’s feature list
--image-size<IMAGE_SIZE>Total image size (e.g. 4G); default: the recipe/device image_size. The rootfs grows to fill its medium on first boot, so this bounds the artifact, not the installed system
--locale<LOCALE>System locale — the image’s LANG (e.g. de_DE.UTF-8); default: the recipe/base locale. Always generated into the image, so it is safe to name a locale nothing else lists
--locale-gen<LOCALES_GENERATE>, repeatableExtra locale to generate into the image, repeatable (--locale-gen fr_FR.UTF-8). When any is given, replaces the base locales_generate list; the system locale is generated regardless
--timezone<TIMEZONE>System timezone (e.g. America/New_York); default: the recipe/base timezone
--ntp-server<NTP_SERVERS>, repeatableNTP server the image prefers, repeatable (--ntp-server ntp.lan); default: the recipe/base ntp_servers. When any is given, replaces that list. Debian’s fallback pool is kept either way, so this sets a preference rather than the only source — worth setting for a board that boots on a network the public pool cannot be reached from
--keymap<KEYMAP>Console keyboard layout (e.g. gb); default: the recipe/device keymap, and none at all on a headless board. Sets XKBLAYOUT; the model, variant, and options keep their defaults — set those in the device’s [keymap] table
--sudo<SUDO>What sudo asks of the default account: nopasswd (root with no prompt) or password (prompts for the account’s own); default: the recipe/base sudo
--password-length<PASSWORD_LENGTH>Length of the generated per-image first-boot password; default: the recipe/base first_boot_password_length. Shorter is friendlier to transcribe at a console and weaker in exactly one way — an attack on the password hash inside a shared image — so authorize an SSH key (ssh_authorized_keys) rather than shortening this if the goal is to stop typing it

update

Resolve upstream refs + hash blobs and write the recipe’s .lock. The sole path that consults upstream; build reads only the lock

argumentrequiredwhat it is
recipeyesRecipe to resolve (e.g. turing-rk1/forky)
flagvaluewhat it does
--feature<FEATURES>, repeatableRootfs feature to select, repeatable (--feature jellyfin --feature media-accel-rockchip). Replaces the recipe’s own feature list and pins the result as a variant of the recipe: the lock, its solved package manifest, and the build directory are all named <recipe>+<feature>..., so the recipe’s own lock is left alone and two selections never collide. Order is significant — kernel fragments and patch series compose in selection order. A variant carries no [support] claim; the claim belongs to the recipe
--kernel-ref<KERNEL_REF>Kernel ref to pin, resolved to a commit (e.g. v7.2). Optional once a lock exists: omitting it re-pins the previous lock’s kernel ref, so a routine re-pin (e.g. after importing a patch) needs no kernel tag the user did not touch. Required only for the first update, which has no prior ref to inherit. Auto-resolving a kernel track to its latest tag is a later refinement
--uboot-ref<UBOOT_REF>u-boot ref to pin. Defaults to the boot-method’s uboot_ref, re-read on every update, so bumping that one constraint moves every board on the method — except a lock already pinned to a bare commit sha, which is kept as the deliberate hand-pin only this flag can have created
--userspace-ref<NAME=REF>, repeatableMedia-accel userspace ref to pin, as NAME=REF, repeatable. Defaults to that tree’s own [[userspace]] ref, re-read on every update; a lock pinned to a bare commit sha is kept instead. The SoC declares which trees it has, so each override names one (--userspace-ref mpp=v1.5.0)
--ffmpeg-base-ref<FFMPEG_BASE_REF>ffmpeg base (V4L2) ref to pin. Defaults to the SoC layer’s ffmpeg.base, re-read on every update; a lock pinned to a bare commit sha is kept instead
--ffmpeg-rockchip-ref<FFMPEG_ROCKCHIP_REF>ffmpeg Rockchip provenance-tree ref to pin. Defaults to the SoC layer’s ffmpeg.rockchip, re-read on every update; a lock pinned to a bare commit sha is kept instead. Recorded as the graft’s provenance; not fetched
--patches-path<PATCHES_PATH>patches repo checkout whose HEAD pins the series (default: the config root’s sibling ../patches). update requires this local clone when the kernel names a patch series — the pin is its HEAD — unlike build, which auto-fetches the already-pinned commit and needs no checkout
--blobs-dir<BLOBS_DIR>Vendored rkbin blob directory (default: blobs/SOC under the config root)
--rootfs-manifest<ROOTFS_MANIFEST>Name recorded for the solved package manifest the rootfs stage writes (default: RECIPE.pkgs.lock)

verify-patches

Dry-run the locked patch series against source checkouts with git am --3way, hard-erroring on the first patch that does not apply

argumentrequiredwhat it is
recipeyesRecipe whose lock names the kernel ref + patch series
flagvaluewhat it does
--kernel-path<KERNEL_PATH>Kernel checkout to verify the kernel series against. Optional: omit it and the locked kernel is auto-fetched at its pinned ref into a durable cache, so verification works on a fresh clone with no hand-cloned tree
--kernel-src<KERNEL_SRC>Kernel clone source (git URL or local path) for the auto-fetch, in place of the kernel definition’s upstream URL. A local checkout (e.g. ../linux) that holds the locked commit makes the fetch near-instant. Ignored with --kernel-path, and only used on the first materialization (the cache keys on the commit, so later runs are hits regardless)
--ffmpeg-path<FFMPEG_PATH>ffmpeg checkout to verify the ffmpeg series against. Optional: omit it and, when the series carries ffmpeg patches, the locked ffmpeg base is auto-fetched at its pin
--ffmpeg-base-src<FFMPEG_BASE_SRC>ffmpeg base clone source (git URL or local path) for the auto-fetch, in place of the SoC layer’s ffmpeg.base URL. A local checkout makes the fetch near-instant. Ignored with --ffmpeg-path
--uboot-path<UBOOT_PATH>u-boot checkout to verify the u-boot series against. Optional: omit it and, when the recipe pins a u-boot series, the locked u-boot is auto-fetched at its pin
--uboot-src<UBOOT_SRC>u-boot clone source (git URL or local path) for the auto-fetch, in place of the boot method’s uboot_source. Ignored with --uboot-path
--userspace-path<USERSPACE_PATH>Userspace (MPP/RGA) checkout to verify the userspace series against. Optional: omit it and, when the series carries userspace patches, the locked MPP tree is auto-fetched at its pin
--userspace-src<USERSPACE_SRC>Clone source (git URL or local path) for the auto-fetch of the patched userspace tree, in place of that tree’s own [[userspace]] URL. A local checkout makes the fetch near-instant. Ignored with --userspace-path
--patches-path<PATCHES_PATH>patches repo checkout the series + patches are read from. Omit to use the config root’s sibling ../patches if present, else auto-fetch the series at the lock’s patches.commit
--patches-url<PATCHES_URL>Clone URL for auto-fetching the patches series when no local checkout is present; default: the repo the lock’s patch pin names
--kernel<VERSION>Verify against this kernel version instead of the one the lock pins, leaving the lock untouched — “would this series survive 7.2?” answered before adopting 7.2. Takes a kernel tag (v7.2, v7.2-rc3); pair it with --kernel-path or --kernel-src pointing at a tree that holds it. — A version outside the series’ declared applies_to_kernel is measured, not refused: that is the case worth asking about, and gating on the envelope would answer the question by assuming it. The run says so and reports what git am actually does, so a clean result is the evidence for widening the envelope. — A release candidate is matched against its base release here, so an -rc tree is answerable; the build path stays release-strict. — Kernel axis only: a recipe that pins no kernel (a deliverable = "uboot" one) rejects it rather than quietly verifying its u-boot series and reporting a green that answers nothing.
--keep-goingReport every patch that fails to apply rather than stopping at the first. — One boundary usually spawns adjacent ones, so the first failure is rarely the whole story. Note that each failing patch is skipped, so later results are measured against a tree missing it — a map of the damage, not a final verdict.

verify-config

Generate the kernel .config (base defconfig + fragments via merge_config.sh) on a patched kernel tree; with a reference config, additionally check byte-identical CONFIG_* parity against it

argumentrequiredwhat it is
recipeyesRecipe whose resolved kernel names the base defconfig + fragments
flagvaluewhat it does
--kernel-path<KERNEL_PATH>Kernel checkout (at the locked ref, patch series applied) to configure. Optional: omit it and the locked kernel is auto-fetched at its pinned ref and the kernel patch series applied for you, so the gate works on a fresh clone
--reference-config<REFERENCE_CONFIG>Reference .config to check byte-identical CONFIG_* parity against. Omit for a clean-merge check only
--work-dir<WORK_DIR>Directory for the two out-of-tree config builds (default: a temp dir)
--kernel-src<KERNEL_SRC>Kernel clone source (git URL or local path) for the auto-fetch, in place of the kernel definition’s upstream URL. A local checkout (e.g. ../linux) that holds the locked commit makes the fetch near-instant. Ignored with --kernel-path
--patches-path<PATCHES_PATH>patches repo checkout the kernel series is read from when auto-fetching the tree (ignored with --kernel-path, which is assumed already patched). Omit to use the config root’s sibling ../patches if present, else auto-fetch at the lock’s patches.commit
--patches-url<PATCHES_URL>Clone URL for auto-fetching the patches series; default: the kernel definition’s patches_url. Used only when auto-fetching the kernel tree

verify-packages

Ask the archives a build would resolve against whether they carry every package the recipe names, and report the ones they do not. Runs the read half of a resolve — release and indexes, nothing downloaded, no closure computed — so one pass answers every name at once, before any build work starts

argumentrequiredwhat it is
recipeyesRecipe whose resolved package set to check (e.g. turing-rk1/forky)

This command takes no flags of its own.

verify-image

Hold a finished image artifact to the invariants that are checkable without a board: the artifact set is present, the plan document parses and its digest matches what the provenance records, [[archives]] is well formed, the ext4 filesystem is exactly its GPT partition, and a fitted --image-size left the slack it asked for. Read-only, no root: only the head of the artifact is decompressed. The off-board half of the hardware gate

argumentrequiredwhat it is
recipeyesRecipe whose built image to verify (e.g. turing-rk1/forky)
flagvaluewhat it does
--out-dir<OUT_DIR>Directory holding the built artifacts (default: the recipe’s own <work>/artifacts)

verify-sources

Probe each locked source pin against its configured upstream URL and report whether it is a durable tag, an ephemeral branch, or ORPHANED (not re-fetchable) — the source-pin durability survey as a command. Read-only: git ls-remote plus a timeout-bounded ancestry check, no build, no checkout, no hardware

argumentrequiredwhat it is
recipeyesRecipe whose lock names the source pins (e.g. turing-rk1/forky)

This command takes no flags of its own.

patch

Curate the patch series. Subcommand: import

patch import

Fetch a patch (patchwork/mbox URL, a file, or - for stdin), normalize it to canonical git am-ready mbox, slot it into a series’ scope at a position, and — with --verify-tree — dry-run git am-verify the resulting series

argumentrequiredwhat it is
sourceyesPatch source: an http(s):// URL (a patchwork mbox), a local file path, or - to read from stdin
flagvaluewhat it does
--series<SERIES>Series to slot the patch into (e.g. rk3588-accel) — names series/<name>/series.toml in the patches repo
--scope<SCOPE>Which source tree’s ordered list to insert into
--position<POSITION>1-based position in the scope list to insert at (default: append to the end). 0 or a value past one-beyond-the-end is an error, not a clamp
--dest-dir<DEST_DIR>Repo subdirectory to write the patch into (default: media-accel/<scope>). Use e.g. rocket to target the NPU scope of the kernel list
--name<NAME>Filename slug override (default: a kebab-case slug of the subject). The written file is <dest-dir>/<prefix>-<slug>.patch
--as<LABEL>Explicit repo-relative destination label, overriding the derived dir/prefix/slug entirely (e.g. media-accel/kernel/045-fix.patch)
--author<AUTHOR> (default boot2deb import <import@boot2deb>)From: author for a synthesized header (bare diff / git show fallback)
--subject<SUBJECT>Subject override — the title for a bare diff carrying none, or an override for git show. Ignored for an already-formatted mbox
--origin<ORIGIN>DEP-3 Origin: provenance trailer to add to the commit message
--patches-path<PATCHES_PATH>patches repo checkout to write into (default: the config root’s sibling ../patches). patch import requires this local clone — it writes the patch file and edits the series there — unlike build, which auto-fetches pinned commits
--verify-tree<VERIFY_TREE>Source checkout to dry-run git am-verify the spliced series against. Omit to import without verifying (a warning is printed)
--forceOverwrite the destination file if it already exists (default: refuse)

build

Drive the build stages (kernel, u-boot, userspace, ffmpeg, and the disk image) from the recipe’s lock, streaming the structured build event stream. Reads only the lock for pinned sources; the lock-independent image axes (--layout, --image-size) are overridable, while re-pinning a source axis (kernel/suite/features/boot-method) is update’s job

argumentrequiredwhat it is
recipeyesRecipe to build (e.g. turing-rk1/forky); its .lock must exist
flagvaluewhat it does
--feature<FEATURES>, repeatableRootfs feature to select, repeatable — the same selection update --feature pinned. It names which lock to build from (<recipe>+<feature>...), it does not re-resolve one: update must have written that variant’s lock first, and a selection with no lock is an error naming the update line to run. Passing the reference directly (build turing-rk1/forky+jellyfin) is equivalent
--stageall | kernel | dtb | kmod | uboot | userspace | ffmpeg | rootfs | image (default all)Which stage(s) to run
--kernel-src<KERNEL_SRC>Kernel clone source (git URL or local path); default: the kernel definition’s source URL. A local clone (e.g. ../linux) is far faster
--uboot-src<UBOOT_SRC>u-boot clone source (git URL or local path); default: the boot method’s uboot_source
--userspace-src<NAME=SRC>, repeatableMedia-accel userspace clone source, as NAME=SRC, repeatable; default: that tree’s own [[userspace]] URL. The SoC declares which trees it has, so each override names one (--userspace-src mpp=../mpp-rockchip). A local checkout is far faster than a fresh clone. The clone is still made at the locked commit, so the named tree must contain it
--ffmpeg-base-src<FFMPEG_BASE_SRC>ffmpeg base (Kwiboo) clone source; default: the SoC layer’s ffmpeg.base URL. A local checkout makes the fetch near-instant
--kmod-src<NAME=SRC>, repeatableOut-of-tree module clone source, as NAME=SRC, repeatable; default: that kmod’s locked source. Unlike the single-tree axes there are several modules, so each override names the device_kmods entry it applies to (--kmod-src aic8800=../aic8800). The clone is still made at the locked commit, so the named tree must contain it
--userspace<NAME>, repeatableAlso build an optional media-accel userspace tree, by name, repeatable. — A tree the SoC marks optional is skipped unless named here: libmali is the live case — the transcode pipeline rides the VPU and the RGA, not the GPU, so a headless box never needs the blob and compiling its variant matrix is minutes for nothing. Naming an optional tree also changes what the whole userspace stage layers, so every tree’s cache key moves with it.
--patches-path<PATCHES_PATH>patches repo checkout the series is read from. Omit to use the config root’s sibling ../patches (if present, with the lock’s patches.commit enforced), else auto-fetch the series at the pinned commit from --patches-url/the repo the pin names. Pass an explicit path to co-develop the series from a working checkout, which downgrades a pin mismatch to a loud warning
--patches-url<PATCHES_URL>Clone URL for auto-fetching the patches series when no local checkout is present; default: the repo the lock’s patch pin names. The series is fetched at the lock’s patches.commit into a durable cache and its pin enforced. Ignored when --patches-path or the sibling ../patches supplies a checkout
--blobs-dir<BLOBS_DIR>Vendored rkbin blob directory (default: blobs/SOC under the config root)
--keyring<KEYRING>Debian archive keyring every root this build provisions is verified against (default: the vendored blobs/keyrings/debian-archive-keyring.gpg; omit on a Debian host to use its apt trust store)
--unsafe-overlay-keyringTrust an overlay-shipped copy of the archive keyring. By default an overlay that ships blobs/keyrings/debian-archive-keyring.gpg is refused as a trust-anchor swap; this opts into the overlay’s copy explicitly
--work-dir<WORK_DIR>Scratch dir for clones + builds (default: <root>/build/RECIPE)
--out-dir<OUT_DIR>Where produced artifacts are staged (default: WORK_DIR/artifacts). Every artifact is named for the recipe, so several builds may share one directory
--jobs<JOBS>make -j parallelism (default: host available parallelism). Must be at least 1 — 0 would reach make -j0 (“unlimited”), never what a typo means
--rootfs-tar<ROOTFS_TAR>Rootfs tar archive for the image stage. Optional: --stage image otherwise uses the tar the rootfs stage produced (auto-discovered in the output dir), so this is only needed to point at a tar built elsewhere
--rootfs-label<ROOTFS_LABEL> (default rootfs)ext4 volume label / GPT partition name for the image rootfs
--compressxz | gz | none, repeatable (default xz)Containers to compress the finished image(s) into, comma-separated and in preference order — xz (default), gz, or none. Use gz for an image u-boot will write to a disk itself: gzwrite reads gzip only, never xz. --compress xz,gz emits both; the first named is what the next: hint points at
--keep-rawKeep the raw .img after compressing it (default: delete it once every requested container is written, since it is derivable and the largest artifact). Has no effect under --compress none, where the raw image is the only output anyway
--layout<LAYOUT>Image layout override (combined | split); default: the recipe/device layout. Lock-independent — it changes only image packaging, not any pinned source, so it is safe to set against an existing lock
--image-size<IMAGE_SIZE>Image-size override (e.g. 4G, or fit+20% to size the image to its contents with a fifth of the rootfs left free); default: the recipe/device image_size. Lock-independent — it changes only image geometry, not any pinned source
--snapshot<SNAPSHOT>Snapshot activation for the rootfs bootstrap: off (live mirror), fallback (live first, snapshot.debian.org fills 404s), pin (snapshot only, fully deterministic). Default: the lock’s captured mode (off if none). fallback/pin need a captured snapshot (--save-snapshot)
--save-snapshotAfter a successful build, capture the current UTC time as a snapshot.debian.org timestamp into the lock (dormant, mode = off), so the solved versions stay fetchable after they rotate off the live mirror; a later build activates it with --snapshot fallback|pin
--save-manifestAfter the rootfs stage, commit the solved package manifest beside the lock and record its sha256 in the lock ([rootfs].manifest_sha256) — the reproducibility pin later builds verify a fresh solve against
--allow-manifest-driftDowngrade a solved-manifest drift from the committed pin to a warning instead of a hard error — for co-development or a knowingly-moved mirror. Re-pin deliberately with --save-manifest (which skips the drift check entirely, so combining the two is rejected as contradictory)
--sbomspdx | cyclonedx, repeatableAlso write a software bill of materials beside the image, in this format (repeatable — --sbom spdx --sbom cyclonedx writes both). Off by default, so a build never silently gains a file; the same documents can be produced later from the published provenance manifest with boot2deb sbom. Set SOURCE_DATE_EPOCH for a byte-reproducible document — everything else in it is derived from the image’s own content
--refresh-rootfsIgnore a rootfs cache hit and re-bootstrap, refreshing the stored tree. The plan is still resolved — the rootfs cache keys on the solved set, so a moved mirror already rebuilds automatically; this is the manual escape when you want a clean bootstrap regardless
--no-artifact-cacheDisable the Tier-2 artifact cache: always recompile the kernel / u-boot / userspace / ffmpeg .debs instead of restoring a stored output on a signature hit, and do not store this build’s outputs. The durable store at <root>/cache/artifacts is left untouched
--allow-stale-builderBuild even though this boot2deb binary does not match the source checkout it is being run from — it was compiled before the checkout’s current commit, or before edits under crates/. The image is built by the running binary either way; what the mismatch costs is the truth of the [built_with] stamp, which would name a commit that is not what ran. The fix is normally cargo build, which takes seconds; this is for the case where you mean it

reproduce

Rebuild an image from the plan document a previous build published, instead of resolving the archive afresh. The lock pins the sources; the plan pins the package versions the archive served, which the lock cannot. Takes every build flag, and differs from it in one way: the rootfs installs the plan’s exact set by the digests it records, reading neither a release nor a package index — so the plan, not an archive signature, is what those digests chain to

argumentrequiredwhat it is
recipeyesRecipe to reproduce (e.g. turing-rk1/forky); its .lock must exist
flagvaluewhat it does
--from<FROM>Directory holding the published <stem>.plan (and, for the builder advisory, <stem>.provenance.toml) — the directory the image shipped from. Default: this build point’s own output dir, which is where a build on this machine already published them
--feature<FEATURES>, repeatableRootfs feature to select, repeatable — the same selection update --feature pinned. It names which lock to build from (<recipe>+<feature>...), it does not re-resolve one: update must have written that variant’s lock first, and a selection with no lock is an error naming the update line to run. Passing the reference directly (build turing-rk1/forky+jellyfin) is equivalent
--stageall | kernel | dtb | kmod | uboot | userspace | ffmpeg | rootfs | image (default all)Which stage(s) to run
--kernel-src<KERNEL_SRC>Kernel clone source (git URL or local path); default: the kernel definition’s source URL. A local clone (e.g. ../linux) is far faster
--uboot-src<UBOOT_SRC>u-boot clone source (git URL or local path); default: the boot method’s uboot_source
--userspace-src<NAME=SRC>, repeatableMedia-accel userspace clone source, as NAME=SRC, repeatable; default: that tree’s own [[userspace]] URL. The SoC declares which trees it has, so each override names one (--userspace-src mpp=../mpp-rockchip). A local checkout is far faster than a fresh clone. The clone is still made at the locked commit, so the named tree must contain it
--ffmpeg-base-src<FFMPEG_BASE_SRC>ffmpeg base (Kwiboo) clone source; default: the SoC layer’s ffmpeg.base URL. A local checkout makes the fetch near-instant
--kmod-src<NAME=SRC>, repeatableOut-of-tree module clone source, as NAME=SRC, repeatable; default: that kmod’s locked source. Unlike the single-tree axes there are several modules, so each override names the device_kmods entry it applies to (--kmod-src aic8800=../aic8800). The clone is still made at the locked commit, so the named tree must contain it
--userspace<NAME>, repeatableAlso build an optional media-accel userspace tree, by name, repeatable. — A tree the SoC marks optional is skipped unless named here: libmali is the live case — the transcode pipeline rides the VPU and the RGA, not the GPU, so a headless box never needs the blob and compiling its variant matrix is minutes for nothing. Naming an optional tree also changes what the whole userspace stage layers, so every tree’s cache key moves with it.
--patches-path<PATCHES_PATH>patches repo checkout the series is read from. Omit to use the config root’s sibling ../patches (if present, with the lock’s patches.commit enforced), else auto-fetch the series at the pinned commit from --patches-url/the repo the pin names. Pass an explicit path to co-develop the series from a working checkout, which downgrades a pin mismatch to a loud warning
--patches-url<PATCHES_URL>Clone URL for auto-fetching the patches series when no local checkout is present; default: the repo the lock’s patch pin names. The series is fetched at the lock’s patches.commit into a durable cache and its pin enforced. Ignored when --patches-path or the sibling ../patches supplies a checkout
--blobs-dir<BLOBS_DIR>Vendored rkbin blob directory (default: blobs/SOC under the config root)
--keyring<KEYRING>Debian archive keyring every root this build provisions is verified against (default: the vendored blobs/keyrings/debian-archive-keyring.gpg; omit on a Debian host to use its apt trust store)
--unsafe-overlay-keyringTrust an overlay-shipped copy of the archive keyring. By default an overlay that ships blobs/keyrings/debian-archive-keyring.gpg is refused as a trust-anchor swap; this opts into the overlay’s copy explicitly
--work-dir<WORK_DIR>Scratch dir for clones + builds (default: <root>/build/RECIPE)
--out-dir<OUT_DIR>Where produced artifacts are staged (default: WORK_DIR/artifacts). Every artifact is named for the recipe, so several builds may share one directory
--jobs<JOBS>make -j parallelism (default: host available parallelism). Must be at least 1 — 0 would reach make -j0 (“unlimited”), never what a typo means
--rootfs-tar<ROOTFS_TAR>Rootfs tar archive for the image stage. Optional: --stage image otherwise uses the tar the rootfs stage produced (auto-discovered in the output dir), so this is only needed to point at a tar built elsewhere
--rootfs-label<ROOTFS_LABEL> (default rootfs)ext4 volume label / GPT partition name for the image rootfs
--compressxz | gz | none, repeatable (default xz)Containers to compress the finished image(s) into, comma-separated and in preference order — xz (default), gz, or none. Use gz for an image u-boot will write to a disk itself: gzwrite reads gzip only, never xz. --compress xz,gz emits both; the first named is what the next: hint points at
--keep-rawKeep the raw .img after compressing it (default: delete it once every requested container is written, since it is derivable and the largest artifact). Has no effect under --compress none, where the raw image is the only output anyway
--layout<LAYOUT>Image layout override (combined | split); default: the recipe/device layout. Lock-independent — it changes only image packaging, not any pinned source, so it is safe to set against an existing lock
--image-size<IMAGE_SIZE>Image-size override (e.g. 4G, or fit+20% to size the image to its contents with a fifth of the rootfs left free); default: the recipe/device image_size. Lock-independent — it changes only image geometry, not any pinned source
--snapshot<SNAPSHOT>Snapshot activation for the rootfs bootstrap: off (live mirror), fallback (live first, snapshot.debian.org fills 404s), pin (snapshot only, fully deterministic). Default: the lock’s captured mode (off if none). fallback/pin need a captured snapshot (--save-snapshot)
--save-snapshotAfter a successful build, capture the current UTC time as a snapshot.debian.org timestamp into the lock (dormant, mode = off), so the solved versions stay fetchable after they rotate off the live mirror; a later build activates it with --snapshot fallback|pin
--save-manifestAfter the rootfs stage, commit the solved package manifest beside the lock and record its sha256 in the lock ([rootfs].manifest_sha256) — the reproducibility pin later builds verify a fresh solve against
--allow-manifest-driftDowngrade a solved-manifest drift from the committed pin to a warning instead of a hard error — for co-development or a knowingly-moved mirror. Re-pin deliberately with --save-manifest (which skips the drift check entirely, so combining the two is rejected as contradictory)
--sbomspdx | cyclonedx, repeatableAlso write a software bill of materials beside the image, in this format (repeatable — --sbom spdx --sbom cyclonedx writes both). Off by default, so a build never silently gains a file; the same documents can be produced later from the published provenance manifest with boot2deb sbom. Set SOURCE_DATE_EPOCH for a byte-reproducible document — everything else in it is derived from the image’s own content
--refresh-rootfsIgnore a rootfs cache hit and re-bootstrap, refreshing the stored tree. The plan is still resolved — the rootfs cache keys on the solved set, so a moved mirror already rebuilds automatically; this is the manual escape when you want a clean bootstrap regardless
--no-artifact-cacheDisable the Tier-2 artifact cache: always recompile the kernel / u-boot / userspace / ffmpeg .debs instead of restoring a stored output on a signature hit, and do not store this build’s outputs. The durable store at <root>/cache/artifacts is left untouched
--allow-stale-builderBuild even though this boot2deb binary does not match the source checkout it is being run from — it was compiled before the checkout’s current commit, or before edits under crates/. The image is built by the running binary either way; what the mismatch costs is the truth of the [built_with] stamp, which would name a commit that is not what ran. The fix is normally cargo build, which takes seconds; this is for the case where you mean it

diff

Compare two build points: the packages, the kernel pin and its requested config, the patch series and the patch files behind them, every other source pin, the rkbin blobs, and what built each side. Each side is a recipe name, a .lock, or a .provenance.toml; mixing is allowed, and a section only one side can answer is reported unavailable rather than as a change. Offline — reads documents the build already wrote

argumentrequiredwhat it is
leftyesThe left side: a recipe (e.g. turing-rk1/forky), or a path to a .lock or .provenance.toml
rightyesThe right side, in any of the same forms
flagvaluewhat it does
--sectionpackages | kernel | patches | sources | blobs | builder, repeatableReport only these sections (repeatable). Default: all of them
--patches-path<PATCHES_PATH>patches checkout to resolve a moved patches commit into named files. Default: the config root’s sibling ../patches

sbom

Export an image’s bill of materials as SPDX 2.3 or CycloneDX 1.6 JSON, from the provenance manifest and solved package manifest a build published. Lists every installed package with its version and sha256, every pinned source tree the image was compiled from, every rkbin blob, and every externally-fetched .deb. Licenses are declared NOASSERTION — boot2deb records none, and inventing them would produce a field that looks authoritative and is not. Offline; builds nothing

argumentrequiredwhat it is
targetyesRecipe whose published image to describe (e.g. turing-rk1/forky), or a path to a .provenance.toml shipped with an image
flagvaluewhat it does
--formatspdx | cyclonedx (default spdx)Document format to write
--out<OUT>Write to this file instead of stdout
--feature<FEATURES>, repeatableRootfs feature the published image was built with, repeatable — the same selection build --feature used. It names which image’s documents to read; passing the reference directly (sbom turing-rk1/forky+jellyfin) is equivalent. Ignored when a .provenance.toml path is given, which already names one image

size

Break down what an image’s package set weighs, from the plan document a build published — per binary package, per source package, or per repository. The figures are the archives’ own Installed-Size estimates in kibibytes, so they answer “what did the packages contribute” and not “how large is the image”: they exclude filesystem overhead and everything the image gains after dpkg. Offline; builds nothing

argumentrequiredwhat it is
targetyesRecipe whose published image to weigh (e.g. turing-rk1/forky), or a path to a .plan shipped with an image
flagvaluewhat it does
--bypackage | source | archive (default package)Axis to roll up on: one row per binary package, per source package (which attributes a source’s several outputs to the thing that was built), or per repository (which separates what Debian shipped from what this build compiled)
--top<TOP> (default 25)Show only the heaviest N rows; 0 shows every row. The totals always describe the whole set, so a truncated view still says what it is a view of
--feature<FEATURES>, repeatableRootfs feature the published image was built with, repeatable — the same selection build --feature used. It names which image’s plan to read; passing the reference directly (size turing-rk1/forky+jellyfin) is equivalent. Ignored when a .plan path is given, which already names one image

outdated

Survey what has moved upstream since the locks were pinned: for each recipe’s git source pins, whether a newer release tag exists (and how far behind the pin is), or whether a pinned branch’s tip has moved. Read-only — one git ls-remote per distinct remote, no fetch and no re-pin. Being behind is not a failure, so this always exits zero; verify-sources is the gate, and it answers the different question of whether a pin is still fetchable at all

argumentrequiredwhat it is
recipesnoRecipes to survey (e.g. turing-rk1/forky). Default: every recipe in the config tree

This command takes no flags of its own.

why-rebuild

Explain, per compile node, what the next build will actually redo: whether it reuses or rebuilds the cached source tree (naming the pinned input that moved), and whether the durable artifact cache lets it skip the compile entirely. Offline: reads the lock, the build stamps, and the artifact store; runs no build

argumentrequiredwhat it is
recipeyesRecipe to inspect (e.g. turing-rk1/forky); its .lock must exist
flagvaluewhat it does
--work-dir<WORK_DIR>Build scratch dir to inspect (default: <root>/build/RECIPE) — must match the dir the build used, since the stamps live there
--patches-path<PATCHES_PATH>The build being reasoned about used an explicit --patches-path co-dev checkout (folded into the kernel/u-boot/ffmpeg signatures). Pass the same value so the prediction matches what that build would reuse
--userspace<NAME>, repeatableThe build being reasoned about names these optional userspace trees (--userspace <name>). Pass the same set: an optional tree changes what the whole userspace stage layers, so it moves every userspace node’s key
--no-artifact-cacheThe build being reasoned about passes --no-artifact-cache. The Tier-2 artifact cache is then off, so no node restores a stored .deb and every one recompiles — pass it here to see that prediction rather than the cached one

shell

Open an interactive shell in the root a build stage compiles in — the same base tree, the same layered build-dependencies, the same mounts and the same environment the compile has. The way to diagnose a failed compile by looking at it rather than by reading what it printed. Provisions the root if this work dir has none; needs a terminal

argumentrequiredwhat it is
recipeyesRecipe whose root to enter (e.g. turing-rk1/forky); its .lock must exist
commandnoThe command to run in the root, and its arguments. Default: an interactive bash. Everything after -- is taken verbatim, so a command’s own flags reach it rather than boot2deb
flagvaluewhat it does
--stagekernel | uboot | kmod | userspace | ffmpeg | packagingWhich root to enter. Required: the whole point is entering a particular stage’s root, and no default is more likely right than another
--feature<FEATURES>, repeatableRootfs feature to select, repeatable — the same selection build --feature used, since a variant builds in a work dir of its own. Passing the reference directly (shell turing-rk1/forky+jellyfin) is equivalent
--work-dir<WORK_DIR>Build scratch dir whose roots to enter (default: <root>/build/RECIPE) — the same default build uses, so a session lands in the tree a build made
--out-dir<OUT_DIR>Directory holding the .debs the compile stages staged (default: WORK_DIR/artifacts). Read only by --stage ffmpeg, whose root layers this build’s own userspace packages out of it
--userspace<NAME>, repeatableEnter the userspace root as a build naming these optional trees would see it, carrying the development packages their own probes need — the same set the userspace stage ran under
--snapshot<SNAPSHOT>Snapshot activation, as build takes it. Default: the lock’s captured mode. It is in every provisioned root’s cache key, so a session opened under a different mode than the build ran under would enter a different tree
--keyring<KEYRING>Debian archive keyring for the bootstrap, if the root has to be provisioned. Default: the vendored blobs/keyrings/debian-archive-keyring.gpg

clean

Remove a recipe’s build scratch (clones, sandbox, rootfs cache) under its work dir, or sweep the durable caches every recipe shares, to reclaim disk or force a clean rebuild

argumentrequiredwhat it is
recipenoRecipe whose build scratch to remove (e.g. turing-rk1/forky). Optional when every selector given is root-scoped (--artifacts, --verify-trees, --kconfig, --all-caches), since those name a shared store rather than one recipe’s work dir
flagvaluewhat it does
--work-dir<WORK_DIR>Build scratch dir to clean (default: <root>/build/RECIPE)
--cacheRemove only the rootfs early-cutoff cache (WORK_DIR/cache), keeping the compiled source trees and artifacts
--sandboxRemove only the provisioned roots (WORK_DIR/sandbox: the target-arch build sandbox and the host-arch packaging root) — the largest reclaimable tree
--build-rootsRemove the provisioned build roots and the layers staged over them, sparing the packaging root, so the next build provisions them against the archive as it stands now — the answer to the <stage> build root does not satisfy its own dependencies, where a cached base has aged past the archive its layer resolved from. --sandbox clears the same skew but takes the packaging root with it, which is a second bootstrap for a root that is never layered and cannot skew
--artifactsRemove the durable Tier-2 artifact store (<root>/cache/artifacts). Root-scoped: this store is shared across recipes, so it clears cached outputs for every recipe, not just one
--verify-treesPrune the auto-fetched source checkouts (<root>/cache/verify-trees, and the patches checkouts beside them) down to what is still pinned: a checkout is commit-addressed, so one whose commit no recipes/*/*.lock names can only be re-fetched, never reconstructed from, and is dead. Root-scoped. Pinned checkouts stay — --all-caches is what takes those too. Pass the same --overlay flags a build of these recipes uses: the pinned set is read from the search paths, so a sweep that omits an overlay calls its checkouts dead. The run reports how many locks it read, which is what makes a narrow set visible
--kconfigRemove verify-config’s scratch tree (<root>/cache/kconfig), one work dir per recipe holding a provisioned cross root and a kbuild output dir. Pure scratch: the next verify-config re-provisions. Root-scoped
--all-cachesRemove the whole durable cache tree (<root>/cache) — artifacts, every auto-fetched checkout including the pinned ones, the kconfig scratch, and the pre-built extra-deb store. Root-scoped, and the nuclear option: everything here is reclaimable by construction, but re-earning it costs a full re-fetch and a cache-cold rebuild
--dry-runShow what would be removed (with sizes) without removing anything
--forceRemove the work dir even when it is not stamped as boot2deb-created (no .boot2deb-work marker). Without this, clean refuses such a target, so a mistyped --work-dir cannot recursively delete an arbitrary tree

press

Produce a ready-to-flash image file from a build’s artifacts, verified and optionally personalized per unit (--hostname/--ssh-key/--wifi-ssid seed keys) or extended with per-site files (--copy/--deb/--embed-image, which re-assemble the image from the kept rootfs tar). boot2deb does not write devices — hand the pressed file to any flasher, dd included

argumentrequiredwhat it is
recipeyesRecipe whose artifacts to press (e.g. turing-rk1/forky)
outputnoThe image file to write, for a build with one artifact (a combined image or a u-boot deliverable). A split build is two files for two media and takes --boot-out + --rootfs-out instead
flagvaluewhat it does
--boot-out<BOOT_OUT>The boot image’s output file, for a split build — what goes onto the eMMC/SPI medium the board boots from
--rootfs-out<ROOTFS_OUT>The rootfs image’s output file, for a split build — what goes onto the disk the OS lives on
--hostname<HOSTNAME>Per-unit hostname, written into the image’s seed partition and applied by the device at first boot
--ssh-key<SSH_KEYS>, repeatableSSH public key (the full ssh-ed25519 AAAA... comment line), repeatable — appended to the default account’s authorized_keys at first boot
--wifi-ssid<WIFI_SSID>Wi-Fi network the device joins at first boot (images with NetworkManager only — every Wi-Fi-capable board’s has it). The per-site value that never belongs in a committed recipe
--wifi-psk<WIFI_PSK>WPA passphrase for --wifi-ssid (8-63 characters, or 64 hex digits). Omit for an open network. Stored as plain text in the seed partition, like every seed key
--static-ip<ADDR/PREFIX[,GW[,DNS...]]>Static IPv4 (ADDRESS/PREFIX[,GATEWAY[,DNS...]]) for the connection the seed sets up: the Wi-Fi profile when --wifi-ssid is present, the wired interface otherwise — NetworkManager or dhcpcd, whichever the image carries. Omit for DHCP
--copy<SRC:DEST>, repeatableCopy a host file into the image at an absolute path (SRC:DEST), repeatable — a site config, a one-off script. Mode 0644 (0755 when the source is executable), owner root. Re-assembles the image from the kept rootfs tar, so the build must have run. A source named *.tmpl is a template: its {{image.<name>}} references (hostname, PARTUUIDs, suite, …) are expanded at press time and it lands at DEST
--copy-tree<DIR>, repeatableCopy a whole directory that mirrors the target rootfs, repeatable — DIR/etc/site.conf lands at /etc/site.conf. Every regular file and symlink under it is placed; directories are not, since the parents each file needs are created root-owned 0755. Same modes as --copy, and a *.tmpl file is expanded and lands without the suffix
--deb<PATH>, repeatableStage a local .deb (repeatable) for installation at first boot via dpkg -i. Dependencies already in the image resolve immediately; missing ones are fetched only if the board has network by then
--embed-imageCarry the recipe’s own compressed image artifact inside the pressed image (at /var/lib/boot2deb/install/), so the booted board can install itself to internal storage with boot2deb-install-to — the boot-from-card, install-to-eMMC workflow
--no-verifySkip the post-write verification of the pressed file. The press is not faster; only the re-read is saved
--dry-runPrint what would be pressed — artifacts, outputs, additions, seed keys — without writing anything
--layout<LAYOUT>Image layout override (combined | split), matching the build that produced the artifacts
--rootfs-label<ROOTFS_LABEL> (default rootfs)ext4 volume label / GPT partition name for a re-assembled rootfs — match the build --rootfs-label the artifacts were made with
--work-dir<WORK_DIR>Build scratch dir holding the artifacts (default: <root>/build/RECIPE)
--out-dir<OUT_DIR>Directory the build wrote its artifacts to (default: WORK_DIR/artifacts)

seed

Rewrite the per-unit seed partition of an already-pressed image file — the same personalization press applies, without re-pressing. With no keys the seed resets to the empty template. Takes a file: to re-personalize a card that is already written, edit seed.txt on its B2D-SEED volume directly

argumentrequiredwhat it is
imageyesThe pressed image file whose seed partition to rewrite
flagvaluewhat it does
--hostname<HOSTNAME>Per-unit hostname, written into the image’s seed partition and applied by the device at first boot
--ssh-key<SSH_KEYS>, repeatableSSH public key (the full ssh-ed25519 AAAA... comment line), repeatable — appended to the default account’s authorized_keys at first boot
--wifi-ssid<WIFI_SSID>Wi-Fi network the device joins at first boot (images with NetworkManager only — every Wi-Fi-capable board’s has it). The per-site value that never belongs in a committed recipe
--wifi-psk<WIFI_PSK>WPA passphrase for --wifi-ssid (8-63 characters, or 64 hex digits). Omit for an open network. Stored as plain text in the seed partition, like every seed key
--static-ip<ADDR/PREFIX[,GW[,DNS...]]>Static IPv4 (ADDRESS/PREFIX[,GATEWAY[,DNS...]]) for the connection the seed sets up: the Wi-Fi profile when --wifi-ssid is present, the wired interface otherwise — NetworkManager or dhcpcd, whichever the image carries. Omit for DHCP
--dry-runPrint what the seed would say without writing anything

try

Boot the built image under QEMU before it is flashed, and assert the userland works: systemd reaches multi-user with no failed unit, the generated password logs in, first-boot completes, the on-image selftest passes in userland mode — and a second boot of the same disk still does, the check no single-boot smoke test covers. Boots the suite’s generic kernel as a fixture; the shipped kernel and the board are not under test

argumentrequiredwhat it is
recipeyesRecipe whose built image to boot (e.g. turing-rk1/forky); run boot2deb build first
flagvaluewhat it does
--timeout<TIMEOUT> (default 900)Seconds one boot may take to reach a login prompt (and to settle after it). The default is sized for TCG emulation on a loaded host; with KVM a boot takes a fraction of it, and the timeout is a ceiling, not a wait
--keep-diskKeep the booted disk copy under the work dir after the run, for a post-mortem or to boot it by hand. Its account password was changed at first login; the run’s report prints the one now set
--refresh-fixtureDiscard the cached fixture kernel and harvest the suite’s current one — how a new point release of the generic kernel is picked up
--work-dir<WORK_DIR>Build scratch directory (default build/<recipe> under the config root) — where the disk copy and the fixture kernel live
--out-dir<OUT_DIR>Where the build’s artifacts were written, when not the default <work-dir>/artifacts
--keyring<KEYRING>Debian archive keyring for the fixture-kernel root’s bootstrap (default: the vendored debian-archive-keyring.gpg)

Support matrix

What each shipped recipe has been taken through, and against which pins. Every column but the last two is read from the recipe’s lock — the exact pins a build resolves — so this table cannot claim a combination that was never built.

StatusMeaning
validatedAn image built from this recipe booted on the hardware.
expectedDerived from a validated sibling, differing only along an axis not expected to change the outcome; never built, or built and never booted.
experimentalUnder active bring-up. It may not build.

The date is when the claim was last established: for validated, the day the image booted; otherwise the day the claim was last assessed. Re-pinning a lock under a validated claim is flagged by boot2deb update, because moving the pins retires the evidence the claim rested on.

A status says how far a build point has been taken, not that everything on the board works. What each one does not do is under Caveats below, and is printed at the end of a build of that recipe.

RecipeDeviceSuiteKernelPatchesU-bootModulesStatusAs of
asus-c100p/forkyasus-c100pforkydebian-armmp (from the suite)nonenonenoneexpected2026-07-20
asus-c100p/trixieasus-c100ptrixiedebian-armmp (from the suite)nonenonenoneexpected2026-07-20
asus-c201-libreboot/forkyasus-c201-librebootforkydebian-armmp (from the suite)nonenonenoneexpected2026-07-31
asus-c201-libreboot/libre-forkyasus-c201-librebootforkyrk3288-libre-7.2 sources/v7.2-gnurk3288-fixes main (ddc856cdd91e)nonenoneexpected2026-08-21
asus-c201-libreboot/mainline-forkyasus-c201-librebootforkyrk3288-mainline-7.2 v7.2rk3288-fixes main (ddc856cdd91e)nonenoneexpected2026-08-21
asus-c201/forkyasus-c201forkydebian-armmp (from the suite)nonenonenonevalidated2026-07-14
asus-c201/libre-forkyasus-c201forkyrk3288-libre-7.2 sources/v7.2-gnurk3288-fixes main (ddc856cdd91e)nonenoneexpected2026-08-21
asus-c201/mainline-forkyasus-c201forkyrk3288-mainline-7.2 v7.2rk3288-fixes main (ddc856cdd91e)nonenoneexpected2026-08-21
asus-c201/trixieasus-c201trixiedebian-armmp (from the suite)nonenonenoneexpected2026-07-20
asus-chromebit-cs10/forkyasus-chromebit-cs10forkydebian-armmp (from the suite)nonenonenoneexpected2026-07-20
asus-chromebit-cs10/trixieasus-chromebit-cs10trixiedebian-armmp (from the suite)nonenonenoneexpected2026-07-20
h96-max-m9/forkyh96-max-m9forkyrk3576-mainline-7.2 v7.2rk3576-fixes, rk3576-npu main (ddc856cdd91e)rk3576-display main (ddc856cdd91e)aic8800 main (df4c783b663e)expected2026-08-21
h96-max-m9/media-accelh96-max-m9forkyrk3576-mainline-7.2 v7.2rk3576-fixes, rk3576-npu, rk3576-rga main (ddc856cdd91e)rk3576-display main (ddc856cdd91e)aic8800 main (df4c783b663e)experimental2026-08-21
h96-max-m9/utilh96-max-m9(u-boot only)noneh96-max-m9-util main (ddc856cdd91e)noneexpected2026-07-22
rk3576-evb1-v10/forkyrk3576-evb1-v10forkyrk3576-mainline-7.2 v7.2rk3576-fixes main (ddc856cdd91e)rk3576-loader main (ddc856cdd91e)noneexpected2026-08-21
rk3576-generic/loaderrk3576-generic(u-boot only)nonerk3576-loader main (ddc856cdd91e)noneexpected2026-07-21
rk3576-generic/utilrk3576-generic(u-boot only)nonerk3576-util main (ddc856cdd91e)noneexpected2026-07-21
turing-rk1/forkyturing-rk1forkyrk3588-mainline-7.2 v7.2rk3588-accel main (ddc856cdd91e)turing-rk1-recovery main (ddc856cdd91e)noneexpected2026-08-21
turing-rk1/jellyfin-forkyturing-rk1forkyrk3588-mainline-7.2 v7.2rk3588-accel main (ddc856cdd91e)turing-rk1-recovery main (ddc856cdd91e)noneexperimental2026-08-21
turing-rk1/jellyfin-trixieturing-rk1trixierk3588-mainline-7.2 v7.2rk3588-accel main (ddc856cdd91e)turing-rk1-recovery main (ddc856cdd91e)noneexperimental2026-08-21
turing-rk1/media-accel-forkyturing-rk1forkyrk3588-mainline-7.2 v7.2rk3588-accel main (ddc856cdd91e)turing-rk1-recovery main (ddc856cdd91e)noneexpected2026-08-21
turing-rk1/media-accel-trixieturing-rk1trixierk3588-mainline-7.2 v7.2rk3588-accel main (ddc856cdd91e)turing-rk1-recovery main (ddc856cdd91e)noneexpected2026-08-21
turing-rk1/trixieturing-rk1trixierk3588-mainline-7.2 v7.2rk3588-accel main (ddc856cdd91e)turing-rk1-recovery main (ddc856cdd91e)noneexpected2026-08-21
turing-rk1/utilturing-rk1(u-boot only)noneturing-rk1-util main (ddc856cdd91e)noneexpected2026-08-05

Caveats

Limitations that hold whatever you build: they come from the silicon, the board, a capability the recipe selected, or the build point itself, and no rebuild lifts them. The first two are listed once per device, since they hold for every recipe on it; the rest are listed per recipe, and a (feature) tag marks the ones that follow their capability onto any other recipe composing it. A recipe with none listed is not a recipe with none — nothing mechanical establishes that — only one that states none.

Anything a running system could be asked about belongs in that board’s selftest expectations instead, where it fails rather than merely informs. These are the ones that cannot be checked from the running system.

asus-c100p

  • (SoC) HDMI tops out at 4K30 and cannot reach 4K60: the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path either.
  • (board) Two display controllers advertise the same maximum resolution and DRM decides at runtime which one the HDMI encoder lands on; the smaller (VOPL) tops out at 2560x1600. A 4K display showing only part of the picture is that, and dmesg | grep -i vop says which controller it got.

asus-c201-libreboot

  • (SoC) HDMI tops out at 4K30 and cannot reach 4K60: the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path either.
  • (board) Two display controllers advertise the same maximum resolution and DRM decides at runtime which one the HDMI encoder lands on; the smaller (VOPL) tops out at 2560x1600. A 4K display showing only part of the picture is that, and dmesg | grep -i vop says which controller it got.

asus-c201

  • (SoC) HDMI tops out at 4K30 and cannot reach 4K60: the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path either.
  • (board) Two display controllers advertise the same maximum resolution and DRM decides at runtime which one the HDMI encoder lands on; the smaller (VOPL) tops out at 2560x1600. A 4K display showing only part of the picture is that, and dmesg | grep -i vop says which controller it got.

asus-chromebit-cs10

  • (SoC) HDMI tops out at 4K30 and cannot reach 4K60: the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path either.

h96-max-m9

  • (SoC) HDMI tops out at 4K30 and cannot reach 4K60: the dw-hdmi-qp bridge has no SCDC/scrambling support and rejects every mode above 340 MHz TMDS, even where the display advertises 4K60. This is upstream behaviour, not a device-tree limitation.
  • (SoC) There is no mainline hardware video encoder for this SoC. Decode is driven (VDPU383, H.264 and HEVC, 1080p and 4K); encode is not driven at all.
  • (SoC) Hardware H.264 decode fails intermittently, per decode session, and when it fails the whole session is wrong. Most sessions are bit-exact against the software decoder; roughly one in three to one in six comes out visibly corrupt instead, diverging from the first frame onward at about 17 dB PSNR. It is all-or-nothing per session, not per frame, and re-running the same file usually succeeds. HEVC is unaffected and is bit-exact against software on every run. Until this is fixed, do not rely on hardware H.264 decode; the software decoder is correct and fast enough (about 160 fps at 1080p, 40 at 4K).
  • (SoC) The NPU computes but nothing can drive it yet: the rocket userspace stack targets RK3588 only, so no RK3576 userspace exists.
  • (board) Only the blue port beside HDMI carries USB 3.0. It reaches 5 Gbps and holds ~154 MB/s over sustained reads. The black ports never will – they sit behind an internal USB 2.0 hub, and their SuperSpeed lane reaches no connector.
  • (board) A drive in the blue port that enumerates at 480 Mb/s is usually not seated. With the board sitting back from the enclosure’s front panel a plug bottoms out on the case before the connector’s recessed SuperSpeed contacts mate, so USB 2.0 works perfectly and SuperSpeed is silent. Push the plug fully home, or reseat the board against the panel.
  • (board) There is no SD-card slot: it is depopulated on this box. eMMC and USB are the only storage.

rk3576-evb1-v10

  • (SoC) HDMI tops out at 4K30 and cannot reach 4K60: the dw-hdmi-qp bridge has no SCDC/scrambling support and rejects every mode above 340 MHz TMDS, even where the display advertises 4K60. This is upstream behaviour, not a device-tree limitation.
  • (SoC) There is no mainline hardware video encoder for this SoC. Decode is driven (VDPU383, H.264 and HEVC, 1080p and 4K); encode is not driven at all.
  • (SoC) Hardware H.264 decode fails intermittently, per decode session, and when it fails the whole session is wrong. Most sessions are bit-exact against the software decoder; roughly one in three to one in six comes out visibly corrupt instead, diverging from the first frame onward at about 17 dB PSNR. It is all-or-nothing per session, not per frame, and re-running the same file usually succeeds. HEVC is unaffected and is bit-exact against software on every run. Until this is fixed, do not rely on hardware H.264 decode; the software decoder is correct and fast enough (about 160 fps at 1080p, 40 at 4K).
  • (SoC) The NPU computes but nothing can drive it yet: the rocket userspace stack targets RK3588 only, so no RK3576 userspace exists.

rk3576-generic

  • (SoC) HDMI tops out at 4K30 and cannot reach 4K60: the dw-hdmi-qp bridge has no SCDC/scrambling support and rejects every mode above 340 MHz TMDS, even where the display advertises 4K60. This is upstream behaviour, not a device-tree limitation.
  • (SoC) There is no mainline hardware video encoder for this SoC. Decode is driven (VDPU383, H.264 and HEVC, 1080p and 4K); encode is not driven at all.
  • (SoC) Hardware H.264 decode fails intermittently, per decode session, and when it fails the whole session is wrong. Most sessions are bit-exact against the software decoder; roughly one in three to one in six comes out visibly corrupt instead, diverging from the first frame onward at about 17 dB PSNR. It is all-or-nothing per session, not per frame, and re-running the same file usually succeeds. HEVC is unaffected and is bit-exact against software on every run. Until this is fixed, do not rely on hardware H.264 decode; the software decoder is correct and fast enough (about 160 fps at 1080p, 40 at 4K).
  • (SoC) The NPU computes but nothing can drive it yet: the rocket userspace stack targets RK3588 only, so no RK3576 userspace exists.

asus-c201-libreboot/libre-forky

  • The internal BCM4354 Wi-Fi and Bluetooth do not work. linux-libre removes brcmfmac’s firmware request and btbcm’s patchram filename, and this radio is the one part of the board that cannot run without a blob. An AR9271 USB adapter (firmware-ath9k-htc, in Debian main and already installed) is the supported way onto a network.

asus-c201/libre-forky

  • The internal BCM4354 Wi-Fi and Bluetooth do not work. linux-libre removes brcmfmac’s firmware request and btbcm’s patchram filename, and this radio is the one part of the board that cannot run without a blob. An AR9271 USB adapter (firmware-ath9k-htc, in Debian main and already installed) is the supported way onto a network.

h96-max-m9/media-accel

  • (feature) 10-bit content decodes in hardware, but nothing downstream can use the result without a copy. The decoder emits NV12 for 8-bit and NV15 — 10-bit packed 4:2:0 — for 10-bit. Nothing in this image consumes NV15: Vulkan has no such format, Mesa and ffmpeg’s filters cannot import it, and the decoder cannot be asked for P010 instead because the hardware writes NV15 natively. A 10-bit transcode therefore converts on the CPU. RGA can convert NV15 to P010, but ffmpeg cannot reach RGA here (see the next caveat); a program speaking librga directly can. The display controller scans NV15 out unconverted, so playback to a KMS plane is unaffected.
  • (feature) ffmpeg has no RGA filters here. scale_rkrga and vpp_rkrga need --enable-rkmpp, and MPP needs the vendor mpp_service kernel framework that mainline does not have. ffmpeg scales on CPU or GPU; RGA is reached through librga by a program that speaks its API.
  • (feature) RGA colour conversion covers BT.601 limited and full range and BT.709, but not BT.2020.
  • (feature) Pass RGA its buffers as DMA-BUF file descriptors (importbuffer_fd). librga’s virtual-address import, which builds an IOMMU mapping per call over ordinary process memory, faults the RGA IOMMU on roughly a third of jobs: the job times out after a second, the core is soft-reset, and the destination is left untouched. The same operations over DMA-BUF run clean and about seven times faster.

turing-rk1/jellyfin-forky

  • (feature) Scaling inside a hardware transcode runs on the CPU. The RGA filters (scale_rkrga, vpp_rkrga, overlay_rkrga) accept only frames carrying an RKMPP hardware context, and the mainline V4L2 decoder hands out plain DRM PRIME frames, which they reject; hwmap does not bridge the two either. A decode/scale/encode chain therefore downloads frames to system memory, scales them with swscale, and re-uploads them to the encoder. RGA still accelerates a scale fed from software-decoded frames, and the 2D engine itself works.
  • (feature) The h264_rkmpp, hevc_rkmpp, vp8_rkmpp and vp9_rkmpp decoders are compiled in but fail to open: MPP finds no decode client on a mainline kernel, where rkvdec is a V4L2 stateless driver rather than an MPP service. Hardware decode is reached with -hwaccel v4l2request. The matching *_rkmpp encoders do work.
  • (feature) 10-bit content decodes in hardware, but a 10-bit transcode still converts on the CPU. The V4L2 decoder emits NV12 for 8-bit and NV15 — 10-bit packed 4:2:0 — for 10-bit, and no filter or encoder in this build imports NV15. The decoder cannot be asked for P010 instead; the hardware writes NV15 natively. RGA converts NV15 to P010, but only for a program that speaks librga directly, since the RGA filters reject these frames as described above.
  • (feature) Every ffmpeg-rk invocation writes mpp_platform: client N driver is not ready! to standard error, once per vendor service client MPP looks for and does not find on a mainline kernel. It is harmless and appears even for ffmpeg -version, but it is captured by anything that logs the process’s stderr.
  • Transcoding is hardware-encode only. The seeded /etc/jellyfin/encoding.xml selects the rkmpp acceleration type with an empty hardware-decoding codec list, so video is decoded and scaled on the CPU and encoded on the VEPU580. Re-enabling any codec under Playback > Transcoding > “Enable hardware decoding for” makes Jellyfin emit -hwaccel rkmpp, whose decoder cannot open on a mainline kernel, and those streams fail outright rather than falling back to software.
  • Jellyfin’s bundled FFmpeg is not installed — the recipe supplies /opt/ffmpeg-rk/bin/ffmpeg instead, which is the only build here that can reach the VEPU580. There is therefore no second encoder to fall back to: Jellyfin validates the path during startup and exits if the binary does not run, so pointing Dashboard > Playback > Transcoding > “FFmpeg path” at something invalid leaves the server failing to start rather than transcoding in software.

turing-rk1/jellyfin-trixie

  • (feature) Scaling inside a hardware transcode runs on the CPU. The RGA filters (scale_rkrga, vpp_rkrga, overlay_rkrga) accept only frames carrying an RKMPP hardware context, and the mainline V4L2 decoder hands out plain DRM PRIME frames, which they reject; hwmap does not bridge the two either. A decode/scale/encode chain therefore downloads frames to system memory, scales them with swscale, and re-uploads them to the encoder. RGA still accelerates a scale fed from software-decoded frames, and the 2D engine itself works.
  • (feature) The h264_rkmpp, hevc_rkmpp, vp8_rkmpp and vp9_rkmpp decoders are compiled in but fail to open: MPP finds no decode client on a mainline kernel, where rkvdec is a V4L2 stateless driver rather than an MPP service. Hardware decode is reached with -hwaccel v4l2request. The matching *_rkmpp encoders do work.
  • (feature) 10-bit content decodes in hardware, but a 10-bit transcode still converts on the CPU. The V4L2 decoder emits NV12 for 8-bit and NV15 — 10-bit packed 4:2:0 — for 10-bit, and no filter or encoder in this build imports NV15. The decoder cannot be asked for P010 instead; the hardware writes NV15 natively. RGA converts NV15 to P010, but only for a program that speaks librga directly, since the RGA filters reject these frames as described above.
  • (feature) Every ffmpeg-rk invocation writes mpp_platform: client N driver is not ready! to standard error, once per vendor service client MPP looks for and does not find on a mainline kernel. It is harmless and appears even for ffmpeg -version, but it is captured by anything that logs the process’s stderr.
  • Transcoding is hardware-encode only. The seeded /etc/jellyfin/encoding.xml selects the rkmpp acceleration type with an empty hardware-decoding codec list, so video is decoded and scaled on the CPU and encoded on the VEPU580. Re-enabling any codec under Playback > Transcoding > “Enable hardware decoding for” makes Jellyfin emit -hwaccel rkmpp, whose decoder cannot open on a mainline kernel, and those streams fail outright rather than falling back to software.
  • Jellyfin’s bundled FFmpeg is not installed — the recipe supplies /opt/ffmpeg-rk/bin/ffmpeg instead, which is the only build here that can reach the VEPU580. There is therefore no second encoder to fall back to: Jellyfin validates the path during startup and exits if the binary does not run, so pointing Dashboard > Playback > Transcoding > “FFmpeg path” at something invalid leaves the server failing to start rather than transcoding in software.

turing-rk1/media-accel-forky

  • (feature) Scaling inside a hardware transcode runs on the CPU. The RGA filters (scale_rkrga, vpp_rkrga, overlay_rkrga) accept only frames carrying an RKMPP hardware context, and the mainline V4L2 decoder hands out plain DRM PRIME frames, which they reject; hwmap does not bridge the two either. A decode/scale/encode chain therefore downloads frames to system memory, scales them with swscale, and re-uploads them to the encoder. RGA still accelerates a scale fed from software-decoded frames, and the 2D engine itself works.
  • (feature) The h264_rkmpp, hevc_rkmpp, vp8_rkmpp and vp9_rkmpp decoders are compiled in but fail to open: MPP finds no decode client on a mainline kernel, where rkvdec is a V4L2 stateless driver rather than an MPP service. Hardware decode is reached with -hwaccel v4l2request. The matching *_rkmpp encoders do work.
  • (feature) 10-bit content decodes in hardware, but a 10-bit transcode still converts on the CPU. The V4L2 decoder emits NV12 for 8-bit and NV15 — 10-bit packed 4:2:0 — for 10-bit, and no filter or encoder in this build imports NV15. The decoder cannot be asked for P010 instead; the hardware writes NV15 natively. RGA converts NV15 to P010, but only for a program that speaks librga directly, since the RGA filters reject these frames as described above.
  • (feature) Every ffmpeg-rk invocation writes mpp_platform: client N driver is not ready! to standard error, once per vendor service client MPP looks for and does not find on a mainline kernel. It is harmless and appears even for ffmpeg -version, but it is captured by anything that logs the process’s stderr.
  • (feature) This feature adds 25 packages and roughly 305 MiB to the installed system, measured against the RK3588 media-accel package set on forky. Two thirds of it is not the driver: mesa-vulkan-drivers is 144 MiB, and it depends on libllvm21 (127 MiB) and through it libz3-4 (25 MiB), which are there for the lvp software rasterizer Debian ships in the same package rather than for PanVk. Debian offers no per-driver package — that one carries the AMD, Intel, NVIDIA, virtio and software back ends as well as the Mali one this hardware uses — so an image that wants PanVk takes all of them and the LLVM they imply.
  • (feature) Vulkan conformance is a per-GPU fact, not a per-feature one. PanVk is conformant on the RK3588’s Mali-G610 and is not conformant on the RK3576’s Mali-G52; the same package installs on both and only the first carries a conformance claim.
  • (feature) A software Vulkan device (lvp, llvmpipe) enumerates alongside the hardware one. Anything that leaves device selection to the default can bind it and run GPU work on the CPU, which is slower than not using this feature at all and does not announce itself.

turing-rk1/media-accel-trixie

  • (feature) Scaling inside a hardware transcode runs on the CPU. The RGA filters (scale_rkrga, vpp_rkrga, overlay_rkrga) accept only frames carrying an RKMPP hardware context, and the mainline V4L2 decoder hands out plain DRM PRIME frames, which they reject; hwmap does not bridge the two either. A decode/scale/encode chain therefore downloads frames to system memory, scales them with swscale, and re-uploads them to the encoder. RGA still accelerates a scale fed from software-decoded frames, and the 2D engine itself works.
  • (feature) The h264_rkmpp, hevc_rkmpp, vp8_rkmpp and vp9_rkmpp decoders are compiled in but fail to open: MPP finds no decode client on a mainline kernel, where rkvdec is a V4L2 stateless driver rather than an MPP service. Hardware decode is reached with -hwaccel v4l2request. The matching *_rkmpp encoders do work.
  • (feature) 10-bit content decodes in hardware, but a 10-bit transcode still converts on the CPU. The V4L2 decoder emits NV12 for 8-bit and NV15 — 10-bit packed 4:2:0 — for 10-bit, and no filter or encoder in this build imports NV15. The decoder cannot be asked for P010 instead; the hardware writes NV15 natively. RGA converts NV15 to P010, but only for a program that speaks librga directly, since the RGA filters reject these frames as described above.
  • (feature) Every ffmpeg-rk invocation writes mpp_platform: client N driver is not ready! to standard error, once per vendor service client MPP looks for and does not find on a mainline kernel. It is harmless and appears even for ffmpeg -version, but it is captured by anything that logs the process’s stderr.
  • (feature) This feature adds 25 packages and roughly 305 MiB to the installed system, measured against the RK3588 media-accel package set on forky. Two thirds of it is not the driver: mesa-vulkan-drivers is 144 MiB, and it depends on libllvm21 (127 MiB) and through it libz3-4 (25 MiB), which are there for the lvp software rasterizer Debian ships in the same package rather than for PanVk. Debian offers no per-driver package — that one carries the AMD, Intel, NVIDIA, virtio and software back ends as well as the Mali one this hardware uses — so an image that wants PanVk takes all of them and the LLVM they imply.
  • (feature) Vulkan conformance is a per-GPU fact, not a per-feature one. PanVk is conformant on the RK3588’s Mali-G610 and is not conformant on the RK3576’s Mali-G52; the same package installs on both and only the first carries a conformance claim.
  • (feature) A software Vulkan device (lvp, llvmpipe) enumerates alongside the hardware one. Anything that leaves device selection to the default can bind it and run GPU work on the CPU, which is slower than not using this feature at all and does not announce itself.

The on-image self-test

Every image carries boot2deb-selftest, a small POSIX-sh program that compares what the image claims to be against what is actually running, and exits non-zero on any disagreement. It exists for one class of failure: the board that boots, logs in, and is quietly missing something — a GPU whose firmware file moved, a /boot with no initrd, a sound card whose codec never probed, a PREEMPT_RT kernel nobody asked for. None of these announces itself; each has cost a debugging session that started from the symptom instead of the cause.

# On the board. Root is needed to read dmesg and the initramfs.
sudo boot2deb-selftest
turing-rk1 / forky / rk3588-mainline-7.2

identity
  ok      kernel-release    7.2.0
  ok      kernel-flavor     arm64
  ok      dtb               rockchip/rk3588-turing-rk1.dtb
  ok      single-kernel     7.2.0-1-arm64
soc-rk3588
  ok      firmware          arm/mali/arch10.8/mali_csffw.bin
  FAILED  driver-bound      fb000000.gpu panthor  (no /sys/bus/*/drivers/panthor/fb000000.gpu)
  ...

12 ok, 1 failed. Failures are what this image was expected to have and does not.

The exit code makes it composable: 0 when nothing failed, 1 on any failure, 2 when no checks are installed at all. It can be the last thing a bring-up session runs, a step in a serial-console validation script, or a once-per-boot journal entry (see Running it every boot).

Where the checks come from

The checks are not written on the device and the runner parses no TOML. Each config layer may declare an [[expect]] array — the SoC, the boot method, the device, the kernel definition, a feature, a kmod — and the build flattens each layer’s entries into its own file under /etc/boot2deb/selftest.d/:

identity.checks                 derived from the build itself (see below)
soc-rk3588.checks               socs/rk3588.toml [[expect]]
boot-method-rockchip-rkbin.checks
device-turing-rk1.checks
kernel-rk3588-mainline-7.2.checks
feature-media-accel-rockchip.checks
kmod-aic8800.checks

One file per layer is deliberate: a failing check names the layer whose contract it is, which is where the fix — or the stale expectation — lives. Two layers declaring the same check run it twice; each file states its own layer’s contract, and de-duplicating across them would let one layer’s edit silently change another’s file.

identity.checks is generated, never authored. It restates what the lock and the resolved device already own — the pinned kernel version, the uname -r flavor suffix, the board’s DTB — because a layer restating any of those could drift from the pin. It also carries single-kernel, which restates nothing and belongs to no layer: it is a claim about how every boot2deb image is built, and only the build is in a position to make it.

Each line is one check: the kind, then its argument text to the end of the line. Blank lines and full-line # comments are skipped. There is no quoting and no escaping; the build validates at config load that no argument needs any.

The check kinds

KindPasses whenExample
filethe absolute path (globs allowed) matches somethingfile /boot/initrd.img-*
dtbthe blob is installed in any layout the shipped kernels usedtb rockchip/rk3588-turing-rk1.dtb
firmwarethe path exists under /lib/firmware (compressed spellings included)firmware arm/mali/arch10.8/mali_csffw.bin
initramfs-modulethe module is built into the installed kernel or present in its initramfsinitramfs-module dw_mmc-rockchip
driver-bound/sys/bus/*/drivers/<driver>/<device> existsdriver-bound fb000000.gpu panthor
devnodethe node exists under /dev (globs allowed)devnode /dev/dri/renderD128
sound-cardthe name appears in /proc/asound/cardssound-card H96 Analog
no-dmesg-matchthe POSIX ERE does not match the kernel logno-dmesg-match SError|Synchronous External Abort
kernel-releaseuname -r starts with the pinned version (generated only)kernel-release 7.2.0
kernel-flavoruname -r ends in the flavor and is not its -rt- variant (generated only)kernel-flavor arm64
single-kernel/boot holds exactly one kernel and its module tree is that kernel’s (generated only; takes no argument)single-kernel

A check kind the runner does not know is reported skipped, never failed — an image built by an older boot2deb than the config tree that later grew a new kind must not fail for it. Skips are loud in the output for the same reason a silent skip is banned everywhere else: a check that quietly stops running looks exactly like a check that passes.

kernel-flavor earns its two lines of logic: 7.2.0-1-rt-arm64 also ends in -arm64, so the runner refuses the -rt- spelling first. That is the check that catches an accidentally-RT kernel, which boots fine and quietly changes scheduling behaviour.

single-kernel is the one check with no argument, because there is nothing to parameterize: an image installs one solved package plan with one linux-image in it and never dist-upgrades mid-build, so a second version on /boot means a feature or a --deb addition pulled one in. It checks the module tree too — one kernel whose /usr/lib/modules is for a different version is the shape a half-swapped kernel leaves, and a bare count would call that healthy. A failure names the versions it found and stops there: losing a kernel is worse than shipping two, so nothing is swept.

It is an as-built invariant, and that is worth knowing before you meet it on a long-lived board. A system that has since installed a second kernel — a distro-package build that took an apt kernel upgrade, which keeps the old one on purpose — genuinely differs from the image it was flashed from, and the check says so. That is the report doing its job rather than a false alarm; where you want two kernels on purpose, delete the line from identity.checks.

Authoring an expectation

When a board teaches you something — a firmware path its driver demands, a device node that proves a subsystem came up, a dmesg signature of a failure you never want to meet twice — write it down where the knowledge belongs, as the thing that failed would have been caught:

# In the layer that owns the fact: socs/<soc>.toml, devices/<name>.toml,
# kernels/<id>.toml, features/<name>.toml, kmods/<name>.toml, or a
# boot-methods/*.toml.
[[expect]]
check  = "driver-bound"
device = "fb000000.gpu"
driver = "panthor"

Placement follows the same rule as caveats: the SoC layer for what every board on the part has, the device for what one board wires up, the kernel definition for what its patches and fragments deliver, a feature for the capability’s own proof, a kmod for the driver’s runtime contract. Two placements deserve calling out:

  • Firmware for a blob-loading driver goes on the kernel definitions that can load it, not the SoC. A libre kernel drops non-free firmware by design, so a SoC-wide blob check would fail a correctly built libre image.
  • Boot artifacts go on the boot method. /boot/extlinux/extlinux.conf exists on a rockchip-rkbin board and never on a depthcharge one, whose kernel lives in a signed GPT partition no file check can see.

An unknown kind, a missing argument, or an argument that belongs to a different kind fails at config load, naming the field — a typo is caught by resolve (or any other command), not on the board. no-dmesg-match patterns deserve care in the other direction: author them narrowly, because a pattern that matches a benign line makes every run red and teaches people to ignore the tool.

The caveat rule in the config model is the flip side of this page: a limitation that is mechanically checkable belongs here, where it fails, and only what cannot be checked from the running system belongs in a caveat.

Running it every boot

# In the recipe: run the selftest once per boot, after multi-user.target,
# logging to the journal. Off by default.
selftest_on_boot = true

Every image ships the boot2deb-selftest.service unit disabled; the flag adds the enable symlink. It is meant for boards validated over a serial console, where nobody is logged in to run the check by hand — the failed unit in systemctl --failed and the journal entry are the announcement:

# Read the last boot's result.
journalctl -u boot2deb-selftest -b

The unit carries ConditionVirtualization=no: the hardware checks describe the board, and under emulation the boot2deb try harness runs the selftest itself in the mode built for that.

Running it against a node you are not sitting at

Over the network, it is one line — the check runs on the board, where the hardware half of it means something:

ssh operator@rk1-03 'sudo boot2deb-selftest'

That is the form to reach for in a validation script across a cluster: the exit code is the result, and the output names the layer behind any failure.

A node with no network yet — one being brought up, or one whose networking is exactly what you are checking — has only its serial console, and that is a person at a console or a tool that drives one. boot2deb does not drive board consoles: it produces images and knows what they should contain, and reaching out to operate hardware is the same boundary that keeps it from writing devices. What it gives that tooling is this runner and its exit code, already on the image; selftest_on_boot = true above is the other half, since a node that checks itself every boot needs nothing driven at all.

Inspecting an image from outside

The runner takes --root so a mounted image can be checked without booting it, and --mode userland so the hardware checks report n/a instead of failing on a machine that is not the board:

# A mounted (or extracted) rootfs on any machine: check disk content only.
boot2deb-selftest --root /mnt/image --mode userland

In userland mode the kernel checks read /boot instead of uname -r — the running kernel is whatever machine or emulator this is, and the question becomes “does the image carry the kernel it pins”, which is still answerable. This is exactly how boot2deb try runs the selftest inside a QEMU-booted guest, where the running kernel is a fixture and the board hardware is absent.

Trying an image before flashing

boot2deb try boots the built image under QEMU and asserts that the userland works — before a card is written, a board is opened, or a serial console is wired. It is the answer to the failure class that survives a clean build and a clean flash: the journald that SIGILLs on its first message, the first-boot hook that shipped without its executable bit and bricks the second boot, the account whose generated password does not actually log in.

# Boot the built image twice under QEMU and assert the userland works.
boot2deb try turing-rk1/forky

# Keep the booted disk for a post-mortem, and allow a slower first boot.
boot2deb try turing-rk1/forky --keep-disk --timeout 1800
try turing-rk1/forky: PASS
  first boot   running, first-boot completed, selftest: 11 ok, 4 not applicable.
  second boot  running, first-boot did not re-run, selftest: 11 ok, 4 not applicable.

What it asserts

One run is two boots of the same disk, and each boot must pass all of:

  1. systemd settles as running — no unit in systemctl --failed.
  2. The image’s account logs in on the serial console with the generated first-boot password from the provenance manifest. The first login walks the forced password change the image ships with; that the whole conversation works is the account assertion, not a side effect of it.
  3. first-boot ran to completion and wrote its stamp — and, on the second boot, did not run again. The second boot is the whole reason try exists as more than a smoke test: a brick-on-second-boot image passes every single-boot check ever written.
  4. The on-image self-test passes in userland mode: the disk-content half of the board’s expectations (kernel on /boot, firmware files, initramfs modules) checked inside the guest, with the hardware checks reported not-applicable rather than failed.

try exits non-zero on the first assertion that fails, with the guest’s console tail in the error.

What it deliberately does not test

The board. The guest machine is -M virt: no RK3588, no panel, no codec — the hardware half of the selftest runs on the device, not here. And the boot path: QEMU loads the kernel directly, so u-boot, extlinux, and the depthcharge signing are exercised on hardware only. try replaces a flash-plus-serial-console cycle for userland faults; it replaces nothing else.

The shipped kernel is not booted either. It is configured from the board’s fragments and has no reason to carry virtio drivers — adding them would change the shipped kernel to serve the test, which is backwards. The guest boots the suite’s own generic kernel (linux-image-arm64 / linux-image-armmp), fetched and installed through the same pinned, sandboxed machinery as every package stage, its initramfs built by initramfs-tools inside a target-arch root. The kernel is a fixture; the userland is what is under test. For a distro-package kernel build the two coincide, and try says so. The pair is cached under the recipe’s work dir; --refresh-fixture re-harvests it when the suite’s kernel moves.

Mechanics worth knowing

  • The image artifact is never booted directly: the run decompresses a copy under <work-dir>/try/ and boots that. --keep-disk keeps it afterwards — note the forced first-login change means the kept disk’s password is no longer the generated one; the run’s report prints the one now set.
  • The run needs the build’s artifacts and its provenance manifest (for the password), so it follows boot2deb build — the build’s closing hint says so.
  • systemd-modules-load.service is masked on the guest’s kernel command line: the image force-loads board-kernel modules the fixture kernel cannot have, and that is a fact about this boot, not about the image.
  • Runtime is minutes under TCG emulation on an x86 host — this replaces a flash-and-serial cycle, not a unit test. On a matching host with /dev/kvm (an arm64 box building arm64 images), KVM makes it fast.
  • qemu-system-aarch64 / qemu-system-arm is the one host tool involved, and it is optional: boot2deb doctor <recipe> lists it as such, with the package name for the host distro.

RK3576 u-boot images

RK3576 boards build one of three u-boot images from mainline u-boot on the rk3576-generic control DTB. They share the same SoC bring-up — the clock and timer fixes, the inno-usb2 PHY reset, the VOP quiesce at OS handoff — and differ in how far each goes beyond booting:

ImageRole
loaderFlash and dump a board from a laptop.
displayThe u-boot an image ships with. Boots, and recovers without a serial cable.
utilA recovery and bring-up tool: a boot menu, diagnostics, and image verification at the prompt.

The u-boot variant is its own axis: a recipe selects one with uboot_series, independent of the kernel. A loader/util tool is a u-boot-only deliverable (deliverable = "uboot") that names no suite or kernel; display is the u-boot a full image recipe ships with.

Generic vs board-specific

Most of the RK3576 u-boot series is SoC-generic — it patches only the rk3576-generic control DTB and defconfig, so the payload is identical on any RK3576 board. Those images are therefore homed on the rk3576-generic tool host (not a board):

ImageRecipeu-boot seriesScope
loaderrk3576-generic/loaderrk3576-loaderSoC-generic
utilrk3576-generic/utilrk3576-utilSoC-generic

A board contributes its own recipes where it needs something board-specific:

ImageRecipeu-boot seriesScope
display (shipped image)h96-max-m9/forkyrk3576-displayboard image
util + etherneth96-max-m9/utilh96-max-m9-utilboard-specific

h96-max-m9/util is the SoC-generic util plus the H96’s GMAC0 RGMII ethernet, so a rescue session can pull an image over the network (dhcp/tftp) and ping. The ethernet is board-specific — an RTL8211F at MDIO address 1, PHY reset on gpio2 PB3, tx_delay 0x1b, rgmii-rxid — so it ships in a board series (h96-max-m9-util) that layers one board patch on the generic util series, leaving the SoC-generic images ethernet-free. The board patch adds the nodes to the shared rk3576-generic control DTB; a board wanting its own control DTB would carry the ethernet there instead.

USB port roles

RK3576 exposes two USB controllers, and u-boot has no runtime OTG role switch on them, so each build fixes their roles at build time:

  • drd0 (the USB 3.0 OTG port) is a device. It is the rockusb/ums gadget and the port the BootROM download cable uses — a laptop connects here.
  • drd1 (the USB 2.0 host port) is a host, for a keyboard and a USB stick.

A single build is therefore both a device (to a laptop, on drd0) and a host (for peripherals, on drd1) at once, one role per connector. A USB hub on the drd1 port carries a keyboard and a bootable stick together — u-boot enumerates hubs during usb start and boots from a device behind one.

The images with a USB keyboard (display and util) run usb start automatically before the prompt (USE_PREBOOT, whose default command is usb start once USB_KEYBOARD is set), so the keyboard is live at the prompt with nothing typed over serial — which is what lets these images be driven with no UART at all. This enumerates only the drd1 host; drd0 stays the gadget, so ums and reboot loader→rockusb are unaffected.

The loader image is the exception: it brings up no USB host and no display, so drd0 is its only USB role.

Capabilities

loaderdisplayutil
Deliverymaskrom RAMeMMC (shipped)maskrom RAM / flashable
Autoboots10 s, interruptible— (drops to the prompt)
Serial consoleyesyesyes
HDMI console + USB keyboardyesyes
ums (export a block device to a laptop)yesyesyes
rockusb via reboot loaderyesyesyes
SARADC download key → BootROMyesyesyes
maskrom USB boot images (usb471/472)yesyesyes
Boot a USB rescue stick (bootflow scan, extlinux)yesyesyes
md / mw (memory peek/poke)yesyesyes
bootmenu (interactive boot menu)yes
clk (dump the clock tree)yes
memtest (DRAM walk)yes
md5sum / sha1sum (verify an image)yes
smc / cache commands (developer)yes
ethernet (dhcp/tftp/ping)board util only

The SoC-generic images bring up no networking; recovery runs over USB and maskrom. A board’s own util recipe can add ethernet — h96-max-m9/util does, and it is validated on the H96 (DHCP binds a lease and ping replies).

Building

Each image’s u-boot is produced by staging just the bootloader:

boot2deb build <recipe> --stage uboot

For the maskrom-delivered images (loader and util), the deliverable is the <point>-u-boot-rockchip-usb471.bin / usb472.bin pair (and the packed <point>-u-boot-rockchip-maskrom.bin) — stream them into RAM with the BootROM download protocol and run u-boot with nothing written to storage. See The maskrom loader.

For the display image, the u-boot is written to the raw gap ahead of the rootfs as part of a full image build, and also emitted on its own by --stage uboot for reflashing.

Choosing an image

  • display ships on the board. A user whose OS will not boot reaches the u-boot prompt on the television with a USB keyboard, boots a rescue stick, or — with a laptop on drd0 — runs ums to image the eMMC or reboot loader to re-flash over rockusb.
  • util is the same hardware support with a boot menu and the diagnostic and verification commands, and it never autoboots. It is normally streamed into RAM over maskrom for a recovery or bring-up session rather than installed. The SoC-generic rk3576-generic/util is the board-neutral tool; a board that has ethernet wired gets a board util recipe (h96-max-m9/util) that adds it, so a rescue session can also dhcp and pull a rescue image over tftp.
  • loader is the minimal laptop-driven path: no host and no display, just rockusb and ums on drd0. It is the smallest image that can flash or dump a board.

Overlays

An overlay is an out-of-tree directory of config layers that boot2deb merges on top of the shipped tree. It is how you keep your devices, recipes, and retunings in your own repo — versioned, private, and never a fork of the vendored config. Pass one (or several) with the global --overlay <dir> flag, on any command:

boot2deb --overlay ~/my-boards build my-tablet/forky

Each --overlay must name an existing directory. An empty or mistyped path is a resolve-time error rather than a silent no-op: an empty one would resolve every asset against the current directory, and a typo would shadow nothing at all — either way the build would quietly use a config tree you did not intend, which is exactly what an overlay exists to make explicit.

An overlay has the same directory layout as the shipped root — any subset of devices/, socs/, arches/, boot-methods/, kernels/, features/, recipes/, plus fragments/, blobs/, and per-layer overlay/ trees. You ship only the files you add or change; everything else resolves from the shipped tree underneath. Because an overlay is just a second config search root, everything the CLI does — resolve, doctor, verify-*, build, and the list-* commands — sees the merged tree.

What an overlay can do

  • Retune one value. An overlay devices/turing-rk1.toml holding only image_size = "8G" changes that one field — every other key merges from the shipped file (see merge semantics).
  • Add to a list. Add a supported_kernel, an extra rootfs package, another [[apt_sources]] — by restating the array with your addition (arrays are replaced wholesale, not concatenated).
  • Add a whole target. Drop in a new devices/my-tablet.toml, socs/…, kernels/…, features/…, or recipes/…; it lists and builds alongside the shipped ones, since list-devices, list-recipes, and friends union the overlay’s targets in.

How overlays merge

The search path is the shipped root first, then each --overlay in the order given; later wins, and any overlay wins over the shipped root. When the same layer file (e.g. devices/turing-rk1.toml) exists in more than one root, the copies are deep-merged:

  • Tables merge key-by-key, recursing into nested tables — so setting one field leaves its siblings intact.
  • Scalars and arrays are replaced wholesale — an overlay array sets the value, it does not append. To add one entry to a shipped list, restate the list with your entry included.

A layer file present only in an overlay simply adds a new target (nothing to merge). Fragments, blobs, and per-feature/-layer rootfs trees (overlay/, overlay-pre/ and overlay-nonfree/) resolve along the same path: a same-named asset in an overlay shadows the shipped one, while rootfs trees present in both roots stack (shipped first, overlay last).

The three rootfs trees

A layer can carry up to three trees, which differ in when they are laid in and whether they are:

TreeLaid inCarried by
overlay-pre/before any package installs, so a maintainer script sees the config while it runsany layer
overlay/after every package, so it wins over whatever a package shippedany layer
overlay-nonfree/with overlay/, and only when the build is not librea SoC or device layer

overlay-nonfree/ is where a hardware layer vendors nonfree firmware Debian does not package — on the RK3288 that is the two Broadcom blobs in socs/rk3288/overlay-nonfree/. It stacks directly after its own layer’s overlay/, so a board’s own file still wins over what it extends. It is a separate tree rather than a subtraction from overlay/ so that every blob a layer ships sits in one directory you can audit, and a libre build skipping it cannot miss one by spelling a path wrong.

File modes in a rootfs overlay/ tree

A per-layer overlay/, overlay-pre/ or overlay-nonfree/ tree is copied into the image, and its modes are normalized to git’s own file model on the way in: directories become 0755, and a file becomes 0755 if its executable bit is set and 0644 otherwise. Symlinks are untouched.

That is the identity function on everything a git tree can express — git records exactly 100644 and 100755, and no directory mode at all — and it discards the one thing it cannot: your umask at checkout time. Set the executable bit on a hook and it stays executable in the image; everything else about the mode is not yours to choose here.

If a file needs a mode outside those two — a 0440 sudoers drop-in, a 0700 home — an overlay tree is the wrong place for it, because a git checkout cannot carry that mode to begin with. Ship it from a first-boot hook, which runs as root on the board.

Locks land in the owning overlay

update writes a recipe’s lock, and build --save-manifest writes its solved manifest, into the root that owns the recipe — so an overlay recipe’s lock and manifest land in that overlay, beside the recipe, not in the shipped tree. An out-of-tree recipe stays fully self-contained: recipe, lock, and manifest are all versioned together in your repo.

The keyring is a fixed trust anchor

One asset an overlay may not silently replace: the Debian archive keyring (blobs/keyrings/debian-archive-keyring.gpg). It is the trust root for the rootfs bootstrap, so an overlay that ships its own copy is refused with a fail-closed error rather than trusted — an overlay must not be able to swap the bootstrap’s trust anchor. If you genuinely intend to use the overlay’s keyring, opt in explicitly with build --unsafe-overlay-keyring. Every other asset follows the normal highest-precedence-wins rule; only this trust anchor is pinned to the shipped root.

Overlay or in-tree edit?

Two paths, chosen by intent:

  • Overlay — you are bringing up your own board, or tuning a build for yourself. Keep it out-of-tree with --overlay; there is nothing to upstream and nothing to fork.
  • In-tree edit — you are contributing a board back to boot2deb. Edit the vendored tree directly and open a pull request.

Adding a board walks through the layers to write and applies to both paths — the only difference is whether the files land in your overlay or in the vendored tree.

Image identity

Every image boot2deb builds carries /etc/boot2deb/image.toml — a small TOML document in which the image says what it is.

It exists for readers that are not the running system. A tool repairing a board that will not boot is looking at the disk from somewhere else: from a USB stick, or from a laptop with the eMMC dumped to a file, quite possibly without mounting the filesystem at all. Such a reader can work out a great deal from the disk itself — the partition table, the boot scheme, the kernel in the signed slot. This file is for the part it cannot.

What it looks like

A depthcharge board:

version = 1

[image]
device = "asus-c201"
description = "ASUS Chromebook C201 (RK3288, google,veyron-speedy)"
arch = "armv7"
soc = "rk3288"
boot_method = "depthcharge"
board = "speedy"
suite = "forky"
features = []
layout = "combined"
hostname = "asus-c201"

[kernel]
id = "debian-armmp"
flavor = "distro-package"
package = "linux-image-armmp"

A rockchip-rkbin board with a compiled kernel — no board, and the kernel is a git pin rather than a package:

[image]
device = "turing-rk1"
boot_method = "rockchip-rkbin"
...

[kernel]
id = "rk3588-mainline-7.2"
flavor = "mainline"
reference = "v7.2"
commit = "8d3ae59288f1e7d58d76558a6ee96d533bc5019f"
patch_series = "rk3588-accel"

board is the reason the file exists

Everything else here is a cross-check: a reader can already infer the device, the boot method, and the architecture from the disk, and comparing what it inferred against what the image claims is worth doing — a disagreement is itself a finding.

board is different. It is the depthcharge board profile the kernel partition was signed for, and it is not recoverable from the image. depthchargectl normally works it out by reading the running board’s hardware ID and device-tree compatibles, which is exactly what a tool running somewhere else cannot do. Re-signing a C201’s kernel from a laptop means passing --board speedy, and this file is how that laptop knows to.

It also distinguishes firmware, not just hardware: a stock C201 and a libreboot’d one are the same board and take different profiles — which is why they are separate build points (asus-c201/forky and asus-c201-libreboot/forky) rather than one image with a flag.

The field is absent under a boot method that has no board profile, rather than being an empty string a reader would have to special-case.

kernel.flavor decides how a kernel upgrade arrives

distro-package means the kernel comes from the Debian mirror and an upgrade is apt upgrade. mainline or vendor means boot2deb compiled it, nothing will ever offer it to the board, and a new one is a .deb somebody has to hand it. A tool that intends to put a kernel on this system needs to know which.

layout matters on a split image

Under layout = "split" the boot payload and the root filesystem live on different media — u-boot on the eMMC, the OS on NVMe. A reader that finds this rootfs with no bootloader beside it is looking at an expected state, not a fault.

pressed marks a derived image

An image press re-assembled with tree additions carries one extra table:

[pressed]
source = "turing-rk1-forky"
copies = ["/etc/myapp/site.conf"]
debs = ["myapp_1.2_arm64.deb"]
embedded_image = "turing-rk1-forky.img.xz"

Its absence is the claim: no [pressed] table means this filesystem is the recipe’s canonical artifact, byte for byte. Where it is present, source names the artifact stem the file derives from and the remaining keys list what was added, by kind and destination — never by content. reproduce reproduces builds, not pressings. The seed partition is deliberately not summarized here: it is self-describing, and boot2deb seed can rewrite it later without touching the filesystem, so a copy of it in this file would go stale. See Producing images.

It carries no secrets

boot2deb also emits a provenance manifest (a .provenance.toml) beside the image, which records every source pin, the toolchain, the solved package manifest’s digest, and the image’s initial first-boot password. That document stays with the build. It never ships inside an image, and image.toml is a deliberately chosen subset of it with the credential — and everything else an image has no business carrying — left out.

Two values that are in the manifest cannot be in image.toml even in principle: the solved-manifest digest and the package count are produced by the rootfs bootstrap, so they are not yet known at the moment the file is written into the rootfs they would describe.

Compatibility

version is the schema version, and this is a wire format: it is parsed by programs versioned independently of boot2deb. A reader must check it, and must tolerate fields it does not recognise. Adding an optional field does not bump the version; changing what a field means, or removing one, does.

The file is written as part of the generated config, alongside /etc/boot2deb/board.conf, so it folds into the rootfs cache key like every other generated file — a cached rootfs can never be reused under an identity that disagrees with it.

The maskrom loader

Some boards’ u-boot builds emit a maskrom loader — a single file you stream to a Rockchip SoC over USB to run that u-boot from RAM, writing nothing to storage.

It exists for the case where the board’s own bootloader cannot help you. A Rockchip SoC whose eMMC carries no bootloader, or a broken one, still enters the BootROM’s download mode, and the BootROM will accept a bootloader over USB and jump to it. That is the floor beneath every other recovery path: it does not need the board to boot, and it does not need the case open.

When you get one

The artifact appears when the board’s u-boot build enables CONFIG_ROCKCHIP_MASKROM_IMAGE. On a build that produces it, three files are staged beside the usual <point>-idbloader.img and <point>-u-boot.itb. <point> is the build point with its / flattened — rk3576-generic/utilrk3576-generic-util — like every other artifact a build publishes:

ArtifactWhat it is
<point>-u-boot-rockchip-usb471.binCODE471 — the DDR init blob (TPL), which the BootROM runs first to bring RAM up
<point>-u-boot-rockchip-usb472.binCODE472 — SPL plus the FIT, which runs once there is RAM to run it in
<point>-u-boot-rockchip-maskrom.binthe two above packed into one RKBOOT container

The split matters because the two host tools want different things. Tools that speak the download protocol directly take the raw pair, in order. rkdeveloptool db takes the single packed container. Both are staged so you do not have to convert between them.

boot2deb build <recipe> --stage uboot

Using it

Put the board in maskrom mode — how differs per board; see that board’s page — and confirm the host sees it:

lsusb | grep 2207          # 2207 is Rockchip's vendor ID
rkdeveloptool ld           # lists devices and their mode

Then stream the loader and let it run:

rkdeveloptool db rk3576-generic-util-u-boot-rockchip-maskrom.bin

The board now runs that u-boot out of RAM. Nothing has been written, so a power cycle returns it to exactly the state it was in — which is what makes this safe to try on a board you have not diagnosed yet.

Why boot2deb packs it itself

Rockchip’s own boot_merger builds this container, but it ships as a closed binary in the vendor rkbin repository. boot2deb writes the format directly instead, in pure Rust, so producing a loader needs no vendor tooling and the output is deterministic — the same inputs give the same bytes, which is what lets the artifact cache treat it like any other build output.

The container holds only the two download sections. A full vendor loader also carries a LOADER section — the blobs a host writes to storage — which db never downloads, and which is the only part bearing a signed or encrypted header. Omitting it is what keeps the writer pure: there is nothing to sign, so there is nothing that needs a vendor key.

Reproducibility

Reproducibility here is a property of a lock, not a promise of the tool. boot2deb does not guarantee that any clone rebuilds any image forever — that would over-promise, and during active development it is not even true. What it guarantees is narrower and honest: the machinery to make a given build point reproducible to whatever strength you choose, and a documented way to rebuild the images the project publishes.

A build is a point across axes (device, kernel, suite, features, layout). The recipe .toml records the constraints; the sibling .lock records the exact resolution — every pinned commit, blob hash, and package manifest. build reads only the lock. That separation is what lets one recipe serve two intents without choosing between them:

  • Rolling — “give me a current working image.” Fresh clone, update to re-pin at today’s upstream, build. Best day-to-day UX; the resulting image’s provenance records exactly what went into it, so it is reproducible as of now.
  • Frozen — “reproduce exactly what shipped.” The lock is pinned and left alone; the image ships with a provenance manifest, and rebuilding it is a mechanical replay. Reproducible across time.

You opt into a strength per lock. Rolling and frozen are the same tool at two dial settings.

The three layers

An image rests on three independent inputs, each with its own durability and its own way to pin. Reproducibility is only as strong as the weakest one you froze.

1. Upstream sources (git commits, blobs)

Every compiled input — kernel, u-boot, the MPP/RGA/ffmpeg trees — is pinned to an exact commit in the lock; rkbin blobs are pinned by sha256. A commit is only re-fetchable if its remote still advertises it, so pins fall into durability classes: a release tag is immutable and fetchable forever; a branch tip is ephemeral (a force-push orphans it); a bare local commit is unfetchable by construction. boot2deb keeps shipped recipes on durable tags, makes a non-durable pin loud at update time, and never substitutes a different commit for an orphaned one — a different SHA is different bytes.

boot2deb verify-sources <recipe> is the check: a read-only probe that reports each pin as durable | ephemeral | ORPHANED | skipped and exits non-zero on any orphan, so CI can gate on it. It touches only the git remotes.

Custom kernels. A custom kernel is pinned the same way — a source commit plus a patch series commit. Its one failure mode is rebasing or force-pushing the patch repo, which orphans the pinned commit. Keep it in the durable class by tagging the patch repo at each release; the pinned commit then lives under an immutable ref and stays fetchable across future rebases.

2. The Debian archive (rootfs)

The rootfs is the fast-moving layer: a testing suite like forky changes daily, and the exact package versions a build installs rotate off the live mirror as it advances. Three mechanisms pin it:

  • The lock’s solved manifest fixes which bytes install — every package name, version, and sha256. This is always present.
  • The published plan document (<point>.plan, beside the image) additionally fixes how to install exactly those bytes, and records the archive state they were selected from. boot2deb reproduce replays it. This is written by every build.
  • A captured snapshot.debian.org timestamp fixes availability of those bytes after they leave the live mirror. This is opt-in and dormant by default (mode = off), so day-to-day builds go straight to the live mirror.

The manifest and the plan are not two spellings of one thing. The manifest is a pin the next build is verified against — a fresh solve that no longer reproduces it is an error. The plan is an instruction: hand it back and the rootfs installs that set without solving at all, which is the difference between detecting drift and not being subject to it. And the plan carries what neither the manifest nor the lock does — the mirror that answered, the suite and components, the sha256 of the release body that was verified, its Date and Valid-Until, the fingerprint of the certificate that verified it, and the key under that certificate which made the signature. The provenance manifest repeats those as [[archives]], one entry per repository, so an image’s own record says what its packages were selected from and not only which they were.

The plan document states its own format version in its first line, and a boot2deb reads exactly one. A plan written by an older boot2deb is refused, naming both versions, rather than being read on a guess about what its fields meant:

$ boot2deb reproduce turing-rk1/forky --from published/
error: read the pinned plan published/turing-rk1-forky.plan: the document is in format
"ferroday-cage-plan 1", and this library reads "ferroday-cage-plan 2"

That is the correct failure and not a gap to work around. The version moved because a field changed meaning: Signed-By named the signing subkey and now names the certificate’s primary key. Reading an old document under the new rule would silently report a different key for an archive that never rotated one — which is exactly the event a recorded fingerprint exists to catch. Rebuild the image from its lock to get a plan in the current format.

Replaying a plan moves the trust anchor, which is why it is reproduce’s to do and not something a build flag can turn on. A pinned install reads neither a release nor a package index, so the package digests no longer chain to an archive signature; they chain to the plan. Each .deb is still verified against the digest the plan records, so a mirror serving different bytes is caught — what is no longer checked is that the document describes a set the archive ever offered. For reproducing an image you published, whose plan you have alongside it, that is the right trade. For a routine build it is not, and a build that sets no plan resolves exactly as before.

One consequence is worth stating plainly: a recipe that compiles its own packages replays only if those compiles are byte-reproducible. The kernel .deb, and on a media-accel recipe ffmpeg-rk/librockchip-mpp1/librga2, install from the build’s own local pool and are pinned by digest like everything else. So a replay either matches them — which proves the whole image reproduced, compiles included — or fails naming the package that drifted. The failure is the honest outcome, not a defect in the mechanism: a build that cannot reproduce its own compiler output was never reproducible, and this is where that becomes visible instead of silent. A board that installs Debian’s kernel and compiles nothing has no such dependency.

Snapshot has three modes: off (live mirror only), fallback (live first, the snapshot backfills anything that 404s), and pin (the snapshot only — a fully deterministic userland). Capture a timestamp with --save-snapshot; activate a mode with --snapshot fallback|pin. A fallback/pin with no captured timestamp is refused rather than silently downgraded.

The mirror list a mode resolves to is used for every root a build provisions, not only for the image’s own userland: the target-arch sandbox the media-accel packages compile inside, the host-arch cross root the kernel, u-boot and modules compile inside, and the packaging root whose dpkg-deb archives them. Those roots hold the compilers and the archiver, so pinning the runtime without them would fix what ships and leave what produced it free to move. Each root’s identity is folded into the artifact-cache keys of what it built, so a snapshot-pinned build never restores a live-mirror build’s .debs — and so is the build-dependency set layered over it, because a compile probes for what is present.

This is why forky’s churn is not at odds with the model: the tool to freeze against it exists; a frozen build turns it on.

3. The builder (boot2deb itself)

The same lock built by a different boot2deb can produce a different image, or fail to read an old lock — during active development, breaking changes are expected, and the project does not carry compatibility shims to read old locks forever. So the builder is an input like any other, and the provenance manifest records it in [built_with].

That section carries two commits, because “what produced this image” has two answers that move independently:

fieldnamescaptured
commit / dirtythe program — the boot2deb binary that ranstamped into the binary when it is compiled
config_commit / config_dirtythe data — the config tree it read layers, recipes and the lock fromread from --root when the build starts

One checkout can supply both, and in the layout boot2deb is developed in it does. They still cannot answer for each other: an installed boot2deb run against a config tree has a commit from wherever it was built and a config_commit from the tree in front of it, and a single binary building two different config trees is the ordinary case, not an exotic one.

The binary’s commit is stamped at compile time rather than read at run time, and that is deliberate. The binary is the builder, so its identity has to travel with it: an installed boot2deb has no source tree in reach, and reading whatever checkout happened to be nearby would record a different claim than the field makes. The cost is that a binary can fall behind the checkout it was built from — commit, forget to cargo build, and the next image is stamped with the commit before yours, or with one an amend left unreachable.

So a build refuses to start when the running binary provably is not this checkout’s source:

$ boot2deb build h96-max-m9/forky
error: this boot2deb was compiled from 7e6e2f02674c, but the checkout is at
90ab9c660bc1. An image built now records 7e6e2f02674c as its builder — a commit
that is not what is on disk, and that nobody else can resolve if it was amended
away. Run `cargo build` (seconds) to re-stamp it.

Two cases are certain enough to refuse: the stamp names a different commit than HEAD, or it names HEAD but the sources under crates/ have been edited since. Editing a device .toml or a .dts is not one of them — that is build input, recorded by the lock and by config_commit, and it leaves the binary’s identity intact. A binary compiled from an already-dirty tree is reported and not refused: it records dirty = true, which is the honest answer rather than a false one.

--allow-stale-builder proceeds anyway, for when you mean it. The check itself is two git reads, and it runs before any compile — the alternative is discovering the wrong stamp in a provenance file written at the end of the build. boot2deb doctor reports the same verdict standing still, and why-rebuild shows it as a builder row above the compile nodes.

The builder also decides the environment a compile runs in. Every package build and the rootfs customize run in an unprivileged sandbox, and what they produce depends on the variables they carry, the filesystem they see, the identity they hold, whether they can reach a network, and which syscalls succeed — none of which any source pin covers, and all of which move with the sandbox library boot2deb links. So the manifest records them as data rather than leaving them to be inferred from a version: [sandbox] is the launch posture, [sandbox_env] is the command’s complete environment, and [[sandbox_mounts]] is every mount the sandbox establishes, in order, down to the /dev device nodes and symlinks. Two images built from one lock that differ can be compared on the inputs that could explain it.

That record is the series every command starts from. A run’s own working and artifact binds are per-build paths, its root is a per-build path, and the subordinate identity map the rootfs customize adds is its own — so none of them is recorded. The rooting mode contributes its kind (plain or overlay) and nothing else, for the same reason: a record carrying an overlay’s lower stack or a range map’s id extents would be a property of the machine rather than of the builder.

That stamp is an as-built record, not a requirement. The stamped commit is a floor: it, and later commits up to the next change that alters the output for this lock, will reproduce the image — and a later one may carry fixes you want. A commit past that change will not. And the floor is all that can ever be recorded, because the breaking change is in the future and unknowable at build time — even a bugfix can be output-affecting. So the stamp says when the build worked, never when it will break. A reproduce flow reads it to advise — “built with X; you are on Y, newer, likely fine; here is how to get X” — never to enforce.

The build host

The three layers above are inputs you choose. The build host is not — it is whatever machine you happened to run on — so the rule for it is different: a host setting either does not reach the image, or it is recorded. Nothing in between.

What is kept out:

  • Your umask. Git records two file modes and no directory modes at all, so a checked-out overlay tree’s modes are your umask, not authored data. The staged tree is normalized back to git’s own model — directories 0755, files 0644 or 0755 by the executable bit — before it is laid into the rootfs. Without it a 002 umask (the Ubuntu/Pop!_OS default) ships a group-writable /etc, /usr, and /boot, and a 077 umask ships an image whose /etc no non-root process can read.

  • Your git configuration. Every git the build runs, and the pure-Rust clone beside it, is isolated from /etc/gitconfig, ~/.gitconfig, and /etc/gitattributes. The setting that decides this is url.<base>.insteadOf: it rewrites a remote URL, so a host carrying one would fetch a pinned commit from a remote the lock does not name — the exact input the lock exists to fix. core.hooksPath, am.threeWay, apply.whitespace, and a system gitattributes are the same class. Transport settings are the cost: express a proxy or credentials through the environment (http_proxy, https_proxy), which git still reads.

  • Your distro’s dpkg. No .deb is archived by a tool from your host, the kernel’s included. The u-boot and kmod packages are staged, then archived by a dpkg-deb from a packaging root; the kernel’s make bindeb-pkg runs dpkg-buildpackage and dh_builddeb inside the cross root. Both are Debian userlands resolved from the same mirror list as the image itself, so the archiver’s version and its liblzma are sha256-pinned inputs the lock describes rather than a property of the distribution that ran the build. The compressor for what boot2deb archives itself (xz, level 6) is stated rather than inherited on top of that, so the archive’s structure is a property of boot2deb and not of the suite. No fakeroot on any path either — every root maps the caller to uid 0, so a staged tree is already root-owned where it is archived and dpkg-buildpackage needs no gain-root command.

  • Your compiler. Every compile runs in a provisioned root: the kernel, u-boot and the out-of-tree modules in a host-arch cross root carrying crossbuild-essential-<target>, and the media-accel .debs in a target-arch build sandbox. Neither your gcc nor your make is on any build path, and the host cross toolchain is not either. Each stage additionally declares the build-dependencies it layers over that base, and the declaration is folded into the artifact key — because a compile probes for what is present, and a package added to the layer is a different build.

    The base is provisioned once and cached, and the layer over it is resolved against the archive as it stands when the build runs — so the two could describe different archive states, which is what would leave a layer package’s declared dependency unmet. Two things keep that from happening.

    The base is checked against the archive every time it is reused. A cached base records the exact package set its bootstrap installed. Before reusing one, the build resolves that set against the archive as it stands now and compares: if the archive has moved past the tree, the tree is discarded and provisioned again, and the build says which packages moved.

    the archive has moved past the arm64 rootfs at
    build/turing-rk1/forky/sandbox/build-arm64-forky-1d64cce0ea48: 1 package(s) resolve
    differently now, so it is being re-provisioned:
      libc6:arm64 2.42-17 -> 2.43-3
    

    The check is on the solved package set, not on the suite’s Release date. A suite republishes its Release several times a day and almost never touches the handful of packages a base holds, so expiring on the date would re-bootstrap for nothing. Under --snapshot pin the archive does not move at all and the check never fires.

    A staged root is checked against its own dependencies. This is the backstop, for a skew the first check cannot see. Before any compile runs in it, a build root is checked against its own Depends and Pre-Depends, and a build whose base and layer disagree stops there, naming the package, the constraint it declared, and the version actually installed:

    error: the ffmpeg build root does not satisfy its own dependencies — the cached base
    and the freshly resolved layer describe different archive states:
      libglib2.0-0t64 2.88.3-3 requires `libc6 (>= 2.43)` — installed 2.42-17
    Drop the cached build roots so the next build provisions them against the current
    archive: `boot2deb clean RECIPE --build-roots`.
    
  • Your TMPDIR. The provisioned rootfs — the whole target userland, carrying xattrs and mapped ownership — is staged in the build’s work dir. On /tmp it would land on a RAM-backed tmpfs on most desktops, making “does the build fit” a property of your mount table.

  • Your shell environment. Every build command runs with TZ=UTC and LC_ALL=C.UTF-8, and with KCFLAGS/KAFLAGS/KCPPFLAGS/MAKEFLAGS cleared, so a flag exported in your shell cannot shape kernel bytes that a lock-keyed cache entry claims to reproduce.

  • Your openssl. The image’s first-boot /etc/shadow entry is hashed in-process. No host binary sits on the credential path.

What is recorded, because it genuinely does reach the image:

  • [toolchain] — the host/target arch and the cross prefix. jobs records the parallelism: recorded but deliberately not keyed, since a build whose output depends on its job count has a bug, and keying it would fragment the artifact cache by machine size. The compilers are not here; they are named, sha256-pinned, in the root sections below.

  • [toolchain.qemu] — the qemu-user interpreter that, on a host that cannot execute target binaries, ran the target compiler for the sandbox-built packages and every maintainer script that configured the rootfs. Absent where nothing is interpreted; an arm64 host building armhf cross-compiles and then runs the result natively, so it records none. This is the one compile input still probed on the host, because it is registered with the host kernel’s binfmt handler and no provisioned root can carry it.

    It is taken from the kernel’s binfmt registration, not from a PATH lookup, and the difference is not academic: the registered path is normally a wrapper under /usr/libexec/qemu-binfmt/ rather than the qemu-<arch>-static on your PATH, and nothing requires the two to name the same file. A build with no interpreter on PATH at all still runs every target binary through the registered one. So interpreter is the path the kernel recorded, resolved is that path with symlinks followed — the two are separate facts, because repointing the wrapper symlink swaps the interpreter with the registration unchanged — and sha256 is the content, which is also what the artifact cache keys on. A digest rather than a version line because it moves when the binary is rebuilt at an unchanged version, and because it can be taken from a binary that refuses to run, which the wrapper name does. version is read from the resolved path for a reader, and may be absent.

  • [filesystem] — the on-disk contract the rootfs was formatted to. Every other pin answers “which sources went in”; this one answers “what shape were they written into”, and it is the only such determinant that moves independently of the lock, since the format options are builder constants rather than resolved config values. It is three records, because three things move for three different reasons:

    • policy_pin is the intent — the formatter’s own policy document, carried whole: every feature word twice over, as exact bits and as names, plus the block and inode sizes, plus the seven options outside the feature set entirely (the grow reservation, the inode ratio, the reserved share, the error behaviour, the journal size, and the two directory-hash choices). Every one of those moves bytes, and errors is the sharp case: it reaches neither a feature word nor the geometry, so no other record here would notice it changing. Nothing image-specific is in it — no UUID, no timestamp, no label, no block count — so two images built from these constants carry byte-identical policy pins, and a difference always means the contract changed.
    • reference_geometry_pin is what that policy lays out, planned at one size chosen once (4 GiB) and never moved. It closes the gap the policy pin cannot see: a change to the formula behind an option whose name did not change. grow max reads the same before and after a change to what Max reserves; the blocks it reserves do not. It is a function of the options and the reference size alone, so it says nothing about what went into the image.
    • [filesystem.geometry] is what the format realized for this image — block and inode counts, group layout, and max_grow_blocks, the ceiling the reserved descriptor blocks buy, which is how large a disk the image can still grow onto at first boot. It answers to the image’s size as well as to the policy, so a larger partition moves every number in it with both pins unchanged.
  • [verification] — which checks the finished rootfs filesystem passed. The built-in scan always runs — every metadata checksum, each group’s metadata placement, and every in-use inode’s block map, directory records and attributes — and any finding at all fails the build. The independent e2fsck -fn cross-check runs only where the host carries e2fsprogs; its value is not extra depth (the scan is deeper) but independence, since the scan is one implementation checking its own output. That makes verification depth host-determined, so it is stated rather than left to a log line, and a release build can be gated on it.

  • [image].image_bytes — the whole-disk size the build laid out, beside the image_size the recipe authored. The two agree for a stated size and the redundancy is the point; they differ in kind for a measured one, where fit+20% states the rule and only this says what it came to. Without it a fitted image’s manifest could not answer how large its own image is.

  • [[archives]] — the state each configured repository was in when the rootfs plan resolved, in the order the resolve saw them, which is the index the .plan document’s packages name. Per entry: the mirror that answered, the suite and components, the sha256 of the release body that was verified, its Date and Valid-Until, and two key fingerprints. signed_by is the certificate that verified the release — its primary key, which is what a keyring entry is named by and what blobs/keyrings/*.fingerprints pins, so the manifest is directly comparable against that list. signing_key is the key that actually made the signature, usually a dedicated signing subkey of that certificate; the two are separate because a certificate rotating its subkey moves the second and leaves the first alone. [rootfs] says which package bytes shipped; this says what they were selected from, which is the question a solved manifest cannot answer — the same suite resolves to different versions a week apart. An empty signed_by is a fact rather than a gap: it says that repository was trusted unsigned, which is how the build’s own .deb pool is configured, and signing_key is empty exactly when it is. That pool’s entry is marked local and carries no mirror, because its URL is a per-run path under a per-run directory — a property of the machine, kept out for the same reason the sandbox record carries no working or artifact path.

  • [build_sandbox], [cross_sandbox] and [packaging_root] — the package sets of the three provisioned roots that produced the build’s .debs: the target-arch base that compiled the media-accel packages, the host-arch base that compiled the kernel, u-boot and out-of-tree modules, and the host-arch root whose dpkg archived the staged ones. [rootfs] records what the image carries; these record what produced the parts of it boot2deb built — further Debian trees, resolved from the same mirrors, that no source pin covers. Each names a manifest published beside the image (<recipe>.sandbox.pkgs, <recipe>.cross.pkgs, <recipe>.packaging.pkgs), sha256-pinned per package exactly as the rootfs manifest is. [cross_sandbox] in particular is where the compiler is named, by package and sha256 rather than by the version string it prints — which is why [toolchain] above carries no cc. Each is absent when the build produced nothing of its kind — no cross root for a board that installs Debian’s kernel and boots its own firmware, no packaging root for a build whose artifacts all came back from the artifact cache. They are records, not contracts: nothing pins them in the lock and no later build is verified against them.

    They describe the roots that stood up for this run. A build that restored some node’s outputs from the artifact cache did not compile that node here, so the three blocks account for the compiled part alone — which is what [[restored_nodes]] below makes legible.

  • [[restored_nodes]] — one row per build step whose outputs came back from the Tier-2 artifact cache instead of being compiled, naming the step and whether every one of its outputs was restored (restored) or only some (partly restored):

    [[restored_nodes]]
    step = "userspace"
    outcome = "partly restored"
    

    Omitted entirely by a build that compiled everything it shipped. It answers a question no other section can: not what went into the image, but which parts of it this run actually built. Without it the three root blocks above read as a claim about every .deb in the image, which holds only for a build that compiled them all — a mixed build restores some .debs that an earlier run produced, in a root that need not be the one named here. Pin a snapshot (--snapshot pin) when you need the stronger claim, or build with --no-artifact-cache to make every .deb this run’s own.

  • [sandbox], [sandbox_env] and [[sandbox_mounts]] — the posture, the environment and the complete mount series every sandboxed build command runs under, as the sandbox library resolves them. All three sit outside that library’s compatibility promise, so they are recorded rather than inferred from its version, and most of what they hold has no other accessor at all — down to the /dev device nodes and symlinks.

    [sandbox] states how the sandbox is rooted (plain or overlay), the identity the command holds (single — the calling user is root inside and nothing else is mapped), the network it can reach (isolated — a fresh namespace with loopback only, declared by boot2deb rather than taken from a library default), where the three standard streams go, any resource limits in force, and whether the library’s hardening layer is compiled in. hardening = "unavailable" is written rather than omitted: an absent key cannot be told from one written before the key existed, and a provenance record has to be readable without knowing which builder wrote it.

    [sandbox.streams] is there because a build’s output depends on it: isatty on the standard streams steers debconf’s frontend, a compiler’s colour diagnostics, and every progress display, so two builds under two stream postures can differ with nothing else to show for it. boot2deb declares stdin = "null", which does more than state that a build is non-interactive — it puts the sandboxed command in a session of its own. Inherited, it would stay in yours, where a maintainer script could open /dev/tty to read what is typed at your terminal and push characters into its input queue for your shell to run afterwards. Out of that session /dev/tty fails. The output pair reads inherit, which is a statement about the profile rather than about a compile: a capturing launch attaches its own pipes, so build output never went to your terminal either.

These identities also key the caches, so a .deb built with one toolchain is never restored for a build using another — and neither is a rootfs whose packages were configured under a different qemu-user.

Two audiences

Because reproducibility is a property of a lock, the story splits by who owns the lock.

The project, publishing a release. We own every axis — recipe, lock, snapshot timestamp, patch-repo tag, builder commit — so we offer a closed guarantee for a shipped image: check out boot2deb at the stamped commit, build this lock, get that image. The consumer mostly flashes; rebuilding is the frozen path. This is the release ritual below.

Someone who clones and authors their own recipe. Their subject is their build point, not ours, and their reproducibility is forward-looking — “make my current build re-buildable later” — rather than “rebuild what the project shipped.” They own their lock: when to update, whether to --save-snapshot, which builder they are on. The project does not guarantee their build; it hands them the same machinery and lets them set the strength.

The release ritual

To publish an image that stays reproducible across time, freeze all three layers and commit the result:

  1. Freeze the userland: capture a snapshot.debian.org timestamp into the lock with boot2deb build <recipe> --save-snapshot and set its mode to pin, so the rootfs is deterministic even after the suite advances. Commit the snapshot-pinned lock — it is part of the release.
  2. Keep sources durable: tag the patch repo at its pinned commit, and confirm boot2deb verify-sources <recipe> reports no ORPHANED pins.
  3. Build from that clean, committed checkout, so the image’s [built_with] records real commits with dirty = false and config_dirty = false. Run cargo build first: the build refuses a binary that is behind the checkout, but nothing can make a dirty one identify itself, and a release stamped dirty = true names no commit anyone can return to.
  4. Publish the image together with its .provenance.toml and its .plan. The manifest names the builder that produced it and the archives it resolved against; the plan is the document that replays them; the committed lock — recoverable at that commit — carries the snapshot timestamp and every source pin.
  5. Ship a bill of materials with it, for the consumers who read one rather than a provenance manifest: --sbom spdx --sbom cyclonedx on the build, or boot2deb sbom later from the manifest in step 4. It is deterministic on the same terms as everything else here — its identity is derived from the solved package set, so set SOURCE_DATE_EPOCH and two renderings of one image are byte-identical.

Reproducing a frozen image

boot2deb reproduce <recipe> --from <dir holding the published .plan>

That is the whole flow. It runs the ordinary pipeline — the lock’s pinned commits and blobs reproduce the compiled inputs — and replaces one step: the rootfs installs the plan’s exact package set instead of solving for a new one. Point --from at wherever the image, its provenance manifest and its .plan were published; omit it to use this build point’s own output directory, which is where a build on this machine already wrote them.

reproduce reproduces builds, not pressings: an image press extended with per-site additions is a derived copy (marked as such in its own image.toml), and what reproduces is the artifact it was pressed from.

The command reads the [built_with] stamp beside the plan and reports how the running checkout compares. That is advice, not a gate — the stamp is a floor, not a ceiling: a newer builder usually reproduces the image too and may carry fixes, so a current clone is the normal first attempt, and git checkout <built_with.commit> is the step to take only if it diverges. The builder stamp lives in the build’s .provenance.toml, not on the image; the on-image /etc/boot2deb/image.toml (see Image identity) records the image and kernel identity, which a rescue tool reads without the provenance file.

What each layer contributes to that one command: the lock reproduces the sources, the plan reproduces the package set, and the lock’s snapshot pin keeps that set fetchable after the live mirror has moved on. Freeze all three and the replay is mechanical; freeze fewer and it is reproducible to whatever strength you chose.

What is deliberately outside the claim

The per-image first-boot password is unique per build by design, so /etc/shadow is intentionally not byte-reproducible. Everything else in the rootfs is, given the same three layers frozen. The rootfs export clamps every tar member’s mtime to SOURCE_DATE_EPOCH, so a bootstrap’s wall-clock stamps do not leak into the image: its encoder records each mtime as min(mtime, epoch) as it writes. The encoder is the one place that can apply the ceiling: under the subordinate id-map that gives the tree its real ownership, the provisioned files sit at ids the host user cannot set times on. The export also emits entries in sorted order — directory children and extended attributes by name — so a content-identical tree encodes to a byte-identical archive.

Adding a board

Bringing up a new device is mostly writing config layers — a build resolves from TOML across the config model’s axes, so a new board is a set of small TOML files plus any vendored blobs and kernel fragments. The one exception is a genuinely new chip family, which also needs a small Rust change; see What needs code.

Which track are you on? This is the bring-up track (resolve → update → verify → build) — for a board or patch that has no lock yet. If you only want to build one of the shipped recipes, take the shorter Getting started track (doctor → build) instead. To add a patch rather than a board, see Adding a patch; to name a new build point on a board that is already here, see Authoring a recipe.

Start with the generator. boot2deb new-device <name> scaffolds the device (and a matching recipe) for you — it offers the valid SoC/boot-method/kernel/feature choices, fills every derivable value, and leaves the researched ones marked # TODO:. Run it (--soc <soc> non-interactively, or answer the prompts on a terminal; add --overlay <dir> to scaffold into your own tree), then edit the TODO values below. The rest of this page explains what those files mean and which values you must research. See new-device.

Where your files go: overlay or in-tree

Two ways to add a board, chosen by intent:

  • Out-of-tree overlay — you are bringing up a board for yourself. Put the files in your own directory and pass --overlay <dir>; there is nothing to fork, and the board’s lock is written back beside it. This is the third-party path — see Overlays.
  • In-tree — you are contributing a board back to boot2deb. Add the files to the vendored tree and open a pull request.

The files are identical either way; only their location differs. The rest of this page describes those files.

What needs code

Most of a board is data, but three axes are closed Rust enumsArch, Soc, and BootMethod in crates/core/src/model.rs — chosen for type safety and exhaustiveness checking. A board built on a chip family that already exists (any RK35xx SoC, the rockchip-rkbin boot method) needs no code: the variant is already there. A genuinely new family does:

  • New SoC (e.g. a non-Rockchip chip) — add a variant to the Soc enum near the top of model.rs and to its kebab_enum! invocation grouped just below the enum definitions, then rebuild. The compiler flags every match that must now handle it.
  • New architecture or new boot method — the same, on Arch / BootMethod. A new boot method also needs the engine taught how to write its payloads.

This is a deliberate boundary: closed enums give the compiler a single source of truth and catch a half-added target at compile time, at the cost of a recompile for a new family. Within an existing family it is pure config.

The layers to write

Work from the bottom of the hardware stack up, adding only what is new:

First, check whether the board joins a family that is already here. If its SoC and boot method are both supported, a new board can be a device file and nothing else — no overlay, no kernel, no engine change. That is not an aspiration: the ASUS C100P and Chromebit CS10 each ship as a single TOML, and the Chromebit is a stick PC with no SD slot, no keyboard, no EC and no analog audio. The rule that makes it work is that anything true of the whole family belongs on the SoC layer, not on the board that happened to need it first — socs/rk3288/ carries the family’s radio blobs, initramfs module list and network stack for exactly that reason. When you find yourself copying a file from one board to another, move it up instead.

  1. arch (arches/<arch>.toml) — only for a CPU architecture not already present. Arch-wide kbuild facts: the cross triple, kbuild’s ARCH=, and the kernel image path. (u-boot takes no ARCH= from here — its defconfig carries it.)

  2. soc (socs/<soc>.toml, plus socs/<soc>/overlay/ for files baked into the rootfs) — the SoC’s shared properties: device-tree directory, force-loaded modules, arch, and any SoC-wide firmware packages.

    • Media-accel sources are optional and ride the feature. Supply the one [[userspace]] entry per vendor tree the part has, [ffmpeg.base], and [ffmpeg.rockchip] stanzas here only if a board of this SoC will enable a media-accel-* feature (the feature compiles them into .debs); copy the block from socs/rk3588.toml. A headless SoC that never transcodes omits them entirely. Selecting a requires_media_accel feature on a SoC that lacks them is a resolve-time error, so the coupling is checked, not assumed.
  3. boot-method (boot-methods/<method>.toml) — how this family boots. The file’s shape depends on the method, because the methods differ in kind:

    • rockchip-rkbin compiles a bootloader: the u-boot source + ref and the raw-gap offsets (where idbloader and u-boot.itb sit outside any partition, and where the rootfs partition starts).
    • depthcharge compiles nothing — the firmware is the board’s own and what it loads is the signed kernel — so the file carries the ChromeOS kernel partition’s geometry, its GPT attribute bits, and the command line to sign into the kernel.

    A field from the other method is an unknown field and fails to parse, which is the point: an image cannot half-belong to two boot chains. boot-methods/<method>/overlay/ ships any boot-time files (e.g. the extlinux generator), and overlay-pre/ ships config a package’s own maintainer scripts must see while they run (see Two overlay stages).

  4. device (devices/<device>.toml) — the board itself, stating only its deltas: its soc, boot_method, supported_boot_methods, kernel_dtb, image_size, hostname, supported_kernels / default_kernel, default_suite, default_layout, plus whatever its boot method requires:

    • The slug must be a host name[A-Za-z0-9-] labels joined by ., no leading or trailing -, at most 64 characters. hostname defaults to it, so anything else would hand the image a name it cannot come up under; use my-board, not my_board. See A value that becomes a file or a line is checked at resolve. boot2deb new-device narrows this further for generated boards, to lowercase, digits, and dashes.
    • under rockchip-rkbin: a uboot_defconfig, and — only if the board departs from the SoC’s defaults — an [rkbin] block. The bootloader blobs are inherited from the soc layer and merged per field, so a board on the SoC’s usual memory omits the block entirely; a board with different DRAM overrides just tpl.
    • under depthcharge: a [depthcharge] block naming the board profile and the series the unit supports. No uboot_defconfig, no blobs — this board compiles no bootloader, and resolution does not ask it to.
    • device_config_fragments gotcha: naming a fragment here makes its file mandatory. device_config_fragments = ["device/my-board"] requires fragments/device/my-board.config to exist — a missing file fails resolve. A board with no board-specific kconfig deltas uses device_config_fragments = [] to add none. Do not name a fragment you have not written.
    • A variant of a board already here uses extends. If your board is another one with one difference — a block enabled for bring-up, a different DTB or DRAM fitting — write extends = "<other-device>" and state only the deltas. It inherits that device’s keys and its overlay/ tree, so the parent board’s driver tuning, units, and keymaps reach your image and any file of them can be overridden by shipping your own copy at the same path. Do not hand-copy the other device’s file: most arrays replace rather than append across the merge, so restate any list you extend — the five that describe the board (caveats, expect, nonfree_firmware_packages, packages, exclude) accumulate instead. See A variant board extends another.
  5. kmod (kmods/<name>.toml) — only if the board carries hardware whose driver is in nobody’s kernel tree. Run boot2deb list-kmods first: if a kmod for the chip already exists, your board needs one line, device_kmods = ["<name>"], and nothing else. If not, write the layer — the vendor repo, its ref, the subdir make M= builds in, the modules to ship — and put any patch of your own under kmods/<name>/patches/. Everything in that file is a property of the driver, so state nothing board-specific there; a device cannot override a kmod’s fields, and a board needing different build flags is a second kmod, not an override. See Out-of-tree modules are their own layer.

  6. kernel (kernels/<kernel>.toml) — the orthogonal kernel axis. Ask first whether the board needs a kernel of yours at all.

    • If Debian’s own kernel already runs the hardware — which it does for any SoC and board that are fully upstream — write a flavor = "distro-package" definition naming the package (linux-image-armmp) and you are done. No source ref, no defconfig, no fragments, no patches, and one definition serves every suite. This is the better answer where it applies: apt keeps the board’s kernel patched, which a kernel you compiled does not.
    • Otherwise write a mainline or vendor definition with its source refs, .config fragments, and patch series. Version-coupled, so a new kernel version is a new file. A compiled kernel that applies no series writes patch_series = "none" and then never reads the patches repo.

    Note that a distro kernel and the compile-only device fields (device_dts, device_config_fragments, device_patch_series, device_kmods) are mutually exclusive, and resolution says so: nothing would ever build the DTB, merge the fragments, apply the series, or give the modules a tree to build against.

Two overlay stages

Each layer may ship two trees of files that are copied into the rootfs:

  • overlay/ — laid in after every package. It therefore wins over whatever the packages shipped, which is what nearly all config wants.
  • overlay-pre/ — laid in before any package is installed. This is for config a package’s own maintainer scripts must see while they run, where winning afterwards is too late because the package already acted. The Veyron Chromebooks are the clearest case: the initramfs module list under usr/share/initramfs-tools/modules.d/ has to precede the kernel package, or the first initramfs is built without the drivers that reach the root device and then thrown away and rebuilt.

Boot-method config that resolution derives — the depthcharge-tools board profile, the signed cmdline, the initramfs MODULES=/COMPRESS= settings — is generated into the same pre-install stage rather than authored as an overlay file, for the same before-the-package reason. A layer does not ship those; it states the values they come from.

Use overlay/ unless the package acts before your file would arrive.

Supporting assets:

  • blobs (blobs/<soc>/) — vendored bootloader binaries the device/boot-method references.
  • fragments (fragments/<name>.config) — kernel .config fragments merged onto the base defconfig, referenced by name from a kernel or device.
  • patch series — lives in the separate patches repo, referenced by the kernel; see Adding a patch. Omitted entirely by a patch_series = "none" kernel.
  • board device tree — only when the board’s .dts is not yet upstream; see device_dts.

Finally, a recipe (recipes/<device>/<leaf>.toml) pins one point across the axes — device, kernel, suite, features, layout.

Values you must research

Most fields fail loudly at resolve: bad image geometry, a missing fragment, or an unvendored keyring are all caught up front. Two fields are not validated until the stage that consumes them compiles, so a typo produces a late, confusing failure:

ValueLayerFails at
kernel_dtbdevicethe kernel build — the DTB is not produced (unless the board carries device_dts, below, which makes this a resolve-time check)
uboot_defconfigdevicethe u-boot build — unknown defconfig

Take both from the board’s upstream support: kernel_dtb is the device tree the mainline kernel builds for the board (under arch/<arch>/boot/dts/<dt_dir>/), and uboot_defconfig is the board’s u-boot defconfig. Confirm each exists in the exact kernel/u-boot versions you pin before you trust a green resolve.

When the board’s device tree is not upstream

A freshly-supported SoC often has every driver in mainline but none of its boards. Fork the nearest in-tree board .dts, put it in your overlay, and list it in device_dts:

kernel_dtb = "rockchip/rk3576-my-box.dtb"
device_dts = ["devices/my-box/dts/rk3576-my-box.dts"]

The kernel stage copies it into the tree and registers the DTB with kbuild, so it ships in the linux-image deb like any in-tree board. kernel_dtb is then validated at resolve — it must be the DTB one of those sources builds — so the table above no longer applies to it. Iterate with build <recipe> --stage dtb, which rebuilds only the DTB.

Keep device_dts for the new board file. An edit to an existing upstream .dts is a patch in the kernel’s patch series; a source that would overwrite an in-tree file is refused rather than silently shadowing it.

Bring it up

With the layers written, use the CLI’s checks as guardrails. This is the same sequence Authoring a recipe uses — a new board only differs in having written the device file first:

# 1. Does it resolve to a coherent build point? Also runs the geometry / fragment /
#    keyring preflight, so this is a real coherence gate, not just a merge print.
boot2deb resolve <recipe>

# 2. Is the host equipped to build it? Before `update`, so a missing host requirement
#    costs nothing but a re-run rather than a network round trip.
boot2deb doctor <recipe>

# 3. Resolve upstream refs + hash blobs into the lock. The only command that consults
#    the network for pins; the verify gates below all read the lock it writes.
boot2deb update <recipe> --kernel-ref <ref>

# 4. Does the patch series apply cleanly to the pinned kernel? Auto-fetches the locked
#    kernel — no hand-cloned tree — or add --kernel-src ../linux if you have a checkout.
boot2deb verify-patches <recipe>

# 5. Does the .config generate (and, with --reference-config, match a reference)?
boot2deb verify-config <recipe>

# 6. Build.
boot2deb build <recipe>

resolve, update, and the two verify-* commands fail with a typed error before any compile starts, so most config mistakes surface in seconds rather than partway through a build. The verify-* commands auto-fetch the pinned source trees, so this whole sequence works on a fresh clone with no hand-cloned kernel — see Verification.

A worked example: a second RK3588 board

A board on an existing SoC needs only a device file and a recipe — arch, soc, and boot-method all reuse the shipped layers. Suppose my-board is another RK3588 module.

devices/my-board.toml:

description             = "My RK3588 board"
soc                     = "rk3588"                        # reuse the shipped SoC layer
boot_method             = "rockchip-rkbin"
supported_boot_methods  = ["rockchip-rkbin"]
uboot_defconfig         = "my-board-rk3588_defconfig"    # research: must exist in u-boot
kernel_dtb              = "rockchip/rk3588-my-board.dtb" # research: must exist in the kernel
device_config_fragments = []                             # no board-specific kconfig deltas
supported_kernels       = ["rk3588-mainline-7.2"]
default_kernel          = "rk3588-mainline-7.2"
default_suite           = "forky"
default_layout          = "combined"
hostname                = "my-board"                     # defaults to the slug, which is already one
image_size              = "2G"

[rkbin]                                                  # board-memory-specific DDR init
atf = "rk3588_bl31_v1.51.elf"
tpl = "rk3588_ddr_lp4_2112MHz_lp5_2400MHz_v1.19.bin"

recipes/my-board/forky.toml:

device   = "my-board"
kernel   = "rk3588-mainline-7.2"
suite    = "forky"
features = ["media-accel-rockchip"]   # or [] for a plain image
layout   = "combined"

Then run the bring-it-up sequence against my-board/forky. A new SoC would additionally need socs/<soc>.toml (with the required userspace/ffmpeg stanzas) and its fragments; a new family would need the code change from What needs code.

Document your board

Give each board a page under Boards, the way the Turing RK1 page does, since flashing is inherently per-board — a Turing Pi module flashes through the BMC, a standalone SBC takes an SD card or a maskrom loader, a laptop boots UEFI. A useful skeleton:

# <Board name>

The `<recipe>` recipe builds a bootable Debian <suite> image for the <board> (<SoC>).
It pins kernel `<ver>`, u-boot `<ver>`, and <features>.

Build it as in [Getting started](../getting-started.md):

    boot2deb build <recipe>

## Flash
<how this board takes an image: card reader, BMC, maskrom, UEFI…>

## Serial console
<UART pins / adapter / baud>

## First boot
<credentials, resize-on-first-boot, hostname>

Adding a patch

boot2deb applies an ordered patch series to each source tree (kernel, ffmpeg, userspace, u-boot) before it compiles. The series is declared by a patch series that lives in the separate patches repo. Adding a patch means getting it into that series and then into a build. This page walks the loop end to end.

This page is about getting a new patch into a series. Carrying an existing series across a kernel version — measuring what breaks, and encoding the boundary in the series — is Moving a board to a newer kernel.

It applies to a kernel that names a series. A kernel with patch_series = "none" applies no series and never reads the patches repo; giving such a board a patch means first authoring a series for its kernel.

The loop

patch import  ->  commit in ../patches  ->  boot2deb update  ->  boot2deb build
                          (verify-patches at any point along the way)

The linchpin is the middle two steps: update re-pins the patches repo’s current commit into the lock, and build reads the series at exactly that pinned commit. So a patch sitting on disk does nothing until it is committed in the patches repo and the lock is re-pinned to include that commit. patch import prints these follow-ups for you; the rest of this page is the same steps, with their failure modes.

The running example imports a kernel patch into the rk3588-accel series and builds the turing-rk1/forky recipe.

1. Import the patch

patch import fetches a patch (a patchwork/mbox URL, a local file, or - for stdin), normalizes it to canonical git am-ready mbox, writes it into the series’ tree, and slots it into the series manifest at the right position:

boot2deb patch import \
  https://patchwork.kernel.org/project/linux-rockchip/patch/NNNN/mbox/ \
  --series rk3588-accel --scope kernel
  • --scope selects which tree’s series to insert into: kernel, ffmpeg, userspace, or uboot.
  • The filename prefix is chosen to sort the patch at its list position; pass --position to insert at a specific index (default: append). If the neighbours leave no numeric gap (e.g. 070/071), the import falls back to a lettered sub-prefix (070a) automatically.
  • Add --verify-tree <kernel-checkout> to dry-run git am the resulting series during the import (it rolls back the write on failure). Without it, the patch is written unverified — see verify.

On success it prints exactly what to do next:

patch import: wrote media-accel/kernel/045-fix-foo.patch (3812 bytes)

!! patch written but NOT verified — it has not been dry-run against a kernel tree.
   verify it now:   boot2deb verify-patches turing-rk1/forky
                    (auto-fetches the locked kernel at its pin — no checkout needed)
   next time:        add --verify-tree <kernel-checkout> to verify during import.
patch import: rk3588-accel/kernel now lists the patch at position 5 of 11

next steps — no build reads the patch until the series is committed and re-pinned:
  1. commit it:      git -C /home/you/dev/patches add -A && git -C /home/you/dev/patches commit
  2. re-pin locks:   boot2deb update turing-rk1/forky

The re-pin line names each recipe that applies the series you imported into, on either axis — a device’s default_uboot_series counts the same as a kernel’s patch_series, so importing into a u-boot series names the recipes that carry it. The checkout path it prints is the one it wrote to: --patches-path when you passed one, else the config root’s sibling ../patches — anchored to --root, not to the directory you ran from.

2. Commit in the patches repo

The new patch file and the series edit both live in the patches repo. Commit them there:

git -C ../patches add -A
git -C ../patches commit -m "kernel: fix foo"

This matters because update pins the patches repo’s HEAD commit. An uncommitted patch is invisible to that pin — which is exactly why update refuses to run against a dirty patches checkout (see failure modes).

3. Verify

Confirm the series still applies cleanly to the pinned tree:

boot2deb verify-patches turing-rk1/forky

With no --kernel-path, verify-patches auto-fetches the locked kernel at its pin — no hand-cloned tree needed. The first run on a cold cache clones linux-stable (large); if you already have a checkout, point --kernel-src at it to skip the clone:

boot2deb verify-patches turing-rk1/forky --kernel-src ../linux

A --scope uboot import verifies the same way, against the recipe that carries the u-boot series — the locked u-boot is auto-fetched at its pin, and the run reports the uboot series at the u-boot tag rather than a kernel one:

boot2deb verify-patches rk3576-generic/loader

You can verify before or after committing — the series on disk is what is checked. (Passing --verify-tree to patch import runs the same check inline.) See Verification for verify-config and the full flag set.

4. Re-pin the lock, then build

update re-pins the patches commit (and re-resolves the other refs) into the lock:

boot2deb update turing-rk1/forky

You do not need --kernel-ref for a patch-only re-pin: with a lock already present, update inherits the previous kernel ref and re-pins only what changed. Commit the updated recipes/<device>/<leaf>.lock, then build:

boot2deb build turing-rk1/forky

The build reads the series at the pinned commit and applies it with git am --3way.

Failure modes

Dirty patches checkout. update refuses a patches repo with uncommitted changes (PatchesDirty): a dirty pin would be wrong in every case, so commit first. This guard turns “I imported but forgot to commit” into an instant, offline error instead of a confusing build-time one.

Stale / mismatched pin. If the checkout build reads is at a different commit than the lock pins, you get PatchesPinMismatch. Its remedy text distinguishes the cases: if your local HEAD is ahead of the pin (you committed but did not re-pin), run update; if the checkout is behind the pin (stale), git checkout the pinned commit. Re-pinning after a commit is the usual fix.

Auto-fetch can’t find the commit. A zero-clone build (no local ../patches) fetches the series at the pinned commit from the series’ patches_url. That only works if the commit has been pushed — an unpushed local commit resolves fine against a local checkout but not on another machine. Push the patches repo before relying on the auto-fetch.

Co-developing the series

While iterating on a patch you may not want to commit-and-re-pin on every change. Point build (and verify-patches) at your working checkout instead:

boot2deb build turing-rk1/forky --patches-path ../patches

An explicit --patches-path downgrades a pin mismatch from an error to a loud warning, so you can build from an uncommitted, un-re-pinned series. The trade-off is reproducibility: that build is no longer pinned by the lock, so it is a development convenience, not a committed result. When the patch is settled, commit it, update, and drop the flag.