From e8ced851041d4b77db19feb1a47d9dec668376f8 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:53:20 -0500 Subject: [PATCH 1/8] ci: enforce canonical issue and PR evidence --- .github/workflows/pr-governance.yml | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/pr-governance.yml diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml new file mode 100644 index 000000000..2eab41258 --- /dev/null +++ b/.github/workflows/pr-governance.yml @@ -0,0 +1,80 @@ +name: PR Governance + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review] + +permissions: + contents: read + issues: read + pull-requests: read + +concurrency: + group: pr-governance-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + policy: + name: Canonical issue and evidence + runs-on: ubuntu-latest + steps: + - name: Validate delivery contract + uses: actions/github-script@v8 + with: + script: | + const pr = context.payload.pull_request; + if (pr.draft) { + core.notice("Draft PR: governance enforcement begins when marked ready."); + return; + } + + const body = pr.body || ""; + const requiredSections = [ + "## Canonical issue", + "## Outcome", + "## Risk", + "## Verification", + "## Production evidence" + ]; + const missing = requiredSections.filter(section => !body.includes(section)); + + const closingPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; + const issueNumbers = [...body.matchAll(closingPattern)].map(match => Number(match[1])); + const canonicalIssues = [...new Set(issueNumbers)]; + + if (canonicalIssues.length !== 1) { + missing.push("exactly one closing reference: Closes #"); + } + + if (canonicalIssues.length === 1) { + const canonical = canonicalIssues[0]; + const pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100 + }); + + const competing = pulls.filter(candidate => { + if (candidate.number === pr.number) return false; + const matches = [...(candidate.body || "").matchAll(closingPattern)] + .map(match => Number(match[1])); + return matches.includes(canonical); + }); + + if (competing.length) { + const links = competing.map(candidate => `#${candidate.number}`).join(", "); + core.setFailed( + `Issue #${canonical} already has another open implementation PR: ${links}. ` + + "Supersede, close, or explicitly consolidate it before this PR becomes canonical." + ); + return; + } + } + + if (missing.length) { + core.setFailed(`PR delivery contract is incomplete: ${missing.join("; ")}`); + return; + } + + core.notice("PR has one canonical issue and the required delivery evidence sections."); From f5ca91f54acfd88d06ce051a057ec6bbfbe8d3a4 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:53:38 -0500 Subject: [PATCH 2/8] ci: add daily repository drift reconciliation --- .../workflows/repository-reconciliation.yml | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/repository-reconciliation.yml diff --git a/.github/workflows/repository-reconciliation.yml b/.github/workflows/repository-reconciliation.yml new file mode 100644 index 000000000..0536a7770 --- /dev/null +++ b/.github/workflows/repository-reconciliation.yml @@ -0,0 +1,115 @@ +name: Repository Reconciliation + +on: + schedule: + - cron: "17 13 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: repository-reconciliation + cancel-in-progress: true + +jobs: + report: + runs-on: ubuntu-latest + steps: + - name: Reconcile canonical delivery state + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const now = Date.now(); + const staleAfterMs = 14 * 24 * 60 * 60 * 1000; + const closingPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; + + const pulls = await github.paginate(github.rest.pulls.list, { + owner, repo, state: "open", per_page: 100 + }); + const branches = await github.paginate(github.rest.repos.listBranches, { + owner, repo, protected: false, per_page: 100 + }); + const activeHeads = new Set(pulls.map(pr => pr.head.ref)); + + const untracked = []; + const issueToPulls = new Map(); + for (const pr of pulls) { + const issues = [...(pr.body || "").matchAll(closingPattern)] + .map(match => Number(match[1])); + const unique = [...new Set(issues)]; + if (unique.length !== 1) untracked.push(pr); + for (const issue of unique) { + const existing = issueToPulls.get(issue) || []; + existing.push(pr.number); + issueToPulls.set(issue, existing); + } + } + + const duplicates = [...issueToPulls.entries()] + .filter(([, numbers]) => numbers.length > 1); + + const staleBranches = []; + for (const branch of branches) { + if (branch.name === "main" || activeHeads.has(branch.name)) continue; + const commit = await github.rest.repos.getCommit({ + owner, repo, ref: branch.commit.sha + }); + const date = commit.data.commit.committer?.date || commit.data.commit.author?.date; + if (date && now - new Date(date).getTime() > staleAfterMs) { + staleBranches.push({ name: branch.name, date, sha: branch.commit.sha.slice(0, 8) }); + } + } + + const lines = [ + "## Canonical delivery-state reconciliation", + "", + `Generated: ${new Date().toISOString()}`, + "", + `- Open PRs: **${pulls.length}**`, + `- Remote branches: **${branches.length}**`, + `- Ready PRs without exactly one canonical issue: **${untracked.length}**`, + `- Issues with competing implementation PRs: **${duplicates.length}**`, + `- Unattached branches older than 14 days: **${staleBranches.length}**`, + "", + "### PRs requiring canonical issue", + untracked.length + ? untracked.map(pr => `- #${pr.number} — ${pr.title}`).join("\n") + : "- None", + "", + "### Competing PRs", + duplicates.length + ? duplicates.map(([issue, numbers]) => `- Issue #${issue}: ${numbers.map(n => `#${n}`).join(", ")}`).join("\n") + : "- None", + "", + "### Stale unattached branches", + staleBranches.length + ? staleBranches.slice(0, 100).map(branch => + `- \`${branch.name}\` — ${branch.sha}, last commit ${branch.date}` + ).join("\n") + : "- None", + "", + "> This report is intentionally non-destructive. Branch deletion requires a merged PR or an explicit retention decision.", + "", + "Canonical governance: #898" + ]; + + const title = "[automation] Repository drift report"; + const query = `repo:${owner}/${repo} is:issue is:open in:title "${title}"`; + const existing = await github.rest.search.issuesAndPullRequests({ + q: query, per_page: 10 + }); + const report = existing.data.items.find(item => item.title === title); + const body = lines.join("\n"); + + if (report) { + await github.rest.issues.update({ + owner, repo, issue_number: report.number, body + }); + } else { + await github.rest.issues.create({ owner, repo, title, body }); + } From b0793b58b2fd6bbcb4393e5afbd2d8f1bd81129f Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:54:03 -0500 Subject: [PATCH 3/8] docs: strengthen evidence-based PR delivery contract --- .github/pull_request_template.md | 33 ++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ee79fa4f3..920b1ad25 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,17 +1,42 @@ -## Summary +## Canonical issue -Describe the outcome and the evidence that supports it. +Closes # -## Linked issue +## Outcome -Fixes # +Describe the user or operational result this PR produces. + +## Scope + +- Included: +- Explicitly excluded: + +## Risk + +- Risk level: low / medium / high +- Failure mode: +- Rollback: ## Verification +List exact automated and manual checks, tied to the current head SHA. + - [ ] Focused tests - [ ] Required CI - [ ] Review threads resolved +## Production evidence + +Provide the Vercel preview, production deployment, runtime evidence, or state why production evidence is not applicable. + +## Agent handoff + +- [ ] One canonical issue is linked +- [ ] No competing PR implements the same issue +- [ ] Acceptance criteria are satisfied +- [ ] Required checks pass on the current head +- [ ] Human decision is requested only for product, security, irreversible infrastructure, or production approval + ## Agent provenance Human-authored pull requests may delete this section. Agent-authored pull requests must replace agent-lock-example with agent-lock-manifest and fill the values. Scope and test paths remain authoritative in the linked issue. From f16368e4ec7250b4a29188600521b718d0841c6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:02:51 +0000 Subject: [PATCH 4/8] ci: address all review feedback on governance workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pr-governance.yml: reject empty/placeholder section content by parsing section bodies (strip HTML comments, check non-empty) - pr-governance.yml: validate canonical issue via Issues API — confirm it exists, is an issue (not a PR), and is open before searching for competing PRs - repository-reconciliation.yml: exclude draft PRs from untracked list to match the deferred-enforcement rule in pr-governance.yml - repository-reconciliation.yml: validate every referenced issue number through the Issues API before using it in duplicate classification - repository-reconciliation.yml: restrict activeHeads to same-repo PRs (filter by head.repo.full_name) to prevent fork branch-name collisions - repository-reconciliation.yml: fetch all branches (remove protected:false) and label the metric as 'Total remote branches' for accuracy; exclude branch.protected===true from stale candidates - README.md / AUDIT.md: add catalog rows for both new workflows - tests: add test_pr_governance_workflow.py and test_repository_reconciliation_workflow.py (17 tests, all passing) --- .github/workflows/AUDIT.md | 7 +- .github/workflows/README.md | 2 + .github/workflows/pr-governance.yml | 84 ++++++++++---- .../workflows/repository-reconciliation.yml | 46 ++++++-- tests/unit/test_pr_governance_workflow.py | 93 ++++++++++++++++ ...test_repository_reconciliation_workflow.py | 104 ++++++++++++++++++ 6 files changed, 306 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_pr_governance_workflow.py create mode 100644 tests/unit/test_repository_reconciliation_workflow.py diff --git a/.github/workflows/AUDIT.md b/.github/workflows/AUDIT.md index 63fa27412..fa02fcc74 100644 --- a/.github/workflows/AUDIT.md +++ b/.github/workflows/AUDIT.md @@ -65,4 +65,9 @@ valid. Referenced paths were checked against the working tree: | `agent-completion-enforcement.yml` | **ADD** | Protected-default-branch verifier that creates the independent **Agent completion enforcement** Check directly against the PR head SHA. It accepts only an exact-head machine-readable report from the configured dedicated GitHub App; missing/stale/mutable evidence, untrusted label provenance, and custom roles all fail closed. The existing `agent-completion/truth-gate` status stays advisory and must not be made required. | -The protected policy at `.github/agent-lock/trusted-publishers.json` starts with empty allowlists and therefore blocks until a repository administrator provisions the dedicated App and trusted actor identities through protected review. The repository ruleset must then require **Agent completion enforcement**, one independent approval, and resolved conversations. \ No newline at end of file +The protected policy at `.github/agent-lock/trusted-publishers.json` starts with empty allowlists and therefore blocks until a repository administrator provisions the dedicated App and trusted actor identities through protected review. The repository ruleset must then require **Agent completion enforcement**, one independent approval, and resolved conversations. + +## Repository governance workflows + +| `pr-governance.yml` | **ADD** | Validates that every non-draft ready PR links exactly one real open issue (not a PR number) with non-empty delivery evidence sections (Outcome, Risk, Verification, Production evidence). Fails closed on competing implementation PRs. Triggers on `pull_request_target`. | +| `repository-reconciliation.yml` | **ADD** | Scheduled (13:17 UTC daily) non-destructive reconciliation report: identifies ready PRs missing a canonical issue, issues with competing implementation PRs (references validated via Issues API), and stale unattached branches. Excludes draft PRs and fork-branch name collisions. Upserts a single issue titled "[automation] Repository drift report". | \ No newline at end of file diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0e5c52aca..f87eb7a92 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -121,6 +121,8 @@ A full audit of this directory was performed (see | Agent completion enforcement | `agent-completion-enforcement.yml` | `pull_request_target`; manual | Creates the independent, head-bound `Agent completion enforcement` Check from protected default-branch code. | +| PR Governance | `pr-governance.yml` | `pull_request_target` (opened/edited/reopened/synchronize/ready_for_review) | Validates that every ready PR links exactly one real open canonical issue and contains non-empty delivery evidence sections; fails on competing PRs. | +| Repository Reconciliation | `repository-reconciliation.yml` | daily (13:17 UTC); manual | Non-destructive daily report of ready PRs missing a canonical issue, issues with competing implementation PRs, and stale unattached branches. | ## Agent-completion enforcement diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 2eab41258..d0d02a3f5 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -29,6 +29,17 @@ jobs: } const body = pr.body || ""; + + // Returns the text content of a section (between its heading and the next ## heading), + // with HTML comments and whitespace stripped. Returns null when the heading is absent. + function getSectionContent(text, heading) { + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(escapedHeading + '\\s*\\n([\\s\\S]*?)(?=\\n## |$)', 'i'); + const match = text.match(pattern); + if (!match) return null; + return match[1].replace(//g, '').trim(); + } + const requiredSections = [ "## Canonical issue", "## Outcome", @@ -36,7 +47,12 @@ jobs: "## Verification", "## Production evidence" ]; - const missing = requiredSections.filter(section => !body.includes(section)); + + // Reject sections that are absent or contain only placeholder / empty content. + const missing = requiredSections.filter(section => { + const content = getSectionContent(body, section); + return content === null || content.length === 0; + }); const closingPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; const issueNumbers = [...body.matchAll(closingPattern)].map(match => Number(match[1])); @@ -48,27 +64,51 @@ jobs: if (canonicalIssues.length === 1) { const canonical = canonicalIssues[0]; - const pulls = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: "open", - per_page: 100 - }); - - const competing = pulls.filter(candidate => { - if (candidate.number === pr.number) return false; - const matches = [...(candidate.body || "").matchAll(closingPattern)] - .map(match => Number(match[1])); - return matches.includes(canonical); - }); - - if (competing.length) { - const links = competing.map(candidate => `#${candidate.number}`).join(", "); - core.setFailed( - `Issue #${canonical} already has another open implementation PR: ${links}. ` + - "Supersede, close, or explicitly consolidate it before this PR becomes canonical." - ); - return; + + // Validate that the referenced number is a real, open issue (not a PR). + try { + const issueResp = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: canonical + }); + const issue = issueResp.data; + if (issue.pull_request) { + missing.push(`#${canonical} is a pull request, not an issue`); + } else if (issue.state !== "open") { + missing.push(`#${canonical} is not open (state: ${issue.state})`); + } + } catch (err) { + if (err.status === 404) { + missing.push(`#${canonical} does not exist in this repository`); + } else { + throw err; + } + } + + if (missing.length === 0) { + const pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100 + }); + + const competing = pulls.filter(candidate => { + if (candidate.number === pr.number) return false; + const matches = [...(candidate.body || "").matchAll(closingPattern)] + .map(match => Number(match[1])); + return matches.includes(canonical); + }); + + if (competing.length) { + const links = competing.map(candidate => `#${candidate.number}`).join(", "); + core.setFailed( + `Issue #${canonical} already has another open implementation PR: ${links}. ` + + "Supersede, close, or explicitly consolidate it before this PR becomes canonical." + ); + return; + } } } diff --git a/.github/workflows/repository-reconciliation.yml b/.github/workflows/repository-reconciliation.yml index 0536a7770..60fb04a93 100644 --- a/.github/workflows/repository-reconciliation.yml +++ b/.github/workflows/repository-reconciliation.yml @@ -24,6 +24,7 @@ jobs: script: | const owner = context.repo.owner; const repo = context.repo.repo; + const repoFullName = `${owner}/${repo}`; const now = Date.now(); const staleAfterMs = 14 * 24 * 60 * 60 * 1000; const closingPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; @@ -31,19 +32,49 @@ jobs: const pulls = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 }); + // Fetch all branches (protected and unprotected) so the total metric is accurate. const branches = await github.paginate(github.rest.repos.listBranches, { - owner, repo, protected: false, per_page: 100 + owner, repo, per_page: 100 }); - const activeHeads = new Set(pulls.map(pr => pr.head.ref)); + // Only track head refs from PRs targeting this repository (not forks) to prevent + // branch-name collisions between fork branches and local branches. + const activeHeads = new Set( + pulls + .filter(pr => pr.head.repo && pr.head.repo.full_name === repoFullName) + .map(pr => pr.head.ref) + ); + + // Collect all unique issue numbers referenced across open PRs and validate each one + // against the Issues API before using them for classification. This prevents textual + // references like "Closes #999999" from creating fictitious duplicate groups. + const allIssueNumbers = new Set(); + for (const pr of pulls) { + const nums = [...(pr.body || "").matchAll(closingPattern)].map(m => Number(m[1])); + nums.forEach(n => allIssueNumbers.add(n)); + } + const validIssues = new Set(); + for (const issueNum of allIssueNumbers) { + try { + const resp = await github.rest.issues.get({ owner, repo, issue_number: issueNum }); + if (!resp.data.pull_request && resp.data.state === "open") { + validIssues.add(issueNum); + } + } catch (err) { + if (err.status !== 404) throw err; + // 404 → non-existent; skip silently + } + } const untracked = []; const issueToPulls = new Map(); for (const pr of pulls) { const issues = [...(pr.body || "").matchAll(closingPattern)] .map(match => Number(match[1])); - const unique = [...new Set(issues)]; - if (unique.length !== 1) untracked.push(pr); - for (const issue of unique) { + // Restrict to validated issue references only. + const validUnique = [...new Set(issues)].filter(n => validIssues.has(n)); + // Drafts mirror the governance workflow's deferred-enforcement rule and are excluded. + if (validUnique.length !== 1 && !pr.draft) untracked.push(pr); + for (const issue of validUnique) { const existing = issueToPulls.get(issue) || []; existing.push(pr.number); issueToPulls.set(issue, existing); @@ -55,7 +86,8 @@ jobs: const staleBranches = []; for (const branch of branches) { - if (branch.name === "main" || activeHeads.has(branch.name)) continue; + // Exclude main, protected branches, and branches attached to open PRs. + if (branch.name === "main" || branch.protected || activeHeads.has(branch.name)) continue; const commit = await github.rest.repos.getCommit({ owner, repo, ref: branch.commit.sha }); @@ -71,7 +103,7 @@ jobs: `Generated: ${new Date().toISOString()}`, "", `- Open PRs: **${pulls.length}**`, - `- Remote branches: **${branches.length}**`, + `- Total remote branches: **${branches.length}**`, `- Ready PRs without exactly one canonical issue: **${untracked.length}**`, `- Issues with competing implementation PRs: **${duplicates.length}**`, `- Unattached branches older than 14 days: **${staleBranches.length}**`, diff --git a/tests/unit/test_pr_governance_workflow.py b/tests/unit/test_pr_governance_workflow.py new file mode 100644 index 000000000..bac53c953 --- /dev/null +++ b/tests/unit/test_pr_governance_workflow.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + + +WORKFLOW_PATH = Path(__file__).resolve().parents[2] / ".github/workflows/pr-governance.yml" + + +def _load_workflow() -> dict: + assert WORKFLOW_PATH.exists(), "PR governance workflow should exist" + return yaml.safe_load(WORKFLOW_PATH.read_text()) + + +def _get_script(workflow: dict) -> str: + steps = workflow["jobs"]["policy"]["steps"] + script_step = next( + step for step in steps if "Validate delivery contract" in step.get("name", "") + ) + return script_step["with"]["script"] + + +def test_governance_workflow_file_is_valid_yaml() -> None: + workflow = _load_workflow() + assert workflow["name"] == "PR Governance" + + +def test_governance_workflow_triggers_on_pull_request_target() -> None: + workflow = _load_workflow() + # PyYAML parses the YAML 'on' key as Python True. + triggers = workflow[True] + assert "pull_request_target" in triggers + types = triggers["pull_request_target"]["types"] + assert "opened" in types + assert "ready_for_review" in types + + +def test_governance_workflow_minimum_permissions() -> None: + workflow = _load_workflow() + perms = workflow["permissions"] + # Must NOT request write permissions to contents or pull-requests. + assert perms.get("contents") == "read" + assert perms.get("pull-requests") == "read" + assert perms.get("issues") == "read" + + +def test_governance_workflow_draft_bypass_present() -> None: + """Draft PRs should be bypassed; enforcement begins on ready_for_review.""" + script = _get_script(_load_workflow()) + assert "pr.draft" in script + assert "Draft PR" in script or "draft" in script.lower() + + +def test_governance_workflow_detects_empty_placeholder_content() -> None: + """Sections must be checked for empty/placeholder content, not just heading presence.""" + script = _get_script(_load_workflow()) + # The script should strip HTML comments and trim before checking emptiness. + assert "getSectionContent" in script or "replace" in script + assert "/g, '').trim(); + return match[1].replace(//g, "").trim(); + } + + const placeholderPatterns = [ + /^Describe the user or operational result this PR produces\.?$/i, + /^List exact automated and manual checks, tied to the current head SHA\.?$/i, + /^Provide the Vercel preview, production deployment, runtime evidence, or state why production evidence is not applicable\.?$/i, + /^-\s*Risk level:\s*low\s*\/\s*medium\s*\/\s*high\s*$/i, + /^-\s*Failure mode:\s*$/i, + /^-\s*Rollback:\s*$/i, + /^-\s*\[\s\]\s*(Focused tests|Required CI|Review threads resolved)\s*$/i, + /^(Closes?|Fix(?:es|ed)?|Resolves?)\s+#\s*$/i + ]; + + function hasMeaningfulContent(content) { + if (content === null) return false; + const meaningfulLines = content + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .filter(line => !placeholderPatterns.some(pattern => pattern.test(line))); + return meaningfulLines.length > 0; } const requiredSections = [ @@ -47,25 +96,24 @@ jobs: "## Verification", "## Production evidence" ]; - - // Reject sections that are absent or contain only placeholder / empty content. - const missing = requiredSections.filter(section => { - const content = getSectionContent(body, section); - return content === null || content.length === 0; - }); - - const closingPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; - const issueNumbers = [...body.matchAll(closingPattern)].map(match => Number(match[1])); - const canonicalIssues = [...new Set(issueNumbers)]; + const findings = requiredSections + .filter(section => !hasMeaningfulContent(getSectionContent(body, section))) + .map(section => `${section} is missing or still contains only template placeholders`); + + const closingPattern = + /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; + const canonicalIssues = [ + ...new Set( + [...body.matchAll(closingPattern)].map(match => Number(match[1])) + ) + ]; if (canonicalIssues.length !== 1) { - missing.push("exactly one closing reference: Closes #"); + findings.push("exactly one closing reference is required: Closes #"); } if (canonicalIssues.length === 1) { const canonical = canonicalIssues[0]; - - // Validate that the referenced number is a real, open issue (not a PR). try { const issueResp = await github.rest.issues.get({ owner: context.repo.owner, @@ -74,47 +122,52 @@ jobs: }); const issue = issueResp.data; if (issue.pull_request) { - missing.push(`#${canonical} is a pull request, not an issue`); + findings.push(`#${canonical} is a pull request, not an issue`); } else if (issue.state !== "open") { - missing.push(`#${canonical} is not open (state: ${issue.state})`); + findings.push(`#${canonical} is not open (state: ${issue.state})`); } - } catch (err) { - if (err.status === 404) { - missing.push(`#${canonical} does not exist in this repository`); + } catch (error) { + if (error.status === 404) { + findings.push(`#${canonical} does not exist in this repository`); } else { - throw err; + throw error; } } - if (missing.length === 0) { + if (findings.length === 0) { const pulls = await github.paginate(github.rest.pulls.list, { owner: context.repo.owner, repo: context.repo.repo, state: "open", per_page: 100 }); - const competing = pulls.filter(candidate => { if (candidate.number === pr.number) return false; - const matches = [...(candidate.body || "").matchAll(closingPattern)] - .map(match => Number(match[1])); + const matches = [ + ...(candidate.body || "").matchAll(closingPattern) + ].map(match => Number(match[1])); return matches.includes(canonical); }); - if (competing.length) { - const links = competing.map(candidate => `#${candidate.number}`).join(", "); - core.setFailed( - `Issue #${canonical} already has another open implementation PR: ${links}. ` + - "Supersede, close, or explicitly consolidate it before this PR becomes canonical." + findings.push( + `Issue #${canonical} already has another open implementation PR: ` + + competing.map(candidate => `#${candidate.number}`).join(", ") ); - return; } } } - if (missing.length) { - core.setFailed(`PR delivery contract is incomplete: ${missing.join("; ")}`); + if (findings.length) { + await publish( + "failure", + "Canonical delivery contract blocked", + findings.join("; ") + ); return; } - core.notice("PR has one canonical issue and the required delivery evidence sections."); + await publish( + "success", + "Canonical delivery contract verified", + `PR #${pr.number} has one real open canonical issue and meaningful evidence. Verified exact head ${pr.head.sha}.` + ); From da79c6b2c7267a33b91c55a44739f19234b73313 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:10:14 -0500 Subject: [PATCH 6/8] test: cover exact-head governance enforcement --- tests/unit/test_pr_governance_workflow.py | 63 ++++++++++++----------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/tests/unit/test_pr_governance_workflow.py b/tests/unit/test_pr_governance_workflow.py index bac53c953..5a33ff8ba 100644 --- a/tests/unit/test_pr_governance_workflow.py +++ b/tests/unit/test_pr_governance_workflow.py @@ -16,7 +16,9 @@ def _load_workflow() -> dict: def _get_script(workflow: dict) -> str: steps = workflow["jobs"]["policy"]["steps"] script_step = next( - step for step in steps if "Validate delivery contract" in step.get("name", "") + step + for step in steps + if "Validate delivery contract" in step.get("name", "") ) return script_step["with"]["script"] @@ -33,61 +35,64 @@ def test_governance_workflow_triggers_on_pull_request_target() -> None: assert "pull_request_target" in triggers types = triggers["pull_request_target"]["types"] assert "opened" in types + assert "synchronize" in types assert "ready_for_review" in types -def test_governance_workflow_minimum_permissions() -> None: +def test_governance_workflow_uses_minimum_permissions() -> None: workflow = _load_workflow() perms = workflow["permissions"] - # Must NOT request write permissions to contents or pull-requests. + assert perms.get("checks") == "write" assert perms.get("contents") == "read" assert perms.get("pull-requests") == "read" assert perms.get("issues") == "read" + assert set(perms) == {"checks", "contents", "issues", "pull-requests"} -def test_governance_workflow_draft_bypass_present() -> None: - """Draft PRs should be bypassed; enforcement begins on ready_for_review.""" +def test_governance_workflow_publishes_exact_head_check() -> None: + script = _get_script(_load_workflow()) + assert 'name: "PR Governance"' in script + assert "github.rest.checks.create" in script + assert "head_sha: pr.head.sha" in script + assert 'status: "completed"' in script + + +def test_governance_workflow_draft_bypass_is_head_bound() -> None: script = _get_script(_load_workflow()) assert "pr.draft" in script - assert "Draft PR" in script or "draft" in script.lower() + assert '"neutral"' in script + assert "Governance deferred for draft PR" in script + assert "pr.head.sha" in script -def test_governance_workflow_detects_empty_placeholder_content() -> None: - """Sections must be checked for empty/placeholder content, not just heading presence.""" +def test_governance_workflow_rejects_default_placeholders() -> None: script = _get_script(_load_workflow()) - # The script should strip HTML comments and trim before checking emptiness. - assert "getSectionContent" in script or "replace" in script - assert "/g, "").trim(); + return match[1].replace(//g, "").trim(); } const placeholderPatterns = [ From cbae9110cfd7b6b89b2686bd9fd705ea4e649778 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:31:29 -0500 Subject: [PATCH 8/8] test: reject comment-only governance evidence --- tests/unit/test_pr_governance_workflow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_pr_governance_workflow.py b/tests/unit/test_pr_governance_workflow.py index 5a33ff8ba..fd342b808 100644 --- a/tests/unit/test_pr_governance_workflow.py +++ b/tests/unit/test_pr_governance_workflow.py @@ -73,6 +73,8 @@ def test_governance_workflow_rejects_default_placeholders() -> None: assert "Risk level:" in script assert "Focused tests" in script assert "meaningfulLines.length > 0" in script + assert r'replace(//g, "").trim()' in script + assert r'replace(//g, "").trim()' not in script def test_governance_workflow_validates_issue_via_api() -> None: