Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b001e56
ci: display the gate job as CI Status with a ci-status alias
kyle-sexton Nov 11, 2026
05be5aa
docs(pr-verify): name CI Status and its alias in the verifier prompt
kyle-sexton Nov 11, 2026
b19a0ce
ci: name every workflow job in Title Case
kyle-sexton Nov 11, 2026
0a14319
ci: run the org naming lint in CI Status
kyle-sexton Nov 11, 2026
0ab64eb
docs: drop naming rules the org lint now enforces
kyle-sexton Nov 11, 2026
f913fcc
ci: name the alias step in sentence case
kyle-sexton Nov 11, 2026
33a745b
Merge remote-tracking branch 'origin/ci/ci-status-display-name' into …
kyle-sexton Nov 11, 2026
9fdb6ec
ci: run the naming lint as a lint-repo step
kyle-sexton Nov 11, 2026
f803b68
ci: title-case the dead-link workflow job names
kyle-sexton Nov 11, 2026
e586629
Merge remote-tracking branch 'origin/main' into ci/ci-status-display-…
kyle-sexton Nov 11, 2026
ef23060
Merge remote-tracking branch 'origin/ci/ci-status-display-name' into …
kyle-sexton Nov 11, 2026
9c18517
Merge remote-tracking branch 'origin/main' into ci/job-display-names
kyle-sexton Nov 11, 2026
45eba9b
fix(guardrails): keep the merge-guard option description under budget
kyle-sexton Nov 11, 2026
02e37d7
ci: pin aggregate-results to the alias-aware commit
kyle-sexton Nov 11, 2026
d695ffb
Merge remote-tracking branch 'origin/main' into ci/ci-status-display-…
kyle-sexton Nov 11, 2026
2c096fc
Merge remote-tracking branch 'origin/ci/ci-status-display-name' into …
kyle-sexton Nov 12, 2026
73dc83c
Merge remote-tracking branch 'origin/main' into ci/job-display-names
kyle-sexton Nov 12, 2026
1c6f2a7
Merge remote-tracking branch 'origin/main' into ci/job-display-names
kyle-sexton Nov 12, 2026
c063be2
fix(ci): read renamed broker-check and run jobs as pre-model
kyle-sexton Nov 12, 2026
8dedc99
ci: list the workflow-naming gate as CI only
kyle-sexton Nov 12, 2026
65823d6
Merge remote-tracking branch 'origin/main' into ci/job-display-names
kyle-sexton Nov 12, 2026
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
8 changes: 8 additions & 0 deletions .changes/guardrails/ci-job-display-names-7d1e2a40.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: patch
---

### Changed

- The `merge_guard_ignored_checks` default is `github-actions:Merge,github-actions:merge`: the
pr-merge job's check is now `Merge`, and `merge` on a head tested before the rename.
9 changes: 9 additions & 0 deletions .changes/pr-pipeline/ci-job-display-names-4c15e25f.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
bump: patch
---

### Changed

- Every claude-code-plugins workflow job has a Title Case display name, so its check is `Test Node`,
`Fix CI / Run` or `PR Review / review-status`; the pipeline docs name the test lanes and review
status jobs that way.
10 changes: 10 additions & 0 deletions .changes/source-control/ci-job-display-names-3b9c51e8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
bump: patch
---

### Changed

- `babysit-prs` reads the claude-code-plugins jobs by their Title Case check names (`PR Review /
review-status`, `Kill Switch`, `Test Bash (3)`), else by the job id on a head tested before the
rename or in a repository whose jobs have no display name; post-merge baselines compare a check
across both forms. `pull-request` review-venue finds `Test *` lanes too.
8 changes: 8 additions & 0 deletions .changes/work-items/ci-job-display-names-44e8b567.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: patch
---

### Changed

- `morning-brief` reads a reusable caller's activity from its Title Case check name (`Fix CI / Run`)
as well as from its job id.
100 changes: 51 additions & 49 deletions .github/actions/resolve-config/callers.test.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/actions/resolve-config/fix-ci-context.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const api = {
listPullCommits: async () => [fixCommit, fixCommit, fixCommit],
// A failing check: the round cap is read only when one fails.
listCommitCheckRuns: async () =>
Object.assign([{ id: 11, name: "lint-shell", status: "completed", conclusion: "failure", app: { slug: "github-actions" } }], { complete: true }),
Object.assign([{ id: 11, name: "Lint Shell", status: "completed", conclusion: "failure", app: { slug: "github-actions" } }], { complete: true }),
};

test("with no FIX_CI_ROUNDS, main reads loop.fix-ci-rounds from the base config", async () => {
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/intake-reenter/reenter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ test("the workflow runs on a needs-human unlabel", () => {
// and, case-insensitively as GitHub does, over the comment body.
function reenterGate({ event, labels, label = null, comment = null, sender = null, action = event === "issues" ? "unlabeled" : "created", fieldId = null, author = OWNER.id }) {
const workflow = readFileSync(new URL("../../workflows/intake-reenter.yml", import.meta.url), "utf8");
const block = /\n {2}reenter:\n(?: {4}#.*\n)* {4}if: >-\n((?: {6}.*\n)+)/.exec(workflow)?.[1];
const block = /\n {2}reenter:\n(?: {4}#.*\n)*(?: {4}name: .*\n)? {4}if: >-\n((?: {6}.*\n)+)/.exec(workflow)?.[1];
assert.ok(block, "the reenter job has a folded if:");
let body = block
.split("\n")
Expand Down
8 changes: 7 additions & 1 deletion .github/scripts/lib/handoff-evidence.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,15 @@ max_reads=12
# Per requested fact: "true", "false", or the missing evidence, as one line
# "fact<TAB>value".
# shellcheck disable=SC2016 # a jq program, not a shell expansion
# A call's job is listed by its display name, the call's id and the callee's
# job id each in Title Case (`fix-ci / broker-check` shows as
# `Fix CI / Broker Check`), else by those ids: a run started before the
# rename, which this script, read from the default branch tip, can still read.
settle='
. as $jobs
| def job($n): first($jobs[] | select(.name == $n)) // null;
| def titled($id): $id | split("-") | map(if . == "ci" then "CI" else (.[:1] | ascii_upcase) + .[1:] end) | join(" ");
def job($n): ($n | split(" / ")) as $p
| first($jobs[] | select(.name == "\(titled($p[0])) / \(titled($p[1]))")) // first($jobs[] | select(.name == $n)) // null;
def step($j; $s): first(($j.steps // [])[] | select(.name == $s)) // null;
def by_step($j; $s): step($j; $s) as $st
| if $st.conclusion == "success" then "true"
Expand Down
39 changes: 31 additions & 8 deletions .github/scripts/lib/handoff-evidence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ const answer = (status, headers = {}, body) =>
[`HTTP/2.0 ${status}`, ...Object.entries(headers).map(([name, value]) => `${name}: ${value}`), "", body === undefined ? "" : JSON.stringify(body)].join("\r\n");
const listing = (...jobs) => ({ total_count: jobs.length, jobs });

const BROKER = { name: "fix-docs / broker-check", conclusion: "success", steps: [] };
const BROKER = { name: "Fix Docs / Broker Check", conclusion: "success", steps: [] };
// A report job the listing shows before its steps, then with them.
const UNLISTED = { name: "fix-docs / report", conclusion: "success", steps: [] };
const POSTED = { name: "fix-docs / report", conclusion: "success", steps: [{ name: "Post the check run", conclusion: "success" }] };
const UNLISTED = { name: "Fix Docs / Report", conclusion: "success", steps: [] };
const POSTED = { name: "Fix Docs / Report", conclusion: "success", steps: [{ name: "Post the check run", conclusion: "success" }] };

// Runs `handoff-evidence.sh` with no waits, `posted` unless facts says; each
// answer is [gh -i output, gh exit status]. A call past the last answer gets
Expand Down Expand Up @@ -91,12 +91,35 @@ test("a repeated read sends the page's ETag, and a 304 gives back the body that
]);
assert.equal(out.status, 0, out.stdout + out.stderr);
assert.deepEqual(out.args, [`api -i ${FIRST}`, `api -i ${FIRST} -H If-None-Match: "e1"`, `api -i ${FIRST} -H If-None-Match: "e1"`]);
assert.match(out.stdout, /^Read 1: missing: fix-docs \/ report has no steps listed; reading again in 0s$/m);
assert.match(out.stdout, /^Read 2: missing: fix-docs \/ report has no steps listed; reading again in 0s$/m, "the 304 kept the stored body");
assert.match(out.stdout, /^Read 1: missing: Fix Docs \/ Report has no steps listed; reading again in 0s$/m);
assert.match(out.stdout, /^Read 2: missing: Fix Docs \/ Report has no steps listed; reading again in 0s$/m, "the 304 kept the stored body");
assert.match(out.stdout, /^handoff evidence: posted=true \(3 reads\)$/m);
assert.equal(out.output, "posted=true\n");
});

test("a run started before the rename is read by its job ids", () => {
const out = run([[answer("200 OK", {}, listing({ ...POSTED, name: "fix-docs / report" })), 0]]);
assert.equal(out.status, 0, out.stdout + out.stderr);
assert.equal(out.output, "posted=true\n");
});

// Each CALLS id a workflow passes, in Title Case, is the display name of that
// workflow's job of that id, so the script finds the call's report job.
test("every call a workflow names is read under that job's display name", () => {
const dir = new URL("../../workflows/", import.meta.url);
for (const file of ["pr-refine.yml", "pr-unblock.yml", "work-resolve-issue.yml"]) {
const text = readFileSync(new URL(file, dir), "utf8");
const calls = new Set([...text.matchAll(/^ +CALLS: ([\w -]+)$/gm)].flatMap((match) => match[1].split(" ")));
assert.ok(calls.size > 0, file);
for (const call of calls) {
const display = new RegExp(`^ {2}${call}:\\n {4}name: (.+)$`, "m").exec(text)?.[1];
assert.ok(display, `${file} has no named ${call} job`);
const out = run([[answer("200 OK", {}, listing({ ...POSTED, name: `${display} / Report` })), 0]], "0", { calls: call });
assert.equal(out.output, "posted=true\n", `${file} ${call}: ${out.stdout}`);
}
}
});

// The 304 for page 1 carries no Link header: the next page is the one its 200 named.
test("each page of the listing is read through its Link and keeps its own ETag", () => {
const next = `<${SECOND}>; rel="next", <${SECOND}>; rel="last"`;
Expand Down Expand Up @@ -205,7 +228,7 @@ test("a listing that stays unsettled is no failed read: fix-docs reads on throug
const out = run(UNSETTLED.slice(0, 6), "0 0 0 0 0", { env: { HANDOFF_EVIDENCE_LAG: "notice" } });
assert.equal(out.status, 0);
assert.equal(out.args.length, 6);
assert.match(out.stdout, /^::notice::.*after 6 reads: missing: fix-docs \/ report has no steps listed; the outputs stay empty$/m);
assert.match(out.stdout, /^::notice::.*after 6 reads: missing: Fix Docs \/ Report has no steps listed; the outputs stay empty$/m);
assert.equal(out.output, "");
});

Expand Down Expand Up @@ -293,15 +316,15 @@ test("only the fix-docs handoff jobs and the key-credit-markers jobs name HANDOF
test("a fact still unsettled in a listing that was read fails with no output by default", () => {
const out = run(lagged(), "0");
assert.equal(out.status, 1);
assert.match(out.stdout, /^::error::handoff evidence for fix-docs still missing after 2 reads: missing: fix-docs \/ report has no steps listed$/m);
assert.match(out.stdout, /^::error::handoff evidence for fix-docs still missing after 2 reads: missing: Fix Docs \/ Report has no steps listed$/m);
assert.equal(out.output, "");
});

for (const name of ["fix-docs-handoff", "fix-docs-subscription-handoff"]) {
test(`${name}: a listing that lags past the last read ends as a notice with no output`, () => {
const out = run(lagged(), "0", { env: refineJobEnv(name) });
assert.equal(out.status, 0, out.stdout + out.stderr);
assert.match(out.stdout, /^::notice::handoff evidence for fix-docs still missing after 2 reads: missing: fix-docs \/ report has no steps listed; the outputs stay empty$/m);
assert.match(out.stdout, /^::notice::handoff evidence for fix-docs still missing after 2 reads: missing: Fix Docs \/ Report has no steps listed; the outputs stay empty$/m);
assert.doesNotMatch(out.stdout, /::error::/);
assert.equal(out.output, "", "an empty output starts no later leg");
});
Expand Down
23 changes: 15 additions & 8 deletions .github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,22 @@ export const renderState = ({ runs, filed, runUrl, repoUrl, details = [], reache
? `Intake triage did not triage this issue after ${runs.length} dispatches; #${filed} hands it to a maintainer.`
: `The triage sweep dispatched [intake triage](${runUrl}) for this issue (dispatch ${runs.length}; ${ATTEMPTS} runs that reach intake, or ${RUN_LIMIT} dispatches, hand it to a maintainer).`);

// Whether a run reached intake, from its jobs: the intake-triage caller job, or
// a job of the reusable it calls ("intake-triage / ..."), finished neither
// Whether a run reached intake, from its jobs: the Intake Triage caller job, or
// a job of the reusable it calls ("Intake Triage / ..."), finished neither
// skipped nor cancelled. The kill switch, a degraded upstream or a born-triaged
// gate skips it; a run cancelled before it, or that never started, lists it
// cancelled, skipped or not at all.
// cancelled, skipped or not at all. A run from before the job rename names the
// caller intake-triage and the verdict job apply-verdict.
const INTAKE_JOB = /^(?:Intake Triage|intake-triage)(?: \/ |$)/;
const INTAKE_APPLY_JOB = /^(?:Intake Triage|intake-triage) \/ .*-apply$/;
const APPLY_JOB = "Apply Verdict";
const APPLY_JOB_BEFORE_RENAME = "apply-verdict";
export const reachedIntake = (jobs) =>
(jobs ?? []).some(
(j) => /^intake-triage(?: \/ |$)/.test(String(j.name ?? "")) && j.status === "completed" && !["skipped", "cancelled"].includes(j.conclusion),
);
(jobs ?? []).some((j) => INTAKE_JOB.test(String(j.name ?? "")) && j.status === "completed" && !["skipped", "cancelled"].includes(j.conclusion));
const applyJob = (jobs) => {
const done = jobs.filter((j) => j.status === "completed");
return done.find((j) => j.name === APPLY_JOB) ?? done.find((j) => j.name === APPLY_JOB_BEFORE_RENAME);
};

// A run's mark, from its jobs and one annotations read (`annotations(path)`
// returns a check run's annotation list): `-` when it never reached intake,
Expand Down Expand Up @@ -219,9 +226,9 @@ const checkRunPath = (job) => {
};
export async function classifyRun(jobs, annotations) {
if (!reachedIntake(jobs)) return "-";
const apply = jobs.find((j) => j.name === "apply-verdict" && j.status === "completed");
const apply = applyJob(jobs);
if (apply?.conclusion === "skipped") {
const reusable = jobs.find((j) => /^intake-triage \/ .*-apply$/.test(String(j.name ?? "")) && j.conclusion === "success");
const reusable = jobs.find((j) => INTAKE_APPLY_JOB.test(String(j.name ?? "")) && j.conclusion === "success");
const path = checkRunPath(reusable);
if (!path) return "+";
const notes = (await annotations(path)) ?? [];
Expand Down
39 changes: 28 additions & 11 deletions .github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -612,20 +612,26 @@ test("a run still in progress holds the issue for a later pass", async () => {
// switch stopped (run 41591527742: kill-switch succeeded, everything after it
// skipped), and one cancelled before intake.
const job = (name, conclusion) => ({ name, status: "completed", conclusion });
const RAN = [job("kill-switch", "success"), job("born-triaged", "success"), job("intake-triage / claude-intake-triage", "success"), job("intake-triage / claude-intake-triage-skipped", "skipped"), job("apply-verdict", "success")];
const KILLED = [job("born-triaged", "success"), job("kill-switch", "success"), job("intake-triage", "skipped"), job("apply-verdict", "skipped")];
const CANCELLED = [job("kill-switch", "success"), job("born-triaged", "cancelled"), job("intake-triage", "cancelled"), job("apply-verdict", "skipped")];
const RAN = [job("Kill Switch", "success"), job("Born Triaged", "success"), job("Intake Triage / claude-intake-triage", "success"), job("Intake Triage / claude-intake-triage-skipped", "skipped"), job("Apply Verdict", "success")];
const KILLED = [job("Born Triaged", "success"), job("Kill Switch", "success"), job("Intake Triage", "skipped"), job("Apply Verdict", "skipped")];
const CANCELLED = [job("Kill Switch", "success"), job("Born Triaged", "cancelled"), job("Intake Triage", "cancelled"), job("Apply Verdict", "skipped")];

test("reachedIntake: an intake job that ran counts; skipped, cancelled, unfinished or absent does not", () => {
assert.equal(reachedIntake(RAN), true);
assert.equal(reachedIntake([job("intake-triage / claude-intake-triage", "failure")]), true);
assert.equal(reachedIntake([job("Intake Triage / claude-intake-triage", "failure")]), true);
assert.equal(reachedIntake(KILLED), false);
assert.equal(reachedIntake(CANCELLED), false);
assert.equal(reachedIntake([{ name: "intake-triage / claude-intake-triage", status: "in_progress", conclusion: null }]), false);
assert.equal(reachedIntake([job("kill-switch", "success"), job("intake-triage-other", "success")]), false);
assert.equal(reachedIntake([{ name: "Intake Triage / claude-intake-triage", status: "in_progress", conclusion: null }]), false);
assert.equal(reachedIntake([job("Kill Switch", "success"), job("Intake Triage Other", "success")]), false);
assert.equal(reachedIntake([]), false);
});

test("reachedIntake reads a run from before the job rename by its job ids", () => {
assert.equal(reachedIntake([job("kill-switch", "success"), job("intake-triage / claude-intake-triage", "success")]), true);
assert.equal(reachedIntake([job("kill-switch", "success"), job("intake-triage", "skipped")]), false);
assert.equal(reachedIntake([job("intake-triage-other", "success")]), false);
});

test("decide: only runs that reached intake count, and RUN_LIMIT dispatches hand off whatever reached it", () => {
const done = { status: "completed", conclusion: "success" };
assert.equal(decide({ state: { runs: [1, 2], filed: null }, latest: done, reached: 1 }), "dispatch");
Expand Down Expand Up @@ -767,11 +773,11 @@ test("a failing jobs read counts the run, as before: two runs hand the issue off
// returned them for those runs.
const cr = (id) => `https://api.github.com/repos/o/r/check-runs/${id}`;
const withUrl = (name, conclusion, id) => ({ ...job(name, conclusion), check_run_url: cr(id) });
const reachedWith = (...rest) => [job("kill-switch", "success"), job("intake-triage / claude-intake-triage", "success"), ...rest];
const reachedWith = (...rest) => [job("Kill Switch", "success"), job("Intake Triage / claude-intake-triage", "success"), ...rest];
const note = (annotation_level, message) => ({ annotation_level, message });
const FAILED_500 = reachedWith(withUrl("apply-verdict", "failure", 220));
const MISSED_502 = reachedWith(withUrl("apply-verdict", "success", 220));
const DEFERRED = reachedWith(withUrl("intake-triage / claude-intake-triage-apply", "success", 221), job("apply-verdict", "skipped"));
const FAILED_500 = reachedWith(withUrl("Apply Verdict", "failure", 220));
const MISSED_502 = reachedWith(withUrl("Apply Verdict", "success", 220));
const DEFERRED = reachedWith(withUrl("Intake Triage / claude-intake-triage-apply", "success", 221), job("Apply Verdict", "skipped"));
const NOTES = {
500: [note("notice", "api-calls lanes-app=6"), note("failure", "Process completed with exit code 1."), note("failure", "POST /repos/o/r/issues/1/issue-field-values failed with status 500")],
502: [
Expand Down Expand Up @@ -803,6 +809,17 @@ for (const [shape, jobs22, annotations] of [
});
}

test("a run from before the job rename, its jobs named by id, is classified the same way", async () => {
const old = (jobs) => jobs.map((j) => ({ ...j, name: j.name.replace("Kill Switch", "kill-switch").replace("Intake Triage", "intake-triage").replace("Apply Verdict", "apply-verdict") }));
for (const [jobs22, annotations] of [
[old(FAILED_500), { 220: NOTES[500] }],
[old(DEFERRED), { 221: NOTES.deferred }],
]) {
const result = await sweep(transientRuns(jobs22, annotations));
assert.deepEqual([result.dispatched, result.handed], [[1], []], JSON.stringify(jobs22));
}
});

test("a run whose apply failed for a reason a retry does not clear counts", async () => {
for (const message of ["POST /repos/o/r/issues/1/labels failed with status 403", "admission failed: the rules file does not parse"]) {
const gh = transientRuns(FAILED_500, { 220: [note("failure", "Process completed with exit code 1."), note("failure", message)] });
Expand All @@ -829,7 +846,7 @@ test("a run whose apply failed for a reason a retry does not clear counts", asyn
test("a run whose outcome cannot be read counts: a failing annotations read, or no check run to read", async () => {
const unread = transientRuns(FAILED_500, {});
assert.deepEqual((await sweep(unread)).handed, [1]);
const noUrl = transientRuns(reachedWith(job("apply-verdict", "failure")), { 220: NOTES[500] });
const noUrl = transientRuns(reachedWith(job("Apply Verdict", "failure")), { 220: NOTES[500] });
assert.deepEqual((await sweep(noUrl)).handed, [1]);
});

Expand Down
Loading
Loading