-
Notifications
You must be signed in to change notification settings - Fork 1
ci: establish autonomous repository governance #899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
e8ced85
ci: enforce canonical issue and PR evidence
groupthinking f5ca91f
ci: add daily repository drift reconciliation
groupthinking b0793b5
docs: strengthen evidence-based PR delivery contract
groupthinking f16368e
ci: address all review feedback on governance workflows
Copilot 8996df1
ci: publish PR governance check on exact head
groupthinking da79c6b
test: cover exact-head governance enforcement
groupthinking 3b57c4f
fix: strip HTML comments from governance evidence
groupthinking cbae911
test: reject comment-only governance evidence
groupthinking File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| name: PR Governance | ||
|
|
||
| on: | ||
| pull_request_target: | ||
| types: [opened, edited, reopened, synchronize, ready_for_review] | ||
|
groupthinking marked this conversation as resolved.
|
||
|
|
||
| permissions: | ||
| checks: write | ||
| 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 and publish exact-head Check | ||
| uses: actions/github-script@v8 | ||
| with: | ||
| script: | | ||
| const pr = context.payload.pull_request; | ||
| const runUrl = | ||
| `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | ||
|
|
||
| async function publish(conclusion, title, summary) { | ||
| await github.rest.checks.create({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| name: "PR Governance", | ||
| head_sha: pr.head.sha, | ||
| status: "completed", | ||
| conclusion, | ||
| details_url: runUrl, | ||
| output: { | ||
| title, | ||
| summary: summary.slice(0, 60000) | ||
| } | ||
| }); | ||
| if (conclusion === "failure") { | ||
| core.setFailed(summary); | ||
| } | ||
| } | ||
|
|
||
| if (pr.draft) { | ||
| await publish( | ||
| "neutral", | ||
| "Governance deferred for draft PR", | ||
| `Draft PR #${pr.number} is not enforced. The Check is bound to exact head ${pr.head.sha}.` | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const body = pr.body || ""; | ||
|
|
||
| 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(/<!--[\s\S]*?-->/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 = [ | ||
| "## Canonical issue", | ||
| "## Outcome", | ||
| "## Risk", | ||
| "## Verification", | ||
| "## Production evidence" | ||
| ]; | ||
| 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) { | ||
| findings.push("exactly one closing reference is required: Closes #<issue>"); | ||
| } | ||
|
|
||
| if (canonicalIssues.length === 1) { | ||
| const canonical = canonicalIssues[0]; | ||
| 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) { | ||
| findings.push(`#${canonical} is a pull request, not an issue`); | ||
| } else if (issue.state !== "open") { | ||
| findings.push(`#${canonical} is not open (state: ${issue.state})`); | ||
| } | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
| findings.push(`#${canonical} does not exist in this repository`); | ||
| } else { | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| 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])); | ||
| return matches.includes(canonical); | ||
| }); | ||
| if (competing.length) { | ||
| findings.push( | ||
| `Issue #${canonical} already has another open implementation PR: ` + | ||
| competing.map(candidate => `#${candidate.number}`).join(", ") | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (findings.length) { | ||
| await publish( | ||
| "failure", | ||
| "Canonical delivery contract blocked", | ||
| findings.join("; ") | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| 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}.` | ||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| name: Repository Reconciliation | ||
|
groupthinking marked this conversation as resolved.
|
||
|
|
||
| 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 | ||
|
groupthinking marked this conversation as resolved.
|
||
| with: | ||
| 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; | ||
|
|
||
| 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, per_page: 100 | ||
| }); | ||
| // 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])); | ||
| // 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); | ||
| } | ||
| } | ||
|
|
||
| const duplicates = [...issueToPulls.entries()] | ||
| .filter(([, numbers]) => numbers.length > 1); | ||
|
|
||
| const staleBranches = []; | ||
| for (const branch of branches) { | ||
| // 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 | ||
| }); | ||
| 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}**`, | ||
| `- 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}**`, | ||
| "", | ||
| "### 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 }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.