Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- pr-merge's merge job uploads its last saved artifact listing as `pr-merge-verify-listing` (scheduled and hand-started runs, one day), and the merge job, enable-auto-merge and ci-status's merge-queue reader fetch it from the newest such run on main (`.github/scripts/pr-merge/listing-carry.mjs`), so the verdict reader lists only the artifacts uploaded since instead of every page, one GITHUB_TOKEN request each (69 pages on 2026-10-27).
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
bump: patch
---

### Changed

- `babysit_verify_evidence.py` lists only the newest artifact pages on top of a whole listing begun within three hours: its own, the run's saved one, or the one a trusted pr-merge run carried (`BABYSIT_VERIFY_LISTING_CARRY`). It reads pages newest id first down past that listing's highest id until an artifact created two hours before that listing was read, drops entries expired by the first page's `Date` or missing from the pages it read, and keeps the result only when it holds exactly the first page's live `total_count`. Any other count, a missing, older or malformed base, or a failed read lists every page as before. On 2026-10-27 this read 2 pages in 1.3 to 1.6 s where the full listing read 69 pages in 50 to 58 s, and held every artifact the full listing held plus those the full listing skipped at its page boundaries.

### Fixed

- `babysit_verify_evidence.py` no longer judges on a whole artifact listing that skipped an artifact at a page boundary. A whole listing can skip one when an earlier artifact expires while it pages: on 2026-10-27 two of three whole listings skipped live artifacts, FAIL verdicts among them, so a pull request could be allowed past a FAIL. Each whole listing is now deduplicated by id, added to the ones read before it, and kept only when a top-up from the first page shows it holds GitHub's live `total_count`; after three whole listings that never hold it the pull request refuses `verify:unreadable`.
1 change: 1 addition & 0 deletions .github/scripts/pr-merge/clear-escalations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
// GITHUB_API_URL
// GITHUB_RUN_NUMBER where the sweep starts (rotated)
// BABYSIT_VERIFY_GH_TOKEN, BABYSIT_VERIFY_LISTING, BABYSIT_VERIFY_HEAD_REFS,
// BABYSIT_VERIFY_LISTING_CARRY,
// BABYSIT_RUN_CACHE the gate reader's Actions token and the run's shared
// reads, as enable-auto-merge passes them
import process from "node:process";
Expand Down
9 changes: 5 additions & 4 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1839,8 +1839,9 @@ with mock.patch.object(policy, "gh_capture", gh), mock.patch.object(postmerge, "
});

// The reader's own listing rules (babysit_verify_evidence.py main and
// Reader._current_listing), its judgment stubbed to a pass that reads the
// listing: every candidate is allowed, so a run that lists again before an
// Reader._current_listing), each listing from the API stubbed to one call
// (`_complete_listing`, with no base to build on) and its judgment to a pass
// that reads the listing: every candidate is allowed, so a run that lists again before an
// allow on the saved listing would page once per candidate.
test("a reader run pages the artifact listing once for all its candidates", async () => {
const dir = mkdtempSync(join(tmpdir(), "enable-auto-merge-listing-"));
Expand All @@ -1867,15 +1868,15 @@ def gh(args, **_):
def listing(self):
with open(os.environ["LISTINGS"], "a") as log:
log.write("listing\\n")
return [{"total_count": 0, "artifacts": []}]
return []

def passes(self, number, head, pr=None):
self._listing()
return ve.Evidence(None, head, (ve.Verdict(head, "pass", 1),), 0)

argv = sys.argv[1:]
pr = lambda repo, number: {"number": number, "head": {"sha": argv[-1]}}
with mock.patch.object(policy, "gh_capture", gh), mock.patch.object(postmerge, "gh_capture", gh), mock.patch.object(ve.Reader, "_read_listing", listing), mock.patch.object(ve.Reader, "_evidence", passes), mock.patch.object(policy, "pull_request", pr), mock.patch.object(ve, "lane_risk_refusal", lambda *args, **kwargs: None), mock.patch.object(policy, "read_config_at", lambda repo, sha: policy.PipelineConfig(sha, "C2", "pr-merge", "agent[bot]", None, (), "c2-mechanical")), mock.patch.object(policy, "with_default_branch", lambda repo, base, *rest: base), mock.patch.object(babysit_merge, "read_merge_queue", lambda repo, number: {"readError": None, "failedEjections": 0}):
with mock.patch.object(policy, "gh_capture", gh), mock.patch.object(postmerge, "gh_capture", gh), mock.patch.object(ve.Reader, "_incremental", lambda self, *args: None), mock.patch.object(ve.Reader, "_complete_listing", listing), mock.patch.object(ve.Reader, "_evidence", passes), mock.patch.object(policy, "pull_request", pr), mock.patch.object(ve, "lane_risk_refusal", lambda *args, **kwargs: None), mock.patch.object(policy, "read_config_at", lambda repo, sha: policy.PipelineConfig(sha, "C2", "pr-merge", "agent[bot]", None, (), "c2-mechanical")), mock.patch.object(policy, "with_default_branch", lambda repo, base, *rest: base), mock.patch.object(babysit_merge, "read_merge_queue", lambda repo, number: {"readError": None, "failedEjections": 0}):
sys.exit(ve.main(argv))
`,
);
Expand Down
87 changes: 87 additions & 0 deletions .github/scripts/pr-merge/listing-carry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env node
// listing-carry.mjs: hands the verdict reader the repository artifact listing
// the last run saved (babysit_verify_evidence.py BABYSIT_VERIFY_LISTING_CARRY),
// so it lists only the artifacts uploaded since (`Reader._incremental`)
// instead of every page.
//
// The file is the newest scheduled or hand-started run of main's pr-merge.yml
// (rotation.mjs `newestTrustedRun`), this run aside, holding the
// pr-merge-verify-listing artifact, and only when its head commit is on main
// (head-ref-map.mjs `onMain`), as the head-ref map and the carried run cache
// are read. Only a run's own steps can upload its artifacts, so the file was
// written by main's pr-merge.yml; it never goes through actions/cache, which
// any job in main's cache scope can write. Any file already at
// BABYSIT_VERIFY_LISTING_CARRY is removed first, so one left there is never
// read. The reader checks the file itself, and keeps a listing built on it
// only when it holds exactly the live artifact count GitHub reports;
// otherwise it lists every page.
//
// Any failure writes nothing and warns: the reader then lists every page.
//
// Environment:
// ACTIONS_TOKEN the job's GITHUB_TOKEN (actions: read)
// GITHUB_REPOSITORY owner/repo
// GITHUB_RUN_ID this run, which has no listing yet
// RUNNER_TEMP where the artifact is downloaded
// PR_MERGE_READS_PATH the job's shared GET answers
// (rotation.mjs sharedReads), if set
// BABYSIT_VERIFY_LISTING_CARRY the file to write
// GITHUB_API_URL
import { renameSync, rmSync, writeFileSync } from "node:fs";
import process from "node:process";
import { pathToFileURL } from "node:url";

import { createGitHub } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs";
import { onMain } from "./head-ref-map.mjs";
import { ghDownload, newestTrustedRun, sharedReads } from "./rotation.mjs";

export const LISTING_ARTIFACT = "pr-merge-verify-listing";
export const LISTING_FILE = "verify-listing.json";
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;

const escapeCommand = (text) => String(text).replaceAll(/[\r\n%]/g, " ");
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);

// The newest trusted run's saved listing and its run id, or undefined when no
// run has one; an error for a file of another shape.
export async function readListing({ repo, runId, actions: client, download, shared }) {
const actions = sharedReads(client, shared);
const accept = (run) => onMain({ repo, actions, sha: run.head_sha });
const id = await newestTrustedRun({ repo, runId, actions, name: LISTING_ARTIFACT, accept });
if (id === undefined) {
return undefined;
}
const text = download(id);
const data = JSON.parse(text);
if (!isObject(data) || typeof data.fetchedAt !== "string" || !Array.isArray(data.artifacts)) {
throw new Error(`the artifact listing of run ${id} has an unexpected shape`);
}
return { run: id, text, count: data.artifacts.length };
}

export async function main({ env = process.env, actions, download } = {}) {
const repo = env.GITHUB_REPOSITORY ?? "";
const path = env.BABYSIT_VERIFY_LISTING_CARRY;
try {
if (!path) throw new Error("BABYSIT_VERIFY_LISTING_CARRY is not set");
rmSync(path, { force: true });
if (!REPO.test(repo)) throw new Error("GITHUB_REPOSITORY is not owner/repo");
actions ??= createGitHub({ token: env.ACTIONS_TOKEN, apiUrl: env.GITHUB_API_URL || undefined });
download ??= ghDownload({ repo, token: env.ACTIONS_TOKEN, dir: env.RUNNER_TEMP, name: LISTING_ARTIFACT, file: LISTING_FILE });
const found = await readListing({ repo, runId: String(env.GITHUB_RUN_ID), actions, download, shared: env.PR_MERGE_READS_PATH });
if (found === undefined) {
console.log("no earlier artifact listing; the verdict reader lists every page");
return 0;
}
writeFileSync(`${path}.tmp`, found.text);
renameSync(`${path}.tmp`, path);
console.log(`artifact listing of run ${found.run}: ${found.count} artifacts`);
} catch (error) {
console.log(`::warning title=artifact listing::${escapeCommand(error?.message ?? error)}; the verdict reader lists every page`);
}
return 0;
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exitCode = await main();
}
98 changes: 98 additions & 0 deletions .github/scripts/pr-merge/listing-carry.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Expected values come from the incremental listing design: the carried
// listing is read only from the newest scheduled or hand-started pr-merge.yml
// run on main other than this one (found as head-ref-map.mjs finds its map)
// whose head commit is on main, under the name pr-merge-verify-listing, never
// through actions/cache; a file already at the path is never read; any
// failure writes nothing and warns, so the reader lists every page.
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, test } from "node:test";

import { forgetMainTip } from "./head-ref-map.mjs";
import { LISTING_ARTIFACT, main } from "./listing-carry.mjs";
import { mainCall } from "./on-main.fixture.mjs";

const REPO = "melodic-software/claude-code-plugins";
const dir = mkdtempSync(join(tmpdir(), "listing-carry-"));
after(() => rmSync(dir, { recursive: true, force: true }));
const listingOf = (artifacts, extra = {}) => JSON.stringify({ fetchedAt: "2026-10-27T15:00:00+00:00", artifacts, ...extra });
const sha = (id) => String(id).padStart(40, "a");
const wf = (id, event, branch = "main") => ({ id, event, head_branch: branch, path: ".github/workflows/pr-merge.yml", head_sha: sha(id) });

let n = 0;
async function readRun({ pages = [[]], files = {}, offMain = [], fail, before } = {}) {
forgetMainTip();
n += 1;
const path = join(dir, `carry-${n}.json`);
if (before !== undefined) writeFileSync(path, before);
const downloads = [];
const actions = async (method, url) => {
if (fail) throw fail;
const page = /\/actions\/workflows\/pr-merge\.yml\/runs\?branch=main&.*&page=(\d+)$/.exec(url)?.[1];
if (page) return { workflow_runs: pages[Number(page) - 1] ?? [] };
const onMain = mainCall(url, { offMain });
if (onMain !== undefined) return onMain;
const id = /\/actions\/runs\/(\d+)\/artifacts\?per_page=100$/.exec(url)?.[1];
assert.ok(id, `unexpected call ${url}`);
const artifacts = id in files ? [{ name: LISTING_ARTIFACT, expired: false }] : [{ name: "pr-merge-head-refs", expired: false }];
return { total_count: artifacts.length, artifacts };
};
const download = (id) => {
downloads.push(id);
return files[id];
};
const lines = [];
const log = console.log;
console.log = (line) => lines.push(String(line));
try {
assert.equal(await main({ env: { GITHUB_REPOSITORY: REPO, GITHUB_RUN_ID: "999", BABYSIT_VERIFY_LISTING_CARRY: path }, actions, download }), 0);
} finally {
console.log = log;
}
return { written: existsSync(path) ? readFileSync(path, "utf8") : undefined, downloads, lines };
}

test("the newest trusted run on main's listing is written as it was saved", async () => {
const kept = listingOf([{ id: 1 }, { id: 2 }]);
const result = await readRun({
pages: [[wf(999, "schedule"), wf(9, "pull_request_target"), wf(8, "schedule"), wf(7, "workflow_dispatch"), wf(6, "schedule")]],
files: { 9: listingOf([{ id: 66 }]), 8: listingOf([]), 7: kept, 6: listingOf([]) },
offMain: [sha(8)],
});
// This run is skipped, a pull_request_target run is never asked, and run 8
// is not on main.
assert.deepEqual(result.downloads, [7]);
assert.equal(result.written, kept);
assert.ok(result.lines.includes("artifact listing of run 7: 2 artifacts"), result.lines.join("\n"));
});

test("an untrusted run's listing alone writes nothing, so the reader lists every page", async () => {
const result = await readRun({
pages: [[wf(9, "pull_request_target"), wf(8, "push"), wf(7, "schedule", "feature")]],
files: { 9: listingOf([]), 8: listingOf([]), 7: listingOf([]) },
});
assert.deepEqual(result.downloads, []);
assert.equal(result.written, undefined);
});

test("no earlier listing writes nothing, and removes a file left at the path", async () => {
const result = await readRun({ pages: [[wf(5, "schedule")]], before: listingOf([{ id: 1 }]) });
assert.equal(result.written, undefined);
assert.deepEqual(result.lines, ["no earlier artifact listing; the verdict reader lists every page"]);
});

test("an unreadable or malformed listing writes nothing and warns", async () => {
for (const options of [
{ fail: new Error("HTTP 502") },
{ pages: [[wf(1, "schedule")]], files: { 1: "not json" } },
{ pages: [[wf(1, "schedule")]], files: { 1: JSON.stringify([]) } },
{ pages: [[wf(1, "schedule")]], files: { 1: listingOf({}) } },
{ pages: [[wf(1, "schedule")]], files: { 1: listingOf([], { fetchedAt: 1 }) } },
]) {
const result = await readRun({ ...options, before: listingOf([{ id: 1 }]) });
assert.equal(result.written, undefined);
assert.ok(result.lines.some((line) => line.startsWith("::warning title=artifact listing::")), result.lines.join("\n"));
}
});
1 change: 1 addition & 0 deletions .github/scripts/pr-merge/merge-holds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export const HOLD_PATH_CLASSES = Object.freeze([
[".github/scripts/pr-merge/who.mjs", "gate"],
[".github/scripts/pr-merge/post-unarmed-status.mjs", "gate"],
[".github/scripts/pr-merge/verify-answers.mjs", "gate"],
[".github/scripts/pr-merge/listing-carry.mjs", "gate"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py", "gate"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_check_inputs.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_checks.py", "enforcement"],
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/pr-merge/workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,8 @@ test("enable-auto-merge skips its work when a waiting run's enable-auto-merge jo
// answers in that job's RUNNER_TEMP; the readers' tests count what it saves.
test("the merge and enable-auto-merge jobs' kept-file readers share their reads", () => {
for (const [id, scripts] of [
["merge", ["rotation.mjs", "head-ref-map.mjs read", "lane-records.mjs read"]],
["enable-auto-merge", ["head-ref-map.mjs read", "lane-records.mjs read", "verify-answers.mjs"]],
["merge", ["rotation.mjs", "head-ref-map.mjs read", "lane-records.mjs read", "listing-carry.mjs"]],
["enable-auto-merge", ["head-ref-map.mjs read", "lane-records.mjs read", "verify-answers.mjs", "listing-carry.mjs"]],
["post-merge-dispatch", ["lane-records.mjs read", "last-report.mjs"]],
]) {
for (const script of scripts) {
Expand Down
Loading
Loading