diff --git a/.github/workflows/pr-governance-tests.yml b/.github/workflows/pr-governance-tests.yml index 57e8704..09b1e33 100644 --- a/.github/workflows/pr-governance-tests.yml +++ b/.github/workflows/pr-governance-tests.yml @@ -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 diff --git a/pr-governance/README.md b/pr-governance/README.md index 1b82be8..34cae99 100644 --- a/pr-governance/README.md +++ b/pr-governance/README.md @@ -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 @@ -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 diff --git a/pr-governance/scripts/__pycache__/secret_scan.cpython-313.pyc b/pr-governance/scripts/__pycache__/secret_scan.cpython-313.pyc new file mode 100644 index 0000000..856784b Binary files /dev/null and b/pr-governance/scripts/__pycache__/secret_scan.cpython-313.pyc differ diff --git a/pr-governance/scripts/runner.cjs b/pr-governance/scripts/runner.cjs index fc576e0..d91273b 100644 --- a/pr-governance/scripts/runner.cjs +++ b/pr-governance/scripts/runner.cjs @@ -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); diff --git a/pr-governance/scripts/secret_scan.py b/pr-governance/scripts/secret_scan.py index 711625d..92bec15 100644 --- a/pr-governance/scripts/secret_scan.py +++ b/pr-governance/scripts/secret_scan.py @@ -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 @@ -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): diff --git a/pr-governance/scripts/test-runner.cjs b/pr-governance/scripts/test-runner.cjs index 159cf26..2414e45 100644 --- a/pr-governance/scripts/test-runner.cjs +++ b/pr-governance/scripts/test-runner.cjs @@ -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' } }; diff --git a/pr-governance/scripts/test_secret_scan.py b/pr-governance/scripts/test_secret_scan.py index d7a9db4..c537cf5 100644 --- a/pr-governance/scripts/test_secret_scan.py +++ b/pr-governance/scripts/test_secret_scan.py @@ -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): diff --git a/pr-governance/templates/codex-nudge.yml b/pr-governance/templates/codex-nudge.yml index 053ce61..352362a 100644 --- a/pr-governance/templates/codex-nudge.yml +++ b/pr-governance/templates/codex-nudge.yml @@ -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 diff --git a/pr-governance/templates/gitleaks.yml b/pr-governance/templates/gitleaks.yml index b1fa5a7..8e1706b 100644 --- a/pr-governance/templates/gitleaks.yml +++ b/pr-governance/templates/gitleaks.yml @@ -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 diff --git a/pr-governance/templates/pr-review-signal.yml b/pr-governance/templates/pr-review-signal.yml new file mode 100644 index 0000000..0d9fc3b --- /dev/null +++ b/pr-governance/templates/pr-review-signal.yml @@ -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" diff --git a/pr-governance/templates/risk-gate.yml b/pr-governance/templates/risk-gate.yml index 3585f67..7874788 100644 --- a/pr-governance/templates/risk-gate.yml +++ b/pr-governance/templates/risk-gate.yml @@ -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 @@ -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 @@ -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 diff --git a/pr-governance/templates/weekly-secret-scan.yml b/pr-governance/templates/weekly-secret-scan.yml index 8fcd03a..339de27 100644 --- a/pr-governance/templates/weekly-secret-scan.yml +++ b/pr-governance/templates/weekly-secret-scan.yml @@ -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: