From b8c8f657093c613c0cbc1bff9fa23f9264ad779a Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:28:39 +0000 Subject: [PATCH] ci(governance): allow low-risk dependency/CI chore PRs to bypass human approval Adds a bot-allowlist branch to the "Require Human Approval" gate. A PR is auto-approved (required=0 humans) only when ALL of the following are true: - Author is dependabot[bot], renovate[bot], or chitcommit - PR has label 'dependencies' or 'chore', OR title starts with 'chore(deps):' / 'chore(ci):' - Only files touched are: package.json, pnpm-lock.yaml, package-lock.json, .github/workflows/security-gates.yml, .github/dependabot.yml - No reviewer has requested changes - All other check runs on the head SHA are green (CodeQL, gates, Dependency Audit, Secret Scan, claude-review, etc.) Otherwise the original 1-human-approval requirement applies. Job name unchanged so branch protection rules continue to match. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/governance.yml | 85 ++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 3a275d4..5fb03e4 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -19,6 +19,8 @@ on: permissions: contents: read pull-requests: read + checks: read + statuses: read jobs: governance: @@ -138,15 +140,88 @@ jobs: uses: actions/github-script@v7 with: script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + const pull_number = pr.number; + + // Bot allowlist: low-risk dependency / CI chore PRs from trusted authors + // touching only lockfiles / dep manifests / specific governance files + // may satisfy the gate without a human approval, provided all other + // required checks pass and no changes_requested reviews exist. + const ALLOWED_AUTHORS = new Set(['dependabot[bot]', 'renovate[bot]', 'chitcommit']); + const ALLOWED_LABELS = new Set(['dependencies', 'chore']); + const ALLOWED_TITLE_PREFIXES = ['chore(deps):', 'chore(ci):']; + const ALLOWED_FILES = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'package-lock.json', + '.github/workflows/security-gates.yml', + '.github/dependabot.yml', + ]); + + const author = pr.user && pr.user.login; + const title = pr.title || ''; + const labels = (pr.labels || []).map(l => l.name); + + const authorOk = ALLOWED_AUTHORS.has(author); + const labelOk = labels.some(l => ALLOWED_LABELS.has(l)); + const titleOk = ALLOWED_TITLE_PREFIXES.some(p => title.startsWith(p)); + const labelOrTitleOk = labelOk || titleOk; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number, per_page: 100, + }); + const filenames = files.map(f => f.filename); + const filesOk = filenames.length > 0 && filenames.every(f => ALLOWED_FILES.has(f)); + const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number + owner, repo, pull_number, }); - const humanApprovals = reviews.filter(r => r.state === 'APPROVED' && !r.user.login.includes('[bot]')); - const required = 1; + // Latest review state per reviewer + const latestByReviewer = new Map(); + for (const r of reviews) { + const key = r.user && r.user.login; + if (!key) continue; + latestByReviewer.set(key, r); + } + const latestReviews = [...latestByReviewer.values()]; + const hasChangesRequested = latestReviews.some(r => r.state === 'CHANGES_REQUESTED'); + const humanApprovals = latestReviews.filter(r => + r.state === 'APPROVED' && !(r.user && r.user.login && r.user.login.includes('[bot]')) + ); + + // Other required checks must be green before bot-allowlist applies. + // Look at check runs + commit statuses on the head sha, exclude this job. + const headSha = pr.head.sha; + const selfJobName = 'Require Human Approval'; + const selfCheckName = 'PR Governance Check'; + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, repo, ref: headSha, per_page: 100, + }); + const otherChecks = checkRuns.filter(c => + c.name !== selfJobName && c.name !== selfCheckName + ); + const checksAllGreen = otherChecks.length > 0 && otherChecks.every(c => + c.status === 'completed' && (c.conclusion === 'success' || c.conclusion === 'neutral' || c.conclusion === 'skipped') + ); + + const allowlistMatch = + authorOk && labelOrTitleOk && filesOk && !hasChangesRequested && checksAllGreen; + + core.info(`Author: ${author} (ok=${authorOk})`); + core.info(`Labels: ${labels.join(',')} | TitlePrefix ok=${titleOk} | LabelOrTitle ok=${labelOrTitleOk}`); + core.info(`Files (${filenames.length}): ${filenames.join(', ')} (ok=${filesOk})`); + core.info(`Changes requested: ${hasChangesRequested}`); + core.info(`Other checks all green: ${checksAllGreen} (count=${otherChecks.length})`); + core.info(`Human approvals: ${humanApprovals.length}`); + core.info(`Bot-allowlist match: ${allowlistMatch}`); + + const required = allowlistMatch ? 0 : 1; if (humanApprovals.length < required) { core.setFailed(`Requires at least ${required} human approval(s). Current: ${humanApprovals.length}`); + } else if (allowlistMatch) { + core.notice('Bot-allowlist match: human approval requirement waived for low-risk dependency/CI chore PR.'); } hardening: