From b44c0b8967e3ece4e567897077907ac7f40919d7 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Fri, 24 Apr 2026 07:45:02 -0400 Subject: [PATCH 1/3] =?UTF-8?q?tools:=20lint/runner-version-freshness.sh?= =?UTF-8?q?=20=E2=80=94=20structural=20enforcement=20for=20Otto-213=20dura?= =?UTF-8?q?ble=20lesson?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Otto-214 implementation of the tooling-level enforcement I proposed Otto-213. Memory-alone was not sufficient to stop the "write a stale version number" recurrence pattern; this script adds a CI-fail gate. Behavior: - Walks .github/workflows/*.yml files - Extracts runs-on: + os: matrix lines - Fails (exit 2) if any line references a STALE runner version (ubuntu-22.04, macos-14, macos-15, windows-2022, ubuntu-20.04, macos-13, macos-15-intel, etc.) - Warns (exit 3) if the allow-list itself is stale (>30 days since LAST_VERIFIED) - Prints the canonical list of ALLOWED labels on failure + the authoritative GitHub docs URL for re-verification Allow-list verified 2026-04-24 via https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job#standard-github-hosted-runners-for-public-repositories exact quote "Use of the standard GitHub-hosted runners is free and unlimited on public repositories." First-run detects 13 stale-label hits across codeql.yml, gate.yml, github-settings-drift.yml (plus stale comment- block references in gate.yml from the pre-correction history). These will be cleaned up by PR #359 for gate.yml; codeql.yml + github-settings-drift.yml need separate follow-up PRs. Does NOT wire into gate.yml automatically — separate step to add the lint check after the baseline is green. Premature enforcement would block every current PR. Sequencing: (1) this PR ships the tool; (2) follow-up PRs clean up existing stale refs (gate.yml already covered by #359; others queued); (3) once baseline is clean, add to gate.yml lint job. Composes with: - Otto-213 version-numbers-require-websearch memory - Otto-212 use-latest-tags + security-hygiene directive - Otto-210/211 macOS-is-free + M1-not-Intel corrections - FACTORY-HYGIENE row #43 safe-pattern compliance - Analogous pattern to audit-cross-platform-parity.sh (detect-only-first, enforce-when-baseline-green) Test plan: - Runs clean when no stale labels present - Exits 2 with clear message when stale labels present - Warns when allow-list >30 days old - Shellcheck clean (SC2001 note acknowledged; the non-bash-4 sed-style substitution is intentional for macOS default-bash-3.x compatibility per FACTORY- HYGIENE row #51 cross-platform parity) - Portable: no mapfile (bash 4+ only); uses while-read loop pattern that works in bash 3.x Co-Authored-By: Claude Opus 4.7 --- tools/lint/runner-version-freshness.sh | 183 +++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 tools/lint/runner-version-freshness.sh diff --git a/tools/lint/runner-version-freshness.sh b/tools/lint/runner-version-freshness.sh new file mode 100755 index 00000000..6b0dbf65 --- /dev/null +++ b/tools/lint/runner-version-freshness.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# tools/lint/runner-version-freshness.sh +# +# Fails CI when a GitHub Actions workflow pins a runner +# to a version-older-than-latest. Otto-213 durable +# compounding-failure mitigation: training-data version +# numbers are stale by definition; structural lint is +# the enforcement mechanism that memory-alone doesn't +# provide. +# +# Allow-list sourced from the authoritative GitHub docs +# page: +# https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job#standard-github-hosted-runners-for-public-repositories +# The "Standard GitHub-hosted runners for public +# repositories" table lists the current standard-runner +# labels. Public-repo-free applies to these labels only. +# +# Allow-list verified: 2026-04-24 via the above URL. +# Refresh cadence: the ALLOWED_LABELS list below has an +# explicit "LAST_VERIFIED" timestamp. If the timestamp +# is >30 days old, the script warns (not fails) reminding +# the operator to re-verify. When GitHub announces a new +# stable runner (macos-27 GA, windows-2028 GA, etc.), +# the allow-list must be updated + LAST_VERIFIED bumped. +# +# Deliberately NOT allowed: older pinned versions +# (ubuntu-22.04, macos-14, macos-15, windows-2022, +# windows-2019, etc.). These were "latest" at some +# prior point but are stale now. Pinning to them creates +# upgrade debt the moment the pin lands. +# +# Usage: +# tools/lint/runner-version-freshness.sh # lint all workflows +# tools/lint/runner-version-freshness.sh ... # lint specific files +# +# Exit codes: +# 0 all runner labels are current +# 1 environment / usage error +# 2 one or more stale labels detected +# 3 allow-list age warning (stale LAST_VERIFIED > 30 days) + +set -euo pipefail + +# Allow-list verified 2026-04-24. Source URL: +# https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job#standard-github-hosted-runners-for-public-repositories +LAST_VERIFIED="2026-04-24" +VERIFY_URL="https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job#standard-github-hosted-runners-for-public-repositories" + +ALLOWED_LABELS=( + # Linux x64 — latest LTS + slim variant + "ubuntu-slim" + "ubuntu-latest" + "ubuntu-24.04" + + # Linux arm64 — no -latest alias exists yet; pin to + # latest specific version available + "ubuntu-24.04-arm" + + # Windows x64 — latest GA + rolling alias + 2025 vs + # vs2026 variant + "windows-latest" + "windows-2025" + "windows-2025-vs2026" + + # Windows arm64 + "windows-11-arm" + + # macOS arm64 (Apple Silicon) — latest GA + rolling + "macos-latest" + "macos-26" + + # macOS Intel — latest GA + "macos-26-intel" + + # Self-hosted labels — not on the standard list but + # allowed-by-convention when the self-hosted pool is + # configured. Add here if needed with comment + # justifying the exception. +) + +STALE_LABELS=( + "ubuntu-22.04" + "ubuntu-22.04-arm" + "ubuntu-20.04" + "macos-14" + "macos-15" + "macos-15-intel" + "macos-13" + "macos-13-xlarge" + "windows-2022" + "windows-2019" +) + +# Warn if allow-list is stale. +_verify_age_ok() { + # Portable: compute days since LAST_VERIFIED on both + # Linux (GNU date) and macOS (BSD date). + local now_epoch last_epoch age_days + now_epoch="$(date -u +%s)" + if date -j -f "%Y-%m-%d" "$LAST_VERIFIED" "+%s" >/dev/null 2>&1; then + last_epoch="$(date -j -f "%Y-%m-%d" "$LAST_VERIFIED" "+%s")" + elif date -d "$LAST_VERIFIED" "+%s" >/dev/null 2>&1; then + last_epoch="$(date -d "$LAST_VERIFIED" "+%s")" + else + echo "WARN: could not parse LAST_VERIFIED=$LAST_VERIFIED on this platform" >&2 + return 0 + fi + age_days=$(( (now_epoch - last_epoch) / 86400 )) + if (( age_days > 30 )); then + echo "WARN: runner-version allow-list last verified $age_days days ago ($LAST_VERIFIED)." >&2 + echo " Re-verify against: $VERIFY_URL" >&2 + echo " Then bump LAST_VERIFIED in this script." >&2 + return 1 + fi + return 0 +} + +# Discover files. +if [ $# -eq 0 ]; then + files=() + while IFS= read -r f; do files+=("$f"); done < <(find .github/workflows -type f \( -name "*.yml" -o -name "*.yaml" \) 2>/dev/null | sort) +else + files=("$@") +fi + +if [ "${#files[@]}" -eq 0 ]; then + echo "no workflow files found; nothing to lint" + exit 0 +fi + +# Build regex alternation of stale labels for one grep. +stale_pattern="$(IFS='|'; echo "${STALE_LABELS[*]}")" + +fail=0 +warn=0 + +for file in "${files[@]}"; do + # Extract lines that look like runner-label references. + # Match: + # runs-on: