ci: run required checks on merge-queue groups (#11297)

* ci: run required checks on merge-queue groups

Add the merge_group trigger to CI and PR title validation so every required
check reports on the queued group's exact head. Group runs use exact-head
concurrency groups that pushes cannot cancel, classify the group's combined
diff from its base (a non-ancestor base fails planning), and keep the CI
required gate running when a group is cancelled so it fails instead of
reporting a skipped check. Title validation checks every squash commit
subject between the group base and head.

* ci: read the merge-group comparison from a file
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 11:29:13 -05:00
1 parent 28e95f34d0
commit 5efaedda88
9 files changed
+328 -34

No files matched your search

+19 -5
View File
@@ -5,6 +5,9 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main, "release/*"] branches: [main, "release/*"]
# The merge queue tests each group's exact head before it lands on main.
merge_group:
types: [checks_requested]
schedule: schedule:
- cron: "23 0-2,4-23 * * *" - cron: "23 0-2,4-23 * * *"
- cron: "23 3 * * *" - cron: "23 3 * * *"
@@ -33,7 +36,8 @@ concurrency:
# Main feedback also finishes, coalescing newer pushes to one pending run. # Main feedback also finishes, coalescing newer pushes to one pending run.
# Scheduled/manual main runs coalesce to one active and the latest pending; # Scheduled/manual main runs coalesce to one active and the latest pending;
# release and label-triggered qualification use exact-revision groups. # release and label-triggered qualification use exact-revision groups.
group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }} # Merge-queue groups use their exact head and are never cancelled by pushes.
group: ci-${{ github.event_name == 'merge_group' && 'merge-queue' || (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ github.event.merge_group.head_sha || inputs.source_sha || github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }} cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
env: env:
@@ -102,6 +106,14 @@ jobs:
run: | run: |
test "${SOURCE_SHA}" = "${GITHUB_SHA}" test "${SOURCE_SHA}" = "${GITHUB_SHA}"
test "${SOURCE_SHA}" = "$(git rev-parse HEAD)" test "${SOURCE_SHA}" = "$(git rev-parse HEAD)"
- name: Bind classification to the queued merge-group head
if: github.event_name == 'merge_group'
env:
MERGE_GROUP_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
run: |
test -n "${MERGE_GROUP_HEAD_SHA}"
test "${MERGE_GROUP_HEAD_SHA}" = "${GITHUB_SHA}"
test "${MERGE_GROUP_HEAD_SHA}" = "$(git rev-parse HEAD)"
- name: Find a verified ancestor for push selection - name: Find a verified ancestor for push selection
id: baseline id: baseline
if: github.event_name == 'push' && inputs.source_sha == '' if: github.event_name == 'push' && inputs.source_sha == ''
@@ -125,9 +137,9 @@ jobs:
- name: Select verification lanes - name: Select verification lanes
id: plan id: plan
env: env:
BASE_SHA: ${{ steps.baseline.outputs.base || github.event.pull_request.base.sha }} BASE_SHA: ${{ steps.baseline.outputs.base || github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
BASELINE_REASON: ${{ steps.baseline.outputs.reason }} BASELINE_REASON: ${{ steps.baseline.outputs.reason }}
HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }} HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }} CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }}
CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }} CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }}
run: node scripts/ci/plan.mjs run: node scripts/ci/plan.mjs
@@ -328,7 +340,7 @@ jobs:
strategy: strategy:
# Failed PRs release the remaining matrix slots; qualification retains # Failed PRs release the remaining matrix slots; qualification retains
# every shard's diagnostics and coverage. Passing PRs run the full suite. # every shard's diagnostics and coverage. Passing PRs run the full suite.
fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }} fail-fast: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && needs.changes.outputs.tier == 'fast' }}
matrix: matrix:
# Fast feedback uses one source group and twelve built groups. Shards # Fast feedback uses one source group and twelve built groups. Shards
# are balanced by recorded duration, but hosted runner speed varies, # are balanced by recorded duration, but hosted runner speed varies,
@@ -1215,7 +1227,9 @@ jobs:
required: required:
name: CI required name: CI required
# Report failed dependencies, but release superseded PR concurrency slots. # Report failed dependencies, but release superseded PR concurrency slots.
if: ${{ !cancelled() }} # A merge-queue run always reports: a skipped required check counts as
# passing, so a cancelled group must fail this gate instead.
if: ${{ github.event_name == 'merge_group' || !cancelled() }}
needs: needs:
[ [
changes, changes,
+55 -7
View File
@@ -4,26 +4,74 @@ on:
pull_request: pull_request:
branches: [main, "release/*"] branches: [main, "release/*"]
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened, edited]
# A queued group lands its squash commits on main. Their subjects are the
# queued PR titles, so the queue revalidates exactly what Release Please reads.
merge_group:
types: [checks_requested]
permissions: {} permissions: {}
concurrency: concurrency:
group: pr-title-${{ github.event.pull_request.number }} group: pr-title-${{ github.event.pull_request.number || github.event.merge_group.head_sha }}
cancel-in-progress: true cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
pr-title: pr-title:
name: Conventional PR title name: Conventional PR title
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 2 timeout-minutes: 2
permissions:
contents: read
steps: steps:
- name: Validate title for Release Please - name: Validate title for Release Please
env: env:
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
run: >- GH_TOKEN: ${{ github.token }}
node -e 'const title = process.env.PR_TITLE ?? ""; MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
MERGE_GROUP_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
run: |
set -euo pipefail
# The comparison includes file patches and can exceed the size of one
# environment string or argument, so it travels through a file.
comparison_file=""
if [ "${GITHUB_EVENT_NAME}" = "merge_group" ]; then
comparison_file="$(mktemp)"
trap 'rm -f "${comparison_file}"' EXIT
gh api "repos/${GITHUB_REPOSITORY}/compare/${MERGE_GROUP_BASE_SHA}...${MERGE_GROUP_HEAD_SHA}" > "${comparison_file}"
fi
MERGE_GROUP_COMPARISON_FILE="${comparison_file}" node --input-type=module <<'EOF'
import { readFileSync } from "node:fs";
const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u; const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u;
if (!conventional.test(title)) { const event = process.env.GITHUB_EVENT_NAME;
console.error("PR title must use Conventional Commits, for example: feat: add historical source import"); let titles;
if (event === "pull_request") {
titles = [process.env.PR_TITLE ?? ""];
} else if (event === "merge_group") {
// Every queued squash commit between the group base and head lands on
// main. An incomplete or unexpected comparison fails closed.
const comparison = JSON.parse(
readFileSync(process.env.MERGE_GROUP_COMPARISON_FILE ?? "", "utf8") || "null",
);
const commits = Array.isArray(comparison?.commits) ? comparison.commits : [];
if (
comparison?.status !== "ahead" ||
comparison.behind_by !== 0 ||
commits.length === 0 ||
comparison.total_commits !== commits.length ||
commits.at(-1)?.sha !== process.env.MERGE_GROUP_HEAD_SHA
) {
console.error("Merge group commits between its base and head could not be identified");
process.exit(1);
}
titles = commits.map((entry) => String(entry?.commit?.message ?? "").split("\n")[0]);
} else {
console.error(`Unsupported title validation event: ${event}`);
process.exit(1); process.exit(1);
}' }
const invalid = titles.filter((title) => !conventional.test(title));
if (invalid.length) {
console.error("PR title must use Conventional Commits, for example: feat: add historical source import");
if (event === "merge_group") for (const title of invalid) console.error(`Queued commit subject: ${title}`);
process.exit(1);
}
EOF
+3 -1
View File
@@ -356,7 +356,9 @@ rather than the generated pages.
## Development feedback and PR verification ## Development feedback and PR verification
For routine merges, wait for the required checks to pass for the current PR For routine merges, wait for the required checks to pass for the current PR
revision. Administrators retain an override for deliberate maintainer recovery. revision, then add the PR to the [merge queue](docs/development/testing.md#merge-queue),
which lands it only after the same checks pass on the queued group.
Administrators retain an override for deliberate maintainer recovery.
If `main` is broken, prioritize a focused repair before updating affected PRs. If `main` is broken, prioritize a focused repair before updating affected PRs.
Select checks for the behavior changed. For a source edit: Select checks for the behavior changed. For a source edit:
+28
View File
@@ -1298,6 +1298,34 @@ and workspace rather than reporting successful removal. The controlled
`packageUpdateRegistryFailure` regression needs built process modules, uses no `packageUpdateRegistryFailure` regression needs built process modules, uses no
public registry, and checks both the primary failure and live descendant absence. public registry, and checks both the primary failure and live descendant absence.
## Merge queue
Pull requests land on `main` through GitHub's merge queue. Once a PR's own
checks pass and review is complete, add it to the queue instead of merging it
directly. The queue builds a temporary group commit that squashes the PR, with
any PRs queued ahead of it, onto the current `main`, then runs CI on that exact
commit. The group lands only when every required check passes on it; otherwise
the PR leaves the queue and the groups behind it are rebuilt without it.
Each required check reports on the group (`merge_group` event):
- `Conventional PR title` reads the squash commits between the group base and
head and validates each subject, which is the PR title that Release Please
will read on `main`. An incomplete or unexpected comparison fails.
- `Static checks` and `Portable tests` run on the group head with the fast-tier
lanes that the group's combined diff from its base selects, as for a `main`
push.
- `CI required` checks classification and every selected lane. A merge group
whose base is not an ancestor of its head fails classification. The gate
also runs when the group's run is cancelled, so a cancelled group fails
instead of reporting a skipped (passing) check.
Group runs use concurrency groups keyed by the exact group head, and pushes to
PRs or `main` never cancel them. When a group fails, read the failing job
in the group's run; fix the cause on the PR branch and queue it again. Use
`ci:full` on the PR when a change needs full qualification before it is
queued.
## Real-toolchain verification lanes ## Real-toolchain verification lanes
See [Real-toolchain verification lanes](../testing-lanes.md#real-toolchain-verification-lanes). See [Real-toolchain verification lanes](../testing-lanes.md#real-toolchain-verification-lanes).
+8 -3
View File
@@ -785,14 +785,19 @@ summary. Representative Git-diff cases live in
rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or
unknown tiers and requires every canonical scope to be explicitly `true` or unknown tiers and requires every canonical scope to be explicitly `true` or
`false`; incomplete classification cannot silently omit verification lanes. `false`; incomplete classification cannot silently omit verification lanes.
The aggregate remains cancellable so superseded runs release their slots. The aggregate remains cancellable so superseded runs release their slots,
except in a merge group, where it always runs so a cancelled group fails.
Branch protection Branch protection
should require this aggregate, `Static checks`, `Portable tests`, and should require this aggregate, `Static checks`, `Portable tests`, and
`Conventional PR title`. Title validation runs separately on PR opening, `Conventional PR title`. Title validation runs separately on PR opening,
reopening, commits, and edits; correcting a title refreshes only this lightweight reopening, commits, and edits; correcting a title refreshes only this lightweight
check. It reads title text through an environment variable, executes no PR code, check. It reads title text through an environment variable, executes no PR code,
and has no token permissions. Its separate concurrency group cannot cancel and its token can only read repository contents. In a merge group it validates
test runs. Fast PR runs cancel superseded feedback for the same PR. Main push every squash commit subject between the group base and head instead. Its separate
concurrency group cannot cancel test runs. Fast PR runs cancel superseded feedback
for the same PR. [Merge-queue](development/testing.md#merge-queue) runs use the
group's exact head SHA as their concurrency group and are never cancelled by newer
pushes. Main push
runs keep one active run and the latest pending run for that branch. runs keep one active run and the latest pending run for that branch.
Scheduled/manual qualification has a separate concurrency group: active work Scheduled/manual qualification has a separate concurrency group: active work
finishes and only the latest pending run is retained. Release qualification uses finishes and only the latest pending run is retained. Release qualification uses
+20 -2
View File
@@ -172,7 +172,13 @@ export function planChanges(request, repository) {
const { event } = request; const { event } = request;
if ( if (
!["pull_request", "push", "schedule", "workflow_dispatch"].includes(event) ![
"pull_request",
"push",
"merge_group",
"schedule",
"workflow_dispatch",
].includes(event)
) )
throw new Error(`Unsupported CI event: ${event}`); throw new Error(`Unsupported CI event: ${event}`);
const requestedFull = const requestedFull =
@@ -297,11 +303,23 @@ export function planChanges(request, repository) {
); );
} else if (tier === "full") { } else if (tier === "full") {
full("Full baseline requested by event or ci:full label"); full("Full baseline requested by event or ci:full label");
} else if (event === "pull_request" || event === "push") { } else {
// Fast pull_request, push and merge_group plans diff an exact base.
const { head } = request; const { head } = request;
let base; let base;
if (event === "pull_request") { if (event === "pull_request") {
base = repository.mergeBase(request.base, head); base = repository.mergeBase(request.base, head);
} else if (event === "merge_group") {
// The queue builds a group on its base, so every queued change lies
// between that base and the group head. Anything else fails planning
// rather than narrowing the lanes a queued batch must pass.
const candidate = request.base ?? "";
if (
!/^[0-9a-f]{40}$/i.test(candidate) ||
!repository.isAncestor(candidate, head)
)
throw new Error("Merge group base must be an ancestor of its head");
base = candidate;
} else { } else {
const candidate = request.base; const candidate = request.base;
const { baselineReason } = request; const { baselineReason } = request;
@@ -402,6 +402,7 @@ it("passes the workflow environment through the entrypoint unchanged", async ()
for (const [event, skipFull, tier] of [ for (const [event, skipFull, tier] of [
["pull_request", false, "fast"], ["pull_request", false, "fast"],
["push", false, "fast"], ["push", false, "fast"],
["merge_group", false, "fast"],
["schedule", true, "reused"], ["schedule", true, "reused"],
] as const) { ] as const) {
const expected = await classify( const expected = await classify(
@@ -688,6 +689,33 @@ it("returns an empty baseline when GitHub lookup fails", async ({ skip }) => {
expect(head).not.toBe(base); expect(head).not.toBe(base);
}); });
it("selects a queued group's lanes from its base without a PR test-selection proposal", async () => {
const { directory, base } = await fixture();
const first = await change(directory, ["docs/readme/README_ja.md"]);
const head = await change(directory, ["src/evm/Example.ts"]);
// Every queued entry between the group base and head contributes lanes, and
// a merge group is never a PR test-selection candidate.
expect(await classify(directory, base, head, "merge_group")).toMatchObject({
tier: "fast",
code: true,
docs: true,
evm: true,
files: expect.arrayContaining([
"docs/readme/README_ja.md",
"src/evm/Example.ts",
]),
testSelection: { mode: "full" },
});
// A base that is missing, malformed or not an ancestor is not this group's
// change, so planning fails instead of narrowing the queued lanes.
await git(directory, ["switch", "-c", "unrelated", base]);
const unrelated = await change(directory, ["other.txt"]);
for (const queuedBase of ["", "not-a-commit", unrelated, head])
await expect(
classify(directory, queuedBase, first, "merge_group"),
).rejects.toThrow("Merge group base must be an ancestor of its head");
});
it("fails closed when the successful push baseline is not an ancestor", async () => { it("fails closed when the successful push baseline is not an ancestor", async () => {
const { directory, base } = await fixture(); const { directory, base } = await fixture();
await git(directory, ["switch", "-c", "unrelated"]); await git(directory, ["switch", "-c", "unrelated"]);
+133 -12
View File
@@ -1,15 +1,25 @@
import { readFile } from "node:fs/promises"; import { chmod, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { parse } from "yaml"; import { parse } from "yaml";
import { z } from "zod"; import { z } from "zod";
import { executeWorkflowFixture } from "../../support/workflowGit.js"; import { executeWorkflowFixture } from "../../support/workflowGit.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../../support/workspace/workspaceFixture.js";
async function workflow(name: string) { async function workflow(name: string) {
return z return z
.object({ .object({
on: z.record(z.string(), z.unknown()), on: z.record(z.string(), z.unknown()),
permissions: z.record(z.string(), z.string()), permissions: z.record(z.string(), z.string()),
concurrency: z.object({ group: z.string() }).optional(), concurrency: z
.object({
group: z.string(),
"cancel-in-progress": z.union([z.string(), z.boolean()]).optional(),
})
.optional(),
jobs: z.record( jobs: z.record(
z.string(), z.string(),
z z
@@ -46,42 +56,153 @@ it("refreshes title validation on edits without checking out PR code or cancelli
expect(title.on.pull_request).toMatchObject({ expect(title.on.pull_request).toMatchObject({
types: ["opened", "synchronize", "reopened", "edited"], types: ["opened", "synchronize", "reopened", "edited"],
}); });
expect(title.on.merge_group).toEqual({ types: ["checks_requested"] });
expect(title.permissions).toEqual({}); expect(title.permissions).toEqual({});
expect(title.concurrency?.group).not.toBe( expect(title.concurrency?.group).not.toBe(
(await workflow("ci.yml")).concurrency?.group, (await workflow("ci.yml")).concurrency?.group,
); );
// A queued group's check uses its exact head and is never superseded.
expect(title.concurrency?.group).toContain(
"github.event.merge_group.head_sha",
);
expect(title.concurrency?.["cancel-in-progress"]).toBe(
"${{ github.event_name == 'pull_request' }}",
);
const jobs = Object.values(title.jobs); const jobs = Object.values(title.jobs);
expect(jobs).toHaveLength(1); expect(jobs).toHaveLength(1);
expect(jobs[0]?.name).toBe("Conventional PR title"); expect(jobs[0]?.name).toBe("Conventional PR title");
expect(jobs[0]?.if).toBeUndefined(); // A skipped check must not green-light an invalid title on body edits. expect(jobs[0]?.if).toBeUndefined(); // A skipped check must not green-light an invalid title on body edits.
// The token reads only the merge group comparison.
expect(jobs[0]?.permissions).toEqual({ contents: "read" });
const steps = jobs[0]?.steps ?? []; const steps = jobs[0]?.steps ?? [];
expect(steps).toHaveLength(1); expect(steps).toHaveLength(1);
expect(steps[0]?.uses).toBeUndefined(); expect(steps[0]?.uses).toBeUndefined();
expect(steps[0]?.env).toEqual({ expect(steps[0]?.env).toEqual({
PR_TITLE: "${{ github.event.pull_request.title }}", PR_TITLE: "${{ github.event.pull_request.title }}",
GH_TOKEN: "${{ github.token }}",
MERGE_GROUP_BASE_SHA: "${{ github.event.merge_group.base_sha }}",
MERGE_GROUP_HEAD_SHA: "${{ github.event.merge_group.head_sha }}",
}); });
const command = z.string().parse(steps[0]?.run); const command = z.string().parse(steps[0]?.run);
const runPullRequest = (title: string) =>
executeWorkflowFixture("bash", ["-c", command], {
env: {
...process.env,
GITHUB_EVENT_NAME: "pull_request",
PR_TITLE: title,
},
});
for (const title of [ for (const title of [
"fix(ci): repair workflow", "fix(ci): repair workflow",
"feat!: changed contract", "feat!: changed contract",
"docs: literal $(exit 91) `exit 92` text", "docs: literal $(exit 91) `exit 92` text",
]) { ]) {
await expect( await expect(runPullRequest(title)).resolves.toMatchObject({ stderr: "" });
executeWorkflowFixture("bash", ["-c", command], {
env: { ...process.env, PR_TITLE: title },
}),
).resolves.toMatchObject({ stderr: "" });
} }
for (const title of ["", "Fix CI", "feat: "]) { for (const title of ["", "Fix CI", "feat: "]) {
await expect( await expect(runPullRequest(title)).rejects.toMatchObject({ code: 1 });
executeWorkflowFixture("bash", ["-c", command], {
env: { ...process.env, PR_TITLE: title },
}),
).rejects.toMatchObject({ code: 1 });
} }
await expect(
executeWorkflowFixture("bash", ["-c", command], {
env: { ...process.env, GITHUB_EVENT_NAME: "push", PR_TITLE: "fix: x" },
}),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("Unsupported title validation event"),
});
expect((await workflow("ci.yml")).jobs["pr-title"]).toBeUndefined(); expect((await workflow("ci.yml")).jobs["pr-title"]).toBeUndefined();
}); });
it("validates every squash subject a queued merge group lands", async () => {
const [step] =
Object.values((await workflow("pr-title.yml")).jobs)[0]?.steps ?? [];
const command = z.string().parse(step?.run);
const workspace = await createTestWorkspace("rea-pr-title-queue-");
try {
// The fake gh records its request and prints the queued comparison.
const bin = await workspace.mkdir("bin");
const request = workspace.path("gh-request");
await writeFile(
join(bin, "gh"),
'#!/bin/sh\nprintf "%s " "$@" > "$GH_REQUEST"\ncat "$GH_COMPARISON_FILE"\n',
);
await chmod(join(bin, "gh"), 0o755);
const base = "a".repeat(40);
const head = "c".repeat(40);
const comparison = (
subjects: readonly string[],
overrides: Record<string, unknown> = {},
) => ({
status: "ahead",
behind_by: 0,
total_commits: subjects.length,
commits: subjects.map((subject, index) => ({
sha: index === subjects.length - 1 ? head : "b".repeat(40),
commit: { message: `${subject}\n\n* fixup: body text` },
})),
...overrides,
});
const comparisonFile = workspace.path("gh-comparison.json");
const run = async (value: unknown) => {
await writeFile(comparisonFile, JSON.stringify(value));
return executeWorkflowFixture("bash", ["-c", command], {
env: {
...process.env,
PATH: `${bin}:${process.env.PATH ?? ""}`,
GITHUB_EVENT_NAME: "merge_group",
GITHUB_REPOSITORY: "owner/repository",
MERGE_GROUP_BASE_SHA: base,
MERGE_GROUP_HEAD_SHA: head,
GH_REQUEST: request,
GH_COMPARISON_FILE: comparisonFile,
// A merge group carries no PR title; it must not be consulted.
PR_TITLE: "fix: ignored",
},
});
};
await expect(
run(comparison(["fix(ci): first (#1)", "feat!: second (#2)"])),
).resolves.toMatchObject({ stderr: "" });
expect(await readFile(request, "utf8")).toBe(
`api repos/owner/repository/compare/${base}...${head} `,
);
// The comparison carries file patches; a large group must not exceed the
// per-string environment limit (128 KiB on Linux) or the argument limit.
await expect(
run(
comparison(["fix: large (#3)"], {
files: [{ filename: "large.txt", patch: "+x\n".repeat(1_000_000) }],
}),
),
).resolves.toMatchObject({ stderr: "" });
await expect(
run(comparison(["fix: first (#1)", "Second change (#2)"])),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("Queued commit subject: Second change"),
});
// Incomplete, diverged or foreign comparisons fail closed.
for (const value of [
null,
{},
comparison([]),
comparison(["fix: one (#1)"], { status: "diverged" }),
comparison(["fix: one (#1)"], { behind_by: 1 }),
comparison(["fix: one (#1)"], { total_commits: 251 }),
comparison(["fix: one (#1)"], {
commits: [{ sha: "d".repeat(40), commit: { message: "fix: one" } }],
}),
]) {
await expect(run(value)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("could not be identified"),
});
}
} finally {
await removeTestWorkspace(workspace.root);
}
});
it("qualifies only ci:full label additions at the caller's merge revision", async () => { it("qualifies only ci:full label additions at the caller's merge revision", async () => {
const full = await workflow("ci-full-pr.yml"); const full = await workflow("ci-full-pr.yml");
expect(full.on.pull_request).toMatchObject({ types: ["labeled"] }); expect(full.on.pull_request).toMatchObject({ types: ["labeled"] });
@@ -531,6 +531,9 @@ it("keeps fast feedback separate from complete qualification and release proof",
on: z.object({ on: z.object({
push: z.unknown(), push: z.unknown(),
pull_request: z.unknown(), pull_request: z.unknown(),
merge_group: z.object({
types: z.tuple([z.literal("checks_requested")]),
}),
schedule: z.array(z.object({ cron: z.string() })), schedule: z.array(z.object({ cron: z.string() })),
workflow_call: z.object({ workflow_call: z.object({
inputs: z.object({ inputs: z.object({
@@ -560,8 +563,10 @@ it("keeps fast feedback separate from complete qualification and release proof",
steps: z steps: z
.array( .array(
z.object({ z.object({
name: z.string().optional(),
run: z.string().optional(), run: z.string().optional(),
if: z.string().optional(), if: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
with: z.record(z.string(), z.unknown()).optional(), with: z.record(z.string(), z.unknown()).optional(),
}), }),
) )
@@ -584,12 +589,37 @@ it("keeps fast feedback separate from complete qualification and release proof",
expect(ci.concurrency.group).toContain("'qualification' || 'feedback'"); expect(ci.concurrency.group).toContain("'qualification' || 'feedback'");
expect(ci.concurrency.group).toContain("inputs.source_sha ||"); expect(ci.concurrency.group).toContain("inputs.source_sha ||");
expect(ci.concurrency.group).toContain("github.ref"); expect(ci.concurrency.group).toContain("github.ref");
// A queued group owns an exact-revision group that pushes cannot cancel.
expect(ci.concurrency.group).toContain(
"github.event_name == 'merge_group' && 'merge-queue' ||",
);
expect(ci.concurrency.group).toContain(
"${{ github.event.merge_group.head_sha || inputs.source_sha ||",
);
expect(ci.concurrency["cancel-in-progress"]).toBe( expect(ci.concurrency["cancel-in-progress"]).toBe(
"${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}", "${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}",
); );
// Failed dependencies still reach the gate, but superseded PR runs must // Failed dependencies still reach the gate, but superseded PR runs must
// release their concurrency slot without waiting for another runner. // release their concurrency slot without waiting for another runner. A
expect(ci.jobs.required?.if).toBe("${{ !cancelled() }}"); // cancelled merge group must fail the gate: a skipped required check passes.
expect(ci.jobs.required?.if).toBe(
"${{ github.event_name == 'merge_group' || !cancelled() }}",
);
// Classification diffs the queued group's base and exact head.
const classification = ci.jobs.changes?.steps ?? [];
const bind = classification.find(
(step) =>
step.name === "Bind classification to the queued merge-group head",
);
expect(bind?.if).toBe("github.event_name == 'merge_group'");
expect(bind?.run).toContain(
'test "${MERGE_GROUP_HEAD_SHA}" = "${GITHUB_SHA}"',
);
const plan = classification.find(
(step) => step.run === "node scripts/ci/plan.mjs",
);
expect(plan?.env?.BASE_SHA).toContain("github.event.merge_group.base_sha");
expect(plan?.env?.HEAD_SHA).toContain("github.event.merge_group.head_sha");
const shards = ci.jobs["test-shard"]?.steps ?? []; const shards = ci.jobs["test-shard"]?.steps ?? [];
const fast = shards.find((step) => step.run?.includes("npm run test:built")); const fast = shards.find((step) => step.run?.includes("npm run test:built"));
const full = shards.find((step) => step.run?.includes("test:ci:shard:run")); const full = shards.find((step) => step.run?.includes("test:ci:shard:run"));
@@ -734,11 +764,11 @@ it("rejects incomplete or malformed classification even when available jobs succ
} }
}); });
it("fails fast only for ordinary PR matrices and rejects partial portable feedback", async () => { it("fails fast only for PR and merge-queue matrices and rejects partial portable feedback", async () => {
const { jobs } = await workflow(); const { jobs } = await workflow();
expect(jobs["test-shard"]?.strategy).toMatchObject({ expect(jobs["test-shard"]?.strategy).toMatchObject({
"fail-fast": "fail-fast":
"${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}", "${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && needs.changes.outputs.tier == 'fast' }}",
}); });
expect(jobs["package-e2e"]?.strategy).toMatchObject({ "fail-fast": false }); expect(jobs["package-e2e"]?.strategy).toMatchObject({ "fail-fast": false });
const flags = await scopeFlags(); const flags = await scopeFlags();