diff --git a/.github/rulesets/README.adoc b/.github/rulesets/README.adoc index e3ee2b90..5b735ca4 100644 --- a/.github/rulesets/README.adoc +++ b/.github/rulesets/README.adoc @@ -158,7 +158,7 @@ Check for drift against live state (needs repository administration read): [source,bash] ---- -GH_TOKEN= python3 scripts/ci/check-ruleset-drift.py +GH_TOKEN= scripts/ci/check-ruleset-drift.sh ---- Exit 0 means no drift, 1 means drift, 2 means the rulesets could not be read. diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 9b8fdae1..ce46a7b3 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -34,7 +34,7 @@ jobs: # Ultraplan Phase 2.3 (tracking issue #416): every continue-on-error unit # needs an owner issue and an unexpired date on the same line. - name: Allow-fail units have an owner and an unexpired date - run: python3 scripts/ci/check-allow-fail-expiry.py + run: bash scripts/ci/check-allow-fail-expiry.sh - name: Verify immutable action lockfile with the authoritative tool env: diff --git a/docs/planning/ULTRAPLAN-2026-10-09.adoc b/docs/planning/ULTRAPLAN-2026-10-09.adoc index 81f8031e..7c5fc444 100644 --- a/docs/planning/ULTRAPLAN-2026-10-09.adoc +++ b/docs/planning/ULTRAPLAN-2026-10-09.adoc @@ -151,7 +151,7 @@ Status as of 2026-10-09, after the owner's first response. Phase 1 verdict) follows. * *Done in the CI hygiene PR:* the nine dead playground workflows moved to `echidna-playground/ci-templates/` (P0.6). All 14 allow-fail units carry an - owner issue and an expiry, enforced by `scripts/ci/check-allow-fail-expiry.py` + owner issue and an expiry, enforced by `scripts/ci/check-allow-fail-expiry.sh` in the workflow linter (Phase 2.3, tracking issue #416). A live ruleset snapshot and a drift check were added (P0.5, partial). * *Owner-held and not yet done:* rotate `VERISIMDB_PAT` (#310); add a @@ -373,7 +373,7 @@ reflects consequence if an outside user relies on the claim. is dated 2026-03-24. `docs/tech-debt-2026-05-26.adoc` says it is superseded and points to a root `DEBT.md` that is not present. The ROADMAP was last revised 2026-06-01. -| Python link checker; file headers +| Scratch link checker (not committed); file headers | *Medium* | L14 diff --git a/scripts/ci/check-allow-fail-expiry.py b/scripts/ci/check-allow-fail-expiry.py deleted file mode 100755 index 80ac7b18..00000000 --- a/scripts/ci/check-allow-fail-expiry.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -"""Fail CI when an allow-fail unit lacks an owner or an expiry, or has expired. - -Every workflow line that sets `continue-on-error` to anything other than the -literal `false` must carry, on the same line: - - # allow-fail: issue=# expires=YYYY-MM-DD - -The checker is deliberately text-based: it needs no YAML library, and it sees -the same literal the runner sees. It does not decide what happens at expiry. -A person does (ultraplan Phase 2.3, tracking issue #416). - -Usage: check-allow-fail-expiry.py [--today YYYY-MM-DD] [--warn-days N] [DIR ...] -""" -import argparse -import datetime as dt -import glob -import os -import re -import sys - -KEY = re.compile(r"^\s*continue-on-error:\s*(?P[^#]*?)\s*(?:#.*)?$") -MARK = re.compile(r"#\s*allow-fail:\s*issue=#(?P\d+)\s+expires=(?P\d{4}-\d{2}-\d{2})\b") - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("dirs", nargs="*", default=[".github/workflows"]) - ap.add_argument("--today", default=None) - ap.add_argument("--warn-days", type=int, default=21) - args = ap.parse_args() - today = dt.date.fromisoformat(args.today) if args.today else dt.date.today() - - errors, warnings, units = [], [], 0 - files = [] - for d in args.dirs: - files += sorted(glob.glob(os.path.join(d, "*.yml")) + glob.glob(os.path.join(d, "*.yaml"))) - - for path in files: - with open(path, encoding="utf-8") as fh: - for lineno, line in enumerate(fh, 1): - m = KEY.match(line.rstrip("\n")) - if not m or m.group("value") == "false": - continue - units += 1 - where = f"{path}:{lineno}" - mk = MARK.search(line) - if not mk: - errors.append(f"{where}: continue-on-error without '# allow-fail: issue=#N expires=YYYY-MM-DD'") - continue - try: - expiry = dt.date.fromisoformat(mk.group("date")) - except ValueError: - errors.append(f"{where}: invalid expiry date {mk.group('date')}") - continue - if expiry < today: - errors.append(f"{where}: allow-fail expired on {expiry} (issue #{mk.group('issue')}); make it blocking, re-justify, or remove") - elif (expiry - today).days <= args.warn_days: - warnings.append(f"{where}: expires {expiry} (issue #{mk.group('issue')})") - - for w in warnings: - print(f"::warning::{w}") - print(f"allow-fail units checked: {units}") - if errors: - for e in errors: - print(f"::error::{e}") - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/check-allow-fail-expiry.sh b/scripts/ci/check-allow-fail-expiry.sh new file mode 100755 index 00000000..9af3bdf4 --- /dev/null +++ b/scripts/ci/check-allow-fail-expiry.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Fail CI when an allow-fail unit lacks an owner or an expiry, or has expired. +# +# Every workflow line that sets `continue-on-error` to anything other than the +# literal `false` must carry, on the same line: +# +# # allow-fail: issue=# expires=YYYY-MM-DD +# +# The checker is deliberately text-based: it needs no YAML library, and it sees +# the same literal the runner sees. It does not decide what happens at expiry. +# A person does (ultraplan Phase 2.3, tracking issue #416). +# +# Usage: check-allow-fail-expiry.sh [--today YYYY-MM-DD] [--warn-days N] [DIR ...] +# +# Exit codes: 0 all units owned and in date, 1 a unit is unowned, undated, +# or expired, 2 bad arguments. + +set -euo pipefail +export TZ=UTC + +today="" +warn_days=21 +dirs=() + +while [ $# -gt 0 ]; do + case "$1" in + --today) [ $# -ge 2 ] || { echo "--today needs a value" >&2; exit 2; }; today="$2"; shift 2 ;; + --warn-days) [ $# -ge 2 ] || { echo "--warn-days needs a value" >&2; exit 2; }; warn_days="$2"; shift 2 ;; + -h|--help) sed -n '3,20p' "$0"; exit 0 ;; + --) shift; dirs+=("$@"); break ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) dirs+=("$1"); shift ;; + esac +done + +if [ ${#dirs[@]} -eq 0 ]; then + dirs=(.github/workflows) +fi +if [ -z "$today" ]; then + today=$(date +%F) +fi +if ! today_s=$(date -d "$today" +%s 2>/dev/null); then + echo "invalid --today: $today" >&2 + exit 2 +fi +if ! [[ $warn_days =~ ^[0-9]+$ ]]; then + echo "--warn-days must be a non-negative integer" >&2 + exit 2 +fi + +shopt -s nullglob +files=() +for d in "${dirs[@]}"; do + for f in "$d"/*.yml "$d"/*.yaml; do + files+=("$f") + done +done + +errors=() +warnings=() +units=0 + +for file in "${files[@]}"; do + lineno=0 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + # Same shape as the old Python KEY pattern: the key must open the line. + [[ $line =~ ^[[:space:]]*continue-on-error:[[:space:]]*(.*)$ ]] || continue + value=$(printf '%s' "${BASH_REMATCH[1]}" | sed -E 's/[[:space:]]*(#.*)?$//') + [ "$value" = "false" ] && continue + + units=$((units + 1)) + where="$file:$lineno" + mark=$(printf '%s' "$line" \ + | grep -oE '#[[:space:]]*allow-fail:[[:space:]]*issue=#[0-9]+[[:space:]]+expires=[0-9]{4}-[0-9]{2}-[0-9]{2}([^[:alnum:]_]|$)' \ + | head -1 || true) + if [ -z "$mark" ]; then + errors+=("$where: continue-on-error without '# allow-fail: issue=#N expires=YYYY-MM-DD'") + continue + fi + issue=$(printf '%s' "$mark" | sed -E 's/.*issue=#([0-9]+).*/\1/') + expiry=$(printf '%s' "$mark" | sed -E 's/.*expires=([0-9]{4}-[0-9]{2}-[0-9]{2}).*/\1/') + if ! expiry_s=$(date -d "$expiry" +%s 2>/dev/null); then + errors+=("$where: invalid expiry date $expiry") + continue + fi + if [ "$expiry_s" -lt "$today_s" ]; then + errors+=("$where: allow-fail expired on $expiry (issue #$issue); make it blocking, re-justify, or remove") + elif [ $(( (expiry_s - today_s) / 86400 )) -le "$warn_days" ]; then + warnings+=("$where: expires $expiry (issue #$issue)") + fi + done < "$file" +done + +for w in ${warnings[@]+"${warnings[@]}"}; do + echo "::warning::$w" +done +echo "allow-fail units checked: $units" +if [ ${#errors[@]} -gt 0 ]; then + for e in "${errors[@]}"; do + echo "::error::$e" + done + exit 1 +fi +exit 0 diff --git a/scripts/ci/check-ruleset-drift.py b/scripts/ci/check-ruleset-drift.py deleted file mode 100755 index 44b1fda3..00000000 --- a/scripts/ci/check-ruleset-drift.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -"""Compare the committed ruleset snapshot with the rulesets GitHub enforces. - -Snapshot files live in .github/rulesets/live/.json, one per ruleset, -keyed by the ruleset `id`. The top-level .github/rulesets/*.json files are -historical payloads and are not compared. - -Reading rulesets needs repository administration read. Run this with a token -that has it (`GH_TOKEN=... python3 scripts/ci/check-ruleset-drift.py`). The -default GITHUB_TOKEN may not be enough in Actions; see the workflow note in -.github/rulesets/README.adoc. Exit codes: 0 no drift, 1 drift, 2 cannot read. - -Regenerate the snapshot after an intentional change, not to silence drift: - python3 scripts/ci/check-ruleset-drift.py --write -""" -import argparse -import glob -import json -import os -import subprocess -import sys - -VOLATILE = {"_links", "created_at", "updated_at", "node_id", "current_user_can_bypass", "source", "source_type"} - - -def normalise(obj): - if isinstance(obj, dict): - return {k: normalise(v) for k, v in obj.items() if k not in VOLATILE} - if isinstance(obj, list): - return [normalise(v) for v in obj] - return obj - - -def gh_json(args): - out = subprocess.run(["gh", "api", *args], check=True, capture_output=True, text=True) - return json.loads(out.stdout) - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--repo", default="hyperpolymath/echidna") - ap.add_argument("--dir", default=".github/rulesets/live") - ap.add_argument("--write", action="store_true") - args = ap.parse_args() - - try: - listing = gh_json([f"repos/{args.repo}/rulesets"]) - except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError) as exc: - print(f"cannot read rulesets for {args.repo}: {exc}", file=sys.stderr) - return 2 - - live = {} - for r in listing: - live[r["id"]] = normalise(gh_json([f"repos/{args.repo}/rulesets/{r['id']}"])) - - if args.write: - os.makedirs(args.dir, exist_ok=True) - for rid, body in live.items(): - name = body["name"].replace("/", "-") - with open(os.path.join(args.dir, f"{name}.json"), "w", encoding="utf-8") as fh: - json.dump(body, fh, indent=2, sort_keys=True) - fh.write("\n") - print(f"wrote {len(live)} ruleset snapshot(s) to {args.dir}") - return 0 - - drift, seen = [], set() - for path in sorted(glob.glob(os.path.join(args.dir, "*.json"))): - with open(path, encoding="utf-8") as fh: - snap = normalise(json.load(fh)) - rid = snap.get("id") - seen.add(rid) - if rid not in live: - drift.append(f"{path}: ruleset {rid} ({snap.get('name')}) is in the snapshot but not enforced") - elif live[rid] != snap: - drift.append(f"{path}: ruleset {rid} ({snap.get('name')}) differs from the enforced state") - for rid, body in live.items(): - if rid not in seen: - drift.append(f"ruleset {rid} ({body.get('name')}) is enforced but has no snapshot") - - for d in drift: - print(f"::error::{d}") - print(f"rulesets enforced: {len(live)}, snapshots: {len(seen)}, drift: {len(drift)}") - return 1 if drift else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/check-ruleset-drift.sh b/scripts/ci/check-ruleset-drift.sh new file mode 100755 index 00000000..34ed6e00 --- /dev/null +++ b/scripts/ci/check-ruleset-drift.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Compare the committed ruleset snapshot with the rulesets GitHub enforces. +# +# Snapshot files live in .github/rulesets/live/.json, one per ruleset, +# keyed by the ruleset `id`. The top-level .github/rulesets/*.json files are +# historical payloads and are not compared. +# +# Reading rulesets needs repository administration read. Run this with a token +# that has it (`GH_TOKEN=... scripts/ci/check-ruleset-drift.sh`). The default +# GITHUB_TOKEN may not be enough in Actions; see the workflow note in +# .github/rulesets/README.adoc. Exit codes: 0 no drift, 1 drift, 2 cannot read. +# +# Regenerate the snapshot after an intentional change, not to silence drift: +# scripts/ci/check-ruleset-drift.sh --write +# +# Requires gh and jq. + +set -euo pipefail + +repo="hyperpolymath/echidna" +dir=".github/rulesets/live" +write=0 + +while [ $# -gt 0 ]; do + case "$1" in + --repo) [ $# -ge 2 ] || { echo "--repo needs a value" >&2; exit 2; }; repo="$2"; shift 2 ;; + --dir) [ $# -ge 2 ] || { echo "--dir needs a value" >&2; exit 2; }; dir="$2"; shift 2 ;; + --write) write=1; shift ;; + -h|--help) sed -n '3,19p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +for tool in gh jq; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "cannot read rulesets for $repo: $tool is not installed" >&2 + exit 2 + fi +done + +# Fields that change without a configuration change. Removed at every depth, +# then keys are sorted, so the comparison is on configuration only. +NORM=' + def norm: + if type == "object" then + with_entries(select(.key as $k | (["_links","created_at","updated_at","node_id","current_user_can_bypass","source","source_type"] | index($k)) == null)) + | map_values(norm) + elif type == "array" then map(norm) + else . end; + norm' + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# Read the enforced rulesets. Any failure here is "cannot read", exit 2. +if ! listing=$(gh api "repos/$repo/rulesets" 2>"$tmp/err"); then + echo "cannot read rulesets for $repo: $(cat "$tmp/err")" >&2 + exit 2 +fi +if ! jq -e 'type == "array"' <<<"$listing" >/dev/null 2>&1; then + echo "cannot read rulesets for $repo: listing is not a JSON array" >&2 + exit 2 +fi + +live_ids=() +while IFS= read -r rid; do + [ -n "$rid" ] || continue + live_ids+=("$rid") + if ! gh api "repos/$repo/rulesets/$rid" 2>"$tmp/err" \ + | jq -S "$NORM" > "$tmp/live-$rid.json" 2>>"$tmp/err"; then + echo "cannot read ruleset $rid for $repo: $(cat "$tmp/err")" >&2 + exit 2 + fi +done < <(jq -r '.[].id' <<<"$listing") + +if [ "$write" -eq 1 ]; then + mkdir -p "$dir" + for rid in ${live_ids[@]+"${live_ids[@]}"}; do + name=$(jq -r '.name' "$tmp/live-$rid.json" | tr '/' '-') + cp "$tmp/live-$rid.json" "$dir/$name.json" + done + echo "wrote ${#live_ids[@]} ruleset snapshot(s) to $dir" + exit 0 +fi + +drift=() +seen=() +shopt -s nullglob +for snap in "$dir"/*.json; do + rid=$(jq -r '.id // empty' "$snap") + name=$(jq -r '.name // ""' "$snap") + seen+=("$rid") + if [ -z "$rid" ] || [ ! -f "$tmp/live-$rid.json" ]; then + drift+=("$snap: ruleset $rid ($name) is in the snapshot but not enforced") + continue + fi + snap_c=$(jq -S -c "$NORM" "$snap") + live_c=$(jq -S -c . "$tmp/live-$rid.json") + if [ "$snap_c" != "$live_c" ]; then + drift+=("$snap: ruleset $rid ($name) differs from the enforced state") + fi +done + +for rid in ${live_ids[@]+"${live_ids[@]}"}; do + found=0 + for s in ${seen[@]+"${seen[@]}"}; do + [ "$s" = "$rid" ] && { found=1; break; } + done + if [ "$found" -eq 0 ]; then + lname=$(jq -r '.name // ""' "$tmp/live-$rid.json") + drift+=("ruleset $rid ($lname) is enforced but has no snapshot") + fi +done + +for d in ${drift[@]+"${drift[@]}"}; do + echo "::error::$d" +done + +uniq_seen=$(printf '%s\n' ${seen[@]+"${seen[@]}"} | sort -u | grep -c . || true) +echo "rulesets enforced: ${#live_ids[@]}, snapshots: $uniq_seen, drift: ${#drift[@]}" +if [ ${#drift[@]} -gt 0 ]; then + exit 1 +fi +exit 0