fix(ci): skip unsupported stacked PRs in nightly merge (#10063)

Signed-off-by: WenyXu <wenymedia@gmail.com>
This commit is contained in:
Weny Xu
2026-11-02 18:19:28 +00:00
committed by GitHub
parent 6491700fe6
commit eef2d58a48
3 changed files with 46 additions and 3 deletions
+4
View File
@@ -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.
+30 -2
View File
@@ -26,7 +26,7 @@ const checked = '- [x] Merge during the nightly window <!-- nightly-merge -->';
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']
]);
}
});
+12 -1
View File
@@ -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']);