Skip to content

Commit 3f34549

Browse files
ci(governance): R5 generic — config-driven canonical-reference drift (#330)
## Summary Adds a config-driven R5 step to the `security-policy` job in `governance-reusable.yml`. Each caller repo declares its own canonical-reference rules via `.github/canonical-references/*.{yml,yaml}`; the step skips silently when the directory is absent so repos opt in by creating it. Rule file shape: ```yaml id: short-slug description: one-line summary patterns: - 'POSIX-ERE regex 1' - 'POSIX-ERE regex 2' canonical_pointer: path/to/single-source-of-truth.md scope: include: - README.adoc - CLAUDE.md # … ``` POSIX-ERE semantics preserved via `grep -E`; YAML parsing via `python3` + PyYAML (standard on `ubuntu-latest`). Matches emit `::error file=...,line=...::` annotations and fail the job. ## Why Generalises echidna's R5a (bare prover counts → `docs/PROVER_COUNT.md`) so sibling repos with their own canonical-reference cohorts can declare drift patterns without carrying a per-repo workflow file: - **proven**: lemma counts → `PROOF_STATUS.md` - **affinescript**: stdlib fn counts → `STDLIB.md` - **typed-wasm**: proof-obligation counts → `docs/PROOF_COUNT.md` - **ephapax**: rule counts → `RULES.md` R5b (estate-wide hardcoded version-string check) stays separate. The two coexist: R5b is universal, R5 generic covers per-repo patterns. ## Self-tested - ✅ YAML safe_load on the modified workflow - ✅ Embedded heredoc Python compiles via `compile()` (58 lines, no SyntaxError) - ✅ Local exercise on synthetic config + drift docs: `128 prover backends` + `144 ProverKind variants` both fire on line 2; `Tier-1 prover backends` correctly does NOT fire (anchoring preserved); clean doc → 0 hits - ✅ Backward-compat: standards itself has no `.github/canonical-references/` → step exits 0 with `ℹ️ [R5] no .github/canonical-references/ — skipped` ## Promotion roadmap 1. **(this PR)** Generic R5 lands in standards. 2. echidna migrates R5a into `.github/canonical-references/prover-counts.yml` and deletes `governance-doc-drift.yml`. 3. Cohort repos adopt incrementally as their canonical-reference docs stabilise. ## Test plan - [x] YAML safe_load OK - [x] Embedded Python compiles - [x] Negative test (synthetic drift) fires - [x] Positive test (no config) skips silently - [ ] CI on this PR — `governance / Security policy checks` green Refs hyperpolymath/echidna#171, refs #329.
1 parent bf406d6 commit 3f34549

1 file changed

Lines changed: 101 additions & 0 deletions

File tree

.github/workflows/governance-reusable.yml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,107 @@ jobs:
615615
fi
616616
echo "✅ [R5b] Documentation version-string drift: clean."
617617
618+
- name: Canonical-reference drift (R5 generic)
619+
# Repo-local canonical-reference rules. Reads
620+
# `.github/canonical-references/*.{yml,yaml}` in the calling
621+
# repo; each file declares one drift pattern with its
622+
# canonical pointer. Skipped silently when the directory is
623+
# absent — repos opt in by creating it.
624+
#
625+
# Generalises echidna's R5a (bare prover counts → docs/PROVER_COUNT.md)
626+
# so sibling repos with their own canonical-reference cohorts
627+
# (lemma counts for proven, stdlib fn counts for affinescript,
628+
# proof-obligation counts for typed-wasm, rule counts for
629+
# ephapax, …) can declare their drift patterns without
630+
# carrying a per-repo workflow.
631+
#
632+
# Rule file shape:
633+
# id: short-slug
634+
# description: one-line summary
635+
# patterns: [POSIX-ERE strings]
636+
# canonical_pointer: path/to/single-source-of-truth.md
637+
# scope:
638+
# include: [list, of, files, to, scan]
639+
#
640+
# Patterns are POSIX-ERE (run through `grep -E`). Each
641+
# listed include path is scanned line-by-line; a match emits
642+
# a `::error::` annotation and the job fails non-zero.
643+
# CHANGELOG.{md,adoc} and the rule files themselves are
644+
# excluded automatically; the rule's own `canonical_pointer`
645+
# is also excluded so the canonical doc can name what it
646+
# is the canonical answer for.
647+
run: |
648+
set -uo pipefail
649+
DIR=.github/canonical-references
650+
if [ ! -d "$DIR" ]; then
651+
echo "ℹ️ [R5] no $DIR/ — skipped (repo has not opted in)"
652+
exit 0
653+
fi
654+
if ! command -v python3 >/dev/null 2>&1; then
655+
echo "❌ [R5] python3 missing on runner — required for YAML rule parsing"
656+
exit 2
657+
fi
658+
python3 - <<'PY'
659+
import os, sys, glob, subprocess
660+
try:
661+
import yaml
662+
except ImportError:
663+
sys.exit("❌ [R5] PyYAML not installed on runner; install python3-yaml")
664+
665+
dir_ = ".github/canonical-references"
666+
files = sorted(glob.glob(f"{dir_}/*.yml") + glob.glob(f"{dir_}/*.yaml"))
667+
if not files:
668+
print(f"ℹ️ [R5] {dir_}/ has no .yml/.yaml rules — skipped")
669+
sys.exit(0)
670+
671+
total = 0
672+
for rf in files:
673+
with open(rf, encoding="utf-8") as fh:
674+
cfg = yaml.safe_load(fh)
675+
if not isinstance(cfg, dict):
676+
print(f"❌ [R5] {rf}: top-level must be a mapping"); total += 1; continue
677+
rid = cfg.get("id", os.path.basename(rf))
678+
desc = cfg.get("description", "")
679+
pats = cfg.get("patterns") or []
680+
canon = cfg.get("canonical_pointer", "")
681+
scope = (cfg.get("scope") or {})
682+
includes = scope.get("include") or []
683+
if not pats or not includes:
684+
print(f"❌ [R5:{rid}] missing patterns or scope.include in {rf}")
685+
total += 1; continue
686+
# exclude self-references
687+
skip = set(["CHANGELOG.md", "CHANGELOG.adoc", rf])
688+
if canon: skip.add(canon)
689+
rule_hits = 0
690+
for f_ in includes:
691+
if f_ in skip or not os.path.isfile(f_):
692+
continue
693+
for pat in pats:
694+
r = subprocess.run(
695+
["grep", "-nE", pat, f_],
696+
capture_output=True, text=True,
697+
)
698+
if r.returncode == 0:
699+
for line in r.stdout.splitlines():
700+
ln_no, _, body = line.partition(":")
701+
tail = canon or "drop or move to a canonical source"
702+
print(f"::error file={f_},line={ln_no}::"
703+
f"[R5:{rid}] {body} → route to {tail}")
704+
rule_hits += 1
705+
elif r.returncode > 1:
706+
print(f"❌ [R5:{rid}] grep error on {f_}: {r.stderr.strip()}")
707+
rule_hits += 1
708+
if rule_hits:
709+
print(f"❌ [R5:{rid}] {rule_hits} hit(s). {desc}")
710+
total += rule_hits
711+
712+
if total:
713+
print()
714+
print(f"❌ [R5] {total} canonical-reference drift hit(s) across {len(files)} rule(s).")
715+
sys.exit(1)
716+
print(f"✅ [R5] canonical-reference drift: clean across {len(files)} rule(s).")
717+
PY
718+
618719
quality:
619720
name: Code quality + docs
620721
runs-on: ${{ inputs.runs-on }}

0 commit comments

Comments
 (0)