Handbook · Chapter 10 of 12 · 16 min read
Getting the image onto metal: installers and ISOs
A bootc image is an OCI artifact. Registries deliver upgrades; they do not deliver the first install. Something has to partition a disk, lay down an ostree deployment from the container, and wire the bootloader. Margine's ISO history runs through two pipelines:
- Path A — bootc-image-builder (BIB)
anaconda-iso(retired, ADR-0008 Phase 5/7): the image is embedded in the ISO; Anaconda installs it offline; a kickstart%poststack repoints the origin, tunes the filesystem, stages MOK enrollment, and rsyncs baked Flatpaks. This was the published ISO until June 2026; it is documented below as history. BIB itself is still used — but only to emit theqcow2the QEMU smoke gate boots (chapter 9), never an ISO. - Path B — Titanoboa live ISO (ADR-0008): the official and only published ISO. A real live GNOME session whose squashfs is a
margine-liveOCI layer, with Anaconda WebUI installingmargine:stablefrom the registry.
Both satisfy the same install-time invariants: registry origin = ghcr.io/.../margine:stable, btrfs + compress=zstd:1, two-tier MOK enrollment, ~38 BAKE Flatpaks present at first login.
10.1 Path A — bootc-image-builder Anaconda ISO (retired / historical)
Status: the Anaconda-ISO path was retired per ADR-0008 (Phase 5 made Titanoboa the default, Phase 7 removed BIB's ISO matrix). It no longer produces a published artifact; BIB now emits only the
qcow2used by the smoke gate. This section is kept as a record of how it used to work — the kickstart logic it pioneered was ported nearly verbatim into the Titanoboa path (§10.2).
BIB consumes a bootc image plus a TOML config and emits qcow2, raw, vmdk, or anaconda-iso. The qcow2 path (the one still in use — it feeds the QEMU smoke gate, chapter 9) needs almost nothing:
[[customizations.filesystem]]
mountpoint = "/"
minsize = "20 GiB"
margine-image/disk_config/disk.toml (entire file). The qcow2 exists to boot in CI; 4 lines suffice.
The ISO config is 304 lines, nearly all of it an embedded kickstart under [customizations.installer.kickstart] contents = """...""".
The installer-image trick (BAKE Flatpaks at OCI build time)
BIB's ISO packs the input image's rootfs as the installer environment. Margine exploits that: instead of feeding margine:stable to BIB directly, CI first builds a transient margine-installer image that is margine:stable + ~29 Flatpaks pre-installed into /var/lib/flatpak:
ARG BASE_IMAGE=ghcr.io/daniel-g-carrasco/margine:stable
ARG FLATPAK_LIST_FILE=flatpaks-base
FROM ${BASE_IMAGE}
ARG FLATPAK_LIST_FILE
RUN --mount=type=bind,source=.,target=/src,rw \
FLATPAK_LIST_FILE="${FLATPAK_LIST_FILE}" /src/build.sh
RUN bootc container lint
margine-image/installer/Containerfile. This image is never published as a :stable tag — it exists only as margine-installer:run-<run_id> to be BIB's input. The bind mount keeps the list/script out of the final layers.
installer/build.sh needs two odd lines before flatpak install works inside podman build:
mkdir -p "$(realpath /root)"
mount -o remount,rw /proc/sys
margine-image/installer/build.sh:29-30. flatpak's apply_extra (Reaper, Steam, openh264 binary blobs) runs under bwrap, which needs a real /root and writable /proc/sys/user/max_user_namespaces. The build itself must run with --cap-add sys_admin --security-opt label=disable (see the CI snippet below).
A subtle parsing bug lives here too: the list file allows inline comments, and an un-stripped com.github.tchx84.Flatseal # Flatpak permissions GUI passes # as a literal Flatpak ID — flatpak install fails with Invalid id #: Name can't start with # (build #27075455521). The fix is a sed strip of trailing comments before word-splitting (installer/build.sh:52-54).
Kickstart: %pre disk autodetect + partitioning
%include /tmp/part-include.ks
zerombr
clearpart --all --initlabel --disklabel=gpt
part /boot/efi --fstype=efi --size=4096 --label=ESP
part / --fstype=btrfs --grow --label=margine_root
bootloader --timeout=1
Historical: this lived in the deleted disk_config/iso-gnome.toml; the %pre autodetect survives in live-env/src/anaconda/interactive-defaults.ks (the WebUI path drops the explicit clearpart/part). A %pre script enumerates /sys/block/*, filters out loop*|ram*|zram*|sr*|fd*|md*|dm-*, read-only, and removable devices, and — if exactly one candidate remains — writes ignoredisk --only-use=<dev> into /tmp/part-include.ks. Single-disk machines install without a disk-selection click; multi-disk machines fall back to explicit Anaconda selection. The 4 GiB ESP is deliberate headroom for future UKI/sealed-boot work (ADR-0007).
%post stack — the four jobs
1. Repoint the origin (--erroronfail). BIB installs from the embedded image snapshot; without this, bootc upgrade would forever poll a URI that never updates:
%post --erroronfail
bootc switch --mutate-in-place --transport registry ghcr.io/daniel-g-carrasco/margine:stable
%end
Historical (deleted iso-gnome.toml); now live-env/src/anaconda/post-scripts/bootc-switch.ks. --mutate-in-place edits the just-installed deployment's origin file instead of staging a new deployment. This is the only %post allowed to fail the install — a wrong upgrade origin is a real defect.
2. Stage MOK enrollment before the first reboot (--nochroot). Margine ships a CachyOS kernel signed with its own MOK (chapter 5); mokutil --import writes a pending request into EFI variables so shim opens MokManager on the very first post-install reboot:
log "Setting MokManager timeout to direct entry"
mokutil --timeout -1 || log "WARN: failed to set MokTimeout; continuing"
log "Importing Margine MOK request"
if printf '%s\n%s\n' 'margine-os' 'margine-os' | mokutil --import "$MOK_CERT"; then
log "MOK import request submitted — shim should launch MokManager on the next boot"
else
log "WARN: mokutil import failed — first-boot mok-enroll.service remains fallback"
fi
Historical (deleted iso-gnome.toml); now live-env/src/anaconda/post-scripts/secureboot-enroll-key.ks. --timeout -1 disables shim's 10 s auto-continue. The cert is located inside the target deployment (/mnt/sysimage/ostree/deploy/default/deploy/*.0/usr/share/cert/MOK.der). Every exit path is soft: mok-enroll.service in the image re-stages the request at first boot if the user misses MokManager (two-tier enrollment, PR #88).
3. zstd compression. Anaconda's btrfs default has no compression. Two layers because they cover different windows: btrfs property set / compression zstd affects all new writes immediately; a python3 inline patch appends compress=zstd:1 to the / btrfs line in /etc/fstab for durability (python3 instead of sed — backslash escaping inside TOML triple-quoted strings is misery). Already-installed /usr content is not recompressed; the win is /var and /home growth (lines 139-218, not --erroronfail: QoL, not install-critical).
4. Flatpak rsync (--nochroot). ostree+bootc reset /var per deployment, so the installer's pre-baked /var/lib/flatpak must be copied into the target deployment:
DEPLOY_DIR=$(ls -d /mnt/sysimage/ostree/deploy/default/deploy/*.0 2>/dev/null | head -1)
...
rsync -aAXUHK --open-noatime /var/lib/flatpak "$DEPLOY_DIR/var/lib/"
Historical (deleted iso-gnome.toml); now live-env/src/anaconda/post-scripts/install-flatpaks.ks. Belt-and-suspenders: every BAKE app is also listed in /usr/share/flatpak/preinstall.d/margine-defaults.preinstall, so a silent rsync failure degrades to a first-boot download via flatpak-preinstall.service, not missing apps.
Lesson — install-time
flatpak installsilently OOMs. Symptom: the 2026-06-04 fresh install completed "successfully" but first boot had no Flatpaks. Root cause: the earlier%post --nochrootdidflatpak install --systemof the ~5 GB BAKE set at install time, inside the installer environment's small tmpfs/tmp— it died quietly (--noninteractivereturns 0 on partial failure). Fix: the Bazzite installer-image pattern (2026-06-05): downloads move to OCI build time on a CI runner with real disk; install time is reduced to an rsync. The post-mortem was documented inline in the BIB kickstart (since deleted); the live-ISO path keeps the same build-time-bake / install-time-rsync split inlive-env.
Trimming Anaconda modules
[customizations.installer.modules]
enable = [
"org.fedoraproject.Anaconda.Modules.Storage",
"org.fedoraproject.Anaconda.Modules.Runtime",
"org.fedoraproject.Anaconda.Modules.Network"
]
disable = [
"org.fedoraproject.Anaconda.Modules.Security",
"org.fedoraproject.Anaconda.Modules.Services",
"org.fedoraproject.Anaconda.Modules.Users",
"org.fedoraproject.Anaconda.Modules.Subscription",
"org.fedoraproject.Anaconda.Modules.Timezone"
]
Historical (deleted iso-gnome.toml): a BIB-only [customizations.installer.modules] block with no direct Titanoboa equivalent — the live ISO trims spokes through Anaconda's profile instead (§10.2). Users/timezone/services come from Margine's own first-login bootstrap (ujust margine-bootstrap), so their installer spokes are dead weight. Network stays enabled: on a laptop the user needs the Wi-Fi picker (wired DHCP auto-configures without it).
CI invocation (historical)
This was the matrix-conditional BIB invocation while the anaconda-iso entry still existed; today the build_disk job only runs the qcow2 branch, so it passes ./disk_config/disk.toml unconditionally:
- name: Build disk image
uses: osbuild/bootc-image-builder-action@019bb59c5100ecec4e78c9e94e18a840110f7a0b # v0.0.2
with:
builder-image: ${{ env.BIB_IMAGE }}
# was: matrix.disk-type == 'anaconda-iso' && './disk_config/iso-gnome.toml' || ...
config-file: './disk_config/disk.toml'
image: ${{ format('{0}/{1}:{2}', env.IMAGE_REGISTRY, env.IMAGE_NAME, env.DEFAULT_TAG) }}
rootfs: btrfs
types: ${{ matrix.disk-type }}
margine-image/.github/workflows/build-disk.yml (build_disk job). One trap still encoded here: rootfs: btrfs is mandatory because Bluefin DX doesn't set the containers.bootc.rootfs OCI label — without it BIB dies with DefaultRootFs missing. (In the retired ISO branch, the build also consumed the installer tag, not :stable.)
Why Path A was replaced
The iso-gnome.toml kickstart hit BIB's architectural ceiling: 300+ lines of kickstart inside a TOML string, BIB upstream in maintenance mode (Universal Blue retired it in March 2025, ublue-os/main#468), no live "try before install" session, the Anaconda GTK spoke not pre-selecting single disks, and a MokManager that never appeared on a Framework 13 where Bluefin's ISO showed it. Hence ADR-0008 — and iso-gnome.toml was subsequently deleted; its four kickstart jobs now live as the .ks fragments described in §10.2.
10.2 Path B — Titanoboa live ISO (the published ISO)
Titanoboa (ublue-os/titanoboa) is a ~150-line bash ISO assembler implementing the Container-native ISO contract v0.1.0 (ondrejbudai/bootc-isos). It does almost nothing: mksquashfs /rootfs → /LiveOS/squashfs.img, copy /rootfs/usr/lib/modules/*/{vmlinuz,initramfs.img} to /images/pxeboot/, copy the EFI tree, generate grub.cfg from /usr/lib/bootc-image-builder/iso.yaml (hard-required — exits 1 if absent), build a FAT32 uefi.img, xorriso -as mkisofs. All customization must already be inside the input image.
The post-#138 contract: two inputs, one output
Since PR #138 (2026-05-19, "Only use container images as the only source of truth") the action has exactly image-ref (required) and iso-dest (optional) as inputs, and iso-dest as output. The previous 12-input API (flatpaks-list, hook-post-rootfs, kargs, ...) was silently dropped — consumers passing the old inputs get a ##[warning]Unexpected input(s) and a broken ISO. Bluefin's CI ran red for 3+ weeks because of this. Margine pins by SHA, Renovate-disabled:
- name: Build Live ISO (Titanoboa)
id: titanoboa
# Pinned to env.TITANOBOA_REF (a personal margine-pins fork).
uses: daniel-g-carrasco/titanoboa@cce73fc476e97fed626283afb6c518e0882a12d7
with:
image-ref: ${{ steps.live.outputs.live_tag }}
iso-dest: ${{ github.workspace }}/margine-live.iso
margine-image/.github/workflows/build-disk.yml (build_iso_titanoboa job). The pin is a personal fork, daniel-g-carrasco/titanoboa (branch margine-pins, SHA-pinned via the TITANOBOA_REF env — the snippet above shows the current SHA): upstream's post-#138 HEAD plus the margine patch set — upstream PR #147's mksquashfs -e ordering fix (the gzip-fallback Lesson below), the raw extra_cfg grub fragment (proposed upstream as #148), and a grub.cfg directory-glob fix so plain files at the ESP root (EFI/MOK.der) don't break the build. image-ref is a transient margine-live:ci-run-<run_id> tag pushed just before — Titanoboa issue #141 (open) hardcodes podman pull of the ref, so a local-only tag is not enough. Pin bumps require an explicit follow-up ADR.
iso.yaml — label, kargs, and the initrd rename
label: "Margine-Live"
grub2:
default: 0
timeout: 5
entries:
- name: "Install Margine"
linux: "/images/pxeboot/vmlinuz quiet rhgb root=live:CDLABEL=Margine-Live enforcing=0 rd.live.image"
initrd: "/images/pxeboot/initrd.img"
margine-image/live-env/src/iso.yaml:19-26. Three load-bearing details: (1) rd.live.image + root=live:CDLABEL=<label> are mandatory or dmsquash-live cannot find /LiveOS/squashfs.img and the boot panics; CDLABEL must match label exactly. (2) The initrd path is initrd.img, not initramfs.img — Titanoboa renames /usr/lib/modules/*/initramfs.img to /images/pxeboot/initrd.img on copy. (3) enforcing=0 is live-session-only convenience; the installed system is enforcing.
live-env: Containerfile + build.sh
ARG BASE_IMAGE=ghcr.io/daniel-g-carrasco/margine:stable
FROM ${BASE_IMAGE}
RUN --mount=type=bind,source=src,target=/src,rw \
/src/build.sh
margine-image/live-env/Containerfile (trimmed). Built with --cap-add sys_admin --security-opt label=disable (dracut + flatpak/bwrap). The squashfs of the produced ISO is this image's rootfs — try-before-install is literally the distro.
build.sh runs in three phases (one git commit per phase, mapping ADR-0008 §6):
Phase 1 — bootable. First, the single-kernel invariant: Titanoboa copies /usr/lib/modules/*/... with "behaviour unspecified" for multiple kernels, and a dnf install later in the script could pull a second one. So assert_single_kernel runs at the start and at the very end. Margine deliberately keeps the CachyOS kernel in the live env (no Bazzite-style vanilla-kernel swap) — the accepted cost is that live boot under Secure Boot needs SB disabled until the MOK is enrolled.
Then dracut-live:
dnf install -y --setopt=install_weak_deps=False \
dracut-live livesys-scripts grub2-efi-x64-cdboot
DRACUT_NO_XATTR=1 dracut -v --force --zstd --reproducible --no-hostonly \
--add "dmsquash-live dmsquash-live-autooverlay" \
"/usr/lib/modules/${KERNEL}/initramfs.img" "${KERNEL}"
margine-image/live-env/src/build.sh:72-82. --no-hostonly is mandatory: Fedora defaults to hostonly=yes, which strips dmsquash-live, and the live ISO kernel-panics looking for a real root. dmsquash-live-autooverlay gives the session a writable overlay.
livesys session + EFI assembly:
echo "livesys_session=gnome" > /etc/sysconfig/livesys # (sed if file exists)
systemctl enable livesys.service livesys-late.service
mkdir -p /boot/efi
cp -av /usr/lib/efi/*/*/EFI /boot/efi/
test -d /boot/efi/EFI/fedora || { echo "ERROR: EFI tree not assembled..." >&2; exit 1; }
cp -v /boot/efi/EFI/fedora/grubx64.efi /boot/efi/EFI/BOOT/fbx64.efi
margine-image/live-env/src/build.sh:87-112 (condensed). bootc images keep EFI binaries under /usr/lib/efi/; Titanoboa looks under /boot/efi/EFI — the copy bridges the two layouts. The glob guard fails the build loudly instead of producing a cryptic failure deep in build_iso.sh. fbx64.efi is the removable-media fallback the firmware loads when no NVRAM boot entry exists (the USB-stick case).
/var/tmp sizing: in a booted live ISO, / is an overlayfs whose upperdir sits on a small tmpfs under /run. Anaconda's ostree install needs gigabytes of scratch in /var/tmp, so build.sh installs a var-tmp.mount unit with Options=size=50%%,nr_inodes=1m (%% because % is a systemd specifier) mounting a half-of-RAM tmpfs there (lines 118-133). Finally iso.yaml is copied to /usr/lib/bootc-image-builder/iso.yaml — the path Titanoboa hard-requires.
Lesson — the BIOS
[ -f ]guard: the "hybrid" ISO that isn't. Symptom: build logs claimed "BIOS hybrid boot: /usr/lib/grub/i386-pc present", implying a BIOS-bootable ISO. It is UEFI-only. Root cause: Titanoboabuild_iso.sh:32tests the i386-pc directory with[ -f ]— always false, so the GRUB BIOS modules are never copied — and its xorriso call has no El Torito BIOS image (-b) anyway. Found in the 2026-06-09 full build-log scan. Fix: log the truth instead of a comforting lie (BIOS stays non-gating per ADR-0008 §4 — all reference hardware is UEFI):if [[ -d /usr/lib/grub/i386-pc ]]; then echo "NOTE: /usr/lib/grub/i386-pc present, but current Titanoboa produces a UEFI-only ISO (no BIOS El Torito; upstream build_iso.sh:32 -f-vs-directory bug)"
margine-image/live-env/src/build.sh:61-65.
Lesson — mksquashfs silently falls back to gzip. Symptom: the squashfs was larger and faster-built than zstd-19 should produce; the requested compression never applied. Root cause: in Titanoboa,
-comp zstd -Xcompression-level 19is placed after-eon the mksquashfs command line — mksquashfs swallows everything after-eas exclude names and silently falls back to its gzip default. Same 2026-06-09 build-log scan; this class of bug is invisible unless you read the tool's own banner output. Fix: upstream PRublue-os/titanoboa#147reorders the flags; Margine carries it directly by pinningTITANOBOA_REFat the personal forkdaniel-g-carrasco/titanoboa, whose patch set carries exactly that fix — so the shipped ISO is zstd-compressed, not gzip.
Phase 2 — BAKE Flatpaks. Same bwrap prep and comment-stripping as installer/build.sh, then flatpak install --system --noninteractive --or-update flathub $APPS from live-env/src/flatpaks (lines 145-171). Then the live session is defended against the user:
cat >/etc/systemd/system/var-lib-flatpak.mount <<'EOF'
[Mount]
What=/var/lib/flatpak
Where=/var/lib/flatpak
Type=none
Options=bind,ro
EOF
systemctl enable var-lib-flatpak.mount
margine-image/live-env/src/build.sh:175-188 (trimmed). A read-only bind of /var/lib/flatpak over itself: the user can poke around the live desktop but cannot taint the baked set before it is rsync'd into the install target.
Phase 3 — Anaconda WebUI. dnf install firefox anaconda-live anaconda-webui libblockdev-{btrfs,lvm,dm}, mkdir /var/lib/rpm-state (WebUI requires it), install the profile, copy post-scripts/*.ks, and append (not replace) Margine's fragment to the interactive-defaults.ks that anaconda-live ships — the base carries liveinst integration that must be preserved. Then a defensive loop disables units that are meaningless or harmful in a throwaway live session (uupd.timer, flatpak-preinstall.service, brew-*, tailscaled, bazaar.service, ...), checking each unit exists first so a renamed unit never fails the build (lines 227-252).
profile.d detection and storage defaults
[Profile]
profile_id = margine
[Profile Detection]
os_id = fedora
variant_id = margine
[Storage]
default_scheme = BTRFS
btrfs_compression = zstd:1
default_partitioning =
/ (min 1 GiB)
/home (min 500 MiB, free 50 GiB)
/var (btrfs)
[User Interface]
webui_web_engine = slitherer
margine-image/live-env/src/anaconda/profile.d/margine.conf (trimmed). Margine keeps ID=fedora in os-release (Bluefin inheritance), so os_id alone would collide with stock Fedora — variant_id=margine must also match, and both must hold for the profile to activate. slitherer is the WebUI engine Bluefin/Bazzite ship in production; falling back to GTK Anaconda is a one-line change (webui_web_engine = none).
Lesson — explicit
partcrashes Anaconda WebUI 68. Symptom: WebUI dies at startup with "Reading information about the computer failed" (DBusInvalidArgs). Root cause: any kickstartpart/clearpartdirective makes Anaconda select the CUSTOM partitioning method, which never publishes theStorage.Partitioning.AutomaticDBus interface — and anaconda-webui 44-68 (Fedora 44 ships 68) queries that interface unconditionally. Fixed upstream in anaconda-webui 69 (commit135c87881, 2026-03-18), not yet in F44. Fix (PR #92): drop explicit partitioning entirely; let the profile's[Storage] default_partitioningdrive the AUTOMATIC flow (exactly like Aurora/Bazzite). The%preautodetect is kept — it only emitsignoredisk --only-use=<dev>, which does not select CUSTOM, so it is WebUI-safe. Cost: the ESP stays at Anaconda's hardcoded ~600 MiB (no profile key can enlarge it on the AUTOMATIC path); the 4 GiB ESP from Path A is deferred until F44 ships anaconda-webui ≥ 69. ~600 MiB still holds several UKIs. Documented in theinteractive-defaults.ksheader (lines 6-22).
interactive-defaults.ks: ostreecontainer + %include chain
%include /tmp/part-include.ks
ostreecontainer --url=ghcr.io/daniel-g-carrasco/margine:stable --transport=registry --no-signature-verification
%include /usr/share/anaconda/post-scripts/bootc-switch.ks
%include /usr/share/anaconda/post-scripts/zstd-compress.ks
%include /usr/share/anaconda/post-scripts/secureboot-enroll-key.ks
%include /usr/share/anaconda/post-scripts/install-flatpaks.ks
margine-image/live-env/src/anaconda/interactive-defaults.ks:64-84 (comments trimmed). ostreecontainer --transport=registry pulls margine:stable from GHCR at install time — chosen over pre-pulling into the ISO's containers-storage (--transport=containers-storage), which would add ~5-6 GB and break the 10 GB ISO budget. Consequence: installs now need network (the retired Path A was offline-capable) — the standing #1 revisit item since cutover. The 300-line TOML monolith of the old Path A became four self-documenting .ks fragments — same four jobs, ported nearly verbatim. Order matters: switch origin, tune fs, stage MOK, bake apps.
One deliberate delta in the Flatpak rsync: Path B uses Bluefin's production incantation rsync -aAXUHKP --filter='-x security.selinux' (post-scripts/install-flatpaks.ks:39) — preserve POSIX xattrs/ACLs, strip SELinux labels and let ostree-finalize relabel. Wrong labels mean baked Flatpaks fail to launch with AVC denials.
CI wiring for Path B
The Titanoboa job (build_iso_titanoboa in build-disk.yml) is the ISO build since the Phase 5 cutover (2026-06-11); the old BIB anaconda-iso matrix entry is gone. Notable plumbing: ubuntu-24.04 runners have ~14 GB free on /, but the zstd squashfs of a ~14 GB rootfs + the base image in storage + the ISO need far more — so rootful podman storage is remounted onto an 80 GB btrfs loopback on the ephemeral /mnt SSD with compress-force=zstd:2 (lines 371-384, mirroring Bazzite). After the build, a prune step keeps only the newest 3 margine-live GHCR tags. Test ISOs are pushed to the Internet Archive's auto-expiring test_collection (publish-titanoboa-test-iso.yml) because GHA artifact egress to residential connections crawls at ~1-1.5 MB/s — 2-4 h for an 8 GB ISO.
10.3 Escape hatch: plain bootc install to-disk
No custom ISO required: boot any stock Fedora live USB, then
sudo podman run --rm --privileged --pid=host \
-v /dev:/dev -v /var/lib/containers:/var/lib/containers \
ghcr.io/daniel-g-carrasco/margine:stable \
bootc install to-disk --wipe /dev/nvme0n1
The image installs itself — bootc ships the installer logic in every image. You lose everything the kickstarts do (MOK staging, zstd fstab patch, Flatpak bake, guided partitioning), so first boot is slower and Secure Boot needs manual mokutil. Useful for headless boxes, VMs, and recovery; not the documented end-user path.
10.4 Alternatives & other distros
- Titanoboa — Universal Blue's direction. Bazzite: only production-grade post-#138 consumer, but pins
Zeglius/titanoboa@revamp-pr(the #138 author's fork). Bluefin (projectbluefin/iso): pins@mainwhile still passing pre-#138 inputs — red since 2026-05-19; reference for content, not workflow. Aurora (get-aurora-dev/iso): green, but pinned pre-#138 (840217d9, 2026-01-04) — old 12-input API. Margine: a personal post-#138 fork (daniel-g-carrasco/titanoboa) pinned by SHA, upstream HEAD plus PR #147 — Margine's official ISO path. Trade-off across all: minimal assembler, everything lives in your image; you inherit its bugs until your next pin bump. - bootc-image-builder
anaconda-iso— Fedora/CentOS bootc's documented path; Margine's former published ISO (retired per ADR-0008 — maintenance-mode upstream, no live session, kickstart-in-TOML scales badly). BIB is still used to emit the smoke-gate qcow2, just not an ISO. - lorax / livemedia-creator — how Fedora builds official Silverblue/Kinoite ISOs (Anaconda + ostree remote); full Fedora release engineering machinery, heavyweight to self-host.
- Anaconda
bootckickstart verb (Fedora, Dec 2025) — the long-term BIB successor; too new for Margine v1, new partitioning model to learn. - kiwi — openSUSE MicroOS/Aeon image/ISO builder; mature multi-format, XML descriptions, not container-native (their atomic model is btrfs-snapshot, not OCI).
- mkosi — systemd's image builder (ParticleOS); first-class UKI/sealed-boot, builds from package lists rather than consuming an OCI image.
- Readymade (FyraLabs; Ultramarine, tauOS) — bootc support merged 2025-04; Bluefin evaluating (titanoboa#66); not production-ready as of 2026-06. Candidate ADR-0010 for Margine post-Phase 7.
- Calamares — distro-independent GUI installer (Manjaro, KDE neon); no bootc/ostree integration, rejected for phase 1.
- Agama — openSUSE's web-based installer; SUSE-ecosystem-shaped.
- Vanilla OS — own first-setup installer over ABRoot A/B partitions; bespoke, not reusable.
- NixOS —
nixos-installfrom any live medium + a flake; declarative but a different universe (no OCI delivery). - ChimeraOS —
frzrdeploys read-only btrfs images from GitHub releases; simplest possible "installer", no Anaconda at all. bootc install to-diskfrom a stock Fedora USB — zero pipeline cost, zero polish (§10.3).
The pattern worth stealing regardless of tool: keep install-time logic in small, individually testable kickstart fragments shipped inside the image (/usr/share/anaconda/post-scripts/*.ks), make exactly one of them fatal (bootc switch --erroronfail), and let everything else degrade to a first-boot fallback.