From 85b54daf3d50f0b2dd616faf5f5186397663e1b9 Mon Sep 17 00:00:00 2001 From: Steven Noonan Date: Tue, 14 Jul 2026 14:00:04 -0700 Subject: [PATCH] build: publish a pinned kernel build environment image Kernel builds currently construct their build environment from scratch on every matrix job: a digest-pinned debian:bookworm base, plus build-essential, cross compilers, and supporting tools installed from the Debian archive at build time. The base pin does not pin the toolchain - apt installs whatever bookworm serves that day (gcc-12 is at 12.2.0-14+deb12u1 and counting), so toolchain updates arrive silently on whatever build happens to run first. That matters more once compile caching keys on the compiler binary's content hash: an unplanned gcc update turns a random build into a 100% cache miss. It also costs about 80 seconds of apt work per job, repeated across every job in the matrix. Introduce ghcr.io/edera-dev/kernel-buildenv, built from Dockerfile.buildenv: the same toolchain as today (debian:bookworm + build-essential + cross compilers) plus a pinned, checksum-verified sccache release with compiler wrapper scripts in /usr/lib/sccache, ready for the compile-cache work that consumes this image. A new workflow builds it natively per arch (amd64/arm64), assembles a manifest list tagged latest and YYYYMMDD, and cosign-signs it - on the 1st and 15th of each month, on demand, and on changes to Dockerfile.buildenv. The twice-monthly cadence makes toolchain updates deliberate, dated events instead of continuous drift. The image is self-describing: the last layer bakes a sorted dpkg package manifest (plus the sccache version) into /usr/share/buildenv/packages.tsv, so any two image digests can be diffed without external records. A companion PR workflow uses that to summarize exactly which packages changed whenever a PR bumps the kernel-buildenv digest pin in the kernel Dockerfile (dependabot's docker ecosystem already watches such pins), so version bumps arrive as reviewable diffs rather than opaque digest changes. Nothing consumes the image yet; wiring the kernel Dockerfile to build FROM it happens separately once the first image is published. Signed-off-by: Steven Noonan --- .github/workflows/buildenv-diff.yml | 71 ++++++++++++++++ .github/workflows/buildenv.yml | 124 ++++++++++++++++++++++++++++ Dockerfile.buildenv | 61 ++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 .github/workflows/buildenv-diff.yml create mode 100644 .github/workflows/buildenv.yml create mode 100644 Dockerfile.buildenv diff --git a/.github/workflows/buildenv-diff.yml b/.github/workflows/buildenv-diff.yml new file mode 100644 index 0000000..9c3496e --- /dev/null +++ b/.github/workflows/buildenv-diff.yml @@ -0,0 +1,71 @@ +name: Buildenv Package Diff +on: + # When a PR bumps the kernel-buildenv digest pin (normally dependabot), show + # what actually changed between the two images: each image carries its own + # package manifest at /usr/share/buildenv/packages.tsv, so the diff is just + # two pulls and a compare, posted to the check summary for the reviewer. + pull_request: + paths: + - "Dockerfile" +permissions: + contents: read + packages: read +jobs: + diff: + name: package diff + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - name: docker login ghcr.io + uses: Wandalen/wretry.action@e68c23e6309f2871ca8ae4763e7629b9c258e1ea # v3.8.0 + with: + action: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: | + registry: ghcr.io + username: "${{github.actor}}" + password: "${{secrets.GITHUB_TOKEN}}" + - name: diff package manifests + run: | + extract_ref() { + grep -oE 'ghcr\.io/edera-dev/kernel-buildenv[^[:space:]]*@sha256:[0-9a-f]+' "$1" | head -1 || true + } + git fetch --depth 1 origin "${{ github.base_ref }}" + git show FETCH_HEAD:Dockerfile > Dockerfile.base 2>/dev/null || : > Dockerfile.base + OLD_REF="$(extract_ref Dockerfile.base)" + NEW_REF="$(extract_ref Dockerfile)" + if [ -z "${NEW_REF}" ] || [ "${OLD_REF}" = "${NEW_REF}" ]; then + echo "kernel-buildenv pin unchanged; nothing to diff" + exit 0 + fi + MANIFEST=/usr/share/buildenv/packages.tsv + docker run --rm "${NEW_REF}" cat "${MANIFEST}" > packages-new.tsv + { + echo "## kernel-buildenv package changes" + echo "" + } >> "${GITHUB_STEP_SUMMARY}" + if [ -z "${OLD_REF}" ]; then + { + echo "New pin \`${NEW_REF}\` (no previous pin to diff against);" + echo "$(wc -l < packages-new.tsv) packages in the image." + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + docker run --rm "${OLD_REF}" cat "${MANIFEST}" > packages-old.tsv + { + echo "\`${OLD_REF}\`" + echo "→ \`${NEW_REF}\`" + echo "" + if diff -u packages-old.tsv packages-new.tsv > packages.diff; then + echo "No package version changes (image metadata/base layer only)." + else + echo '```diff' + # Skip the +++/--- header noise; keep only the package changes. + grep -E '^[+-][^+-]' packages.diff + echo '```' + fi + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/buildenv.yml b/.github/workflows/buildenv.yml new file mode 100644 index 0000000..720093a --- /dev/null +++ b/.github/workflows/buildenv.yml @@ -0,0 +1,124 @@ +name: Build Environment Image +on: + # Rebuild on a schedule so toolchain updates land as deliberate, dated image + # releases (which dependabot then proposes as digest bumps in the kernel + # Dockerfile) instead of drifting silently into every kernel build. The + # cadence balances cache stability (each new image is a cold sccache build) + # against sitting too long on Debian toolchain fixes. + schedule: + - cron: "0 0 1,15 * *" + workflow_dispatch: + push: + branches: + - main + paths: + - "Dockerfile.buildenv" + - ".github/workflows/buildenv.yml" +permissions: + contents: read + packages: write + id-token: write +concurrency: + group: "buildenv-publish" +env: + IMAGE: ghcr.io/edera-dev/kernel-buildenv +jobs: + build: + name: "build ${{ matrix.arch }}" + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: "${{ matrix.runner }}" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - name: docker setup buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: docker login ghcr.io + uses: Wandalen/wretry.action@e68c23e6309f2871ca8ae4763e7629b9c258e1ea # v3.8.0 + with: + action: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: | + registry: ghcr.io + username: "${{github.actor}}" + password: "${{secrets.GITHUB_TOKEN}}" + - name: build and push by digest + run: | + docker buildx build \ + -f Dockerfile.buildenv \ + --platform "${{ matrix.platform }}" \ + --metadata-file metadata.json \ + --output "type=image,name=${IMAGE},push-by-digest=true,name-canonical=true,push=true" \ + . + jq -r '."containerimage.digest"' metadata.json > "digest-${{ matrix.arch }}" + - name: extract package manifest + run: | + docker run --rm "${IMAGE}@$(cat digest-${{ matrix.arch }})" \ + cat /usr/share/buildenv/packages.tsv > "packages-${{ matrix.arch }}.tsv" + - name: upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: "buildenv-digest-${{ matrix.arch }}" + path: "digest-${{ matrix.arch }}" + if-no-files-found: error + compression-level: 0 + retention-days: 1 + - name: upload package manifest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: "buildenv-packages-${{ matrix.arch }}" + path: "packages-${{ matrix.arch }}.tsv" + if-no-files-found: error + compression-level: 0 + merge: + name: merge and publish + needs: build + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: docker setup buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: docker login ghcr.io + uses: Wandalen/wretry.action@e68c23e6309f2871ca8ae4763e7629b9c258e1ea # v3.8.0 + with: + action: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: | + registry: ghcr.io + username: "${{github.actor}}" + password: "${{secrets.GITHUB_TOKEN}}" + - name: download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: "buildenv-digest-*" + merge-multiple: true + - name: assemble and tag manifest list + run: | + TAG="$(date -u +%Y%m%d)" + docker buildx imagetools create \ + -t "${IMAGE}:${TAG}" \ + -t "${IMAGE}:latest" \ + "${IMAGE}@$(cat digest-amd64)" \ + "${IMAGE}@$(cat digest-arm64)" + DIGEST="$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{.Manifest.Digest}}')" + cosign sign --yes "${IMAGE}@${DIGEST}" + { + echo "published \`${IMAGE}:${TAG}\`" + echo "" + echo "pin as: \`${IMAGE}:${TAG}@${DIGEST}\`" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/Dockerfile.buildenv b/Dockerfile.buildenv new file mode 100644 index 0000000..fda9588 --- /dev/null +++ b/Dockerfile.buildenv @@ -0,0 +1,61 @@ +# Kernel build environment image, published as ghcr.io/edera-dev/kernel-buildenv. +# +# This exists so the toolchain is a deliberate, reviewed input to kernel builds +# rather than whatever the Debian archive serves on build day: sccache keys +# cache entries on the compiler binary's content hash, so an unplanned gcc +# update silently turns a build into a 100% cache miss. The image is rebuilt on +# a schedule (see .github/workflows/buildenv.yml); the kernel Dockerfile pins +# it by digest and dependabot proposes bumps as reviewable PRs. +FROM debian:bookworm@sha256:ed4fcc40bb1162b6d2d32e7bec15044d13963779abbe63f67f1cd62b06220519 + +RUN export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -y \ + build-essential squashfs-tools python3-yaml \ + patch diffutils sed mawk findutils zstd \ + python3 python3-packaging curl rsync cpio gpg grep \ + flex bison pahole libssl-dev libelf-dev bc kmod && \ + rm -rf /var/lib/apt/lists/* +ARG TARGETARCH +RUN if [ "${TARGETARCH}" = "amd64" ]; then \ + apt-get update && apt-get install -y linux-headers-amd64 g++-aarch64-linux-gnu gcc-aarch64-linux-gnu && rm -rf /var/lib/apt/lists/*; fi +RUN if [ "${TARGETARCH}" = "arm64" ]; then \ + apt-get update && apt-get install -y linux-headers-arm64 g++-x86-64-linux-gnu gcc-x86-64-linux-gnu && rm -rf /var/lib/apt/lists/*; fi + +ARG SCCACHE_VERSION=0.16.0 +ARG SCCACHE_SHA256_AMD64=aec995a83ad3dff3d14b6314e08858b7b73d35ca85a5bcf3d3a9ec07dee35588 +ARG SCCACHE_SHA256_ARM64=f73a5c39f96bb6ebb89cc7915cf182260d4cbf30765322c5e793d0fe8bd80784 +# The wrappers must reference the real compiler by absolute path: /usr/lib/sccache +# is first in PATH, so a bare compiler name would resolve back to the wrapper +# itself and recurse. +RUN case "${TARGETARCH}" in \ + amd64) SCCACHE_ARCH=x86_64 SCCACHE_SHA256="${SCCACHE_SHA256_AMD64}" ;; \ + arm64) SCCACHE_ARCH=aarch64 SCCACHE_SHA256="${SCCACHE_SHA256_ARM64}" ;; \ + *) echo "unsupported TARGETARCH ${TARGETARCH}" >&2; exit 1 ;; \ + esac && \ + SCCACHE_DIST="sccache-v${SCCACHE_VERSION}-${SCCACHE_ARCH}-unknown-linux-musl" && \ + curl -Lf -o /tmp/sccache.tar.gz "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${SCCACHE_DIST}.tar.gz" && \ + echo "${SCCACHE_SHA256} /tmp/sccache.tar.gz" | sha256sum -c - && \ + tar -xz -C /tmp -f /tmp/sccache.tar.gz && \ + install -m 0755 "/tmp/${SCCACHE_DIST}/sccache" /usr/local/bin/sccache && \ + rm -rf /tmp/sccache.tar.gz "/tmp/${SCCACHE_DIST}" && \ + mkdir -p /usr/lib/sccache && \ + for compiler in cc c++ gcc g++ \ + x86_64-linux-gnu-gcc x86_64-linux-gnu-g++ \ + aarch64-linux-gnu-gcc aarch64-linux-gnu-g++; do \ + [ -x "/usr/bin/${compiler}" ] || continue; \ + printf '#!/bin/sh\nexec /usr/local/bin/sccache /usr/bin/%s "$@"\n' "${compiler}" >"/usr/lib/sccache/${compiler}"; \ + chmod 0755 "/usr/lib/sccache/${compiler}"; \ + done +ENV PATH="/usr/lib/sccache:${PATH}" + +RUN useradd -ms /bin/sh build + +# Self-describing package manifest: lets any two image digests be diffed +# without external records (docker run cat /usr/share/buildenv/packages.tsv). +# The buildenv-diff workflow uses this to summarize dependabot digest bumps. +RUN mkdir -p /usr/share/buildenv && \ + { dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\n' | sort; \ + printf 'sccache\t%s\tgithub-release\n' "${SCCACHE_VERSION}"; } \ + >/usr/share/buildenv/packages.tsv + +LABEL org.opencontainers.image.source="https://github.com/edera-dev/linux-kernel-oci" +LABEL org.opencontainers.image.description="Edera kernel build environment (toolchain + sccache)"