Files
c630/build/stage2.sh
rob thijssen b05c982a77
Some checks failed
build image / build (push) Has been cancelled
Write up the onboard WiFi investigation, and ship the services it needs
aarch64-laptops' support table ticks WiFi for this machine, so the path exists.
Following their WiFi README got considerably further and then stopped somewhere
useful to have documented.

Two of the four services they list are in the kernel now (pd-mapper, qrtr-ns).
The other two, rmtfs and tqftpserv, are packaged in Fedora and shipped
disabled — tqftpserv was not even installed. Both are now in base.pkgs and
enabled by stage2. Enabling them took qrtr-lookup from 19 registered services
to 25; a working setup is said to show around 40.

The step worth having written down: the modem does not read wlanmdsp.mbn from
/lib/firmware. It asks tqftpserv for it over TFTP, from
/lib/firmware/readonly/firmware/image/. Nothing reports this as an error —
ath10k_snoc binds, registers a QMI client, and waits forever for a service that
never registers. c630-firmware now places the file there, and creates the
writable area the modem asks tqftpserv for.

Where it stops: the modem boots and dies at "RF stuck in QLINK start state",
about every 42 seconds, never reaching the point of requesting wlanmdsp. Three
firmware pairings give three distinct failures, recorded in the doc — the _nm
"no modem" variants, which are the obvious idea and would skip cellular RF
entirely, turn out not to be signed for this device.

The result that narrows it: aarch64-laptops' own wifi directory carries the
qcdsp1v2850.mbn, qcdsp2850.mbn and wlanmdsp.mbn from the setup whose table ticks
WiFi. All three differ from the WOA-Project copies. Installed here with the
services running and wlanmdsp in the TFTP path, they produce the identical QLINK
failure — so it is neither the firmware nor the userspace. What is left is the
kernel: 5.x from 2019 there against 7.1.5 here, with both of their ath10k
patches long since upstream. That points at a regression, and confirming it
means a bisect rather than another file.

Also record what is untried: the other eight driver versions (this machine's
UEFI is from 2019 and the newest package may be the wrong vintage),
mcfg_subsys_ext850.cab, and building board-2.bin from the C630's own bdwlan.*
files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWRjNJMistCy6ngXH5aJLS
2026-07-28 08:52:08 +03:00

535 lines
23 KiB
Bash
Executable File

#!/usr/bin/bash
#
# Runs inside an aarch64 Fedora container (see build/build-image.sh).
#
# Stages a Fedora root filesystem with dnf, applies the C630 overlay, then
# assembles a GPT disk image. Deliberately avoids loop devices: filesystems are
# built from directory trees with `mke2fs -d` and `mcopy`, then dd'd into a
# partitioned sparse file. That keeps the whole thing working on CI runners
# where /dev/loop-control is not available to the job.
set -euo pipefail
SRC=/src
OUT=/out
WORK=/work # persists between builds; see --work in build-image.sh
BASE="$WORK/base" # pristine post-dnf tree, reused between builds
ROOTFS="$WORK/rootfs" # disposable working copy of the above
STAMP="$WORK/base.stamp"
DNF_CACHE=/var/cache/c630-dnf # bind-mounted from the host, survives the run
# shellcheck source=../config/device.env
source "$SRC/config/device.env"
: "${VARIANT:=minimal}"
: "${BUILD_REF:=unknown}"
: "${BUILD_DATE:=unknown}"
: "${KEEP_ROOTFS:=0}"
: "${FRESH:=0}"
: "${COMPRESS_IN_CONTAINER:=1}"
# Bump when the *procedure* for staging the base changes in a way that alters
# its contents. The stamp otherwise hashes only the package list, so a change
# like excluding a weak dependency would silently reuse a stale base.
BASE_RECIPE=2
# dracut-config-rescue arrives as a weak dependency of dracut and only costs us:
# it makes kernel-install build a second, rescue initramfs — another emulated
# dracut run — and leaves a rescue entry in the boot menu we never use. On a
# machine this slow it would also double the cost of every future kernel update.
DNF_EXCLUDE=(--exclude=dracut-config-rescue)
IMAGE_NAME="fedora-${FEDORA_RELEASE}-${VARIANT}-${DEVICE_NAME}-${BUILD_DATE}-${BUILD_REF}"
IMAGE_PATH="$OUT/${IMAGE_NAME}.img"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
MOUNTED=()
unbind_all() {
local i
for (( i=${#MOUNTED[@]}-1; i>=0; i-- )); do
umount "${MOUNTED[i]}" 2>/dev/null || umount -l "${MOUNTED[i]}" 2>/dev/null || true
done
MOUNTED=()
}
trap unbind_all EXIT
bind() { mount --bind "$1" "$2" && MOUNTED+=("$2"); }
mkdir -p "$WORK" "$DNF_CACHE"
# $WORK persists between builds so the staged base can be reused — which means
# everything else in it persists too, including intermediates from a build that
# was cancelled or failed before its cleanup ran. That is not inert: mkfs.vfat
# -C refuses to overwrite an existing file, and `mv dir $WORK/boot` nests inside
# a surviving directory rather than replacing it. So state the invariant
# positively — only the staged base and its stamp survive a new run.
find "$WORK" -mindepth 1 -maxdepth 1 \
! -name base ! -name base.stamp -exec rm -rf {} +
# ---------------------------------------------------------------------------
# Build tooling. Every dnf transaction in here runs emulated, so the prebuilt
# base image (gongfoo's build-fedora-44-aarch64) carries these already and this
# becomes a no-op. Only a stock Fedora image pays for it.
# ---------------------------------------------------------------------------
if command -v mke2fs >/dev/null && command -v mcopy >/dev/null \
&& command -v sgdisk >/dev/null && command -v setfiles >/dev/null; then
echo "build tooling already present in the container image"
else
log "Installing build tooling into the container"
dnf -y install --setopt=install_weak_deps=False \
--setopt=cachedir="$DNF_CACHE" --setopt=keepcache=1 \
e2fsprogs dosfstools mtools gdisk util-linux rsync zstd findutils \
policycoreutils \
>/dev/null
fi
# ---------------------------------------------------------------------------
log "Resolving package list (base + ${VARIANT})"
# ---------------------------------------------------------------------------
read_pkgs() {
# Strip comments and blank lines. '@^env' is dnf4 spelling for an
# environment group; dnf5 wants a plain '@env'.
sed -e 's/#.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' \
-e 's/^@\^/@/' "$1"
}
mapfile -t PACKAGES < <(
read_pkgs "$SRC/config/packages/base.pkgs"
read_pkgs "$SRC/config/packages/${VARIANT}.pkgs"
)
echo "${#PACKAGES[@]} package specs"
# ---------------------------------------------------------------------------
# The dnf transaction is the only genuinely expensive step — an hour or more of
# emulated rpm scriptlets. Everything after it is minutes. So stage it once into
# a pristine tree keyed on the inputs that would change it, and copy that tree
# per build. Iterating on the kernel command line or the overlay then costs a
# copy instead of a reinstall.
# ---------------------------------------------------------------------------
WANT_STAMP="$(printf '%s\n' "$FEDORA_RELEASE" "$TARGET_ARCH" "$VARIANT" \
"recipe=$BASE_RECIPE" "${DNF_EXCLUDE[@]}" "${PACKAGES[@]}" \
| sha256sum | cut -d' ' -f1)"
if [ "$FRESH" = 1 ]; then
log "Discarding the staged base (--fresh)"
rm -rf "$BASE" "$STAMP"
fi
if [ -d "$BASE" ] && [ "$(cat "$STAMP" 2>/dev/null || true)" = "$WANT_STAMP" ]; then
log "Reusing the staged base — package set is unchanged"
echo "pass --fresh to force a reinstall"
else
rm -rf "$BASE" "$STAMP"
mkdir -p "$BASE/etc/yum.repos.d" "$BASE/etc/pki/rpm-gpg" "$BASE/etc/dnf"
# dnf reads its repo definitions from inside --installroot. Seed them from
# the container (same release, same arch) so the first transaction has
# somewhere to fetch from and something to verify signatures against. The
# fedora-repos package overwrites these with its own during the transaction.
cp -a /etc/yum.repos.d/. "$BASE/etc/yum.repos.d/"
cp -a /etc/pki/rpm-gpg/. "$BASE/etc/pki/rpm-gpg/"
if [ -d /etc/dnf/vars ]; then cp -a /etc/dnf/vars "$BASE/etc/dnf/"; fi
# The kernel's %posttrans runs kernel-install, which runs dracut — before
# this build has written /etc/dracut.conf.d/10-c630.conf and before /proc is
# bind-mounted. The result is an initramfs that is both wrong and expensive:
# two emulated dracut runs (normal + rescue) costing roughly 40 minutes,
# immediately superseded by the one stage2 builds later with the right
# config. Both 50-dracut.install and 51-dracut-rescue.install bail out when
# KERNEL_INSTALL_INITRD_GENERATOR is anything other than "dracut".
#
# Expected side effect: with no initramfs present, 95-set-boot-entry.install
# logs "Error: /boot/initramfs-<kver>.img not found" and exits 1 during the
# transaction. rpm reports the scriptlet failure and carries on. It does not
# matter here — stage2 writes its own BLS entry below — and it cannot happen
# on the device, where this file is gone and dracut runs normally.
#
# This file must not survive into the image — see the removal after the
# working copy is made.
mkdir -p "$BASE/etc/kernel"
printf 'initrd_generator=none\n' > "$BASE/etc/kernel/install.conf"
log "Installing Fedora ${FEDORA_RELEASE} (${TARGET_ARCH}) — this is the slow part"
# keepcache=1 with a cachedir outside the install root: the downloaded rpms
# outlive both the transaction and the staged tree, so a --fresh rebuild
# re-runs the scriptlets but does not re-download 500-odd packages.
dnf -y \
--installroot="$BASE" \
--releasever="$FEDORA_RELEASE" \
--setopt=cachedir="$DNF_CACHE" \
--setopt=keepcache=1 \
--setopt=install_weak_deps=True \
"${DNF_EXCLUDE[@]}" \
install "${PACKAGES[@]}"
printf '%s\n' "$WANT_STAMP" > "$STAMP"
fi
# ---------------------------------------------------------------------------
log "Copying the staged base into a working tree"
# ---------------------------------------------------------------------------
# --reflink=auto is near-instant on btrfs (Fedora's default) and degrades to a
# real copy elsewhere. The working tree gets mutated heavily below — accounts,
# initramfs, bootloader — so the base has to stay untouched.
rm -rf "$ROOTFS"
cp -a --reflink=auto "$BASE" "$ROOTFS"
mkdir -p "$WORK/esp"
# Undo the build-time suppression of initramfs generation. Shipping this would
# mean the laptop generates no initramfs on its next kernel update and does not
# come back up — the worst kind of bug, because it appears weeks later and looks
# nothing like an image problem. Nothing in Fedora owns this path, so removing
# it restores stock behaviour exactly.
rm -f "$ROOTFS/etc/kernel/install.conf"
KVER="$(rpm --root "$ROOTFS" -q kernel-core --qf '%{VERSION}-%{RELEASE}.%{ARCH}\n' \
| sort -V | tail -1)"
[ -n "$KVER" ] || { echo "could not determine installed kernel version" >&2; exit 1; }
echo "kernel: $KVER"
if [ ! -e "$ROOTFS/boot/dtb-${KVER}/${DEVICE_DTB}" ]; then
echo "error: ${DEVICE_DTB} is not in this kernel's device trees." >&2
echo " Check DEVICE_DTB in config/device.env against:" >&2
ls "$ROOTFS/boot/dtb-${KVER}/qcom/" | grep -i yoga >&2 || true
exit 1
fi
# ---------------------------------------------------------------------------
log "Applying overlay"
# ---------------------------------------------------------------------------
render() {
sed -e "s|@DEVICE_DTB@|${DEVICE_DTB}|g" \
-e "s|@DEVICE_CMDLINE@|${DEVICE_CMDLINE}|g" \
-e "s|@DEVICE_DESC@|${DEVICE_DESC}|g" \
-e "s|@DEVICE_NAME@|${DEVICE_NAME}|g" \
-e "s|@FEDORA_RELEASE@|${FEDORA_RELEASE}|g" \
-e "s|@BUILD_REF@|${BUILD_REF}|g" \
-e "s|@BUILD_DATE@|${BUILD_DATE}|g"
}
while IFS= read -r rel; do
src="$SRC/overlay/$rel"
dst="$ROOTFS/$rel"
if [[ "$rel" == *.in ]]; then
dst="${dst%.in}"
mkdir -p "$(dirname "$dst")"
render < "$src" > "$dst"
else
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
fi
chmod --reference="$src" "$dst"
done < <(cd "$SRC/overlay" && find . -type f -printf '%P\n')
# Locally-supplied firmware, if the operator dropped any in. Contents mirror
# /usr/lib/firmware/updates/ and are gitignored — see docs/firmware.md.
if compgen -G "$SRC/firmware/local/*" >/dev/null; then
log "Baking in firmware from firmware/local/"
mkdir -p "$ROOTFS/usr/lib/firmware/updates"
rsync -a --exclude=.gitkeep "$SRC/firmware/local/" \
"$ROOTFS/usr/lib/firmware/updates/"
find "$ROOTFS/usr/lib/firmware/updates" -type f -printf ' %P\n'
fi
# ---------------------------------------------------------------------------
log "Generating identifiers and filesystem tables"
# ---------------------------------------------------------------------------
ROOT_UUID="$(uuidgen)"
BOOT_UUID="$(uuidgen)"
ESP_ID="$(od -An -tx1 -N4 /dev/urandom | tr -d ' \n' | tr 'a-f' 'A-F')"
ESP_UUID="${ESP_ID:0:4}-${ESP_ID:4:4}"
cat > "$ROOTFS/etc/fstab" <<EOF
UUID=${ROOT_UUID} / ext4 defaults 1 1
UUID=${BOOT_UUID} /boot ext4 defaults 1 2
UUID=${ESP_UUID} /boot/efi vfat umask=0077,shortname=winnt 0 2
EOF
# The entry kernel-install wrote during the transaction had an empty options
# line, because nothing told it what the command line should be. That same gap
# would bite on the device: the first `dnf update kernel` would write an entry
# with no root= and no C630 quirks, and the machine would not come back. This is
# where kernel-install looks.
mkdir -p "$ROOTFS/etc/kernel"
printf 'root=UUID=%s ro %s\n' "$ROOT_UUID" "$DEVICE_CMDLINE" \
> "$ROOTFS/etc/kernel/cmdline"
# Empty (not missing) machine-id marks this as a first boot for systemd, which
# then generates a unique one per device rather than cloning the builder's.
: > "$ROOTFS/etc/machine-id"
ln -sf ../run/systemd/resolve/stub-resolv.conf "$ROOTFS/etc/resolv.conf"
echo "$DEVICE_NAME" > "$ROOTFS/etc/hostname"
# SELinux labelling happens later, once the bind mounts are gone — see
# "Labelling the filesystem for SELinux" below.
# ---------------------------------------------------------------------------
log "Configuring the target system"
# ---------------------------------------------------------------------------
bind /proc "$ROOTFS/proc"
bind /sys "$ROOTFS/sys"
bind /dev "$ROOTFS/dev"
bind /dev/pts "$ROOTFS/dev/pts"
chroot "$ROOTFS" useradd -m -G wheel -s /bin/bash "$DEFAULT_USER"
echo "${DEFAULT_USER}:${DEFAULT_PASSWORD}" | chroot "$ROOTFS" chpasswd
chroot "$ROOTFS" chage -d 0 "$DEFAULT_USER" # force a change at first login
chroot "$ROOTFS" passwd -l root
# rmtfs and tqftpserv are both shipped disabled by Fedora. They are inert
# without modem firmware and cost nothing to have running, so enable them here
# rather than leaving a step to rediscover later — onboard WiFi cannot work
# without tqftpserv, since the modem fetches the WLAN firmware over TFTP.
chroot "$ROOTFS" systemctl enable \
c630-growfs.service \
sshd.service \
NetworkManager.service \
systemd-resolved.service \
rmtfs.service \
tqftpserv.service
if [ "$VARIANT" = workstation ]; then
chroot "$ROOTFS" systemctl set-default graphical.target
else
chroot "$ROOTFS" systemctl set-default multi-user.target
fi
# ---------------------------------------------------------------------------
log "Building initramfs for ${KVER}"
# ---------------------------------------------------------------------------
chroot "$ROOTFS" dracut --force --no-hostonly --no-hostonly-cmdline \
"/boot/initramfs-${KVER}.img" "$KVER"
# ---------------------------------------------------------------------------
log "Writing bootloader configuration"
# ---------------------------------------------------------------------------
mkdir -p "$ROOTFS/boot/loader/entries"
# kernel-install already wrote an entry during the dnf transaction, from inside
# the install root — where /boot is an ordinary directory, not a partition. It
# is wrong in three ways: its paths are /boot/vmlinuz-… which resolve nowhere
# once /boot is a filesystem in its own right; its options line is empty, so no
# root= and no command line; and it has no devicetree, so even if it loaded, the
# kernel would not know what machine it was on. Being named after the machine-id
# it also sorts before ours, so GRUB picks it. Delete anything we did not write.
rm -f "$ROOTFS/boot/loader/entries/"*.conf
# The bootstrap BLS entry. Subsequent kernels get theirs from kernel-install,
# with the devicetree line supplied by 95-c630-devicetree.install.
cat > "$ROOTFS/boot/loader/entries/c630-${KVER}.conf" <<EOF
title Fedora Linux ${FEDORA_RELEASE} (${KVER}) — ${DEVICE_DESC}
version ${KVER}
linux /vmlinuz-${KVER}
initrd /initramfs-${KVER}.img
devicetree /dtb-${KVER}/${DEVICE_DTB}
options root=UUID=${ROOT_UUID} ro ${DEVICE_CMDLINE}
grub_users \$grub_users
grub_arg --unrestricted
grub_class fedora
EOF
# A deliberately small grub.cfg. grub2-mkconfig would want to probe the running
# system's block devices, which inside this container describe the builder, not
# the C630. blscfg reads the entries above, so there is nothing else to do.
mkdir -p "$ROOTFS/boot/grub2"
cat > "$ROOTFS/boot/grub2/grub.cfg" <<EOF
set timeout=5
set default=0
insmod part_gpt
insmod ext2
insmod fat
insmod all_video
insmod gzio
insmod blscfg
# blscfg turns each BLS entry into a menuentry that calls load_video before
# loading the kernel. Fedora's generated grub.cfg defines it; a hand-written one
# has to as well, or every entry fails with "can't find command 'load_video'".
function load_video {
if [ x\$feature_all_video_module = xy ]; then
insmod all_video
else
insmod efi_gop
insmod video_bochs
insmod video_cirrus
fi
}
search --no-floppy --fs-uuid --set=root ${BOOT_UUID}
if [ -s \$prefix/grubenv ]; then
load_env
fi
blscfg
EOF
: > "$ROOTFS/boot/grub2/grubenv"
# ESP: GRUB at the removable-media path, because that is the only thing the
# C630's firmware will find on a freshly written USB stick or SD card, and a
# one-line stub telling it where the real configuration lives.
GRUB_EFI="$ROOTFS/boot/efi/EFI/fedora/grubaa64.efi"
[ -f "$GRUB_EFI" ] || { echo "grubaa64.efi missing from the install root" >&2; exit 1; }
mkdir -p "$WORK/esp/EFI/BOOT" "$WORK/esp/EFI/fedora"
cp "$GRUB_EFI" "$WORK/esp/EFI/BOOT/BOOTAA64.EFI"
cp "$GRUB_EFI" "$WORK/esp/EFI/fedora/grubaa64.efi"
if [ -f "$ROOTFS/boot/efi/EFI/fedora/shimaa64.efi" ]; then
cp "$ROOTFS/boot/efi/EFI/fedora/shimaa64.efi" "$WORK/esp/EFI/fedora/"
fi
# grubaa64.efi is built with a compiled-in prefix of /EFI/fedora, so this is
# the file it looks for regardless of which path it was launched from.
cat > "$WORK/esp/EFI/fedora/grub.cfg" <<EOF
search --no-floppy --fs-uuid --set=dev ${BOOT_UUID}
set root=\$dev
set prefix=(\$dev)/grub2
export prefix
configfile \$prefix/grub.cfg
EOF
cp "$WORK/esp/EFI/fedora/grub.cfg" "$WORK/esp/EFI/BOOT/grub.cfg"
# ---------------------------------------------------------------------------
log "Splitting /boot out of the root tree"
# ---------------------------------------------------------------------------
# Non-lazily, so the bind-mounted trees are genuinely gone before mke2fs walks
# the root filesystem — a lazy unmount would let it copy in the builder's /dev.
unbind_all
for d in dev/pts dev sys proc; do
if mountpoint -q "$ROOTFS/$d"; then
echo "error: $ROOTFS/$d is still mounted" >&2
exit 1
fi
done
# ---------------------------------------------------------------------------
log "Labelling the filesystem for SELinux"
# ---------------------------------------------------------------------------
# mke2fs -d builds the filesystem from a directory tree and carries security.*
# xattrs across, but nothing has set them: the tree came out of dnf, not out of
# a running SELinux system. Boot an unlabelled root and systemd cannot set a
# context on anything under /dev, logs a screenful of "Permission denied", and
# dies with "Failed to allocate manager object". /.autorelabel does not rescue
# it — PID 1 never survives long enough to act on the flag.
#
# So label it here, after the bind mounts are gone (or setfiles would walk the
# builder's /proc) and before /boot is split out, so /boot's files are labelled
# with everything else.
FILE_CONTEXTS="$ROOTFS/etc/selinux/targeted/contexts/files/file_contexts"
LABELLED=0
if [ -f "$FILE_CONTEXTS" ] && command -v setfiles >/dev/null; then
if setfiles -F -r "$ROOTFS" "$FILE_CONTEXTS" "$ROOTFS"; then
LABELLED=1
echo "filesystem labelled"
else
echo "warning: setfiles failed — falling back to a first-boot relabel" >&2
fi
else
echo "warning: no SELinux policy or no setfiles in this container" >&2
fi
mkdir -p "$ROOTFS/etc/selinux"
cat > "$ROOTFS/etc/selinux/config" <<EOF
# SELINUXTYPE= can take one of these values: targeted, minimum, mls
SELINUXTYPE=targeted
SELINUX=${SELINUX_MODE}
EOF
if [ "$LABELLED" = 1 ]; then
# Already labelled, so skip the first-boot relabel — it is several minutes
# of USB-speed I/O to reproduce what we just did.
rm -f "$ROOTFS/.autorelabel"
else
: > "$ROOTFS/.autorelabel"
fi
mv "$ROOTFS/boot" "$WORK/boot"
mkdir -p "$ROOTFS/boot"
rm -rf "$WORK/boot/efi"
mkdir -p "$WORK/boot/efi"
# ---------------------------------------------------------------------------
log "Partitioning ${IMAGE_SIZE_MIB} MiB image"
# ---------------------------------------------------------------------------
rm -f "$IMAGE_PATH"
truncate -s "${IMAGE_SIZE_MIB}M" "$IMAGE_PATH"
sgdisk --zap-all "$IMAGE_PATH" >/dev/null
sgdisk \
--new "1:1M:+${ESP_SIZE_MIB}M" --typecode 1:ef00 --change-name 1:ESP \
--new "2:0:+${BOOT_SIZE_MIB}M" --typecode 2:8300 --change-name 2:boot \
--new "3:0:0" --typecode 3:8300 --change-name 3:root \
"$IMAGE_PATH" >/dev/null
sgdisk --print "$IMAGE_PATH"
part_first() { sgdisk --info="$1" "$IMAGE_PATH" | awk '/First sector/ {print $3}'; }
part_last() { sgdisk --info="$1" "$IMAGE_PATH" | awk '/Last sector/ {print $3}'; }
ESP_START=$(part_first 1); ESP_SECTORS=$(( $(part_last 1) - ESP_START + 1 ))
BOOT_START=$(part_first 2); BOOT_SECTORS=$(( $(part_last 2) - BOOT_START + 1 ))
ROOT_START=$(part_first 3); ROOT_SECTORS=$(( $(part_last 3) - ROOT_START + 1 ))
# ---------------------------------------------------------------------------
log "Building filesystems from the staged trees"
# ---------------------------------------------------------------------------
# orphan_file and metadata_csum_seed are recent ext4 features that older GRUB
# builds refuse to read. /boot has to be readable by whatever GRUB the firmware
# ends up running, so keep both filesystems conservative.
EXT4_OPTS="^orphan_file,^metadata_csum_seed"
mkfs.vfat -F 32 -n ESP -i "$ESP_ID" -C "$WORK/esp.img" $(( ESP_SECTORS / 2 )) >/dev/null
mcopy -i "$WORK/esp.img" -s "$WORK/esp/EFI" ::
mke2fs -q -t ext4 -b 4096 -O "$EXT4_OPTS" -L boot -U "$BOOT_UUID" \
-d "$WORK/boot" "$WORK/boot.img" $(( BOOT_SECTORS / 8 ))
# Last chance to catch the build-time initramfs suppression leaking into the
# image. If it shipped, the laptop would boot fine and then fail to come back
# after its next kernel update — far from here, and looking nothing like an
# image bug. Cheap to assert, so assert it.
if [ -e "$ROOTFS/etc/kernel/install.conf" ]; then
echo "error: /etc/kernel/install.conf is about to ship — it disables" >&2
echo " initramfs generation and would brick the next kernel update" >&2
exit 1
fi
mke2fs -q -t ext4 -b 4096 -O "$EXT4_OPTS" -L fedora -U "$ROOT_UUID" \
-d "$ROOTFS" "$WORK/root.img" $(( ROOT_SECTORS / 8 ))
# ---------------------------------------------------------------------------
log "Assembling the disk image"
# ---------------------------------------------------------------------------
dd if="$WORK/esp.img" of="$IMAGE_PATH" bs=512 seek="$ESP_START" conv=notrunc,sparse status=none
dd if="$WORK/boot.img" of="$IMAGE_PATH" bs=512 seek="$BOOT_START" conv=notrunc,sparse status=none
dd if="$WORK/root.img" of="$IMAGE_PATH" bs=512 seek="$ROOT_START" conv=notrunc,sparse status=none
# ---------------------------------------------------------------------------
# Compression is architecture-independent, so running it here runs it under
# emulation — roughly twenty minutes for work the host does in two. When the
# host has zstd, build-image.sh handles it and this just hands over the name.
# ---------------------------------------------------------------------------
printf '%s\n' "$IMAGE_NAME" > "$OUT/.build-result"
if [ "$COMPRESS_IN_CONTAINER" = 1 ]; then
log "Compressing (in-container: the host had no zstd)"
zstd -12 -T0 --rm -f -o "${IMAGE_PATH}.zst" "$IMAGE_PATH"
( cd "$OUT" && sha256sum "${IMAGE_NAME}.img.zst" > "${IMAGE_NAME}.img.zst.sha256" )
fi
# The intermediate filesystem images are multi-gigabyte and worthless once
# they are inside the disk image. The staged base is the opposite: expensive to
# produce and the whole point of the cache, so it always stays.
rm -f "$WORK/esp.img" "$WORK/boot.img" "$WORK/root.img"
rm -rf "$WORK/esp"
if [ "$KEEP_ROOTFS" != "1" ]; then
rm -rf "$ROOTFS" "$WORK/boot"
fi
log "Done"
ls -lh "$OUT"
printf '\nstaged base kept (%s) — the next build reuses it unless the package set changes\n' \
"$(du -sh "$BASE" 2>/dev/null | cut -f1)"