Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changes/guardrails/ci-ci-status-display-name-84f4a087.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changes/source-control/ci-ci-status-display-name-46598936.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 5 additions & 3 deletions .github/actions/log-api-budget/log-api-budget.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions .github/actions/report-check-run/decide.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import {
AGGREGATE_JOBS,
FAILED,
firstFailure,
gateEntries,
INFRA_BACKOFF_MINUTES,
infraOnly,
LOOKBACK_HOURS,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" } });
Expand Down
7 changes: 5 additions & 2 deletions .github/scripts/post-merge-demotion-alert/red-main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions .github/scripts/post-merge-demotion-alert/red-main.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
11 changes: 7 additions & 4 deletions .github/scripts/pr-merge/clear-escalations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;
Expand Down Expand Up @@ -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)) {
Expand Down
14 changes: 11 additions & 3 deletions .github/scripts/pr-merge/clear-escalations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
}
});

Expand Down Expand Up @@ -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/],
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/pr-unblock/dependency-dispatch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" : (
Expand Down
21 changes: 17 additions & 4 deletions .github/scripts/pr-unblock/dependency-dispatch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" }]]]);
Expand Down Expand Up @@ -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"));
});
Expand All @@ -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, []);
Expand Down
5 changes: 3 additions & 2 deletions .github/scripts/pr-unblock/fix-ci-context.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/pr-unblock/fix-ci-dispatch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
Loading
Loading