diff --git a/.github/workflows/close-draft-prs.yml b/.github/workflows/close-draft-prs.yml index f2d361e89..6f8eda5cf 100644 --- a/.github/workflows/close-draft-prs.yml +++ b/.github/workflows/close-draft-prs.yml @@ -1,7 +1,18 @@ name: Close Draft PRs +# pull_request_target (not pull_request) so the job runs in the base-repo +# context with a write-capable token even for PRs from forks. Without this, +# fork PRs from first-time/external contributors get a read-only GITHUB_TOKEN +# and the close/comment API calls 403 — letting them bypass the auto-close. +# Safe because this job only reads event metadata and never checks out or +# executes PR-supplied code. +# Residual platform limitation: GitHub deliberately does NOT trigger +# pull_request_target for fork branches whose names look like a Git SHA, so a +# contributor could still evade this by naming their head branch like a commit +# hash. Fully closing that gap needs a scheduled base-context sweep (separate +# concern); a draft PR that evades this still cannot merge and still fails the other gates. on: - pull_request: + pull_request_target: types: [opened, reopened, converted_to_draft] concurrency: diff --git a/tests/workflow-maintainer-skip.test.cjs b/tests/workflow-maintainer-skip.test.cjs index 64233d25c..60e78ff5a 100644 --- a/tests/workflow-maintainer-skip.test.cjs +++ b/tests/workflow-maintainer-skip.test.cjs @@ -29,6 +29,18 @@ describe('PR policy workflow maintainer carve-outs', () => { assertMaintainerSkip(workflow); }); + test('draft PR auto-close triggers on pull_request_target so fork PRs cannot bypass it', () => { + const workflow = readWorkflow('.github/workflows/close-draft-prs.yml'); + + // A bare `pull_request` trigger hands fork PRs (how first-time/external + // contributors contribute) a read-only GITHUB_TOKEN, so the close/comment + // API calls 403 and the draft PR survives — bypassing the auto-close. + // `pull_request_target` runs in the base-repo context with a write-capable + // token. Guard against a regression back to the bypassable trigger. + assert.match(workflow, /^\s*pull_request_target:/m); + assert.doesNotMatch(workflow, /^\s*pull_request:\s*$/m); + }); + test('PR target validator does not run for maintainer-authored PRs', () => { const workflow = readWorkflow('.github/workflows/pr-target-validator.yml');