mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
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:
1 parent
28e95f34d0
commit
5efaedda88
9 files changed
+328
-34
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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<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 () => {
|
||||
const full = await workflow("ci-full-pr.yml");
|
||||
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({
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user