From 582124dd34ab82519ceb3af4fdd5a626827ca8c9 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:24:48 +0800 Subject: [PATCH 1/4] Buttons and splash read the kernel structures of the root's architecture input_event, the key capability words and fb_fix_screeninfo hold C longs: 8 bytes on arm64, 4 on armv7. The helpers size them natively, so the same scripts work in a 32-bit root. --- system/overlay/usr/libexec/minimal-buttons | 6 ++++-- .../overlay/usr/libexec/minimal-clear-splash | 10 ++++++---- tests/test_controls.py | 2 +- tests/test_splash_clear.py | 18 ++++++++++++++---- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/system/overlay/usr/libexec/minimal-buttons b/system/overlay/usr/libexec/minimal-buttons index 331a2f5..f6b35de 100644 --- a/system/overlay/usr/libexec/minimal-buttons +++ b/system/overlay/usr/libexec/minimal-buttons @@ -12,15 +12,17 @@ import stat import struct import subprocess -EVENT = struct.Struct(' xv or yo + y > yv or stride < xv * 4 or stride % 4): diff --git a/tests/test_controls.py b/tests/test_controls.py index 68ae41c..aa89ada 100644 --- a/tests/test_controls.py +++ b/tests/test_controls.py @@ -91,7 +91,7 @@ def test_other_keys_and_touch_are_ignored(self): def test_actual_gpio_key_bitmap_and_abi(self): bits = buttons.key_bits('100000000000 1c000000000000 0') self.assertTrue(all(bits & (1 << key) for key in (114, 115, 116))) - self.assertEqual(buttons.EVENT.size, 24) + self.assertEqual(buttons.EVENT.size, 24 if buttons.LONG_BITS == 64 else 16) def test_device_selection_is_unique_and_not_touch(self): for event, name in (('event0', 'gpio-keys'), ('event1', 'touchscreen')): diff --git a/tests/test_splash_clear.py b/tests/test_splash_clear.py index 8b1d4cd..548618f 100644 --- a/tests/test_splash_clear.py +++ b/tests/test_splash_clear.py @@ -11,7 +11,7 @@ loader.exec_module(module) -def layout(width=4, height=3, stride=20, alpha=False): +def layout(width=4, height=3, stride=20, alpha=False, long=8): variable, fixed = bytearray(160), bytearray(80) struct.pack_into('<8I', variable, 0, width, height, width, height, 0, 0, 32, 0) for offset, shift in ((32, 16), (44, 8), (56, 0)): @@ -19,13 +19,23 @@ def layout(width=4, height=3, stride=20, alpha=False): if alpha: struct.pack_into('<3I', variable, 68, 24, 8, 0) fixed[:16] = b'simpledrmdrmfb\0\0\0' - struct.pack_into(' Date: Sat, 7 Nov 2026 20:24:48 +0800 Subject: [PATCH 2/4] The root has resize2fs The install finish step grows the root from inside it; Alpine keeps resize2fs in e2fsprogs-extra. --- system/install-base | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/system/install-base b/system/install-base index 481ba33..ce2ac5f 100644 --- a/system/install-base +++ b/system/install-base @@ -1,6 +1,6 @@ #!/bin/sh set -eu -apk add --no-cache openrc python3 bash kmod util-linux e2fsprogs openssh-client-default merge-usr \ +apk add --no-cache openrc python3 bash kmod util-linux e2fsprogs e2fsprogs-extra openssh-client-default merge-usr \ shadow openssl # The one regular user; orchard's installer sets its password (chpasswd). useradd --create-home --uid 1000 --user-group --shell /bin/bash tablet @@ -12,4 +12,5 @@ rm -f /etc/machine-id /var/lib/dbus/machine-id test "$(readlink /lib)" = usr/lib test "$(readlink /bin)" = usr/bin test "$(readlink /sbin)" = usr/sbin +test -x /usr/sbin/resize2fs # the install finish step grows the root test ! -e /usr/lib/systemd/systemd From d2ad7d921364ea3f648e9fc73ac203d7d596be8b Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:24:48 +0800 Subject: [PATCH 3/4] An armv7 root and release for orchard's 32-bit platforms orchard build --platform apple-armv7 (or tools/build.py --arch armv7) builds the same system as an armv7 root, released on its own from orchard's 32-bit index: the root as a block image, its logo, and the boards, loaders and layer the index names. One release has one root of one architecture. The pinned arm64 Alpine image builds it: its apk unpacks the signed ARMv7 packages without their scripts, and the recipe does as files what those scripts would (BusyBox applet links, accounts, /etc/shells), then merges /usr with the arm64 merge-usr. No ARMv7 code runs, so no emulation. Alpine's dropbear and udhcpd take the place of orchard's arm64 static tools; the audit checks every ELF file is of the root's architecture. --- BUILD.md | 18 +++- README.md | 2 +- audit.py | 39 +++++++-- build.py | 69 +++++++++++---- build_base.py | 43 +++++++--- pin_components.py | 13 ++- snapshot.py | 16 ++++ system/armv7/Containerfile | 5 ++ system/armv7/install-base | 50 +++++++++++ tests/test_armv7_root.py | 167 +++++++++++++++++++++++++++++++++++++ tools/build.py | 49 ++++++++--- 11 files changed, 415 insertions(+), 56 deletions(-) create mode 100644 system/armv7/Containerfile create mode 100644 system/armv7/install-base create mode 100644 tests/test_armv7_root.py diff --git a/BUILD.md b/BUILD.md index 1421e80..2196332 100644 --- a/BUILD.md +++ b/BUILD.md @@ -23,6 +23,20 @@ change; the release is assembled and audited every time. `orchard run` boots its recovery on the attached device without installing, and `orchard install` installs it. The rest of this page is what that entry runs, step by step. +### 32-bit (armv7) + +A release has one root of one architecture. `orchard build --platform apple-armv7` +(or `tools/build.py --arch armv7`) builds the armv7 root and its own release, for +orchard's 32-bit platforms: the same packages, services and configuration as the +arm64 root, with Alpine's dropbear and udhcpd (`busybox-extras`) in place of +orchard's static arm64 tools. `system/armv7/` builds it in the same pinned arm64 +Alpine image: the native `apk --arch armv7` unpacks the signed ARMv7 packages into +`/armroot` without running their scripts, and the recipe does as files what those +scripts would (applet links, accounts, `/etc/shells`), then merges `/usr` with the +arm64 `merge-usr --root`. Nothing runs ARMv7 code, so no emulation is needed. The +release takes the boards, loaders and layer from orchard's 32-bit index +(`platforms-armv7.toml`, beside the unchanged arm64 `platforms.toml`). + ## Local setup Requires uv and a native ARM64 Podman environment. Run commands from this @@ -51,7 +65,7 @@ the few lines that find it and run its commands. ```sh export ORCHARD=/path/to/orchard PYTHONPATH=/path/to/orchard/src python3 boot/build-artwork.py --output out/artwork -python3 build_base.py --output out/base/base.tar.gz +python3 build_base.py --output out/base/base.tar.gz # --arch armv7: the 32-bit root's base # What orchard supplies finished: kernels and boards per platform, m1n1, Pongo, tools. python3 -m orchard.inputs --output out/orchard-inputs.json --platform apple-16k python3 pin_components.py --loader M1N1 --splash out/artwork/splash --logo out/artwork/logo.bin \ @@ -75,7 +89,7 @@ signature or trust service. Use fresh output paths; producers refuse overwriting. The official Alpine OCI digest is pinned in `system/Containerfile`; APK repositories retain signature checking. The base records package versions in `/etc/alpine-packages.lock`. Its archive hash and all selected hardware hashes are recorded separately from the install contract. The APK mirror is not an immutable snapshot: future base rebuilds may select newer signed package revisions and must get new receipts. -The producer composes Alpine-owned policy into one public gzip root for every platform (no kernel or modules: orchard's platform layer brings those at install, and orchard writes the boot entry), seals a recovery per platform to the root's receipt, adds orchard's layer per platform, and emits `release.toml` plus the index that pins it. Public build receipts omit local machine paths. No private firmware, SSH keys, ADT or machine IDs are embedded. +The producer composes Alpine-owned policy into one public gzip root for every platform of its architecture (no kernel or modules: orchard's platform layer brings those at install, and orchard writes the boot entry), seals a recovery per platform to the root's receipt, adds orchard's layer per platform, and emits `release.toml` plus the index that pins it. Public build receipts omit local machine paths. No private firmware, SSH keys, ADT or machine IDs are embedded. ## Native isolated checks diff --git a/README.md b/README.md index d6afd38..225862e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small Alpine-based Linux image for Apple devices, demonstrating the Orchard release format without a desktop or systemd requirement. -A release covers every device orchard describes: one root image, and orchard's kernel, device trees and modules per platform. How well a device works is orchard's device data, which the installer shows; today the iPad 6 Wi-Fi (`iPad7,5`, J71b) is supported, the others are experimental. Boot is tethered and requires a host computer. The installer sets the password of its one user, `tablet`; the console on tty1 and the USB serial console take it. +A release covers every device orchard describes for its architecture: one root image, and orchard's kernel, device trees and modules per platform. 64-bit devices take the arm64 release, Apple's 32-bit devices (apple-armv7) an armv7 release of the same system. How well a device works is orchard's device data, which the installer shows; today the iPad 6 Wi-Fi (`iPad7,5`, J71b) is supported, the others are experimental. Boot is tethered and requires a host computer. The installer sets the password of its one user, `tablet`; the console on tty1 and the USB serial console take it. ## Local development diff --git a/audit.py b/audit.py index 9a2bd55..aa27858 100644 --- a/audit.py +++ b/audit.py @@ -13,6 +13,20 @@ HARDWARE = None # the orchard snapshot; main() sets it, else orchard_root() finds it +# Per root architecture: its ELF class and machine, and the musl loader. +ARCHITECTURES = {'aarch64': {'class': 2, 'machine': 183, 'loader': 'usr/lib/ld-musl-aarch64.so.1'}, + 'armv7': {'class': 1, 'machine': 40, 'loader': 'usr/lib/ld-musl-armhf.so.1'}} + + +def check_elf_arch(data, arch): + """An ELF file of the root is of the root's architecture: no arm64 tool in an armv7 root, or back.""" + if data[:4] != b'\x7fELF': + return False + target = ARCHITECTURES[arch] + assert data[4] == target['class'] and struct.unpack_from('> "$root/etc/shells" +for list in "$root"/etc/busybox-paths.d/*; do + case "$list" in *busybox-extras) target=/bin/busybox-extras ;; *) target=/bin/busybox ;; esac + while read -r applet; do + applet=${applet#/} + [ -n "$applet" ] || continue + if [ -e "$root/$applet" ] || [ -L "$root/$applet" ]; then continue; fi + mkdir -p "$root/$(dirname "$applet")" + ln -s "$target" "$root/$applet" + done < "$list" +done +groupadd --root "$root" --system klogd +useradd --root "$root" --system --no-create-home --home-dir /dev/null --shell /sbin/nologin \ + --gid klogd --comment klogd klogd +# The one regular user; orchard's installer sets its password (chpasswd). +useradd --root "$root" --create-home --uid 1000 --user-group --shell /bin/bash tablet +merge-usr --root "$root" --dryrun +merge-usr --root "$root" +apk --root "$root" info -v > "$root/etc/alpine-packages.lock" +rm -f "$root/etc/machine-id" "$root/var/lib/dbus/machine-id" +test "$(cat "$root/etc/apk/arch")" = armv7 +test -x "$root/usr/sbin/resize2fs" -a -e "$root/usr/bin/python3" +test "$(readlink "$root/lib")" = usr/lib +test "$(readlink "$root/bin")" = usr/bin +test "$(readlink "$root/sbin")" = usr/sbin +test -x "$root/usr/bin/busybox" -a -L "$root/usr/bin/ls" -a -L "$root/usr/sbin/getty" +test -e "$root/usr/lib/ld-musl-armhf.so.1" +test ! -e "$root/usr/lib/systemd/systemd" diff --git a/tests/test_armv7_root.py b/tests/test_armv7_root.py new file mode 100644 index 0000000..bb9c0a7 --- /dev/null +++ b/tests/test_armv7_root.py @@ -0,0 +1,167 @@ +"""The armv7 root: built by arm64 tools only, released on its own from orchard's 32-bit index. No builds.""" +import gzip +import importlib.util +import io +import json +from pathlib import Path +import struct +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import unittest + +PROJECT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT)) +import snapshot # noqa: E402 + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, PROJECT / path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +build = load('minimal_build_armv7', 'build.py') +audit = load('minimal_audit_armv7', 'audit.py') +base = load('minimal_base_armv7', 'build_base.py') + + +def elf(arch): + """The first bytes of an executable of ARCH.""" + header = bytearray(64) + header[:4] = b'\x7fELF' + header[4] = audit.ARCHITECTURES[arch]['class'] + header[5:7] = b'\x01\x01' + struct.pack_into(' 1: parser.error('--platform all builds every platform and combines with no other') - # The release builder builds every platform when it is given none. - selected = [part for name in args.platform if name != 'all' for part in ('--platform', name)] + known = snapshot.platforms(orchard) + named = [name for name in args.platform if name != 'all'] + unknown = sorted(set(named) - set(known)) + if unknown: + parser.error(f'unknown platform {", ".join(unknown)}; orchard describes {", ".join(known)}') + architectures = {known[name] for name in named} | ({args.arch} if args.arch else set()) + if len(architectures) > 1: + parser.error('one release has one root: choose platforms of one architecture') + arch = architectures.pop() if architectures else 'aarch64' + if not named and arch != 'aarch64': + named = sorted(name for name, value in known.items() if value == arch) + # Without names, orchard's inputs cover every arm64 platform. + selected = [part for name in named for part in ('--platform', name)] - base = snapshot.component('base', INPUTS['base'], - lambda path, work: python('build_base.py', '--output', path), artifact='base.tar.gz') + if arch == 'aarch64': + base = snapshot.component('base', INPUTS['base'], + lambda path, work: python('build_base.py', '--output', path), artifact='base.tar.gz') + else: + base = snapshot.component(f'base-{arch}', INPUTS[f'base-{arch}'], + lambda path, work: python('build_base.py', '--arch', arch, '--output', path), + artifact='base.tar.gz') artwork = snapshot.component('artwork', INPUTS['artwork'], lambda path, work: python('boot/build-artwork.py', '--output', path), artifact='artwork') @@ -57,15 +77,18 @@ def main(): resolved.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.inputs-', dir=PROJECT / 'out') as temporary: snapshot.command(orchard, 'inputs', '--output', resolved, *selected) - shared = json.loads(resolved.read_text())['shared'] - tools = {name: Path(path) for name, path in shared['tools'].items()} inputs = Path(temporary) / 'public' - python('pin_components.py', '--loader', shared['m1n1'], '--splash', artwork / 'splash', - '--logo', artwork / 'logo.bin', '--pongo', shared['pongo'], - '--busybox', tools['busybox'], '--dropbear', tools['dropbear'], '--dropbearkey', tools['dropbearkey'], - '--wpa-supplicant', tools['wpa_supplicant'], '--wpa-cli', tools['wpa_cli'], '--iw', tools['iw'], - '--output', inputs) - python('build.py', '--base', base, '--base-receipt', base.with_name(base.name + '.json'), + pinned = ['--splash', artwork / 'splash', '--logo', artwork / 'logo.bin'] + if arch == 'aarch64': + # orchard's static arm64 tools; an armv7 root has Alpine's packages of them. + shared = json.loads(resolved.read_text())['shared'] + tools = {name: Path(path) for name, path in shared['tools'].items()} + pinned += ['--loader', shared['m1n1'], '--pongo', shared['pongo'], + '--busybox', tools['busybox'], '--dropbear', tools['dropbear'], + '--dropbearkey', tools['dropbearkey'], '--wpa-supplicant', tools['wpa_supplicant'], + '--wpa-cli', tools['wpa_cli'], '--iw', tools['iw']] + python('pin_components.py', *pinned, '--output', inputs) + python('build.py', '--arch', arch, '--base', base, '--base-receipt', base.with_name(base.name + '.json'), '--inputs', inputs / 'inputs.json', '--orchard-inputs', resolved, '--orchard', orchard, '--version', args.version, '--output', args.output) python('audit.py', args.output, '--orchard', orchard) From 3031c93e6ec4144c261d53e7a5e974b0bf6ff02b Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 21:15:57 +0800 Subject: [PATCH 4/4] armv7: the manifest orchard's 32-bit index describes A 32-bit release names each board's Cascadia loader ([cascadia.BOARD], from the index's loaders.cascadia), boots with recovery-autogo and has no Pongo, m1n1 or logo (orchard docs/distribution-contract.md, "32-bit roots"). From a published orchard build it takes platforms-armv7.toml with --architecture arm. --- audit.py | 12 +++++++----- build.py | 29 ++++++++++++++++++----------- tests/test_armv7_root.py | 10 ++++++---- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/audit.py b/audit.py index aa27858..e77bb6a 100644 --- a/audit.py +++ b/audit.py @@ -71,12 +71,14 @@ def audit(directory): rootfile = directory / 'rootfs.tar.gz' assert rootfile.stat().st_size == record['bytes'] and sha(rootfile) == record['sha256'] # The checks read the tree the image was made from; orchard.image.root_image made it, and checks it. - loaders = [value for key, value in manifest.items() - if isinstance(value, dict) and key not in ('orchard', 'install', 'root', 'logo', 'install_tools', - 'platforms')] if arch == 'aarch64': - assert 'pongo' in manifest and 'm1n1' in manifest - for row in (manifest['root'], manifest['root']['map'], manifest['logo'], *loaders, manifest['install_tools']): + assert 'pongo' in manifest and 'm1n1' in manifest and 'logo' in manifest + files = [manifest['logo'], manifest['pongo'], manifest['m1n1']] + else: # a board's Cascadia loader each; no Pongo, m1n1 or logo + assert manifest['boot_method'] == 'recovery-autogo' and manifest['cascadia'] + assert not {'pongo', 'm1n1', 'logo'} & manifest.keys() + files = list(manifest['cascadia'].values()) + for row in (manifest['root'], manifest['root']['map'], *files, manifest['install_tools']): path = directory / row['file'] assert path.stat().st_size == row['bytes'] and sha(path) == row['sha256'], row['file'] entries = {} diff --git a/build.py b/build.py index cb47523..0f2da30 100644 --- a/build.py +++ b/build.py @@ -24,11 +24,11 @@ PROJECT = Path(__file__).resolve().parent POLICY = PROJECT / 'system/image_policy.py' -# Per root architecture: the index orchard.image.platforms_index writes (arm64: platforms.toml; the 32-bit -# platforms have their own), and how the release's boards start when the index doesn't say. +# Per root architecture: orchard's name for it, the index orchard.image.platforms_index writes, and the +# boot method of its manifest (orchard docs/distribution-contract.md, "32-bit roots"). ARCHITECTURES = { - 'aarch64': {'index': 'platforms.toml', 'boot_method': 'checkm8-pongo'}, - 'armv7': {'index': 'platforms-armv7.toml', 'boot_method': 'cascadia'}, + 'aarch64': {'orchard': 'arm64', 'index': 'platforms.toml', 'boot_method': 'checkm8-pongo'}, + 'armv7': {'orchard': 'arm', 'index': 'platforms-armv7.toml', 'boot_method': 'recovery-autogo'}, } # An armv7 root has Alpine's udhcpd (busybox-extras) where an arm64 root has orchard's static BusyBox. ARMV7_BUSYBOX = 'usr/bin/busybox-extras' @@ -181,7 +181,7 @@ def manifests(output, version, root, offered, orchard, arch='aarch64'): ROOT is orchard.image.root_image's table, OFFERED orchard's index of the architecture beside it. """ - boot_method = offered.get('boot_method', ARCHITECTURES[arch]['boot_method']) + boot_method = ARCHITECTURES[arch]['boot_method'] document = ('schema = 2\nkind = "orchard.install"\ndistro = "minimal-linux"\n' 'name = "Minimal Alpine"\nversion = ' + json.dumps(version) + '\n' 'boot_method = ' + json.dumps(boot_method) + '\nmin_orchard = "0.2.0"\nprotocol = 1\n') @@ -193,10 +193,13 @@ def manifests(output, version, root, offered, orchard, arch='aarch64'): 'passwordless_sudo': False}) document += table('root', {key: value for key, value in root.items() if key != 'map'}) document += table('root.map', root['map']) - document += table('logo', receipt(output / 'logo.bin')) # the Alpine mark, drawn by orchard's m1n1 - # orchard's loaders as its index names them (arm64: pongo and m1n1). - for name, row in offered['loaders'].items(): - document += table(name, row) + if arch == 'aarch64': + document += table('logo', receipt(output / 'logo.bin')) # the Alpine mark, drawn by orchard's m1n1 + document += table('pongo', offered['loaders']['pongo']) + table('m1n1', offered['loaders']['m1n1']) + else: + # A 32-bit release names each board's Cascadia loader; it has no Pongo, m1n1 or logo. + for board, row in sorted(offered['loaders']['cascadia'].items()): + document += table(f'cascadia.{board}', row) document += table('install_tools', offered['install_tools']) for name, layer in sorted(offered['platforms'].items()): document += f'\n[platforms.{name}]\nboards = {json.dumps(layer["boards"])}\n' @@ -238,14 +241,18 @@ def main(): output = Path(temp) / 'release' output.mkdir() root_archive(args.base, args.base_receipt, components, output, args.version, orchard, args.arch) - source = (('--release', args.orchard_release, *(part for name in selected for part in ('--platform', name))) + # --architecture only for 32-bit: the arm64 index is the default (and older orchards lack the option). + source = (('--release', args.orchard_release, + *(('--architecture', ARCHITECTURES[args.arch]['orchard']) if args.arch != 'aarch64' else ()), + *(part for name in selected for part in ('--platform', name))) if args.orchard_release else ('--inputs', args.orchard_inputs)) orchard_command(orchard, 'image.platforms_index', output, *source, stdout=subprocess.DEVNULL) offered = tomllib.loads((output / ARCHITECTURES[args.arch]['index']).read_text()) revision = revision or {'commit': offered['commit'], 'modified': False} orchard_command(orchard, 'image.root_image', output / 'rootfs.tar.gz', '--output', output) root = json.loads((output / 'root-image.json').read_text()) - shutil.copyfile(components['logo'], output / 'logo.bin') + if args.arch == 'aarch64': + shutil.copyfile(components['logo'], output / 'logo.bin') manifests(output, args.version, root, offered, revision, args.arch) public_inputs = {**inputs, 'files': {name: {k: v for k, v in row.items() if k != 'path'} for name, row in inputs['files'].items()}} diff --git a/tests/test_armv7_root.py b/tests/test_armv7_root.py index bb9c0a7..7b9a4bc 100644 --- a/tests/test_armv7_root.py +++ b/tests/test_armv7_root.py @@ -143,20 +143,22 @@ class Manifest(unittest.TestCase): def test_loaders_and_boards_come_from_the_32_bit_index(self): with tempfile.TemporaryDirectory() as directory: source = Path(directory) - for name in ('root.blocks.zst', 'root.map.json', 'logo.bin', 'cascadia.bin', 'install-tools-armv7.tar.gz', + for name in ('root.blocks.zst', 'root.map.json', 'cascadia-p105.bin', 'install-tools-armv7.tar.gz', 'platform-apple-armv7.tar.gz'): (source / name).write_bytes(name.encode()) root = {**build.receipt(source / 'root.blocks.zst'), 'compression': 'zstd', 'format': 'blocks', 'expanded_bytes': 100, 'members': 3, 'map': build.receipt(source / 'root.map.json')} - offered = {'loaders': {'cascadia': build.receipt(source / 'cascadia.bin')}, + # orchard's platforms-armv7.toml: a Cascadia loader per board. + offered = {'architecture': 'arm', 'loaders': {'cascadia': {'p105': build.receipt(source / 'cascadia-p105.bin')}}, 'install_tools': build.receipt(source / 'install-tools-armv7.tar.gz'), 'platforms': {'apple-armv7': {**build.receipt(source / 'platform-apple-armv7.tar.gz'), 'compression': 'gzip', 'expanded_bytes': 10, 'members': 2, 'kernel_release': '6.12-armv7', 'boards': ['p105']}}} manifest = tomllib.loads(build.manifests(source, '1', root, offered, {'commit': 'c' * 40, 'modified': False}, 'armv7').read_text()) - self.assertEqual(manifest['boot_method'], 'cascadia') - self.assertEqual(manifest['cascadia']['file'], 'cascadia.bin') + self.assertEqual(manifest['boot_method'], 'recovery-autogo') + self.assertEqual(manifest['cascadia']['p105']['file'], 'cascadia-p105.bin') + self.assertNotIn('logo', manifest) self.assertNotIn('pongo', manifest) self.assertNotIn('m1n1', manifest) self.assertEqual(manifest['platforms']['apple-armv7']['boards'], ['p105'])