From eef2d58a486fcdb304c1bfc08c4a6e01e12b368b Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Mon, 2 Nov 2026 18:19:28 +0000 Subject: [PATCH] fix(ci): skip unsupported stacked PRs in nightly merge (#10063) Signed-off-by: WenyXu --- .github/nightly-merge.md | 4 +++ .github/scripts/tests/nightly-merge.test.cjs | 32 ++++++++++++++++++-- .github/workflows/nightly-merge.yml | 13 +++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/.github/nightly-merge.md b/.github/nightly-merge.md index 08b523dbab2..cc9419ec652 100644 --- a/.github/nightly-merge.md +++ b/.github/nightly-merge.md @@ -13,6 +13,10 @@ GitHub may delay scheduled runs. The label alone controls nightly opt-in; the enqueue job does not check the PR body. PRs must be approved and clean, and have all review threads resolved. Drafts are skipped. +If GitHub rejects enqueueing because a PR belongs to a stack, the workflow skips +it and continues without failing the run or counting it toward the five-PR limit. +Other API errors still fail the run. Dry runs do not call the enqueue API, so +they cannot detect this stack rejection. Already queued PRs and manually enabled auto-merge requests are left alone; Maintainers can use **Merge when ready** to proceed earlier. Unready PRs wait for a later scheduled run; this workflow never enables auto-merge on their behalf. diff --git a/.github/scripts/tests/nightly-merge.test.cjs b/.github/scripts/tests/nightly-merge.test.cjs index 753b458ebce..d6c8792f7fe 100644 --- a/.github/scripts/tests/nightly-merge.test.cjs +++ b/.github/scripts/tests/nightly-merge.test.cjs @@ -26,7 +26,7 @@ const checked = '- [x] Merge during the nightly window '; async function run({ sync = false, body = checked, labels = ['merge nightly'], detail = {}, current = {}, threads = [[true]], count = 1, dryRun = false, - failFirst = false } = {}) { + failFirst = false, enqueueError = Error('head changed') } = {}) { const calls = []; let page = 0; const github = { @@ -43,7 +43,7 @@ async function run({ sync = false, body = checked, labels = ['merge nightly'], graphql: async (query, args) => { if (query.includes('mutation')) { calls.push(['enqueue', args]); - if (failFirst && calls.filter(call => call[0] === 'enqueue').length === 1) throw Error('head changed'); + if (failFirst && calls.filter(call => call[0] === 'enqueue').length === 1) throw enqueueError; return {}; } if (query.includes('reviewThreads')) { @@ -123,3 +123,31 @@ test('label-only opt-in survives PRs without a template and does not require a c // The label is authoritative for admission, even before checkbox synchronization. assert.equal((await run({ body: checked.replace('[x]', '[ ]') })).some(call => call[0] === 'enqueue'), true); }); + +const stackMessage = 'This pull request is part of a stack and must be enqueued using the asynchronous merge REST API. For more information, see https://docs.github.com/rest/pulls/pulls#merge-a-pull-request-asynchronously'; + +test('stacked PR rejection is skipped without consuming the successful enqueue cap', async () => { + const calls = await run({ count: 7, failFirst: true, + enqueueError: Object.assign(Error('GraphQL request failed'), { errors: [{ message: stackMessage }] }) }); + assert.equal(calls.some(c => c[0] === 'failed'), false); + assert.deepEqual(calls.find(c => c[0] === 'summary')[1], [ + ['PR', 'Result'], ['1', 'Skipped: stacked PR'], + ...[2, 3, 4, 5, 6].map(number => [String(number), 'Enqueued']) + ]); + assert.deepEqual(calls.filter(c => c[0] === 'enqueue').map(c => c[1].id), + ['PR_1', 'PR_2', 'PR_3', 'PR_4', 'PR_5', 'PR_6']); +}); + +test('mixed or unstructured errors are not mistaken for a stacked PR rejection', async () => { + for (const enqueueError of [ + Error(stackMessage), + Object.assign(Error('GraphQL request failed'), { errors: [] }), + Object.assign(Error('GraphQL request failed'), { errors: [{ message: stackMessage }, { message: 'Forbidden' }] }) + ]) { + const calls = await run({ count: 2, failFirst: true, enqueueError }); + assert.equal(calls.filter(c => c[0] === 'failed').length, 1); + assert.deepEqual(calls.find(c => c[0] === 'summary')[1], [ + ['PR', 'Result'], ['1', 'Failed: see workflow logs'], ['2', 'Enqueued'] + ]); + } +}); diff --git a/.github/workflows/nightly-merge.yml b/.github/workflows/nightly-merge.yml index 6188d12cd3f..05f5154358b 100644 --- a/.github/workflows/nightly-merge.yml +++ b/.github/workflows/nightly-merge.yml @@ -122,12 +122,23 @@ jobs: } if (!dryRun) { // GitHub enforces branch rules; never bypass or enable auto-merge. - await github.graphql(` + try { + await github.graphql(` mutation($id: ID!, $sha: GitObjectID!) { enqueuePullRequest(input: {pullRequestId: $id, expectedHeadOid: $sha}) { mergeQueueEntry { id } } }`, { id: pr.id, sha: pr.headRefOid }); + } catch (error) { + // Stacked PRs require a different API and are not handled by this workflow. + const stackError = 'This pull request is part of a stack and must be enqueued using the asynchronous merge REST API.'; + if (error.errors?.length && error.errors.every(item => item.message?.startsWith(stackError))) { + core.warning(`PR #${number}: skipped stacked PR`); + rows.push([String(number), 'Skipped: stacked PR']); + continue; + } + throw error; + } } enqueued++; rows.push([String(number), dryRun ? 'Would enqueue' : 'Enqueued']);