fix(ci): allow two concurrent contributor PR runs split by PR parity

This commit is contained in:
t8y2
2026-10-12 18:23:37 +08:00
parent f43a99b4fe
commit 97e3045a7c
3 changed files with 23 additions and 14 deletions
+6 -3
View File
@@ -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-<id>-even` / `pr-author-<id>-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
+16 -10
View File
@@ -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", () => {
+1 -1
View File
@@ -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' }}