diff --git a/.changeset/watch-debian-base-currency.md b/.changeset/watch-debian-base-currency.md new file mode 100644 index 00000000..c96a168d --- /dev/null +++ b/.changeset/watch-debian-base-currency.md @@ -0,0 +1,16 @@ +--- +"ftw": patch +--- + +Notice when a new Debian stable leaves the container base behind. + +The images pin a codename (`debian:trixie-slim`, `python:3.12-slim-trixie`) +rather than `stable`, so a major-version jump can never arrive silently on a +rebuild. Nothing noticed when a new stable shipped, and Dependabot cannot: it +orders numeric tags, and a suite codename has no numeric component to order. + +A weekly check now reads the pin out of the three Dockerfiles and compares it +with Debian's own `stable` release, failing when the pin falls behind or when +the three images stop agreeing on one suite. + +No runtime behaviour changes. diff --git a/.github/workflows/debian-base-currency.yml b/.github/workflows/debian-base-currency.yml new file mode 100644 index 00000000..21815a86 --- /dev/null +++ b/.github/workflows/debian-base-currency.yml @@ -0,0 +1,100 @@ +name: debian base currency + +# The container images pin a Debian codename (debian:trixie-slim) rather than a +# suite alias, so that a major-version jump can never arrive silently on some +# future rebuild. The cost of pinning is that nothing notices when a new stable +# ships, and a base left behind quietly stops receiving security updates once +# its suite leaves LTS. +# +# This notices. It opens a single issue and keeps editing it, so a suite left +# alone for a year costs one thread rather than fifty-two. + +on: + schedule: + # 07:00 UTC on Mondays. Debian stable ships roughly every two years, so a + # daily check would be noise; weekly still surfaces a new release within + # days of it landing, which is far inside the window that matters. + - cron: "0 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: debian-base-currency + cancel-in-progress: false + +jobs: + currency: + name: pinned base follows Debian stable + # Forks inherit this schedule as soon as they sync master, and the run can + # only misfire there: the tracking issue belongs on srcfl/ftw, and a fork + # with issues disabled turns the Monday check into a failure email for its + # owner. A manual dispatch is someone asking, so that still runs. + if: github.repository == 'srcfl/ftw' || github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Compare the pinned suite against Debian stable + id: compare + run: | + set +e + output="$(bash scripts/check-debian-base.sh 2>&1)" + status=$? + set -e + echo "$output" + { + echo "status=${status}" + echo "report<> "$GITHUB_OUTPUT" + # A network or parse failure must not read as "the base is current", + # and must not open an issue claiming a new release we never saw. + # 1 means a newer stable (or a split pin), 0 means current. + if [ "$status" != "0" ] && [ "$status" != "1" ]; then + echo "::error::the Debian currency check could not run (exit ${status})" + exit "$status" + fi + + - name: Open or update the tracking issue + if: steps.compare.outputs.status == '1' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPORT: ${{ steps.compare.outputs.report }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="Container base is behind Debian stable" + body="$(printf '%s\n\n```\n%s\n```\n\n%s\n\nFrom %s\n\nThis issue is rewritten by each run of the `debian base currency` workflow. Closing it without moving the pin means it comes back next Monday.\n' \ + "The Debian suite pinned by the container images is no longer the current Debian stable, or the three images have drifted apart. Core, updater and optimizer are pinned to one suite on purpose: they share a single base layer, so a host pulls that rootfs once instead of three times." \ + "${REPORT}" \ + "Moving the pin means updating the FROM lines in \`Dockerfile\`, \`Dockerfile.updater\` and \`Dockerfile.optimizer\` together, then rebuilding all three and confirming the optimizer still resolves CVXPY and HiGHS wheels on the new suite. Do not move core alone — that splits the shared layer. If the readiness lines above say a tag is not published yet, wait for it rather than splitting the pin." \ + "${RUN_URL}")" + + existing="$(gh issue list --state open --search "in:title \"${title}\"" \ + --json number,title --jq "[.[] | select(.title == \"${title}\")][0].number")" + + if [ -n "$existing" ] && [ "$existing" != "null" ]; then + gh issue edit "$existing" --body "$body" + echo "updated issue #${existing}" + else + gh issue create --title "$title" --body "$body" + fi + + - name: Close the tracking issue once the pin is current + if: steps.compare.outputs.status == '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + title="Container base is behind Debian stable" + existing="$(gh issue list --state open --search "in:title \"${title}\"" \ + --json number,title --jq "[.[] | select(.title == \"${title}\")][0].number")" + if [ -n "$existing" ] && [ "$existing" != "null" ]; then + gh issue close "$existing" --comment "Every image now pins the current Debian stable. Closed by the \`debian base currency\` workflow." + echo "closed issue #${existing}" + else + echo "base is current and no issue is open" + fi diff --git a/Makefile b/Makefile index 165b045e..c7e69484 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,7 @@ optimizer-test: optimizer/.venv/.installed optimizer/.venv/bin/pytest -q optimizer/tests compose-migration-test: - bash -n scripts/enable-modular-stack.sh scripts/migrate-legacy-compose.sh scripts/install-macos.sh scripts/sync-bundled-drivers.sh scripts/check-driver-versions.sh + bash -n scripts/enable-modular-stack.sh scripts/migrate-legacy-compose.sh scripts/install-macos.sh scripts/sync-bundled-drivers.sh scripts/check-driver-versions.sh scripts/check-debian-base.sh bash scripts/test-modular-compose.sh container-boundary-test: diff --git a/scripts/check-debian-base.sh b/scripts/check-debian-base.sh new file mode 100755 index 00000000..9fb7b963 --- /dev/null +++ b/scripts/check-debian-base.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Reports whether the Debian suite pinned by the container images is still the +# current Debian stable. +# +# The images pin a codename (debian:trixie-slim) rather than a suite alias +# (debian:stable-slim) so a major-version jump can never arrive silently on a +# rebuild. The cost of pinning is that nothing notices when a new stable ships — +# this is what notices. +# +# Truth comes from Debian's own Release file for the `stable` suite, not from +# registry tag listings: tags are noisy, rate-limited and say nothing about +# which suite Debian considers stable. +# +# Exit codes, matching scripts/sync-bundled-drivers.sh --behind: +# 0 pinned suite is current stable +# 1 a newer stable exists, or the Dockerfiles disagree with each other +# 2 the check could not run (network, parse) — never reported as "fine" +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +RELEASE_URL="${DEBIAN_RELEASE_URL:-https://deb.debian.org/debian/dists/stable/Release}" + +# Read the pin out of the Dockerfiles instead of hard-coding it here, so this +# check cannot drift away from what actually ships. +pinned_debian() { sed -n 's/^FROM debian:\([a-z][a-z]*\)-slim.*/\1/p' "$1" | head -1; } +pinned_python() { sed -n 's/^FROM python:[0-9.][0-9.]*-slim-\([a-z][a-z]*\).*/\1/p' "$1" | head -1; } + +core=$(pinned_debian Dockerfile) +updater=$(pinned_debian Dockerfile.updater) +optimizer=$(pinned_python Dockerfile.optimizer) + +for pair in "Dockerfile:$core" "Dockerfile.updater:$updater" "Dockerfile.optimizer:$optimizer"; do + if [ -z "${pair#*:}" ]; then + echo "could not read a Debian suite from ${pair%%:*}" >&2 + exit 2 + fi +done + +echo "pinned suite:" +printf ' %-22s %s\n' "Dockerfile" "$core" "Dockerfile.updater" "$updater" "Dockerfile.optimizer" "$optimizer" + +if [ "$core" != "$updater" ] || [ "$core" != "$optimizer" ]; then + echo "" + echo "The three images no longer agree on one Debian suite. Sharing a single" + echo "base layer is the reason they were aligned, and that benefit is lost" + echo "while they differ." + exit 1 +fi + +release=$(curl -fsSL --max-time 20 "$RELEASE_URL" 2>/dev/null) || { + echo "could not fetch $RELEASE_URL" >&2 + exit 2 +} +stable=$(printf '%s\n' "$release" | sed -n 's/^Codename: *//p' | head -1) +version=$(printf '%s\n' "$release" | sed -n 's/^Version: *//p' | head -1) +if [ -z "$stable" ]; then + echo "no Codename field in $RELEASE_URL" >&2 + exit 2 +fi + +echo "" +echo "debian stable: $stable${version:+ (}${version}${version:+)}" + +if [ "$core" = "$stable" ]; then + echo "" + echo "The pinned suite is current." + exit 0 +fi + +echo "" +echo "A newer Debian stable is available: $core -> $stable" + +# Advisory only. A new Debian stable is tagged in the official images promptly, +# but python:-slim- can lag by days, and moving core without the +# optimizer would split the shared base layer. Never fail the check on this — +# it is a readiness note, not the finding. +if command -v docker >/dev/null 2>&1; then + echo "" + echo "image readiness:" + python_tag=$(sed -n 's/^FROM \(python:[0-9.][0-9.]*\)-slim-[a-z][a-z]*.*/\1/p' Dockerfile.optimizer | head -1) + for image in "debian:${stable}-slim" "${python_tag}-slim-${stable}"; do + if docker manifest inspect "$image" >/dev/null 2>&1; then + printf ' %-32s available\n' "$image" + else + printf ' %-32s NOT PUBLISHED YET\n' "$image" + fi + done +fi + +exit 1