|
| 1 | +# SPDX-License-Identifier: PMPL-1.0 |
| 2 | +# governance-reusable.yml — Reusable estate governance bundle (RSR). |
| 3 | +# |
| 4 | +# This is a `workflow_call` reusable workflow: downstream repos invoke it with |
| 5 | +# ONE `uses:` line instead of carrying ~8 separate governance workflow copies. |
| 6 | +# It consolidates the portable, side-effect-free estate governance checks: |
| 7 | +# |
| 8 | +# language-policy <- rsr-antipattern.yml (superset of npm-bun-blocker, |
| 9 | +# ts-blocker) |
| 10 | +# package-policy <- guix-nix-policy.yml |
| 11 | +# security-policy <- security-policy.yml |
| 12 | +# quality <- quality.yml |
| 13 | +# wellknown <- wellknown-enforcement.yml |
| 14 | +# workflow-lint <- workflow-linter.yml |
| 15 | +# |
| 16 | +# Load-bearing build/security workflows (rust-ci, codeql, dependabot, release, |
| 17 | +# secret-scanner SARIF, scorecard SARIF) are intentionally NOT bundled here — |
| 18 | +# they stay as standalone workflows in the consuming repo. |
| 19 | +# |
| 20 | +# Caller example (single wrapper): |
| 21 | +# jobs: |
| 22 | +# governance: |
| 23 | +# uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@main |
| 24 | + |
| 25 | +name: Estate Governance (reusable) |
| 26 | + |
| 27 | +on: |
| 28 | + workflow_call: |
| 29 | + inputs: |
| 30 | + runs-on: |
| 31 | + description: Runner label for all governance jobs |
| 32 | + type: string |
| 33 | + required: false |
| 34 | + default: ubuntu-latest |
| 35 | + |
| 36 | +permissions: |
| 37 | + contents: read |
| 38 | + |
| 39 | +jobs: |
| 40 | + language-policy: |
| 41 | + name: Language / package anti-pattern policy |
| 42 | + runs-on: ${{ inputs.runs-on }} |
| 43 | + permissions: |
| 44 | + contents: read |
| 45 | + steps: |
| 46 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 47 | + |
| 48 | + - name: Check for TypeScript |
| 49 | + run: | |
| 50 | + python3 << 'PYEOF' |
| 51 | + import re, sys, pathlib |
| 52 | +
|
| 53 | + DIR_NAMES_ALLOWED = { |
| 54 | + 'bindings', 'tests', 'test', 'scripts', |
| 55 | + 'mcp-adapter', 'cli', 'vendor', 'examples', 'ffi', |
| 56 | + 'node_modules', 'benchmarks', |
| 57 | + } |
| 58 | +
|
| 59 | + def builtin_allowed(p): |
| 60 | + if p.endswith('.d.ts'): |
| 61 | + return True |
| 62 | + base = p.rsplit('/', 1)[-1] |
| 63 | + if base == 'mod.ts': |
| 64 | + return True |
| 65 | + if base in ('lsp-server.ts', 'lsp_server.ts', 'lsp.ts') or base.endswith('-lsp.ts'): |
| 66 | + return True |
| 67 | + if base.endswith('.bench.ts') or base.endswith('_bench.ts'): |
| 68 | + return True |
| 69 | + segs = p.split('/') |
| 70 | + for s in segs[:-1]: |
| 71 | + if s in DIR_NAMES_ALLOWED: |
| 72 | + return True |
| 73 | + if 'vscode' in s: |
| 74 | + return True |
| 75 | + if s.startswith('deno-'): |
| 76 | + return True |
| 77 | + return False |
| 78 | +
|
| 79 | + def glob_to_regex(g): |
| 80 | + out = [] |
| 81 | + for c in g.lstrip('./'): |
| 82 | + if c == '*': out.append('.*') |
| 83 | + elif c == '?': out.append('.') |
| 84 | + elif c in '.+(){}[]|^$\\': out.append(re.escape(c)) |
| 85 | + else: out.append(c) |
| 86 | + return re.compile('^' + ''.join(out) + '$') |
| 87 | +
|
| 88 | + exemption_patterns = [] |
| 89 | + claude_md = pathlib.Path('.claude/CLAUDE.md') |
| 90 | + if claude_md.exists(): |
| 91 | + in_table = False |
| 92 | + for line in claude_md.read_text(encoding='utf-8').splitlines(): |
| 93 | + if re.search(r'TypeScript [Ee]xemptions', line): |
| 94 | + in_table = True |
| 95 | + continue |
| 96 | + if in_table and line.startswith(('### ', '## ', '# ')): |
| 97 | + break |
| 98 | + if in_table and line.startswith('|'): |
| 99 | + m = re.match(r'\|\s*`([^`]+)`', line) |
| 100 | + if m: |
| 101 | + exemption_patterns.append((m.group(1), glob_to_regex(m.group(1)))) |
| 102 | +
|
| 103 | + def exempt(p): |
| 104 | + for raw, regex in exemption_patterns: |
| 105 | + if regex.match(p): |
| 106 | + return True |
| 107 | + if p == raw.lstrip('./'): |
| 108 | + return True |
| 109 | + if raw.endswith('/') and p.startswith(raw.lstrip('./')): |
| 110 | + return True |
| 111 | + return False |
| 112 | +
|
| 113 | + found = [] |
| 114 | + for ext in ('ts', 'tsx'): |
| 115 | + for p in pathlib.Path('.').rglob(f'*.{ext}'): |
| 116 | + parts = p.parts |
| 117 | + if any(part.startswith('.') and part not in ('.', '..') for part in parts): |
| 118 | + continue |
| 119 | + found.append(p.as_posix().lstrip('./')) |
| 120 | +
|
| 121 | + bad = sorted(f for f in found if not (builtin_allowed(f) or exempt(f))) |
| 122 | + if bad: |
| 123 | + print("❌ TypeScript files detected outside the allowlist.\n") |
| 124 | + for f in bad: |
| 125 | + print(f" {f}") |
| 126 | + print() |
| 127 | + print("To resolve, choose one:") |
| 128 | + print(" (a) migrate the file to AffineScript") |
| 129 | + print(" (b) move to an allowlisted bridge path") |
| 130 | + print(" (c) add an entry to the 'TypeScript Exemptions' table in .claude/CLAUDE.md") |
| 131 | + if exemption_patterns: |
| 132 | + print(f"\n(Currently {len(exemption_patterns)} exemption(s) parsed from .claude/CLAUDE.md.)") |
| 133 | + sys.exit(1) |
| 134 | + print(f"✅ No TypeScript files outside allowlist ({len(exemption_patterns)} per-repo exemption(s) parsed).") |
| 135 | + PYEOF |
| 136 | +
|
| 137 | + - name: Check for ReScript |
| 138 | + run: | |
| 139 | + RES_FILES=$(find . -name "*.res" | grep -v node_modules || true) |
| 140 | + if [ -n "$RES_FILES" ]; then |
| 141 | + echo "❌ ReScript files detected - use AffineScript instead" |
| 142 | + echo "$RES_FILES" |
| 143 | + exit 1 |
| 144 | + fi |
| 145 | + echo "✅ No ReScript files" |
| 146 | +
|
| 147 | + - name: Check for Go |
| 148 | + run: | |
| 149 | + if find . -name "*.go" | grep -q .; then |
| 150 | + echo "❌ Go files detected - use Rust/WASM instead" |
| 151 | + find . -name "*.go" |
| 152 | + exit 1 |
| 153 | + fi |
| 154 | + echo "✅ No Go files" |
| 155 | +
|
| 156 | + - name: Check for Python (non-SaltStack) |
| 157 | + run: | |
| 158 | + PY_FILES=$(find . -name "*.py" | grep -v salt | grep -v _states | grep -v _modules | grep -v pillar | grep -v venv | grep -v __pycache__ || true) |
| 159 | + if [ -n "$PY_FILES" ]; then |
| 160 | + echo "❌ Python files detected - only allowed for SaltStack" |
| 161 | + echo "$PY_FILES" |
| 162 | + exit 1 |
| 163 | + fi |
| 164 | + echo "✅ No non-SaltStack Python files" |
| 165 | +
|
| 166 | + - name: Check for npm/bun artifacts |
| 167 | + run: | |
| 168 | + if [ -f "package-lock.json" ] || [ -f "bun.lockb" ] || [ -f ".npmrc" ] || [ -f "yarn.lock" ]; then |
| 169 | + echo "❌ npm/bun/yarn artifacts detected. Use Deno instead." |
| 170 | + exit 1 |
| 171 | + fi |
| 172 | + echo "✅ No npm/bun violations" |
| 173 | +
|
| 174 | + - name: Check for tsconfig / rescript config |
| 175 | + run: | |
| 176 | + if [ -f "tsconfig.json" ]; then |
| 177 | + echo "❌ tsconfig.json detected - use AffineScript instead" |
| 178 | + exit 1 |
| 179 | + fi |
| 180 | + if [ -f "rescript.json" ] || [ -f "bsconfig.json" ]; then |
| 181 | + echo "❌ rescript.json/bsconfig.json detected - use AffineScript config instead" |
| 182 | + exit 1 |
| 183 | + fi |
| 184 | + echo "✅ No tsconfig.json / rescript config" |
| 185 | +
|
| 186 | + - name: Summary |
| 187 | + run: | |
| 188 | + echo "RSR language/package policy passed — allowed: AffineScript, Deno," |
| 189 | + echo "WASM, Rust, OCaml, Haskell, Guile/Scheme, SaltStack (Python)." |
| 190 | +
|
| 191 | + package-policy: |
| 192 | + name: Guix primary / Nix fallback policy |
| 193 | + runs-on: ${{ inputs.runs-on }} |
| 194 | + permissions: |
| 195 | + contents: read |
| 196 | + steps: |
| 197 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 198 | + - name: Enforce Guix primary / Nix fallback |
| 199 | + run: | |
| 200 | + HAS_GUIX=$(find . -name "*.scm" -o -name ".guix-channel" -o -name "guix.scm" 2>/dev/null | head -1) |
| 201 | + HAS_NIX=$(find . -name "*.nix" 2>/dev/null | head -1) |
| 202 | + 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) |
| 203 | + if [ -n "$NEW_LOCKS" ]; then |
| 204 | + echo "⚠️ Lock files detected. Prefer Guix manifests for reproducibility." |
| 205 | + fi |
| 206 | + if [ -n "$HAS_GUIX" ]; then |
| 207 | + echo "✅ Guix package management detected (primary)" |
| 208 | + elif [ -n "$HAS_NIX" ]; then |
| 209 | + echo "✅ Nix package management detected (fallback)" |
| 210 | + else |
| 211 | + echo "ℹ️ Consider adding guix.scm or flake.nix for reproducible builds" |
| 212 | + fi |
| 213 | + echo "✅ Package policy check passed" |
| 214 | +
|
| 215 | + security-policy: |
| 216 | + name: Security policy checks |
| 217 | + runs-on: ${{ inputs.runs-on }} |
| 218 | + permissions: |
| 219 | + contents: read |
| 220 | + steps: |
| 221 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 222 | + - name: Security checks |
| 223 | + run: | |
| 224 | + FAILED=false |
| 225 | + 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) |
| 226 | + if [ -n "$WEAK_CRYPTO" ]; then |
| 227 | + echo "⚠️ Weak crypto (MD5/SHA1) detected. Use SHA256+ for security:" |
| 228 | + echo "$WEAK_CRYPTO" |
| 229 | + fi |
| 230 | + 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) |
| 231 | + if [ -n "$HTTP_URLS" ]; then |
| 232 | + echo "⚠️ HTTP URLs found. Use HTTPS:" |
| 233 | + echo "$HTTP_URLS" |
| 234 | + fi |
| 235 | + 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) |
| 236 | + if [ -n "$SECRETS" ]; then |
| 237 | + echo "❌ Potential hardcoded secrets detected!" |
| 238 | + FAILED=true |
| 239 | + fi |
| 240 | + if [ "$FAILED" = true ]; then |
| 241 | + exit 1 |
| 242 | + fi |
| 243 | + echo "✅ Security policy check passed" |
| 244 | +
|
| 245 | + quality: |
| 246 | + name: Code quality + docs |
| 247 | + runs-on: ${{ inputs.runs-on }} |
| 248 | + permissions: |
| 249 | + contents: read |
| 250 | + steps: |
| 251 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 252 | + - name: Check file permissions |
| 253 | + run: | |
| 254 | + find . -type f -perm /111 -name "*.sh" | head -10 || true |
| 255 | + - name: Check for secrets |
| 256 | + uses: trufflesecurity/trufflehog@6c05c4a00b91aa542267d8e32a8254774799d68d # v3.93.8 |
| 257 | + with: |
| 258 | + path: ./ |
| 259 | + base: ${{ github.event.pull_request.base.sha || github.event.before }} |
| 260 | + head: ${{ github.sha }} |
| 261 | + continue-on-error: true |
| 262 | + - name: Check TODO/FIXME |
| 263 | + run: | |
| 264 | + echo "=== TODOs ===" |
| 265 | + grep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.rs" --include="*.res" --include="*.py" --include="*.ex" . | head -20 || echo "None found" |
| 266 | + - name: Check for large files |
| 267 | + run: | |
| 268 | + find . -type f -size +1M -not -path "./.git/*" | head -10 || echo "No large files" |
| 269 | + - name: EditorConfig check |
| 270 | + uses: editorconfig-checker/action-editorconfig-checker@4b6cd6190d435e7e084fb35e36a096e98506f7b9 # v2.1.0 |
| 271 | + continue-on-error: true |
| 272 | + - name: Check documentation |
| 273 | + run: | |
| 274 | + MISSING="" |
| 275 | + [ ! -f "README.md" ] && [ ! -f "README.adoc" ] && MISSING="$MISSING README" |
| 276 | + [ ! -f "LICENSE" ] && [ ! -f "LICENSE.txt" ] && [ ! -f "LICENSE.md" ] && MISSING="$MISSING LICENSE" |
| 277 | + [ ! -f "CONTRIBUTING.md" ] && [ ! -f "CONTRIBUTING.adoc" ] && MISSING="$MISSING CONTRIBUTING" |
| 278 | + if [ -n "$MISSING" ]; then |
| 279 | + echo "::warning::Missing docs:$MISSING" |
| 280 | + else |
| 281 | + echo "✅ Core documentation present" |
| 282 | + fi |
| 283 | +
|
| 284 | + wellknown: |
| 285 | + name: Well-Known (RFC 9116 + RSR) |
| 286 | + runs-on: ${{ inputs.runs-on }} |
| 287 | + permissions: |
| 288 | + contents: read |
| 289 | + steps: |
| 290 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 291 | + - name: RFC 9116 security.txt validation |
| 292 | + run: | |
| 293 | + SECTXT="" |
| 294 | + [ -f ".well-known/security.txt" ] && SECTXT=".well-known/security.txt" |
| 295 | + [ -f "security.txt" ] && SECTXT="security.txt" |
| 296 | + if [ -z "$SECTXT" ]; then |
| 297 | + echo "::warning::No security.txt found." |
| 298 | + exit 0 |
| 299 | + fi |
| 300 | + grep -q "^Contact:" "$SECTXT" || { echo "::error::Missing Contact field"; exit 1; } |
| 301 | + if ! grep -q "^Expires:" "$SECTXT"; then |
| 302 | + echo "::error::Missing Expires field" |
| 303 | + exit 1 |
| 304 | + fi |
| 305 | + EXPIRES=$(grep "^Expires:" "$SECTXT" | cut -d: -f2- | tr -d ' ' | head -1) |
| 306 | + if date -d "$EXPIRES" > /dev/null 2>&1; then |
| 307 | + DAYS=$(( ($(date -d "$EXPIRES" +%s) - $(date +%s)) / 86400 )) |
| 308 | + if [ $DAYS -lt 0 ]; then |
| 309 | + echo "::error::security.txt EXPIRED" |
| 310 | + exit 1 |
| 311 | + elif [ $DAYS -lt 30 ]; then |
| 312 | + echo "::warning::security.txt expires in $DAYS days" |
| 313 | + else |
| 314 | + echo "✅ security.txt valid ($DAYS days)" |
| 315 | + fi |
| 316 | + fi |
| 317 | + - name: RSR well-known compliance |
| 318 | + run: | |
| 319 | + MISSING="" |
| 320 | + [ ! -f ".well-known/security.txt" ] && [ ! -f "security.txt" ] && MISSING="$MISSING security.txt" |
| 321 | + [ ! -f ".well-known/ai.txt" ] && MISSING="$MISSING ai.txt" |
| 322 | + [ ! -f ".well-known/humans.txt" ] && MISSING="$MISSING humans.txt" |
| 323 | + if [ -n "$MISSING" ]; then |
| 324 | + echo "::warning::Missing RSR recommended files:$MISSING" |
| 325 | + else |
| 326 | + echo "✅ RSR well-known compliant" |
| 327 | + fi |
| 328 | + - name: Mixed content check |
| 329 | + run: | |
| 330 | + 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) |
| 331 | + if [ -n "$MIXED" ]; then |
| 332 | + echo "::error::Mixed content (HTTP in HTML)" |
| 333 | + echo "$MIXED" |
| 334 | + exit 1 |
| 335 | + fi |
| 336 | + echo "✅ No mixed content" |
| 337 | +
|
| 338 | + workflow-lint: |
| 339 | + name: Workflow security linter |
| 340 | + runs-on: ${{ inputs.runs-on }} |
| 341 | + permissions: |
| 342 | + contents: read |
| 343 | + steps: |
| 344 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 345 | + - name: Check SPDX headers + permissions |
| 346 | + run: | |
| 347 | + failed=0 |
| 348 | + for file in .github/workflows/*.yml .github/workflows/*.yaml; do |
| 349 | + [ -f "$file" ] || continue |
| 350 | + if ! head -1 "$file" | grep -q "^# SPDX-License-Identifier:"; then |
| 351 | + echo "ERROR: $file missing SPDX header"; failed=1 |
| 352 | + fi |
| 353 | + if ! grep -q "^permissions:" "$file"; then |
| 354 | + echo "ERROR: $file missing top-level 'permissions:' declaration"; failed=1 |
| 355 | + fi |
| 356 | + done |
| 357 | + [ $failed -eq 1 ] && { echo "Add SPDX header + permissions:"; exit 1; } |
| 358 | + echo "All workflows have SPDX headers + permissions" |
| 359 | + - name: Check SHA-pinned actions |
| 360 | + run: | |
| 361 | + unpinned=$(grep -rnE "^[[:space:]]+uses:" .github/workflows/ | \ |
| 362 | + grep -v "@[a-f0-9]\{40\}" | \ |
| 363 | + grep -v "uses: \./\|uses: docker://\|uses: actions/github-script\|uses: hyperpolymath/standards/" || true) |
| 364 | + if [ -n "$unpinned" ]; then |
| 365 | + echo "ERROR: Found unpinned actions:" |
| 366 | + echo "$unpinned" |
| 367 | + exit 1 |
| 368 | + fi |
| 369 | + echo "All actions are SHA-pinned" |
| 370 | + - name: Check for duplicate workflows |
| 371 | + run: | |
| 372 | + if [ -f .github/workflows/codeql.yml ] && [ -f .github/workflows/codeql-analysis.yml ]; then |
| 373 | + echo "ERROR: Duplicate CodeQL workflows found"; exit 1 |
| 374 | + fi |
| 375 | + echo "No critical duplicates found" |
0 commit comments