From 2155a75334ef4eca246d3a4f10f8f5389cd90734 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:43:39 +0100 Subject: [PATCH] fix(governance): restore the 8 governance jobs stripped in #393 #393 reduced governance-reusable.yml from 9 jobs to 2 (only staleness + hypatia-baseline), but branch-protection across the estate still requires the named governance contexts -> permanent phantom deadlock on every repo. Restores: Language/package anti-pattern policy, Guix primary/Nix fallback policy, Security policy checks, Code quality + docs, Well-Known (RFC 9116 + RSR), Workflow security linter, Trusted-base reduction policy, Licence consistency. Keeps the newer workflow-staleness + hypatia-baseline jobs. Stale actions/checkout v6.0.3 pins bumped to v7.0.0. actionlint clean. These contexts were already required-but-not-emitted (all repos blocked); re-emitting them can only unblock compliant repos and surface real drift. --- .github/workflows/governance-reusable.yml | 787 ++++++++++++++++++++++ 1 file changed, 787 insertions(+) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 482e0e4d..60d1ce11 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -4,6 +4,12 @@ name: Governance Reusable Workflow on: workflow_call: + inputs: + runs-on: + description: Runner label for all governance jobs + type: string + required: false + default: ubuntu-latest permissions: contents: read @@ -91,3 +97,784 @@ jobs: exit 1 fi echo "Baseline validation successful. No new findings." + language-policy: + name: Language / package anti-pattern policy + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + + # Estate language policy bans Python with no exceptions (CLAUDE.md + # Language Policy; SaltStack exception removed 2026-01-03). The + # previous in-line `python3 << PYEOF` heredoc made this very gate a + # self-referential violation — same structural class as the CSA001 + # self-loop fixed in hypatia#328. Eradicated by porting the logic + # to a Deno script that lives in this standards repo. + # + # Implementation note: a reusable workflow only auto-checks-out its + # YAML, not sibling files in its repo. So we explicitly check out + # this repo into `.standards-checkout/`, then run the script from + # there. We pin to `main` because `github.workflow_sha` resolves to + # the caller repo's commit SHA (not standards'), which makes the + # fetch fail with exit 128 ("No commit found for SHA"). Caller + # repos already pin the reusable's YAML by SHA, so the bounded + # drift is just whatever's on standards/main between the reusable + # version and the script version — acceptable since scripts here + # are read-only governance checks. + - name: Set up Deno + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Check out standards repo for shared scripts + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: hyperpolymath/standards + ref: main + path: .standards-checkout + # Sparse-checkout only the scripts dir to keep this fast. + sparse-checkout: | + scripts + sparse-checkout-cone-mode: false + + - name: Check for TypeScript + # Read-only execution; never writes outside the runner workspace. + # `--no-lock` so an empty / stale / missing `deno.lock` doesn't fail + # `deno run` before the file-walker even starts — the script does not + # import anything, so the lockfile is irrelevant to its execution. + # See standards#294. + # + # Runs the AffineScript-compiled `.deno.js` (source of truth: + # `scripts/check-ts-allowlist.affine`). The .ts archetype is kept + # alongside for the regression suite (`scripts/tests/check-ts- + # allowlist-test.sh`) and for parallel-validation during the + # TS→AffineScript migration (standards#239 / #241). Retirement of + # the .ts is a separate follow-up after the dual-target window. + run: deno run --allow-read --no-lock .standards-checkout/scripts/check-ts-allowlist.deno.js + + - name: check-ts-allowlist source/compile drift (informational) + # Non-blocking — informational until the AffineScript compiler + # output is hash-pinned per compiler version. The compiler header + # currently stamps "Generated by AffineScript compiler" which is + # a moving target as the codegen evolves, so spurious diff = + # "compiler bumped" vs real diff = "someone edited .affine + # without recompiling". Promotion to blocking is gated on a + # compiler-version pin landing (see standards#312). + continue-on-error: true + run: | + if ! command -v affinescript >/dev/null 2>&1; then + echo "::notice::affinescript compiler unavailable on runner — skipping drift check" + exit 0 + fi + tmp="$(mktemp /tmp/check-ts-allowlist-drift.XXXXXX.deno.js)" + if ! affinescript compile --deno-esm -o "$tmp" .standards-checkout/scripts/check-ts-allowlist.affine; then + echo "::warning::affinescript compile failed — drift check skipped" + rm -f "$tmp" + exit 0 + fi + if diff -u .standards-checkout/scripts/check-ts-allowlist.deno.js "$tmp"; then + echo "✅ check-ts-allowlist .affine source and .deno.js compiled output are in sync" + else + echo "::warning::check-ts-allowlist.deno.js drifted from check-ts-allowlist.affine — re-run \`just check-ts-allowlist-drift\` locally and recommit the .deno.js" + fi + rm -f "$tmp" + + # Shared escape hatch for the banned-language-file checks below. + # Honours three exemption mechanisms (see + # standards/docs/EXEMPTION-MECHANISMS.adoc): + # 1. `.hypatia-baseline.json` — array of acknowledged findings, + # shape mirrors the Hypatia findings themselves. Schema is + # `.machine_readable/hypatia-baseline.schema.json` in this + # repo. The validate-baseline job above schema-checks this. + # Added 2026-05-25 as part of the convergence on a single + # exemption mechanism. + # 2. `.hypatia-ignore` flat-file — legacy single-rule-per-line + # format (`cicd_rules/banned_language_file:`). + # Will be retired in a follow-up PR once .hypatia-baseline.json + # is in active use across the estate. + # 3. Inline `# hypatia:ignore ...` pragma in the file's first + # 8 lines — the same escape the Hypatia scanner itself + # honours. + - name: Check banned-language files (ReScript / Go / Python / Java / Kotlin / Swift / Dart / V-lang / ATS2 / Makefile) + run: | + rule_module="cicd_rules" + rule_type="banned_language_file" + rule="${rule_module}/${rule_type}" + + # Baseline lookup: returns 0 (exempt) if the file appears in + # .hypatia-baseline.json with a matching rule_module + type. + # Honours both `file` (exact) and `file_pattern` (glob) entries. + # The glob → regex translation mirrors apply-baseline.sh exactly: + # `**` matches any depth (incl. `/`); `*` matches one segment. + # Pattern support unblocks language-demo repos (absolute-zero + # carries ~30 banned-language example files under `examples/`) + # and any repo that vendors such subtrees, replacing per-file + # `.hypatia-ignore` enumeration with one `file_pattern` entry. + in_baseline() { + local target="$1" + [ -f .hypatia-baseline.json ] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + # Note: the `as $pat` capture is essential — inside `test(...)` + # the dot rebinds to test's input ($f, a string), so + # `.file_pattern` would error with "Cannot index string". We + # capture file_pattern in $pat first, then reference it inside + # the test() argument. + jq -e \ + --arg rm "$rule_module" \ + --arg rt "$rule_type" \ + --arg f "$target" \ + 'any(.[]; + .rule_module == $rm and .type == $rt + and ( + (.file? // null) == $f + or ( + (.file_pattern? // null) as $pat + | $pat != null + and ($f | test( + $pat + | gsub("\\*\\*"; "DOUBLESTAR") + | gsub("\\*"; "[^/]*") + | gsub("DOUBLESTAR"; ".*") + | "^" + . + "$" + )) + ) + ))' \ + .hypatia-baseline.json >/dev/null 2>&1 + } + + is_exempt() { + f="${1#./}" + if in_baseline "$f"; then + echo "⏭️ exempt (baseline): $f" + return 0 + fi + if [ -f .hypatia-ignore ] && grep -qxF "${rule}:${f}" .hypatia-ignore; then + return 0 + fi + if head -n 8 "$1" 2>/dev/null | grep -q "hypatia:ignore.*${rule}"; then + return 0 + fi + return 1 + } + + # $1 = human label, $2 = remediation hint, $3 = newline-separated + # candidate paths (possibly empty). Reads via here-string so this + # runs in the current shell — no subshell, exit propagates. + enforce() { + label="$1"; hint="$2"; candidates="$3"; violations="" + while IFS= read -r f; do + [ -z "$f" ] && continue + if is_exempt "$f"; then + echo "⏭️ exempt (${rule}): $f" + continue + fi + violations="${violations}${f} + " + done <<< "$candidates" + if [ -n "$(printf '%s' "$violations" | tr -d '[:space:]')" ]; then + echo "❌ ${label} detected - ${hint}" + printf '%s' "$violations" + echo " (declare an exemption via .hypatia-ignore or an inline" + echo " '# hypatia:ignore ${rule}' pragma if intentional)" + exit 1 + fi + echo "✅ No non-exempt ${label}" + } + + # Use `git ls-files` instead of `find` so only TRACKED files are + # inspected. This respects .gitignore by definition — vendored + # deps in deps/, node_modules/, _build/ etc. are never tracked + # and cannot produce false-positives. Root cause for the switch: + # developer-ecosystem@baab1534 had false-positive governance + # failures because `find` crawled gitignored vendor directories. + # `git ls-files` works correctly on fresh PR checkouts because + # actions/checkout populates the index before running workflows. + RES_FILES=$(git ls-files '*.res' || true) + GO_FILES=$(git ls-files '*.go' || true) + PY_FILES=$(git ls-files '*.py' \ + | grep -v venv | grep -v __pycache__ || true) + MAKE_FILES=$(git ls-files 'Makefile' 'Makefile.*' '*.mk' \ + | grep -v '\.github/' || true) + # Platform-required JVM shims carve-out 2026-06-02: + # Java is permitted only in Android source trees + # (android/**/src/**/*.java) because Android instantiates + # Service/BroadcastReceiver/AppWidgetProvider/Activity classes by + # name at platform boundaries — Rust/Zig cannot provide JVM + # bytecode for these. Each Android Java shim must be a minimal + # delegating wrapper (typically <10 LoC) that JNIs into Rust/Zig + # immediately. Kotlin (*.kt, *.kts) remains banned outright. + JAVA_FILES=$(git ls-files '*.java' '*.kt' '*.kts' \ + | grep -vE '(^|/)android/.*/src/.*\.java$' || true) + SWIFT_FILES=$(git ls-files '*.swift' || true) + DART_FILES=$(git ls-files '*.dart' 'pubspec.yaml' || true) + # V-lang detected by manifest (v.mod / vpkg.json); the .v extension + # collides with Verilog so we never key on it. + VMOD_FILES=$(git ls-files 'v.mod' 'vpkg.json' || true) + # ATS2 source extensions: rejected in favour of Idris2 / Rust/SPARK. + ATS2_FILES=$(git ls-files '*.dats' '*.sats' '*.hats' || true) + + enforce "ReScript files" "use AffineScript instead" "$RES_FILES" + enforce "Go files" "use Rust/WASM instead" "$GO_FILES" + enforce "Python files" "Python is fully banned — use AffineScript/Rust/SPARK/Julia (SaltStack carveout removed 2026-01-03)" "$PY_FILES" + enforce "Makefiles" "use Mustfile/justfile instead" "$MAKE_FILES" + enforce "Java/Kotlin files" "use Rust/Tauri/Dioxus instead" "$JAVA_FILES" + enforce "Swift files" "use Tauri/Dioxus instead" "$SWIFT_FILES" + enforce "Flutter/Dart files" "use Tauri/Dioxus instead (Google lock-in)" "$DART_FILES" + enforce "V-lang manifests (v.mod / vpkg.json)" "V-lang is banned since 2026-04-10 — migrate to Zig" "$VMOD_FILES" + enforce "ATS2 files (.dats / .sats / .hats)" "use Idris2 or Rust/SPARK instead" "$ATS2_FILES" + + - name: Check for npm/bun artifacts + # standards#67 — npm-avoidant: package-lock.json must never be tracked + # estate-wide. Check recursively (not just root) to catch monorepo + # sub-packages. See docs/JS-RUNTIME-POLICY.adoc. + run: | + LOCK_FILES=$(git ls-files 'package-lock.json' '**/package-lock.json' 2>/dev/null || true) + BUN_FILES=$(find . -name "bun.lockb" -not -path "./.git/*" 2>/dev/null || true) + YARN_FILES=$(find . -name "yarn.lock" -not -path "./.git/*" 2>/dev/null || true) + NPMRC_FILES=$(find . -name ".npmrc" -not -path "./.git/*" 2>/dev/null || true) + FAILED="" + if [ -n "$LOCK_FILES" ]; then + echo "❌ Tracked package-lock.json detected (standards#67 — npm-avoidant):" + printf '%s\n' "$LOCK_FILES" + FAILED=1 + fi + if [ -n "$BUN_FILES" ]; then + echo "❌ bun.lockb detected. Use Deno instead." + printf '%s\n' "$BUN_FILES" + FAILED=1 + fi + if [ -n "$YARN_FILES" ]; then + echo "❌ yarn.lock detected. Use Deno instead." + printf '%s\n' "$YARN_FILES" + FAILED=1 + fi + if [ -n "$NPMRC_FILES" ]; then + echo "❌ .npmrc detected. Use Deno deno.json for JS config." + printf '%s\n' "$NPMRC_FILES" + FAILED=1 + fi + # Root package.json with runtime "dependencies" — moved here from + # the now-deleted language-policy.yml. devDependencies-only is + # tolerated for transitional tooling (e.g., a legacy bundler + # invoked from Justfile), but a `"dependencies"` field + # indicates Node.js runtime use, which Deno replaces. + if [ -f package.json ] && grep -q '"dependencies"[[:space:]]*:[[:space:]]*{[^}]' package.json; then + echo "❌ package.json with runtime \"dependencies\" detected. Use deno.json imports instead." + FAILED=1 + fi + if [ -n "$FAILED" ]; then + echo "See hyperpolymath/standards docs/JS-RUNTIME-POLICY.adoc for remediation." + exit 1 + fi + echo "✅ No npm/bun violations" + + - name: Check for tsconfig / rescript config + # Honours the same `.hypatia-ignore` exemption mechanism as the + # banned-language-files step (standards#72, Explicit-Escape + # Principle). A config file is exempt if either + # * `.hypatia-ignore` contains the exact line + # `cicd_rules/banned_config_file:`, OR + # * the file carries an inline `# hypatia:ignore … banned_config_file` + # pragma in its first 8 lines (works for JSONC-style configs; + # not for strict JSON variants). + # Required so repos that legitimately retain ReScript while in + # mid-migration (per their `.claude/CLAUDE.md`, e.g. verisimdb) + # can declare the exemption explicitly instead of being globally + # blocked. Same pattern as the .res file step above. + run: | + rule="cicd_rules/banned_config_file" + + is_exempt() { + f="$1" + if [ -f .hypatia-ignore ] && grep -qxF "${rule}:${f}" .hypatia-ignore; then + return 0 + fi + if head -n 8 "$f" 2>/dev/null | grep -q "hypatia:ignore.*${rule}"; then + return 0 + fi + return 1 + } + + check_config() { + cfg="$1"; hint="$2" + if [ -f "$cfg" ]; then + if is_exempt "$cfg"; then + echo "⏭️ exempt (${rule}): $cfg" + return 0 + fi + echo "❌ ${cfg} detected - ${hint}" + echo " (declare an exemption via .hypatia-ignore line" + echo " '${rule}:${cfg}' if intentional)" + exit 1 + fi + } + + check_config "tsconfig.json" "use AffineScript instead" + check_config "rescript.json" "use AffineScript config instead" + check_config "bsconfig.json" "use AffineScript config instead" + echo "✅ No non-exempt tsconfig.json / rescript config" + + - name: Summary + run: | + echo "RSR language/package policy passed — allowed: AffineScript, Deno," + echo "WASM, Rust, OCaml, Haskell, Guile/Scheme." + + package-policy: + name: Guix primary / Nix fallback policy + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + - name: Enforce Guix primary / Nix fallback + run: | + HAS_GUIX=$(find . -name "*.scm" -o -name ".guix-channel" -o -name "guix.scm" 2>/dev/null | head -1) + HAS_NIX=$(find . -name "*.nix" 2>/dev/null | head -1) + NEW_LOCKS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E 'package-lock\.json|yarn\.lock|Gemfile\.lock|Pipfile\.lock|poetry\.lock' || true) + if [ -n "$NEW_LOCKS" ]; then + echo "⚠️ Lock files detected. Prefer Guix manifests for reproducibility." + fi + if [ -n "$HAS_GUIX" ]; then + echo "✅ Guix package management detected (primary)" + elif [ -n "$HAS_NIX" ]; then + echo "✅ Nix package management detected (fallback)" + else + echo "ℹ️ Consider adding guix.scm or flake.nix for reproducible builds" + fi + echo "✅ Package policy check passed" + + security-policy: + name: Security policy checks + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + - name: Security checks + run: | + FAILED=false + WEAK_CRYPTO=$(grep -rE 'md5\(|sha1\(' --include="*.py" --include="*.rb" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" . 2>/dev/null | grep -v 'checksum\|cache\|test\|spec' | head -5 || true) + if [ -n "$WEAK_CRYPTO" ]; then + echo "⚠️ Weak crypto (MD5/SHA1) detected. Use SHA256+ for security:" + echo "$WEAK_CRYPTO" + fi + HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) + if [ -n "$HTTP_URLS" ]; then + echo "⚠️ HTTP URLs found. Use HTTPS:" + echo "$HTTP_URLS" + fi + SECRETS=$(grep -rEi '(api_key|apikey|secret_key|password)\s*[=:]\s*["\x27][A-Za-z0-9+/=]{20,}' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.env" . 2>/dev/null | grep -v 'example\|sample\|test\|mock\|placeholder' | head -3 || true) + if [ -n "$SECRETS" ]; then + echo "❌ Potential hardcoded secrets detected!" + FAILED=true + fi + if [ "$FAILED" = true ]; then + exit 1 + fi + echo "✅ Security policy check passed" + - name: SSH-remote policy (token-in-URL detection) + # Estate Remote-URL policy (standards#69 / REMOTE-URL-POLICY.adoc). + # Scans every .git/config present in the checkout tree for token-in-URL + # remotes. Fails hard so a compromised credential cannot silently reach + # a PR or main-branch push. + run: | + found=0 + while IFS= read -r cfg; do + if grep -qE "url[[:space:]]*=[[:space:]]*https://[^[:space:]]*(x-access-token:|:gho_|:ghp_|:ghs_|:github_pat_)" "$cfg" 2>/dev/null; then + echo "❌ token-in-URL remote detected in: $cfg" + grep -E "url[[:space:]]*=" "$cfg" \ + | sed 's/\(x-access-token:\|gho_\|ghp_\|ghs_\|github_pat_\)[^@]*/\1****/g' + found=$((found + 1)) + fi + done < <(find . -name "config" -path "*/.git/config" 2>/dev/null) + if [ "$found" -gt 0 ]; then + echo "" + echo "Remediation: git remote set-url git@github.com:/.git" + echo "Policy: REMOTE-URL-POLICY.adoc — SSH-only remotes; no PAT/token in URL ever." + exit 1 + fi + echo "✅ SSH-remote policy: no token-in-URL remotes detected" + + - name: Tooling version integrity + # Estate Tooling Version Integrity policy (root cause: burble#39). + # Inline + dependency-free so it runs in any caller repo. + # R0 just>=1.19.0 floor (blocking when just present) and R1 + # unversioned family-tool install (blocking) are hard; R4 + # unexplained continue-on-error is advisory-first per the + # documented "advisory now, --strict later" gating doctrine. + run: | + set -uo pipefail + FAMILY='just|must|trust|adjust|bust|dust|intend' + if command -v just >/dev/null 2>&1; then + jv=$(just --version 2>/dev/null | cut -d' ' -f2) + maj=${jv%%.*}; rest=${jv#*.}; min=${rest%%.*} + if [ -z "$jv" ] || ! { [ "${maj:-0}" -gt 1 ] || { [ "${maj:-0}" -eq 1 ] && [ "${min:-0}" -ge 19 ]; }; }; then + echo "❌ [R0] just ${jv:-?} < 1.19.0 — import? unsupported"; exit 1 + fi + echo "✅ [R0] just $jv >= 1.19.0" + else + echo "ℹ️ [R0] just not on PATH — skipped" + fi + R1=0 + if [ -d .github/workflows ]; then + while IFS= read -r hit; do + [ -n "$hit" ] || continue + echo "❌ [R1] unversioned family-tool install: $hit" + R1=$((R1+1)) + done < <(grep -rnE "^[[:space:]]*tool:[[:space:]]*(${FAMILY})[[:space:]]*$" .github/workflows 2>/dev/null || true) + fi + [ "$R1" -gt 0 ] && { echo "❌ [R1] $R1 unversioned family-tool install(s) — pin tool: @"; exit 1; } + echo "✅ Tooling version integrity passed (R1 clean; R4 advisory via standards/tasks/tooling-integrity-lint.sh)" + + - name: Documentation version-string drift (R5b) + # Estate canonical-reference drift policy. Forbids pinned + # `Version: x.y.z` strings in load-bearing top-level docs + # because they reliably go stale and disagree with each other + # (echidna had v1.5.0 in README.adoc, v2.3.0 in CLAUDE.md, + # v2.1.0 in Cargo.toml simultaneously — cleared by echidna#169 + # / #170 / #171). CHANGELOG.md is the canonical release-history + # surface; Cargo.toml's [package].version is the canonical + # semver pin; the git log carries dates. Duplicating either + # in human prose invites drift. + # + # Scope: repo-root *.md + *.adoc only. Sub-tree historical + # docs (docs/handover/, docs/decisions/, docs/releases/, + # audit reports) are owner-managed snapshots and not in + # scope. CHANGELOG.{md,adoc} explicitly skipped. + # + # Companion rule R5a (bare prover counts) is repo-local where + # the count semantics are domain-specific — see + # `echidna/.github/workflows/governance-doc-drift.yml`. + run: | + set -uo pipefail + PATTERN='^[[:space:]]*[*_]{0,2}Version[*_]{0,2}[[:space:]]*[:=][[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' + R5B=0 + shopt -s nullglob + for doc in *.md *.adoc; do + [ -f "$doc" ] || continue + case "$doc" in CHANGELOG.md|CHANGELOG.adoc) continue ;; esac + while IFS= read -r hit; do + [ -n "$hit" ] || continue + echo "❌ [R5b] pinned version string: $doc:$hit" + R5B=$((R5B+1)) + done < <(grep -nE "$PATTERN" "$doc" 2>/dev/null || true) + done + if [ "$R5B" -gt 0 ]; then + echo "" + echo "❌ [R5b] $R5B pinned version-string line(s) in load-bearing docs." + echo "Fix: drop the embedded version; defer to CHANGELOG.md (release" + echo "history) and Cargo.toml's [package].version (semver pin) or the" + echo "equivalent package manifest. Git log carries dates." + exit 1 + fi + echo "✅ [R5b] Documentation version-string drift: clean." + + - name: Canonical-reference drift (R5 generic) + # Repo-local canonical-reference rules. Reads + # `.github/canonical-references/*.{yml,yaml}` in the calling + # repo; each file declares one drift pattern with its + # canonical pointer. Skipped silently when the directory is + # absent — repos opt in by creating it. + # + # Generalises echidna's R5a (bare prover counts → docs/PROVER_COUNT.md) + # so sibling repos with their own canonical-reference cohorts + # (lemma counts for proven, stdlib fn counts for affinescript, + # proof-obligation counts for typed-wasm, rule counts for + # ephapax, …) can declare their drift patterns without + # carrying a per-repo workflow. + # + # Rule file shape: + # id: short-slug + # description: one-line summary + # patterns: [POSIX-ERE strings] + # canonical_pointer: path/to/single-source-of-truth.md + # scope: + # include: [list, of, files, to, scan] + # + # Patterns are POSIX-ERE (run through `grep -E`). Each + # listed include path is scanned line-by-line; a match emits + # a `::error::` annotation and the job fails non-zero. + # CHANGELOG.{md,adoc} and the rule files themselves are + # excluded automatically; the rule's own `canonical_pointer` + # is also excluded so the canonical doc can name what it + # is the canonical answer for. + run: | + set -uo pipefail + DIR=.github/canonical-references + if [ ! -d "$DIR" ]; then + echo "ℹ️ [R5] no $DIR/ — skipped (repo has not opted in)" + exit 0 + fi + if ! command -v python3 >/dev/null 2>&1; then + echo "❌ [R5] python3 missing on runner — required for YAML rule parsing" + exit 2 + fi + python3 - <<'PY' + import os, sys, glob, subprocess + try: + import yaml + except ImportError: + sys.exit("❌ [R5] PyYAML not installed on runner; install python3-yaml") + + dir_ = ".github/canonical-references" + files = sorted(glob.glob(f"{dir_}/*.yml") + glob.glob(f"{dir_}/*.yaml")) + if not files: + print(f"ℹ️ [R5] {dir_}/ has no .yml/.yaml rules — skipped") + sys.exit(0) + + total = 0 + for rf in files: + with open(rf, encoding="utf-8") as fh: + cfg = yaml.safe_load(fh) + if not isinstance(cfg, dict): + print(f"❌ [R5] {rf}: top-level must be a mapping"); total += 1; continue + rid = cfg.get("id", os.path.basename(rf)) + desc = cfg.get("description", "") + pats = cfg.get("patterns") or [] + canon = cfg.get("canonical_pointer", "") + scope = (cfg.get("scope") or {}) + includes = scope.get("include") or [] + if not pats or not includes: + print(f"❌ [R5:{rid}] missing patterns or scope.include in {rf}") + total += 1; continue + # exclude self-references + skip = set(["CHANGELOG.md", "CHANGELOG.adoc", rf]) + if canon: skip.add(canon) + rule_hits = 0 + for f_ in includes: + if f_ in skip or not os.path.isfile(f_): + continue + for pat in pats: + r = subprocess.run( + ["grep", "-nE", pat, f_], + capture_output=True, text=True, + ) + if r.returncode == 0: + for line in r.stdout.splitlines(): + ln_no, _, body = line.partition(":") + tail = canon or "drop or move to a canonical source" + print(f"::error file={f_},line={ln_no}::" + f"[R5:{rid}] {body} → route to {tail}") + rule_hits += 1 + elif r.returncode > 1: + print(f"❌ [R5:{rid}] grep error on {f_}: {r.stderr.strip()}") + rule_hits += 1 + if rule_hits: + print(f"❌ [R5:{rid}] {rule_hits} hit(s). {desc}") + total += rule_hits + + if total: + print() + print(f"❌ [R5] {total} canonical-reference drift hit(s) across {len(files)} rule(s).") + sys.exit(1) + print(f"✅ [R5] canonical-reference drift: clean across {len(files)} rule(s).") + PY + + quality: + name: Code quality + docs + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + - name: Check file permissions + run: | + find . -type f -perm /111 -name "*.sh" | head -10 || true + - name: Check for secrets + uses: trufflesecurity/trufflehog@d411fff7b8879a62509f3fa98c07f247ac089a51 # v3.95.5 + with: + path: ./ + base: ${{ github.event.pull_request.base.sha || github.event.before }} + head: ${{ github.sha }} + # by-design: trufflehog is a best-effort advisory scan; a scanner + # diff/range hiccup must not fail the whole governance gate. The + # blocking secret check is the inline grep in the security job. + # (Tooling Version Integrity Rule 4 — documented soft-gate.) + continue-on-error: true + - name: Check TODO/FIXME + run: | + echo "=== TODOs ===" + grep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.rs" --include="*.res" --include="*.py" --include="*.ex" . | head -20 || echo "None found" + - name: Check for large files + run: | + find . -type f -size +1M -not -path "./.git/*" | head -10 || echo "No large files" + - name: EditorConfig check + uses: editorconfig-checker/action-editorconfig-checker@840e866d93b8e032123c23bac69dece044d4d84c # v2.2.0 + # advisory: formatting hygiene is reported from the reusable estate + # bundle; repos opt into blocking formatter checks locally when ready. + continue-on-error: true + - name: Check documentation + run: | + MISSING="" + [ ! -f "README.md" ] && [ ! -f "README.adoc" ] && MISSING="$MISSING README" + [ ! -f "LICENSE" ] && [ ! -f "LICENSE.txt" ] && [ ! -f "LICENSE.md" ] && MISSING="$MISSING LICENSE" + [ ! -f "CONTRIBUTING.md" ] && [ ! -f "CONTRIBUTING.adoc" ] && MISSING="$MISSING CONTRIBUTING" + if [ -n "$MISSING" ]; then + echo "::warning::Missing docs:$MISSING" + else + echo "✅ Core documentation present" + fi + + wellknown: + name: Well-Known (RFC 9116 + RSR) + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + - name: RFC 9116 security.txt validation + run: | + SECTXT="" + [ -f ".well-known/security.txt" ] && SECTXT=".well-known/security.txt" + [ -f "security.txt" ] && SECTXT="security.txt" + if [ -z "$SECTXT" ]; then + echo "::warning::No security.txt found." + exit 0 + fi + grep -q "^Contact:" "$SECTXT" || { echo "::error::Missing Contact field"; exit 1; } + if ! grep -q "^Expires:" "$SECTXT"; then + echo "::error::Missing Expires field" + exit 1 + fi + EXPIRES=$(grep "^Expires:" "$SECTXT" | cut -d: -f2- | tr -d ' ' | head -1) + if date -d "$EXPIRES" > /dev/null 2>&1; then + DAYS=$(( ($(date -d "$EXPIRES" +%s) - $(date +%s)) / 86400 )) + if [ $DAYS -lt 0 ]; then + echo "::error::security.txt EXPIRED" + exit 1 + elif [ $DAYS -lt 30 ]; then + echo "::warning::security.txt expires in $DAYS days" + else + echo "✅ security.txt valid ($DAYS days)" + fi + fi + - name: RSR well-known compliance + run: | + MISSING="" + [ ! -f ".well-known/security.txt" ] && [ ! -f "security.txt" ] && MISSING="$MISSING security.txt" + [ ! -f ".well-known/ai.txt" ] && MISSING="$MISSING ai.txt" + [ ! -f ".well-known/humans.txt" ] && MISSING="$MISSING humans.txt" + if [ -n "$MISSING" ]; then + echo "::warning::Missing RSR recommended files:$MISSING" + else + echo "✅ RSR well-known compliant" + fi + - name: Mixed content check + run: | + MIXED=$(grep -rE 'src="http://|href="http://' --include="*.html" --include="*.htm" . 2>/dev/null | grep -vE 'localhost|127\.0\.0\.1|example\.com|lol/|node_modules/|third-party/|vendor/' | head -5 || true) + if [ -n "$MIXED" ]; then + echo "::error::Mixed content (HTTP in HTML)" + echo "$MIXED" + exit 1 + fi + echo "✅ No mixed content" + + workflow-lint: + name: Workflow security linter + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + - name: Check SPDX headers + permissions + run: | + failed=0 + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -f "$file" ] || continue + if ! head -1 "$file" | grep -q "^# SPDX-License-Identifier:"; then + echo "ERROR: $file missing SPDX header"; failed=1 + fi + if ! grep -q "^permissions:" "$file"; then + echo "ERROR: $file missing top-level 'permissions:' declaration"; failed=1 + fi + done + [ $failed -eq 1 ] && { echo "Add SPDX header + permissions:"; exit 1; } + echo "All workflows have SPDX headers + permissions" + - name: Check SHA-pinned actions + run: | + unpinned=$(grep -rnE "^[[:space:]]+uses:" .github/workflows/ | \ + grep -v "@[a-f0-9]\{40\}" | \ + grep -v "uses: \./\|uses: docker://\|uses: actions/github-script\|uses: hyperpolymath/standards/" || true) + if [ -n "$unpinned" ]; then + echo "ERROR: Found unpinned actions:" + echo "$unpinned" + exit 1 + fi + echo "All actions are SHA-pinned" + - name: Check for duplicate workflows + run: | + if [ -f .github/workflows/codeql.yml ] && [ -f .github/workflows/codeql-analysis.yml ]; then + echo "ERROR: Duplicate CodeQL workflows found"; exit 1 + fi + echo "No critical duplicates found" + + trusted-base: + name: Trusted-base reduction policy + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout caller repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + path: caller + - name: Checkout standards (for the check script) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: hyperpolymath/standards + ref: main + path: standards + - name: Run trusted-base check on caller repo + run: | + bash standards/scripts/check-trusted-base.sh caller + + licence-consistency: + timeout-minutes: 10 + name: Licence consistency + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + steps: + - name: Checkout caller repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + path: caller + - name: Checkout standards (for the check script) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: hyperpolymath/standards + ref: main + path: standards + - name: Run licence-consistency check on caller repo + run: | + bash standards/scripts/check-licence-consistency.sh caller