Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 80 additions & 5 deletions .github/workflows/governance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ on:
permissions:
contents: read
pull-requests: read
checks: read
statuses: read

jobs:
governance:
Expand Down Expand Up @@ -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,
});
Comment on lines 178 to 180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Paginate reviews before trusting latest review states

On bot PRs with more than the first page of reviews, this single listReviews call can miss later review records, including a later CHANGES_REQUESTED from a reviewer. Because the allowlist then treats hasChangesRequested as false, a dependency/CI PR can have the human approval waived even though a reviewer has an outstanding change request.

Useful? React with πŸ‘Β / πŸ‘Ž.

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,
});
Comment on lines +199 to +201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include commit statuses before waiving approval

When an allowlisted bot PR has required legacy commit status contexts (for example an external CI or coverage service), this only reads GitHub Check Runs and never reads commit statuses despite the comment and new statuses: read permission. In that context a failed or pending status is invisible here, so allowlistMatch can waive the human approval solely because all check runs are green.

Useful? React with πŸ‘Β / πŸ‘Ž.

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')
Comment on lines +205 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-run or wait before requiring green sibling checks

For an otherwise allowlisted bot PR, this evaluates checksAllGreen while the governance job is still running; any sibling check run such as Portfolio Hardening Check or another CI workflow that is still queued/in_progress makes this false and immediately falls back to requiring a human approval. Since this workflow only triggers on pull_request, it will not automatically re-evaluate when those checks later complete, so low-risk dependency/CI PRs can remain blocked despite all checks eventually passing.

Useful? React with πŸ‘Β / πŸ‘Ž.

);

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:
Expand Down
Loading