From 10b10697b867c49febed97131c4d1498963293c0 Mon Sep 17 00:00:00 2001 From: Reuben Bowlby Date: Sat, 4 Jul 2026 17:45:14 -0400 Subject: [PATCH 1/3] feat: add report-only governed pr check Adds a stdlib checker, reusable workflow wrapper, and pilot adoption docs for Governed Throughput report-only validation. --- .github/scripts/governed_pr_check.py | 223 ++++++++++++++++++++++++ .github/workflows/governed-pr-check.yml | 51 ++++++ docs/GOVERNED_PR_CHECK.md | 54 ++++++ 3 files changed, 328 insertions(+) create mode 100644 .github/scripts/governed_pr_check.py create mode 100644 .github/workflows/governed-pr-check.yml create mode 100644 docs/GOVERNED_PR_CHECK.md diff --git a/.github/scripts/governed_pr_check.py b/.github/scripts/governed_pr_check.py new file mode 100644 index 0000000..eed013d --- /dev/null +++ b/.github/scripts/governed_pr_check.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Report-only validator for HUMMBL governed pull request metadata.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +BOUNDARY_OPTIONS = ( + "Public", + "Private", + "Internal-only", + "Sensitive / requires review", +) +SOURCE_STATUS_OPTIONS = ( + "No source/canon impact", + "Source candidate only", + "Prior art", + "Canon-bearing change", + "Deprecated/removal", +) +CHANGE_CLASS_OPTIONS = ( + "Docs-only", + "CI/CD", + "Source packet", + "Code/runtime", + "Governance", + "Public surface", +) + +CANON_TERMS = ("canon", "canonical", "verified", "official") +PRIVATE_MARKERS = ( + "private repo", + "private-only", + "internal-only", + "confidential", + "secret", +) +PLACEHOLDER_PATTERNS = ( + r"^\s*$", + r"tests/checks run:\s*$", + r"receipt:\s*$", + r"todo\b", + r"tbd\b", + r"n/a\s*$", +) +GOVERNANCE_SENSITIVE_PREFIXES = ( + ".github/", + "CODEOWNERS", + "CONSTITUTION.md", + "GOVERNANCE.md", + "KRINEIA.md", + "hummbl.repo.yaml", +) + + +@dataclass(frozen=True) +class Finding: + level: str + code: str + message: str + + +def checked_options(body: str, options: Iterable[str]) -> list[str]: + selected: list[str] = [] + for option in options: + pattern = re.compile( + rf"^\s*-\s*\[[xX]\]\s*{re.escape(option)}\s*$", + re.MULTILINE, + ) + if pattern.search(body): + selected.append(option) + return selected + + +def section_text(body: str, heading: str) -> str: + pattern = re.compile( + rf"^##\s+{re.escape(heading)}\s*$\n(?P.*?)(?=^##\s+|\Z)", + re.MULTILINE | re.DOTALL, + ) + match = pattern.search(body) + if not match: + return "" + return match.group("body").strip() + + +def has_linked_issue(body: str) -> bool: + patterns = ( + r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#\d+\b", + r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+https://github\.com/[^/\s]+/[^/\s]+/issues/\d+\b", + r"\blinked issue\s*[:#]\s*\d+\b", + ) + return any(re.search(pattern, body, re.IGNORECASE) for pattern in patterns) + + +def is_placeholder(text: str) -> bool: + normalized = text.strip().lower() + if not normalized: + return True + return any(re.search(pattern, normalized, re.IGNORECASE) for pattern in PLACEHOLDER_PATTERNS) + + +def body_without_unchecked_options(body: str) -> str: + lines = [] + for line in body.splitlines(): + if re.match(r"^\s*-\s*\[\s*\]\s+", line): + continue + lines.append(line) + return "\n".join(lines) + + +def changed_files_from_json(path: Path | None) -> list[str]: + if path is None: + return [] + payload = json.loads(path.read_text(encoding="utf-8")) + files = payload.get("files", []) + result: list[str] = [] + for item in files: + filename = item.get("filename") + if isinstance(filename, str): + result.append(filename) + return result + + +def governance_sensitive_files(files: Iterable[str]) -> list[str]: + sensitive: list[str] = [] + for filename in files: + if filename in GOVERNANCE_SENSITIVE_PREFIXES: + sensitive.append(filename) + continue + if any(filename.startswith(prefix) for prefix in GOVERNANCE_SENSITIVE_PREFIXES): + sensitive.append(filename) + return sensitive + + +def validate(body: str, changed_files: Iterable[str]) -> list[Finding]: + findings: list[Finding] = [] + + if not has_linked_issue(body): + findings.append(Finding("warning", "linked-issue-missing", "No linked issue closure/reference was found.")) + + for code, label, options in ( + ("boundary", "boundary", BOUNDARY_OPTIONS), + ("source-status", "source status", SOURCE_STATUS_OPTIONS), + ("change-class", "change class", CHANGE_CLASS_OPTIONS), + ): + selected = checked_options(body, options) + if not selected: + findings.append(Finding("warning", f"{code}-missing", f"No {label} option is selected.")) + elif len(selected) > 1: + joined = ", ".join(selected) + findings.append(Finding("warning", f"{code}-multiple", f"Multiple {label} options are selected: {joined}.")) + + evidence = section_text(body, "Evidence") + if is_placeholder(evidence): + findings.append(Finding("warning", "evidence-placeholder", "Evidence is blank or placeholder-only.")) + + lower_body = body.lower() + lower_asserted_body = body_without_unchecked_options(body).lower() + has_gate_marker = "canon-bearing change" in lower_body or "governance gate" in lower_body + if any(re.search(rf"\b{re.escape(term)}\b", lower_body) for term in CANON_TERMS) and not has_gate_marker: + findings.append(Finding("warning", "canon-claim-without-gate", "Canon/official claim appears without a gate marker.")) + + boundary = checked_options(body, BOUNDARY_OPTIONS) + if boundary == ["Public"] and any(marker in lower_asserted_body for marker in PRIVATE_MARKERS): + findings.append(Finding("warning", "public-boundary-private-marker", "Public-boundary PR includes private/internal markers.")) + + classes = checked_options(body, CHANGE_CLASS_OPTIONS) + if "Code/runtime" in classes: + if "docs-only" in lower_body or "no runtime change" in lower_body: + pass + elif is_placeholder(evidence) or not re.search(r"\b(test|pytest|lint|ci|check)\b", evidence, re.IGNORECASE): + findings.append(Finding("warning", "runtime-without-test-evidence", "Code/runtime change lacks test evidence or no-runtime declaration.")) + + sensitive = governance_sensitive_files(changed_files) + if sensitive and "owner review" not in lower_body and "codeowners" not in lower_body: + preview = ", ".join(sensitive[:5]) + if len(sensitive) > 5: + preview += ", ..." + findings.append(Finding("warning", "governance-files-without-owner-path", f"Governance-sensitive files changed without owner review path: {preview}.")) + + return findings + + +def format_markdown(findings: list[Finding]) -> str: + if not findings: + return "### Governed PR Check\n\nNo report-only findings." + lines = ["### Governed PR Check", "", "Report-only findings:"] + for finding in findings: + lines.append(f"- `{finding.code}`: {finding.message}") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--body-file", required=True, type=Path) + parser.add_argument("--files-json", type=Path) + parser.add_argument("--markdown-output", type=Path) + parser.add_argument("--fail-on-findings", action="store_true") + args = parser.parse_args() + + body = args.body_file.read_text(encoding="utf-8") + changed_files = changed_files_from_json(args.files_json) + findings = validate(body, changed_files) + markdown = format_markdown(findings) + + if args.markdown_output: + args.markdown_output.write_text(markdown + "\n", encoding="utf-8") + print(markdown) + + if findings and args.fail_on_findings: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/governed-pr-check.yml b/.github/workflows/governed-pr-check.yml new file mode 100644 index 0000000..54d7336 --- /dev/null +++ b/.github/workflows/governed-pr-check.yml @@ -0,0 +1,51 @@ +name: Governed PR Check + +on: + workflow_call: + inputs: + fail-on-findings: + description: "Fail the workflow when report-only findings are present." + required: false + default: false + type: boolean + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + governed-pr-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Write PR body + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + printf '%s' "$PR_BODY" > pr-body.md + + - name: Fetch changed files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" > pr-files.json + + - name: Run governed PR check + run: | + python3 .github/scripts/governed_pr_check.py \ + --body-file pr-body.md \ + --files-json pr-files.json \ + --markdown-output governed-pr-check.md \ + ${{ inputs.fail-on-findings && '--fail-on-findings' || '' }} + + - name: Add summary + run: | + cat governed-pr-check.md >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/GOVERNED_PR_CHECK.md b/docs/GOVERNED_PR_CHECK.md new file mode 100644 index 0000000..191c4f8 --- /dev/null +++ b/docs/GOVERNED_PR_CHECK.md @@ -0,0 +1,54 @@ +# Governed PR Check + +`governed-pr-check` is the first executable validation layer for the Governed +Throughput defaults. It is intentionally report-only by default. + +## What It Checks + +- linked issue presence, +- exactly one boundary selection, +- exactly one source-status selection, +- exactly one change-class selection, +- non-placeholder evidence, +- canon/official claims without a gate marker, +- public-boundary PRs that mention private/internal markers, +- runtime changes without test evidence or a no-runtime declaration, +- governance-sensitive file changes without an owner-review path. + +The check reports warnings. It should not become a required blocking check until +pilot repositories show acceptable false-positive rates. + +## Reusable Workflow + +Pilot repositories can call the default workflow: + +```yaml +name: Governed PR Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + governed-pr-check: + uses: hummbl-dev/.github/.github/workflows/governed-pr-check.yml@main + with: + fail-on-findings: false +``` + +Keep `fail-on-findings` set to `false` during the pilot phase. + +## Local Validation + +```bash +python3 .github/scripts/governed_pr_check.py \ + --body-file pr-body.md \ + --files-json pr-files.json +``` + +`pr-files.json` should be the GitHub pull-request files API payload. If omitted, +body-only checks still run. From 6fb5d175b4a3f77f93f1163a506555095926708e Mon Sep 17 00:00:00 2001 From: Reuben Bowlby Date: Sat, 4 Jul 2026 17:47:21 -0400 Subject: [PATCH 2/3] fix: accept pull files api arrays Handles the GitHub pull-request files API array response shape in governed_pr_check.py while preserving object-shaped fixture support. --- .github/scripts/governed_pr_check.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/scripts/governed_pr_check.py b/.github/scripts/governed_pr_check.py index eed013d..82bb630 100644 --- a/.github/scripts/governed_pr_check.py +++ b/.github/scripts/governed_pr_check.py @@ -119,11 +119,16 @@ def changed_files_from_json(path: Path | None) -> list[str]: if path is None: return [] payload = json.loads(path.read_text(encoding="utf-8")) - files = payload.get("files", []) + if isinstance(payload, list): + files = payload + elif isinstance(payload, dict): + files = payload.get("files", []) + else: + return [] result: list[str] = [] for item in files: - filename = item.get("filename") - if isinstance(filename, str): + if isinstance(item, dict) and isinstance(item.get("filename"), str): + filename = item["filename"] result.append(filename) return result From 06a50ce8a2d3fa321a2e6a5afc8444385b69b693 Mon Sep 17 00:00:00 2001 From: Reuben Bowlby Date: Sat, 4 Jul 2026 17:50:59 -0400 Subject: [PATCH 3/3] ci: cancel stale governed pr check runs Adds workflow concurrency so superseded governed-pr-check runs for the same PR are canceled. --- .github/workflows/governed-pr-check.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/governed-pr-check.yml b/.github/workflows/governed-pr-check.yml index 54d7336..df01247 100644 --- a/.github/workflows/governed-pr-check.yml +++ b/.github/workflows/governed-pr-check.yml @@ -11,6 +11,10 @@ on: pull_request: types: [opened, edited, synchronize, reopened, ready_for_review] +concurrency: + group: governed-pr-check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read pull-requests: read