diff --git a/.github/scripts/ci-guide.md b/.github/scripts/ci-guide.md index 4845b8538a..8403885717 100644 --- a/.github/scripts/ci-guide.md +++ b/.github/scripts/ci-guide.md @@ -12,9 +12,12 @@ execute the gate and report cancelled prerequisites as a failure. ## Contributor concurrency -PRs from anyone other than the repository owner share a main `CI` concurrency -group keyed by the PR author's stable numeric user ID. Only one run in that -author's group executes at a time; other authors have independent groups. +PRs from anyone other than the repository owner share main `CI` concurrency +groups keyed by the PR author's stable numeric user ID and the parity of the PR +number (`pr-author--even` / `pr-author--odd`). At most two runs per author +execute at a time — one per parity group — and other authors have independent +groups. Actions expressions have no modulo, so parity is read from the PR number's +last digit with `endsWith`. Two PRs of the same parity still queue behind each other. The PR author, not the trigger actor or author association, determines this policy, so a maintainer retrigger cannot bypass contributor limits. `cancel-in-progress: false` preserves the executing PR, and `queue: max` retains diff --git a/.github/scripts/ci-workflow.test.mjs b/.github/scripts/ci-workflow.test.mjs index f5bb9c972d..8a31a7433e 100644 --- a/.github/scripts/ci-workflow.test.mjs +++ b/.github/scripts/ci-workflow.test.mjs @@ -18,29 +18,35 @@ function job(name, content = workflow) { function concurrencySettings(github) { const block = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)?.[1]; assert.ok(block, "missing workflow concurrency"); - const format = (template, value) => template.replace("{0}", String(value)); + const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)])); + const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase()); return Object.fromEntries(block.trim().split("\n").map((line) => { const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/); - const evaluate = (expression) => new Function("github", "format", `return (${expression});`)(github, format); + const evaluate = (expression) => + new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith); const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)]; return [setting, expressions.length === 1 && expressions[0][0] === value ? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))]; })); } -function concurrencyContext({ authorId = 101, author = "contributor", actor = author, ref = "refs/pull/100/merge", eventName = "pull_request" } = {}) { +function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) { return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName, - event: eventName === "pull_request" ? { pull_request: { user: { id: authorId, login: author } } } : {} }; + event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} }; } -test("a contributor shares one main CI queue across all PRs regardless of trigger actor", () => { - const settings = concurrencySettings(concurrencyContext()); - assert.deepEqual(settings, { group: "CI-pr-author-101", "cancel-in-progress": false, queue: "max" }); - for (const context of [concurrencyContext({ ref: "refs/pull/200/merge" }), concurrencyContext({ actor: "t8y2" }), +test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => { + const even = concurrencySettings(concurrencyContext()); + assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" }); + for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }), concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) { - assert.deepEqual(concurrencySettings(context), settings); + assert.deepEqual(concurrencySettings(context), even); } - assert.notEqual(concurrencySettings(concurrencyContext({ authorId: 102, author: "another-contributor" })).group, settings.group); + for (const number of [101, 11849, 11853, 7]) { + assert.deepEqual(concurrencySettings(concurrencyContext({ number })), { ...even, group: "CI-pr-author-101-odd" }, String(number)); + } + for (const number of [2, 4, 6, 8, 11850]) assert.equal(concurrencySettings(concurrencyContext({ number })).group, even.group); + assert.notEqual(concurrencySettings(concurrencyContext({ authorId: 102, author: "another-contributor" })).group, even.group); }); test("repository-owner PRs remain independent and superseded runs are cancelled only within a PR", () => { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a8fe029dd..62741485ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [ main ] concurrency: - group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && format('pr-author-{0}', github.event.pull_request.user.id) || github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && format('pr-author-{0}-{1}', github.event.pull_request.user.id, (endsWith(github.event.pull_request.number, '0') || endsWith(github.event.pull_request.number, '2') || endsWith(github.event.pull_request.number, '4') || endsWith(github.event.pull_request.number, '6') || endsWith(github.event.pull_request.number, '8')) && 'even' || 'odd') || github.ref }} cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login == github.repository_owner }} queue: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && 'max' || 'single' }}