diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cd3bbd9d7..54c54fc736 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: branches: [main, "release/*"] + # The merge queue tests each group's exact head before it lands on main. + merge_group: + types: [checks_requested] schedule: - cron: "23 0-2,4-23 * * *" - cron: "23 3 * * *" @@ -33,7 +36,8 @@ concurrency: # Main feedback also finishes, coalescing newer pushes to one pending run. # Scheduled/manual main runs coalesce to one active and the latest pending; # 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') }} env: @@ -102,6 +106,14 @@ jobs: run: | test "${SOURCE_SHA}" = "${GITHUB_SHA}" 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 id: baseline if: github.event_name == 'push' && inputs.source_sha == '' @@ -125,9 +137,9 @@ jobs: - name: Select verification lanes id: plan 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 }} - 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_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }} run: node scripts/ci/plan.mjs @@ -328,7 +340,7 @@ jobs: strategy: # Failed PRs release the remaining matrix slots; qualification retains # 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: # Fast feedback uses one source group and twelve built groups. Shards # are balanced by recorded duration, but hosted runner speed varies, @@ -1215,7 +1227,9 @@ jobs: required: name: CI required # 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: [ changes, diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index bb2715b3ef..4dd2bcd508 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -4,26 +4,74 @@ on: pull_request: branches: [main, "release/*"] 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: {} concurrency: - group: pr-title-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: pr-title-${{ github.event.pull_request.number || github.event.merge_group.head_sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: pr-title: name: Conventional PR title runs-on: ubuntu-24.04 timeout-minutes: 2 + permissions: + contents: read steps: - name: Validate title for Release Please env: PR_TITLE: ${{ github.event.pull_request.title }} - run: >- - node -e 'const title = process.env.PR_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 }} + 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; - if (!conventional.test(title)) { - console.error("PR title must use Conventional Commits, for example: feat: add historical source import"); + const event = process.env.GITHUB_EVENT_NAME; + 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); - }' + } + 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 063b71c47e..6ec47199c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -356,7 +356,9 @@ rather than the generated pages. ## Development feedback and PR verification 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. Select checks for the behavior changed. For a source edit: diff --git a/docs/development/testing.md b/docs/development/testing.md index abe7e1ffe2..359ad19f55 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -1298,6 +1298,34 @@ and workspace rather than reporting successful removal. The controlled `packageUpdateRegistryFailure` regression needs built process modules, uses no 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 See [Real-toolchain verification lanes](../testing-lanes.md#real-toolchain-verification-lanes). diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 81ab6d99d0..8adfdd25af 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -785,14 +785,19 @@ summary. Representative Git-diff cases live in rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or unknown tiers and requires every canonical scope to be explicitly `true` or `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 should require this aggregate, `Static checks`, `Portable tests`, and `Conventional PR title`. Title validation runs separately on PR opening, reopening, commits, and edits; correcting a title refreshes only this lightweight check. It reads title text through an environment variable, executes no PR code, -and has no token permissions. Its separate concurrency group cannot cancel -test runs. Fast PR runs cancel superseded feedback for the same PR. Main push +and its token can only read repository contents. In a merge group it validates +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. Scheduled/manual qualification has a separate concurrency group: active work finishes and only the latest pending run is retained. Release qualification uses diff --git a/scripts/ci/change-plan.mjs b/scripts/ci/change-plan.mjs index 393c352d70..406361544f 100644 --- a/scripts/ci/change-plan.mjs +++ b/scripts/ci/change-plan.mjs @@ -172,7 +172,13 @@ export function planChanges(request, repository) { const { event } = request; 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}`); const requestedFull = @@ -297,11 +303,23 @@ export function planChanges(request, repository) { ); } else if (tier === "full") { 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; let base; if (event === "pull_request") { 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 { const candidate = request.base; const { baselineReason } = request; diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 31698d8ff0..be19bb885e 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -402,6 +402,7 @@ it("passes the workflow environment through the entrypoint unchanged", async () for (const [event, skipFull, tier] of [ ["pull_request", false, "fast"], ["push", false, "fast"], + ["merge_group", false, "fast"], ["schedule", true, "reused"], ] as const) { const expected = await classify( @@ -688,6 +689,33 @@ it("returns an empty baseline when GitHub lookup fails", async ({ skip }) => { 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 () => { const { directory, base } = await fixture(); await git(directory, ["switch", "-c", "unrelated"]); diff --git a/tests/boundary/filesystem/ciPrEvents.test.ts b/tests/boundary/filesystem/ciPrEvents.test.ts index b68bb43af0..357d8a7d54 100644 --- a/tests/boundary/filesystem/ciPrEvents.test.ts +++ b/tests/boundary/filesystem/ciPrEvents.test.ts @@ -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 { parse } from "yaml"; import { z } from "zod"; import { executeWorkflowFixture } from "../../support/workflowGit.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; async function workflow(name: string) { return z .object({ on: z.record(z.string(), z.unknown()), 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( z.string(), z @@ -46,42 +56,153 @@ it("refreshes title validation on edits without checking out PR code or cancelli expect(title.on.pull_request).toMatchObject({ types: ["opened", "synchronize", "reopened", "edited"], }); + expect(title.on.merge_group).toEqual({ types: ["checks_requested"] }); expect(title.permissions).toEqual({}); expect(title.concurrency?.group).not.toBe( (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); expect(jobs).toHaveLength(1); 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. + // The token reads only the merge group comparison. + expect(jobs[0]?.permissions).toEqual({ contents: "read" }); const steps = jobs[0]?.steps ?? []; expect(steps).toHaveLength(1); expect(steps[0]?.uses).toBeUndefined(); expect(steps[0]?.env).toEqual({ 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 runPullRequest = (title: string) => + executeWorkflowFixture("bash", ["-c", command], { + env: { + ...process.env, + GITHUB_EVENT_NAME: "pull_request", + PR_TITLE: title, + }, + }); for (const title of [ "fix(ci): repair workflow", "feat!: changed contract", "docs: literal $(exit 91) `exit 92` text", ]) { - await expect( - executeWorkflowFixture("bash", ["-c", command], { - env: { ...process.env, PR_TITLE: title }, - }), - ).resolves.toMatchObject({ stderr: "" }); + await expect(runPullRequest(title)).resolves.toMatchObject({ stderr: "" }); } for (const title of ["", "Fix CI", "feat: "]) { - await expect( - executeWorkflowFixture("bash", ["-c", command], { - env: { ...process.env, PR_TITLE: title }, - }), - ).rejects.toMatchObject({ code: 1 }); + await expect(runPullRequest(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(); }); +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 = {}, + ) => ({ + 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 () => { const full = await workflow("ci-full-pr.yml"); expect(full.on.pull_request).toMatchObject({ types: ["labeled"] }); diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index c693dd7687..0736e556ec 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -531,6 +531,9 @@ it("keeps fast feedback separate from complete qualification and release proof", on: z.object({ push: 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() })), workflow_call: z.object({ inputs: z.object({ @@ -560,8 +563,10 @@ it("keeps fast feedback separate from complete qualification and release proof", steps: z .array( z.object({ + name: z.string().optional(), run: z.string().optional(), if: z.string().optional(), + env: z.record(z.string(), z.string()).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("inputs.source_sha ||"); 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( "${{ 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 - // release their concurrency slot without waiting for another runner. - expect(ci.jobs.required?.if).toBe("${{ !cancelled() }}"); + // release their concurrency slot without waiting for another runner. A + // 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 fast = shards.find((step) => step.run?.includes("npm run test:built")); 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(); expect(jobs["test-shard"]?.strategy).toMatchObject({ "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 }); const flags = await scopeFlags();