Build a bootable live ISO from any bootable container image — entirely in your browser. No server, no upload: CI uses the same engine (tacklebox). We compile it to WebAssembly, and it runs client-side.
Live: https://iso.tunaos.org
Your machine runs one task at a time using your signed-in AI coding CLI. The relay alternates between eligible School (OS) and Reef (apps) hives after each task. If one hive has no work, the other can continue.
The guide covers prerequisites, verified just setup, credentials, provider
costs, and how to stop with Ctrl+C. Your subscription quota or API charges apply.
Never post account credentials or API keys in issues or pull requests.
For step-by-step browser instructions and current limits, see the browser quickstart.
- Pick a Base & Desktop: Select your base (AlmaLinux Kitten, Fedora, Debian, etc.). Then select a desktop (GNOME, KDE Plasma, COSMIC, Niri, XFCE).
- Instant Inspection: The builder downloads metadata and inspects the image layers in seconds.
- Build ISO: Click build. The builder streams a custom bootable ISO to your local storage.
All other options are under Advanced, and each one is opt-in. You can preload Flatpaks, add system packages (remora), add custom repos, or set a custom registry relay.
The builder is a serverless, client-side application. Its design avoids the usual browser limits on memory and storage for multi-gigabyte container images:
sequenceDiagram
autonumber
actor User as Local Disk
participant Browser as Browser Client (tbox.wasm)
participant Relay as CORS Relay (relay.tunaos.org)
participant Registry as GHCR (ghcr.io)
User->>Browser: Select Image & click Build
Browser->>Relay: HTTP GET /token
Relay->>Registry: Request anonymous read token
Registry-->>Relay: Token payload
Relay-->>Browser: CORS-enabled Token
Browser->>Relay: HTTP GET /manifests/tag
Relay->>Registry: Proxy manifest fetch
Registry-->>Relay: Manifest JSON
Relay-->>Browser: CORS-enabled Manifest
Browser->>Relay: HTTP GET /blobs/digest (Streamed)
Relay->>Registry: Proxy blob fetch
Registry-->>Relay: Layer gzip/zstd chunks
Relay-->>Browser: CORS-enabled streams
Note over Browser: tacklebox WASM decodes tar headers, unpacks overlay, writes EROFS/FAT filesystem
Browser->>User: Stream ISO chunks via File System Access API
- Stateless CORS Relay (
worker/): Docker registries (for example, GHCR) do not emit browserAccess-Control-Allow-Originheaders. The Cloudflare Worker shims the CORS preflight requests. It also keeps an edge cache (cf: { cacheEverything: true }) of immutable blob digests to absorb repeat downloads. - Back-to-Front Tar Scan: The engine decodes the layer tars in reverse order (topmost first). It finds the kernel and initramfs in seconds. Then it stops the connection streams early, and does not pull gigabytes of unnecessary data.
- Stream to Disk: The final ISO is multi-gigabyte. A browser tab can crash with OOM if it holds the full ISO in memory. So the engine streams the chunks to disk through
showSaveFilePicker(). Browsers without this support (Safari, Firefox) keep the ISO in memory instead, and show a warning.
| Path | Description |
|---|---|
app/public/ |
Static web application assets. tbox.wasm is tacklebox compiled for GOOS=js GOARCH=wasm. |
app/wrangler.jsonc |
Cloudflare Workers static-assets deployment configuration. Deployed to iso.tunaos.org. |
worker/ |
cors-shim.js — Cloudflare Worker shim (relay.tunaos.org) proxying GHCR + Flathub/package search APIs. |
e2e/ |
Playwright test suite driving the real WASM engine against live container registries. |
native/ |
Cross-platform desktop writer for creating and managing persistent multi-boot drives. |
docs/ |
Design and lifecycle documentation for multi-boot drive management. |
CONTRIBUTING.md |
Developer guide, test suite instructions, and contribution process. |
# Build the WASM engine first (not committed to git — see "Updating the WASM Engine")
GOOS=js GOARCH=wasm go build -o app/public/tbox.wasm ./cmd/tbwasm # from the pinned tacklebox checkout
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" app/public/
# Serve the app locally
cd app/public && python3 -m http.server 8080 # → http://localhost:8080
# E2E Setup & Execution
cd e2e && npm ci && npx playwright install --with-deps chromium
npx playwright test --grep-invert @full # Runs UI & inspect network flowCI's opt-in strict_roundtrip_test route needs run_full=true,
retain_iso_artifact=true, keep_iso=false, retain_candidate=false,
benchmark=false, pair=false and exactly one only= cell. Its
strict_install_mode choice applies only to this route:
| Strict install mode | diagnostic_unqualified |
only= |
Evidence scope |
|---|---|---|---|
plain (default) |
false |
skipjack-gnome |
Observed plain install; not-assessed, not full qualification |
luks |
true |
skipjack-gnome |
Diagnostic LUKS install; diagnostic-unqualified, never qualification |
both |
false |
one qualify: true linux/amd64 cell |
Plain then LUKS on the same ISO in one harness run; on main, an attested qualification receipt |
For example, dispatch an observed plain run with:
gh workflow run ci.yml \
-f run_full=true -f only=skipjack-gnome -f retain_iso_artifact=true \
-f strict_roundtrip_test=true -f strict_install_mode=plain \
-f diagnostic_unqualified=false -f keep_iso=false \
-f retain_candidate=false -f benchmark=false -f pair=falseFor diagnostic LUKS, use strict_install_mode=luks and
diagnostic_unqualified=true together. The catalog/source admission guards
still apply; a non-diagnostic run does not bypass them.
All three modes retain the browser-built ISO and discard the local original.
They download the chunks again, reconstruct the ISO, and verify its hash against
the browser measurement before the guest starts. The helper checks that the
receipt agrees with the source, browser, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT,
and artifact identities for the strict invocation. Only LUKS with diagnostics
sets TUNAOS_INSTALLED_BOOT_DIAGNOSTIC=1. Neither single-mode result is full
qualification evidence.
both runs the strict harness once with --strict-offline-gui=plain,luks
against the round-tripped ISO. When that job passes on refs/heads/main,
scripts/build-qualification-receipt.py writes a
BrowserIsoQualificationReceipt/v1. Its inputs are the harness's
strict-qual-plain.json and strict-qual-luks.json (copied verbatim), the
same-byte binding, the harness revision pinned in ci.yml and the run's
GITHUB_* identity. The receipt is canonical JSON
(sort_keys, (",", ":") separators, no trailing newline), so
sha256(receipt.json) is the receipt digest.
The builder writes a receipt only when these conditions are true:
- Both legs installed and did a cold boot and a warm boot.
- Each warm boot has a kernel boot ID that is different from the cold boot.
- Each leg created the account in GNOME Initial Setup and logged in through GDM.
- The plain leg shows no passphrase prompt and the LUKS leg shows one.
- The booted digest equals the source digest and the image digest.
- The imgref is the target's published image, its catalog
publication.ref. That isghcr.io/tuna-os/<variant>:<flavor>unless the variant sets a publish name or tag suffix (flounder-sid:gnomeisflounder:gnome-sid). - The run is not diagnostic, ran both modes, and ran on
main.
A branch dispatch still runs both legs but writes no receipt.
The job attests receipt.json with actions/attest-build-provenance, which
is why full-matrix alone holds id-token: write and attestations: write.
It then checks the bundle the way a consumer will and uploads receipt.json
and bundle.jsonl as qualification-receipt-<slug>-run<id>-a<attempt>.
To authenticate a downloaded receipt:
gh attestation verify receipt.json --repo tuna-os/iso-builder \
--signer-workflow tuna-os/iso-builder/.github/workflows/ci.yml \
--source-ref refs/heads/main --bundle bundle.jsonlThe wide header uses /wordmark.svg, copied without modification from
system_files/usr/share/tunaos/lettermarks/tunaos.svg at tunaOS
95d55c725755b12e54b0c1a3d4f7e3c2a00ba62c
(PR #4168). The favicon and notifications keep the fish from Noto Emoji;
the old builder mascot is not a wordmark.
Anime wallpapers remain tunaOS assets, not marks for the builder.
Import the config, flavor metadata and wordmark from that same immutable primary checkout; do not hand-edit generated config or create a second catalog:
node scripts/sync-builder-config.cjs /path/to/tunaOS/docs/iso-builder.config.json \
--source-commit 95d55c725755b12e54b0c1a3d4f7e3c2a00ba62c \
--flavors /path/to/tunaOS/docs/iso-builder.flavors.json \
--wordmark /path/to/tunaOS/system_files/usr/share/tunaos/lettermarks/tunaos.svg
node scripts/generate-builder-config.cjs
node scripts/generate-guided-catalog.cjs
node scripts/generate-experiences.cjsThe source config must declare brand.logo_url: "/wordmark.svg" and
brand.favicon_url: "/favicon-32.png". The sync script copies the bytes
from the primary export without changes and records their commit and digest.
The generators use the existing catalog snapshot and the manifest for
deployment experiences.
After regeneration, serve app/public with the command above and open
http://localhost:8080/ at desktop and 320px viewport widths. Confirm the whole
wordmark is visible, navigation wraps, and the tab shows the emoji mark.
Select each experience and confirm it retains the project's marks.
This prepares source only: do not deploy until maintainers republish the overlay and put the agreed base binding in place.
Important
Playwright tests run in a persistent browser context in ~/tmp/, not in /tmp. Some Linux systems limit /tmp to a small tmpfs RAM disk. There, Chrome can run out of storage space when it downloads the layers of a real image.
The desktop writer has separate platform prerequisites and uses the Go toolchain.
See native/README.md for build and test commands, and
docs/MULTI-BOOT-DRIVE-MANAGEMENT.md
for the drive-management lifecycle and safety model.
cd app && npx wrangler deploy # Deploys to Pages (iso.tunaos.org)
cd worker && npx wrangler deploy # Deploys to Workers (relay.tunaos.org)Note: You need CLOUDFLARE_API_TOKEN in your environment, with the Workers and Pages deployment scope.
Normally this is automatic: the deploy job in .github/workflows/ci.yml deploys both surfaces on every push to main. Its final step checks the app response, compares the served engine digest with the tested artifact, and checks relay /healthz. A failed check stops the run; rollback remains manual. For checks after a manual deploy and rollback steps, see runbooks/deploy-and-rollback.md.
Update the tacklebox module pin in native/go.mod and its checksums in
native/go.sum. CI resolves that pin to a full commit, builds the WASM,
tests it, and deploys the same artifact. A source fix in tacklebox alone
does not change the engine served by ISO Builder.
For example, browser install run
41270163456
reached the installer but failed with bound blob size mismatch: the
index in /OCI/index.json omitted the retained manifest's size. The engine repair
in tacklebox#437 supplies
that exact byte count and preserves the manifest bytes and installer
validation. The new pin needs a fresh browser-built ISO; retained
media from the old engine still contains the defect. Build and source checks
do not prove an offline installation through the GUI or detached cold and warm login.
app/public/tbox.wasm is built from the tacklebox repository:
GOOS=js GOARCH=wasm go build -o tbox.wasm ./cmd/tbwasmWhen you update the WASM file, always copy the matching wasm_exec.js from your Go installation:
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" app/public/app/public/systemd-bootx64.efi is a committed copy of the systemd-boot EFI
stub. The app fetches it at runtime for the DDI build path. We pin it to
one exact version of the Ubuntu package, with a checksum. For the provenance
and the pinned version, see
app/public/sdboot-NOTICE.txt. That file also
has the command to verify or refresh the stub.