Skip to content

Tachyon A/B system image v1: dual-slot LUN0 + particle_image_v1 OTA format#59

Open
mrlambchop wants to merge 10 commits into
mainfrom
feature/tachyon-image-v1
Open

Tachyon A/B system image v1: dual-slot LUN0 + particle_image_v1 OTA format#59
mrlambchop wants to merge 10 commits into
mainfrom
feature/tachyon-image-v1

Conversation

@mrlambchop

@mrlambchop mrlambchop commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Combines the two halves of the A/B system-image work into a single PR (supersedes #57 and #58, which are closed in favour of this).

1. A/B LUN0 layout (was #57)

Redesigns LUN0 for A/B updates: dual system_a/system_b (12 GB each), dual efi_a/efi_b (512 MB each, distinct GUIDs), persistent persist, and a grow-to-fill userdata. Compose writes rootfs/efi into both slots. (Boot/firmware LUNs already carried A/B.)

2. particle_image_v1 OTA format (was #58)

compose_24_04.sh §6c emits + validates the Particle OTA image (<image>.pimg.zip) from the assembled factory tree using the shared @particle/tachyon-image CLI, signed with the test ed25519 key and validated against the bundled public key. Additive and never fatal to the legacy zip.

  • Dockerfile 1.5: adds nodejs/npm.
  • CI (build.yml): uploads the .pimg.zip per region/variant as a downloadable artifact.
  • CLI source: installs @particle/tachyon-image@0.1.0 from npm (published, prebuilt — no in-container git/build, which is what failed previously).

Why combine

The image check showed they're inseparable: the OTA format needs a second system slot to be useful, and that slot only exists with the LUN0 redesign. Shipping them together means a built image is actually A/B-OTA-capable end to end.

Test against

Each matrix build (RoW/NA × headless/desktop) attaches the legacy system-image .zip (S3 + PR comment) and the new *.pimg.zip (GitHub artifact).


Update — ship UFS provisioning in the image manifest

compose_24_04.sh now emits edl.provision_xml in manifest.json when provision_ufs22.xml is present in the factory tree (it is — make_factory_img.sh copies it). The OTA image format then carries the provisioning XML, so a particle tachyon update --mode factory re-provisions the UFS LUN geometry (the A/B layout changed it) before programming — required so the NON-HLOS modem write doesn't run off the device's old LUN boundary.

Note: the in-container particle_image_v1 emit installs @particle/tachyon-image from npm (PARTICLE_IMAGE_VERSION, currently 0.1.0); a published version carrying manifest.provision (lib PR) is needed for the regenerated pimg to include provisioning.

mrlambchop and others added 7 commits June 24, 2026 18:18
…rdata

Rework the LUN0 (HLOS) layout in partition_ext.xml from a single-slot
efi+system to an A/B layout, and write both slots at factory:

  efi_a / efi_b       512 MB each (ESP), A/B
  system_a / system_b 12 GB each (fixed), A/B — both flashed with rootfs.ext4
  persist             30 MB, not slotted (survives A/B)
  userdata            grow-to-fill last partition, 4 GB floor, not slotted

system_a/system_b must be fixed (only the last partition can grow), so they
are sized to fit the 64 GB SKU: 2x12 GB system + 2x512 MB EFI + persist =
25 GB fixed, leaving ~30 GB for userdata on 64 GB and ~90 GB on 128 GB. LUN0
already grows to fill the whole device (provision_ufs22.xml LUNtoGrow=0), so
userdata absorbs the SKU difference dynamically.

compose_24_04.sh now passes --system_a/--system_b (both -> rootfs.ext4) and
--efi_a/--efi_b (both -> efi.img): one source image written to both slots,
stored once in the zip, exactly as the 20.04 dual-slot flash did.

A/B partitions carry distinct type GUIDs (same convention as the LUN4
firmware slots). This lands the partition STRUCTURE; making slot B actually
bootable still needs firmware-B blobs, a UEFI slot-switch policy, per-slot
GRUB, and userdata first-boot mkfs+mount (tracked separately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7qj7xrmw9rmu1m23n9qeB
Add nodejs/npm to the builder (Dockerfile v1.5) and a section 6c that runs the
shared @particle/tachyon-image CLI over the assembled factory tree to emit the
Particle OTA image format and validate it (signature, slot symmetry, sizes,
emittable formats). Makefile gains EMIT_FORMAT/EMIT_SLOT, a vendor_particle_image
target (build+pack the lib into .tmp/vendor as a tgz the container installs), and
passes the two args to compose. Step is additive: if the CLI/tgz is absent the
legacy factory zip is still produced. ed25519 manifest-signing test keypair added
under keys/ (public + throwaway TEST private; prod key supplied at build time).
OTA.md documents the full design.
The section 6c emit previously resolved particle-image only from a local
sibling checkout, which CI does not have, so the OTA format was silently
skipped on PR builds. Add a public-tag fallback (github:#PARTICLE_IMAGE_REF,
default v0.0.1) resolved by the container's npm, so CI emits + validates the
particle_image_v1 format for real. Upload the resulting .pimg.zip as a
downloadable GitHub artifact per region/variant so it can be tested against.
Best-effort throughout: compose has no set -e, so a skip/failure never blocks
the legacy system-image zip.
The first PR build skipped the OTA emit: npm's `github:` shorthand resolves
to git+ssh first, and the build container has no SSH key, so the install
failed in ~5s and the silent redirect hid it. Use the explicit
git+https://github.com/...#<ref> URL (public repo → no auth) and show npm's
output instead of discarding it. Verified locally: https ls-remote needs no
credentials and npm git+https install builds the CLI bin.
Replace the in-container git+https install (which failed: the debianised
container npm could not run the git-dependency prepare build — 'tsc: not
found') with the published, prebuilt npm package. The npm tarball ships dist/,
so there's no clone and no build step in the container. PARTICLE_IMAGE_REF
(git tag) becomes PARTICLE_IMAGE_VERSION (npm semver, default 0.1.0).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR combines the A/B LUN0 partition layout work with generation/validation of a new particle_image_v1 OTA artifact (*.pimg.zip) emitted from the assembled factory tree via the @particle/tachyon-image CLI.

Changes:

  • Redesign LUN0 to dual-slot efi_a/b + system_a/b plus non-slotted persist and grow-to-fill userdata.
  • Add optional emission + validation of particle_image_v1 from compose_24_04.sh, with Node/npm added to the builder image.
  • Extend CI to upload the resulting *.pimg.zip as a workflow artifact per region/variant.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
scripts/assemble/config/partition_ext.xml Updates LUN0 partition table for A/B + adds userdata partition.
OTA.md Adds design/format documentation for Particle’s OTA image system and related components.
Makefile Adds vendoring support + parameters for particle_image_v1 emission and passes them into the compose script.
keys/particle-ota-test-ed25519.key Adds an ed25519 private key intended for test signing.
keys/particle-ota-pub.pem Adds the corresponding public key for validation.
keys/.gitignore Re-includes the new OTA signing key material under keys/.
Dockerfile Installs nodejs/npm in the builder container to run the particle-image CLI.
compose_24_04.sh Writes rootfs/efi into both slots at factory; adds section 6c to emit/validate *.pimg.zip.
.github/workflows/build.yml Uploads *.pimg.zip as a GitHub Actions artifact (warn if missing).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread compose_24_04.sh Outdated
Comment on lines +323 to +327
"$PI" generate --factory-dir "$OUT/factory" --out "$PIMG" \
--emit "$EMIT_FORMAT" "${SLOT_ARGS[@]}" \
--sign-profile "$SIGNING_PROFILE" "${KEY_ARGS[@]}"
"$PI" validate "$PIMG" --public-key "$PROJ/keys/particle-ota-pub.pem"
echo "OK particle_image_v1: $PIMG"
Comment thread compose_24_04.sh Outdated
Comment on lines +318 to +320
if [ "$SIGNING_PROFILE" != "none" ] && [ -f "$PROJ/keys/particle-ota-test-ed25519.key" ]; then
KEY_ARGS=(--key "$PROJ/keys/particle-ota-test-ed25519.key" --key-id particle-ota-test)
fi
Comment on lines +1 to +3
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIB4HoQ+FixBE6sakVXVOaQSetc4ODVevHO/N6bzdJkXP
-----END PRIVATE KEY-----
Comment thread keys/.gitignore
Comment on lines +15 to +19
# re-include the particle_image_v1 manifest-signing keys. The public key is needed
# everywhere to verify; the .key is a THROWAWAY TEST signing key only (sign-profile=test).
# Production manifest keys are supplied at build time via secret/mount and never tracked.
!particle-ota-pub.pem
!particle-ota-test-ed25519.key
<partition label="system" size_in_kb="10485760" type="B921B045-1DF0-41C3-AF44-4C6F280D3FAE" bootable="false" readonly="false" system="true" dontautomount="true" filename="system.img"/>
</physical_partition>
<partition label="efi_a" size_in_kb="524288" type="C12A7328-F81F-11D2-BA4B-00A0C93EC93B" bootable="false" readonly="true" system="true" dontautomount="true" filename="efi.bin"/>
<partition label="efi_b" size_in_kb="524288" type="FFFE4A7E-CB5C-4290-87D9-4B7C5B994CA5" bootable="false" readonly="true" system="true" dontautomount="true" filename=""/>
Comment thread OTA.md Outdated
Comment on lines +32 to +45
## Workflow constraints & branch setup

- **Never push.** All work stays local — feature branches and local commits only; no `git push`, no
PRs, until the user explicitly asks.
- **Branch per repo (off the latest default branch):**
- `tachyon-composer`: discard the uncommitted `partition_ext.xml` experiment
(`git checkout -- scripts/assemble/config/partition_ext.xml`), then branch
**`feature/ota-image-format`** off `main` (carry `OTA.md` over).
- `particle-cli` (`../../cli/particle-cli`): default branch is **`master`** — pull latest, then
branch **`feature/tachyon-ota-ab`**. Leave the existing untracked backup artifacts alone.
- `particle-tachyon-image`, `particle-tachyon-ota`: new repos (`git init`, initial local commit).
- **Everything must run.** Build + unit-test the shared lib and CLI as they're written; the CLI gains
a **`--dry-run`** mode (below) so the full flash/slot/update path is exercisable with no hardware.

Comment thread OTA.md Outdated
Comment on lines +75 to +78
New repo at **`../particle-tachyon-image`** (i.e. `/Users/nicklambourne/Documents/particle/tachyon/particle-tachyon-image`,
sibling of tachyon-composer; to be created) — npm package, recommend **TypeScript**, emit CommonJS+ESM
(it is a contract shared by four consumers; types prevent drift). Every module is independently unit-tested (the user's explicit
requirement: *the common files themselves need tests*). Modules:
Comment thread Makefile Outdated
Comment on lines +388 to +397
@if [ -d "$(PARTICLE_IMAGE_LIB)" ]; then \
echo "Vendoring particle-image from $(PARTICLE_IMAGE_LIB) ..."; \
mkdir -p "$(VENDOR_DIR)"; \
rm -f "$(VENDOR_DIR)"/particle-tachyon-image-*.tgz; \
( cd "$(PARTICLE_IMAGE_LIB)" && npm ci --silent && npm run build --silent \
&& npm pack --silent --pack-destination "$(abspath $(VENDOR_DIR))" ) \
&& echo "Vendored: $$(ls "$(VENDOR_DIR)"/particle-tachyon-image-*.tgz)"; \
else \
echo "NOTE: $(PARTICLE_IMAGE_LIB) not found; particle_image_v1 emission will be skipped"; \
fi
@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW headless
Version: 1.2.1-dev+build.aed688a
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA headless
Version: 1.2.1-dev+build.aed688a
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW desktop
Version: 1.2.1-dev+build.aed688a
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA desktop
Version: 1.2.1-dev+build.aed688a
Download: URL

…ack for prod

Address Copilot review on PR #59:
- compose_24_04.sh: guard particle-image generate/validate so a non-zero exit
  no longer aborts the legacy factory build under set -euo pipefail (warn only).
- compose_24_04.sh: select the OTA signing key by profile via a case statement.
  The committed ed25519 key is test-only and is used solely for SIGNING_PROFILE=test;
  any real/prod profile must supply PARTICLE_OTA_KEY[/PARTICLE_OTA_KEY_ID] or the
  OTA step is skipped with a loud error instead of signing with the test key.
  Validation pubkey is overridable via PARTICLE_OTA_PUBKEY.
- Makefile: gate vendor_particle_image on npm availability; emit a NOTE instead
  of hard-failing make build_24.04 when npm is absent on the host.
- OTA.md: drop the local working-plan directives and the personal filesystem
  path in favour of neutral, portable repo guidance.
@mrlambchop

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — addressed in 0b9589d. Summary of what changed vs. what's intentional:

Fixed

  • OTA emission no longer aborts the legacy build (compose_24_04.sh): particle-image generate/validate now run in a guarded block, so a non-zero exit under set -euo pipefail only warns — the legacy factory zip is always produced.
  • No test-key fallback for prod (compose_24_04.sh): key selection is now a case on SIGNING_PROFILE. The committed ed25519 key is used only for test; any real/prod profile must supply PARTICLE_OTA_KEY[/PARTICLE_OTA_KEY_ID] (validation pubkey overridable via PARTICLE_OTA_PUBKEY), otherwise the OTA step is skipped with a loud error rather than signing with an untrusted key.
  • Makefile vendoring gated on npm (Makefile): vendor_particle_image now emits a NOTE instead of hard-failing make build_24.04 when npm is absent on the host (the package installs from the npm registry inside the container anyway).
  • OTA.md cleanup: removed the local working-plan directives ("Never push", branch-setup) and the personal /Users/... path in favour of neutral, portable guidance.

Intentional / deferred (with rationale)

  • efi_b type GUID (partition_ext.xml): A/B slots need distinct GPT type GUIDs in this layout, and slot B is selected by the bootloader, not by UEFI ESP auto-discovery — so efi_b deliberately does not carry the standard ESP type GUID. We'll revisit uniqueguid if we ever want firmware to auto-discover B as an ESP.
  • Committed test signing key (keys/): this is an intentional, throwaway test-only key (the trust model keeps the verification key on-device, never in the image). It is wired solely to SIGNING_PROFILE=test. Moving it to a CI-generated/secret-sourced fixture is a reasonable follow-up but out of scope for this PR.

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA headless
Version: 1.2.2-dev+build.1dcf2ba
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW desktop
Version: 1.2.2-dev+build.1dcf2ba
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA desktop
Version: 1.2.2-dev+build.1dcf2ba
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW headless
Version: 1.2.2-dev+build.1dcf2ba
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW headless
Version: 1.2.2-dev+build.be2573c
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA headless
Version: 1.2.2-dev+build.be2573c
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA desktop
Version: 1.2.2-dev+build.be2573c
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW desktop
Version: 1.2.2-dev+build.be2573c
Download: URL

Emit edl.provision_xml when provision_ufs22.xml is present in the factory
tree, so the OTA image format carries it and a factory flash can re-provision
the LUN geometry before programming.
@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW headless
Version: 1.2.2-dev+build.f7a91d4
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA headless
Version: 1.2.2-dev+build.f7a91d4
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for NA desktop
Version: 1.2.2-dev+build.f7a91d4
Download: URL

@github-actions

Copy link
Copy Markdown

PR build uploaded for RoW desktop
Version: 1.2.2-dev+build.f7a91d4
Download: URL

@mrlambchop mrlambchop deployed to production June 26, 2026 04:47 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants