diff --git a/BUILD.md b/BUILD.md index 9058c47..9e4138a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -17,9 +17,10 @@ orchard build [--version VERSION] builds a release into `out/releases/VERSION` (default `dev-DATE-TIME`) through `tools/build.py`, the entry `orchard.toml` names: each component below is kept in `out/components/NAME/KEY/`, keyed by its files, and rebuilt only when they -change; the release is assembled and audited every time. `orchard run` then 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. +change; the release is assembled and audited every time. `orchard run` starts +the selected kernel on the attached device and `orchard install` installs the +release. `orchard build --platform apple-armv7` builds the 32-bit (armv7h) +release instead. The rest of this page describes the producer steps. ## Local setup @@ -47,17 +48,20 @@ Python 3.11. - `desktop/build-sources` and `desktop/build`: desktop applications and payload. - `system/build-rootfs`: signed Arch Linux ARM base and package receipt; see [system/README.md](system/README.md). - orchard supplies the kernel, DTBs and modules of every platform, its platform layer, and the static BusyBox/Dropbear recipes; see [orchard's build instructions](https://github.com/OrchardLinux/orchard/blob/main/BUILD.md). Local assembly builds or reuses each platform's kernel through orchard's shared cache. Build the static tools separately; supply explicit kernel bundles only when overriding that default. -- `python boot/build-artwork --output DIR`: the Omarchy mark as data for orchard's m1n1: `splash` (480 px, painted by m1n1 for an installed boot entry) and `logo.bin` (m1n1's custom logo payload, 256 and 128 px, shown by the recovery). No m1n1 is built here; releases boot orchard's, `quiet/m1n1.bin` from the component bundle. -- Pongo is not built here. Releases ship orchard's one Pongo, `quiet/pongo.bin` from the orchard component bundle (black screen; the logo appears with m1n1). +- `python boot/build-artwork --output DIR`: the Omarchy mark: `splash` (480 px, painted for an installed boot entry) and `logo.bin` (256 and 128 px). +- No loader is built here: a release names orchard's loaders as orchard's index lists them (arm64: Pongo and m1n1). Retain each component's receipt. Do not change hardware activation or timing as part of presentation changes. ## Assemble The following example uses distro-owned components collected under `out/components/`. -A release covers every platform: one root image, and per platform a recovery -(`recovery-PLATFORM.bin`) and orchard's platform layer (`platform-PLATFORM.tar.gz`: -kernel, every board's DTB, modules), which the installer writes into the root. +A release has one root of one userspace architecture, and the same shape for +both: the root as a block image (`root.blocks.zst`, `root.map.json`), its logo, +and orchard's platform layers, loaders and install tools, which +`orchard.image.platforms_index` supplies from orchard's index of that +architecture (`platforms.toml` for arm64, the separate 32-bit index for +armv7h). No recovery image is part of a release. The kernels come from the same orchard builder and cache as `orchard build` in orchard's examples; matching inputs reuse the compiled bundles. Boot artwork, userspace and assembly remain owned by this repository; the m1n1 is orchard's. @@ -71,32 +75,37 @@ python -m release.build \ --inputs out/inputs.json \ --base out/components/base/rootfs.tar.gz \ --base-receipt out/components/base/rootfs.tar.gz.json \ - --loader out/components/quiet/m1n1.bin \ --splash out/components/artwork/splash \ --logo out/components/artwork/logo.bin \ - --pongo out/components/quiet/pongo.bin \ --busybox out/components/boot/busybox \ --dropbear out/components/boot/dropbear \ --dropbearkey out/components/boot/dropbearkey \ - --wpa-supplicant out/components/boot/wpa_supplicant \ - --wpa-cli out/components/boot/wpa_cli \ - --iw out/components/boot/iw \ --cursor-tools out/components/desktop/cursor-tools \ --bluetooth-tools out/components/desktop/bluetooth-tools \ --labwc-tools out/components/desktop/labwc-tools \ --output out/releases/0.1.0-orchard.3 ``` -To use a staged kernel bundle instead (as CI does with orchard's published -component bundles), pass `--kernel-dir PLATFORM=DIRECTORY` per platform; the -others still come from `--inputs`, and without `--inputs` the given bundles are -the release's platforms. `--platform PLATFORM` builds only that -platform, for development; a release covers every platform. Each recovery carries -the DTB of every board of its platform; m1n1 boots with the one matching the -device. The cache respects `XDG_CACHE_HOME` and `ORCHARD_CACHE_DIR`, just like RAM -builds. - -Choose a new version and output directory for each build. Root builds first; recoveries embed its exact stream receipt, and `release.toml` and the index are written last. The installed root carries no Orchard device runtime: it boots directly from the boot entry orchard's installer writes. +To use a staged kernel bundle, pass `--kernel-dir PLATFORM=DIRECTORY` to +`orchard.inputs`. Select a subset there with `--platform PLATFORM`; the index +consumes that document's exact selection. `release.build --orchard-release TAG` +takes finished AArch64 files from a published Orchard build and accepts +`--platform PLATFORM` to select its layers. `--architecture armv7h` builds the +32-bit release; from a published build it names its platforms +(`--platform apple-armv7`). The cache respects `XDG_CACHE_HOME` +and `ORCHARD_CACHE_DIR`, just like RAM builds. + +Choose a new version and output directory for each build. Root builds first, +then orchard's files and the block image; `release.toml` and the index are +written last. The cached base retains `base-` in +`etc/ipadlinux/release`; `etc/orchard/release.json` identifies the final +release. + +For the ARMv7 compiler and build, see +[ARMv7 desktop components](desktop/armv7/README.md). Building the armv7h +desktop and base runs ARMv7 code: the builder image needs a Podman connection +that executes ARM32 binaries (emulated on an arm64 host, 1 to 3 hours). The +release step itself runs only arm64 tools. ## Validate without a device @@ -106,7 +115,10 @@ python -m release.audit out/releases/0.1.0-orchard.3/release.toml PYTHONPATH=$ORCHARD_CHECKOUT/src python -m orchard.manifests --verify-local out/releases/0.1.0-orchard.3/index.toml ``` -The auditor verifies, per platform, the recovery's contents, endpoints and loader framing, orchard's layer against its receipt and SHA256SUMS, and that recovery and layer carry the same modules; for the root, desktop/privilege policy, that it holds no file of any layer, and the absence of private session/firmware data. +The auditor checks the block image, map, logo, loaders and install tools, the +platform layers against their receipts and SHA256SUMS, desktop integrity, +package privileges, that the desktop helpers are of the root's architecture, +and the absence of private session and firmware data. Headless finished-artifact checks use Podman without network or device passthrough: diff --git a/desktop/armv7/README.md b/desktop/armv7/README.md new file mode 100644 index 0000000..60c1ba8 --- /dev/null +++ b/desktop/armv7/README.md @@ -0,0 +1,59 @@ +# ARMv7 desktop components + +`desktop/build --architecture armv7h` builds the same desktop payload for an +ARMv7 Arch Linux ARM userspace. The existing AArch64 signed Omacalc input +continues to serve AArch64 builds. ARMv7 builds compile Omacalc from the source +recorded in `sources.json`, with the recipe in `../packages/omacalc`. + +The Omacalc archive is pinned to upstream v0.3.1, commit +`573d7106f5c6c0225864eb8634ae37ddcb657f63`. Its SHA-256 was measured from the +265,215-byte GitHub commit archive. The recipe installs upstream's MIT license +and the bundled font's OFL notice, and runs the upstream Qt tests offscreen. + +A provisioned native ARMv7 Arch Linux ARM environment can run the recipes with +`ORCHARD_BUILD_NATIVE=1` and `ORCHARD_BUILD_ENV_ID` set to its reproducible image +identity. Orchard's ARMv7 build environment support is required. Container +builds take `--builder-image IMAGE`, naming an ARMv7 Arch Linux ARM compiler +with the dependencies from `desktop/install-toolchain` and +`desktop/labwc/install-toolchain`, plus `qt6-base`, `qt6-declarative` and +`qt6-tools`. The same option is accepted by the component builders. The +repository's pinned AArch64 container is selected by AArch64 builds. + +`system/build-armv7-builder` prepares that compiler image from the official +Arch Linux ARM bootstrap pinned in `system/armv7/bootstrap.json`: one dated +build, fetched from its copy in this repository's releases (upstream only +publishes a "latest" that it replaces), else from upstream while that is still +the same build. It verifies the archive's SHA-256 and detached signature +against Arch Linux ARM's build key before importing it. The selected Podman connection must already support +ARM32 execution. An emulator is a host prerequisite; the recipe leaves its +registration and the default connection to the person managing the host. + +```sh +system/build-armv7-builder --receipt out/armv7-builder.json +python3 tools/build.py --orchard /path/to/orchard --platform apple-armv7 \ + --version armv7-development --output out/armv7-development +``` + +For an isolated builder, pass `--connection NAME` to the compiler recipe and +set `CONTAINER_CONNECTION=NAME` for the release build. The compiler receipt +records its image ID and installed package versions. Component cache keys +include the target architecture and the selected compiler image ID. + +`tools/build.py` uses `localhost/omarchy-a-arch-builder:armv7h`, what this +recipe makes, unless `--builder-image` names another. + +Each release contains one userspace ISA. Selecting `apple-armv7` chooses +`armv7h`; mixing it with AArch64 platforms is rejected before components +build. The release is the root as a block image with orchard's 32-bit +platform layer, loaders and install tools, like the arm64 one; orchard's +inputs supply its armv7 static tools (BusyBox, Dropbear). + +The producers check the compiler's machine and the resulting ELF headers. +Package verification checks `.PKGINFO` and the ELF headers inside each package, +including the ARM EABI5 hard-float flags. Each component receipt records the +userspace architecture, and the release refuses a component, base or static +tool of another one. + +These source recipes require a full ARMv7 component build and desktop smoke +check before hardware qualification. Tests of the architecture guards use +synthetic packages and executable headers. diff --git a/desktop/armv7/sources.json b/desktop/armv7/sources.json new file mode 100644 index 0000000..a880a88 --- /dev/null +++ b/desktop/armv7/sources.json @@ -0,0 +1,10 @@ +{ + "omacalc": { + "version": "0.3.1", + "commit": "573d7106f5c6c0225864eb8634ae37ddcb657f63", + "urls": [ + "https://github.com/omacom/omacalc/archive/573d7106f5c6c0225864eb8634ae37ddcb657f63.tar.gz" + ], + "sha256": "6a4ad731acb4f68226b975e1b817b2907fea1534d52d1e769564a76a48f3eafc" + } +} diff --git a/desktop/build b/desktop/build index 21339a9..07b3e42 100755 --- a/desktop/build +++ b/desktop/build @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build common AArch64 desktop components and stage the pinned Omarchy UI.""" +"""Build target desktop components and stage the pinned Omarchy UI.""" import argparse import hashlib import json @@ -10,6 +10,7 @@ import subprocess import sys import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, source_pins, check_package native = buildenv.native HERE = Path(__file__).resolve().parent @@ -39,9 +40,9 @@ def recipe_sha(directory): return digest.hexdigest() -def source_metadata(built): - pins = json.loads((HERE / 'sources.json').read_text()) - if built['architecture'] != 'aarch64' or built['source_sha256'] != { +def source_metadata(built, architecture='aarch64'): + pins = source_pins(architecture) + if built['architecture'] != architecture or built['source_sha256'] != { name: entry['sha256'] for name, entry in pins.items()}: raise ValueError('Source-build pins differ') if any(built['source_urls'][name] not in entry['urls'] for name, entry in pins.items()): @@ -50,26 +51,36 @@ def source_metadata(built): built['pkgbuild_sha256'] != sha(HERE / 'packages/omafox/PKGBUILD') or built.get('wlroots_recipe_sha256') != recipe_sha(HERE / 'packages/wlroots0.20')): raise ValueError('Source-build recipe differs') - if set(built['files']) != {'wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz'}: + expected = {'wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz'} + if architecture == 'armv7h': + expected.add('omacalc.pkg.tar.zst') + if built.get('omacalc_recipe_sha256') != recipe_sha(HERE / 'packages/omacalc'): + raise ValueError('Omacalc source-build recipe differs') + if set(built['files']) != expected: raise ValueError('Source-build files differ') return built['files'] -def verify(directory): - pins = json.loads(PINS.read_text()) +def verify(directory, architecture=None): meta = json.loads((directory / 'manifest.json').read_text()) + architecture = validate(architecture or meta['architecture']) + pins = json.loads(PINS.read_text()) + files = FILES if architecture == 'aarch64' else {'omafox_xpi': FILES['omafox_xpi']} lock = dict(line.split('=', 1) for line in (HERE / 'upstream.lock').read_text().splitlines() if '=' in line) - if (meta['architecture'] != pins['architecture'] or + if (meta['architecture'] != architecture or meta['omarchy_commit'] != lock['omarchy_commit'] or - meta['inputs'] != {name: pins[name] for name in FILES}): + meta['inputs'] != {name: pins[name] for name in files}): raise ValueError('Desktop input manifest differs') - built_files = source_metadata(meta['source_build']) + built_files = source_metadata(meta['source_build'], architecture) packages = directory / 'packages' if sha(packages / 'omafox.pkg.tar.xz') != built_files['omafox.pkg.tar.xz']: raise ValueError('Source-built Omafox package differs') if sha(packages / 'wlroots0.20.pkg.tar.xz') != built_files['wlroots0.20.pkg.tar.xz']: raise ValueError('Source-built wlroots package differs') - if sha(packages / 'omacalc.pkg.tar.zst') != pins['omacalc_package']: + omacalc_sha = pins['omacalc_package'] if architecture == 'aarch64' else built_files['omacalc.pkg.tar.zst'] + for name in ('omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz', 'omacalc.pkg.tar.zst'): + check_package(packages / name, architecture) + if sha(packages / 'omacalc.pkg.tar.zst') != omacalc_sha: raise ValueError('Signed Omacalc package differs') payload = directory / 'payload' manifest = payload / 'arch-ipad-ui-manifest.json' @@ -84,6 +95,7 @@ def verify(directory): if p.is_file() and not p.is_symlink()} if actual != set(contents) | {'arch-ipad-ui-manifest.json'}: raise ValueError('Desktop UI files differ from manifest') + check_elf(payload / KEYBOARD_PATH, architecture, 'keyboard') if sha(payload / KEYBOARD_PATH) != built_files['wvkbd-mobintl']: raise ValueError('Source-built keyboard differs') if sha(payload / XPI_PATH) != pins['omafox_xpi']: @@ -91,18 +103,25 @@ def verify(directory): return meta -def build(output): +def build(output, architecture='aarch64', builder_image=None): + validate(architecture) if output.exists(): raise ValueError('Output already exists') pins = json.loads(PINS.read_text()) - for name, path in FILES.items(): + files = FILES if architecture == 'aarch64' else {'omafox_xpi': FILES['omafox_xpi']} + for name, path in files.items(): if sha(path) != pins[name]: raise ValueError(f'Pinned desktop input differs: {name}') lock = dict(line.split('=', 1) for line in (HERE / 'upstream.lock').read_text().splitlines() if '=' in line) commit = lock['omarchy_commit'] if len(commit) != 40 or any(c not in '0123456789abcdef' for c in commit): raise ValueError('Invalid Omarchy commit pin') - for tool in (('git', 'gpg') if native() else ('git', 'gpg', 'podman')): + required = ['git', 'bsdtar'] + if architecture == 'aarch64': + required.append('gpg') + if not native(): + required.append('podman') + for tool in required: if shutil.which(tool) is None: raise RuntimeError(f'{tool} is required') (HERE / '.work').mkdir(exist_ok=True) @@ -111,18 +130,22 @@ def build(output): with tempfile.TemporaryDirectory(prefix='desktop-', dir=HERE / '.work') as temporary, \ tempfile.TemporaryDirectory(prefix='oa-gpg-') as gnupg: work = Path(temporary) - subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--import', - str(INPUTS / 'omarchy-pkgs.asc')], check=True, stdout=subprocess.DEVNULL) - result = subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--status-fd', '1', - '--verify', str(INPUTS / 'omacalc.pkg.tar.zst.sig'), - str(FILES['omacalc_package'])], check=True, capture_output=True, text=True) - if not any(line.startswith('[GNUPG:] VALIDSIG ' + pins['omacalc_signer'] + ' ') - for line in result.stdout.splitlines()): - raise ValueError('Omacalc signature has the wrong signer') + if architecture == 'aarch64': + subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--import', + str(INPUTS / 'omarchy-pkgs.asc')], check=True, stdout=subprocess.DEVNULL) + result = subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--status-fd', '1', + '--verify', str(INPUTS / 'omacalc.pkg.tar.zst.sig'), + str(FILES['omacalc_package'])], check=True, capture_output=True, text=True) + if not any(line.startswith('[GNUPG:] VALIDSIG ' + pins['omacalc_signer'] + ' ') + for line in result.stdout.splitlines()): + raise ValueError('Omacalc signature has the wrong signer') sources = work / 'source-build' - subprocess.run([sys.executable, str(HERE / 'build-sources'), '--output', str(sources)], check=True) + options = ['--architecture', architecture] + if builder_image: + options.extend(['--builder-image', builder_image]) + subprocess.run([sys.executable, str(HERE / 'build-sources'), '--output', str(sources), *options], check=True) built = json.loads((sources / 'manifest.json').read_text()) - built_files = source_metadata(built) + built_files = source_metadata(built, architecture) for name, digest in built_files.items(): if sha(sources / name) != digest: raise ValueError(f'Source-built file differs: {name}') @@ -141,17 +164,18 @@ def build(output): subprocess.run([sys.executable, str(HERE / 'stage.py'), str(result_dir / 'payload'), '--upstream', str(upstream), '--keyboard', str(sources / 'wvkbd-mobintl'), '--keyboard-sha256', built_files['wvkbd-mobintl'], - '--omafox-xpi', str(FILES['omafox_xpi'])], check=True) + '--omafox-xpi', str(FILES['omafox_xpi']), '--architecture', architecture], check=True) packages = result_dir / 'packages' packages.mkdir() shutil.copyfile(sources / 'omafox.pkg.tar.xz', packages / 'omafox.pkg.tar.xz') shutil.copyfile(sources / 'wlroots0.20.pkg.tar.xz', packages / 'wlroots0.20.pkg.tar.xz') - shutil.copyfile(FILES['omacalc_package'], packages / 'omacalc.pkg.tar.zst') - meta = {'architecture': 'aarch64', 'omarchy_commit': commit, - 'inputs': {name: pins[name] for name in FILES}, 'source_build': built, + calculator = FILES['omacalc_package'] if architecture == 'aarch64' else sources / 'omacalc.pkg.tar.zst' + shutil.copyfile(calculator, packages / 'omacalc.pkg.tar.zst') + meta = {'architecture': architecture, 'omarchy_commit': commit, + 'inputs': {name: pins[name] for name in files}, 'source_build': built, 'payload_manifest_sha256': sha(result_dir / 'payload/arch-ipad-ui-manifest.json')} (result_dir / 'manifest.json').write_text(json.dumps(meta, indent=2) + '\n') - verify(result_dir) + verify(result_dir, architecture) output.parent.mkdir(parents=True, exist_ok=True) os.replace(result_dir, output) print('Desktop output:', output) @@ -162,12 +186,14 @@ def main(): action = parser.add_mutually_exclusive_group(required=True) action.add_argument('--output', type=Path) action.add_argument('--verify', type=Path) + parser.add_argument('--architecture', choices=('aarch64', 'armv7h')) + parser.add_argument('--builder-image', help='existing target compiler image') args = parser.parse_args() try: if args.output: - build(args.output.resolve()) + build(args.output.resolve(), args.architecture or 'aarch64', args.builder_image) else: - verify(args.verify) + verify(args.verify, args.architecture) print('Verified desktop output:', args.verify) except (OSError, KeyError, ValueError, subprocess.CalledProcessError) as exc: parser.error(str(exc)) diff --git a/desktop/build-cursor-helper b/desktop/build-cursor-helper index 73856db..262dc13 100755 --- a/desktop/build-cursor-helper +++ b/desktop/build-cursor-helper @@ -9,6 +9,7 @@ import shutil import subprocess import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, machine, podman_arch, builder, source_pins, check_package from urllib.request import urlopen import xml.etree.ElementTree as ET @@ -23,11 +24,13 @@ def sha(path): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True, type=Path) - parser.add_argument('--image', default=IMAGE, help='existing desktop compiler image') + parser.add_argument('--architecture', default='aarch64', choices=('aarch64', 'armv7h')) + parser.add_argument('--builder-image', '--image', dest='builder_image', help='existing target compiler image') args = parser.parse_args() output = args.output.resolve() if output.exists(): parser.error('Choose a new output directory') + image = builder(buildenv, args.architecture, args.builder_image) pin = json.loads((HERE / 'cursor/wtype.json').read_text()) cache = HERE / '.work/cursor' cache.mkdir(parents=True, exist_ok=True) @@ -49,12 +52,14 @@ wayland-scanner private-code protocol/virtual-keyboard-unstable-v1.xml virtual-k wayland-scanner client-header protocol/virtual-keyboard-unstable-v1.xml virtual-keyboard-unstable-v1-client-protocol.h # shellcheck disable=SC2046 # pkg-config emits compiler argument words. cc -O2 '-DVERSION="0.4"' -I. main.c virtual-keyboard-unstable-v1-protocol.c $(pkg-config --cflags --libs wayland-client wayland-cursor xkbcommon) -lrt -o "$ORCHARD_WORK/wtype" -readelf -h "$ORCHARD_WORK/wtype" | grep -q 'Machine:.*AArch64' +test "$(uname -m)" = "$OMARCHY_MACHINE" cp LICENSE "$ORCHARD_WORK/LICENSE" cp protocol/virtual-keyboard-unstable-v1.xml "$ORCHARD_WORK/protocol.xml" ''' - buildenv.run(args.image, ['sh', '-ec', script], [(work, '/work', False)]) - image = buildenv.identity(args.image) + buildenv.run(image, ['sh', '-ec', script], [(work, '/work', False)], + env={'OMARCHY_MACHINE': machine(args.architecture)}, arch=podman_arch(args.architecture)) + check_elf(work / 'wtype', args.architecture, 'cursor helper') + image_id = buildenv.identity(image) result = work / 'result' result.mkdir() for name in ('wtype', 'LICENSE'): @@ -63,7 +68,7 @@ cp protocol/virtual-keyboard-unstable-v1.xml "$ORCHARD_WORK/protocol.xml" protocol_license = ET.parse(work / 'protocol.xml').getroot().find('copyright').text.strip() with (result / 'LICENSE').open('a') as notices: notices.write('\nVirtual keyboard protocol:\n\n' + protocol_license + '\n') - record = {'schema': 1, 'architecture': 'aarch64', 'source': pin, 'compiler_image': image, + record = {'schema': 1, 'architecture': args.architecture, 'source': pin, 'compiler_image': image_id, 'files': {name: sha(result / name) for name in ('wtype', 'LICENSE')}} (result / 'manifest.json').write_text(json.dumps(record, indent=2) + '\n') output.parent.mkdir(parents=True, exist_ok=True) diff --git a/desktop/build-labwc b/desktop/build-labwc index 56da3cb..95b4d39 100644 --- a/desktop/build-labwc +++ b/desktop/build-labwc @@ -10,6 +10,7 @@ import subprocess import tarfile import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, machine, podman_arch, builder, source_pins, check_package import urllib.request HERE = Path(__file__).resolve().parent @@ -23,10 +24,13 @@ def sha(path): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True, type=Path) + parser.add_argument('--architecture', default='aarch64', choices=('aarch64', 'armv7h')) parser.add_argument('--archive', type=Path) + parser.add_argument('--builder-image', help='existing target compiler image') args = parser.parse_args() if args.output.exists(): parser.error('Choose a new Labwc output directory') + image = builder(buildenv, args.architecture, args.builder_image, labwc=True) pin = json.loads((HERE / 'labwc/source.json').read_text()) patch = HERE / 'labwc/0001-cancel-super-tap-on-pointer.patch' if sha(patch) != pin['patch_sha256']: @@ -34,8 +38,7 @@ def main(): data = args.archive.read_bytes() if args.archive else urllib.request.urlopen(pin['url'], timeout=30).read() if hashlib.sha256(data).hexdigest() != pin['sha256']: parser.error('Labwc source differs from pin') - buildenv.build(IMAGE, HERE / 'labwc/Containerfile', HERE / 'labwc') - image_id = buildenv.identity(IMAGE) + image_id = buildenv.identity(image) args.output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=args.output.parent, prefix='.labwc-') as directory: work = Path(directory).resolve() @@ -55,7 +58,7 @@ def main(): subprocess.run(['patch', '-p1', '-i', str(patch.resolve())], cwd=source, check=True) recipe = ''' set -eu -test "$(uname -m)" = aarch64 +test "$(uname -m)" = "$OMARCHY_MACHINE" meson setup "$ORCHARD_WORK/build" "$ORCHARD_SOURCE" --buildtype=release --wrap-mode=nofallback -Dman-pages=disabled -Dlabnag=disabled -Dxwayland=enabled -Dnls=enabled -Dsystemd-session=disabled meson compile -C "$ORCHARD_WORK/build" -j2 cp "$ORCHARD_WORK/build/labwc" "$ORCHARD_OUTPUT/labwc" @@ -63,12 +66,14 @@ pacman -Q > "$ORCHARD_OUTPUT/packages.lock" cc --version > "$ORCHARD_OUTPUT/compiler.txt" "$ORCHARD_OUTPUT/labwc" --version > "$ORCHARD_OUTPUT/version.txt" ''' - buildenv.run(IMAGE, ['sh', '-c', recipe], - [(source, '/source', True), (output, '/output', False)], root=True) + buildenv.run(image, ['sh', '-c', recipe], + [(source, '/source', True), (output, '/output', False)], root=True, + env={'OMARCHY_MACHINE': machine(args.architecture)}, arch=podman_arch(args.architecture)) + check_elf(output / 'labwc', args.architecture, 'Labwc') shutil.copyfile(source / 'LICENSE', output / 'LICENSE') shutil.copyfile(patch, output / '0001-cancel-super-tap-on-pointer.patch') (output / 'source.tar.gz').write_bytes(data) - record = {'schema': 1, 'source': pin, 'compiler_image': image_id, + record = {'schema': 1, 'architecture': args.architecture, 'source': pin, 'compiler_image': image_id, 'build_options': ['release', 'nofallback', 'xwayland=enabled', 'nls=enabled', 'labnag=disabled'], 'files': {p.name: sha(p) for p in sorted(output.iterdir())}} (output / 'manifest.json').write_text(json.dumps(record, indent=2) + '\n') diff --git a/desktop/build-sources b/desktop/build-sources index 9cc0f4a..8ba134d 100755 --- a/desktop/build-sources +++ b/desktop/build-sources @@ -9,6 +9,7 @@ import shutil import subprocess import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, machine, podman_arch, builder, source_pins, check_package from urllib.error import HTTPError, URLError from urllib.request import urlopen @@ -52,19 +53,22 @@ def fetch(entry, target): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--architecture', default='aarch64', choices=('aarch64', 'armv7h')) + parser.add_argument('--builder-image', help='existing target compiler image') args = parser.parse_args() output = args.output.resolve() if output.exists(): parser.error(f'Output exists: {output}') if not buildenv.native() and not shutil.which('podman'): parser.error('Podman is required') - pins = json.loads((HERE / 'sources.json').read_text()) + pins = source_pins(args.architecture) pkgbuild = HERE / 'packages/omafox/PKGBUILD' if pins['omafox']['sha256'] not in pkgbuild.read_text(): parser.error('Omafox PKGBUILD and source pin differ') wlroots = HERE / 'packages/wlroots0.20/PKGBUILD' if pins['wlroots']['sha256'] not in wlroots.read_text(): parser.error('wlroots PKGBUILD and source pin differ') + image = builder(buildenv, args.architecture, args.builder_image) (HERE / '.work').mkdir(exist_ok=True) try: with tempfile.TemporaryDirectory(prefix='sources-', dir=HERE / '.work') as temporary: @@ -76,11 +80,12 @@ def main(): 'omafox': fetch(pins['omafox'], sources / 'omafox-1.1.0.tar.gz'), 'wlroots': fetch(pins['wlroots'], sources / 'wlroots-0.20.2.tar.gz'), } - print('Preparing AArch64 desktop builder...', flush=True) - buildenv.build(IMAGE, HERE / 'Containerfile', HERE) + if args.architecture == 'armv7h': + fetched['omacalc'] = fetch(pins['omacalc'], sources / 'omacalc-0.3.1.tar.gz') print('Building keyboard and Omafox package...', flush=True) script = r''' set -euo pipefail +test "$(uname -m)" = "$OMARCHY_MACHINE" build=$(mktemp -d "$ORCHARD_TMP/desktop.XXXXXX") trap 'rm -rf "$build"' EXIT mkdir -p "$build/keyboard" "$build/omafox" "$build/result" "$ORCHARD_WORK/result" @@ -93,35 +98,56 @@ fi tar -xzf "$ORCHARD_WORK/sources/wvkbd-v0.20.tar.gz" --strip-components=1 -C "$build/keyboard" patch --batch --fuzz=0 -d "$build/keyboard" -p1 < "$ORCHARD_RECIPE/keyboard/wvkbd-0.20.patch" make -C "$build/keyboard" -j2 wvkbd-mobintl -readelf -h "$build/keyboard/wvkbd-mobintl" | grep -q 'Machine:.*AArch64' +readelf -h "$build/keyboard/wvkbd-mobintl" >/dev/null cp "$build/keyboard/wvkbd-mobintl" "$ORCHARD_WORK/result/wvkbd-mobintl" cp "$ORCHARD_RECIPE/packages/omafox/PKGBUILD" "$ORCHARD_RECIPE/packages/omafox/omafox.install" "$build/omafox/" cp "$ORCHARD_WORK/sources/omafox-1.1.0.tar.gz" "$build/omafox/omafox-1.1.0.tar.gz" cd "$build/omafox" -PKGDEST="$build/result" makepkg --noconfirm --nodeps -package="$build/result/omafox-1.1.0-1-aarch64.pkg.tar.xz" +PKGDEST="$build/result" PKGEXT='.pkg.tar.xz' makepkg --noconfirm --nodeps +package="$build/result/omafox-1.1.0-1-$OMARCHY_ARCH.pkg.tar.xz" test -s "$package" -bsdtar -xOf "$package" .PKGINFO | grep -x 'arch = aarch64' >/dev/null +bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $OMARCHY_ARCH" >/dev/null cp "$package" "$ORCHARD_WORK/result/omafox.pkg.tar.xz" mkdir -p "$build/wlroots" cp "$ORCHARD_RECIPE"/packages/wlroots0.20/* "$build/wlroots/" cp "$ORCHARD_WORK/sources/wlroots-0.20.2.tar.gz" "$build/wlroots/" cd "$build/wlroots" -PKGDEST="$build/result" makepkg --noconfirm --nodeps -package=$(ls "$build"/result/wlroots0.20-0.20.2-*-aarch64.pkg.tar.*) +PKGDEST="$build/result" PKGEXT='.pkg.tar.xz' makepkg --noconfirm --nodeps +package=$(ls "$build"/result/wlroots0.20-0.20.2-*-$OMARCHY_ARCH.pkg.tar.*) test -s "$package" -bsdtar -xOf "$package" .PKGINFO | grep -x 'arch = aarch64' >/dev/null +bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $OMARCHY_ARCH" >/dev/null cp "$package" "$ORCHARD_WORK/result/wlroots0.20.pkg.tar.xz" ''' - buildenv.run(IMAGE, ['bash', '-euc', script], - [(work, '/work', False), (HERE, '/recipe', True)]) + if args.architecture == 'armv7h': + script += r''' +mkdir -p "$build/omacalc" +cp "$ORCHARD_RECIPE/packages/omacalc/PKGBUILD" "$build/omacalc/" +cp "$ORCHARD_WORK/sources/omacalc-0.3.1.tar.gz" "$build/omacalc/" +cd "$build/omacalc" +QT_QPA_PLATFORM=offscreen PKGDEST="$build/result" PKGEXT='.pkg.tar.zst' makepkg --noconfirm --nodeps +package="$build/result/omacalc-0.3.1-1-$OMARCHY_ARCH.pkg.tar.zst" +bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $OMARCHY_ARCH" >/dev/null +cp "$package" "$ORCHARD_WORK/result/omacalc.pkg.tar.zst" +''' + buildenv.run(image, ['bash', '-euc', script], + [(work, '/work', False), (HERE, '/recipe', True)], + env={'OMARCHY_MACHINE': machine(args.architecture), 'OMARCHY_ARCH': args.architecture}, + arch=podman_arch(args.architecture)) built = work / 'result' - files = {name: sha(built / name) for name in ('wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz')} - meta = {'architecture': 'aarch64', 'source_urls': fetched, + check_elf(built / 'wvkbd-mobintl', args.architecture, 'keyboard') + names = ['wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz'] + if args.architecture == 'armv7h': + names.append('omacalc.pkg.tar.zst') + for name in names[1:]: + check_package(built / name, args.architecture) + files = {name: sha(built / name) for name in names} + meta = {'architecture': args.architecture, 'source_urls': fetched, 'source_sha256': {name: pins[name]['sha256'] for name in pins}, 'patch_sha256': sha(HERE / 'keyboard/wvkbd-0.20.patch'), 'pkgbuild_sha256': sha(pkgbuild), 'wlroots_recipe_sha256': recipe_sha(HERE / 'packages/wlroots0.20'), 'files': files} + if args.architecture == 'armv7h': + meta['omacalc_recipe_sha256'] = recipe_sha(HERE / 'packages/omacalc') (built / 'manifest.json').write_text(json.dumps(meta, indent=2) + '\n') output.parent.mkdir(parents=True, exist_ok=True) os.replace(built, output) diff --git a/desktop/install-toolchain b/desktop/install-toolchain index 6cba335..42102ab 100644 --- a/desktop/install-toolchain +++ b/desktop/install-toolchain @@ -12,7 +12,7 @@ rank-mirrors pacman-key --init pacman-key --populate archlinuxarm # The base image's kernel and firmware are not needed to build: never download them. -mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-firmware.*)$') +mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-armv7|linux-firmware.*)$') (( ${#unneeded[@]} == 0 )) || pacman -Rdd --noconfirm "${unneeded[@]}" packages=(base-devel wayland libxkbcommon pango scdoc pkgconf patch python python-pip git curl tar gzip # wlroots0.20 (packages/wlroots0.20): Arch's makedepends and the libraries it links diff --git a/desktop/labwc/install-toolchain b/desktop/labwc/install-toolchain index 057d973..ad7e6bb 100644 --- a/desktop/labwc/install-toolchain +++ b/desktop/labwc/install-toolchain @@ -5,7 +5,7 @@ set -euo pipefail # also in CI). rank-mirrors # The base image's kernel and firmware are not needed to build: never download them. -mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-firmware.*)$') +mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-armv7|linux-firmware.*)$') (( ${#unneeded[@]} == 0 )) || pacman -Rdd --noconfirm "${unneeded[@]}" packages=(meson wlroots0.20 wayland-protocols libsfdo libxml2 glib2 cairo pango librsvg xcb-util-wm xorg-xwayland gettext) diff --git a/desktop/packages/omacalc/PKGBUILD b/desktop/packages/omacalc/PKGBUILD new file mode 100644 index 0000000..485388d --- /dev/null +++ b/desktop/packages/omacalc/PKGBUILD @@ -0,0 +1,36 @@ +# Omarchy A: ARMv7 build of Omacalc's pinned, MIT-licensed source. +# Corresponding source: omacom/omacalc v0.3.1, commit 573d7106f5c6c0225864eb8634ae37ddcb657f63. +pkgname=omacalc +pkgver=0.3.1 +pkgrel=1 +pkgdesc="Omarchy calculator" +arch=('armv7h') +url='https://github.com/omacom/omacalc' +license=('MIT' 'OFL-1.1') +depends=('qt6-base' 'qt6-declarative' 'xdg-desktop-portal') +makedepends=('qt6-tools') +_commit=573d7106f5c6c0225864eb8634ae37ddcb657f63 +source=("$pkgname-$pkgver.tar.gz::$url/archive/$_commit.tar.gz") +sha256sums=('6a4ad731acb4f68226b975e1b817b2907fea1534d52d1e769564a76a48f3eafc') + +build() { + cd "$srcdir/omacalc-$_commit" + mkdir -p build + cd build + qmake6 ../omacalc.pro + make +} + +check() { + cd "$srcdir/omacalc-$_commit" + sh bin/test +} + +package() { + cd "$srcdir/omacalc-$_commit" + install -Dm755 build/omacalc "$pkgdir/usr/bin/omacalc" + install -Dm644 pkgbuild/omacalc.desktop "$pkgdir/usr/share/applications/omacalc.desktop" + install -Dm644 pkgbuild/omacalc.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/omacalc.svg" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/omacalc/LICENSE" + install -Dm644 fonts/OFL.txt "$pkgdir/usr/share/licenses/omacalc/OFL.txt" +} diff --git a/desktop/packages/omafox/PKGBUILD b/desktop/packages/omafox/PKGBUILD index cad6390..0deed8a 100644 --- a/desktop/packages/omafox/PKGBUILD +++ b/desktop/packages/omafox/PKGBUILD @@ -4,7 +4,7 @@ pkgname=omafox pkgver=1.1.0 pkgrel=1 pkgdesc='Synchronize Firefox and Zen Browser themes with Omarchy' -arch=('x86_64' 'aarch64') +arch=('x86_64' 'aarch64' 'armv7h') url='https://github.com/cover/omafox' license=('MIT') depends=('bash' 'glibc') diff --git a/desktop/packages/wlroots0.20/PKGBUILD b/desktop/packages/wlroots0.20/PKGBUILD index 79b2039..9ae4174 100644 --- a/desktop/packages/wlroots0.20/PKGBUILD +++ b/desktop/packages/wlroots0.20/PKGBUILD @@ -10,7 +10,7 @@ pkgrel=1.6 pkgdesc="Modular Wayland compositor library" url="https://gitlab.freedesktop.org/wlroots/wlroots" license=('MIT') -arch=('x86_64' 'aarch64') +arch=('x86_64' 'aarch64' 'armv7h') depends=('glibc' 'libdisplay-info.so' 'libdrm' 'libglvnd' 'libinput' 'liblcms2.so' 'libliftoff.so' 'libpixman-1.so' 'libseat.so' 'libudev.so' 'libvulkan.so' 'libwayland-client.so' 'libwayland-server.so' 'libxcb' 'libxkbcommon.so' 'wayland-protocols' 'opengl-driver' 'xcb-util-errors' 'xcb-util-renderutil' 'xcb-util-wm') diff --git a/desktop/rank-mirrors b/desktop/rank-mirrors index 34fa8c9..c637a79 100755 --- a/desktop/rank-mirrors +++ b/desktop/rank-mirrors @@ -24,6 +24,7 @@ candidates=( mirrors.tuna.tsinghua.edu.cn/archlinuxarm mirrors.ustc.edu.cn/archlinuxarm ) arch=$(uname -m) +case "$arch" in armv7l|armv7|armhf) arch=armv7h;; esac work=$(mktemp -d) trap 'rm -rf -- "$work"' EXIT diff --git a/desktop/stage.py b/desktop/stage.py index ad7fb38..411db39 100644 --- a/desktop/stage.py +++ b/desktop/stage.py @@ -10,8 +10,12 @@ import json from pathlib import Path import shutil +import sys HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) +from target import check_elf PORT = HERE / "shell" CONFIG = HERE / "configuration" INPUTS = json.loads((HERE / 'arm64/inputs.json').read_text()) @@ -36,12 +40,14 @@ def main(): parser.add_argument("--keyboard", type=Path, required=True, help="built wvkbd-mobintl binary") parser.add_argument("--keyboard-sha256", required=True, help="hash from the source-build manifest") parser.add_argument("--omafox-xpi", type=Path, required=True, help="signed Omafox extension") + parser.add_argument("--architecture", default="aarch64", choices=("aarch64", "armv7h")) args = parser.parse_args() FORK, KEYBOARD, XPI = args.upstream, args.keyboard, args.omafox_xpi target = args.target.resolve() if target.exists() or target == Path("/") or target.is_relative_to(Path("/Volumes")): parser.error("target must be a new local staging directory, not an existing/mounted volume") checked(KEYBOARD, args.keyboard_sha256) + check_elf(KEYBOARD, args.architecture, "keyboard") checked(XPI, XPI_SHA) source = target / "home/tablet/.local/share/omarchy-ipad" # Copy shell sources and immutable upstream theme assets. @@ -122,6 +128,12 @@ def main(): (target / "usr/local/libexec/arch-ipad-rotate-output").chmod(0o755) (target / "usr/local/libexec/arch-ipad-touch-output").chmod(0o755) (target / "usr/local/libexec/arch-ipad-login-session").chmod(0o755) + # The base overlay preserves directory modes. Set the target layout + # explicitly so a private build umask cannot block the tablet session. + home = target / "home/tablet" + for path in [target, *target.rglob("*")]: + if not path.is_symlink() and path.is_dir(): + path.chmod(0o700 if path.is_relative_to(home) else 0o755) # Record a content hash for each staged payload file. files = sorted(p for p in target.rglob("*") if p.is_file() and not p.is_symlink()) manifest = {str(p.relative_to(target)): hashlib.sha256(p.read_bytes()).hexdigest() for p in files} diff --git a/desktop/target.py b/desktop/target.py new file mode 100644 index 0000000..7c88e2d --- /dev/null +++ b/desktop/target.py @@ -0,0 +1,75 @@ +"""Desktop build targets, checked against component bytes and package metadata.""" +from pathlib import Path +import json +import platform +import subprocess +import sys +import tarfile + +PROJECT = Path(__file__).resolve().parents[1] +if str(PROJECT) not in sys.path: + sys.path.insert(0, str(PROJECT)) +from release.architecture import validate, check_elf, machine, podman_arch + + +def source_pins(architecture): + validate(architecture) + here = Path(__file__).resolve().parent + pins = json.loads((here / 'sources.json').read_text()) + if architecture == 'armv7h': + pins.update(json.loads((here / 'armv7/sources.json').read_text())) + return pins + + +def builder(buildenv, architecture, image=None, *, labwc=False): + validate(architecture) + if buildenv.native(): + if platform.machine() != machine(architecture): + raise ValueError('Native compiler architecture differs from ' + architecture) + return image or 'native:' + architecture + if image: + return image + if architecture == 'armv7h': + raise ValueError('ARMv7 desktop builds require --builder-image with an ARMv7 Arch Linux ARM compiler') + here = Path(__file__).resolve().parent + image = 'localhost/omarchy-a-' + ('labwc' if labwc else 'desktop') + '-builder:arm64' + folder = here / 'labwc' if labwc else here + buildenv.build(image, folder / 'Containerfile', folder, arch=podman_arch(architecture)) + return image + + +def check_package(path, architecture): + """A package's signed/hash-checked bytes must name the selected target.""" + validate(architecture) + result = subprocess.run(['bsdtar', '-xOf', str(path), '.PKGINFO'], check=True, + text=True, capture_output=True, timeout=30) + values = [line.removeprefix('arch = ') for line in result.stdout.splitlines() if line.startswith('arch = ')] + if values != [architecture]: + raise ValueError('Package architecture differs from ' + architecture + ': ' + str(path)) + # Re-stream the package as tar so Python versions without zstd support + # inspect ELF headers without extracting files onto the host. + process = subprocess.Popen(['bsdtar', '-cf', '-', '@' + str(Path(path).resolve())], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + expanded = count = 0 + with tarfile.open(fileobj=process.stdout, mode='r|') as archive: + for member in archive: + count += 1 + expanded += member.size + if count > 100000 or expanded > (1 << 30): + raise ValueError('Desktop package exceeds its inspection bound') + if member.isfile(): + with archive.extractfile(member) as source: + header = source.read(64) + if header.startswith(b'\x7fELF'): + check_elf(header, architecture, member.name) + process.stdout.close() + if process.wait(timeout=30): + raise ValueError('Desktop package could not be inspected') + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + process.stdout.close() + process.stderr.close() + return architecture diff --git a/docs/releasing.md b/docs/releasing.md index b6a87c2..4f12a9e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,9 @@ # Publishing Omarchy A -Build and validate a coherent local release using [BUILD.md](../BUILD.md). Its rootfs and recovery must be produced together; do not combine artifacts from different builds. +Build and validate a coherent local release using [BUILD.md](../BUILD.md). Its +root and orchard's files must come from that build. The arm64 and the 32-bit +(armv7h) release are two releases of the same shape, each its root as a block +image; the 32-bit one is not published by the workflow below yet. ## GitHub builds @@ -38,13 +41,13 @@ https://github.com/OrchardLinux/omarchy-a/releases/latest/download/index.toml Only that entry point follows latest. Its manifest and image references point to specific release tags, preventing cross-release mixing. -Every release carries its installer: `install.sh` and `orchard-install.pyz` (orchard's core with this release's index, device data and launch loaders), made by `orchard.installer` from the orchard release CI pins and rewritten by `release.publish` for the release's URLs. The README's command runs the latest release's `install.sh`; the repository's `install.sh` only forwards to it, for older instructions. Publish the pinned [orchard release](https://github.com/OrchardLinux/orchard/blob/main/docs/releases.md) first: its tag provides the components and the loaders. Change the README's install command only once a release with `install.sh` is published. +Every public release carries its installer: `install.sh` and `orchard-install.pyz` (orchard's core with this release's index, device data and launch loaders), made by `orchard.installer` from the orchard release CI pins and rewritten by `release.publish` for the release's URLs. The README's command runs the latest release's `install.sh`; the repository's `install.sh` only forwards to it, for older instructions. Publish the pinned [orchard release](https://github.com/OrchardLinux/orchard/blob/main/docs/releases.md) first: its tag provides the components and the loaders. Change the README's install command only once a release with `install.sh` is published. Before announcing a release: - Resolve its hosted index with `orchard resolve https://github.com/OrchardLinux/omarchy-a/releases/latest/download/index.toml` from a clean host environment. - Test installation and `orchard launch` after a cold restart on supported hardware; do not infer device qualification from unit tests or container checks. -- Confirm the published root and recovery come from the audited build and retain their build receipts, source pins, licenses and corresponding source/build inputs. +- Confirm the published root and orchard files come from the audited build and retain their build receipts, source pins, licenses and corresponding source/build inputs. - Include the experimental status and the README's automatic-login/passwordless-sudo warning in release notes. ## Updates diff --git a/release/architecture.py b/release/architecture.py new file mode 100644 index 0000000..344434e --- /dev/null +++ b/release/architecture.py @@ -0,0 +1,68 @@ +"""Userspace targets and executable checks shared by the release producers.""" +from pathlib import Path +import struct +import tomllib + + +TARGETS = { + 'aarch64': {'orchard': 'arm64', 'machine': 'aarch64', 'class': 2, 'elf_machine': 183}, + 'armv7h': {'orchard': 'arm', 'machine': 'armv7l', 'class': 1, 'elf_machine': 40}, +} + + +def validate(architecture): + if architecture not in TARGETS: + raise ValueError(f'Unsupported userspace architecture: {architecture}') + return architecture + + +def orchard_arch(architecture): + return TARGETS[validate(architecture)]['orchard'] + + +def machine(architecture): + return TARGETS[validate(architecture)]['machine'] + + +def podman_arch(architecture): + return orchard_arch(architecture) + + +def platform_arch(platform, snapshot=None): + """The userspace ISA for PLATFORM, checked against its Orchard descriptor.""" + if snapshot is not None: + if not isinstance(platform, str) or '/' in platform or platform in ('', '.', '..'): + raise ValueError('Invalid platform name') + path = Path(snapshot) / 'platforms' / (platform + '.toml') + arch = tomllib.loads(path.read_text())['kernel']['arch'] + for name, target in TARGETS.items(): + if target['orchard'] == arch: + return name + raise ValueError(f'{platform}: unsupported kernel architecture {arch}') + known = {'apple-16k': 'aarch64', 'apple-4k': 'aarch64', 'apple-armv7': 'armv7h'} + if platform not in known: + raise ValueError(f'Unknown platform architecture: {platform}') + return known[platform] + + +def check_elf(data, architecture, label='binary', *, executable=True): + """Require an executable/shared ELF for the selected ISA and hard-float ABI.""" + target = TARGETS[validate(architecture)] + if isinstance(data, (str, Path)): + with Path(data).open('rb') as stream: + data = stream.read(64) + minimum = 64 if target['class'] == 2 else 52 + if (len(data) < minimum or data[:4] != b'\x7fELF' or + data[4] != target['class'] or data[5:7] != b'\x01\x01'): + raise ValueError(f'{label}: expected little-endian {architecture} ELF') + kind, elf_machine, version = struct.unpack_from('> 24 != 5 or (kind != 1 and (not flags & 0x400 or flags & 0x200)): + raise ValueError(f'{label}: expected ARM EABI5 hard-float executable') + return architecture diff --git a/release/audit.py b/release/audit.py index 7d19b57..8624773 100644 --- a/release/audit.py +++ b/release/audit.py @@ -7,6 +7,7 @@ import tarfile from release import snapshot +from release.architecture import validate from release.manifests import read_release from .common import PROJECT @@ -65,10 +66,18 @@ def audit(manifest, orchard=None): raise ValueError('Incompatible release manifest') private = private_firmware(orchard) # The checks read the tree the image was made from; orchard.image.root_image made it, and checks it. - root_path = asset(manifest, {'file': 'rootfs.tar.zst', **{key: value for key, value in json.loads( - (Path(manifest).parent / 'rootfs.json').read_text()).items() if key in ('bytes', 'sha256')}}, 'root') - for role, item in (('root image', record['root']), ('root map', record['root']['map']), ('logo', record['logo']), - ('pongo', record['pongo']), ('m1n1', record['m1n1']), ('install tools', record['install_tools'])): + root_receipt = json.loads((Path(manifest).parent / 'rootfs.json').read_text()) + architecture = validate(root_receipt.get('architecture', 'aarch64')) + root_path = asset(manifest, {'file': 'rootfs.tar.zst', **{key: value for key, value in root_receipt.items() + if key in ('bytes', 'sha256')}}, 'root') + if architecture == 'aarch64' and not ({'pongo', 'm1n1'} <= record['loaders'].keys() and record['logo']): + raise ValueError('An arm64 release boots with orchard\'s Pongo and m1n1, and has a logo') + if architecture == 'armv7h' and (record['boot_method'] != 'recovery-autogo' or record['logo'] or + not all(name.startswith('cascadia ') for name in record['loaders'])): + raise ValueError('A 32-bit release boots with each board\'s Cascadia loader, and has no logo') + for role, item in (('root image', record['root']), ('root map', record['root']['map']), + *((('logo', record['logo']),) if record['logo'] else ()), + *record['loaders'].items(), ('install tools', record['install_tools'])): asset(manifest, item, role) platforms, layers = {}, {} for name, files in sorted(record['platforms'].items()): @@ -153,10 +162,15 @@ def audit(manifest, orchard=None): raise ValueError('Privileged package file differs: ' + name) if not cursor or hashes.get('usr/libexec/omarchy-wtype') != cursor['files'].get('wtype'): raise ValueError('Startup cursor helper differs from build receipt') + # The helpers built for the desktop are of the root's architecture. + if cursor.get('architecture') != architecture: + raise ValueError('Startup cursor helper architecture differs from root') if hashes.get('usr/share/licenses/omarchy-wtype/LICENSE') != cursor['files'].get('LICENSE'): raise ValueError('Missing startup cursor helper license') verify_bluetooth(bluetooth, hashes, modes, owners, groups) verify_labwc(labwc, hashes, modes, owners, groups) + if labwc.get('architecture', 'aarch64') != architecture: + raise ValueError('Labwc architecture differs from root') if 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml' not in desktop: raise ValueError('Pairing UI is absent from desktop integrity manifest') for name in ('omarchy-desktop-ready', 'omarchy-wtype', 'omarchy-powerd', 'omarchy-powerctl', 'omarchy-bluetooth-pair'): @@ -168,7 +182,7 @@ def audit(manifest, orchard=None): raise ValueError('Power, lock and sleep have no owner: omarchy-powerd is not enabled') if links.get('lib') != (True, 'usr/lib') or owners.get('home/tablet') != 1000 or sizes.get('etc/machine-id') != 0: raise ValueError('Invalid root/session contract') - return {'passed': True, 'hardware_validated': False, 'release': record['release'], + return {'passed': True, 'hardware_validated': False, 'release': record['release'], 'architecture': architecture, 'members': len(names), 'desktop_files_verified': len(desktop), 'root_sha256': record['root']['sha256'], 'platforms': platforms, 'privileged_entries_verified': len(privileges)} diff --git a/release/build.py b/release/build.py index a69fdc2..3e6ddcf 100644 --- a/release/build.py +++ b/release/build.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Produce one release for every platform: the root as a block image, its logo, and a manifest naming orchard's files. +"""Produce one release for every platform of one architecture: the root as a block image, its logo, and a manifest naming orchard's files. The root image holds no kernel. orchard's platform layer (kernel, DTBs, modules) goes next to it, and the installer boots that kernel and writes the -layer into the root (orchard docs/boot-contract.md). +layer into the root (orchard docs/boot-contract.md). A release has one root: +aarch64 for orchard's arm64 platforms (platforms.toml), armv7h for its 32-bit +ones (a release of its own, from orchard's 32-bit index). """ import argparse import json @@ -14,18 +16,25 @@ from .public_root import build as build_root, sha from release import snapshot -from release.manifests import write as write_manifests +from release.architecture import platform_arch, validate +from release.manifests import INDEXES, ORCHARD_ARCHITECTURES, write as write_manifests def orchard_files(args): - """Kernels, loaders and install tools into the release, listed in its platforms.toml. + """Kernels, loaders and install tools into the release, listed in orchard's index of the root's architecture. Finished from a published orchard build (--orchard-release), or from this build's inputs: a developer's own kernel. Only the former is linked instead of hosted when published. """ - source = ('--release', args.orchard_release) if args.orchard_release else ('--inputs', args.inputs) + if args.orchard_release: + # --architecture only for 32-bit: the arm64 index is the default (and older orchards lack the option). + source = ('--release', args.orchard_release, + *(('--architecture', ORCHARD_ARCHITECTURES[args.architecture]) if args.architecture != 'aarch64' else ()), + *(part for name in args.platform for part in ('--platform', name))) + else: + source = ('--inputs', args.inputs) snapshot.command(args.orchard, 'orchard.image.platforms_index', args.output, *source, stdout=subprocess.DEVNULL) - return tomllib.loads((args.output / 'platforms.toml').read_text()) + return tomllib.loads((args.output / INDEXES[args.architecture]).read_text()) def main(): @@ -33,6 +42,10 @@ def main(): for name in ('base', 'base-receipt', 'splash', 'logo', 'busybox', 'dropbear', 'dropbearkey', 'cursor-tools', 'bluetooth-tools', 'labwc-tools', 'output'): parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--architecture', choices=sorted(INDEXES), default='aarch64', help="the root's architecture") + parser.add_argument('--platform', action='append', default=[], + help='with --orchard-release: only this platform; repeat (default: every one of the ' + "architecture; armv7h names its platforms)") parser.add_argument('--orchard', type=Path, help='the orchard snapshot (default: ORCHARD_CHECKOUT)') source = parser.add_mutually_exclusive_group(required=True) source.add_argument('--orchard-release', metavar='TAG', help="take orchard's files finished from this build") @@ -49,24 +62,33 @@ def main(): if not getattr(args, name).is_dir(): parser.error(f'Missing tools directory: {getattr(args, name)}') try: + validate(args.architecture) args.orchard = snapshot.directory(args.orchard) document = json.loads(args.inputs.read_text()) if args.inputs else None + # Every platform of the release runs the root's architecture. + for name in (*args.platform, *((document or {}).get('platforms') or ())): + if platform_arch(name, args.orchard) != args.architecture: + raise ValueError(f'{name} is not a platform of the {args.architecture} root') + if args.orchard_release and args.architecture != 'aarch64' and not args.platform: + raise ValueError('Name the platforms of the armv7h release with --platform') except (ValueError, OSError) as error: parser.error(str(error)) revision = (document or {}).get('orchard') or snapshot.revision(args.orchard) if not revision: parser.error(f'{args.orchard} is no git checkout and no --inputs names its revision') - build_root(args.base, args.base_receipt, args.output, args.release, - args.cursor_tools, args.bluetooth_tools, args.labwc_tools, - tools={'dropbear': args.dropbear, 'dropbearkey': args.dropbearkey, 'busybox': args.busybox}, - splash=args.splash) + public_root = build_root(args.base, args.base_receipt, args.output, args.release, + args.cursor_tools, args.bluetooth_tools, args.labwc_tools, + tools={'dropbear': args.dropbear, 'dropbearkey': args.dropbearkey, 'busybox': args.busybox}, + splash=args.splash, architecture=args.architecture) offered = orchard_files(args) snapshot.command(args.orchard, 'orchard.image.root_image', args.output / 'rootfs.tar.zst', '--output', args.output) root = json.loads((args.output / 'root-image.json').read_text()) - shutil.copyfile(args.logo, args.output / 'logo.bin') - published = write_manifests(args.output, args.release, root, offered, revision) + if args.architecture == 'aarch64': # a 32-bit release has no logo + shutil.copyfile(args.logo, args.output / 'logo.bin') + published = write_manifests(args.output, args.release, root, offered, revision, architecture=args.architecture) # Assembly provenance is separate from the public installation contract. - receipt = {'schema': 2, 'release': args.release, 'orchard': revision, 'platforms': offered['platforms'], + receipt = {'schema': 2, 'release': args.release, 'architecture': args.architecture, 'orchard': revision, + 'public_root': public_root, 'platforms': offered['platforms'], 'inputs': {name: {'bytes': path.stat().st_size, 'sha256': sha(path)} for name, path in vars(args).items() if isinstance(path, Path) and path.is_file()}} (args.output / 'build-receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') diff --git a/release/manifests.py b/release/manifests.py index d68bc1b..445ef07 100644 --- a/release/manifests.py +++ b/release/manifests.py @@ -1,9 +1,19 @@ -"""Publish the release's install manifest (every platform) and the index that pins it.""" +"""Publish the release's install manifest (every platform of its architecture) and the index that pins it.""" import hashlib import json from pathlib import Path import tomllib +from release.architecture import validate + +# 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, its index and the manifest's boot method +# (orchard docs/distribution-contract.md, "32-bit roots"). +INDEXES = {'aarch64': 'platforms.toml', 'armv7h': 'platforms-armv7.toml'} +ORCHARD_ARCHITECTURES = {'aarch64': 'arm64', 'armv7h': 'arm'} +BOOT_METHODS = {'aarch64': 'checkm8-pongo', 'armv7h': 'recovery-autogo'} + def sha(path): digest = hashlib.sha256() @@ -21,15 +31,18 @@ def table(name, row): return '\n[' + name + ']\n' + ''.join(key + ' = ' + json.dumps(value) + '\n' for key, value in row.items()) -def write(output, version, root, offered, orchard): +def write(output, version, root, offered, orchard, *, architecture='aarch64'): """release.toml and index.toml in OUTPUT. - ROOT is orchard.image.root_image's table, OFFERED orchard's platforms.toml beside it; - ORCHARD is the snapshot's revision ({'commit', 'modified'}). + ROOT is orchard.image.root_image's table, OFFERED orchard's index of the root's architecture + beside it (platforms.toml, or the 32-bit index); ORCHARD is the snapshot's revision + ({'commit', 'modified'}). """ + validate(architecture) + boot_method = BOOT_METHODS[architecture] document = ('schema = 2\nkind = "orchard.install"\ndistro = "omarchy-a"\n' 'name = "Omarchy A"\nversion = ' + json.dumps(version) + '\n' - 'boot_method = "checkm8-pongo"\nmin_orchard = "0.2.0"\nprotocol = 1\n') + 'boot_method = ' + json.dumps(boot_method) + '\nmin_orchard = "0.2.0"\nprotocol = 1\n') document += table('orchard', orchard) # The tablet user gets the password and the lock-screen PIN at install. The # installer waits over this boot's SSH for the greeter's ready record: the @@ -40,9 +53,14 @@ def write(output, version, root, offered, orchard): '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')) - document += table('pongo', offered['loaders']['pongo']) - document += table('m1n1', offered['loaders']['m1n1']) + if architecture == 'aarch64': + document += table('logo', receipt(output / 'logo.bin')) + document += table('pongo', offered['loaders']['pongo']) + document += 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' @@ -61,6 +79,10 @@ def read_release(manifest): value = tomllib.loads(Path(manifest).read_text()) if value.get('schema') != 2 or value.get('kind') != 'orchard.install' or value.get('distro') != 'omarchy-a': raise ValueError('Expected an Omarchy Orchard installation manifest') + # The files that start the release: Pongo and m1n1, or each board's Cascadia loader. + loaders = ({f'cascadia {board}': row for board, row in value['cascadia'].items()} if 'cascadia' in value else + {name: value[name] for name in ('pongo', 'm1n1') if name in value}) return {'schema': value['schema'], 'protocol': value['protocol'], 'release': value['version'], - 'root': value['root'], 'pongo': value['pongo'], 'm1n1': value['m1n1'], - 'install_tools': value['install_tools'], 'logo': value['logo'], 'platforms': value['platforms']} + 'boot_method': value['boot_method'], 'root': value['root'], 'loaders': loaders, + 'pongo': value.get('pongo'), 'm1n1': value.get('m1n1'), + 'install_tools': value['install_tools'], 'logo': value.get('logo'), 'platforms': value['platforms']} diff --git a/release/public_root.py b/release/public_root.py index 1e0d17d..2df5ef0 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -11,6 +11,7 @@ from .common import group_membership, PROJECT, SYSTEM, NETWORK_RULE from release.package_permissions import privileged_entries, apply as apply_package_permissions +from release.architecture import validate, check_elf def sha(path): @@ -41,10 +42,11 @@ def desktop_replacements(): return replacements -def cursor_files(directory): +def cursor_files(directory, architecture='aarch64'): + validate(architecture) record = json.loads((directory / 'manifest.json').read_text()) pin = json.loads((PROJECT / 'desktop/cursor/wtype.json').read_text()) - if record.get('schema') != 1 or record.get('architecture') != 'aarch64' or record.get('source') != pin: + if record.get('schema') != 1 or record.get('architecture') != architecture or record.get('source') != pin: raise ValueError('Incompatible startup cursor helper receipt') files = {} for name, destination, mode in ( @@ -56,6 +58,8 @@ def cursor_files(directory): data = path.read_bytes() if hashlib.sha256(data).hexdigest() != record['files'].get(name): raise ValueError('Startup cursor helper differs from receipt: ' + name) + if name == 'wtype': + check_elf(data, architecture, 'startup cursor helper') files[destination] = (data, mode) files['etc/omarchy/cursor-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) return files @@ -87,11 +91,15 @@ def bluetooth_files(directory): return files -def labwc_files(directory): +def labwc_files(directory, architecture='aarch64'): + validate(architecture) record = json.loads((directory / 'manifest.json').read_text()) pin = json.loads((PROJECT / 'desktop/labwc/source.json').read_text()) if record.get('schema') != 1 or record.get('source') != pin: raise ValueError('Incompatible Labwc component receipt') + # Earlier AArch64 Labwc receipts predate the architecture field. + if record.get('architecture', 'aarch64') != architecture: + raise ValueError('Labwc component architecture differs from root') expected = {'labwc', 'LICENSE', 'source.tar.gz', '0001-cancel-super-tap-on-pointer.patch', 'packages.lock', 'compiler.txt', 'version.txt'} if record.get('files', {}).keys() != expected: @@ -102,7 +110,10 @@ def labwc_files(directory): if path.is_symlink() or not path.is_file() or sha(path) != digest: raise ValueError('Labwc component differs from receipt') destination = 'usr/bin/labwc' if name == 'labwc' else 'usr/share/licenses/omarchy-labwc/' + name - files[destination] = (path.read_bytes(), 0o755 if name == 'labwc' else 0o644) + data = path.read_bytes() + if name == 'labwc' and (data.startswith(b'\x7fELF') or 'architecture' in record or architecture == 'armv7h'): + check_elf(data, architecture, 'Labwc') + files[destination] = (data, 0o755 if name == 'labwc' else 0o644) if record['files']['source.tar.gz'] != pin['sha256'] or record['files']['0001-cancel-super-tap-on-pointer.patch'] != pin['patch_sha256']: raise ValueError('Labwc corresponding source differs from pin') files['etc/omarchy/labwc-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) @@ -126,36 +137,44 @@ def system_files(): return additions -def access_files(tools): +def access_files(tools, architecture='aarch64'): """orchard's static dropbear (SSH) and BusyBox (DHCP on usb0) for the system's own services. TOOLS: {'dropbear', 'dropbearkey', 'busybox'} paths from orchard's build cache. """ + validate(architecture) + result = {} # Arch's /usr/sbin is a link to /usr/bin. - return {'usr/bin/dropbear': (Path(tools['dropbear']).read_bytes(), 0o755), - 'usr/bin/dropbearkey': (Path(tools['dropbearkey']).read_bytes(), 0o755), - 'usr/lib/orchard/busybox': (Path(tools['busybox']).read_bytes(), 0o755)} + for name, target in (('dropbear', 'usr/bin/dropbear'), ('dropbearkey', 'usr/bin/dropbearkey'), + ('busybox', 'usr/lib/orchard/busybox')): + data = Path(tools[name]).read_bytes() + check_elf(data, architecture, name) + result[target] = (data, 0o755) + return result def build(base, base_receipt, outdir, release, cursor_tools, bluetooth_tools=None, labwc_tools=None, tools=None, - splash=None): + splash=None, architecture='aarch64'): """The root image for every platform: no kernel, DTBs or modules (orchard's layer has them).""" + validate(architecture) if outdir.exists(): raise ValueError('Choose a new release output directory') record = json.loads(base_receipt.read_text()) + if record.get('architecture', 'aarch64') != architecture: + raise ValueError('Base root architecture differs from selected architecture') if base.is_symlink() or base.stat().st_size != record['bytes'] or sha(base) != record['sha256']: raise ValueError('Base root differs from receipt') privileges = privileged_entries(base) privileged_digests = {} - additions = cursor_files(cursor_tools) + additions = cursor_files(cursor_tools, architecture) additions.update(bluetooth_files(bluetooth_tools or PROJECT / 'out/desktop/bluetooth-tools')) - additions.update(labwc_files(labwc_tools or PROJECT / 'out/desktop/labwc-tools')) + additions.update(labwc_files(labwc_tools or PROJECT / 'out/desktop/labwc-tools', architecture)) desktop_extra = desktop_additions() additions.update(desktop_extra) additions['etc/omarchy/package-privileges.json'] = (json.dumps(privileges, sort_keys=True).encode(), 0o644) additions.update(system_files()) if tools: - additions.update(access_files(tools)) + additions.update(access_files(tools, architecture)) if splash: # orchard launch paints it before the kernel (orchard docs/boot-contract.md). additions['usr/share/orchard/splash'] = (Path(splash).read_bytes(), 0o644) @@ -167,6 +186,7 @@ def build(base, base_receipt, outdir, release, cursor_tools, bluetooth_tools=Non replacements = desktop_replacements() outdir.parent.mkdir(parents=True, exist_ok=True) seen, directories, expanded, count = set(), set(), 0, 0 + base_stamp = None with tempfile.TemporaryDirectory(dir=outdir.parent, prefix='.root-') as temp: temporary = Path(temp) archive = temporary / 'rootfs.tar.zst' @@ -204,6 +224,12 @@ def add(member, data=None): if member.name == 'lib' and (not member.issym() or member.linkname != 'usr/lib'): raise ValueError('Base must link lib to usr/lib') data = source.extractfile(member).read() if member.isfile() else None + if member.name == 'etc/ipadlinux/release': + if data is None or len(data) > 256: + raise ValueError('Invalid base release stamp') + base_stamp = data.decode().rstrip('\n') + if record.get('release') is not None and data != (record['release'] + '\n').encode(): + raise ValueError('Base release stamp differs from its receipt') if member.name in ('etc/group', 'etc/gshadow'): data = group_membership(data, shadow=member.name == 'etc/gshadow') if member.name in replacements: @@ -261,9 +287,12 @@ def add_parents(name): 'arch-ipad-ui-manifest.json'} if not required <= seen or not privileges.keys() <= seen or not replacements.keys() <= seen: raise ValueError('Incomplete public root or privileged package metadata') - receipt = {'schema': 2, 'kind': 'rootfs', 'release': release, + if record.get('release') is not None and base_stamp is None: + raise ValueError('Base release stamp is missing') + receipt = {'schema': 2, 'kind': 'rootfs', 'release': release, 'architecture': architecture, 'bytes': archive.stat().st_size, 'sha256': sha(archive), 'compression': 'zstd', - 'expanded_bytes': expanded, 'members': count, 'base_sha256': record['sha256']} + 'expanded_bytes': expanded, 'members': count, 'base_sha256': record['sha256'], + 'stamps': {'base': base_stamp, 'orchard': release}} (temporary / 'rootfs.json').write_text(json.dumps(receipt, indent=2) + '\n') os.replace(temporary, outdir) return receipt @@ -274,8 +303,10 @@ def main(): for name in ('base', 'base-receipt', 'outdir', 'cursor-tools', 'bluetooth-tools', 'labwc-tools'): parser.add_argument('--' + name, type=Path, required=True) parser.add_argument('--release', required=True) + parser.add_argument('--architecture', choices=('aarch64', 'armv7h'), default='aarch64') args = parser.parse_args() - print(json.dumps(build(args.base, args.base_receipt, args.outdir, args.release, args.cursor_tools, args.bluetooth_tools, args.labwc_tools), indent=2)) + print(json.dumps(build(args.base, args.base_receipt, args.outdir, args.release, args.cursor_tools, + args.bluetooth_tools, args.labwc_tools, architecture=args.architecture), indent=2)) if __name__ == '__main__': diff --git a/release/smoke_desktop.py b/release/smoke_desktop.py index 9ce2b93..233e425 100644 --- a/release/smoke_desktop.py +++ b/release/smoke_desktop.py @@ -23,12 +23,13 @@ def main(): # Verify namespace safety and all published digests before using GNU tar. checked = audit(manifest) record = read_release(manifest) + root_archive = 'rootfs.tar.zst' if record['root'].get('format') == 'blocks' else record['root']['file'] args.output.mkdir(parents=True) output = args.output.resolve() recipe = Path(__file__).with_name('smoke-desktop-in-container').resolve() argv = ['podman', 'run', '--rm', '--network', 'none', '--userns', 'keep-id:uid=0,gid=0', '--cap-add', 'SYS_ADMIN', '--cap-add', 'SYS_PTRACE', '--security-opt', 'seccomp=unconfined', - '-e', 'OMA_ROOT_FILENAME=' + record['root']['file'], + '-e', 'OMA_ROOT_FILENAME=' + root_archive, '-v', str(manifest.parent) + ':/release:ro', '-v', str(recipe) + ':/validate.sh:ro', '-v', str(output) + ':/evidence:rw', args.image, 'sh', '/validate.sh'] if args.pointer_helper: diff --git a/system/armv7/Containerfile b/system/armv7/Containerfile new file mode 100644 index 0000000..5d50def --- /dev/null +++ b/system/armv7/Containerfile @@ -0,0 +1,16 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +COPY desktop/install-toolchain desktop/rank-mirrors desktop/pacman-download /tmp/desktop/ +COPY desktop/labwc/install-toolchain /tmp/labwc/install-toolchain +RUN test "$(uname -m)" = armv7l && \ + bash /tmp/desktop/install-toolchain && bash /tmp/labwc/install-toolchain && \ + pacman-download -Syu --noconfirm --needed arch-install-scripts qt6-base qt6-declarative qt6-tools && \ + pacman -Su --noconfirm --needed arch-install-scripts qt6-base qt6-declarative qt6-tools && \ + pacman -Q > /usr/share/omarchy-builder-packages.lock && \ + pacman -Sc --noconfirm && rm -rf /tmp/desktop /tmp/labwc +# The shared makepkg defaults enable RELR, which GNU ld for ARM32 rejects. +# Keep the remaining Arch hardening flags and check them with this compiler. +RUN sed -i 's/ -Wl,-z,pack-relative-relocs//g' /etc/makepkg.conf && \ + bash -euc 'source /etc/makepkg.conf; printf "int main(void) { return 0; }\n" | cc ${CFLAGS} ${LDFLAGS} -x c - -o /tmp/compiler-check; /tmp/compiler-check; rm /tmp/compiler-check' +LABEL org.orchard.target.architecture="armv7h" +WORKDIR /work diff --git a/system/armv7/archlinuxarm-build.asc b/system/armv7/archlinuxarm-build.asc new file mode 100644 index 0000000..c551dd1 --- /dev/null +++ b/system/armv7/archlinuxarm-build.asc @@ -0,0 +1,114 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: Hockeypuck 2.2 +Comment: Hostname: + +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 +=lA9r +-----END PGP PUBLIC KEY BLOCK----- diff --git a/system/armv7/bootstrap.json b/system/armv7/bootstrap.json new file mode 100644 index 0000000..cf52ec1 --- /dev/null +++ b/system/armv7/bootstrap.json @@ -0,0 +1,18 @@ +{ + "schema": 2, + "architecture": "armv7h", + "build_date": "2026-08-05", + "urls": [ + "https://github.com/OrchardLinux/omarchy-a/releases/download/armv7-bootstrap-20260805/ArchLinuxARM-armv7-20260805.tar.gz", + "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz" + ], + "signature_urls": [ + "https://github.com/OrchardLinux/omarchy-a/releases/download/armv7-bootstrap-20260805/ArchLinuxARM-armv7-20260805.tar.gz.sig", + "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz.sig" + ], + "bytes": 770429322, + "sha256": "ebe0fa119dea45e3af0aa61785676bdbf417bc5f754d529b87255dbf0ca4e3f0", + "signature_sha256": "0d4bb680d381c8053c378fad27cedb67446b39b7bff1c87257ebf6743db0fd94", + "signer_source": "https://archlinuxarm.org/about/package-signing", + "signer": "68B3537F39A313B3E574D06777193F152BDBE6A6" +} diff --git a/system/assemble-rootfs b/system/assemble-rootfs index c5ee3c2..234594f 100755 --- a/system/assemble-rootfs +++ b/system/assemble-rootfs @@ -1,27 +1,36 @@ #!/usr/bin/env bash set -euo pipefail -root= output= desktop= release= +root= output= desktop= release= architecture=aarch64 while (($#)); do case "$1" in --root) root=$2; shift 2;; --output) output=$2; shift 2;; --desktop) desktop=$2; shift 2;; --release) release=$2; shift 2;; + --architecture) architecture=$2; shift 2;; *) echo "Unknown argument: $1" >&2; exit 2;; esac done for value in "$root" "$output" "$desktop" "$release"; do [[ -n "$value" ]] || { echo 'Missing required build input' >&2; exit 2; } done -[[ $(uname -m) == aarch64 && $EUID == 0 ]] || { echo 'Requires an aarch64 Arch Linux ARM build environment as root' >&2; exit 1; } -for cmd in pacman useradd usermod systemctl tar python3; do command -v "$cmd" >/dev/null || { echo "Missing $cmd" >&2; exit 1; }; done +case "$architecture" in + aarch64) expected_machine=aarch64;; + armv7h) expected_machine=armv7l;; + *) echo "Unsupported userspace architecture: $architecture" >&2; exit 2;; +esac +[[ $(uname -m) == "$expected_machine" && $EUID == 0 ]] || { echo "Requires an $expected_machine Arch Linux ARM build environment as root" >&2; exit 1; } +for cmd in pacman useradd usermod systemctl tar bsdtar python3; do command -v "$cmd" >/dev/null || { echo "Missing $cmd" >&2; exit 1; }; done project=$(cd "$(dirname "$0")/.." && pwd) root=$(realpath -m -- "$root") output=$(realpath -m -- "$output") [[ "$root" != / && ! -e "$root" && ! -e "$output" ]] || { echo 'Root and archive must be new paths' >&2; exit 1; } mapfile -t requested < <(grep -vE '^\s*(#|$)' "$project/system/packages.txt") [[ ${#requested[@]} -gt 0 ]] || exit 1 -printf '%s\n' "${requested[@]}" | grep -qx 'linux-aarch64' && { echo 'Generic kernel is not an input' >&2; exit 1; } +printf '%s\n' "${requested[@]}" | grep -Eq '^linux(-aarch64|-armv7|-rpi)?$' && { echo 'Generic kernel is not an input' >&2; exit 1; } +for package in "$desktop/packages/omafox.pkg.tar.xz" "$desktop/packages/omacalc.pkg.tar.zst" "$desktop/packages/wlroots0.20.pkg.tar.xz"; do + bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $architecture" >/dev/null || { echo "Package architecture differs from $architecture: $package" >&2; exit 1; } +done # Install signed packages into a clean target; no mounts or VM root are needed. # The caller's umask 077 would make pacman's own state (/var/lib/pacman, @@ -70,14 +79,18 @@ tar --hard-dereference --numeric-owner --one-file-system -C "$root" \ --exclude='./dev/*' --exclude='./proc/*' --exclude='./sys/*' \ --exclude='./run/*' --exclude='./tmp/*' --exclude='./var/cache/pacman/pkg/*' \ -cf - . | gzip -1 > "$output" -python3 - "$output" "$release" <<'PY' +python3 - "$output" "$release" "$architecture" "$root" "$project" <<'PY' import hashlib,json,pathlib,sys archive,release=pathlib.Path(sys.argv[1]),sys.argv[2] +sys.path.insert(0,sys.argv[5]) +from release.architecture import check_elf +architecture=sys.argv[3] +check_elf(pathlib.Path(sys.argv[4])/'usr/lib/systemd/systemd',architecture,'root systemd') h=hashlib.sha256() with archive.open('rb') as stream: for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) lock=archive.parent/'packages.lock' -metadata={'release':release,'bytes':archive.stat().st_size,'sha256':h.hexdigest(), +metadata={'release':release,'architecture':architecture,'bytes':archive.stat().st_size,'sha256':h.hexdigest(), 'packages_lock_sha256':hashlib.sha256(lock.read_bytes()).hexdigest()} archive.with_suffix(archive.suffix+'.json').write_text(json.dumps(metadata,indent=2)+'\n') print('Clean rootfs:',archive,'SHA-256',h.hexdigest()) diff --git a/system/build-armv7-builder b/system/build-armv7-builder new file mode 100755 index 0000000..b58cb5c --- /dev/null +++ b/system/build-armv7-builder @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Prepare an ARMv7 compiler from a hash-pinned and signature-checked Arch root. + +The selected Podman connection must already execute ARM32 binaries. This +command does not install an emulator or change the host's default connection. +The bootstrap is one dated Arch Linux ARM build: its copy in this repository's +releases first, then upstream's "latest" while that is still the same build. +""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +from urllib.request import urlopen + + +PROJECT = Path(__file__).resolve().parents[1] +INPUTS = PROJECT / 'system/armv7' + + +def sha(path): + result = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def verify(archive, signature, pin): + if not archive.is_file() or not signature.is_file(): + raise ValueError('Bootstrap and signature must be regular files') + if archive.stat().st_size != pin['bytes'] or sha(archive) != pin['sha256']: + raise ValueError('ARMv7 bootstrap differs from its pinned bytes') + if sha(signature) != pin['signature_sha256']: + raise ValueError('ARMv7 bootstrap signature differs from its pin') + # Short paths keep GnuPG's local socket within the macOS path limit. + with tempfile.TemporaryDirectory(prefix='oa-armv7-gpg-') as home: + command = ['gpg', '--homedir', home, '--batch', '--no-auto-key-retrieve'] + subprocess.run([*command, '--import', str(INPUTS / 'archlinuxarm-build.asc')], + check=True, capture_output=True, text=True, timeout=30) + checked = subprocess.run([*command, '--status-fd', '1', '--verify', str(signature), str(archive)], + check=True, capture_output=True, text=True, timeout=60) + expected = '[GNUPG:] VALIDSIG ' + pin['signer'] + ' ' + if not any(line.startswith(expected) for line in checked.stdout.splitlines()): + raise ValueError('ARMv7 bootstrap signature has the wrong signer') + return pin['sha256'] + + +def fetch_first(urls, path, maximum, pinned): + """The first of URLS whose bytes are PINNED's sha256 (the dated copy, then upstream), into PATH.""" + if path.exists(): + return path + failures = [] + for url in urls: + try: + fetch(url, path, maximum) + except (OSError, ValueError) as error: + failures.append(f'{url}: {error}') + continue + if sha(path) == pinned: + return path + path.unlink() + failures.append(f'{url}: another build than the pinned one') + raise ValueError('No source has the pinned ARMv7 bootstrap: ' + '; '.join(failures)) + + +def fetch(url, path, maximum): + if path.exists(): + return path + temporary = tempfile.NamedTemporaryFile(prefix=path.name + '.', suffix='.part', dir=path.parent, delete=False) + partial = Path(temporary.name) + count = 0 + try: + with temporary as output, urlopen(url, timeout=60) as source: + while block := source.read(1024 * 1024): + count += len(block) + if count > maximum: + raise ValueError('Bootstrap download exceeds its pinned size bound') + output.write(block) + if path.exists(): + partial.unlink() + else: + partial.replace(path) + except BaseException: + partial.unlink(missing_ok=True) + raise + return path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + parser.add_argument('--bootstrap', type=Path, help='existing official archive; otherwise use the pinned download') + parser.add_argument('--signature', type=Path, help='signature for --bootstrap (default: ARCHIVE.sig)') + parser.add_argument('--cache', type=Path, default=PROJECT / 'system/.work/armv7-bootstrap') + parser.add_argument('--connection', help='Podman connection for this build') + parser.add_argument('--output-image', default='localhost/omarchy-a-arch-builder:armv7h') + parser.add_argument('--receipt', type=Path, required=True) + parser.add_argument('--verify-only', action='store_true') + args = parser.parse_args() + if args.receipt.exists(): + parser.error('Choose a new compiler receipt path') + pin = json.loads((INPUTS / 'bootstrap.json').read_text()) + args.cache.mkdir(parents=True, exist_ok=True) + archive = args.bootstrap or fetch_first(pin['urls'], args.cache / 'bootstrap.tar.gz', pin['bytes'], pin['sha256']) + signature = args.signature or (Path(str(args.bootstrap) + '.sig') if args.bootstrap else + fetch_first(pin['signature_urls'], args.cache / 'bootstrap.tar.gz.sig', 16384, + pin['signature_sha256'])) + verify(archive, signature, pin) + result = {'schema': 1, 'architecture': 'armv7h', 'bootstrap': pin, 'signature_valid': True} + if not args.verify_only: + podman = [shutil.which('podman') or 'podman'] + if args.connection: + podman += ['--connection', args.connection] + base = 'localhost/omarchy-a-armv7-base:' + pin['sha256'][:16] + exists = subprocess.run([*podman, 'image', 'exists', base], check=False, timeout=30).returncode == 0 + if not exists: + subprocess.run([*podman, 'import', '--arch', 'arm', '--variant', 'v7', + '--change', 'LABEL org.orchard.bootstrap.sha256=' + pin['sha256'], + str(archive.resolve()), base], check=True, timeout=600) + inspection = json.loads(subprocess.check_output([*podman, 'image', 'inspect', base], text=True, timeout=30))[0] + labels = inspection.get('Labels') or inspection.get('Config', {}).get('Labels', {}) + if labels.get('org.orchard.bootstrap.sha256') != pin['sha256']: + raise ValueError('Existing compiler base has another bootstrap pin') + subprocess.run([*podman, 'build', '--platform', 'linux/arm/v7', '--build-arg', 'BASE_IMAGE=' + base, + '-f', str(INPUTS / 'Containerfile'), '-t', args.output_image, str(PROJECT)], + check=True, timeout=7200) + result['image'] = args.output_image + result['image_id'] = subprocess.check_output([*podman, 'image', 'inspect', '--format', '{{.Id}}', + args.output_image], text=True, timeout=30).strip() + result['packages_lock'] = subprocess.check_output([*podman, 'run', '--rm', '--platform', 'linux/arm/v7', + args.output_image, 'cat', + '/usr/share/omarchy-builder-packages.lock'], text=True, timeout=120) + args.receipt.parent.mkdir(parents=True, exist_ok=True) + with args.receipt.open('x') as output: + json.dump(result, output, indent=2) + output.write('\n') + print('Verified ARMv7 compiler inputs:', args.receipt) + + +if __name__ == '__main__': + main() diff --git a/system/build-rootfs b/system/build-rootfs index 987c768..b20e6cb 100755 --- a/system/build-rootfs +++ b/system/build-rootfs @@ -4,31 +4,38 @@ umask 077 here=$(cd "$(dirname "$0")" && pwd) project=$(cd "$here/.." && pwd) -output='' desktop='' release='' +output='' desktop='' release='' architecture=aarch64 builder_image='' while (($#)); do case "$1" in --output) output=$2; shift 2;; --desktop) desktop=$2; shift 2;; --release) release=$2; shift 2;; + --architecture) architecture=$2; shift 2;; + --builder-image) builder_image=$2; shift 2;; *) echo "Unknown argument: $1" >&2; exit 2;; esac done if [[ -z "$output" || -z "$release" || ! -d "$desktop/payload" || ! -d "$desktop/packages" || -e "$output" ]]; then - echo 'Usage: ./system/build-rootfs --output DIRECTORY --desktop DIRECTORY --release NAME' >&2 + echo 'Usage: ./system/build-rootfs --output DIRECTORY --desktop DIRECTORY --release NAME [--architecture aarch64|armv7h] [--builder-image IMAGE]' >&2 exit 2 fi +case "$architecture" in + aarch64) expected_machine=aarch64; container_args=(--arch arm64);; + armv7h) expected_machine=armv7l; container_args=(--platform linux/arm/v7);; + *) echo "Unsupported userspace architecture: $architecture" >&2; exit 2;; +esac podman=$(command -v podman || true) if [[ ${ORCHARD_BUILD_NATIVE:-0} != 1 ]]; then [[ -n "$podman" ]] || { echo 'Podman is required' >&2; exit 1; } "$podman" info >/dev/null || { echo 'Podman is unavailable' >&2; exit 1; } else - [[ $EUID == 0 && $(uname -m) == aarch64 ]] || { echo 'Native rootfs assembly requires Linux ARM64 as root' >&2; exit 1; } + [[ $EUID == 0 && $(uname -m) == "$expected_machine" ]] || { echo "Native rootfs assembly requires Linux $expected_machine as root" >&2; exit 1; } fi absolute() { python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$1"; } output=$(absolute "$output") desktop=$(absolute "$desktop") -"$project/desktop/build" --verify "$desktop" +"$project/desktop/build" --verify "$desktop" --architecture "$architecture" mkdir -p "$here/.work/rootfs" stage=$(mktemp -d "$here/.work/rootfs/result.XXXXXX") @@ -45,30 +52,36 @@ trap cleanup EXIT if [[ ${ORCHARD_BUILD_NATIVE:-0} == 1 ]]; then native_work=$(mktemp -d "$here/.work/rootfs/native.XXXXXX") "$here/assemble-rootfs" --root "$native_work/root" \ - --output "$native_work/rootfs.tar.gz" --desktop "$desktop" --release "$release" + --output "$native_work/rootfs.tar.gz" --desktop "$desktop" --release "$release" --architecture "$architecture" cp "$native_work/rootfs.tar.gz" "$native_work/rootfs.tar.gz.json" "$native_work/packages.lock" "$stage/" else - "$podman" build -f "$here/Containerfile" -t localhost/omarchy-a-arch-builder:latest "$here" + if [[ -z "$builder_image" ]]; then + [[ "$architecture" == aarch64 ]] || { echo 'ARMv7 builds require --builder-image with an ARMv7 Arch Linux ARM environment, or ORCHARD_BUILD_NATIVE=1 on ARMv7' >&2; exit 1; } + builder_image=localhost/omarchy-a-arch-builder:latest + "$podman" build -f "$here/Containerfile" -t "$builder_image" "$here" + fi volume_name="omarchy-a-rootfs-${stage##*/}-$$" "$podman" volume create "$volume_name" >/dev/null volume=$volume_name - "$podman" run --rm \ + "$podman" run --rm "${container_args[@]}" \ -v "$volume:/work:rw" \ - -v "$here:/repo/system:ro" -v "$desktop:/inputs/desktop:ro" \ - localhost/omarchy-a-arch-builder:latest \ + -v "$project:/repo:ro" -v "$desktop:/inputs/desktop:ro" \ + "$builder_image" \ /repo/system/assemble-rootfs \ --root /work/root --output /work/rootfs.tar.gz \ - --desktop /inputs/desktop --release "$release" - "$podman" run --rm --network none -v "$volume:/work:ro" \ - localhost/omarchy-a-arch-builder:latest \ + --desktop /inputs/desktop --release "$release" --architecture "$architecture" + # Drain the export through EOF, including tar's trailing zero padding. + "$podman" run --rm "${container_args[@]}" --network none -v "$volume:/work:ro" \ + "$builder_image" \ tar -C /work -cf - rootfs.tar.gz rootfs.tar.gz.json packages.lock \ - | tar -C "$stage" -xf - + | tar -C "$stage" --ignore-zeros -xf - fi -python3 - "$stage" <<'PY' +python3 - "$stage" "$architecture" <<'PY' import hashlib, json, pathlib, sys root = pathlib.Path(sys.argv[1]) archive = root / 'rootfs.tar.gz' receipt = json.loads((root / 'rootfs.tar.gz.json').read_text()) +assert receipt['architecture'] == sys.argv[2] digest = hashlib.sha256() with archive.open('rb') as stream: for block in iter(lambda: stream.read(1024 * 1024), b''): diff --git a/system/install-toolchain b/system/install-toolchain index eb542a4..e368aee 100644 --- a/system/install-toolchain +++ b/system/install-toolchain @@ -12,7 +12,7 @@ rank-mirrors pacman-key --init pacman-key --populate archlinuxarm # The base image's kernel and firmware are not needed to build: never download them. -mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-firmware.*)$') +mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-armv7|linux-firmware.*)$') (( ${#unneeded[@]} == 0 )) || pacman -Rdd --noconfirm "${unneeded[@]}" packages=(arch-install-scripts python) pacman-download -Sy --noconfirm --needed "${packages[@]}" diff --git a/system/rank-mirrors b/system/rank-mirrors index 34fa8c9..c637a79 100755 --- a/system/rank-mirrors +++ b/system/rank-mirrors @@ -24,6 +24,7 @@ candidates=( mirrors.tuna.tsinghua.edu.cn/archlinuxarm mirrors.ustc.edu.cn/archlinuxarm ) arch=$(uname -m) +case "$arch" in armv7l|armv7|armhf) arch=armv7h;; esac work=$(mktemp -d) trap 'rm -rf -- "$work"' EXIT diff --git a/tests/test_architecture.py b/tests/test_architecture.py new file mode 100644 index 0000000..b3921a6 --- /dev/null +++ b/tests/test_architecture.py @@ -0,0 +1,208 @@ +"""A release's native components must agree with its userspace ABI.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import struct +import sys +import tempfile +import unittest +from unittest.mock import patch + +from release.architecture import check_elf, orchard_arch, platform_arch + + +PROJECT = Path(__file__).resolve().parents[1] +PRODUCER = {'orchard': {'commit': 'a' * 40, 'modified': False, 'tree_sha256': 'b' * 64}, + 'buildenv': {'sha256': 'c' * 64}, 'wrapper': {'sha256': 'd' * 64}} + + +def executable(architecture): + size, elf_class, machine = (64, 2, 183) if architecture == 'aarch64' else (52, 1, 40) + data = bytearray(size) + data[:7] = b'\x7fELF' + bytes((elf_class, 1, 1)) + struct.pack_into(' 1: - parser.error('--platform all builds every platform and combines with no other') - # orchard.inputs resolves every platform when it is given none. - selected = [part for name in args.platform if name != 'all' for part in ('--platform', name)] + try: + architecture, platforms = targets(orchard, args.platform, args.architecture) + except (OSError, KeyError, ValueError) as error: + parser.error(str(error)) + # orchard.inputs resolves every arm64 platform when it is given none; the 32-bit ones are named. + named = [name for name in args.platform if name != 'all'] or ([] if architecture == 'aarch64' else platforms) + selected = [part for name in named for part in ('--platform', name)] + target_args = ['--architecture', architecture] + if architecture == 'armv7h' and not args.builder_image: + args.builder_image = ARMV7_BUILDER # what system/build-armv7-builder prepares + builder_args = ['--builder-image', args.builder_image] if args.builder_image else [] + labwc_image = args.labwc_builder_image or args.builder_image + labwc_args = ['--builder-image', labwc_image] if labwc_image else [] + identity = builder_key(args.builder_image) + compiler_identity = builder_key(labwc_image) desktop, desktop_key = component(PROJECT, 'desktop', INPUTS['desktop'], - lambda path, work: python('desktop/build', '--output', path), artifact='desktop') + lambda path, work: python('desktop/build', '--output', path, *target_args, *builder_args), + artifact='desktop', extra=[architecture, identity]) cursor, _ = component(PROJECT, 'cursor', INPUTS['cursor'], - lambda path, work: python('desktop/build-cursor-helper', '--output', path), - artifact='cursor-tools') + lambda path, work: python('desktop/build-cursor-helper', '--output', path, + *target_args, *builder_args), + artifact='cursor-tools', extra=[architecture, identity]) bluetooth, _ = component(PROJECT, 'bluetooth', INPUTS['bluetooth'], lambda path, work: python('desktop/build-bluetooth-tools', '--output', path), artifact='bluetooth-tools') labwc, _ = component(PROJECT, 'labwc', INPUTS['labwc'], - lambda path, work: python('desktop/build-labwc', '--output', path), - artifact='labwc-tools') + lambda path, work: python('desktop/build-labwc', '--output', path, *target_args, *labwc_args), + artifact='labwc-tools', extra=[architecture, compiler_identity]) # The base names the component, not a release: it serves every build with the same inputs. base, _ = component(PROJECT, 'base', INPUTS['base'], lambda path, work: run('system/build-rootfs', '--output', path, '--desktop', desktop, - '--release', 'base-' + desktop_key[:16]), - artifact='base', extra=[desktop_key]) - # The Omarchy mark is data for orchard's m1n1, which every distribution boots. + '--release', 'base-' + desktop_key[:16], *target_args, *builder_args), + artifact='base', extra=[desktop_key, architecture, identity]) + # The Omarchy mark: the splash of the root, the logo of the release. artwork, _ = component(PROJECT, 'artwork', INPUTS['artwork'], lambda path, work: python('boot/build-artwork', '--output', path), artifact='artwork') - # What orchard supplies finished: kernels, m1n1, Pongo, tools (the nightly's when they match). - inputs = PROJECT / 'out/components/.inputs.json' + # What orchard supplies finished: kernels, loaders, tools (the nightly's when they match). + inputs = PROJECT / f'out/components/.inputs-{architecture}.json' orchard_command('orchard.inputs', '--output', inputs, *selected) - shared = json.loads(inputs.read_text())['shared'] - tools = shared['tools'] + tools = json.loads(inputs.read_text())['shared']['tools'] python('-m', 'release.build', '--release', args.version, '--inputs', inputs, '--base', base / 'rootfs.tar.gz', '--base-receipt', base / 'rootfs.tar.gz.json', '--splash', artwork / 'splash', '--logo', artwork / 'logo.bin', *(part for name in ('busybox', 'dropbear', 'dropbearkey') for part in ('--' + name, tools[name])), '--cursor-tools', cursor, '--bluetooth-tools', bluetooth, '--labwc-tools', labwc, - '--output', args.output) + *target_args, '--output', args.output) python('-m', 'release.audit', args.output / 'release.toml') # install.sh and orchard-install.pyz, with orchard's launch loaders. orchard_command('orchard.installer', 'release', args.output)