Skip to content

Commit 468c3a1

Browse files
feat(ci): reusable governance workflow bundle (verisimiser#59) (#81)
## What Adds `.github/workflows/governance-reusable.yml` — a single `workflow_call` reusable workflow that consolidates the portable, side-effect-free estate governance checks into 6 jobs: | reusable job | sourced from | |---|---| | `language-policy` | `rsr-antipattern.yml` (superset of `npm-bun-blocker`, `ts-blocker`) | | `package-policy` | `guix-nix-policy.yml` | | `security-policy` | `security-policy.yml` | | `quality` | `quality.yml` | | `wellknown` | `wellknown-enforcement.yml` | | `workflow-lint` | `workflow-linter.yml` | ## Why Per verisimiser#59 — repos carry ~20 workflows, most of them estate governance scaffolding duplicated per-repo. This makes the governance set callable as a **single wrapper** so consuming repos drop ~8 workflow copies down to one `uses:` line, removing per-PR check noise. Load-bearing build/security workflows (`rust-ci`, `codeql`, `dependabot`, `release`, SARIF-uploading `secret-scanner`/`scorecard`) are intentionally **not** bundled — they stay standalone in the consuming repo. `rsr-antipattern` is a superset of the npm-bun/ts blockers, so the bundle deliberately de-duplicates them (net noise reduction, not just relocation). ## Caller usage ```yaml jobs: governance: uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@main ``` First consumer: `hyperpolymath/verisimiser` (companion PR). ## Notes - Existing standalone governance workflows are left untouched (no collision with concurrent estate work); this is purely additive. - YAML validated (`yaml.safe_load`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7b5d2f0 commit 468c3a1

2 files changed

Lines changed: 382 additions & 2 deletions

File tree

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
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"

.github/workflows/hypatia-scan.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,13 @@ jobs:
4848
- name: Build Hypatia scanner
4949
run: |
5050
cd "$HOME/hypatia"
51-
if [ ! -f hypatia-v2 ]; then
52-
cd scanner && mix deps.get && mix escript.build && mv hypatia ../hypatia-v2
51+
# The Hypatia scanner is an escript built from the repo root
52+
# (mix.exs and hypatia-cli.sh live at the root; there is no
53+
# longer a scanner/ subdirectory). `mix escript.build` produces
54+
# `hypatia`, which hypatia-cli.sh prefers over the legacy
55+
# `hypatia-v2` name, so no rename is needed.
56+
if [ ! -f hypatia ]; then
57+
mix deps.get && mix escript.build
5358
fi
5459
5560
- name: Run Hypatia scan

0 commit comments

Comments
 (0)