ARM system build¶
The system-build runner builds AWTRIX on Linux for 32-bit ARM with a pinned Buildroot tree. One run produces:
- the musl cross toolchain (in
output/host/) that builds every program of a TC002 release – the TC002 recipes take it asTC002_MUSL_PREFIX; - an ARM userspace archive with the cross-compiled
awtrix-linux, used for emulated tests and license evidence.
It does not flash a device, build a kernel or produce a bootable image. The tools live in
tools/system/; the Buildroot
configuration they use is described in Buildroot configuration.
How it works¶
build.py works in a dedicated work directory and runs in steps:
| Command | What it does |
|---|---|
doctor |
Reports missing host tools. It installs nothing. |
prepare |
Resolves a local Git commit and archives only the application's build inputs from it (uncommitted edits are not included). Copies the Buildroot external tree from the checkout and records every file's hash, so recipe changes can be tested before committing. Verifies the pinned Buildroot archive before extracting it. |
verify |
Checks the application archive, extracted application, external recipes, Buildroot archive and extracted Buildroot tree against the recorded hashes. |
configure |
Applies the checked-in ARM profile awtrix_armv7_defconfig. |
build |
Configures again and builds, with --jobs compiler jobs. |
toolchain |
Prepares the work directory when it is new (without the application license check, which only the application needs), configures and builds only the cross toolchain (output/host/), with --jobs compiler jobs. |
legal-info |
Collects license and source records. |
Inputs stay frozen across retries; use a new work directory when you change the application
commit or the recipes. The runner refuses non-empty directories without its marker file. A failed
prepare is kept for inspection; choose another directory to retry. Existing cache corruption
and partial downloads cause errors instead of silently replacing files.
Buildroot's filesystem skeletons contain absolute links for the target system. Extraction creates links last and rejects archive entries that would write through them; source-tree link targets are recorded without following links out of the tree.
Before downloading Buildroot, prepare checks every application license file named by the recipe
against the exact exported Git bytes. Hashes are computed from raw bytes without newline
conversion.
Build¶
Use Python 3.12 or later on Linux or WSL. On Debian or Ubuntu:
sudo apt-get install build-essential git python3 rsync patch bc bison flex \
file unzip cpio wget xz-utils
python3 tools/system/build.py doctor
Use a work directory on the Linux filesystem, outside the source checkout. Do not use /tmp for
results that must survive a WSL restart.
python3 tools/system/build.py prepare --work "$HOME/awtrix-armv7-build" --revision HEAD
python3 tools/system/build.py verify --work "$HOME/awtrix-armv7-build"
python3 tools/system/build.py configure --work "$HOME/awtrix-armv7-build"
python3 tools/system/build.py build --work "$HOME/awtrix-armv7-build" --jobs 4
python3 tools/system/build.py legal-info --work "$HOME/awtrix-armv7-build"
Building the toolchain takes much longer than an application rebuild. For TC002 work,
build.py toolchain --work DIR alone is enough; point the recipes at the result:
Outputs¶
| File | Content |
|---|---|
inputs.json |
Application commit, SOURCE_DATE_EPOCH, source lock and frozen file hashes |
output/.config |
Resolved Buildroot configuration |
output/host/ |
The cross toolchain |
output/target/usr/bin/awtrix-linux |
Cross-compiled application |
output/images/rootfs.tar |
Userspace archive, without kernel or bootloader |
build-result.json |
Hashes and sizes, written after the build and the ARM ELF check succeed |
output/legal-info/ |
License and source records from legal-info |
legal-info also copies the exact Buildroot archive, application archive, frozen external
recipes and input manifest into output/legal-info/build-inputs/ with checksums. Buildroot's own
legal-info README asks the developer to collect these by hand; the original README is kept.
This is source evidence, not a completed license audit or SBOM (software bill of materials).
build-result.json records bootable_tc002_image: false, tc002_hardware_verified: false and
bit_reproducibility_tested: false.
The build, qualification and reproduction tools write each JSON record to <name>.partial,
created exclusively, and rename it after fsync. An existing .partial file or link at that
path stops the command; remove it only after checking that no other run uses the directory.
Test¶
Offline tests¶
The tests use temporary files, a temporary Git repository and mocked downloads. They cover corruption, archive traversal and links, kept partial files, tampering with frozen inputs, commit snapshots, ARM result checks, the component manifest, the host package lock, the build-host recipe and evidence bundles. The committed component manifest is checked against the checkout, so a vendored change without a manifest update fails. The tests build no toolchain and contact no device.
Emulated application contracts¶
After a build, install qemu-user, openssl and mosquitto on the host and run:
The runner checks the frozen inputs and artifact hashes, then runs the real ARM awtrix-linux
and awtrix-update-verify under QEMU (--qemu, default qemu-arm). It runs the application,
HTTPS and MQTT security, HTTP header limit and update package tests, including an ARM-built
filesystem fault library, which is preloaded inside the emulated process only. All network
fixtures are required. The emulation sysroot must match the verified rootfs archive before and
after the tests (only Buildroot's target-directory warning file is excluded).
Results go to arm-contracts.json, which binds artifact hashes, test-source hashes, emulator,
compiler and fault fixture. A rerun sets the status to running or failed until every suite
passes. This records a local test run; it is not an attestation of the host or emulator, and
emulation is not a hardware test.
Component manifest¶
tools/system/components.json
is the reviewed record of every vendored or bundled application component: Berry, PubSubClient,
TJpg_Decoder, TJpgDec, cpp-httplib, densaugeo/base64, the ESP-IDF dynamic mbedTLS buffers and the
Matrix-Fonts glyph tables. Each entry records:
- name, version, origin URL and upstream reference (or a note when the commit is unknown);
- whether it is vendored into the repository or bundled from a package source, and its paths;
- every local patch and the files it touches;
- its license file under
LICENSES/with SHA-256, and its row inTHIRD-PARTY-NOTICES.md; - the targets that compile it (
esp32,linux); - a dated review decision with a rationale (verifiable facts, not a legal opinion).
Vendored entries carry files_sha256, a digest over the raw bytes of every file below their
paths, so any change to a vendored tree needs a new review. An entry may name a patch_marker;
every file that contains the marker must be listed as a patch file. An unmarked edit is caught
only by files_sha256, and the reviewer who records the new digest must list it as a patch.
Rows of the notices tables outside this scope (framework, toolchain, the ESP32 libraries
PlatformIO resolves, the Unity test framework) are listed as notice_exclusions with a reason,
so every row is claimed exactly once.
python3 tools/system/check_components.py
python3 tools/system/check_components.py --print-files-sha256 berry
The check validates the schema, hashes the vendored trees, enforces the patch-marker rule, parses
the notices tables in both directions, checks that LICENSES/ contains exactly the linked files,
verifies every license hash and compares the license files of Linux-target components with
AWTRIX_NG_LICENSE_FILES in the ARM recipe. Every table in THIRD-PARTY-NOTICES.md needs a
header separator, and every body line up to the next blank line must be a row starting with the
component link.
Digests are computed from raw bytes, so a checkout that converts line endings gives different
values; when the only difference is CRLF, the check says so. Such a checkout ignores
.gitattributes; restore LF files from the repository before checking.
To add or update a component, change together: the vendored tree, LICENSES/,
THIRD-PARTY-NOTICES.md and, for Linux targets, awtrix-ng.mk and awtrix-ng.hash. Then
recompute the digest, record the review decision with today's date and run the check. Paste a
printed digest only after reviewing the change it stands for.
Qualification report¶
After build and legal-info, run on the same work directory:
It verifies the frozen sources, build-result identity, resolved configuration, both artifact
hashes and sizes, the ARM ELF header and the checksums of the legal material. It evaluates
Buildroot's show-info against the frozen recipes and checks the legal manifests against that
metadata. It also verifies components.json inside the frozen application tree. It downloads
nothing and touches no hardware.
Output goes to output/qualification/ unless --output says otherwise:
| File | Content |
|---|---|
inventory.cdx.json |
CycloneDX 1.6 inventory: configured source packages, build dependencies, the two verified output files, and the Linux-target application components nested under the awtrix-ng entry |
show-info.json |
Buildroot's original package metadata |
qualification.json |
Integrity results, hashes, application components (component_manifest_sha256, application_components), vulnerability-triage status, review states and public-release blockers |
evidence-template.json |
Review record format bound to the exact inputs and artifact hashes |
triage-template.json |
Vulnerability-triage record format bound to the component manifest hash |
The inventory keeps Buildroot's license wording instead of guessing SPDX expressions, marks host and target package roles, and does not list every installed file. Its Linux-header entry is not an installed-kernel declaration. See the CycloneDX 1.6 specification.
Vulnerability triage¶
vulnerability_triage is not_performed unless --triage FILE supplies a record: a JSON file
whose subject carries the manifest hash, whose data_source names the advisory source,
reference and retrieval date, whose reviewer is non-empty, and whose components map covers
every Linux-target component with a status (no_known_advisories, not_affected, affected,
under_investigation or fixed_locally) and a note. A triage covers only these application
components, not Buildroot packages. The tool never emits a CycloneDX vulnerabilities array.
Review records¶
To record a review, copy evidence-template.json to a separate directory and pass its path with
--evidence. Keep schema_version and subject unchanged. Every named check needs a state of
unmet or passed, a non-empty note and a files array. A passed check also needs a
non-empty reviewer and at least one file record with a relative path and its sha256. Paths
are relative to the review file; escaping paths and symlinks are rejected.
The named checks are hardware_identity, maintained_kernel_boot, peripherals,
install_without_opening, recovery_without_opening, power_loss_and_rollback,
security_review, signed_updates, redistribution_review and bit_reproducibility; the
template carries a one-line description of each. Hashed attachments keep their content identity;
they do not authenticate the reviewer.
Exit status: 0 for a valid development inventory, 1 for malformed or inconsistent evidence, 2
with --public when public release is blocked. affected or under_investigation findings and
pending component review decisions add blockers. The userspace-only profile always blocks public release
as TC002 firmware, whatever the review records say.
Reproducible builds¶
Build host image¶
tools/system/Dockerfile
builds the host image in two stages. The Ubuntu 24.04 base is pinned by digest. The final stage
installs the build, emulation and MQTT tools from tools/system/ubuntu.sources, whose stanzas
carry a Snapshot: timestamp (20260915T213330Z), so apt resolves every package from
snapshot.ubuntu.com at that point in time.
The base image has no CA bundle and the snapshot service is HTTPS-only. A first stage therefore
installs ca-certificates from the live archive (plain HTTP, protected by apt's signature check,
not pinned) and hands over only /etc/ssl/certs/ca-certificates.crt; the final stage replaces it
with the snapshot's own ca-certificates. The build log shows both hosts, because apt still
fetches the live index files, but every package download comes from the snapshot. The final stage
sets ubuntu.sources to mode 0644 before apt runs.
tools/system/host-packages.lock
is the raw dpkg-query -W output of that image (211 entries, amd64). Check an image against it:
--check-host-lock takes only --image. Regenerate the lock only together with a deliberate
change of the snapshot timestamp:
docker run --rm --network none --read-only --entrypoint /usr/bin/dpkg-query IMAGE -W \
> tools/system/host-packages.lock
The lock proves package identity; the immutable image ID identifies the bytes. If the snapshot
service stops serving the timestamp, the build fails at apt-get update instead of taking newer
packages. The lock is amd64-only, so an arm64 Docker host is refused.
Two clean builds¶
Build the image once, then reuse its content ID for two clean builds. All package downloads must
already be cached by a preceding successful build and legal-info:
docker build -f tools/system/Dockerfile -t awtrix-build-host:snapshot-20260915 tools/system
python3 tools/system/reproduce.py --check-host-lock --image awtrix-build-host:snapshot-20260915
python3 tools/system/reproduce.py --work "$HOME/awtrix-armv7-build" \
--output "$HOME/awtrix-armv7-reproduction" --image awtrix-build-host:snapshot-20260915 --jobs 4
python3 tools/system/qualify.py --work "$HOME/awtrix-armv7-build"
The runner resolves the tag to an immutable image ID and compares the image's package inventory
with the lock before writing anything. It records the inventory, uses the same /work path in
both containers, drops capabilities and disables the network during compilation. It copies
source inputs and downloads but no earlier compiler or output. The output directory must be new;
failed attempts are kept. Only two complete, matching builds give a successful
reproduction.json, which records the image identity, host packages, runner and verifier hashes,
commands, logs, source identity and artifact hashes. A change to the application or build inputs
needs a new run.
BR2_REPRODUCIBLE alone guarantees nothing across different absolute paths; that is why both
builds use /work. Keep bit_reproducibility_tested false until two complete builds have been
compared.
Verify a finished run¶
A completed run can be checked without compiling. Record the original inputs.json hash and the
original runner/build.py hash when you launch the build, and keep them with the trusted image
ID outside the run directory:
python3 tools/system/reproduce.py --verify-existing \
--work "$HOME/awtrix-armv7-build" --output "$HOME/awtrix-armv7-reproduction" \
--image "$AWTRIX_BUILD_IMAGE_ID" --expected-inputs-sha256 "$AWTRIX_INPUTS_SHA256" \
--expected-runner-sha256 "$AWTRIX_RUNNER_SHA256"
AWTRIX_BUILD_IMAGE_ID is the immutable sha256:... ID of the build image. The check covers
source identity, both copied input trees, configuration and output hashes, the ARM ELF, image
identity, the frozen runner, the host package inventory and logs. Success writes a separate
reproduction-verified.json. Its host_packages_lock_check reports whether the recorded
inventory equals the current lock; a difference is reported, not refused, so runs against an
older host stay verifiable. Neither command deletes an earlier failed build directory.
These records trust the local orchestrator and image. A check afterwards verifies retained files and identities; it cannot prove that an untrusted image really ran the compiler.
Evidence bundles¶
bundle_evidence.py packs one build's evidence into a single tar with a hashed manifest:
python3 tools/system/bundle_evidence.py create --work "$HOME/awtrix-armv7-build" \
--output /var/tmp/awtrix-evidence/<name>.tar [--reproduction DIR] [--logs DIR] [--no-downloads]
python3 tools/system/bundle_evidence.py verify --bundle /var/tmp/awtrix-evidence/<name>.tar
create first verifies the build like qualify.py and requires an
output/qualification/qualification.json bound to that exact build (inputs hash, commit,
build_result_sha256, artifacts and generated file hashes). Every file under
output/legal-info/ must be covered by that record: its legal_evidence_sha256 lists the files
in legal-info.sha256, which must be the path-sorted rendering of that list, and
build-inputs/manifest.json must name exactly application.tar, inputs.json, the Buildroot
archive and external.tar with the build's digests. Any other file is refused as unrecorded.
It then copies inputs.json, build-result.json, application.tar, output/.config, both
artifacts, arm-contracts.json when present, output/legal-info/, output/qualification/ and,
unless --no-downloads, the downloads/ cache into one uncompressed PAX tar. A --reproduction
directory must hold a successful reproduction.json for the same inputs; its files go under
reproduction/. Files of a --logs directory go under logs/. Member metadata is fixed and the
order is sorted, so two bundles of the same evidence are byte-identical. If any copied file's
hash differs from what was verified, the .partial tar is kept, no manifest is written and
create fails with evidence changed while bundling. <name>.tar.manifest.json records every
member's SHA-256 and size, the bundle hash, missing optional files and the build identity.
Member names must be canonical relative paths, free of forms that Windows resolves to another
file (:, trailing . or space, 8.3 short names such as ~1), unique when case is ignored,
and inside the paths create writes. verify refuses a bundle with any other name, so no member
can extract over another.
verify streams the tar, rechecks every hash and repeats the checks of create that the
bundled files allow: ARM profile, successful build of that commit, ARM 32-bit little-endian ELF,
digests of application.tar, output/.config and (when included) the Buildroot archive,
qualification and legal-info bindings, and reproduction bindings. A change to any digest-bound
file fails even when the manifest was rehashed. qualification.json,
build-inputs/manifest.json and reproduction.json are checked for their binding fields only,
so a consistent rewrite of these together with the manifest is not detected. The other
downloads, reproduction files and logs are bound only by the manifest.
Outputs under /tmp, /dev/shm, the interpreter's temporary directory, inside a source
directory or over an existing file (including leftover .partial names) are refused, as are
symlinked sources and symlinks inside evidence trees. A bundle records the bytes that were kept;
it does not prove that a process ran.
CI¶
The Experimental ARM userspace workflow
(.github/workflows/system-build.yml)
runs on pull requests that change firmware/buildroot/, tools/system/ or the workflow, and on
manual start. It runs the offline tests, cross-builds, runs the emulated contracts and uploads a
development bundle named arm-userspace-development-not-tc002-firmware: a compressed tar of
build-result.json, inputs.json, arm-contracts.json, output/.config,
output/images/rootfs.tar, output/qualification/ and output/legal-info/. It publishes no
firmware release.
Related¶
- Buildroot configuration
- AWTRIX on Linux
- AWTRIX NG on the TC002 – toolchains and release bundles