diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34f1090c73..95e5b61136 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -670,6 +670,10 @@ jobs: if: ${{ github.event_name == 'merge_group' }} runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} timeout-minutes: 5 + # Tells the generic merge-queue failure notice in `validate` whether this job already + # announced the ejection, so a label-only ejection gets exactly one comment. + outputs: + ejection_notice: ${{ steps.merge_blocking_label_gate.outputs.comment_created }} steps: # The merge-blocking label gate produces a `handoff/comment` artifact when it ejects a @@ -922,6 +926,188 @@ jobs: path: ${{ runner.temp }}/hash-state.json key: ci-hash-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }}-${{ github.run_attempt }} + # A failed workload ejects the queued PR just as invisibly as a blocking label does: this + # required check fails on the queue's transient ref, the PR's own checks stay green, and + # its queue entry silently disappears. The merge-blocking label gate announces its own + # ejections; this step announces every other one (failed or cancelled workloads, a + # fail-closed policy error) so the author learns which jobs failed and where to look. + # Best-effort by construction: it only runs after the gate has already failed and can + # never soften, mask, or retry that result. + - name: Checkout handoff helper + if: ${{ failure() && github.event_name == 'merge_group' }} + uses: actions/checkout@v6.0.2 + with: + sparse-checkout: .github + sparse-checkout-cone-mode: true + + - name: Produce merge-queue failure notice handoff + id: merge_queue_failure_notice + if: ${{ failure() && github.event_name == 'merge_group' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + NEEDS_JSON: ${{ toJSON(needs) }} + # The queue names the PR that heads this group in its temporary ref + # (`gh-readonly-queue//pr--`); that PR owns this run's result. + MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }} + BASE_SHA: ${{ github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.merge_group.head_sha }} + # `true` when the merge-blocking label gate already produced its own ejection notice. + LABEL_NOTICE_EMITTED: ${{ needs.merge_policy.outputs.ejection_notice }} + # This job's own display name: it is still in progress while this step runs, but a + # replay of a completed run would otherwise list the gate itself as a failed job. + SELF_JOB_NAME: Validate workspace + run: | + set -euo pipefail + + # Which jobs the gate just rejected. Mirrors its two scans: any non-success, + # non-skipped result plus any plan-required job that did not succeed. A broken plan + # output must not break the notice, so the required-job scan is fault tolerant here. + failed_jobs="$(jq -r ' + . as $needs | + (try (($needs.plan.outputs.run // "{}") | fromjson) catch {}) as $run | + ( + [ $needs | to_entries[] | select(.value.result != "success" and .value.result != "skipped") | .key ] + + [ (["plan"] + [$run | to_entries[] | select(.value) | .key])[] + | select(($needs[.].result // "missing") != "success") ] + ) | unique[]' <<< "$NEEDS_JSON")" + + pr="$(printf '%s\n' "$MERGE_GROUP_REF" | grep -oE 'pr-[0-9]+' | head -n 1 | grep -oE '[0-9]+' || true)" + if [ -z "$pr" ]; then + echo "::warning::merge_group ref '$MERGE_GROUP_REF' names no pull request; skipping the ejection notice." + exit 0 + fi + pr_json="$(gh api "repos/$REPO/pulls/$pr")" + pr_head="$(jq -r '.head.sha' <<< "$pr_json")" + pr_base="$(jq -r '.base.sha' <<< "$pr_json")" + + # A label ejection is already announced by the gate's own notice (and a PR that still + # carries a blocking label is about to get one). Drop the policy job from this notice + # so a label-only ejection gets exactly one comment; a workload that failed alongside + # the label is still reported. + label_covered=false + if [ "${LABEL_NOTICE_EMITTED:-}" = "true" ]; then + label_covered=true + elif jq -e '[.labels[]?.name] | any(. == "needs-validation" or . == "needs-maintainer-check")' <<< "$pr_json" >/dev/null; then + label_covered=true + fi + skip_job_name="" + if [ "$label_covered" = "true" ]; then + skip_job_name="Merge policy" + failed_jobs="$(printf '%s\n' "$failed_jobs" | grep -vx 'merge_policy' || true)" + fi + if [ -z "$failed_jobs" ]; then + echo "Only the merge-blocking label gate failed; its own notice covers this ejection." + exit 0 + fi + echo "Failed validation jobs: $(printf '%s' "$failed_jobs" | tr '\n' ' ')" + + # Job-level detail straight from the run: display names, the failing step, and a log + # link per failed job. Read-only and best-effort; the notice still ships without it. + jobs_json="$(gh api --paginate "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" | jq -s '[.[].jobs[]]' || echo '[]')" + # Markdown code spans are intentionally literal in these single-quoted strings. + # shellcheck disable=SC2016 + failed_lines="$(jq -r --arg skip "$skip_job_name" --arg self "$SELF_JOB_NAME" ' + .[] + | select(.conclusion != null and .conclusion != "success" and .conclusion != "skipped" and .name != $skip and .name != $self) + | "- **\(.name)** — \(.conclusion)" + + ([.steps[]? | select(.conclusion == "failure") | .name] | if length > 0 then " at `" + join("`, `") + "`" else "" end) + + " ([job log](\(.html_url)))"' <<< "$jobs_json")" + if [ -z "$failed_lines" ]; then + # shellcheck disable=SC2016 + failed_lines="$(printf '%s\n' "$failed_jobs" | sed -E 's/^/- `/; s/$/`/')" + fi + + # Pull the failing assertions out of the job logs (vitest `FAIL` lines, Playwright + # `✘` lines, runner error annotations), capped so the comment stays readable. + # CI logs are full of terminal escape sequences, and gh >= 2.97 refuses to print such + # a body unless told otherwise (GHSA-3m3g-3wcr-px46) while older gh does not know the + # flag — probe once so the excerpt works with either runner image. + gh --version | head -n 1 + # Capture the help text first: `gh api --help | grep -q` would let grep exit early and + # fail the pipeline under pipefail, silently disabling the flag. + api_help="$(gh api --help 2>/dev/null || true)" + escape_flag="" + if grep -q -- '--allow-escape-sequences' <<< "$api_help"; then + escape_flag="--allow-escape-sequences" + fi + esc="$(printf '\033')" + excerpt="" + for job_id in $(jq -r --arg skip "$skip_job_name" --arg self "$SELF_JOB_NAME" '.[] | select(.conclusion == "failure" and .name != $skip and .name != $self) | .id' <<< "$jobs_json" | head -n 4); do + lines="$(gh api ${escape_flag:+"$escape_flag"} "repos/$REPO/actions/jobs/$job_id/logs" 2>/dev/null \ + | tr -d '\r' \ + | sed -E "s/${esc}\[[0-9;]*[A-Za-z]//g; s/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //" \ + | grep -E '^ ?FAIL |^[[:space:]]*[✘×] |^##\[error\]' \ + | grep -vE '^##\[error\]Process completed with exit code' \ + | sed -E 's/^##\[error\]//; s/^[[:space:]]+//' \ + | awk '!seen[$0]++' \ + | head -n 8 || true)" + if [ -n "$lines" ]; then + excerpt="${excerpt}${lines}"$'\n' + fi + done + # Shards that fail on the same test would otherwise repeat the same lines per job. + if [ -n "$excerpt" ]; then + excerpt="$(printf '%s' "$excerpt" | awk '!seen[$0]++')"$'\n' + fi + + # Other PRs batched ahead of this one in the same group (their squashed commits sit + # between the group base and head), so the author can tell whose change may have failed. + group_note="" + others="$(gh api "repos/$REPO/compare/$BASE_SHA...$HEAD_SHA" 2>/dev/null \ + | jq -r '.commits[].commit.message | split("\n")[0]' \ + | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' | grep -vx "$pr" | sort -un | sed 's/^/#/' | paste -s -d ',' - | sed 's/,/, /g' || true)" + if [ -n "$others" ]; then + group_note=" and with the PRs queued ahead of it ($others)" + fi + + handoff_id="merge-queue-ci-failure-pr-$pr" + marker="" + handoff_root="$RUNNER_TEMP/handoff-comment-$handoff_id" + handoff_dir="$(python3 .github/scripts/handoff.py dir comment "$handoff_id" --root "$handoff_root")" + mkdir -p "$handoff_dir" + run_url="$GITHUB_SERVER_URL/$REPO/actions/runs/$RUN_ID" + # Markdown code spans are intentionally literal in these single-quoted strings. + # shellcheck disable=SC2016 + { + printf '%s\n' "$marker" + printf 'Ejected from the merge queue: CI failed on the queued merge of this PR.\n\n' + printf 'The merge queue gate ([run %s](%s)) failed for the group headed by this PR. That run executes on the queue'"'"'s transient ref, so the failure never shows up in this PR'"'"'s own checks — they stay green, the queue entry simply disappears, and this notice is the only visible trace on the PR.\n\n' "$RUN_ID" "$run_url" + printf 'Failed jobs:\n\n%s\n\n' "$failed_lines" + if [ -n "$excerpt" ]; then + printf 'Failure excerpt (best-effort, from the job logs):\n\n```text\n%s```\n\n' "$excerpt" + fi + printf 'The queued merge combines this PR with everything that landed on `main` after its last CI run%s. When this PR'"'"'s own checks are green, the failure is usually one of: a conflict with newer `main` (for example a test added or changed since this branch diverged), a PR ahead of it in the queue, or a flaky test.\n\n' "$group_note" + printf 'To land this PR: open the run above and check whether the failure is related to this change. If it is, merge or rebase onto the latest `main`, fix, push, and add the PR back to the merge queue. If it is unrelated, add the PR back to the merge queue.\n' + } > "$handoff_dir/body.md" + jq -n \ + --arg kind "comment" \ + --arg id "$handoff_id" \ + --arg head "$pr_head" \ + --arg base "$pr_base" \ + --arg marker "$marker" \ + --argjson schema_version 1 \ + --argjson pr_number "$pr" \ + --argjson run_id "$RUN_ID" \ + '{schema_version: $schema_version, kind: $kind, id: $id, pr_number: $pr_number, head_sha: $head, base_sha: $base, run_id: $run_id, marker: $marker}' \ + > "$handoff_dir/metadata.json" + python3 .github/scripts/handoff.py validate comment "$handoff_dir" >/dev/null + { + echo "comment_created=true" + echo "comment_name=$(python3 .github/scripts/handoff.py artifact-name comment "$handoff_id")" + echo "comment_path=$handoff_root" + } >> "$GITHUB_OUTPUT" + echo "Produced merge-queue failure notice for PR #$pr." + + - name: Upload merge-queue failure notice handoff + if: ${{ failure() && steps.merge_queue_failure_notice.outputs.comment_created == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.merge_queue_failure_notice.outputs.comment_name }} + path: ${{ steps.merge_queue_failure_notice.outputs.comment_path }} + runtime_summary: name: Runtime summary needs: diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index f3d446a037..61a783e19b 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -576,6 +576,269 @@ describe("packaged smoke workflow", () => { expect(commentWorkflow).toContain('[ "$RUN_EVENT" != "merge_group" ] && [ "$current_base" != "$base_sha" ]'); }); + it("[P2] surfaces a merge-queue CI failure ejection as a PR comment handoff", async () => { + const ciWorkflow = await readFile(ciWorkflowPath, "utf8"); + const mergePolicy = sectionBetween(ciWorkflow, " merge_policy:", " validate:"); + const validate = sectionBetween(ciWorkflow, " validate:", " runtime_summary:"); + + // The label gate tells the generic notice whether it already announced this ejection, so a + // label-only ejection gets exactly one comment. + expect(mergePolicy).toContain( + "ejection_notice: ${{ steps.merge_blocking_label_gate.outputs.comment_created }}", + ); + expect(validate).toContain("LABEL_NOTICE_EMITTED: ${{ needs.merge_policy.outputs.ejection_notice }}"); + + // Producer: merge-group only, only after the gate has already failed, unable to change the + // gate result, and uploaded on the failure path exactly like the label notice. + expect(validate).toContain(""); + expect(validate).toContain("if: ${{ failure() && github.event_name == 'merge_group' }}"); + expect(validate).toContain("continue-on-error: true"); + expect(validate).toContain( + "if: ${{ failure() && steps.merge_queue_failure_notice.outputs.comment_created == 'true' }}", + ); + // It sits after hash publication so the success path is untouched and the gate's own jq + // programs stay isolated (see extractValidateGateJqPrograms). + expect(validate.indexOf("Save successful hash map")).toBeLessThan( + validate.indexOf("Produce merge-queue failure notice handoff"), + ); + + // Behavior: run the real step script against a stubbed `gh`, then read the handoff it + // produced through the same helper comment.atom.yml uses to consume it. + const script = extractWorkflowRunScript(ciWorkflow, "Produce merge-queue failure notice handoff"); + const prHead = "4821c4ee".padEnd(40, "0"); + const prBase = "1a7a23d4".padEnd(40, "0"); + const groupHead = "e3051e63".padEnd(40, "0"); + const runId = "32105196624"; + const planRun = JSON.stringify({ static_gate: true, daemon_unit_tests: true, e2e_vitest: false }); + const esc = String.fromCharCode(27); + const failedShardLog = [ + `2026-08-18T06:05:15.3329113Z ${esc}[31m⎯⎯⎯ Failed Tests 1 ⎯⎯⎯${esc}[39m`, + `2026-08-18T06:05:15.3330283Z ${esc}[41m${esc}[1m FAIL ${esc}[22m${esc}[49m tests/project-archive.test.ts > buildProjectArchive > exposes a consumable stream with the same archive contents`, + `2026-08-18T06:05:15.3333182Z ${esc}[31mAssertionError: expected [ Array(6) ] to deeply equal [ 'DESIGN-HANDOFF.md', …(4) ]${esc}[39m`, + "2026-08-18T06:05:15.3398469Z ##[error]AssertionError: expected [ Array(6) ] to deeply equal [ 'DESIGN-HANDOFF.md', …(4) ]", + "2026-08-18T06:05:15.4000000Z ##[error]Process completed with exit code 1.", + "", + ].join("\n"); + + async function runNotice(args: { + needs: Record; + labelNoticeEmitted?: string; + labels?: string[]; + mergePolicyConclusion?: string; + // gh >= 2.97 refuses to print a body with terminal escape sequences unless + // `--allow-escape-sequences` is passed; older gh rejects that flag as unknown. + ghSupportsEscapeFlag?: boolean; + }): Promise<{ + stdout: string; + output: Record; + listed: Array>; + body: string; + }> { + const dir = await mkdtemp(join(tmpdir(), "od-merge-queue-notice-")); + const ghPath = join(dir, "gh"); + const outputPath = join(dir, "github-output"); + const runnerTemp = join(dir, "runner-temp"); + await mkdir(runnerTemp); + await writeFile(outputPath, ""); + const mergePolicyConclusion = args.mergePolicyConclusion ?? "success"; + const fixtures = { + pull: { + head: { sha: prHead }, + base: { sha: prBase }, + labels: (args.labels ?? []).map((name) => ({ name })), + }, + jobs: { + jobs: [ + { + id: 101, + name: "Daemon tests (1/4)", + conclusion: "failure", + html_url: `https://github.com/nexu-io/open-design/actions/runs/${runId}/job/101`, + steps: [ + { name: "Checkout", conclusion: "success" }, + { name: "Run daemon test shard", conclusion: "failure" }, + ], + }, + { id: 102, name: "Daemon tests (2/4)", conclusion: "success", html_url: "", steps: [] }, + { + id: 103, + name: "Merge policy", + conclusion: mergePolicyConclusion, + html_url: `https://github.com/nexu-io/open-design/actions/runs/${runId}/job/103`, + steps: [{ name: "Block merge while a merge-blocking label is present", conclusion: mergePolicyConclusion }], + }, + // Replaying a completed run reports the gate itself as failed; it must never be listed. + { id: 104, name: "Validate workspace", conclusion: "failure", html_url: "", steps: [] }, + ], + }, + compare: { + commits: [ + { commit: { message: "fix(daemon): keep daemon as sole writer of run events (#6559)\n\nbody" } }, + { commit: { message: "fix(daemon): list dot-prefixed user content in managed projects (#6214)" } }, + ], + }, + logs: failedShardLog, + }; + const supportsEscapeFlag = args.ghSupportsEscapeFlag ?? true; + await writeFile( + ghPath, + `#!/usr/bin/env node +const fixtures = JSON.parse(process.env.FAKE_GH_FIXTURES); +const supportsEscapeFlag = ${supportsEscapeFlag ? "true" : "false"}; +const argv = process.argv.slice(2); +const args = argv.join(" "); +const hasEscapeFlag = argv.includes("--allow-escape-sequences"); +if (args === "--version") process.stdout.write(supportsEscapeFlag ? "gh version 2.98.0 (fake)\\n" : "gh version 2.87.3 (fake)\\n"); +// Real help text is long: a probe that lets grep exit early would SIGPIPE gh and, under +// pipefail, read as "flag unsupported". Pad well past the pipe buffer so that stays red. +else if (args === "api --help") process.stdout.write((supportsEscapeFlag ? " --allow-escape-sequences Allow printing terminal escape sequences\\n" : " --cache duration Cache the response\\n") + " --padding\\n".repeat(8192)); +else if (hasEscapeFlag && !supportsEscapeFlag) { process.stderr.write("unknown flag: --allow-escape-sequences\\n"); process.exit(1); } +else if (/\\/pulls\\/6214$/.test(args)) process.stdout.write(JSON.stringify(fixtures.pull)); +else if (/\\/actions\\/runs\\/${runId}\\/jobs/.test(args)) process.stdout.write(JSON.stringify(fixtures.jobs)); +else if (/\\/actions\\/jobs\\/\\d+\\/logs$/.test(args)) { + // Real gh >= 2.97 behavior for a body full of ANSI codes: nothing on stdout, exit 1. + if (supportsEscapeFlag && !hasEscapeFlag) { process.stderr.write("the response contains terminal escape sequences; pass --allow-escape-sequences to output it anyway\\n"); process.exit(1); } + process.stdout.write(fixtures.logs); +} +else if (/\\/compare\\//.test(args)) process.stdout.write(JSON.stringify(fixtures.compare)); +else { process.stderr.write("unexpected gh call: " + args + "\\n"); process.exit(1); } +`, + ); + await chmod(ghPath, 0o755); + try { + const { stdout } = await execFileAsync("bash", ["-c", script], { + cwd: workspaceRoot, + env: workflowFixtureEnv( + { + GH_TOKEN: "fake", + REPO: "nexu-io/open-design", + RUN_ID: runId, + NEEDS_JSON: JSON.stringify(args.needs), + MERGE_GROUP_REF: `gh-readonly-queue/main/pr-6214-${prBase}`, + BASE_SHA: prBase, + HEAD_SHA: groupHead, + LABEL_NOTICE_EMITTED: args.labelNoticeEmitted ?? "", + SELF_JOB_NAME: "Validate workspace", + GITHUB_OUTPUT: outputPath, + GITHUB_SERVER_URL: "https://github.com", + RUNNER_TEMP: runnerTemp, + FAKE_GH_FIXTURES: JSON.stringify(fixtures), + }, + dir, + ), + }); + const output = parseGithubOutput(await readFile(outputPath, "utf8")); + let listed: Array> = []; + let body = ""; + if (output.comment_path) { + const { stdout: listStdout } = await execFileAsync( + "python3", + [handoffScriptPath, "list", "comment", output.comment_path], + { cwd: workspaceRoot }, + ); + listed = listStdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + body = await readFile( + join(output.comment_path, "handoff", "comment", "merge-queue-ci-failure-pr-6214", "body.md"), + "utf8", + ); + } + return { stdout, output, listed, body }; + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + + // A failed workload: one notice for the PR heading the group, naming the job, the step, + // the failing assertion, the run, and the PR batched ahead of it. + const workloadFailure = await runNotice({ + needs: { + plan: { result: "success", outputs: { run: planRun } }, + merge_policy: { result: "success", outputs: { ejection_notice: "" } }, + static_gate: { result: "success" }, + daemon_unit_tests: { result: "failure" }, + e2e_vitest: { result: "skipped" }, + }, + }); + expect(workloadFailure.output.comment_created).toBe("true"); + expect(workloadFailure.output.comment_name).toBe("handoff-comment-merge-queue-ci-failure-pr-6214"); + expect(workloadFailure.listed).toHaveLength(1); + expect(workloadFailure.listed[0]).toMatchObject({ + kind: "comment", + id: "merge-queue-ci-failure-pr-6214", + pr_number: 6214, + head_sha: prHead, + base_sha: prBase, + run_id: 32105196624, + marker: "", + }); + expect(workloadFailure.body).toContain(""); + expect(workloadFailure.body).toContain( + `[run ${runId}](https://github.com/nexu-io/open-design/actions/runs/${runId})`, + ); + expect(workloadFailure.body).toContain( + `- **Daemon tests (1/4)** — failure at \`Run daemon test shard\` ([job log](https://github.com/nexu-io/open-design/actions/runs/${runId}/job/101))`, + ); + expect(workloadFailure.body).not.toContain("Daemon tests (2/4)"); + expect(workloadFailure.body).not.toContain("Validate workspace"); + expect(workloadFailure.body).toContain( + "FAIL tests/project-archive.test.ts > buildProjectArchive > exposes a consumable stream with the same archive contents", + ); + expect(workloadFailure.body).toContain("AssertionError: expected [ Array(6) ]"); + expect(workloadFailure.body).not.toContain("Process completed with exit code"); + expect(workloadFailure.body).not.toContain(esc); + expect(workloadFailure.body).toContain("queued ahead of it (#6559)"); + expect(workloadFailure.body).toContain("add the PR back to the merge queue"); + + // A label-only ejection is already announced by the gate's notice: no second comment. + const labelOnly = await runNotice({ + needs: { + plan: { result: "success", outputs: { run: planRun } }, + merge_policy: { result: "failure", outputs: { ejection_notice: "true" } }, + static_gate: { result: "success" }, + daemon_unit_tests: { result: "success" }, + }, + labelNoticeEmitted: "true", + labels: ["needs-validation"], + mergePolicyConclusion: "failure", + }); + expect(labelOnly.output.comment_created).toBeUndefined(); + expect(labelOnly.stdout).toContain("its own notice covers this ejection"); + + // A workload that failed alongside a blocking label is still reported, without repeating + // the policy job — and the live label alone is enough to recognize the label ejection. + const labelPlusWorkload = await runNotice({ + needs: { + plan: { result: "success", outputs: { run: planRun } }, + merge_policy: { result: "failure", outputs: { ejection_notice: "" } }, + static_gate: { result: "success" }, + daemon_unit_tests: { result: "failure" }, + }, + labels: ["needs-maintainer-check"], + mergePolicyConclusion: "failure", + }); + expect(labelPlusWorkload.output.comment_created).toBe("true"); + expect(labelPlusWorkload.body).toContain("**Daemon tests (1/4)**"); + expect(labelPlusWorkload.body).not.toContain("Merge policy"); + + // A runner whose gh predates the escape-sequence policy must still get the excerpt: the + // probe finds no flag and the plain fetch is used. + const olderGh = await runNotice({ + needs: { + plan: { result: "success", outputs: { run: planRun } }, + merge_policy: { result: "success", outputs: { ejection_notice: "" } }, + daemon_unit_tests: { result: "failure" }, + }, + ghSupportsEscapeFlag: false, + }); + expect(olderGh.output.comment_created).toBe("true"); + expect(olderGh.body).toContain("FAIL tests/project-archive.test.ts"); + expect(olderGh.stdout).toContain("gh version 2.87.3 (fake)"); + }); + it("[P1] routes configured contributors into an independent maintainer merge block", async () => { const [routingWorkflow, ciWorkflow, inactivityWorkflow] = await Promise.all([ readFile(contributorMaintainerCheckWorkflowPath, "utf8"), diff --git a/specs/current/ci.md b/specs/current/ci.md index 566b51055b..96f31a9d12 100644 --- a/specs/current/ci.md +++ b/specs/current/ci.md @@ -263,6 +263,14 @@ set, enforces merge policy at convergence, and is the only successful publisher of pending hash state. Runner allocation failure and external cancellation are operational failures rather than alternate coverage policy. +A merge-group failure at `Validate workspace` ejects the queued PR without any +trace on the PR itself: the run executes on the queue's transient ref and the +PR's own checks stay green. Two best-effort `handoff/comment` producers make the +ejection visible through `comment.atom.yml`: `merge_policy` announces a +blocking-label ejection, and `validate` announces every other failure (which +jobs failed, a log excerpt, and the PRs batched ahead in the group) after the +gate has already failed. Neither notice changes the gate result. + ## Current references These sections describe active or observed applications of the method. They are