From 458e1b67508b9b4361f8d4f15ff71dc2360a0a12 Mon Sep 17 00:00:00 2001 From: Sascha Ittner Date: Mon, 3 Aug 2026 15:35:02 +0200 Subject: [PATCH 1/2] 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/2] 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"