max / alloy
| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # make-iso.sh — assemble the Alloy installer ISO. Runs inside the builder |
| 4 | # image from build/Containerfile.iso; build/build-iso.sh is what invokes it. |
| 5 | # |
| 6 | # Expects: |
| 7 | # /rootfs the Alloy image's root filesystem, read-only |
| 8 | # /output where install.iso is written |
| 9 | # /source an OCI layout of the Alloy image, copied onto the ISO |
| 10 | # |
| 11 | # The ISO carries two copies of Alloy for two different jobs. The squashfs |
| 12 | # is the live system the installer runs in. The OCI layout is what gets |
| 13 | # deployed, because `bootc install` installs a container image and cannot |
| 14 | # install a running squashfs. They are the same content and the ISO pays |
| 15 | # for it twice; that is the cost of not needing a network to install. |
| 16 | |
| 17 | |
| 18 | |
| 19 | ROOTFS=/rootfs |
| 20 | OUTPUT=/output |
| 21 | SOURCE=/source |
| 22 | WORK=/work |
| 23 | VOLID="ALLOY" |
| 24 | |
| 25 | # The build this medium carries, read out of the image it was made from, so a |
| 26 | # stick can be asked which mint it holds without booting it: |
| 27 | # |
| 28 | # xorriso -indev install.iso -pvd_info (or isoinfo -d, or `blkid`) |
| 29 | # |
| 30 | # It answers the question that otherwise costs an install. The ISO's creation |
| 31 | # timestamp is close but not the same thing: two mints of different trees can |
| 32 | # share a minute, and the stamp names the image. |
| 33 | # |
| 34 | # Empty is not an error. A hand-built image with no stamp is a real thing to |
| 35 | # have, and a medium made from one says so rather than refusing to be made. |
| 36 | STAMP="" |
| 37 | |
| 38 | # Every phase line carries the seconds since the last one, because the ISO is |
| 39 | # where this build spends most of its wall clock and nobody could say on what. |
| 40 | # The four minutes were assumed to be compression until they were measured; |
| 41 | # they were not (mksquashfs at the fast level is 8s of it). A build that |
| 42 | # reports its own shape is the difference between optimizing the slow part and |
| 43 | # optimizing the part that looks slow. |
| 44 | # |
| 45 | # The number on a line is the time spent getting TO it, not the time the phase |
| 46 | # it announces will take, because a phase cannot report its own cost before |
| 47 | # doing the work. Read a line's figure as the price of the line above it. |
| 48 | START_MARK= |
| 49 | LAST_MARK= |
| 50 | |
| 51 | |
| 52 | now= |
| 53 | elapsed= |
| 54 | LAST_MARK= |
| 55 | |
| 56 | } |
| 57 | |
| 58 | [ || { ; ; } |
| 59 | [ || { ; ; } |
| 60 | |
| 61 | # --------------------------------------------------------------------- |
| 62 | # 0. Which architecture this medium is for. |
| 63 | # |
| 64 | # From `uname -m`, because the builder image is derived from the Alloy image |
| 65 | # and carries its kernel: the squashfs, the initramfs and the OCI layout are |
| 66 | # all already the architecture this is running on, and an ISO whose boot |
| 67 | # chain disagreed with them would boot nothing. ALLOY_ISO_ARCH overrides it |
| 68 | # for testing the GRUB half alone; it does not make this a cross-build. |
| 69 | # |
| 70 | # Anything but the two mappings is an explicit refusal. Falling through to a |
| 71 | # default would produce an ISO with the wrong EFI binary name, which fails |
| 72 | # as "the stick is not bootable" with no diagnosis available on the machine |
| 73 | # that cannot boot it. |
| 74 | # --------------------------------------------------------------------- |
| 75 | ARCH="" |
| 76 | case "" in |
| 77 | x86_64) |
| 78 | GRUB_FORMAT="x86_64-efi" |
| 79 | # The removable-media path from the UEFI spec. Firmware looks for this |
| 80 | # exact name on a medium with no boot entry of its own and nothing else, |
| 81 | # so it is per-architecture and is not ours to choose. |
| 82 | EFI_NAME="BOOTX64.EFI" |
| 83 | GRUB_MODULE_PKG="grub2-efi-x64-modules" |
| 84 | SERIAL_CONSOLES="console=ttyS0,115200" ;; |
| 85 | aarch64) |
| 86 | GRUB_FORMAT="arm64-efi" |
| 87 | EFI_NAME="BOOTAA64.EFI" |
| 88 | GRUB_MODULE_PKG="grub2-efi-aa64-modules" |
| 89 | # ttyAMA0 first because SBSA mandates a PL011 and that is what an Ampere |
| 90 | # board actually presents; ttyS0 stays listed for boards that present an |
| 91 | # 8250 instead. The kernel writes to every console that exists and |
| 92 | # ignores the ones that do not, so listing both costs nothing and losing |
| 93 | # the serial log on a headless arm box costs the only view of a failed |
| 94 | # boot. tty0 stays last, so /dev/console is still the screen. |
| 95 | SERIAL_CONSOLES="console=ttyAMA0,115200 console=ttyS0,115200" ;; |
| 96 | *) |
| 97 | |
| 98 | ;; |
| 99 | esac |
| 100 | |
| 101 | # grub2-mkimage reads the modules for the target format out of this |
| 102 | # directory, and says only "cannot open directory" when they are absent. |
| 103 | # Name the package instead, before the several minutes of squashfs that |
| 104 | # would otherwise run first. |
| 105 | [ \ |
| 106 | || { ; ; } |
| 107 | |
| 108 | # The modules the EFI binary is built from, checked here for the same reason: |
| 109 | # grub2-mkimage runs at the end and stops at the first module it cannot open, |
| 110 | # so an absent one costs a whole build to discover and names only itself. |
| 111 | # |
| 112 | # The list is *almost* architecture-independent. Measured on astra 2026-08-06 by |
| 113 | # building an arm ISO rather than assumed: of the 30 names, arm64-efi carries |
| 114 | # 29. The exception is `efi_uga`, and its absence is correct. UGA is EFI 1.x's |
| 115 | # pre-GOP graphics protocol and only ever existed on x86 firmware; arm64 UEFI |
| 116 | # has always had GOP, and `efi_gop` is in the list and present on both. So the |
| 117 | # module is dropped on arm rather than substituted for. |
| 118 | # |
| 119 | # One list with a conditional addition, not two lists: a second copy of 29 |
| 120 | # shared names drifts the day anything is added to one of them. |
| 121 | MODULES="part_gpt part_msdos fat iso9660 udf normal linux echo all_video test |
| 122 | search search_label search_fs_uuid search_fs_file gfxterm gfxterm_background |
| 123 | configfile loadenv chain efi_gop ls cat halt reboot minicmd |
| 124 | font terminal squash4 loopback probe regexp" |
| 125 | [ && MODULES=" efi_uga" |
| 126 | |
| 127 | missing="" |
| 128 | for; do |
| 129 | [ || missing=" " |
| 130 | done |
| 131 | [ || { |
| 132 | |
| 133 | |
| 134 | |
| 135 | } |
| 136 | |
| 137 | |
| 138 | |
| 139 | # Version-sorted, not `head -1`: `ls` collates 7.1.10 before 7.1.3, so the |
| 140 | # alphabetical pick is arbitrary rather than newest. A bootc image should carry |
| 141 | # exactly one kernel, so more than one means something layered a second and the |
| 142 | # choice stops being obvious, so say so rather than picking silently. |
| 143 | KVER="" |
| 144 | [ || { ; ; } |
| 145 | KCOUNT="" |
| 146 | [ || |
| 147 | |
| 148 | |
| 149 | |
| 150 | |
| 151 | |
| 152 | # --------------------------------------------------------------------- |
| 153 | # 1. The live root, as a squashfs holding an ext4 image. |
| 154 | # |
| 155 | # dmsquash-live expects LiveOS/squashfs.img to contain LiveOS/rootfs.img, |
| 156 | # not the root tree directly. Handing it a squashfs of the tree boots to a |
| 157 | # dracut shell with "failed to mount live root", which says nothing about |
| 158 | # the layout being the problem. |
| 159 | # --------------------------------------------------------------------- |
| 160 | |
| 161 | SIZE_KB="" |
| 162 | # Slack for ext4 metadata. Not for the live session's writes: with |
| 163 | # rd.live.overlay.overlayfs=1 this image is the overlay's read-only lower dir |
| 164 | # and is never written to. The upper dir is a tmpfs under /run, so the real |
| 165 | # budget for the live session is RAM, not this number. |
| 166 | IMG_MB= |
| 167 | # Inodes are budgeted from the file count rather than left to the default |
| 168 | # bytes-per-inode ratio. A desktop rootfs is many small files, and the default |
| 169 | # runs out before the space does. That fails mkfs mid-populate, minutes into |
| 170 | # a build, with an error that reads as unrelated to file count. |
| 171 | INODES= |
| 172 | |
| 173 | # The LiveOS directory has to exist *inside* the squashfs, so mksquashfs is |
| 174 | # pointed at its parent: it takes the contents of the directory it is given |
| 175 | # as the squashfs root, so compressing LiveOS/ directly puts rootfs.img at |
| 176 | # the top level and dracut fails with "Failed to find a root filesystem in |
| 177 | # .../squashfs.img" while looking for LiveOS/rootfs.img. |
| 178 | |
| 179 | |
| 180 | |
| 181 | |
| 182 | # The console, into the live root and nowhere else. |
| 183 | # |
| 184 | # The image deliberately does not carry /usr/bin/alloy. A component the base |
| 185 | # carries can never be replaced client-side, so the console and shop travel as |
| 186 | # uninstalled RPMs at /usr/share/alloy/rpm and are layered on the first boot |
| 187 | # after an install (Containerfile, "The component repo"; measurements in |
| 188 | # build/layertest). |
| 189 | # |
| 190 | # The live environment is the one place that cannot wait for a first boot. It |
| 191 | # never gets one, it is thrown away when the install finishes, and `alloy |
| 192 | # install` is the entire reason the medium exists: alloy-installer.service and |
| 193 | # the installer account's ForceCommand both name /usr/bin/alloy. Without this |
| 194 | # the ISO boots to a machine with no installer, which is how it behaved for |
| 195 | # exactly one build. |
| 196 | # |
| 197 | # It goes into the mounted image rather than into $ROOTFS, which is the |
| 198 | # read-only mount of the shipped image and must stay exactly what installs. |
| 199 | # `bootc install` reads the OCI layout under /source, not this tree, so the |
| 200 | # console lands in the live session and reaches no installed machine. |
| 201 | # |
| 202 | # From the carried repo rather than by copying a binary in, and the difference |
| 203 | # is what it proves: this is the first thing that ever resolves against |
| 204 | # /usr/share/alloy/rpm, so a package that cannot install fails here, on a build |
| 205 | # host, instead of at somebody's first boot where the symptom is a session that |
| 206 | # will not start. rpm rather than dnf because the repo is on the read-only tree |
| 207 | # and the dependencies are already in the base, so nothing needs resolving. |
| 208 | |
| 209 | |
| 210 | |
| 211 | |
| 212 | [ && [ \ |
| 213 | || { ; ; ; } |
| 214 | |
| 215 | || { ; ; ; } |
| 216 | |
| 217 | || { ; ; ; } |
| 218 | |
| 219 | |
| 220 | |
| 221 | # ALLOY_ISO_FAST picks the cheap level, for when the question is whether the |
| 222 | # thing boots at all rather than how big it is. |
| 223 | # |
| 224 | # Measured on fw13 (12 cores) against this image, 2026-08-15, 6.4G of rootfs: |
| 225 | # |
| 226 | # level time squashfs.img |
| 227 | # 3 8s 2.519 G |
| 228 | # 9 20s 2.412 G |
| 229 | # 12 29s 2.405 G |
| 230 | # 15 63s 2.398 G |
| 231 | # 19 192s 2.264 G |
| 232 | # |
| 233 | # Two things in that table, and both contradict what this comment used to say. |
| 234 | # It claimed level 19 costs "about ten minutes of saturated CPU for a few |
| 235 | # percent of size"; it costs three, and it is the only level that buys anything |
| 236 | # after 9 — the jump at 19 is zstd's long-distance matching, which the levels |
| 237 | # below it do not enable. And 12 and 15 are dominated: 15 spends 43 seconds |
| 238 | # more than 9 to save 14 MB. |
| 239 | # |
| 240 | # So the two levels here are the two worth having. 19 for a release, where |
| 241 | # three minutes is nothing against 255 MB off every stick and every install. |
| 242 | # 3 for iteration, where eight seconds is close enough to free. |
| 243 | if [; then |
| 244 | COMP_LEVEL=3 |
| 245 | |
| 246 | else |
| 247 | COMP_LEVEL=19 |
| 248 | |
| 249 | fi |
| 250 | |
| 251 | -noappend -no-progress -comp zstd -Xcompression-level "" -b 1M \ |
| 252 | -processors "" |
| 253 | |
| 254 | |
| 255 | # --------------------------------------------------------------------- |
| 256 | # 2. Kernel and a live initramfs. |
| 257 | # |
| 258 | # --no-hostonly matters: a host-only initramfs is built for the hardware |
| 259 | # doing the building, and this one has to boot anything. |
| 260 | # --------------------------------------------------------------------- |
| 261 | |
| 262 | |
| 263 | |
| 264 | # The image's own dracut config is for booting an installed ostree system: |
| 265 | # /usr/lib/dracut/dracut.conf.d/*bootc* add the ostree and bootc modules, |
| 266 | # which assume an ostree root and fail here trying to install /root. A live |
| 267 | # medium has no deployment to find, so start from no distro config at all |
| 268 | # (--conf /dev/null, empty --confdir) and name what this initramfs needs. |
| 269 | |
| 270 | |
| 271 | --conf /dev/null \ |
| 272 | --confdir "/empty-conf" \ |
| 273 | --kver "" \ |
| 274 | --kmoddir "/usr/lib/modules/" \ |
| 275 | --add "dmsquash-live" \ |
| 276 | --omit "ostree bootc" \ |
| 277 | --add-drivers "squashfs loop overlay iso9660 sr_mod sd_mod usb_storage virtio_blk virtio_scsi virtio_pci" \ |
| 278 | "/iso/boot/initramfs.img" 2>&1 | |
| 279 | |
| 280 | # dracut reports module failures on stderr and still exits 0, so a broken |
| 281 | # initramfs ships looking like a success. Check the result instead. |
| 282 | # |
| 283 | # This build used to print `ERROR: installing '/root'` and a FAILED line |
| 284 | # every time, harmlessly: /root is a symlink to var/roothome in the bootc |
| 285 | # layout and no var is populated in a container. The builder now creates |
| 286 | # /var/roothome (see build/Containerfile.iso), so that error is gone rather |
| 287 | # than tolerated, and this check can be the strict one it could not be |
| 288 | # while a known error was expected in normal output. |
| 289 | # |
| 290 | # One message is still allowlisted, by its exact text. dracut tests for |
| 291 | # /dev/log or a logger binary before it installs anything and warns when it |
| 292 | # finds neither; `--install logger` does not satisfy it, because the test |
| 293 | # runs first. It concerns logging from inside the initramfs and nothing |
| 294 | # else. Matching the whole sentence rather than a pattern keeps the |
| 295 | # allowance from widening into "errors mentioning logging are fine". |
| 296 | # |
| 297 | # grep -c rather than grep -q: grep -q exits at the first match, which |
| 298 | # SIGPIPEs lsinitrd, and under `set -o pipefail` that reads as a failed |
| 299 | # check on a perfectly good initramfs. It cost a build to work that out. |
| 300 | [ || { ; ; } |
| 301 | unexpected=" |
| 302 | | || )" |
| 303 | if [; then |
| 304 | |
| 305 | | |
| 306 | |
| 307 | fi |
| 308 | found="" |
| 309 | [ \ |
| 310 | || { ; ; } |
| 311 | |
| 312 | |
| 313 | # --------------------------------------------------------------------- |
| 314 | # 3. The image the installer deploys. |
| 315 | # --------------------------------------------------------------------- |
| 316 | if [ && [; then |
| 317 | |
| 318 | |
| 319 | |
| 320 | else |
| 321 | |
| 322 | fi |
| 323 | |
| 324 | # --------------------------------------------------------------------- |
| 325 | # 4. GRUB. |
| 326 | # |
| 327 | # `alloy.installer` is what arms alloy-installer.service; without it the |
| 328 | # live system boots to a greeter with no account, which looks like a |
| 329 | # broken ISO. rd.live.image and root=live:CDLABEL are what dmsquash-live |
| 330 | # reads to find the squashfs. |
| 331 | # --------------------------------------------------------------------- |
| 332 | |
| 333 | # console= more than once on purpose. The kernel writes to every console |
| 334 | # listed, so serial gets a full log for headless debugging, while |
| 335 | # /dev/console resolves to the last one named and so stays on the screen the |
| 336 | # user is looking at. Which serial lines are named is per-architecture; see |
| 337 | # the arch block at the top. |
| 338 | # Reversed, the installer's own output would go down the serial line and the |
| 339 | # monitor would sit black. The installer renders on tty1 explicitly |
| 340 | # (alloy-installer.service, TTYPath) and does not depend on this ordering. |
| 341 | CONSOLES=" console=tty0" |
| 342 | # SELinux on the live medium: permissive by default, off as a fallback. |
| 343 | # |
| 344 | # The live rootfs.img is built by mkfs.ext4 from a container rootfs, and it |
| 345 | # carries SELinux labels only if the machine doing the building has SELinux |
| 346 | # to derive them from. fw13 is Pop!_OS and has none, so every file in the |
| 347 | # live root is unlabeled and systemd refuses to start at all: "Failed to |
| 348 | # allocate manager object: Permission denied", then it freezes, with no |
| 349 | # hint that labelling is what is missing. |
| 350 | # |
| 351 | # `selinux=0` cures the freeze and costs the install. A process running |
| 352 | # with SELinux switched off cannot write labels to anything, the target |
| 353 | # disk included, so the machine this ISO installs comes up unlabeled and |
| 354 | # freezes the same way on its own first boot, with no way past it but |
| 355 | # editing the kernel line at GRUB by hand. |
| 356 | # |
| 357 | # `enforcing=0` is the setting between the two: the policy in the image at |
| 358 | # /etc/selinux/targeted/policy loads, unlabeled files stop being fatal, and |
| 359 | # the kernel still tracks labels. That is enough for the live system to |
| 360 | # boot and, unlike selinux=0, leaves `bootc install` able to label the |
| 361 | # target as it writes it. An install from the permissive entry should come |
| 362 | # up enforcing and correct on its first boot, with no relabel pass and no |
| 363 | # manual step. |
| 364 | # |
| 365 | # Should, because it is untested until this ISO boots on real hardware, |
| 366 | # which is why both entries ship. Boot the default; if the live system |
| 367 | # freezes before the installer draws, the SELinux-off entry is exactly the |
| 368 | # medium that was known to work, at the cost of an install that needs |
| 369 | # `enforcing=0` typed at GRUB on first boot. Recorded in the wiki note |
| 370 | # alloy-build-notes. |
| 371 | # |
| 372 | # Building on a Fedora host removes the question entirely by shipping a |
| 373 | # labeled live root. That is a build-host project, not a boot flag. |
| 374 | SELINUX_PERMISSIVE="enforcing=0" |
| 375 | SELINUX_OFF="selinux=0" |
| 376 | LIVE="root=live:CDLABEL= rd.live.image rd.live.overlay.overlayfs=1 alloy.installer " |
| 377 | |
| 378 | # Where machines installed from this ISO will fetch their updates. |
| 379 | # |
| 380 | # Empty for ordinary media, which leaves the installer on its compiled-in |
| 381 | # default (the public registry). Set by `build/build-iso.sh --update-target` |
| 382 | # for media built against a registry that exists today, so the machines it |
| 383 | # installs can take an update without a `bootc switch` afterwards. |
| 384 | # |
| 385 | # Whitespace would split into a second kernel parameter and silently truncate |
| 386 | # the reference, so it is refused rather than quoted: a reference cannot |
| 387 | # legally contain any. |
| 388 | if [; then |
| 389 | case "" in |
| 390 | *[[:space:]]*) |
| 391 | ; ;; |
| 392 | esac |
| 393 | # A tag is required, because the containers stack reads its absence as |
| 394 | # `latest` and docs/IMAGE.md makes a major bump a deliberate act. The tag is |
| 395 | # what follows the last colon, and only when no `/` follows it: a colon |
| 396 | # before a slash is a registry port, not a tag. |
| 397 | case "" in |
| 398 | */*|"") |
| 399 | ; ;; |
| 400 | latest) |
| 401 | ; ;; |
| 402 | esac |
| 403 | |
| 404 | LIVE=" alloy.update-target=" |
| 405 | fi |
| 406 | CMDLINE=" quiet loglevel=3" |
| 407 | |
| 408 | |
| 409 | set default=0 |
| 410 | set timeout=5 |
| 411 | |
| 412 | menuentry "Install Alloy" { |
| 413 | linux /boot/vmlinuz |
| 414 | initrd /boot/initramfs.img |
| 415 | } |
| 416 | |
| 417 | # The fallback, for a live system that freezes before the installer draws. |
| 418 | # Installs a machine that needs enforcing=0 on its own first boot; see the |
| 419 | # SELinux note above. |
| 420 | menuentry "Install Alloy (SELinux off, if the default will not boot)" { |
| 421 | linux /boot/vmlinuz quiet loglevel=3 |
| 422 | initrd /boot/initramfs.img |
| 423 | } |
| 424 | |
| 425 | # alloy.debug arms alloy-debug-shell@.service, which puts a root shell on tty9 |
| 426 | # and on the serial console. The live medium has no account and a locked root, |
| 427 | # so without this a failure inside the live system can only be read off |
| 428 | # whatever the installer printed before it stopped. |
| 429 | # |
| 430 | # Quiet, like the default. This entry used to add rd.debug and |
| 431 | # systemd.log_level=debug, which with console=tty0 print over the installer TUI |
| 432 | # and over the shell itself. Every question asked of the live system so far has |
| 433 | # been answered from the shell, with dmesg and findmnt, which hold the same |
| 434 | # information and hold still while being read. |
| 435 | menuentry "Install Alloy (root shell on tty9)" { |
| 436 | linux /boot/vmlinuz alloy.debug |
| 437 | initrd /boot/initramfs.img |
| 438 | } |
| 439 | |
| 440 | # Not a boot option: a rescue shell in the initramfs, for when the live root |
| 441 | # will not mount at all and there is nothing else to ask. |
| 442 | menuentry "Initramfs shell" { |
| 443 | linux /boot/vmlinuz root=live:CDLABEL= rd.live.image rd.break=pre-mount |
| 444 | initrd /boot/initramfs.img |
| 445 | } |
| 446 | EOF |
| 447 | |
| 448 | # grub.cfg next to the EFI binary is the one the firmware reads first; it |
| 449 | # hands off to the copy above so there is a single place to edit entries. |
| 450 | |
| 451 | |
| 452 | search --no-floppy --set=root --label |
| 453 | set prefix=(\$root)/boot/grub |
| 454 | configfile /boot/grub/grub.cfg |
| 455 | EOF |
| 456 | |
| 457 | # Our own GRUB *is* the removable-media binary, with no shim in front of it. |
| 458 | # |
| 459 | # The signed shim and signed grubx64.efi that shim expects to chain to both |
| 460 | # ship in RPMs whose files a bootc image strips out of /boot, and the |
| 461 | # packages still read as installed, so reinstalling them is the only way to |
| 462 | # get the bytes back. Not worth it here: a self-built GRUB is unsigned |
| 463 | # either way, so this ISO needs Secure Boot off regardless of whether shim |
| 464 | # is in front of it. Signing is a distribution problem and distribution is |
| 465 | # not set up (GO task 0d7505b5); when it is, this is where shim goes. |
| 466 | # |
| 467 | # The module list is *almost* architecture-independent. Measured on astra |
| 468 | # 2026-08-06 by building an arm ISO, rather than assumed: of the 30 modules |
| 469 | # below, arm64-efi carries 29. The exception is `efi_uga`, and its absence is |
| 470 | # correct. UGA is EFI 1.x's pre-GOP graphics protocol, which only ever existed |
| 471 | # on x86 firmware; arm64 UEFI has always had GOP, and `efi_gop` is in the list |
| 472 | # and present. So the module is dropped on arm rather than replaced. |
| 473 | # |
| 474 | # The list itself is built at the top of this file, and checked there, so a |
| 475 | # missing module fails before the several minutes of squashfs rather than after. |
| 476 | # |
| 477 | # shellcheck disable=SC2086 # $MODULES is a deliberate word-split list |
| 478 | |
| 479 | --format="" \ |
| 480 | --prefix="/EFI/BOOT" \ |
| 481 | --output="/iso/EFI/BOOT/" \ |
| 482 | |
| 483 | |
| 484 | # The EFI system partition the firmware actually mounts. Sized to contents |
| 485 | # rather than a round number, because a fixed size is a future failure the |
| 486 | # day the EFI binaries grow. |
| 487 | |
| 488 | # FAT16, not whatever mkfs.fat picks. Sized to contents it picks FAT12, which |
| 489 | # UEFI permits on removable media but which no shipping distro relies on -- |
| 490 | # Arch and Fedora both force FAT16 for exactly this image. Firmware that |
| 491 | # declines FAT12 produces no boot option at all, and the GPT still looks |
| 492 | # perfect to every tool you would inspect it with, so the failure arrives as |
| 493 | # "the stick is not bootable" with nothing to read. |
| 494 | # |
| 495 | # FAT16 needs at least 4085 clusters, which the contents-sized image is far |
| 496 | # below, so floor it at 16 MiB. That is noise against a 4.8 GB ISO. |
| 497 | EFI_KB= |
| 498 | [ && EFI_KB=16384 |
| 499 | |
| 500 | # The label some firmware shows for a removable boot entry, which is the only |
| 501 | # string on this medium with any chance of reaching a boot menu. EDK2 does not |
| 502 | # read it -- measured 2026-09-07 under OVMF, which named our medium |
| 503 | # `UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-1` from the USB descriptor alone |
| 504 | # -- but several vendor firmwares do, so it is worth being a name rather than an |
| 505 | # identifier. Eleven characters is the FAT limit and this is ten. |
| 506 | # |
| 507 | # It must NOT equal $VOLID. GRUB's `search --label $VOLID` looks at every |
| 508 | # filesystem it can see, the ESP on this same medium included, so two |
| 509 | # filesystems sharing a label is a coin flip over which one becomes $root. |
| 510 | |
| 511 | |
| 512 | # One code path, and it is allowed to fail. |
| 513 | # |
| 514 | # This used to try mmd/mcopy, fall back to a loop mount when mtools was |
| 515 | # absent, and then re-run mcopy under `|| true`. Three paths, of which one -- |
| 516 | # mmd succeeding and mcopy failing, produced an ESP holding two empty |
| 517 | # directories and no bootloader, reported success, and shipped. mtools was |
| 518 | # not in the builder, so the loop-mount branch is what actually ran; the |
| 519 | # silent branch was live and simply never taken. mtools is installed now |
| 520 | # (build/Containerfile.iso) and this is the only path. |
| 521 | |
| 522 | |
| 523 | |
| 524 | # Read it back out of the filesystem rather than trusting the exit codes: a |
| 525 | # bootloader that is not in here is the one defect this whole file exists to |
| 526 | # avoid, and it is invisible everywhere else. |
| 527 | # |
| 528 | # Checked against the name this build wrote, not a fixed one: an arm ISO |
| 529 | # that failed a check naming BOOTX64 would report an architecture it was |
| 530 | # never building for. |
| 531 | | |
| 532 | || { ; ; } |
| 533 | |
| 534 | # Deliberately not copied into $WORK/iso. It is appended to the medium as a |
| 535 | # real partition below, and El Torito is pointed at that partition rather |
| 536 | # than at a file in the tree, so a copy inside the ISO filesystem would be |
| 537 | # a second copy of these same bytes that nothing reads. |
| 538 | |
| 539 | # --------------------------------------------------------------------- |
| 540 | # 5. Assemble. UEFI only, matching the images bootc produces: they have no |
| 541 | # legacy BIOS path, so the MBR must be protective and must not advertise |
| 542 | # a BIOS boot that would fail. |
| 543 | # --------------------------------------------------------------------- |
| 544 | |
| 545 | # -rock is not decoration here. An OCI layout stores every blob under a |
| 546 | # 64-character hex filename, and plain ISO 9660 truncates names to 31, so |
| 547 | # without Rock Ridge the layout arrives on the medium renamed and the |
| 548 | # install fails looking like a corrupt image rather than a naming problem. |
| 549 | # The squashfs and the boot files have short names and never needed it, |
| 550 | # which is why it was absent until the install source became a directory. |
| 551 | # |
| 552 | # The partition table is written by -append_partition + -appended_part_as_gpt, |
| 553 | # not by -isohybrid-gpt-basdat, which is what this call used to pass. That |
| 554 | # option is a no-op in this shape: it describes an El Torito image that was |
| 555 | # opened with -eltorito-alt-boot and there is no primary entry for it to be |
| 556 | # an alternative to, so xorriso emitted no table and reported no error. The |
| 557 | # result was an ISO whose first 4096 bytes were zero, i.e. no MBR, no GPT |
| 558 | # and no discoverable ESP, which left booting a USB stick up to how lenient |
| 559 | # the firmware felt. It survived because every QEMU test attached the ISO |
| 560 | # with -cdrom, and optical emulation boots El Torito by design and never |
| 561 | # looks for a partition table. |
| 562 | # |
| 563 | # Appending the ESP instead gives the firmware the removable-media path it |
| 564 | # actually specifies: a protective 0xEE MBR, a GPT, and the ESP as a typed |
| 565 | # partition. `-e --interval:appended_partition_2:all::` keeps El Torito as |
| 566 | # well, pointed at those same bytes, so the optical path is unchanged. |
| 567 | # |
| 568 | # fdisk reports three partitions, not two. The third is the 300 KiB of |
| 569 | # padding xorriso appends by default to work around kernels that read past |
| 570 | # the end of the medium; -appended_part_as_gpt describes every region of |
| 571 | # the image, padding included. It is expected, not a stray partition. |
| 572 | |
| 573 | -iso-level 3 \ |
| 574 | -rock \ |
| 575 | -volid "" \ |
| 576 | -appid "Alloy Installer" \ |
| 577 | -publisher "Make Creative, LLC" \ |
| 578 | -preparer "Alloy build/make-iso.sh" \ |
| 579 | -sysid "LINUX" \ |
| 580 | -volset "" \ |
| 581 | -append_partition 2 C12A7328-F81F-11D2-BA4B-00A0C93EC93B "/efiboot.img" \ |
| 582 | -appended_part_as_gpt \ |
| 583 | -eltorito-alt-boot \ |
| 584 | -e --interval:appended_partition_2:all:: \ |
| 585 | -no-emul-boot \ |
| 586 | -output "/install.iso" \ |
| 587 | "/iso" |
| 588 | |
| 589 | # --------------------------------------------------------------------- |
| 590 | # 6. Read the boot structures back. |
| 591 | # |
| 592 | # The bug above shipped because the build trusted a flag that exited 0. |
| 593 | # These checks read the bytes the firmware reads, so a table that silently |
| 594 | # fails to appear fails the build instead of the stick. |
| 595 | # --------------------------------------------------------------------- |
| 596 | |
| 597 | # -v is load-bearing, not decoration. Without it od replaces runs of identical |
| 598 | # 16-byte lines with a single `*`, so the hex string is shorter than the bytes |
| 599 | # it describes and every offset past the first repeat is wrong. A GPT entry |
| 600 | # array is mostly zero padding, which is exactly the input that triggers it. |
| 601 | # Presence checks survive this; arithmetic on offsets does not. |
| 602 | |
| 603 | |
| 604 | [ \ |
| 605 | || { ; ; } |
| 606 | [ \ |
| 607 | || { ; ; } |
| 608 | [ \ |
| 609 | || { ; ; } |
| 610 | [ \ |
| 611 | || { ; ; } |
| 612 | [ \ |
| 613 | || { ; ; } |
| 614 | |
| 615 | # Every check above passes on a GPT that describes no ESP at all, which is the |
| 616 | # one thing this shape exists to add, so read the entry array and find it. The |
| 617 | # type GUID is stored mixed-endian: the first three fields little-endian, the |
| 618 | # last two as written, so C12A7328-F81F-11D2-BA4B-00A0C93EC93B lands on disk as |
| 619 | # the bytes below. A 16-byte substring match is not entry-aligned, but a GUID |
| 620 | # occurring by chance anywhere else in the array is not a thing that happens. |
| 621 | |
| 622 | ENTRY_LBA= |
| 623 | ENTRY_COUNT= |
| 624 | ENTRY_SIZE= |
| 625 | ARRAY= |
| 626 | count= 2>/dev/null | | ) |
| 627 | case "" in |
| 628 | *28732ac11ff8d211ba4b00a0c93ec93b*) ;; |
| 629 | *) ; ;; |
| 630 | esac |
| 631 | |
| 632 | |
| 633 | # The checks above all pass on an image whose El Torito entry points somewhere |
| 634 | # other than the ESP, because they never compare the two. That is the optical boot |
| 635 | # path, and it is the one a partition-table change can silently break while |
| 636 | # every GPT check stays green. Read both numbers and require them to agree. |
| 637 | # |
| 638 | # The GUID match above found the ESP somewhere in the entry array; recover |
| 639 | # which entry it was, so its start LBA can be read rather than assumed. |
| 640 | ESP_PREFIX="" |
| 641 | ESP_INDEX= |
| 642 | ESP_LBA= |
| 643 | |
| 644 | # El Torito: the boot record volume descriptor at LBA 17 points at the boot |
| 645 | # catalog; the default entry sits 32 bytes into it, with the sector count at |
| 646 | # +6 and the load RBA at +8. The RBA is in 2048-byte blocks, the partition in |
| 647 | # 512-byte sectors. |
| 648 | CAT_LBA= |
| 649 | ENTRY_OFF= |
| 650 | BOOT_COUNT= |
| 651 | BOOT_RBA= |
| 652 | |
| 653 | [ \ |
| 654 | || { ; ; } |
| 655 | |
| 656 | ISO_BYTES= |
| 657 | [ \ |
| 658 | || { ; ; } |
| 659 | |
| 660 | |
| 661 | |
| 662 | |
| 663 | |
| 664 | |
| 665 | TOTAL= |
| 666 | |
| 667 |