18 KiB
Private unsigned UAT ISO preparation
The operator requires the same qualified candidate on Framework and Yaya and
an ISO for a physical test install before approving signed OTA/ISO/Companion or
other publication. scripts/build-iso-uat.py prepares the private ISO handoff.
It does not deploy, sign, request keys, generate an OTA manifest, change the public
feed, or authorize publication. The signed release helpers retain their gates.
Existing APK signatures are verified for identity/continuity; none are created.
Inputs required from the integration/qualification owner
- A clean, reviewed, frozen Git commit containing all integrated lanes and this tooling. Full commit identity and matching Cargo/npm versions are mandatory. No release tag is required for UAT. Dirty/untracked changes are refused.
- A native x86-64 backend built from that commit, with retained build commands, toolchain/lockfile and native qualification evidence. ELF inspection alone does not establish build provenance.
- The qualified flat frontend tar.gz for that commit, including dashboard,
/aiui/build with exactBUILD-INFOcommit, Companion APK/metadata, andarchipelago-runtime/RELEASE-PAYLOAD.json. Produce the runtime overlay withrelease_payload.py stage --version VERSION --commit FULL_COMMITas described by that helper's CLI; canonical archive modes are directories 0755/files 0644. This helper consumes that archive; it never substitutes a locally served UI. - Qualified
archy-reticulum-daemon,archy-rnodeconfandarchy-esptoolbinaries, with source/toolchain provenance in the evidence. Esptool's existing builder self-test remains mandatory. - The locally qualified LND Tor OCI archive, its
identity.jsonandfinal-source.jsonfromLND-TOR-ARTIFACT-20261101.md. This is a local import binding, not a registry publication. The exact imported digest (not the pre-export builder digest) must match bothapps/lnd-tor/manifest.ymland nativeQUALIFIED_IMAGES. All six recorded source files must still match. - A retained qualification evidence file (or an archive of the evidence), with actual source/native/frontend/Companion/composition gate results and exact hashes of all eight artifact/proof inputs. Record review/proposal dispositions, unresolved ledger gates, and why this candidate is ready for unsigned UAT, explicitly distinct from physical/operator release acceptance. The qualification owner supplies and reviews this attestation; the helper cannot authenticate a human review or reconstruct native build provenance from an ELF header.
Write a private JSON receipt with this shape. The angle-bracket values below are
placeholders, not hashes or qualification claims; replace every one using
real retained evidence (sha256sum), never copy an earlier candidate's hashes.
{
"schema": "archipelago-unsigned-uat-input-v3",
"qualification": "qualified-for-unsigned-uat",
"reviewed_by": "<qualification owner / review reference>",
"source_commit": "<full 40-character frozen commit>",
"version": "<matching Cargo and npm version>",
"backend": {"path": "<absolute path>", "sha256": "<actual hash>"},
"frontend": {"path": "<absolute path to flat tar.gz>", "sha256": "<actual hash>"},
"reticulum": {"path": "<absolute path>", "sha256": "<actual hash>"},
"rnodeconf": {"path": "<absolute path>", "sha256": "<actual hash>"},
"esptool": {"path": "<absolute path>", "sha256": "<actual hash>"},
"lnd_tor_oci": {"path": "<absolute OCI archive path>", "sha256": "<actual archive hash>"},
"lnd_tor_identity": {"path": "<absolute identity.json path>", "sha256": "<actual identity receipt hash>"},
"lnd_tor_sources": {"path": "<absolute final-source.json path>", "sha256": "<actual source receipt hash>"},
"companion": {
"package_name": "com.archipelago.app.debug",
"versionName": "<actual verified version>",
"versionCode": 58,
"sha256": "<actual APK hash>",
"metadata_sha256": "<actual Companion JSON hash>",
"certificate_sha256": "<actual verified certificate hash>"
},
"evidence": {"path": "<absolute path>", "sha256": "<actual hash>"},
"kernel": {
"pin": {
"architecture": "amd64",
"package": "linux-image-6.12.113+deb13-amd64",
"version": "6.12.113-1",
"source": "linux-signed-amd64",
"archive_sha256": "<qualified Debian archive SHA256>",
"vmlinuz_sha256": "<qualified vmlinuz SHA256>"
},
"archive": {"path": "<absolute .deb path>", "sha256": "<same archive hash>"},
"payload_receipt": {"path": "<absolute kernel payload receipt path>", "sha256": "<receipt hash>"},
"evidence": {"path": "<absolute qualification/provenance evidence path>", "sha256": "<evidence hash>"}
}
}
Keep artifacts and evidence outside the source checkout. A different source commit or artifact requires fresh qualification; a version substring is not source provenance. The final receipt includes the input attestation verbatim and its hash so the later Framework/Yaya deployment owner can match exact bytes.
Build invocation (only after those inputs exist)
From the clean frozen checkout, set AAPT and APKSIGNER to the installed Android
SDK tools. Requires Python 3.12+, Git, sudo, the archived builder dependencies,
xorriso, squashfs tools, QEMU and Debian OVMF. The existing builder uses privileged
containers, authenticated APT repositories and network downloads. Run on a
dedicated disposable build environment; its container engine must also enforce
the CPU2/8G budget (a daemon's containers do not automatically inherit the calling
user scope). No build is authorized on the current dirty integration worktree.
export AAPT=/home/archipelago/Android/Sdk/build-tools/35.0.0/aapt
export APKSIGNER=/home/archipelago/Android/Sdk/build-tools/35.0.0/apksigner
systemd-run --user --scope -p CPUQuota=200% -p MemoryMax=8G -p MemorySwapMax=0 \
flock /tmp/opencode/release-heavy.lock \
python3 -B scripts/build-iso-uat.py \
--qualification /absolute/private/qualified-uat.json \
--output /tmp/opencode/unsigned-uat-unique-run
Use the actual verified versionCode too; 58 above is the currently qualified
Companion, not a permanent version allowance. The complete companion object
must equal SDK-verified source identity plus the metadata hash. V1 and V2 input
receipts are refused: V3 additionally requires the exact qualified kernel binding.
The current x86-64 UAT wrapper requires amd64; it cannot default a missing pin.
Exact qualified kernel contract
kernel.pin has exactly the six keys shown above. Package, Debian version and
signed source must agree with the architecture. Archive/payload/evidence paths
must be absolute regular files with matching hashes; the payload receipt's
package/version/source/archive and vmlinuz hashes must match the pin. Supply the
real retained qualification evidence; this unsigned receipt is not itself a Debian
signature or operator install approval. The .deb is retained and hash-bound as
an input, not installed as a fallback that bypasses current authenticated indexes.
The UAT wrapper passes a canonical pin file by explicit --uat-kernel FILE CLI,
paired with --unsigned-uat-oci. The archived builder clears ambient UAT pin
variables and refuses incomplete/duplicate/invalid CLI pins before build mutation.
Generic signed builds without these options keep the existing CVE floor policy.
For UAT, both the target rootfs and live installer:
- run normal authenticated
apt-get update, then require the exact version, architecture, Debian origin/source and archive hash in those current indexes; - apply a specific positive pin for the one qualified package/version, negative pins for its other versions and all other linux-image packages, including generic metapackages; APT simulation must select no other kernel;
- install the versioned package with
=VERSION, never the generic metapackage; UAT debootstrap omits the kernel altogether until the policy is in place; - keep that policy through recommends and full-upgrade, verify final installed
package set, selected
/vmlinuzand/initrd.imglinks, kernel bytes, module release and actual initramfs contents; retain authenticated package provenance andboot-binding.jsonhashes after generation; - retain APT package record, plan and signed InRelease files with original dates. No GPG/hash/Date/Valid-Until check is disabled, no unsigned mirror is used, and missing exact versions fail rather than substituting a newer fixed kernel.
The exact pin bytes and validation helper enter the rootfs recipe identity.
Every reused UAT rootfs tar is additionally checked for exact dpkg/package/receipt,
all receipt payload hashes, selected links, initrd hash and its module contents;
a copied stamp cannot authorize a stale cache. The final extracted ISO repeats
target/live exact binding, checks /live/vmlinuz and /live/initrd.img, and both
BIOS/UEFI logs must identify the matching kernel build. .115 can remain a known
fixed generic kernel while failing a candidate whose qualified profile is .113.
The extracted target and live receipt file maps must also equal the supplied
qualified archive payload map (4,254 regular files for the current .113 package),
so a changed module plus a rewritten local receipt cannot qualify itself.
UNSIGNED-UAT.json output schema is now archipelago-unsigned-uat-result-v2, with
qualified_kernel and live_boot_binding. Physical install acceptance follows
test-ISO delivery; it is not a prerequisite for building this unsigned test ISO.
The output directory must be new and outside source. Failures retain logs and
intermediates there; absence of UNSIGNED-UAT.json means no successful UAT
artifact handoff. Do not use an ISO left by a failed run. There are no skip-smoke,
skip-boot, signing, deployment or publication options. An existing output is never
overwritten. The archived builder is run from a fresh clone, excluding ignored
local binaries/dist/caches. Its global container cache/build-counter behavior is
another reason to use the dedicated build environment.
Required gates before a success receipt:
- Pre/post clean source and version identity; hash-checked staged inputs and retained qualification evidence; native ELF and strict frontend archive checks.
- Existing Companion aapt/apksigner identity/version/certificate/scheme checks.
- OCI archive hash, every blob hash, all descriptor sizes/media types/digests, the single linux/amd64 manifest/config, UID100/GID100, and every decompressed layer diffID. Extra blobs, stale builder digests, mutable source tags and source receipt drift fail before the build.
- Exactly one new ISO from the fresh clone; real mount-level ISO smoke test.
- xorriso extraction and exact backend, full frontend (including APK/JSON and runtime overlay), and radio helper byte comparisons. Extra web files fail.
- Actual rootfs tar kernel/module receipt hashes and fixed Debian package floor;
actual live squashfs receipt/hash validation and equality with
/live/vmlinuz. These checks reuse the kernel lane's current policy without changing it. - Actual BIOS and UEFI ISO boots into userspace in separate disposable QEMU disks, with serial evidence of the exact verified fixed kernel release/build. Boot sanity is not installation or installed-node acceptance. Existing initrd and boot plumbing checks remain part of the mandatory smoke test.
- ISO hash unchanged through verification. Output is named
archipelago-UNSIGNED-UAT-VERSION-COMMIT-x86_64.iso, with an unsigned.sha256andUNSIGNED-UAT.jsonrecording source tree, artifacts, kernel and log hashes.
Local OCI delivery and appointed-runtime import
The transferable candidate/ directory contains the exact backend/frontend,
radio helpers, original qualification/evidence, all three OCI inputs, and
unsigned-uat-oci/. That sub-bundle contains the archive, identity/source proofs,
source snapshots (including native admission), narrow importer/validator, and a
hash inventory in BUNDLE.json bound to the candidate commit and Companion.
The final UAT result records its actual SHA256.
The unsigned builder passes an explicit --unsigned-uat-oci argument to the
archived builder. It is not an inherited environment option, so signed
wrappers retain their existing behavior. The ISO gets an exact copy at
/archipelago/unsigned-uat-oci; post-build extraction verifies every byte and
revalidates the OCI proof. It is deliberately outside container-images/ so the
legacy default-store loader cannot pick it up.
Both the archived auto-install path (/mnt/target) and the separate
install-to-disk.sh path (/mnt/archipelago) validate and stage it under
/opt/archipelago/unsigned-uat-oci. Staging reports staged-import-deferred and
never invokes Podman, enables a service, or changes app intent. A differing
already-staged candidate is preserved and causes failure instead of overwrite.
After source/candidate qualification and separately authorized node deployment, the D09 admission owner supplies the actual runtime configuration and storage configuration hashes, and the packaging receipt supplies the bundle hash. Run under the shared heavy lock and CPU2/8G limits:
sudo python3 -B /opt/archipelago/unsigned-uat-oci/import-uat-oci.py import \
--bundle /opt/archipelago/unsigned-uat-oci \
--bundle-sha256 "$QUALIFIED_BUNDLE_SHA256" \
--runtime-sha256 "$APPOINTED_RUNTIME_SHA256" \
--storage-sha256 "$APPOINTED_STORAGE_SHA256"
Retain stdout as the per-node import receipt. Do not invent these hashes or obtain
the storage hash from root's home. The root coordinator uses installed
c01_runtime.load_config() and a live RuntimeLease; it pins the lease's actual
mount namespace, enters that namespace, drops to its appointed runtime UID/GID
without supplementary groups, and supplies that runtime's HOME/XDG/config.
Storage declarations are checked before Podman; explicit graphroot/runroot/
driver and --remote=false prevent default-store/remote fallback. Actual Podman
info must prove the rootless config file, graphroot, runroot and driver before
and after import. Runtime/storage hashes and the live lease remain checked.
Only load, digest-derived local tag, and exact digest image inspect are
performed; imported manifest digest, config ID, platform and identity must match.
No image is pulled or pushed, and no container, unit, wallet, app record, stop,
uninstall, pause or hold decision is changed. A local image cache entry grants no
permission to activate an app. Normal admitted lifecycle remains authoritative.
Missing installed runtime helpers, missing/invalid appointment, unavailable live namespace, absent storage config, or a mismatch yields exit 75 / DEFER, with no default-store retry. If failure occurs after loading, it may leave that exact image cached; no success receipt is emitted and no automatic cleanup touches other images. Fixture bundles with null source identity cannot be live-imported. The installer always stages first; it cannot manufacture D09 admission at install time. Installed-runtime import visibility and normal lifecycle are separate qualification requirements, not implied by disposable-store tests.
Smoke failures, including legacy smoke assertions that no longer match a reviewed candidate, must be investigated by their owner; do not skip or relabel them.
Handoff and approval order
The separately authorized deployment owner installs the exact receipt-bound backend/frontend/Companion candidate on both Framework and Yaya, retaining rollback, wallet/data/uninstall-intent and no-repayment safeguards. Record served and installed byte hashes plus actual feature/composition/restart/recovery checks. Give the operator the unsigned ISO and checksum for a physical install; record live, installed and running fixed-kernel checks separately, including reboot. All outstanding regression-ledger requirements remain open until evidenced or explicitly dispositioned by the operator.
Only after these three UAT surfaces and explicit operator approval may the lead proceed with signed OTA/ISO/Companion/etc. Existing annotated-source, mirror parity, signature and public-feed checks still apply to that later pipeline. This UAT receipt is not a signed release manifest and must never be promoted into a feed. If the signed release is rebuilt, byte differences require renewed qualification and, where affected, renewed UAT; do not assume a shared version proves equality.
Tooling verification
systemd-run --user --scope -p CPUQuota=200% -p MemoryMax=8G -p MemorySwapMax=0 \
flock /tmp/opencode/release-heavy.lock \
python3 -B scripts/tests/test_unsigned_uat_iso.py -v
These tests create a real nonbootable synthetic payload ISO using xorriso, extract and compare it, and exercise tamper/failure paths. Synthetic kernel receipts and boot-log strings test validation only. They do not establish Debian APT authentication, candidate ISO smoke/boot, installed-node or operator UAT.
Additional focused suite: PYTHONPATH=scripts/tests python3 -B -m unittest -v test_uat_kernel test_uat_oci test_unsigned_uat_iso test_kernel_security, under the
same resource/lock wrapper. Kernel tests use real APT policy evaluation, actual
cpio/initramfs inspection, cached tar payload tampering, builder CLI execution and
rendered rootfs/executed debootstrap argument checks; these are not full ISO boots.
scripts/tests/qualify_uat_oci.py --evidence E --output /tmp/opencode/NEW-RUN
verifies the real archive and SDK-verified Companion, carries the real bytes
through a nonbootable ISO and offline target staging, then uses the same image
import/inspection routine in a fresh explicitly configured rootless VFS store.
It creates no containers, leaves its store and receipts for inspection, and marks
source_qualified=false, full_candidate_built=false, live_d09_import_tested=false.