diff --git a/.changes/guardrails/ci-ci-status-display-name-84f4a087.md b/.changes/guardrails/ci-ci-status-display-name-84f4a087.md new file mode 100644 index 00000000000..fa76671b43b --- /dev/null +++ b/.changes/guardrails/ci-ci-status-display-name-84f4a087.md @@ -0,0 +1,9 @@ +--- +bump: patch +--- + +### Changed + +- `block-unresolved-pr-merge` reads the gate's check run as `CI Status`, else `ci-status` on a head + tested before the rename, and treats the `ci-status` alias of a superseded `CI Status` in the + same Actions run as superseded with it. diff --git a/.changes/source-control/ci-ci-status-display-name-46598936.md b/.changes/source-control/ci-ci-status-display-name-46598936.md new file mode 100644 index 00000000000..9ff2f06a301 --- /dev/null +++ b/.changes/source-control/ci-ci-status-display-name-46598936.md @@ -0,0 +1,9 @@ +--- +bump: patch +--- + +### Changed + +- `babysit-prs` reads the gate as `CI Status`, else `ci-status` on a head tested before the + rename: the Dependabot class's gate read, the post-merge check (CI Status and its `ci-status` + alias count as one check) and the verifier's draft-run read (CI Status's job, never the alias's). diff --git a/.github/actions/log-api-budget/log-api-budget.test.mjs b/.github/actions/log-api-budget/log-api-budget.test.mjs index de98f44d93b..fa13162d5f5 100644 --- a/.github/actions/log-api-budget/log-api-budget.test.mjs +++ b/.github/actions/log-api-budget/log-api-budget.test.mjs @@ -431,9 +431,11 @@ test("outside names the job-token steps no counted client sees in an api-uncount test("ci-status ends with the same api-budget and api-calls notices the action writes", () => withDir((dir) => { readEach(dir, "github-token", head({ used: 13 }), head({ used: 41 }), head({ status: 304, used: 41 })); - // ci-status is the workflow's last job, and its notices come from its last - // step, which runs on failure too, so every earlier step's reads are in them. - const job = CI.slice(CI.indexOf("\n ci-status:\n")); + // ci-status's notices come from its last step, which runs on failure too, + // so every earlier step's reads are in them. The job ends at the next job key. + const start = CI.indexOf("\n ci-status:\n"); + const next = CI.slice(start + 1).search(/\n {2}[a-z][a-z0-9-]*:/); + const job = CI.slice(start, next < 0 ? undefined : start + 1 + next); assert.equal(job.match(/api_budget_emit github-token;/g)?.length, 1, "ci-status emits its notices once"); const last = job.split(/\n {6}- name: /).at(-1).replace(/(\n *(#[^\n]*)?)+$/, ""); assert.match(last, /^Log the API budget at the end\n {8}if: always\(\)\n/, "ci-status ends with the log step"); diff --git a/.github/actions/report-check-run/decide.test.mjs b/.github/actions/report-check-run/decide.test.mjs index 0690590505e..60bc80b9db9 100644 --- a/.github/actions/report-check-run/decide.test.mjs +++ b/.github/actions/report-check-run/decide.test.mjs @@ -639,6 +639,12 @@ test("a ci-status name is refused even for a cancelled run with no verdict", () assert.equal(decision.kind, "refuse"); }); +test("a CI Status name part is refused: the gate's check name is no convention name", () => { + for (const part of [{ lane: "CI Status" }, { activity: "CI Status" }]) { + assert.equal(decide(verdict(part), inputs(part)).kind, "refuse"); + } +}); + test("a lane or activity that is not a convention name is refused", () => { for (const [lane, activity] of [ ["Pr-Refine", "simplify"], diff --git a/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs b/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs index 747dc13f6e6..0608e829474 100644 --- a/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs +++ b/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs @@ -24,7 +24,20 @@ // so a failed job whose list is not whole (stepsKnown) is neither infra nor // real: the reconciler leaves its run for a later pass. -export const AGGREGATE_JOBS = new Set(["ci-status"]); +// The gate job's check name, CI Status. Its alias job reports CI Status's +// conclusion under the old required context, ci-status, until the ruleset +// requires CI Status; a run from before the rename has only ci-status, the gate. +export const GATE_CHECK = "CI Status"; +export const GATE_ALIAS = "ci-status"; +export const AGGREGATE_JOBS = new Set([GATE_CHECK, GATE_ALIAS]); +// The gate's own entries in a listing of jobs or check runs: CI Status's, else +// ci-status's. Never the alias's beside CI Status's: a re-run of the alias +// replays its old `needs` result, and its steps and output are not the gate's. +export const gateEntries = (entries) => { + const list = Array.isArray(entries) ? entries : []; + const gate = list.filter((entry) => entry?.name === GATE_CHECK); + return gate.length > 0 ? gate : list.filter((entry) => entry?.name === GATE_ALIAS); +}; export const INSTALL_STEP = /^(Install |Set up (job|Node|Python)$)/; export const FAILED = new Set(["failure", "cancelled", "timed_out", "startup_failure"]); // A run that ended without a result; reconcile.mjs re-runs its first attempt once. diff --git a/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.test.mjs b/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.test.mjs index b713782bac8..e60c3037e5f 100644 --- a/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.test.mjs +++ b/.github/scripts/maintenance-reconcile-lost-runs/infra-failure.test.mjs @@ -10,7 +10,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; -import { contractOnly, infraCause, infraOnly, reconcilerOwns, reconcilerRetries, stepsKnown, testedRun, timeLimitNoted } from "./infra-failure.mjs"; +import { contractOnly, gateEntries, infraCause, infraOnly, reconcilerOwns, reconcilerRetries, stepsKnown, testedRun, timeLimitNoted } from "./infra-failure.mjs"; const recorded = (id) => JSON.parse(readFileSync(new URL(`./fixtures/jobs-${id}.json`, import.meta.url), "utf8")).jobs; const MIXED = recorded(39710654665); @@ -189,6 +189,25 @@ test("a run where only ci-status ran is contract-only; a tested run is not", () assert.equal(contractOnly([{ name: "ci-status", conclusion: "failure" }]), false); }); +// The gate job shows as CI Status; its alias job, ci-status, reports the same +// conclusion under the old required context. A run from before has only ci-status. +const GATE = { id: 7, name: "CI Status", conclusion: "failure" }; +const ALIAS = { id: 8, name: "ci-status", conclusion: "failure" }; + +test("the gate's entries are CI Status's, never the alias's beside it, and ci-status's in a run from before", () => { + assert.deepEqual(gateEntries([ALIAS, { name: "lint-repo" }, GATE]), [GATE]); + assert.deepEqual(gateEntries([{ name: "lint-repo" }, ALIAS]), [ALIAS]); + assert.deepEqual(gateEntries([{ name: "lint-repo" }]), []); + assert.deepEqual(gateEntries(undefined), []); +}); + +test("CI Status and its alias are both the aggregate: alone they are a contract-only run and a real failure", () => { + const skipped = { name: "lint-repo", conclusion: "skipped" }; + assert.equal(contractOnly([skipped, GATE, ALIAS]), true); + assert.equal(infraOnly([GATE, ALIAS]), false); + assert.equal(infraOnly([{ ...job("cancelled"), conclusion: "cancelled", steps: [{ name: "Install dependencies", conclusion: "cancelled" }] }, GATE, ALIAS]), true); +}); + test("the tested run steps past failed contract-only runs, and stops at any other run", async () => { const jobsOf = new Map([ [3, recorded(39716948399)], diff --git a/.github/scripts/maintenance-reconcile-lost-runs/reconcile.mjs b/.github/scripts/maintenance-reconcile-lost-runs/reconcile.mjs index 65cc5116b62..549ff6001ec 100755 --- a/.github/scripts/maintenance-reconcile-lost-runs/reconcile.mjs +++ b/.github/scripts/maintenance-reconcile-lost-runs/reconcile.mjs @@ -212,6 +212,7 @@ import { AGGREGATE_JOBS, FAILED, firstFailure, + gateEntries, INFRA_BACKOFF_MINUTES, infraOnly, LOOKBACK_HOURS, @@ -569,7 +570,7 @@ const isFailedChecks = (r) => isRedChecks(r) && r.run_attempt === 1; // The failed ci-status job when it is the run's only failed job, else undefined. const onlyAggregateFailed = (jobs) => Array.isArray(jobs) && jobs.every((job) => !FAILED.has(job?.conclusion) || AGGREGATE_JOBS.has(job.name)) - ? jobs.find((job) => AGGREGATE_JOBS.has(job?.name) && job.conclusion === "failure" && Number.isSafeInteger(job.id)) + ? gateEntries(jobs).find((job) => job.conclusion === "failure" && Number.isSafeInteger(job.id)) : undefined; // True when one of the ci-status job's check-run annotations is the kill-switch // error: a re-run with the switch off passes that step. diff --git a/.github/scripts/maintenance-reconcile-lost-runs/reconcile.test.mjs b/.github/scripts/maintenance-reconcile-lost-runs/reconcile.test.mjs index 61d400da137..f4c72daacd4 100644 --- a/.github/scripts/maintenance-reconcile-lost-runs/reconcile.test.mjs +++ b/.github/scripts/maintenance-reconcile-lost-runs/reconcile.test.mjs @@ -2204,6 +2204,16 @@ test("a lanes App PR whose ci-status the kill switch failed has it re-run once t }); }); +test("the re-run reads CI Status's kill-switch note, not its alias's", async () => { + await withLaneList(async (env) => { + const [lane, gate] = switchJobs(); + const jobs = [lane, { id: 503, name: "ci-status", status: "completed", conclusion: "failure", steps: [], notes: [] }, { ...gate, name: "CI Status" }]; + const { code, failedJobReruns } = await reconcile({ pulls: [lanePr(1)], runs: [failed(101, sha(1), { jobs })], env }); + assert.equal(code, 0); + assert.deepEqual(failedJobReruns, [101]); + }); +}); + test("while the kill switch is on, no lanes App PR is read", async () => { await withLaneList(async (env) => { const { code, calls } = await reconcile({ pulls: [lanePr(1)], runs: [failed(101, sha(1), { jobs: switchJobs() })], env: { ...env, KILL_SWITCH: "" } }); diff --git a/.github/scripts/post-merge-demotion-alert/red-main.mjs b/.github/scripts/post-merge-demotion-alert/red-main.mjs index 1b074aabc1a..4b03b106b76 100644 --- a/.github/scripts/post-merge-demotion-alert/red-main.mjs +++ b/.github/scripts/post-merge-demotion-alert/red-main.mjs @@ -2,6 +2,7 @@ // confirmed, which merge in the failing range is the culprit, whether the bot // may revert it, and the report. docs/conventions/pr-pipeline/README.md, // "Red main", states the rule; report.mjs reads GitHub and calls these. +import { AGGREGATE_JOBS, GATE_ALIAS, GATE_CHECK } from "../maintenance-reconcile-lost-runs/infra-failure.mjs"; // The `red-main` leaf of pr-pipeline.yaml (pr-pipeline.schema.json); the one // copy of its defaults. @@ -48,10 +49,12 @@ export const ALERT_FAILED = Object.freeze(["failure", "timed_out", "cancelled", // `_baseline` leaves it: a baseline check excuses the same check later, and // unknown steps prove no failure to excuse. export function failingChecks(jobs, annotations = {}, { failed: conclusions = ["failure"], baseline = false, noFailedJobs = false } = {}) { - const failed = jobs.filter((job) => conclusions.includes(job.conclusion)); + const listed = jobs.filter((job) => conclusions.includes(job.conclusion)); + // The ci-status alias repeats CI Status's conclusion: the two are one check, CI Status. + const failed = listed.some((job) => job.name === GATE_CHECK) ? listed.filter((job) => job.name !== GATE_ALIAS) : listed; const checks = noFailedJobs && failed.length === 0 ? ["pr-require-checks.yml: no failed jobs"] : []; for (const job of failed) { - if (job.name === "ci-status" && failed.length > 1) continue; + if (AGGREGATE_JOBS.has(job.name) && failed.length > 1) continue; const gates = (annotations[job.id] ?? []) .filter((a) => a.annotation_level === "failure" && (a.title ?? "").length > 0) .map((a) => a.title); diff --git a/.github/scripts/post-merge-demotion-alert/red-main.test.mjs b/.github/scripts/post-merge-demotion-alert/red-main.test.mjs index f594acb8ed0..956f77d7e15 100644 --- a/.github/scripts/post-merge-demotion-alert/red-main.test.mjs +++ b/.github/scripts/post-merge-demotion-alert/red-main.test.mjs @@ -55,6 +55,14 @@ test("ci-status beside another failed job adds nothing; alone it is the check", assert.deepEqual(failingChecks([job(1, "ci-status", "failure")]), ["ci-status"]); }); +test("CI Status and its ci-status alias are one check: nothing beside another failed job, CI Status alone", () => { + const gate = job(1, "CI Status", "failure"); + const alias = job(9, "ci-status", "failure"); + assert.deepEqual(failingChecks([gate, alias, job(2, "lint-repo", "failure")]), ["lint-repo"]); + assert.deepEqual(failingChecks([alias, gate]), ["CI Status"]); + assert.deepEqual(failingChecks([job(1, "CI Status", "success"), alias]), ["ci-status"]); +}); + test("a hygiene gate is its own check, and the all-branches gitleaks gate counts against no merge", () => { const aggregatorOnly = job(7, "lint-repo", "failure", ["Aggregate hygiene results"]); const titled = (title) => ({ annotation_level: "failure", title }); diff --git a/.github/scripts/pr-merge/clear-escalations.mjs b/.github/scripts/pr-merge/clear-escalations.mjs index b91f8ab4bd1..aa751ed8382 100755 --- a/.github/scripts/pr-merge/clear-escalations.mjs +++ b/.github/scripts/pr-merge/clear-escalations.mjs @@ -241,8 +241,11 @@ const CI_FIX = Object.freeze(["ci-fix-round-cap", "ci-fix-empty", "ci-fix-invali // The only conclusions a check's latest run at the head may have for a ci-fix // stop to clear; action_required, stale, null and any other keep it. const PASSING = new Set(["success", "neutral", "skipped"]); -// fix-ci's aggregate check, which the human-gated label itself fails. -const AGGREGATE = "ci-status"; +// fix-ci's aggregate check, CI Status, with its ci-status alias: the +// human-gated label itself fails both. Literals, since ci-status's sparse hold +// config checkout holds only the modules this file loads. +const AGGREGATE = "CI Status"; +const AGGREGATE_JOBS = new Set([AGGREGATE, "ci-status"]); // Check-run pages read at a head; a head with more stays unread. const CHECK_PAGES = 3; const SHA = /^[0-9a-f]{40}$/; @@ -469,11 +472,11 @@ async function ciPasses(context) { // Why `head`'s checks do not pass, or null when they do: no check fails, runs // or was cancelled (fix-ci's failingChecks over every run, so a later skip // hides no failure), each check's latest run ended in PASSING, and one passed. -// ci-status is left out: the label fails it. `read` runs a read with a token +// CI Status and its ci-status alias are left out: the label fails them. `read` runs a read with a token // that holds checks: read and answers null when the read fails. async function checksHeld(read, repo, head) { const runs = await read((reader) => checkRuns(reader, repo, head)); - const others = (runs ?? []).filter((run) => run?.name !== AGGREGATE); + const others = (runs ?? []).filter((run) => !AGGREGATE_JOBS.has(run?.name)); const latest = new Map(); for (const run of others) { if (!(latest.get(run.name)?.id > run.id)) { diff --git a/.github/scripts/pr-merge/clear-escalations.test.mjs b/.github/scripts/pr-merge/clear-escalations.test.mjs index 1e13e4a125d..f3a5acd70bc 100644 --- a/.github/scripts/pr-merge/clear-escalations.test.mjs +++ b/.github/scripts/pr-merge/clear-escalations.test.mjs @@ -1499,7 +1499,7 @@ test("a ci-fix stop comes off once a trusted push moved the head and no check bu await run(w, TRUST_ENV); assert.equal(w.writes().length, 2, reason); assert.match(w.writes()[0].body.body, new RegExp(`reason=${reason} head=${HEAD}`)); - assert.match(w.writes()[0].body.body, /no check other than ci-status fails/); + assert.match(w.writes()[0].body.body, /no check other than CI Status fails/); } }); @@ -1799,16 +1799,24 @@ test("a needs-session stop on an older head comes off once the new head carries const { code, logs } = await run(w); assert.equal(code, 0); assert.deepEqual(w.writes(), [{ method: "DELETE", path: `/repos/${REPO}/issues/8/labels/needs-session`, body: undefined }], reason); - const cleared = new RegExp(`#8 needs-session cleared: ${reason} stopped at ${KEY}, and no ${laneContext(reason)} status fails at the new head ${HEAD}; no check other than ci-status fails`); + const cleared = new RegExp(`#8 needs-session cleared: ${reason} stopped at ${KEY}, and no ${laneContext(reason)} status fails at the new head ${HEAD}; no check other than CI Status fails`); assert.ok(logs.some((line) => cleared.test(line)), logs.join("\n")); } }); +test("a red CI Status and its ci-status alias are not counted either", async () => { + const headChecks = [check("test-node", "success"), check("CI Status", "failure", { id: 2 }), check("ci-status", "failure", { id: 3 })]; + const keyStatuses = [status("failure", ACTIONS_BOT, "2026-10-18T10:00:01Z", laneContext("ci-fix-escalated"))]; + const w = sessionWorld({ timeline: [sessionLabeled(BOT), stopped("ci-fix-escalated", KEY)], keyStatuses, headChecks }); + assert.equal((await run(w)).code, 0); + assert.deepEqual(w.writes(), [{ method: "DELETE", path: `/repos/${REPO}/issues/8/labels/needs-session`, body: undefined }]); +}); + test("a new head keeps needs-session while the stop's status fails there or its checks do not pass", async () => { for (const [options, kept] of [ [{ statuses: [status("failure")], headChecks: NEW_HEAD_GREEN }, /pr-unblock\/fix-ci reads failure at the new head/], [{ statuses: [status("pending"), status("success")], headChecks: NEW_HEAD_GREEN }, /pr-unblock\/fix-ci reads pending at the new head/], - [{ headChecks: RED }, /stays at the new head: a check other than ci-status fails/], + [{ headChecks: RED }, /stays at the new head: a check other than CI Status fails/], [{ headChecks: [check("ci-status", "failure")] }, /stays at the new head/], [{ headChecks: [{ ...check("test-node", "success"), status: "in_progress", conclusion: null }] }, /stays at the new head/], [{ headChecks: new Error("HTTP 403") }, /stays at the new head/], diff --git a/.github/scripts/pr-unblock/dependency-dispatch.mjs b/.github/scripts/pr-unblock/dependency-dispatch.mjs index cc945b498ca..25b0b0078a5 100644 --- a/.github/scripts/pr-unblock/dependency-dispatch.mjs +++ b/.github/scripts/pr-unblock/dependency-dispatch.mjs @@ -27,7 +27,7 @@ import { DEPENDABOT_ID, DEPENDABOT_LOGIN, RED } from "./dependency-classify.mjs" import { emptyRecord, isHandled, markHandled, sendDispatch } from "./dispatch-record.mjs"; import { LIMIT_DEFAULTS } from "../work-resolve-issue/limits.mjs"; import { anyHandles, headLagStop, permanentlyHandles, releasedBy, stopRelease } from "./limit-stop.mjs"; -import { ciStatusOf, classifyPull, commitFilesReader } from "./review-dependency.mjs"; +import { ciStatusOf, classifyPull, commitFilesReader, gateCheckRuns } from "./review-dependency.mjs"; export const DEFAULTS = Object.freeze({ base: "main", @@ -104,7 +104,7 @@ export async function dispatchDependencyReviews({ let tier; let existing; try { - const ci = await api.listCheckRuns({ sha: node.headRefOid, checkName: "ci-status", appId }); + const ci = await gateCheckRuns((name) => api.listCheckRuns({ sha: node.headRefOid, checkName: name, appId })); const ciStatus = ciStatusOf(ci); // The same reads the model job's select step classifies from. tier = RED.has(ciStatus) ? "review" : ( diff --git a/.github/scripts/pr-unblock/dependency-dispatch.test.mjs b/.github/scripts/pr-unblock/dependency-dispatch.test.mjs index 85a0b9c5eb3..3aed8c1033f 100644 --- a/.github/scripts/pr-unblock/dependency-dispatch.test.mjs +++ b/.github/scripts/pr-unblock/dependency-dispatch.test.mjs @@ -145,6 +145,18 @@ test("a red ci-status makes a patch bump review", async () => { assert.equal((await run(api)).dispatched.length, 1); }); +test("a red CI Status makes a patch bump review, and a green ci-status alias beside it is not read", async () => { + const node = pr(8, { title: "bump x from 1.0.0 to 1.0.1" }); + const api = fakeApi({ + nodes: [node], + checks: new Map([[node.headRefOid, [{ id: 1, name: "CI Status", app: { slug: "github-actions" }, status: "completed", conclusion: "failure" }]]]), + ci: new Map([[node.headRefOid, [{ id: 2, app: { slug: "github-actions" }, status: "completed", conclusion: "success" }]]]), + }); + assert.equal((await run(api)).dispatched.length, 1); + assert.deepEqual(api.calls.checks.slice(0, 1).map((call) => call.checkName), ["CI Status"]); + assert.equal(api.calls.checks.some((call) => call.checkName === "ci-status"), false); +}); + test("a review-only dependency makes a patch bump review, and an unreadable policy makes every bump review", async () => { const node = pr(9, { title: "bump x from 1.0.0 to 1.0.1", changedFiles: 1 }); const files = new Map([[9, [{ filename: "package.json", patch: "" }]]]); @@ -476,8 +488,9 @@ test("once the cap is reached the rest are left unread", async () => { const lines = []; const { dispatched } = await run(api, { maxDispatch: 2, log: (line) => lines.push(line) }); assert.deepEqual(dispatched.map((d) => d.number), [21, 22]); - // Before: 8 check-run reads and 4 commits listings, for the same two dispatches. - assert.equal(api.calls.checks.length, 4); + // Before: 12 check-run reads and 4 commits listings, for the same two dispatches. + // No head here has a CI Status run, so ci-status is read after it. + assert.equal(api.calls.checks.length, 6); assert.deepEqual(api.calls.commits, [21, 22]); assert.ok(lines.includes("defer #23: 2 review dispatches this run; left for a later run unread")); }); @@ -490,8 +503,8 @@ test("a reviewed head is recorded, and the next run reads nothing for it", async const record = emptyRecord(); const first = fakeApi({ nodes: [node], checks }); assert.deepEqual((await run(first, { record })).dispatched, []); - // ci-status, then both checks' runs. - assert.equal(first.calls.checks.length, 3); + // CI Status, ci-status (none of CI Status's on the head), then both checks' runs. + assert.equal(first.calls.checks.length, 4); assert.ok(record.handled.get(CHECK).has(node.headRefOid)); const second = fakeApi({ nodes: [node], checks }); assert.deepEqual((await run(second, { record })).dispatched, []); diff --git a/.github/scripts/pr-unblock/fix-ci-context.test.mjs b/.github/scripts/pr-unblock/fix-ci-context.test.mjs index eb7f0002ebe..96734fd6c13 100644 --- a/.github/scripts/pr-unblock/fix-ci-context.test.mjs +++ b/.github/scripts/pr-unblock/fix-ci-context.test.mjs @@ -170,13 +170,14 @@ test("a finding's body and the number of findings are capped", async () => { assert.equal(result.file.review_findings[0].body.length, 2000); }); -test("ci-status, passing checks and a check whose later run passed are left out", async () => { +test("CI Status, its ci-status alias, passing checks and a check whose later run passed are left out", async () => { const api = fakeApi({ checkRuns: [ run(11, "lint-shell", "failure"), run(14, "lint-shell", "success"), run(12, "test-node", "success"), - run(13, "ci-status", "failure"), + run(13, "CI Status", "failure"), + run(15, "ci-status", "failure"), ], }); const result = await selectWith(api); diff --git a/.github/scripts/pr-unblock/fix-ci-dispatch.mjs b/.github/scripts/pr-unblock/fix-ci-dispatch.mjs index 8121d49adfa..398ed9953db 100644 --- a/.github/scripts/pr-unblock/fix-ci-dispatch.mjs +++ b/.github/scripts/pr-unblock/fix-ci-dispatch.mjs @@ -255,7 +255,7 @@ export function staleStop(node, { base, appSenderId, holdLabels, gateLabel }) { ) { return null; } - const checks = (contexts.nodes ?? []).filter((context) => context?.__typename === "CheckRun" && context.name !== "ci-status"); + const checks = (contexts.nodes ?? []).filter((context) => context?.__typename === "CheckRun" && !AGGREGATE_JOBS.has(context.name)); if (checks.some((run) => run.status !== "COMPLETED" || !PASSING.has(run.conclusion))) { return null; } @@ -276,7 +276,7 @@ const cancelled = (required) => required?.run?.status === "completed" && require // The lanes passed and another check failed: an advisory lane's, if any. const advisoryFailed = (node, checks, checkName) => lanesState(node) === "SUCCESS" && - checks.some((run) => run.name !== checkName && run.name !== "ci-status" && run.status === "COMPLETED" && run.conclusion === "FAILURE"); + checks.some((run) => run.name !== checkName && !AGGREGATE_JOBS.has(run.name) && run.status === "COMPLETED" && run.conclusion === "FAILURE"); // A job or step name comes from the PR head's workflow file: control characters // are dropped and the length capped, so it cannot start a workflow command in diff --git a/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs b/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs index 3b66000f48c..5960e43bf61 100644 --- a/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs +++ b/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs @@ -1532,6 +1532,21 @@ test("a head whose fix-ci stop fails with every check passed is dispatched fix-c assert.equal(error.api.calls.dispatch.length, 1); }); +test("a fix-ci stop is cleared over a red CI Status and its ci-status alias, as over ci-status alone", async () => { + const renamed = [lanes("SUCCESS"), check("lint", "SUCCESS"), check("CI Status", "FAILURE"), check("ci-status", "FAILURE"), fixCiStop()]; + const { api } = await run({ pages: [[settled(renamed)]] }); + assert.equal(api.calls.dispatch.length, 1); + assert.equal(staleStop(settled(renamed), { base: "main", appSenderId: APP, holdLabels: [], gateLabel: "needs-human" }).state, "FAILURE"); +}); + +test("a red CI Status or ci-status beside passed lanes is no advisory failure", async () => { + for (const name of ["CI Status", "ci-status"]) { + const { api, result } = await run({ pages: [[pr(5, { checks: [lanes("SUCCESS"), check(name, "FAILURE")] })]] }); + assert.deepEqual(result.dispatched, [], name); + assert.deepEqual(api.calls.paths, [], name); + } +}); + test("a fix-ci stop is left on a head with a check failing or going, a run since the stop, another account's status or a hold label", async () => { for (const [label, page, options] of [ ["a check is going", settled([lanes("SUCCESS"), check("lint", null), fixCiStop()]), {}], diff --git a/.github/scripts/pr-unblock/gate-source.mjs b/.github/scripts/pr-unblock/gate-source.mjs index e836d0eaa32..4a8bb7a1a1f 100644 --- a/.github/scripts/pr-unblock/gate-source.mjs +++ b/.github/scripts/pr-unblock/gate-source.mjs @@ -296,10 +296,12 @@ const REASON_LINE = /^- Reason: `([a-z][a-z-]*)`$/; export { escalationActivity, escalationRoute, ROUTES } from "./escalation-routes.mjs"; export const FAILING = new Set(["failure", "timed_out", "startup_failure"]); -// The aggregate of the others: fixing them fixes it. It also fails on a hold -// label, so it never stands for the head's CI. -const AGGREGATE = "ci-status"; -// A contract-only run skips every lane, and ci-status then carries the last +// The aggregate of the others, CI Status, and its ci-status alias: fixing the +// others fixes them. They also fail on a hold label, so they never stand for +// the head's CI. A literal, not infra-failure.mjs's AGGREGATE_JOBS: this file +// also runs from a copy of this directory alone and from a sparse checkout. +const AGGREGATE_JOBS = new Set(["CI Status", "ci-status"]); +// A contract-only run skips every lane, and CI Status then carries the last // full run's recorded verdict forward, so a skip says nothing about a check. const SILENT = new Set(["skipped", "neutral"]); const silent = (run) => run.status === "completed" && SILENT.has(run.conclusion); @@ -322,7 +324,7 @@ export function latestRuns(checkRuns) { // clear-escalations reads a fix-ci stop's head through it. export function failingChecks(checkRuns) { return [...latestRuns(checkRuns).values()] - .filter((run) => run.name !== AGGREGATE && run.status === "completed" && (FAILING.has(run.conclusion) || run.conclusion === "cancelled")) + .filter((run) => !AGGREGATE_JOBS.has(run.name) && run.status === "completed" && (FAILING.has(run.conclusion) || run.conclusion === "cancelled")) .sort((a, b) => a.id - b.id); } diff --git a/.github/scripts/pr-unblock/review-dependency.mjs b/.github/scripts/pr-unblock/review-dependency.mjs index 11417766632..39bc7551334 100644 --- a/.github/scripts/pr-unblock/review-dependency.mjs +++ b/.github/scripts/pr-unblock/review-dependency.mjs @@ -97,8 +97,8 @@ export function countCheckRounds(checkRunsByCommit) { const newestOf = (checkRuns) => checkRuns.filter((run) => run.app?.slug === "github-actions").sort((a, b) => b.id - a.id)[0]; -// The newest ci-status run GitHub Actions posted on the head: its conclusion -// once completed, else pending. +// The newest gate run GitHub Actions posted on the head: its conclusion once +// completed, else pending. export function ciStatusOf(checkRuns) { const newest = newestOf(checkRuns); return newest?.status === "completed" ? String(newest.conclusion) : "pending"; @@ -117,10 +117,19 @@ export function supersededOnly(annotations, headSha) { return failures.some(marks) && failures.every((message) => marks(message) || EXIT_LINE.test(message)); } +// The gate's check runs on a head, listed by `list(name)`: CI Status's, else +// ci-status's on a head tested before the rename. Never its ci-status alias's +// beside CI Status's: only CI Status carries the gate's annotations. Literals, +// since apply-dependency-verdict.mjs runs from a copy of this directory alone. +export async function gateCheckRuns(list) { + const runs = await list("CI Status"); + return newestOf(runs) ? runs : list("ci-status"); +} + // ciStatusOf, with a superseded contract-only failure read as pending. A // failed annotations read leaves the failure standing. export async function readCiStatus(api, headSha) { - const runs = await api.listCheckRuns(headSha, "ci-status"); + const runs = await gateCheckRuns((name) => api.listCheckRuns(headSha, name)); const status = ciStatusOf(runs); if (status !== "failure") { return status; diff --git a/.github/scripts/pr-unblock/review-dependency.test.mjs b/.github/scripts/pr-unblock/review-dependency.test.mjs index b02210bbd99..796daf516f3 100644 --- a/.github/scripts/pr-unblock/review-dependency.test.mjs +++ b/.github/scripts/pr-unblock/review-dependency.test.mjs @@ -23,7 +23,7 @@ const dependabotCommit = (sha, message = "bump") => ({ commit: { message, author: { name: "dependabot[bot]", email: "49699333+dependabot[bot]@users.noreply.github.com" }, ...SIGNED }, }); -function fakeApi({ pull = {}, comments = [], ciRuns = [], annotations = {}, files = [], modelRuns = {}, commits = null, commitFiles = {}, texts = {} } = {}) { +function fakeApi({ pull = {}, comments = [], gateRuns = [], ciRuns = [], annotations = {}, files = [], modelRuns = {}, commits = null, commitFiles = {}, texts = {} } = {}) { const listed = commits ?? [HEAD, ...Object.keys(modelRuns).filter((sha) => sha !== HEAD)].map((sha) => dependabotCommit(sha)); return { getCommit: async (sha) => ({ sha, files: (commitFiles[sha] ?? []).map((filename) => ({ filename })) }), @@ -42,7 +42,7 @@ function fakeApi({ pull = {}, comments = [], ciRuns = [], annotations = {}, file listIssueComments: async () => comments, listPullFiles: async () => files, listPullCommits: async () => listed, - listCheckRuns: async (sha, name) => (name === "ci-status" ? ciRuns : (modelRuns[sha] ?? [])), + listCheckRuns: async (sha, name) => ({ "CI Status": gateRuns, "ci-status": ciRuns })[name] ?? modelRuns[sha] ?? [], listAnnotations: async (id) => { if (!Object.hasOwn(annotations, id)) throw new Error(`GET check-runs/${id}/annotations returned 500`); return annotations[id]; @@ -170,6 +170,20 @@ test("select does not read a ci-status a full run in flight superseded as red", assert.equal(result.classification.reasons.includes("ci-status-red"), false); }); +test("select reads CI Status's superseded note, not its ci-status alias's red", async () => { + const alias = { id: 10, app: { slug: "github-actions" }, status: "completed", conclusion: "failure" }; + const result = await select({ + api: fakeApi({ pull: { title: "bump x from 1.4.0 to 1.4.1" }, gateRuns: [supersededRun], ciRuns: [alias], annotations: { 9: [EXIT, superseded(HEAD)] } }), + pr: 7, + headSha: HEAD, + appId: APP, + dependabotId: DEPENDABOT, + config: [], + }); + assert.equal(result.decision, "skip"); + assert.equal(result.classification.reasons.includes("ci-status-red"), false); +}); + test("select keeps a superseded-looking ci-status red unless only the marker for this head explains it", async () => { for (const annotations of [ { 9: [EXIT, superseded(OTHER)] }, diff --git a/.github/scripts/pr-verify/probe/replay.mjs b/.github/scripts/pr-verify/probe/replay.mjs index 9ac6b9f9fe7..4929024a605 100644 --- a/.github/scripts/pr-verify/probe/replay.mjs +++ b/.github/scripts/pr-verify/probe/replay.mjs @@ -75,6 +75,7 @@ export const UPSTREAM_JOBS = Object.freeze([ "test-node", "check-managed-files", "check-tests", + "CI Status", "ci-status", ]); @@ -302,12 +303,12 @@ export function scenario(testCase, index, { owner, lane, outsider }) { repository: { full_name: REPOSITORY }, head_repository: { full_name: REPOSITORY }, }, - // A ready pull request's run: ci-status skipped its draft step (resolve-pr draftRun). + // A ready pull request's run: CI Status skipped its draft step (resolve-pr draftRun). jobs: UPSTREAM_JOBS.map((name, i) => ({ name, conclusion: "success", html_url: jobUrl(runId, 8000 + i), - ...(name === "ci-status" && { steps: [{ name: "Note a draft", conclusion: "skipped" }] }), + ...(name === "CI Status" && { steps: [{ name: "Note a draft", conclusion: "skipped" }] }), })), // The pull requests the body's `Siblings:` line names, each `Refs:` the // issue (`{issue}` in its body), merged into main or not, by the lanes diff --git a/.github/scripts/pr-verify/prompt.md b/.github/scripts/pr-verify/prompt.md index 03425f3e176..88f285025b5 100644 --- a/.github/scripts/pr-verify/prompt.md +++ b/.github/scripts/pr-verify/prompt.md @@ -68,8 +68,9 @@ DIFF_PARTSCHUNK_NOTE the change does not touch are skipped by design, so a `skipped` job is a defect only when its suite covers the changed files. When `select-tests` itself is `skipped`, the run was a label or body-edit re-run that ran no suite: the list is then no - evidence either way, and its skips are no defect. `ci-status` sums the others and - fails on holds a person lifts; it is not test evidence. Its `wrapped_node_suites` lists each + evidence either way, and its skips are no defect. `CI Status` (`ci-status` on a run from + before it) sums the others and fails on holds a person lifts, and a `ci-status` job beside it + only repeats its conclusion; neither is test evidence. Its `wrapped_node_suites` lists each Node suite (`suite`) in the judged head's tree that has a sibling `.test.sh` (`wrapper`): CI runs such a suite only through its wrapper, in a `test-bash` job, never in `test-node`, so its evidence is the `test-bash` jobs' conclusions. Job names come from the pull request's own diff --git a/.github/scripts/pr-verify/resolve-pr.mjs b/.github/scripts/pr-verify/resolve-pr.mjs index 8c80fe970e9..17e1e8faf4e 100644 --- a/.github/scripts/pr-verify/resolve-pr.mjs +++ b/.github/scripts/pr-verify/resolve-pr.mjs @@ -69,6 +69,7 @@ import { countRequests } from "../../actions/log-api-budget/rate-guard.mjs"; import { titleRenamers } from "../../actions/select-trusted-text/select-trusted-text.mjs"; import { issuesSuffix, isVerdictName, NAME } from "./write-verdict.mjs"; import { refsIssue, visibleLines } from "../pr-merge/merge-holds.mjs"; +import { GATE_ALIAS, gateEntries } from "../maintenance-reconcile-lost-runs/infra-failure.mjs"; import { carriesSplit, loadRules, personChecks, readSchedule, refinementSpec, SPLIT_CHILD_MARKER, splitChildBody, splitScope, taskItems, webHostsFrom } from "../intake-triage/admit.mjs"; import { issueBody, issueTitle, SCHEDULE_MARKER } from "../recurring-schedule/file-due.mjs"; @@ -348,11 +349,13 @@ async function heldShape(read) { if (!Array.isArray(jobs) || listed.total_count !== jobs.length) { return null; } - const ci = jobs.filter((job) => job?.name === "ci-status"); + // The gate's job: CI Status, whose ci-status alias only repeats its conclusion. + const ci = gateEntries(jobs); if (ci.length !== 1 || ci[0].conclusion !== "failure" || !Number.isSafeInteger(ci[0].id)) { return null; } - if (jobs.some((job) => job !== ci[0] && !["success", "skipped", "neutral"].includes(job?.conclusion))) { + const alias = (job) => job?.name === GATE_ALIAS && job !== ci[0]; + if (jobs.some((job) => job !== ci[0] && !alias(job) && !["success", "skipped", "neutral"].includes(job?.conclusion))) { return null; } const steps = Array.isArray(ci[0].steps) ? ci[0].steps : []; @@ -412,7 +415,7 @@ export async function draftRun(github, repository, runId, read) { if (!Array.isArray(jobs) || listed.total_count !== jobs.length) { throw shortList(`the jobs of run ${runId} could not be read whole`, listed?.total_count, 100); } - const ci = jobs.filter((job) => job?.name === "ci-status"); + const ci = gateEntries(jobs); const steps = ci.length === 1 && Array.isArray(ci[0].steps) ? ci[0].steps.filter((step) => step?.name === DRAFT_STEP) : []; const conclusion = steps.length === 1 ? steps[0].conclusion : undefined; if (conclusion === "success" || conclusion === "skipped") { diff --git a/.github/scripts/pr-verify/resolve-pr.test.mjs b/.github/scripts/pr-verify/resolve-pr.test.mjs index 403a411f6d1..4d501900789 100644 --- a/.github/scripts/pr-verify/resolve-pr.test.mjs +++ b/.github/scripts/pr-verify/resolve-pr.test.mjs @@ -762,6 +762,17 @@ test("a run that failed only on a hold an approval of the head lifts is judged", assert.equal(await held(logOf(OLD_WORDING)), "ok"); }); +// The gate job shows as CI Status, beside its ci-status alias that repeats its +// conclusion; the log read is CI Status's, never the alias's. +test("a held run reads CI Status's log, and its ci-status alias is no other failure", async () => { + const gate = { ...ciJob(), name: "CI Status" }; + const alias = { id: 9009, name: "ci-status", conclusion: "failure", steps: [{ name: "Report the conclusion of `CI Status`", conclusion: "failure" }] }; + const jobs = heldJobs(gate, [{ id: 9002, name: "lint-repo", conclusion: "success" }, alias]); + assert.equal(await held(logOf(PATH_HOLD), { jobs }), "ok"); + const failedLane = heldJobs(gate, [{ id: 9002, name: "lint-repo", conclusion: "failure" }, alias]); + assert.equal(await held(logOf(PATH_HOLD), { jobs: failedLane }), "not-a-passing-run"); +}); + test("a run that failed on anything but a hold an approval lifts starts nothing", async () => { const cases = { "a cost hold beside it": [logOf(PATH_HOLD, COST)], @@ -1137,6 +1148,13 @@ test("a run that started while its pull request was a draft is not judged, thoug assert.equal(await reason({ jobs: READY_JOBS }), "ok"); }); +test("the draft state is CI Status's draft step, not its ci-status alias's steps", async () => { + const alias = { id: 9009, name: "ci-status", conclusion: "success", steps: [{ name: "Report the conclusion of `CI Status`", conclusion: "success" }] }; + const renamed = (jobs) => ({ total_count: 2, jobs: [alias, { ...jobs.jobs[0], name: "CI Status" }] }); + assert.equal((await resolve({ jobs: renamed(DRAFT_JOBS) })).reason, "draft-run"); + assert.equal(await reason({ jobs: renamed(READY_JOBS) }), "ok"); +}); + // The jobs API can list a finished job's steps empty or cut short // (pr-require-checks.yml, the ci-status review-lanes note), so only a listed // conclusion of the draft step decides: `success` a draft's run, `skipped` diff --git a/.github/scripts/release-publish-plugins/ci-status.sh b/.github/scripts/release-publish-plugins/ci-status.sh index 5e155f48324..b826cef4f89 100755 --- a/.github/scripts/release-publish-plugins/ci-status.sh +++ b/.github/scripts/release-publish-plugins/ci-status.sh @@ -2,10 +2,12 @@ # ci-status.sh # ci-status.sh --unfinished # -# Print the ci-status result on in GITHUB_REPOSITORY: success, pending, +# Print the gate's result on in GITHUB_REPOSITORY: success, pending, # failure when any deciding run failed, else another deciding run's -# non-success conclusion. Only GitHub Actions' runs are read: it is the app the -# ruleset requires ci-status from, so another app's run of that name, with any +# non-success conclusion. The gate's check is CI Status; on a SHA with no CI +# Status run, tested before the rename, it is ci-status. Only GitHub Actions' +# runs are read: it is the app the ruleset requires the gate from, so another +# app's run of that name, with any # start time it chooses, never decides. Any GITHUB_TOKEN with checks: write # posts as that app too, as the ruleset's own check does. filter=latest keeps the latest run of # each check suite, so a re-triggered workflow leaves an older run beside a @@ -28,9 +30,14 @@ if [[ $1 == --unfinished ]]; then --jq '.check_runs[] | select(.status != "completed") | .name' fi +# gate_runs : GitHub Actions' runs of that check on . +gate_runs() { gh api "repos/$GITHUB_REPOSITORY/commits/$1/check-runs?check_name=$2&app_id=$ACTIONS_APP_ID&filter=latest&per_page=100" --jq .check_runs; } +runs=$(gate_runs "$1" 'CI%20Status') +[[ $(jq length <<<"$runs") -gt 0 ]] || runs=$(gate_runs "$1" ci-status) + # shellcheck disable=SC2016 # the $ names are jq variables, not shell -gh api "repos/$GITHUB_REPOSITORY/commits/$1/check-runs?check_name=ci-status&app_id=$ACTIONS_APP_ID&filter=latest&per_page=100" --jq ' - .check_runs as $runs +jq -r ' + . as $runs | [$runs[] | try (.started_at | fromdateiso8601) catch null] as $t | (if $t != [] and all($t[]; . != null) then ($t | max) as $last | [range($runs | length) | select($t[.] == $last) | $runs[.]] @@ -40,4 +47,4 @@ gh api "repos/$GITHUB_REPOSITORY/commits/$1/check-runs?check_name=ci-status&app_ | if any($failed[]; . == "failure") then "failure" elif $failed != [] then ($failed[0] // "pending") else "success" end - end' + end' <<<"$runs" diff --git a/.github/scripts/release-publish-plugins/decide-rebuild.test.sh b/.github/scripts/release-publish-plugins/decide-rebuild.test.sh index 505d7419763..6cb91f11c5e 100755 --- a/.github/scripts/release-publish-plugins/decide-rebuild.test.sh +++ b/.github/scripts/release-publish-plugins/decide-rebuild.test.sh @@ -61,7 +61,7 @@ api\ repos/*) runs=${FAKE_RUNS:-"[{\"started_at\":\"2026-10-18T01:00:00Z\",\"status\":\"completed\",\"conclusion\":\"$FAKE_CI\",\"app\":{\"id\":15368}}]"} # Filter on app_id and check_name as the API does, when the query asks for them. app=$(sed -n 's/.*[?&]app_id=\([0-9]*\).*/\1/p' <<<"$2") - name=$(sed -n 's/.*[?&]check_name=\([^&]*\).*/\1/p' <<<"$2") + name=$(sed -n 's/.*[?&]check_name=\([^&]*\).*/\1/p' <<<"$2" | sed 's/%20/ /g') all=$(jq -c --arg app "$app" --arg name "$name" '[.[] | select(($app == "" or (.app.id | tostring) == $app) and ($name == "" or (.name // "ci-status") == $name))]' <<<"$runs") size=${FAKE_PAGE_SIZE:-100} page=0 while @@ -274,6 +274,16 @@ check 'ci-status: a tied cancel and failure is failure' failure "$(ci_status "[$ check 'ci-status: a tied failure and cancel is failure' failure "$(ci_status "[$(run "$t1" failure),$(run "$t1" cancelled)]")" check 'ci-status: a lone cancel is cancelled' cancelled "$(ci_status "[$(run "$t1" cancelled)]")" +# named : one completed GitHub Actions run of check . +named() { printf '{"name":"%s","started_at":%s,"status":"completed","conclusion":"%s","app":{"id":15368}}' "$1" "$2" "$3"; } +check 'ci-status: CI Status decides, not an older ci-status' success "$(ci_status "[$(named 'CI Status' "$t1" success),$(named ci-status "$t2" failure)]")" +check 'ci-status: a red CI Status is failure' failure "$(ci_status "[$(named 'CI Status' "$t1" failure),$(named ci-status "$t1" failure)]")" +check 'ci-status: ci-status decides a SHA with no CI Status run' failure "$(ci_status "[$(named ci-status "$t1" failure)]")" +CI_LOG="$TMP/ci-status.log" +: >"$CI_LOG" +env PATH="$TMP/bin:$PATH" GH_LOG="$CI_LOG" HEAD_SHA="$HEAD_SHA" GITHUB_REPOSITORY=o/r FAKE_RUNS="[$(named 'CI Status' "$t1" success)]" bash "$SCRIPT_DIR/ci-status.sh" "$HEAD_SHA" >/dev/null +check 'ci-status: a SHA with a CI Status run reads it alone' '1 0' "$(grep -c 'check_name=CI%20Status&app_id=15368' "$CI_LOG") $(grep -c 'check_name=ci-status' "$CI_LOG")" + echo '# gate error' new_fixture no FAKE_PRS="$(pr_json MERGEABLE)" run_decide GATE_RC=2 diff --git a/.github/workflows/pr-feedback-dispatch.yml b/.github/workflows/pr-feedback-dispatch.yml index 1407d9eded4..574f20b41f8 100644 --- a/.github/workflows/pr-feedback-dispatch.yml +++ b/.github/workflows/pr-feedback-dispatch.yml @@ -970,8 +970,12 @@ jobs: exit 0 fi id=$(jq -r .id <<<"$run") + # The gate job: CI Status, else ci-status on a run from before the + # rename. Never the ci-status alias beside CI Status: a re-run of the + # alias alone replays its old `needs` result. + gate_job='([.jobs[] | select(.name == "CI Status")] | if length > 0 then . else null end) // [.jobs[] | select(.name == "ci-status")] | max_by(.id) // empty' read_api jobs "the jobs of run $id" "repos/$GITHUB_REPOSITORY/actions/runs/$id/jobs?filter=latest&per_page=100" - job=$(jq -c '[.jobs[] | select(.name == "ci-status")] | max_by(.id) // empty' <<<"$jobs") + job=$(jq -c "$gate_job" <<<"$jobs") if [ "$(jq -r .status <<<"$run")" != completed ]; then case $(jq -r '.status // "absent"' <<<"${job:-null}") in in_progress | completed) ;; @@ -1013,7 +1017,7 @@ jobs: # after both lanes finished, a re-run can only pass again. if [ -n "${polled:-}" ]; then read_api jobs "the jobs of run $id" "repos/$GITHUB_REPOSITORY/actions/runs/$id/jobs?filter=latest&per_page=100" - job=$(jq -c '[.jobs[] | select(.name == "ci-status")] | max_by(.id) // empty' <<<"$jobs") + job=$(jq -c "$gate_job" <<<"$jobs") fi judged_at=$(jq -r 'first((.steps // [])[] | select(.name == "Require both review lanes on this head") | .completed_at | strings) // ""' <<<"${job:-null}") if jq -e --arg s "$judged_at" --arg d "$done_at" 'try (.conclusion == "success" and ($s | fromdateiso8601) >= ($d | fromdateiso8601)) catch false' <<<"${job:-null}" >/dev/null; then diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 1cf91b0be2f..2a45815dd84 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -131,9 +131,11 @@ concurrency: # (`bash`, `python`, `node`). One marker, `mark-lanes`, one resolver, # `select-tests`, ten working jobs, # `lint-repo`, `lint-shell`, `check-plugins`, `check-catalog`, `check-skills`, `test-bash`, -# `test-python`, `test-node`, `check-managed-files` and `check-tests`, and one aggregate, `ci-status`, whose name, -# like this workflow's and this file's, is what the org ci-gate ruleset and its -# verifiers read. No ordinals, no tool name for a multi-tool job, no runner OS +# `test-python`, `test-node`, `check-managed-files` and `check-tests`, and one aggregate, job `ci-status`, whose +# check name is `CI Status`; its alias job `report-ci-status` reports that +# conclusion under the check name `ci-status`, which, like this workflow's and +# this file's name, is what the org ci-gate ruleset and its verifiers read +# until that ruleset requires `CI Status`. No ordinals, no tool name for a multi-tool job, no runner OS # for a portable lane, and no job name another workflow here also uses. A gate # step keeps its `id`, its aggregator key, in whichever job it runs. # @@ -4163,6 +4165,7 @@ jobs: tokens: github-token ci-status: + name: CI Status needs: - mark-lanes - select-tests @@ -5086,7 +5089,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAIL_ROWS: >- - .jobs[] | select(.completed_at and .started_at and .name != "ci-status") + .jobs[] | select(.completed_at and .started_at and (.name | IN("CI Status", "ci-status") | not)) | ([.steps[] | .completed_at // empty] | max) as $last | select($last) | (.conclusion == "cancelled" and ([.steps[].conclusion] | all(. == "success" or . == "skipped"))) as $stuck @@ -6989,7 +6992,7 @@ jobs: # aggregation: the job still fails on the contract step's exit code, and # the `ci-lanes` status is on the SHA for the next contract-only run. if: ${{ !cancelled() }} - uses: melodic-software/ci-workflows/.github/actions/pr-require-checks/aggregate-results@226dad32d631e5870f005b94983261ebbcd737f3 # 226dad3 2026-10-31 + uses: melodic-software/ci-workflows/.github/actions/pr-require-checks/aggregate-results@ac4d8780de793b20e60a66890e767df579c80d83 # ac4d878 2026-11-11 with: # Every lane of the needs graph above — the single source of truth # for the lane list — through the ordered-skip check. Adding a lane @@ -7014,6 +7017,9 @@ jobs: yield-wait-seconds: ${{ ((job.status == 'failure' && github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) || github.event.pull_request.draft) && '0' || '600' }} rerun-contract-only-siblings: ${{ github.event.pull_request.draft && 'false' || 'true' }} carry-forward-wait-seconds: '0' + # report-ci-status mirrors this gate under `ci-status` and runs on + # contract-only events too; the run-shape reads must ignore it. + alias-jobs: ci-status # This step's own name, so the own-step lookup never depends on # how many steps of the job are in progress. step-name: Aggregate lane results @@ -7536,3 +7542,23 @@ jobs: timeout-minutes: 1 run: | [ -z "${API_BUDGET_SH:-}" ] || { eval "$API_BUDGET_SH"; api_budget_emit github-token; api_budget_emit_calls github-token; api_budget_emit_content; api_budget_emit_routes github-token; } + + # Keeps the required context `ci-status` reporting CI Status's conclusion + # until the github-iac ci-gate ruleset requires `CI Status`; then it is + # removed. Like CI Status it carries no contract-only gate, so it reports on + # every event the gate runs on. Re-run CI Status, never this job: a re-run of + # this one alone replays its old `needs` result. + report-ci-status: # lane-coverage-ok: reports CI Status's conclusion under the required context `ci-status` + name: ci-status + needs: [ci-status] + if: ${{ always() }} + runs-on: ubuntu-24.04 + timeout-minutes: 3 + permissions: {} + steps: + - name: Report the conclusion of `CI Status` + env: + RESULT: ${{ needs.ci-status.result }} + run: | + echo "CI Status: $RESULT" + [ "$RESULT" = success ] diff --git a/docs/ci-runner-routing.md b/docs/ci-runner-routing.md index cfcf83a5a62..a0ec318f0ed 100644 --- a/docs/ci-runner-routing.md +++ b/docs/ci-runner-routing.md @@ -74,7 +74,10 @@ inside the same `ci-status` job on the same hosted runner, so they no longer carry status contexts of their own. Fork pull requests receive no secrets and no automated review, by design. -Jobs are named `-` and the job id is the check name: `lint` +Jobs are named `-` and the job id is the check name, except +the gate: job `ci-status` shows as `CI Status`, and its alias job +`report-ci-status` reports that conclusion as `ci-status` until the ci-gate +ruleset requires `CI Status`. `lint` runs linters over source, `check` holds a repository contract across files, `test` executes code. `lint-repo` lints the repository as a whole (text hygiene, the CI configuration, the docs conventions); `lint-shell` runs diff --git a/plugins/guardrails/hooks/block-unresolved-pr-merge.sh b/plugins/guardrails/hooks/block-unresolved-pr-merge.sh index d396df8c98e..6ced64b2bc1 100755 --- a/plugins/guardrails/hooks/block-unresolved-pr-merge.sh +++ b/plugins/guardrails/hooks/block-unresolved-pr-merge.sh @@ -986,8 +986,10 @@ _bupm_runs="$(jq -c '. as $all # `superseded by full run /actions/runs/ in flight on `; its # output summary is empty. Such a run does not count: the named full run's run # of the same check and app decides, and the check is pending until that run -# exists. Only a GitHub Actions run named ci-status is read: any step can print -# the marker, so on another check it proves nothing. Any other failure +# exists. Only the gate's GitHub Actions run is read, CI Status (ci-status on a +# head tested before the rename): any step can print the marker, so on another +# check it proves nothing. The ci-status alias job of a superseded CI Status in +# the same Actions run repeats its failure and is superseded with it. Any other failure # annotation beside the marker and the runner's `Process completed with exit # code .` is a failure of the run's own steps (a contract check), which the # full run cannot clear, so the failure stands. A failed annotations read leaves @@ -1006,8 +1008,15 @@ while IFS= read -r _bupm_id; do read -r _bupm_frun _bupm_fsha <<<"$BUPM_OUT" [[ -n "$_bupm_frun" && "$_bupm_fsha" == "$_bupm_sha" ]] || continue _bupm_sup="$(jq -c --arg k "$_bupm_id" --arg v "$_bupm_frun" '.[$k] = $v' <<<"$_bupm_sup")" || _bupm_sup='{}' -done < <(jq -r '[.[] | select(.status == "completed" and .conclusion == "failure" - and .name == "ci-status" and .app.slug == "github-actions") | .id][:3][]' <<<"$_bupm_runs") +done < <(jq -r '([.[] | select(.name == "CI Status" and .app.slug == "github-actions")] | if length > 0 then . else null end) + // [.[] | select(.name == "ci-status" and .app.slug == "github-actions")] + | [.[] | select(.status == "completed" and .conclusion == "failure") | .id][:3][]' <<<"$_bupm_runs") +# shellcheck disable=SC2016 +_bupm_sup="$(jq -c --argjson sup "$_bupm_sup" ' + def actions_run: (.details_url // "") | if type == "string" then (capture("/actions/runs/(?[0-9]+)(/|$)").r // "") else "" end; + (map(select(.name == "CI Status" and $sup[.id | tostring] and actions_run != "") | {key: actions_run, value: $sup[.id | tostring]}) | from_entries) as $by + | reduce (.[] | select(.name == "ci-status" and .app.slug == "github-actions" and .status == "completed" and .conclusion == "failure")) as $a + ($sup; ($a | actions_run) as $r | if $r != "" and $by[$r] then .[$a.id | tostring] = $by[$r] else . end)' <<<"$_bupm_runs")" || _bupm_sup='{}' # shellcheck disable=SC2016 BUPM_OUT="$(printf '%s\n%s\n' "$_bupm_all" "$_bupm_runs" | jq -rs --argjson sup "$_bupm_sup" ' .[0] as $all | .[1] diff --git a/plugins/guardrails/hooks/block-unresolved-pr-merge.test.sh b/plugins/guardrails/hooks/block-unresolved-pr-merge.test.sh index c3813a8c4d7..fb3c5e8461e 100755 --- a/plugins/guardrails/hooks/block-unresolved-pr-merge.test.sh +++ b/plugins/guardrails/hooks/block-unresolved-pr-merge.test.sh @@ -187,6 +187,16 @@ J echo '{"total_count":3,"check_runs":[{"id":901,"name":"ci-status","app":'"$app"',"check_suite":{"id":1},"status":"completed","conclusion":"success","details_url":"https://github.com/acme/widgets/actions/runs/5000/job/1"},'"$full$sup"']}' >"$FIX/checks.json" echo '['"$extra"'{"annotation_level":"failure","message":"Process completed with exit code 1."},{"annotation_level":"failure","message":"superseded by full run https://github.com/acme/widgets/actions/runs/5001 in flight on '"$sha"': its own ci-status check run is newer than this one and decides the merge gate."}]' >"$FIX/annotations_903.json" ;; + # The same superseded contract-only run (5002) with the gate job shown as + # CI Status and its ci-status alias beside it in each run; the alias of the + # superseded CI Status failed with it and carries no marker of its own. + ci-renamed-superseded-full-success | ci-renamed-superseded-full-absent) + local app='{"id":15368,"slug":"github-actions"}' full='' + local gate='"app":'"$app"',"status":"completed","conclusion":"failure","details_url":"https://github.com/acme/widgets/actions/runs/5002/job/' + [[ "$3" == *-full-success ]] && full='{"id":901,"name":"CI Status","app":'"$app"',"check_suite":{"id":2},"status":"completed","conclusion":"success","details_url":"https://github.com/acme/widgets/actions/runs/5001/job/1"},{"id":902,"name":"ci-status","app":'"$app"',"check_suite":{"id":2},"status":"completed","conclusion":"success","details_url":"https://github.com/acme/widgets/actions/runs/5001/job/2"},' + echo '{"total_count":4,"check_runs":['"$full"'{"id":903,"name":"CI Status","check_suite":{"id":3},'"$gate"'3"},{"id":904,"name":"ci-status","check_suite":{"id":3},'"$gate"'4"}]}' >"$FIX/checks.json" + echo '[{"annotation_level":"failure","message":"Process completed with exit code 1."},{"annotation_level":"failure","message":"superseded by full run https://github.com/acme/widgets/actions/runs/5001 in flight on abc123: its own ci-status check run is newer than this one and decides the merge gate."}]' >"$FIX/annotations_903.json" + ;; # A lone failed ci-status whose annotations carry no superseded marker. ci-failed-plain) echo '{"total_count":1,"check_runs":[{"id":903,"name":"ci-status","app":{"id":15368},"check_suite":{"id":3},"status":"completed","conclusion":"failure","details_url":"https://github.com/acme/widgets/actions/runs/5002/job/3"}]}' >"$FIX/checks.json" @@ -343,6 +353,14 @@ assert_contains "malformed marker -> failing" "$ERR" "Failing checks: ci-status" scenario none none ci-superseded-other-check green run "marker on a check that is not ci-status, --auto" 'gh pr merge 42 --auto --squash' 2 assert_contains "marker on another check -> failing" "$ERR" "Failing checks: test" +scenario none none ci-renamed-superseded-full-success green +run "superseded CI Status and its alias, full run succeeded" 'gh pr merge 42 --squash' 0 +assert_eq "renamed superseded, full success -> no stderr" "" "$ERR" +assert_absent "renamed superseded -> the alias's annotations are not read" "$(calls)" "check-runs/904/annotations" +scenario none none ci-renamed-superseded-full-absent green +run "superseded CI Status and its alias, full run not reported" 'gh pr merge 42 --squash' 2 +assert_contains "renamed superseded, full absent -> both pending" "$ERR" "Pending checks (use --auto to merge once they pass): CI Status, ci-status" +assert_absent "renamed superseded, full absent -> not failing" "$ERR" "Failing checks" scenario none none ci-failed-plain green run "lone failed ci-status" 'gh pr merge 42 --squash' 2 assert_contains "lone failure -> names ci-status" "$ERR" "Failing checks: ci-status" diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py index c7ae1269fbd..391a0caf1d2 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py @@ -5157,12 +5157,25 @@ class `LANE_CLASS`; no `work-class:` label is read, but the linked issue # A commit's file list stops at this many entries, so a full one may hide more. COMMIT_FILES_LIMIT = 300 -CI_STATUS_CHECK = "ci-status" -# GitHub Actions, the app the ruleset requires `ci-status` from; another app's +# The gate job's check name. Its alias job reports CI Status's conclusion under +# the old required context, `ci-status`, until the ruleset requires CI Status; +# a head tested before the rename has only `ci-status`, the gate itself. +GATE_CHECK = "CI Status" +GATE_ALIAS = "ci-status" +GATE_NAMES = frozenset({GATE_CHECK, GATE_ALIAS}) +# GitHub Actions, the app the ruleset requires the gate from; another app's # run of that name, with any start time it chooses, never decides the gate. ACTIONS_APP_ID = 15368 +def gate_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The gate's own jobs or check runs in a listing: CI Status's, else + `ci-status`'s. Never the alias's beside CI Status's: its steps and output + are not the gate's, and a re-run of it alone replays its old `needs`.""" + gate = [e for e in entries if e.get("name") == GATE_CHECK] + return gate or [e for e in entries if e.get("name") == GATE_ALIAS] + + def is_dependabot_pull_request(pr: Mapping[str, Any]) -> bool: """A REST pull request opened by Dependabot's App.""" user = json_object(pr.get("user")) @@ -5419,17 +5432,27 @@ def read(sha: str) -> list[str] | None: def _ci_status(owner: str, name: str, head: str) -> str: - """`success`, `pending`, or the conclusion of a finished `ci-status` run on + """`success`, `pending`, or the conclusion of a finished gate run on `head` that did not succeed; `unreadable` when the runs cannot be read. + The gate's runs are CI Status's, else `ci-status`'s on a head tested + before the rename. `filter=latest` keeps the latest run of each check suite, so a re-triggered workflow leaves an older run beside a newer one: the runs that started last decide, and any failure among them decides, as it does among all runs when one has no readable start time. Only GitHub Actions' runs are read.""" - status, payload = _get( - f"repos/{owner}/{name}/commits/{head}/check-runs" - f"?check_name={CI_STATUS_CHECK}&app_id={ACTIONS_APP_ID}&filter=latest&per_page=100" - ) - runs = [json_object(r) for r in json_array(json_object(payload).get("check_runs"))] + + def read(check: str) -> tuple[int, list[dict[str, Any]]]: + status, payload = _get( + f"repos/{owner}/{name}/commits/{head}/check-runs" + f"?check_name={quote(check)}&app_id={ACTIONS_APP_ID}&filter=latest&per_page=100" + ) + return status, [ + json_object(r) for r in json_array(json_object(payload).get("check_runs")) + ] + + status, runs = read(GATE_CHECK) + if status == 200 and not runs: + status, runs = read(GATE_ALIAS) if status != 200 or not SHA_RE.fullmatch(head): return "unreadable" try: diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index fa82e01ce6c..581be0f8b6d 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -194,7 +194,6 @@ "startup_failure", "action_required", ) -LANE_AGGREGATE_JOB = "ci-status" HYGIENE_AGGREGATE_STEP = "Aggregate hygiene results" UNATTRIBUTABLE_GATES = frozenset({"gitleaks failed"}) # A newly failing check demotes only when one of its failing suites is in the @@ -1966,6 +1965,10 @@ def failing_checks(self, run_id: int, attempt: int) -> frozenset[str]: "jobs", ) jobs = [j for j in listed if j.get("conclusion") in FAILED_CONCLUSIONS] + # The ci-status alias repeats CI Status's conclusion: the two are + # one check, CI Status. + if any(j.get("name") == policy.GATE_CHECK for j in jobs): + jobs = [j for j in jobs if j.get("name") != policy.GATE_ALIAS] self.passed_jobs[key] = frozenset( str(j.get("name") or "") for j in listed @@ -1980,7 +1983,7 @@ def failing_checks(self, run_id: int, attempt: int) -> frozenset[str]: unknown: set[str] = set() for job in jobs: name = str(job.get("name") or "") - if name == LANE_AGGREGATE_JOB and len(jobs) > 1: + if name in policy.GATE_NAMES and len(jobs) > 1: continue by_name[name] = job gates = self._failed_gates(job.get("id")) diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py index 53248986dce..c4921ed4a7e 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py @@ -3895,7 +3895,8 @@ def _judged_a_draft(self, sha: str, run_id: int) -> bool: return bool(before) and all(self._draft_run(i) for i in before) def _draft_run(self, run_id: Any) -> bool: - """Whether checks run `run_id`'s latest ci-status job lists DRAFT_STEP + """Whether checks run `run_id`'s latest gate job (CI Status, else + ci-status, never CI Status's ci-status alias) lists DRAFT_STEP as `success` (a draft's run) or `skipped` (not), a condition that reads the run's own event, which a re-run keeps. The jobs API can list a finished job's steps empty or cut short (pr-require-checks.yml's @@ -3918,7 +3919,7 @@ def _draft_run(self, run_id: Any) -> bool: jobs = listed.get("jobs") if not is_json_array(jobs) or listed.get("total_count") != len(jobs): raise _Unreadable(f"the jobs of run {run_id} could not be read whole") - ci = [j for j in map(json_object, jobs) if j.get("name") == "ci-status"] + ci = merge_policy.gate_entries(list(map(json_object, jobs))) listed_steps = json_array(ci[0].get("steps")) if len(ci) == 1 else [] steps = [ step diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py index 5f70de76782..25af8a99faf 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge_policy.py @@ -9545,12 +9545,17 @@ class DependabotEligibility(unittest.TestCase): f"repos/{REPO}/commits/{'e' * 40}/check-runs" "?check_name=ci-status&app_id=15368&filter=latest&per_page=100" ) + GATE = ( + f"repos/{REPO}/commits/{'e' * 40}/check-runs" + "?check_name=CI%20Status&app_id=15368&filter=latest&per_page=100" + ) def fake( self, *, commits: list[dict[str, Any]] | None = None, ci: list[dict[str, Any]] | None = None, + gate: list[dict[str, Any]] | None = None, comments: list[dict[str, Any]] | None = None, author: dict[str, str] | None = None, cross: bool = False, @@ -9610,6 +9615,8 @@ def read(url: str, accept: str) -> Any: gh.rest[self.COMMITS] = self.commits runs = [{"status": "completed", "conclusion": "success"}] if ci is None else ci gh.rest[self.CI] = {"check_runs": runs} + # A head tested before the rename has no CI Status run. + gh.rest[self.GATE] = {"check_runs": gate or []} gh.rest[f"repos/{REPO}/issues/7/comments?per_page=100"] = comments or [] gh.rest[f"repos/{REPO}/pulls/7/reviews?per_page=100"] = [] gh.rest[f"repos/{REPO}/pulls/7/comments?per_page=100"] = [] @@ -10410,6 +10417,19 @@ def test_the_ci_status_run_that_started_last_decides(self) -> None: policy._ci_status(*REPO.split("/"), self.HEAD), expected ) + def test_ci_status_decides_and_its_alias_is_not_read(self) -> None: + red = {"status": "completed", "conclusion": "failure"} + green = {"status": "completed", "conclusion": "success"} + for gate, alias, expected in ((red, green, "failure"), (green, red, "success")): + with self.subTest(gate=gate["conclusion"]): + gh = self.fake(gate=[gate], ci=[alias]) + self.assertEqual( + policy._ci_status(*REPO.split("/"), self.HEAD), expected + ) + read = [c[1] for c in gh.calls if c[:1] == ["api"]] + self.assertIn(self.GATE, read) + self.assertNotIn(self.CI, read) + def test_ci_status_runs_that_started_together_keep_any_failure(self) -> None: at = "2026-10-24T05:40:00Z" red = {"status": "completed", "conclusion": "failure", "started_at": at} diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py index 7574724ebce..2a56636e1fc 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py @@ -2749,6 +2749,15 @@ def test_a_gitleaks_only_failure_is_charged_to_no_merge(self) -> None: self.assertEqual(verdict.failures, ()) self.assertEqual(verdict.unattributed, (self.SIGHTING,)) + def test_ci_status_and_its_alias_beside_a_gitleaks_failure_add_nothing(self) -> None: + jobs = self.gitleaks_red(312) + jobs[1]["name"] = "CI Status" + alias = failed_job(314, "ci-status", "Report the conclusion of `CI Status`") + self.red_after_merge(*jobs, alias) + verdict = self.check() + self.assertEqual(verdict.failures, ()) + self.assertEqual(verdict.unattributed, (self.SIGHTING,)) + def test_a_new_gate_beside_a_gitleaks_failure_is_charged(self) -> None: self.before_merge("failure", *self.gitleaks_red(301)) self.red_after_merge( diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py index 06259a9667e..438d019f037 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py @@ -8207,6 +8207,20 @@ def draft_run(jobs: dict[str, Any]) -> bool: self.assertTrue(draft_run(DRAFT_JOBS)) self.assertFalse(draft_run(READY_JOBS)) + def test_the_draft_state_is_ci_status_s_not_its_alias_s(self) -> None: + def renamed(jobs: dict[str, Any]) -> dict[str, Any]: + alias = {"id": 9009, "name": "ci-status", "conclusion": "success", "steps": []} + return {"total_count": 2, "jobs": [alias, {**jobs["jobs"][0], "name": "CI Status"}]} + + def draft_run(jobs: dict[str, Any]) -> bool: + fake = FakeGitHub([], {}) + fake.jobs[DRAFT_CHECKS] = jobs + with mock.patch.object(postmerge, "gh_capture", side_effect=fake): + return ve.Reader(REPO, "main")._draft_run(DRAFT_CHECKS) + + self.assertTrue(draft_run(renamed(DRAFT_JOBS))) + self.assertFalse(draft_run(renamed(READY_JOBS))) + def test_an_unreadable_draft_state_keeps_the_fail(self) -> None: for label, jobs in UNREADABLE_DRAFT_STATE.items(): with self.subTest(label): diff --git a/scripts/ci-rerun-ci-status.test.sh b/scripts/ci-rerun-ci-status.test.sh index 7cfdbea16ce..0c19fd2488a 100755 --- a/scripts/ci-rerun-ci-status.test.sh +++ b/scripts/ci-rerun-ci-status.test.sh @@ -322,6 +322,13 @@ if grep -qxF "api -i repos/o/r/actions/workflows/pr-review-security-hosted.yml/r else fail "unexpected reads: '$(sort -u "$TMP_ROOT/log")'" fi +# The gate job shows as CI Status, beside its ci-status alias (a later job id): +# the find step names CI Status, whose re-run re-runs the alias after it; the +# alias's own re-run would replay its old needs result. +STUB_JOBS="$(jq -c '.jobs |= ([{id: 99, name: "ci-status", status: "completed", started_at: "2026-10-23T09:51:00Z"}] + map(if .name == "ci-status" then .name = "CI Status" else . end))' <<<"$(cijob completed)")" +expect "a run with CI Status and its ci-status alias re-runs CI Status" 0 "Re-running ci-status (job 77) of run 9 on $HEAD as attempt 2." +check_reran 1 "CI Status's job, not the alias's, is handed to the re-run step" +STUB_JOBS="$(cijob completed)" STUB_LANE_RUNS="$(runs "$(run 4 completed "$HEAD" "$CODE")")" LANE_PATH=$SEC expect "the security lane finishing last reads the code lane" 0 "Re-running ci-status (job 77)" if grep -q '/actions/workflows/pr-review-hosted.yml/runs?' "$TMP_ROOT/log"; then ok "the security lane's run reads the code lane's runs"; else fail "reads: $(paste -sd'|' "$TMP_ROOT/log")"; fi