Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion .github/workflows/pr-governance-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the gate, label binding and review request
- name: Test the gate, labels, reviewer evidence, runner and review request
run: >-
node --test pr-governance/scripts/test-gate.cjs
pr-governance/scripts/test-labels.cjs pr-governance/scripts/test-codex.cjs
pr-governance/scripts/test-runner.cjs pr-governance/scripts/test-nudge.cjs
- name: Install the pinned scanner
run: bash pr-governance/scripts/install-gitleaks.sh
Expand Down
10 changes: 7 additions & 3 deletions pr-governance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ organization community-health files.
2. Copy `templates/risk-gate.yml`, `templates/codex-nudge.yml`,
`templates/gitleaks.yml` and `templates/weekly-secret-scan.yml` into
`.github/workflows/`, replacing `__GOVERNANCE_REPO__` with the
`owner/repository` of this governance repository.
`owner/repository` of this governance repository and `__GOVERNANCE_SHA__`
with the full commit SHA at the head of `v1`. Copy
`templates/pr-review-signal.yml` unchanged as well: review events on fork
and Dependabot pull requests get a read-only token, so the gate is started
by that workflow's completion instead.
3. Add the required contexts to branch protection on the default branch:
`pr-template`, `ai-review`, `risk-gate`, `Gitleaks`, and the repository's
own checks. Keep `strict` (require branches to be up to date) and
Expand All @@ -79,8 +83,8 @@ file, which is read from the default branch head.
## Trust model

- Workflows never check out or execute pull request code.
- The action is pinned by the `v1` branch of this repository, not by the
repository under judgement.
- The action is pinned to a full commit SHA of this repository, not by the
repository under judgement. Update the pins after `v1` moves.
- The policy is read from the default branch head, so a pull request cannot
change the rules that judge it.
- Reviewer evidence is a marker comment written by the configured reviewer
Expand Down
Binary file not shown.
16 changes: 16 additions & 0 deletions pr-governance/scripts/runner.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ async function selectTargets(github, context, defaultBranch) {
if (!Number.isSafeInteger(event.issue.number) || event.issue.number < 1) throw new Error('Invalid PR number');
return [event.issue.number];
}
if (context.eventName === 'workflow_run') {
// Reviews on fork and Dependabot pull requests arrive through a
// permissionless signal workflow; only its completion is trusted here.
const run = event.workflow_run;
if (!run) throw new Error('Missing workflow run payload');
const listed = Array.isArray(run.pull_requests) ? run.pull_requests : [];
const numbers = listed
.map(item => item?.number)
.filter(number => Number.isSafeInteger(number) && number > 0);
if (numbers.length) return [...new Set(numbers)];
const owner = run.head_repository?.owner?.login;
if (!owner || typeof run.head_branch !== 'string' || !run.head_branch) return [];
const pulls = await github.paginate(github.rest.pulls.list,
{ ...repo, state: 'open', base: defaultBranch, head: `${owner}:${run.head_branch}`, per_page: 100 });
return pulls.map(pull => pull.number);
}
if (context.eventName === 'workflow_dispatch') {
if (context.ref !== `refs/heads/${defaultBranch}`) throw new Error('Dispatch must use the default branch');
const number = Number(event.inputs?.pr_number);
Expand Down
11 changes: 8 additions & 3 deletions pr-governance/scripts/secret_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,15 @@ def fail_walk(error):
raise RuntimeError('Source ignore files are not permitted')


def git(target, *arguments):
def git(target, *arguments, capture=False):
# Patch output is only needed for its exit status, and historical blobs are
# not guaranteed to be UTF-8. Decoding them would abort the scan on any
# repository that ever committed a non-UTF-8 file.
try:
return subprocess.run(['git', '-C', str(target), *arguments],
capture_output=True, text=True, check=False)
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True, encoding='utf-8', errors='replace', check=False)
except OSError:
raise RuntimeError('Git history is unreadable; scan blocked') from None

Expand All @@ -81,7 +86,7 @@ def verify_history(target):
# Gitleaks can report success after its internal Git reader fails. Read the
# complete patch history first, suppressing every source-controlled diagnostic.
try:
root = git(target, 'rev-parse', '--show-toplevel')
root = git(target, 'rev-parse', '--show-toplevel', capture=True)
if root.returncode or Path(root.stdout.strip()).resolve() != Path(target).resolve():
raise RuntimeError('Git history is unreadable; scan blocked')
except (OSError, ValueError):
Expand Down
19 changes: 19 additions & 0 deletions pr-governance/scripts/test-runner.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,25 @@ test('a comment on an issue that is not a pull request evaluates nothing', async
assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [8]);
});

test('a review relay evaluates the pull requests named by the run', async () => {
const m = mock(fixture(), { eventName: 'workflow_run' });
m.context.payload = { workflow_run: { pull_requests: [{ number: 42 }, { number: 7 }, { number: 42 }] } };
assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42, 7]);
m.context.payload = { workflow_run: { pull_requests: [{ number: 0 }, { number: 'x' }] } };
assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []);
});

test('a review relay without pull request entries falls back to the head branch', async () => {
const m = mock(fixture(), { eventName: 'workflow_run', open: [{ number: 42 }] });
m.context.payload = { workflow_run: { pull_requests: [], head_repository: { owner: { login: 'example-org' } }, head_branch: 'feature' } };
assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42]);
assert.equal(m.calls.at(-1).params.head, 'example-org:feature');
m.context.payload = { workflow_run: { pull_requests: [], head_branch: 'feature' } };
assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []);
m.context.payload = {};
await assert.rejects(runner.selectTargets(m.github, m.context, 'main'), /workflow run payload/);
});

test('dispatch validates the number and the default branch', async () => {
const m = mock(fixture(), { eventName: 'workflow_dispatch' });
m.context.payload = { inputs: { pr_number: '42' } };
Expand Down
7 changes: 7 additions & 0 deletions pr-governance/scripts/test_secret_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ def test_range_scan_empty_range_passes(self):
base = self.suppression_repository()
self.assertEqual(self.range_scan(base), 0)

def test_range_scan_survives_non_utf8_history(self):
base = self.suppression_repository()
(self.source / 'legacy.txt').write_bytes(b'\xd2\xbb\xb6\xce\xce\xc4\xb1\xbe\n')
self.git('add', '.')
self.git('commit', '--quiet', '-m', 'test: legacy encoding')
self.assertEqual(self.range_scan(base), 0)

def test_range_scan_unknown_head_fails(self):
base = self.suppression_repository()
with self.assertRaises(RuntimeError):
Expand Down
2 changes: 1 addition & 1 deletion pr-governance/templates/codex-nudge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ jobs:
timeout-minutes: 5
steps:
- name: Request
uses: __GOVERNANCE_REPO__/pr-governance@v1
uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
with:
mode: nudge
2 changes: 1 addition & 1 deletion pr-governance/templates/gitleaks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Scan added commits
uses: __GOVERNANCE_REPO__/pr-governance@v1
uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
with:
mode: scan
25 changes: 25 additions & 0 deletions pr-governance/templates/pr-review-signal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: PR review signal

# Copy to .github/workflows/pr-review-signal.yml and replace nothing: this
# workflow holds no permissions and runs no repository code. It exists because a
# review event on a fork or Dependabot pull request gets a read-only token, so
# the gate that publishes statuses has to be started by `workflow_run` instead.
# Keep the workflow name; risk-gate.yml refers to it.

on:
pull_request_review:
types: [submitted, edited, dismissed]
pull_request_review_comment:
types: [created, edited, deleted]

permissions:
contents: read

jobs:
signal:
name: Signal review change
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Signal
run: echo "review event recorded"
16 changes: 9 additions & 7 deletions pr-governance/templates/risk-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ on:
types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review, closed]
issue_comment:
types: [created, edited, deleted]
pull_request_review:
types: [submitted, edited, dismissed]
pull_request_review_comment:
types: [created, edited, deleted]
# A review or inline comment on a fork or Dependabot pull request gets a
# read-only token, so those events are relayed through the permissionless
# `PR review signal` workflow and evaluated by this trusted run.
workflow_run:
workflows: ["PR review signal"]
types: [completed]
push:
branches: [main, master]
# A merge made with GITHUB_TOKEN raises no push, and nothing raises an event
Expand Down Expand Up @@ -49,7 +51,7 @@ jobs:
statuses: write
steps:
- name: Record label head
uses: __GOVERNANCE_REPO__/pr-governance@v1
uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
with:
mode: gate

Expand All @@ -74,10 +76,10 @@ jobs:
# Job-level, so an irrelevant comment skips the job without cancelling an
# evaluation already running. Label evidence no longer rides on this run.
concurrency:
group: pr-governance-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }}
group: pr-governance-${{ github.event.pull_request.number || github.event.issue.number || github.event.workflow_run.pull_requests[0].number || inputs.pr_number || github.run_id }}
cancel-in-progress: true
steps:
- name: Evaluate
uses: __GOVERNANCE_REPO__/pr-governance@v1
uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
with:
mode: gate
2 changes: 1 addition & 1 deletion pr-governance/templates/weekly-secret-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- name: Fetch all remote branches
run: git fetch origin '+refs/heads/*:refs/remotes/origin/*'
- name: Scan history
uses: __GOVERNANCE_REPO__/pr-governance@v1
uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
env:
SECRET_SCAN_BASELINE_B64: ${{ secrets.SECRET_SCAN_BASELINE_B64 }}
with:
Expand Down
Loading