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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `cost_decision` takes a cost decision's decided head from the review lanes' check-run annotations, never from a `github-actions[bot]` hold comment, which any workflow on `GITHUB_TOKEN` can post. The decided head is the `sha=` of the newest `claude-lane-hold ... criteria=` notice with `cost` that a review lane's status job (`pr-review-hosted.yml`, `pr-review-security-hosted.yml`) recorded before the decision, counted only where it names its own run's head and that head is in this repository, so a fork pull request on a same-named branch decides nothing. Every such run on the head branch started before the decision must be on a head that changes neither review-lane workflow and lists fewer than 300 files, or nothing carries; two heads noticed at the newest time carry nothing. The runs, jobs, annotations and those compares are read with `BABYSIT_VERIFY_GH_TOKEN` when it is set. The edit and deletion checks on the hold comment are gone with the comment read.
9 changes: 9 additions & 0 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ for (const [file, job, route, laneSwitch] of [
assert.equal(evaluate(review.with["model-args"], failed), "");
});

test(`${file}: the review grants the reusable's status job its read scopes and one write`, () => {
assert.deepEqual(jobs[job].permissions, {
actions: "read",
checks: "read",
contents: "read",
"pull-requests": "write",
});
});

test(`${file}: the review runs only when the kill-switch job says proceed`, () => {
const review = jobs[job];
const gate = jobs["kill-switch"];
Expand Down
13 changes: 11 additions & 2 deletions .github/scripts/pr-merge/enable-auto-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,7 @@ async function listPulls(github, owner, name) {
throw new Error(`more than ${MAX_PAGES * 100} open pull requests into main`);
}

export async function main({ env = process.env, github, verify, room, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random, latch, now = Date.now } = {}) {
export async function main({ env = process.env, github, actions, verify, room, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random, latch, now = Date.now } = {}) {
try {
const repo = REPO.exec(env.GITHUB_REPOSITORY ?? "");
if (!repo) {
Expand All @@ -1046,8 +1046,17 @@ export async function main({ env = process.env, github, verify, room, sleep = (m
const onResponse = (response) => {
answer = { limited: response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after"), text: response.clone().text().catch(() => "") };
};
const injected = github !== undefined;
latch ??= rateLimitLatch;
github = withRetry(github ?? createGitHub({ token: env.GH_TOKEN, apiUrl: env.GITHUB_API_URL || undefined, onResponse, latch }), { sleep, random });
// The lane runs, jobs and annotations a cost decision's decided head is
// read from, on the job token (actions: read, checks: read), as the
// verifier reads take it; the lanes App token holds neither.
actions =
actions ??
(!injected && env.BABYSIT_VERIFY_GH_TOKEN
? withRetry(createGitHub({ token: env.BABYSIT_VERIFY_GH_TOKEN, apiUrl: env.GITHUB_API_URL || undefined, latch }), { sleep, random })
: github);
// A reader child that stops on a rate limit writes its stop to the job's
// stop file; set from then on. Its stop holds every lanes App pull request
// as an unread room does, so an armed one is disarmed, unless the disarm's
Expand Down Expand Up @@ -1201,7 +1210,7 @@ export async function main({ env = process.env, github, verify, room, sleep = (m
detail = await wholeDiscussion(github, variables, detail);
const { paths: files, capped } = await changedFiles(github, `${repo[1]}/${repo[2]}`, pull.number);
const humans = trustedHumans(env);
const decision = costHold(files, null) ? await costDecision(github, repo[0], pull.number, pull.body, humans, pull.headRefOid) : null;
const decision = costHold(files, null) ? await costDecision(github, repo[0], pull.number, pull.body, humans, pull.headRefOid, actions) : null;
detail = { ...detail, files, filesCapped: capped, costDecision: decision, lifters: headLifters(detail, humans, pull.headRefOid) };
}
} catch (error) {
Expand Down
26 changes: 17 additions & 9 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
} from "./enable-auto-merge.mjs";
import { GitHubError } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs";
import { createRateLimitLatch } from "../../actions/log-api-budget/rate-guard.mjs";
import { COMMENT_DELETED_QUERY, COMMENT_EDITED_QUERY, LANE_HOLD, PULL_OPENED_QUERY, VERIFY_PATH, personWrote, verdictHold } from "./merge-holds.mjs";
import { COMMENT_EDITED_QUERY, LANE_HOLD, PULL_OPENED_QUERY, VERIFY_PATH, personWrote, verdictHold } from "./merge-holds.mjs";
import { VERIFY_PATH as RESOLVER_VERIFY_PATH } from "../pr-verify/resolve-pr.mjs";
import { HOLD_LINE, decide as decideOutcome } from "../work-resolve-issue/report-outcome.mjs";

Expand Down Expand Up @@ -140,9 +140,20 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
if (method === "GET" && path === "/repos/melodic-software/claude-code-plugins/issues/comments/7") {
return COST_COMMENT;
}
// Its decided head: a review lane's cost hold marker at the head, before it.
if (method === "GET" && /^\/repos\/melodic-software\/claude-code-plugins\/issues\/[0-9]+\/comments\?per_page=100&page=1$/.test(path)) {
return [{ user: { login: "github-actions[bot]", type: "Bot" }, created_at: "2026-10-22T11:00:00Z", body: `<!-- claude-lane-hold lane=claude-review sha=${HEAD} criteria=cost -->` }];
// Its decided head: the code review lane's status job noticed a cost
// hold at the head before it; the security lane has no run.
const runs = /^\/repos\/melodic-software\/claude-code-plugins\/actions\/workflows\/([a-z-]+)\.yml\/runs\?event=pull_request&branch=fix%2Fcost&per_page=100&page=1$/.exec(path);
if (method === "GET" && runs) {
return { workflow_runs: runs[1] === "pr-review-hosted" ? [{ id: 9, head_sha: HEAD, head_repository: { full_name: REPO }, created_at: "2026-10-22T10:50:00Z" }] : [] };
}
if (method === "GET" && path === `/repos/melodic-software/claude-code-plugins/compare/main...${HEAD}`) {
return { files: [{ filename: "infra/main.tf" }] };
}
if (method === "GET" && path === "/repos/melodic-software/claude-code-plugins/actions/runs/9/jobs?filter=all&per_page=100&page=1") {
return { jobs: [{ id: 90, name: "pr-review / claude-review-status", completed_at: "2026-10-22T11:00:00Z" }] };
}
if (method === "GET" && path === "/repos/melodic-software/claude-code-plugins/check-runs/90/annotations?per_page=100&page=1") {
return [{ annotation_level: "notice", message: `claude-lane-hold lane=claude-review sha=${HEAD} criteria=cost label=needs-human` }];
}
// A pull request's whole label list, when its listing cut it.
const labelPage = /^\/repos\/melodic-software\/claude-code-plugins\/issues\/([0-9]+)\/labels\?per_page=100&page=([0-9]+)$/.exec(path);
Expand Down Expand Up @@ -174,14 +185,11 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
assert.equal(`${method} ${path}`, "POST /graphql");
if (body.query === PULL_OPENED_QUERY) {
const nodes = [{ number: body.variables.number + 1000, repository: { nameWithOwner: REPO } }];
return { data: { repository: { pullRequest: { createdAt: "2026-10-22T10:00:00Z", closingIssuesReferences: { totalCount: 1, nodes } } } } };
return { data: { repository: { pullRequest: { createdAt: "2026-10-22T10:00:00Z", baseRefName: "main", headRefName: "fix/cost", closingIssuesReferences: { totalCount: 1, nodes } } } } };
}
if (body.query === COMMENT_EDITED_QUERY) {
return { data: { node: { lastEditedAt: null } } };
}
if (body.query === COMMENT_DELETED_QUERY) {
return { data: { repository: { pullRequest: { timelineItems: { filteredCount: 0, nodes: [] } } } } };
}
if (body.query === PULLS_QUERY) {
assert.deepEqual(body.variables, { owner: "melodic-software", name: "claude-code-plugins", after: null });
return { data: { repository: { pullRequests: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: pulls } } } };
Expand Down Expand Up @@ -237,7 +245,7 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
};
const writes = () =>
calls
.filter(({ method, path, body }) => method === "POST" && path === "/graphql" && ![PULLS_QUERY, DETAIL_QUERY, PULL_OPENED_QUERY, COMMENT_EDITED_QUERY, COMMENT_DELETED_QUERY, UNLABELED_QUERY, DISCUSSION_PAGE_QUERIES.comments, THREAD_PAGE_QUERY].includes(body.query))
.filter(({ method, path, body }) => method === "POST" && path === "/graphql" && ![PULLS_QUERY, DETAIL_QUERY, PULL_OPENED_QUERY, COMMENT_EDITED_QUERY, UNLABELED_QUERY, DISCUSSION_PAGE_QUERIES.comments, THREAD_PAGE_QUERY].includes(body.query))
.map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables }));
return { github, writes, verify, verified, calls, notes, pins };
}
Expand Down
Loading
Loading