Files
c630/build/stage2.sh
rob thijssen 4d1fd98683
Some checks failed
build image / prepare (push) Successful in 0s
build image / build (push) Failing after 29s
Cache the expensive half of the build so iteration is cheap
The dnf transaction is essentially the whole cost of a build — emulated rpm
scriptlets for 502 packages on minimal, 1929 on workstation. Everything after
it is minutes. Getting this laptop to boot will take several attempts at the
kernel command line and the dracut driver list, and paying for a reinstall each
time is not tenable.

Stage the post-dnf tree under <work>/base, keyed on a hash of the package
lists, release and variant, and copy it per build with --reflink=auto (a CoW
clone on btrfs). Config and overlay edits now reuse it; package list edits
invalidate it on their own, so --fresh is only needed to force the issue.

Keep downloaded rpms in a cachedir outside the install root, so even --fresh
re-runs the scriptlets without re-downloading. keepcache=0 was exactly the
wrong setting for a build meant to be run repeatedly.

Add --work so CI can put both outside the job workspace, which is wiped between
runs, and default the build container to the gongfoo aarch64 build base so the
assembly tooling is not installed under emulation every time. That image is a
speedup, not a dependency: fall back to stock Fedora when it is unreachable.

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

390 lines
16 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}"
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"
# ---------------------------------------------------------------------------
# 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 zstd >/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 \
>/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" \
"${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
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 \
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"
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
# 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"
# mke2fs -d does not reliably carry SELinux labels across, and the builder has
# no policy loaded anyway. Relabel on first boot.
: > "$ROOTFS/.autorelabel"
# ---------------------------------------------------------------------------
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
chroot "$ROOTFS" systemctl enable \
c630-growfs.service \
sshd.service \
NetworkManager.service \
systemd-resolved.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"
# ---------------------------------------------------------------------------
# The bootstrap BLS entry. Subsequent kernels get theirs from kernel-install,
# with the devicetree line supplied by 95-c630-devicetree.install.
mkdir -p "$ROOTFS/boot/loader/entries"
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
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
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 ))
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
# ---------------------------------------------------------------------------
log "Compressing"
# ---------------------------------------------------------------------------
zstd -12 -T0 --rm -f -o "${IMAGE_PATH}.zst" "$IMAGE_PATH"
( cd "$OUT" && sha256sum "${IMAGE_NAME}.img.zst" > "${IMAGE_NAME}.img.zst.sha256" )
# 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)"