From 458e1b67508b9b4361f8d4f15ff71dc2360a0a12 Mon Sep 17 00:00:00 2001 From: Sascha Ittner Date: Mon, 3 Aug 2026 15:35:02 +0200 Subject: [PATCH 1/4] Install the target libc headers for the linux-headers package Both boards compiled for over an hour and then failed in binary-headers: HOSTCC debian/linux-headers-.../scripts/basic/fixdep fixdep.c:92:10: fatal error: sys/types.h: No such file or directory install_kernel_headers in scripts/package/builddeb deliberately overrides CC with -gcc when cross compiling, so the fixdep and modpost shipped in linux-headers are arm64 binaries that run on the Pi. That is the only step in the whole build that compiles userspace code for the target, and it needs libc6-dev-arm64-cross - kernel code is freestanding and never includes libc headers, so an hour of successful kernel compilation says nothing about it. --no-install-recommends kept it off the runner. Add a preflight probe that compiles a trivial program including with the cross toolchain, which reproduces the exact error in about a second and names the missing package. Verified both ways: it passes with a real toolchain and fails with a stub cross gcc. Not a problem, checked while here: the headers package also ships resolve_btfids built for the build machine, but cmd_btf_ko skips BTF generation when vmlinux is absent, which it is in a headers package, so out-of-tree module builds on the Pi never execute it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 2 +- README.md | 10 ++++++++-- scripts/build.sh | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de2be57..872d8dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,7 +99,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential bc bison flex libssl-dev libelf-dev libdw-dev dwarves \ - gcc-aarch64-linux-gnu ccache kmod rsync cpio \ + gcc-aarch64-linux-gnu libc6-dev-arm64-cross ccache kmod rsync cpio \ debhelper dpkg-dev zstd # DEBUG_INFO_BTF needs pahole >= 1.21 pahole --version diff --git a/README.md b/README.md index ecbbca8..a6d295b 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,16 @@ Locally the same build runs with: Local requirements (Debian/Ubuntu): ```sh -sudo apt install gcc-aarch64-linux-gnu bc bison flex libssl-dev libelf-dev \ - dwarves debhelper dpkg-dev kmod cpio rsync zstd ccache +sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross bc bison flex \ + libssl-dev libelf-dev dwarves debhelper dpkg-dev kmod cpio \ + rsync zstd ccache ``` +`libc6-dev-arm64-cross` is easy to miss: kernel code is freestanding and never +includes libc headers, but the `linux-headers` package rebuilds `fixdep` and +`modpost` **for the target** so out-of-tree modules can be built on the Pi, and +that compiles userspace code. `build.sh` probes for it up front. + A `CONFIG_ONLY=1` run needs only the first six plus `dwarves`; the packaging tools are not checked for. Either way `build.sh` verifies what it needs in its first second and names anything missing. diff --git a/scripts/build.sh b/scripts/build.sh index a7a2215..f2c4362 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -118,6 +118,27 @@ if [ "$(printf '%s\n1.21\n' "$pahole_version" | sort -V | head -1)" != "1.21" ]; fi echo " toolchain ok (pahole $pahole_version${CROSS_COMPILE:+, cross $CROSS_COMPILE})" +# The linux-headers package rebuilds fixdep and modpost for the *target* with +# CC=-gcc, so they can run on the Pi (see install_kernel_headers in +# scripts/package/builddeb). That is the only thing here that compiles +# userspace code for the target, so it is the only thing that needs the target +# libc headers - kernel code is freestanding and never includes them. Without +# this probe the mistake only surfaces in binary-headers, an hour in. +if [ "${CONFIG_ONLY:-0}" != "1" ] && [ -n "$CROSS_COMPILE" ]; then + probe="$(mktemp -d)" + printf '#include \nint main(void){return 0;}\n' >"$probe/probe.c" + if ! "${CROSS_COMPILE}gcc" "$probe/probe.c" -o "$probe/probe" 2>"$probe/err"; then + echo "error: ${CROSS_COMPILE}gcc cannot compile a target userspace program:" >&2 + sed 's/^/ /' "$probe/err" >&2 + echo "the linux-headers package needs this; install the target libc headers," \ + "e.g. apt install libc6-dev-arm64-cross" >&2 + rm -rf "$probe" + exit 1 + fi + rm -rf "$probe" + echo " cross toolchain can build target userspace (linux-headers needs it)" +fi + # --- source ----------------------------------------------------------------- # A version number is turned into the exact commit on the matching # rpi-..y branch; branches, tags and SHAs pass through untouched. From a94cedfca703f15b5ddd94edc873ab2a40535647 Mon Sep 17 00:00:00 2001 From: Sascha Ittner Date: Mon, 3 Aug 2026 16:05:40 +0200 Subject: [PATCH 2/4] Add a packaging smoke test, and fix version resolution with it `./scripts/build.sh smoke` builds a tinyconfig kernel through the complete bindeb-pkg path in ~7 minutes cold, ~2 incremental, and then checks what came out: the image package carries vmlinuz and a .ko, and the headers package's fixdep is an AArch64 binary. That last assertion is the one that was worth writing - if it ever regresses, out-of-tree module builds on the Pi break and nothing else in the pipeline notices. It immediately earned its keep by exposing a much worse bug in resolve-kernel-ref.sh. The GitHub commit listing spans the whole reachable history, so with the branch tip now at 6.18.42, v6.18.40 resolved to 6a7ecc25 - an upstream stable commit with none of the Pi patches: no bcm2711_defconfig, no Pi drivers. The v6.18.34 resolution reported earlier was wrong the same way. Only the fast path, where the requested version is still at the branch tip, was ever correct. Resolution now runs over the branch's first-parent history, every ancestor of which is a Pi-branch commit by construction, via a commits-only fetch (--filter=tree:0) and a binary search over the version, and the result must contain bcm2711_defconfig. v6.18.40 now resolves to 1047a719 ("usb: xhci: Partial revert to upstream", 6.18.40, defconfig present) and both boards configure cleanly against it again. The CI gates deliberately run against rpi-6.18.y rather than a pinned version: they check that these scripts still work against the current branch, and it avoids a history fetch in three jobs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 37 ++++++++++ README.md | 34 +++++++-- config/smoke.fragment | 28 ++++++++ scripts/build.sh | 95 +++++++++++++++++++++--- scripts/resolve-kernel-ref.sh | 132 +++++++++++++++++----------------- 5 files changed, 246 insertions(+), 80 deletions(-) create mode 100644 config/smoke.fragment diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 366442b..28ee635 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,5 +57,42 @@ jobs: - name: Check ${{ matrix.target }} configuration env: CONFIG_ONLY: "1" + # The branch tip, not a pinned version: these gates check that our + # scripts still work against current rpi-6.18.y, and it skips the + # history fetch a version lookup would need. + KERNEL_REF: rpi-6.18.y GH_TOKEN: ${{ github.token }} run: ./scripts/build.sh ${{ matrix.target }} + + # Builds a tiny kernel through the *complete* bindeb-pkg path in minutes, so + # packaging breakage is caught here instead of after an hour of compiling in + # build.yml. Deliberately uses the same apt list as the real build. + packaging: + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential bc bison flex libssl-dev libelf-dev libdw-dev dwarves \ + gcc-aarch64-linux-gnu libc6-dev-arm64-cross ccache kmod rsync cpio \ + debhelper dpkg-dev zstd + + - name: Packaging smoke test + env: + KERNEL_REF: rpi-6.18.y + GH_TOKEN: ${{ github.token }} + run: ./scripts/build.sh smoke + + - name: Summarise + if: always() + run: | + { + echo "### packaging smoke" + echo '```' + ls -lh build/dist-smoke/ 2>/dev/null || echo "no packages produced" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index a6d295b..dad9c04 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,21 @@ merges the upstream stable releases into a rolling `rpi-..y` branch. [`scripts/resolve-kernel-ref.sh`](scripts/resolve-kernel-ref.sh) therefore turns `v6.18.40` into the newest commit on `rpi-6.18.y` whose `Makefile` still reports 6.18.40 — i.e. the stable release plus any Pi fixes on top of it, and never the -first commit of 6.18.41. It uses the GitHub API only, no clone, and takes a few -seconds. The workflow resolves once in the `prepare` job so both boards build -the identical commit, and `build.sh` re-asserts the version of the checked-out -tree before configuring, so a tag can never quietly build something else. +first commit of 6.18.41. + +If that version is still at the branch tip the tip is used directly. Otherwise +the search runs over the branch's **first-parent** history, which is the part +that is easy to get wrong: the branch merges upstream stable, so a plain history +walk also sees `Linux 6.18.41` and its ancestors, which report the right version +while containing none of the Pi patches — no `bcm2711_defconfig`, no Pi drivers. +Every first-parent ancestor is a Pi-branch commit by construction, and the +result is checked for `arch/arm64/configs/bcm2711_defconfig` regardless. The +history is fetched commits-only (`--filter=tree:0`), so it costs a few hundred +MB once rather than a full clone. + +The workflow resolves once in the `prepare` job so both boards build the +identical commit, and `build.sh` re-asserts the version of the checked-out tree +before configuring, so a tag can never quietly build something else. Two consequences worth knowing: @@ -108,6 +119,21 @@ pushes go to `build.yml` instead) and takes a few minutes: workflow file. - **config** — `CONFIG_ONLY=1 ./scripts/build.sh` for both boards, so a Kconfig option that silently stops applying is caught without compiling anything. +- **packaging** — `./scripts/build.sh smoke`: a `tinyconfig` kernel taken + through the *complete* `bindeb-pkg` path in a few minutes, so packaging + breakage no longer waits an hour to appear. + +`smoke` is not an RT build and proves nothing about `rt.fragment` — it exists to +exercise the packaging work: modules get installed, stripped, compressed and +`depmod`-ed, the `-dbg` package is generated, and `linux-headers` cross-builds +`fixdep`/`modpost`. It then checks the results: the image package carries +`vmlinuz` and at least one `.ko`, and the headers package's `fixdep` is an +**AArch64** binary — if it were not, out-of-tree module builds on the Pi would +fail, and nothing else in the pipeline would notice. + +```sh +./scripts/build.sh smoke # ~7 min cold, ~2 min incremental +``` ## Installing on the Pi diff --git a/config/smoke.fragment b/config/smoke.fragment new file mode 100644 index 0000000..3ed0043 --- /dev/null +++ b/config/smoke.fragment @@ -0,0 +1,28 @@ +# Fragment for `./scripts/build.sh smoke`: the smallest kernel that still +# exercises every part of the packaging path we depend on. +# +# Applied on top of tinyconfig, so this is NOT an RT configuration and says +# nothing about config/rt.fragment - scripts/verify-config.sh covers that. What +# it has to reproduce is the packaging work: +# +# MODULES module install, strip, xz and depmod inside linux-image +# DEBUG_INFO makes mkdebian emit the -dbg package, as a real build does +# BTF runs pahole over vmlinux and the modules +# a real module so lib/modules//kernel actually contains a .ko +# +# The linux-headers package is built regardless, and with it the cross-compiled +# fixdep/modpost that broke the first real release build. + +CONFIG_MODULES=y +CONFIG_MODULE_UNLOAD=y + +CONFIG_NET=y +CONFIG_NETDEVICES=y +CONFIG_DUMMY=m + +CONFIG_BPF_SYSCALL=y +CONFIG_DEBUG_KERNEL=y +# CONFIG_DEBUG_INFO_NONE is not set +CONFIG_DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT=y +CONFIG_DEBUG_INFO_BTF=y +CONFIG_DEBUG_INFO_BTF_MODULES=y diff --git a/scripts/build.sh b/scripts/build.sh index f2c4362..af2bd13 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -29,10 +29,23 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TARGET="${1:-}" case "$TARGET" in -pi4) DEFAULT_DEFCONFIG=bcm2711_defconfig ;; # BCM2711, 4K pages, CONFIG_LOCALVERSION=-v8 -pi5) DEFAULT_DEFCONFIG=bcm2712_defconfig ;; # BCM2712, 16K pages, CONFIG_LOCALVERSION=-v8-16k +pi4) # BCM2711, 4K pages, CONFIG_LOCALVERSION=-v8 + DEFAULT_DEFCONFIG=bcm2711_defconfig + FRAGMENT="$REPO_ROOT/config/rt.fragment" + DEFAULT_LOCALVERSION=-rt + ;; +pi5) # BCM2712, 16K pages, CONFIG_LOCALVERSION=-v8-16k + DEFAULT_DEFCONFIG=bcm2712_defconfig + FRAGMENT="$REPO_ROOT/config/rt.fragment" + DEFAULT_LOCALVERSION=-rt + ;; +smoke) # tiny kernel, same packaging path - see config/smoke.fragment + DEFAULT_DEFCONFIG=tinyconfig + FRAGMENT="$REPO_ROOT/config/smoke.fragment" + DEFAULT_LOCALVERSION=-smoke + ;; *) - echo "usage: $0 {pi4|pi5}" >&2 + echo "usage: $0 {pi4|pi5|smoke}" >&2 exit 2 ;; esac @@ -40,14 +53,18 @@ esac KERNEL_REPO="${KERNEL_REPO:-https://github.com/raspberrypi/linux.git}" KERNEL_REF="${KERNEL_REF:-v6.18.40}" DEFCONFIG="${DEFCONFIG:-$DEFAULT_DEFCONFIG}" -LOCALVERSION="${LOCALVERSION:--rt}" +LOCALVERSION="${LOCALVERSION:-$DEFAULT_LOCALVERSION}" # dpkg-buildpackage is invoked with -a arm64, so dpkg-checkbuilddeps insists on # arm64 builds of libssl-dev/libdw-dev, which cannot be installed on an amd64 # runner. Everything is compiled here with our own cross toolchain before the # packaging step runs, so the check is skipped rather than satisfied. DPKG_FLAGS="${DPKG_FLAGS:--d}" WORK_DIR="${WORK_DIR:-$REPO_ROOT/build}" -DIST_DIR="${DIST_DIR:-$REPO_ROOT/dist}" +if [ "$TARGET" = smoke ]; then + DIST_DIR="${DIST_DIR:-$REPO_ROOT/build/dist-smoke}" +else + DIST_DIR="${DIST_DIR:-$REPO_ROOT/dist}" +fi JOBS="${JOBS:-$(nproc)}" SRC="$WORK_DIR/linux" @@ -181,13 +198,25 @@ fi step "Kernel version: $KERNEL_VERSION ($KERNEL_SHA), package revision $KDEB_REVISION" # --- configure -------------------------------------------------------------- -step "Configuring ($DEFCONFIG + config/rt.fragment)" +step "Configuring ($DEFCONFIG + ${FRAGMENT#"$REPO_ROOT"/})" mkdir -p "$OBJ" kmake "$DEFCONFIG" -"$SRC/scripts/kconfig/merge_config.sh" -m -O "$OBJ" \ - "$OBJ/.config" "$REPO_ROOT/config/rt.fragment" +"$SRC/scripts/kconfig/merge_config.sh" -m -O "$OBJ" "$OBJ/.config" "$FRAGMENT" kmake olddefconfig -"$REPO_ROOT/scripts/verify-config.sh" "$OBJ/.config" +if [ "$TARGET" = smoke ]; then + # The RT assertions do not apply to a tiny kernel; only the options that + # make the packaging path representative have to survive. + for sym in CONFIG_MODULES CONFIG_DEBUG_INFO_BTF CONFIG_DEBUG_INFO_BTF_MODULES; do + grep -q "^$sym=y" "$OBJ/.config" || + { + echo "error: smoke config lost $sym" >&2 + exit 1 + } + done + echo " smoke config ok (modules + BTF, so module install and -dbg run)" +else + "$REPO_ROOT/scripts/verify-config.sh" "$OBJ/.config" +fi KERNELRELEASE="$(kmake -s kernelrelease | tail -1)" step "Kernel release: $KERNELRELEASE" @@ -216,7 +245,11 @@ fi # -j1 and only inherits parallelism through make's jobserver, so doing the # heavy lifting here keeps the packaging step short whatever happens there. step "Building (-j$JOBS)" -kmake -j"$JOBS" Image modules dtbs +if [ "$TARGET" = smoke ]; then + kmake -j"$JOBS" Image modules # a tiny kernel has no device trees +else + kmake -j"$JOBS" Image modules dtbs +fi step "Packaging (bindeb-pkg)" rm -f "$WORK_DIR"/*.deb "$WORK_DIR"/*.buildinfo "$WORK_DIR"/*.changes @@ -247,5 +280,47 @@ EOF (cd "$DIST_DIR" && sha256sum ./*.deb >"sha256sums-$TARGET.txt") +# --- verify the packages ---------------------------------------------------- +# Cheap assertions on what the .debs actually contain. The headers check is the +# one that matters: those host tools have to be target binaries or out-of-tree +# module builds on the Pi cannot work, and getting that wrong is invisible until +# someone tries it. +step "Verifying packages" +image_deb="$(ls "$DIST_DIR"/linux-image-*_"${KDEB_PKGVERSION}"_arm64.deb)" +headers_deb="$(ls "$DIST_DIR"/linux-headers-*_"${KDEB_PKGVERSION}"_arm64.deb)" + +# Listed once into a variable: piping dpkg-deb into `grep -q` makes grep exit +# early, and the resulting SIGPIPE trips `set -o pipefail`. +image_list="$(dpkg-deb -c "$image_deb")" +grep -q "boot/vmlinuz-$KERNELRELEASE" <<<"$image_list" || + { + echo "error: $image_deb has no boot/vmlinuz-$KERNELRELEASE" >&2 + exit 1 + } +grep -q "lib/modules/$KERNELRELEASE/kernel/.*\.ko" <<<"$image_list" || + { + echo "error: $image_deb ships no kernel modules" >&2 + exit 1 + } +echo " linux-image: kernel and modules present" + +unpack="$(mktemp -d)" +trap 'rm -rf "$unpack"' EXIT +dpkg-deb -x "$headers_deb" "$unpack" +fixdep="$unpack/usr/src/linux-headers-$KERNELRELEASE/scripts/basic/fixdep" +[ -x "$fixdep" ] || { + echo "error: $headers_deb has no scripts/basic/fixdep" >&2 + exit 1 +} +fixdep_machine="$(readelf -h "$fixdep" | sed -n 's/^ *Machine: *//p')" +case "$fixdep_machine" in +*AArch64*) echo " linux-headers: fixdep is $fixdep_machine, runs on the target" ;; +*) + echo "error: $headers_deb ships a $fixdep_machine fixdep;" \ + "out-of-tree module builds on the Pi would fail" >&2 + exit 1 + ;; +esac + step "Done" ls -lh "$DIST_DIR" diff --git a/scripts/resolve-kernel-ref.sh b/scripts/resolve-kernel-ref.sh index 2c5157a..f65a67d 100755 --- a/scripts/resolve-kernel-ref.sh +++ b/scripts/resolve-kernel-ref.sh @@ -1,24 +1,31 @@ #!/usr/bin/env bash # Resolve a kernel version to an exact raspberrypi/linux commit. # -# scripts/resolve-kernel-ref.sh v6.18.40 -> 825dba6c63eeb... -# scripts/resolve-kernel-ref.sh v6.18.40-2 -> 825dba6c63eeb... (same kernel, +# scripts/resolve-kernel-ref.sh v6.18.40 -> 1047a7196e99... +# scripts/resolve-kernel-ref.sh v6.18.40-2 -> 1047a7196e99... (same kernel, # the -2 is a package revision) # scripts/resolve-kernel-ref.sh rpi-6.18.y -> rpi-6.18.y (passed through) # # The Raspberry Pi tree carries no per-version tags - it merges the upstream # stable releases into a rolling rpi-..y branch - so "6.18.40" has to -# be turned into the newest commit on that branch whose Makefile still reports -# 6.18.40. Anything that is not a version number (branch, tag, SHA) is echoed -# back untouched. +# be turned into the newest commit on that branch that still reports 6.18.40. +# Anything that is not a version number (branch, tag, SHA) is echoed back +# untouched. # -# Only the GitHub API is used, no clone. Set GH_TOKEN/GITHUB_TOKEN to lift the -# unauthenticated rate limit. +# The search runs over the branch's FIRST-PARENT history. That matters: a plain +# history walk also sees the merged-in upstream commits, and "Linux 6.18.41" and +# its ancestors report the right version while containing none of the Pi patches +# - no bcm2711_defconfig, no Pi drivers. Every first-parent ancestor of the +# branch is a Pi-branch commit by construction, and the result is checked for +# the marker file below regardless. set -euo pipefail REF="${1:?usage: resolve-kernel-ref.sh }" REPO_SLUG="${REPO_SLUG:-raspberrypi/linux}" -MAX_PAGES="${MAX_PAGES:-5}" +HISTORY_DIR="${HISTORY_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/build/history}" +# Present in the Pi tree, absent upstream: proves the resolved commit is really +# on the vendor branch. +MARKER="${MARKER:-arch/arm64/configs/bcm2711_defconfig}" case "$REF" in v[0-9]*.[0-9]*.[0-9]* | [0-9]*.[0-9]*.[0-9]*) ;; @@ -32,6 +39,7 @@ die() { echo "resolve-kernel-ref: $*" >&2 exit 1 } +note() { echo "resolve-kernel-ref: $*" >&2; } BAD="'$REF' is not a kernel version ..[-]" @@ -47,79 +55,71 @@ case "$VERSION" in esac IFS=. read -r MAJ MIN _SUB extra <<<"$VERSION" -# Reject anything that merely looks like a version (v6.18.40rc1), rather than -# letting a non-numeric sublevel break the comparisons below. case "$MAJ$MIN$_SUB" in '' | *[!0-9]*) die "$BAD" ;; esac [ -z "$extra" ] || die "$BAD" BRANCH="rpi-$MAJ.$MIN.y" -note() { echo "resolve-kernel-ref: $*" >&2; } - -auth_header=() -if [ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]; then - auth_header=(-H "Authorization: Bearer ${GH_TOKEN:-$GITHUB_TOKEN}") -fi -# Version reported by the Makefile at a given commit. -makefile_version() { - curl -fsSL "https://raw.githubusercontent.com/$REPO_SLUG/$1/Makefile" | - awk '/^VERSION =/{v=$3} /^PATCHLEVEL =/{p=$3} /^SUBLEVEL =/{s=$3} - /^EXTRAVERSION/{print v "." p "." s; exit}' +parse_makefile_version() { + awk '/^VERSION =/{v=$3} /^PATCHLEVEL =/{p=$3} /^SUBLEVEL =/{s=$3} + /^EXTRAVERSION/{print v "." p "." s; exit}' } -# " " per Makefile-touching commit, newest first. -makefile_commits() { - curl -fsSL "${auth_header[@]}" \ - "https://api.github.com/repos/$REPO_SLUG/commits?sha=$BRANCH&path=Makefile&per_page=100&page=$1" | - python3 -c ' -import json, sys -for c in json.load(sys.stdin): - parents = c.get("parents") or [{}] - print(c["sha"], parents[0].get("sha", "")) -' -} +REPO_URL="https://github.com/$REPO_SLUG.git" -TIP="$(git ls-remote "https://github.com/$REPO_SLUG.git" "refs/heads/$BRANCH" | cut -f1)" +TIP="$(git ls-remote "$REPO_URL" "refs/heads/$BRANCH" | cut -f1)" [ -n "$TIP" ] || die "branch $BRANCH not found in $REPO_SLUG" -# Fast path: the branch has not moved past the requested version yet. -if [ "$(makefile_version "$TIP")" = "$VERSION" ]; then +TIP_VERSION="$(curl -fsSL "https://raw.githubusercontent.com/$REPO_SLUG/$TIP/Makefile" | + parse_makefile_version)" + +if [ "$TIP_VERSION" = "$VERSION" ]; then note "$VERSION is at the tip of $BRANCH" echo "$TIP" exit 0 fi -# Otherwise walk the Makefile-touching commits from newest to oldest. The first -# one still reporting $VERSION is preceded (in branch order) by the commit that -# bumped the version away from it; the first parent of that bump is the newest -# commit that still has $VERSION - including any Pi fixes on top of the stable -# release. -prev_parent="" -for ((page = 1; page <= MAX_PAGES; page++)); do - commits="$(makefile_commits "$page")" - [ -n "$commits" ] || break - while read -r sha parent; do - [ -n "$sha" ] || continue - seen="$(makefile_version "$sha")" - # History only goes down from here, so stop as soon as we are past the - # requested version instead of walking the whole branch. - IFS=. read -r s_maj s_min s_sub <<<"$seen" - if [ "$s_maj.$s_min" != "$MAJ.$MIN" ] || [ "$s_sub" -lt "$_SUB" ]; then - die "$VERSION does not exist on $BRANCH (history is already at $seen)" - fi - if [ "$seen" = "$VERSION" ]; then - [ -n "$prev_parent" ] || - die "internal error: $VERSION found at the newest Makefile commit but not at the branch tip" - got="$(makefile_version "$prev_parent")" - [ "$got" = "$VERSION" ] || - die "resolved $prev_parent reports $got, expected $VERSION" - note "$VERSION resolved to $prev_parent on $BRANCH" - echo "$prev_parent" - exit 0 - fi - prev_parent="$parent" - done <<<"$commits" +# --- first-parent search ---------------------------------------------------- +note "$BRANCH is at $TIP_VERSION, searching its first-parent history for $VERSION" +mkdir -p "$HISTORY_DIR" +if [ ! -d "$HISTORY_DIR/.git" ]; then + git -C "$HISTORY_DIR" init -q + git -C "$HISTORY_DIR" remote add origin "$REPO_URL" +fi +# Commits only (--filter=tree:0): a few hundred MB instead of a full clone, and +# the handful of Makefiles the search reads are fetched on demand. +git -C "$HISTORY_DIR" fetch -q --filter=tree:0 --no-tags --force origin "$BRANCH" + +COMMITS="$HISTORY_DIR/first-parent.txt" +git -C "$HISTORY_DIR" log --first-parent --format=%H FETCH_HEAD >"$COMMITS" + +version_at() { + git -C "$HISTORY_DIR" show "$1:Makefile" 2>/dev/null | parse_makefile_version +} + +# Versions are non-increasing as the list goes back in time, so binary search +# for the newest commit that is no longer newer than the requested version. +lo=1 +hi="$(wc -l <"$COMMITS")" +while [ "$lo" -lt "$hi" ]; do + mid=$(((lo + hi) / 2)) + v="$(version_at "$(sed -n "${mid}p" "$COMMITS")")" + [ -n "$v" ] || die "cannot read Makefile at first-parent commit $mid" + if [ "$v" != "$VERSION" ] && [ "$(printf '%s\n%s\n' "$v" "$VERSION" | sort -V | tail -1)" = "$v" ]; then + lo=$((mid + 1)) # still newer than what we want + else + hi=$mid + fi done -die "no commit reporting $VERSION found in the last $MAX_PAGES pages of $BRANCH history" +SHA="$(sed -n "${lo}p" "$COMMITS")" +GOT="$(version_at "$SHA")" +[ "$GOT" = "$VERSION" ] || + die "$VERSION does not exist on $BRANCH (nearest is $GOT; the tip is $TIP_VERSION)" + +git -C "$HISTORY_DIR" cat-file -e "$SHA:$MARKER" 2>/dev/null || + die "resolved $SHA has no $MARKER, so it is not a $BRANCH commit" + +note "$VERSION resolved to $SHA on $BRANCH" +echo "$SHA" From 2692e8417d6c121bf13390cc898c521b38bbe8e1 Mon Sep 17 00:00:00 2001 From: Sascha Ittner Date: Mon, 3 Aug 2026 17:09:15 +0200 Subject: [PATCH 3/4] Stop parsing the Makefile through a pipe The v6.18.40-4 build died three seconds in, before any real work, with exit code 23. That is curl's CURLE_WRITE_ERROR: parse_makefile_version was an awk that exits at EXTRAVERSION, line 5, which closes the pipe while curl is still writing the ~70 KB Makefile, and set -o pipefail turns the writer's failure into a failure of the whole script. Whether it bites depends on who wins the pipe-buffer race, which is why it passed here every time - five straight attempts against the real URL - and failed on the runner. The Makefile is now downloaded to a file and read from it, the git probe uses command substitution instead of a pipe, and the parser reads to EOF in an END block. Any one of those three fixes it; all three are cheap. Same class of bug as the dpkg-deb | grep -q in the package verification, so the remaining pipelines were audited too: build.sh's pahole version check (sort | head -1) and ci.yml's shellcheck --version | head -2 are the only other consumers that could exit early, and both are now safe. Everything else pipes into tail, cut, tr or sed without q, which drain their input. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- scripts/build.sh | 2 +- scripts/resolve-kernel-ref.sh | 27 ++++++++++++++++++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28ee635..20676fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends shellcheck } - shellcheck --version | head -2 + shellcheck --version shellcheck scripts/*.sh packaging/*.sh - name: workflows parse diff --git a/scripts/build.sh b/scripts/build.sh index af2bd13..73eb8f9 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -129,7 +129,7 @@ if [ ${#missing[@]} -gt 0 ]; then fi # CONFIG_DEBUG_INFO_BTF requires pahole >= 1.21 (DWARF 5). pahole_version="$(pahole --version | tr -dc '0-9.')" -if [ "$(printf '%s\n1.21\n' "$pahole_version" | sort -V | head -1)" != "1.21" ]; then +if [ "$(printf '%s\n1.21\n' "$pahole_version" | sort -V | sed -n 1p)" != "1.21" ]; then echo "error: pahole $pahole_version is too old for CONFIG_DEBUG_INFO_BTF (need >= 1.21)" >&2 exit 1 fi diff --git a/scripts/resolve-kernel-ref.sh b/scripts/resolve-kernel-ref.sh index f65a67d..3f1d060 100755 --- a/scripts/resolve-kernel-ref.sh +++ b/scripts/resolve-kernel-ref.sh @@ -61,9 +61,20 @@ esac [ -z "$extra" ] || die "$BAD" BRANCH="rpi-$MAJ.$MIN.y" +# Reads from a file or a here-string, never from a pipe, and reads its input to +# the end. An awk that exits at EXTRAVERSION closes the pipe while curl or git +# is still writing the ~70 KB Makefile; the writer then fails (curl exits 23, +# "Failure writing output to destination") and `set -o pipefail` turns that into +# a failure of this whole script, three seconds into a release build. Whether it +# happens at all depends on who wins the pipe-buffer race, so it passed here and +# failed on the runner. parse_makefile_version() { - awk '/^VERSION =/{v=$3} /^PATCHLEVEL =/{p=$3} /^SUBLEVEL =/{s=$3} - /^EXTRAVERSION/{print v "." p "." s; exit}' + awk ' + /^VERSION =/ { if (v == "") v = $3 } + /^PATCHLEVEL =/ { if (p == "") p = $3 } + /^SUBLEVEL =/ { if (s == "") s = $3 } + END { if (v != "" && p != "" && s != "") print v "." p "." s } + ' } REPO_URL="https://github.com/$REPO_SLUG.git" @@ -71,8 +82,13 @@ REPO_URL="https://github.com/$REPO_SLUG.git" TIP="$(git ls-remote "$REPO_URL" "refs/heads/$BRANCH" | cut -f1)" [ -n "$TIP" ] || die "branch $BRANCH not found in $REPO_SLUG" -TIP_VERSION="$(curl -fsSL "https://raw.githubusercontent.com/$REPO_SLUG/$TIP/Makefile" | - parse_makefile_version)" +# Downloaded to a file rather than piped: see parse_makefile_version. +TIP_MAKEFILE="$(mktemp)" +trap 'rm -f "$TIP_MAKEFILE"' EXIT +curl -fsSL "https://raw.githubusercontent.com/$REPO_SLUG/$TIP/Makefile" -o "$TIP_MAKEFILE" || + die "cannot fetch the Makefile at $BRANCH tip $TIP" +TIP_VERSION="$(parse_makefile_version <"$TIP_MAKEFILE")" +[ -n "$TIP_VERSION" ] || die "cannot read the Makefile version at $BRANCH tip $TIP" if [ "$TIP_VERSION" = "$VERSION" ]; then note "$VERSION is at the tip of $BRANCH" @@ -95,7 +111,8 @@ COMMITS="$HISTORY_DIR/first-parent.txt" git -C "$HISTORY_DIR" log --first-parent --format=%H FETCH_HEAD >"$COMMITS" version_at() { - git -C "$HISTORY_DIR" show "$1:Makefile" 2>/dev/null | parse_makefile_version + # Command substitution, not a pipe: git runs to completion either way. + parse_makefile_version <<<"$(git -C "$HISTORY_DIR" show "$1:Makefile" 2>/dev/null)" } # Versions are non-increasing as the list goes back in time, so binary search From c4d363afb4edb3a94dc4baa3f26c8663d4868710 Mon Sep 17 00:00:00 2001 From: Sascha Ittner Date: Mon, 3 Aug 2026 17:13:05 +0200 Subject: [PATCH 4/4] Make the release step boring, and test it The release job is the one step nothing has ever run: it only executes after a successful build, so any mistake in it costs a full build to discover. Its release notes were a multi-line shell string, which put shell quoting rules on markdown backticks for no reason. Notes now go through a quoted heredoc and --notes-file, and re-running a tag edits the existing release instead of silently keeping stale notes. Both branches were executed with gh stubbed out: the create path, the edit path, and the rendered notes. Also report free disk and tree sizes in the build summary, so if the full defconfig ever does run the runner out of space, the evidence is in the job rather than in a guess. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 39 ++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 872d8dc..65cde63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -139,6 +139,11 @@ jobs: echo '```' ls -lh dist/ echo '```' + echo "disk after build:" + echo '```' + df -h / + du -sh build/obj-${{ matrix.target }} build/linux 2>/dev/null || true + echo '```' } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v4 @@ -162,18 +167,34 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.prepare.outputs.release_tag }} + env: + KERNEL_REF: ${{ needs.prepare.outputs.kernel_ref }} + KERNEL_SHA: ${{ needs.prepare.outputs.kernel_sha }} run: | ls -lh dist/ - if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \ - --title "$TAG" \ - --notes "PREEMPT_RT kernel packages built from \`${{ needs.prepare.outputs.kernel_ref }}\` (raspberrypi/linux \`${{ needs.prepare.outputs.kernel_sha }}\`). - Install on the Pi with: - \`\`\` - sudo dpkg -i linux-image-*.deb + + # Written to a file: a multi-line --notes argument means shell quoting + # rules apply to markdown backticks, and this step only ever runs after + # a successful build, so a quoting slip costs a full build to discover. + cat > notes.md <<'NOTES' + PREEMPT_RT kernel packages for Raspberry Pi 4 and 5. + + Install on the Pi: + + ```sh + sudo dpkg -i linux-image-*.deb linux-headers-*.deb sudo ./rpi-boot-install.sh sudo reboot - \`\`\` - \`-v8-rt\` = Pi 4 (BCM2711), \`-v8-16k-rt\` = Pi 5 (BCM2712)." + ``` + + `-v8-rt` = Pi 4 (BCM2711), `-v8-16k-rt` = Pi 5 (BCM2712). + NOTES + printf '\nBuilt from `%s` (raspberrypi/linux `%s`).\n' "$KERNEL_REF" "$KERNEL_SHA" >> notes.md + + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --notes-file notes.md + else + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" --notes-file notes.md fi gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber dist/*