Files
c630/build/stage2.sh
rob thijssen 280874f564 Build Fedora aarch64 images for the Lenovo Yoga C630
Assembles a ready-to-write disk image via Gitea Actions. Mainline has carried
sdm850-lenovo-yoga-c630.dts since 5.5 and Fedora ships it in kernel-core, so
unlike aarch64-laptops/build there is no kernel or GRUB to compile — what is
left is producing an image that boots on firmware which hands Linux no device
tree.

The build runs in an aarch64 container under qemu-user and builds filesystems
from directory trees with mke2fs -d and mcopy rather than mounting loop
devices, so it works on runners that will not hand out /dev/loop-control.

A kernel-install hook writes the devicetree line into each BLS entry; without
it the first `dnf update kernel` would produce an unbootable system.

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

338 lines
13 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="$OUT/.work"
ROOTFS="$WORK/rootfs"
# shellcheck source=../config/device.env
source "$SRC/config/device.env"
: "${VARIANT:=minimal}"
: "${BUILD_REF:=unknown}"
: "${BUILD_DATE:=unknown}"
: "${KEEP_ROOTFS:=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"); }
# ---------------------------------------------------------------------------
log "Installing build tooling into the container"
# ---------------------------------------------------------------------------
dnf -y install --setopt=install_weak_deps=False \
e2fsprogs dosfstools mtools gdisk util-linux rsync zstd findutils \
>/dev/null
rm -rf "$WORK"
mkdir -p "$ROOTFS" "$WORK/esp"
# ---------------------------------------------------------------------------
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"
# ---------------------------------------------------------------------------
log "Seeding repository configuration into the install root"
# ---------------------------------------------------------------------------
# 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 copies during the transaction.
mkdir -p "$ROOTFS/etc/yum.repos.d" "$ROOTFS/etc/pki/rpm-gpg" "$ROOTFS/etc/dnf"
cp -a /etc/yum.repos.d/. "$ROOTFS/etc/yum.repos.d/"
cp -a /etc/pki/rpm-gpg/. "$ROOTFS/etc/pki/rpm-gpg/"
if [ -d /etc/dnf/vars ]; then cp -a /etc/dnf/vars "$ROOTFS/etc/dnf/"; fi
# ---------------------------------------------------------------------------
log "Installing Fedora ${FEDORA_RELEASE} (${TARGET_ARCH}) — this is the slow part"
# ---------------------------------------------------------------------------
dnf -y \
--installroot="$ROOTFS" \
--releasever="$FEDORA_RELEASE" \
--setopt=keepcache=0 \
--setopt=install_weak_deps=True \
install "${PACKAGES[@]}"
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" )
if [ "$KEEP_ROOTFS" != "1" ]; then
rm -rf "$WORK"
fi
log "Done"
ls -lh "$OUT"