-
Notifications
You must be signed in to change notification settings - Fork 1
tools: lint/runner-version-freshness.sh — structural enforcement for Otto-213 stale-version lesson #360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
tools: lint/runner-version-freshness.sh — structural enforcement for Otto-213 stale-version lesson #360
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| #!/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 <file>... # 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=( | ||
| # Pinned major-OS-version labels per repo convention — | ||
| # the rolling -latest aliases (ubuntu-latest / | ||
| # windows-latest / macos-latest) are explicitly NOT in | ||
| # the allow-list. Pinned labels make CI reproducibility | ||
| # auditable; rolling aliases silently drift when GitHub | ||
| # rotates the underlying image. | ||
|
|
||
| # Linux x64 | ||
| "ubuntu-slim" | ||
| "ubuntu-24.04" | ||
|
|
||
| # Linux arm64 — pin to latest specific version available | ||
| "ubuntu-24.04-arm" | ||
|
|
||
| # Windows x64 — latest GA + 2025 vs vs2026 variant | ||
| "windows-2025" | ||
| "windows-2025-vs2026" | ||
|
|
||
| # Windows arm64 | ||
| "windows-11-arm" | ||
|
|
||
| # macOS arm64 (Apple Silicon) — latest GA | ||
| "macos-26" | ||
|
|
||
| # macOS Intel — latest GA | ||
| "macos-26-intel" | ||
|
AceHack marked this conversation as resolved.
|
||
|
|
||
| # 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. | ||
| ) | ||
|
|
||
| # Rolling aliases — explicitly forbidden in the repo | ||
| # convention (CI-reproducibility-by-pinning). These are | ||
| # tracked separately from STALE_LABELS so a contributor | ||
| # typing `ubuntu-latest` gets a distinct error from a | ||
| # stale-version error. | ||
| ROLLING_ALIASES=( | ||
| "ubuntu-latest" | ||
| "windows-latest" | ||
| "macos-latest" | ||
| ) | ||
|
|
||
| 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")" | ||
|
AceHack marked this conversation as resolved.
|
||
| 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 | ||
|
AceHack marked this conversation as resolved.
|
||
|
|
||
| # Build regex alternation of stale labels for one grep. | ||
| # Escape regex metachars (. + * ? ( ) [ ] { } | \ /) in | ||
| # labels so `ubuntu-22.04` matches literally, not | ||
| # `ubuntu-22<any-char>04`. | ||
| escape_for_regex() { | ||
| printf '%s' "$1" | sed -e 's#[][\\.*^$+?(){}|/]#\\&#g' | ||
| } | ||
| escaped_stales=() | ||
| for label in "${STALE_LABELS[@]}"; do | ||
| escaped_stales+=("$(escape_for_regex "$label")") | ||
| done | ||
| stale_pattern="$(IFS='|'; echo "${escaped_stales[*]}")" | ||
|
|
||
| # Portable word-boundaries: BSD grep (macOS default) and | ||
| # POSIX ERE do not honor `\b`. Express boundary via | ||
| # explicit non-word character classes that work in both | ||
| # GNU and BSD grep. | ||
| nonword_start='([^A-Za-z0-9_]|^)' | ||
| nonword_end='([^A-Za-z0-9_]|$)' | ||
|
|
||
|
AceHack marked this conversation as resolved.
|
||
| fail=0 | ||
| warn=0 | ||
|
|
||
| for file in "${files[@]}"; do | ||
| # Verify file exists and is readable. Without this, the | ||
| # grep below would silently swallow a missing-file error | ||
| # and report 'ok' for nothing-actually-linted. | ||
| if [ ! -r "$file" ]; then | ||
| echo "ERROR: cannot read $file (does not exist or unreadable)" >&2 | ||
| fail=1 | ||
| continue | ||
|
AceHack marked this conversation as resolved.
|
||
| fi | ||
| # Two-pass YAML comment stripping: | ||
| # 1. Drop full-line comments (first non-whitespace = `#`). | ||
| # 2. Strip trailing comments — anything after a ` #` (with | ||
| # a leading space, the YAML-spec comment-start | ||
| # sentinel) on lines that aren't already comment-only. | ||
| # Conservative: doesn't try to handle `#` inside | ||
| # quoted strings (rare in workflow YAML); tolerates | ||
| # the corner case at the cost of an occasional false | ||
| # positive. | ||
| uncommented="$( | ||
| grep -vE '^[[:space:]]*#' "$file" \ | ||
| | sed -E 's/[[:space:]]+#.*$//' | ||
|
AceHack marked this conversation as resolved.
|
||
| )" | ||
|
AceHack marked this conversation as resolved.
|
||
| # Extract lines that look like runner-label references, | ||
| # then grep for any STALE_LABEL with portable word- | ||
| # boundaries. Matrix-entry prefilter accepts both bare | ||
| # `- <label>` AND quoted `- "<label>"` / `- '<label>'` | ||
| # forms (common YAML matrix syntax). | ||
| matrix_prefix='^[[:space:]]*-[[:space:]]+(['"'"'"]?)' | ||
| matches="$(printf '%s\n' "$uncommented" | grep -nE "runs-on:|(^|[^A-Za-z0-9_])os:|${matrix_prefix}(${stale_pattern})" || true)" | ||
|
AceHack marked this conversation as resolved.
|
||
| hits="$(printf '%s\n' "$matches" | grep -E "${nonword_start}(${stale_pattern})${nonword_end}" || true)" | ||
| if [ -n "$hits" ]; then | ||
| echo "STALE RUNNER LABEL(S) in $file:" | ||
| printf '%s\n' "$hits" | sed 's/^/ /' | ||
| fail=1 | ||
| fi | ||
| # Same scan against rolling-alias forbidden list. | ||
| rolling_pattern="$(IFS='|'; echo "${ROLLING_ALIASES[*]}")" | ||
| rolling_matches="$(printf '%s\n' "$uncommented" | grep -nE "runs-on:|(^|[^A-Za-z0-9_])os:|${matrix_prefix}(${rolling_pattern})" || true)" | ||
| rolling_hits="$(printf '%s\n' "$rolling_matches" | grep -E "${nonword_start}(${rolling_pattern})${nonword_end}" || true)" | ||
| if [ -n "$rolling_hits" ]; then | ||
| echo "ROLLING-ALIAS RUNNER LABEL(S) in $file (use a pinned version per repo convention):" | ||
| printf '%s\n' "$rolling_hits" | sed 's/^/ /' | ||
| fail=1 | ||
| fi | ||
| done | ||
|
|
||
| if _verify_age_ok; then | ||
| : # fresh | ||
| else | ||
| warn=1 | ||
| fi | ||
|
|
||
| if [ "$fail" = "1" ]; then | ||
| echo "" | ||
| echo "One or more workflow files pin stale runner versions." | ||
| echo "Update to the current standard-runner labels. Canonical list:" | ||
| for l in "${ALLOWED_LABELS[@]}"; do | ||
| echo " - $l" | ||
| done | ||
| echo "" | ||
| echo "Source: $VERIFY_URL" | ||
| exit 2 | ||
| fi | ||
|
|
||
| if [ "$warn" = "1" ]; then | ||
| # Header documents the freshness check as warning-only; | ||
| # the warning has already been printed to stderr by | ||
| # _verify_age_ok. Exit 0 so this path doesn't fail CI; | ||
| # operators see the warning and bump LAST_VERIFIED on | ||
| # the next refresh tick. | ||
| exit 0 | ||
|
AceHack marked this conversation as resolved.
|
||
| fi | ||
|
|
||
| echo "ok: all workflow runner labels are current (verified $LAST_VERIFIED)" | ||
| exit 0 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.