diff --git a/.github/scripts/issue-auto-assign.test.mjs b/.github/scripts/issue-auto-assign.test.mjs index 576fc3a2a..b173e7d7d 100644 --- a/.github/scripts/issue-auto-assign.test.mjs +++ b/.github/scripts/issue-auto-assign.test.mjs @@ -112,6 +112,30 @@ test('summary failure fails automation; untrusted text is never rendered', async await f.run(); assert.equal(f.failures.length, 1); }); +test('old-base checkout loads the trusted workflow helper without selecting PR head', () => { + const yaml = readFileSync(new URL('../workflows/issue-auto-assign.yml', import.meta.url), 'utf8').replace(/\r\n/g, '\n'); + const workflowSha = 'a'.repeat(40); + const oldBaseSha = 'b'.repeat(40); + const untrustedHeadSha = 'c'.repeat(40); + const contextRefs = { + 'github.workflow_sha': workflowSha, + 'github.event.pull_request.base.sha': oldBaseSha, + 'github.event.pull_request.head.sha': untrustedHeadSha, + }; + const helper = '.github/scripts/issue-auto-assign.mjs'; + const filesByRevision = new Map([ + [workflowSha, new Set([helper])], + [oldBaseSha, new Set()], + [untrustedHeadSha, new Set([helper])], + ]); + const expression = yaml.match(/^ ref: \$\{\{ ([\w.]+) \}\}$/m)?.[1]; + assert.ok(Object.hasOwn(contextRefs, expression), 'checkout expression must resolve in the event fixture'); + const checkoutSha = contextRefs[expression]; + assert.ok(filesByRevision.get(checkoutSha)?.has(helper), 'selected revision must supply the helper even when the historical base lacks it'); + assert.notEqual(checkoutSha, untrustedHeadSha, 'PR head helper must never be selected'); + assert.equal(checkoutSha, workflowSha, 'helper must come from the trusted executing pull_request_target workflow'); +}); + test('workflow trust, permissions, serialization and executable github-script boundary', async () => { const yaml = readFileSync(new URL('../workflows/issue-auto-assign.yml', import.meta.url), 'utf8').replace(/\r\n/g, '\n'); assert.match(yaml, /pull_request_target:\s*\n\s*types: \[opened, edited, reopened, ready_for_review\]/); @@ -121,10 +145,10 @@ test('workflow trust, permissions, serialization and executable github-script bo [['contents', 'read'], ['issues', 'write'], ['pull-requests', 'read']]); assert.match(yaml, /actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803/); assert.match(yaml, /actions\/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd/); - assert.match(yaml, /ref: \$\{\{ github.event.pull_request.base.sha \}\}/); + assert.match(yaml, /ref: \$\{\{ github.workflow_sha \}\}/); assert.match(yaml, /persist-credentials: false/); assert.match(yaml, /sparse-checkout: \.github\/scripts\/issue-auto-assign.mjs/); - assert.doesNotMatch(yaml, /head\.sha|workflow_sha|secrets\.|run:|checks:|actions: write/); + assert.doesNotMatch(yaml, /github\.event\.pull_request\.base\.sha|pull_request\.head|head\.sha|secrets\.|run:|checks:|actions: write/); const script = yaml.split(' script: |\n')[1].split('\n').map(line => line.replace(/^ /, '')).join('\n'); const execute = new (Object.getPrototypeOf(async function() {}).constructor)('github', 'context', 'core', 'load', script.replace(/await import\([^\n]+\)/, 'await load()')); diff --git a/.github/workflows/issue-auto-assign.yml b/.github/workflows/issue-auto-assign.yml index 3a07b60d2..459fb99e2 100644 --- a/.github/workflows/issue-auto-assign.yml +++ b/.github/workflows/issue-auto-assign.yml @@ -19,10 +19,10 @@ jobs: issues: write pull-requests: read steps: - - name: Load trusted base helper + - name: Load trusted workflow helper uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.workflow_sha }} persist-credentials: false sparse-checkout: .github/scripts/issue-auto-assign.mjs sparse-checkout-cone-mode: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd61bb463..e24822d10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -210,10 +210,13 @@ unassignments, historical scans or required-check/settings changes. This is a convenience, **not a replacement for approved, assigned ownership before implementation**. Required validation and refresh publishers remain independent; -rerun native PR validation after assignment changes. The workflow uses only a -trusted event base-SHA helper, never PR-head code or candidate bootstrap. It becomes -available only after integration on the trusted base. A real fork run remains -pending until then; mocked tests do not establish live token/event behavior. +rerun native PR validation after assignment changes. In this trusted +`pull_request_target` workflow, `github.workflow_sha` supplies the helper from the +executing workflow revision even when the historical PR base lacks it, never +PR-head code or candidate bootstrap. `workflow_sha` is not a universal trust +marker. This correction takes effect only after integration on the trusted +branch. Local tests do not establish hosted old-base, token or event behavior; +a real fork run remains pending. Runs are serialized repository-wide with cancellation disabled for running jobs. GitHub may replace/coalesce pending runs: this is best-effort scheduling, not a