Skip to main content

max / alloy

iso: give the medium a FAT16 ESP and check the boot structures mkfs.fat sized the ESP to its contents and picked FAT12. UEFI permits that on removable media and no shipping distro relies on it; firmware that declines FAT12 produces no boot option while the GPT still inspects perfectly. Force FAT16 and floor the image at 16 MiB, which is what the 4085-cluster minimum needs. The ESP was populated by three code paths: mmd/mcopy, a loop-mount fallback, and a second mcopy under `|| true`. One of the three, mmd succeeding and mcopy failing, produced an ESP holding two empty directories and reported success. mtools was never in the builder, so the loop-mount branch is what actually ran and the silent branch was live but never taken. Install mtools, keep that one path, and read BOOTX64.EFI back out of the filesystem rather than trusting an exit code. The partition-table checks passed on an image whose El Torito entry pointed somewhere other than the ESP, because they never compared the two. Compare them, and assert the extent lands inside the image. That comparison needs offsets into the GPT entry array, which was being read through `od` without -v. od replaces runs of identical lines with a single `*`, so the hex string was shorter than the bytes it described and every offset past the first repeat was wrong. A GPT entry array is mostly zero padding, which is exactly the input that triggers it. Presence checks survived this; arithmetic did not. Also: pick the kernel with sort -V rather than `ls | head -1`, which collates 7.1.10 before 7.1.3, and budget ext4 inodes from the file count instead of the default ratio. output.prev is excluded from the build context. It was added to build-iso.sh and never mirrored here, which quietly put a second multi-gigabyte ISO into every build.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 21:12 UTC
Signed with PGP, not checked
Commit: 8f0baafd0d5587c7e9904788679da1e651efde9f
Parent: a558726
3 files changed, +92 insertions, -18 deletions
@@ -15,7 +15,14 @@
15 15 /target
16 16
17 17 # bootc-image-builder output: the ISO, raw and qcow2 images, manifests.
18 + #
19 + # output.prev is the rotated copy build-iso.sh and build-image.sh leave behind
20 + # so a failed run cannot take the last good artifact with it. It was added to
21 + # a different file and never mirrored here, which quietly put a second
22 + # multi-gigabyte ISO back into every build context. That is the exact cost
23 + # the header above says this file exists to prevent. Both are currently 6.9G.
18 24 /output
25 + /output.prev
19 26 /dist
20 27
21 28 # Not read by any COPY.
@@ -23,6 +23,7 @@
23 23 grub2-tools \
24 24 grub2-tools-extra \
25 25 dosfstools \
26 + mtools \
26 27 rsync \
27 28 && dnf clean all
28 29
M build/make-iso.sh +84 -18
@@ -27,8 +27,14 @@
27 27 [ -d "$ROOTFS" ] || { echo "no $ROOTFS" >&2; exit 1; }
28 28 [ -d "$OUTPUT" ] || { echo "no $OUTPUT" >&2; exit 1; }
29 29
30 - KVER="$(ls "$ROOTFS/usr/lib/modules" | head -1)"
30 + # Version-sorted, not `head -1`: `ls` collates 7.1.10 before 7.1.3, so the
31 + # alphabetical pick is arbitrary rather than newest. A bootc image should carry
32 + # exactly one kernel, so more than one means something layered a second and the
33 + # choice stops being obvious, so say so rather than picking silently.
34 + KVER="$(ls -1 "$ROOTFS/usr/lib/modules" | sort -V | tail -1)"
31 35 [ -n "$KVER" ] || { echo "no kernel in $ROOTFS/usr/lib/modules" >&2; exit 1; }
36 + KCOUNT="$(ls -1 "$ROOTFS/usr/lib/modules" | wc -l)"
37 + [ "$KCOUNT" -eq 1 ] || echo "warning: $KCOUNT kernels present, using newest ($KVER)" >&2
32 38 say "kernel $KVER"
33 39
34 40 rm -rf "$WORK"
@@ -44,8 +50,17 @@
44 50 # ---------------------------------------------------------------------
45 51 say "building rootfs.img"
46 52 SIZE_KB="$(du -sk "$ROOTFS" | cut -f1)"
47 - # Slack for the ext4 metadata plus room for the overlay's early writes.
53 + # Slack for ext4 metadata. Not for the live session's writes: with
54 + # rd.live.overlay.overlayfs=1 this image is the overlay's read-only lower dir
55 + # and is never written to. The upper dir is a tmpfs under /run, so the real
56 + # budget for the live session is RAM, not this number.
48 57 IMG_MB=$(( SIZE_KB / 1024 + 1536 ))
58 + # Inodes are budgeted from the file count rather than left to the default
59 + # bytes-per-inode ratio. A desktop rootfs is many small files, and the default
60 + # runs out before the space does. That fails mkfs mid-populate, minutes into
61 + # a build, with an error that reads as unrelated to file count.
62 + INODES=$(( $(find "$ROOTFS" -xdev 2>/dev/null | wc -l) * 12 / 10 ))
63 + say "rootfs.img ${IMG_MB}M, $INODES inodes"
49 64 # The LiveOS directory has to exist *inside* the squashfs, so mksquashfs is
50 65 # pointed at its parent: it takes the contents of the directory it is given
51 66 # as the squashfs root, so compressing LiveOS/ directly puts rootfs.img at
@@ -53,7 +68,7 @@
53 68 # .../squashfs.img" while looking for LiveOS/rootfs.img.
54 69 mkdir -p "$WORK/sqroot/LiveOS"
55 70 truncate -s "${IMG_MB}M" "$WORK/sqroot/LiveOS/rootfs.img"
56 - mkfs.ext4 -q -L Alloy -d "$ROOTFS" "$WORK/sqroot/LiveOS/rootfs.img"
71 + mkfs.ext4 -q -L Alloy -N "$INODES" -d "$ROOTFS" "$WORK/sqroot/LiveOS/rootfs.img"
57 72
58 73 # zstd level 19 costs minutes of saturated CPU for a few percent of size,
59 74 # which is worth it for a release and pure waste when the question is
@@ -247,21 +262,38 @@
247 262 # rather than a round number, because a fixed size is a future failure the
248 263 # day the EFI binaries grow.
249 264 say "efiboot.img"
265 + # FAT16, not whatever mkfs.fat picks. Sized to contents it picks FAT12, which
266 + # UEFI permits on removable media but which no shipping distro relies on --
267 + # Arch and Fedora both force FAT16 for exactly this image. Firmware that
268 + # declines FAT12 produces no boot option at all, and the GPT still looks
269 + # perfect to every tool you would inspect it with, so the failure arrives as
270 + # "the stick is not bootable" with nothing to read.
271 + #
272 + # FAT16 needs at least 4085 clusters, which the contents-sized image is far
273 + # below, so floor it at 16 MiB. That is noise against a 4.8 GB ISO.
250 274 EFI_KB=$(( $(du -sk "$WORK/iso/EFI" | cut -f1) + 2048 ))
275 + [ "$EFI_KB" -lt 16384 ] && EFI_KB=16384
251 276 truncate -s "${EFI_KB}K" "$WORK/efiboot.img"
252 - mkfs.fat -n ALLOYEFI "$WORK/efiboot.img" >/dev/null
253 - mmd -i "$WORK/efiboot.img" ::/EFI ::/EFI/BOOT 2>/dev/null || {
254 - # mtools absent; fall back to a loop mount, which needs privileges the
255 - # build already has.
256 - mkdir -p "$WORK/efimnt"
257 - mount -o loop "$WORK/efiboot.img" "$WORK/efimnt"
258 - mkdir -p "$WORK/efimnt/EFI/BOOT"
259 - cp -a "$WORK/iso/EFI/BOOT/." "$WORK/efimnt/EFI/BOOT/"
260 - umount "$WORK/efimnt"
261 - }
262 - if command -v mcopy >/dev/null; then
263 - mcopy -i "$WORK/efiboot.img" -s "$WORK/iso/EFI/BOOT/"* ::/EFI/BOOT/ 2>/dev/null || true
264 - fi
277 + mkfs.fat -F 16 -n ALLOYEFI "$WORK/efiboot.img" >/dev/null
278 +
279 + # One code path, and it is allowed to fail.
280 + #
281 + # This used to try mmd/mcopy, fall back to a loop mount when mtools was
282 + # absent, and then re-run mcopy under `|| true`. Three paths, of which one --
283 + # mmd succeeding and mcopy failing, produced an ESP holding two empty
284 + # directories and no bootloader, reported success, and shipped. mtools was
285 + # not in the builder, so the loop-mount branch is what actually ran; the
286 + # silent branch was live and simply never taken. mtools is installed now
287 + # (build/Containerfile.iso) and this is the only path.
288 + mmd -i "$WORK/efiboot.img" ::/EFI ::/EFI/BOOT
289 + mcopy -i "$WORK/efiboot.img" -s "$WORK/iso/EFI/BOOT/"* ::/EFI/BOOT/
290 +
291 + # Read it back out of the filesystem rather than trusting the exit codes: a
292 + # bootloader that is not in here is the one defect this whole file exists to
293 + # avoid, and it is invisible everywhere else.
294 + mdir -i "$WORK/efiboot.img" ::/EFI/BOOT | grep -qi 'BOOTX64' \
295 + || { echo "BOOTX64.EFI is not in the ESP; the medium cannot boot" >&2; exit 1; }
296 + say "ESP $(( EFI_KB / 1024 ))M FAT16, BOOTX64.EFI present"
265 297 # Deliberately not copied into $WORK/iso. It is appended to the medium as a
266 298 # real partition below, and El Torito is pointed at that partition rather
267 299 # than at a file in the tree, so a copy inside the ISO filesystem would be
@@ -321,7 +353,12 @@
321 353 # fails to appear fails the build instead of the stick.
322 354 # ---------------------------------------------------------------------
323 355 say "checking the partition table"
324 - at() { dd if="$OUTPUT/install.iso" bs=1 skip="$1" count="$2" 2>/dev/null | od -An -tx1 | tr -d ' \n'; }
356 + # -v is load-bearing, not decoration. Without it od replaces runs of identical
357 + # 16-byte lines with a single `*`, so the hex string is shorter than the bytes
358 + # it describes and every offset past the first repeat is wrong. A GPT entry
359 + # array is mostly zero padding, which is exactly the input that triggers it.
360 + # Presence checks survive this; arithmetic on offsets does not.
361 + at() { dd if="$OUTPUT/install.iso" bs=1 skip="$1" count="$2" 2>/dev/null | od -An -tx1 -v | tr -d ' \n'; }
325 362
326 363 [ "$(at 510 2)" = "55aa" ] \
327 364 || { echo "no MBR signature at offset 510; the ISO has no partition table" >&2; exit 1; }
@@ -345,12 +382,41 @@
345 382 ENTRY_COUNT=$(le "$(at $((512 + 80)) 4)")
346 383 ENTRY_SIZE=$(le "$(at $((512 + 84)) 4)")
347 384 ARRAY=$(dd if="$OUTPUT/install.iso" bs=512 skip="$ENTRY_LBA" \
348 - count=$(( (ENTRY_COUNT * ENTRY_SIZE + 511) / 512 )) 2>/dev/null | od -An -tx1 | tr -d ' \n')
385 + count=$(( (ENTRY_COUNT * ENTRY_SIZE + 511) / 512 )) 2>/dev/null | od -An -tx1 -v | tr -d ' \n')
349 386 case "$ARRAY" in
350 387 *28732ac11ff8d211ba4b00a0c93ec93b*) ;;
351 388 *) echo "the GPT describes no EFI system partition" >&2; exit 1 ;;
352 389 esac
353 390 say "protective MBR, GPT at LBA 1, ESP appended as partition 2"
354 391
392 + # The checks above all pass on an image whose El Torito entry points somewhere
393 + # other than the ESP, because they never compare the two. That is the optical boot
394 + # path, and it is the one a partition-table change can silently break while
395 + # every GPT check stays green. Read both numbers and require them to agree.
396 + #
397 + # The GUID match above found the ESP somewhere in the entry array; recover
398 + # which entry it was, so its start LBA can be read rather than assumed.
399 + ESP_PREFIX="${ARRAY%%28732ac11ff8d211ba4b00a0c93ec93b*}"
400 + ESP_INDEX=$(( ${#ESP_PREFIX} / 2 / ENTRY_SIZE ))
401 + ESP_LBA=$(le "${ARRAY:$(( (ESP_INDEX * ENTRY_SIZE + 32) * 2 )):16}")
402 +
403 + # El Torito: the boot record volume descriptor at LBA 17 points at the boot
404 + # catalog; the default entry sits 32 bytes into it, with the sector count at
405 + # +6 and the load RBA at +8. The RBA is in 2048-byte blocks, the partition in
406 + # 512-byte sectors.
407 + CAT_LBA=$(le "$(at $((17 * 2048 + 71)) 4)")
408 + ENTRY_OFF=$(( CAT_LBA * 2048 + 32 ))
409 + BOOT_COUNT=$(le "$(at $((ENTRY_OFF + 6)) 2)")
410 + BOOT_RBA=$(le "$(at $((ENTRY_OFF + 8)) 4)")
411 +
412 + [ "$(( BOOT_RBA * 2048 ))" = "$(( ESP_LBA * 512 ))" ] \
413 + || { echo "El Torito load RBA ($(( BOOT_RBA * 2048 ))) is not the ESP ($(( ESP_LBA * 512 ))); optical boot would read the wrong bytes" >&2; exit 1; }
414 +
415 + ISO_BYTES=$(stat -c %s "$OUTPUT/install.iso")
416 + [ "$(( BOOT_RBA * 2048 + BOOT_COUNT * 512 ))" -le "$ISO_BYTES" ] \
417 + || { echo "El Torito extent runs past the end of the image" >&2; exit 1; }
418 +
419 + say "El Torito entry points at the ESP, $BOOT_COUNT sectors, within the image"
420 +
355 421 chmod 0644 "$OUTPUT/install.iso"
356 422 say "built $(du -h "$OUTPUT/install.iso" | cut -f1) at $OUTPUT/install.iso"