Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3b90d1d
feat(pr-pipeline): lane-arm gate workflow and hold-path PRs on bound …
kyle-sexton Oct 29, 2026
fc8dad1
test(pr-pipeline): pin the switch's fail-closed read and qualify the …
kyle-sexton Oct 29, 2026
e6696af
fix(pr-merge): carry an approval over a base merge that only shifts h…
kyle-sexton Oct 29, 2026
8413b31
fix(pr-pipeline): hold every gate row for a person and withdraw the h…
kyle-sexton Oct 29, 2026
50040c0
Merge remote-tracking branch 'origin/main' into feat/lane-arm-gate-wo…
kyle-sexton Oct 29, 2026
47b3aac
Merge remote-tracking branch 'origin/main' into feat/lane-arm-gate-wo…
kyle-sexton Oct 29, 2026
07751e0
Merge remote-tracking branch 'origin/main' into feat/lane-arm-gate-wo…
kyle-sexton Oct 29, 2026
13ba2ed
fix(pr-pipeline): check that the merge scripts' import closure stays …
kyle-sexton Oct 29, 2026
d7264b2
docs(pr-pipeline): add the changelog fragment for the closure check note
kyle-sexton Oct 29, 2026
7c1f502
revert: restore the lane-arm machinery for the gate-attestation rescope
kyle-sexton Oct 29, 2026
6a16f95
feat(pr-pipeline): lane-arm the merge gate's own code on a verified p…
kyle-sexton Oct 29, 2026
5586d25
Merge remote-tracking branch 'origin/main' into feat/lane-arm-gate-wo…
kyle-sexton Oct 29, 2026
60fa48d
test(pr-pipeline): drop a tracker marker from the floor closure pin
kyle-sexton Oct 29, 2026
60af038
Merge remote-tracking branch 'origin/main' into feat/lane-arm-gate-wo…
kyle-sexton Oct 29, 2026
9c711ac
fix(pr-pipeline): accept a lane-armed attestation only from a schedul…
kyle-sexton Oct 29, 2026
ffdde44
fix(pr-pipeline): bind the lane-armed attestation to main's commits a…
kyle-sexton Oct 29, 2026
8f3a6a0
fix(pr-pipeline): judge the floor on the pull request's patch, not th…
kyle-sexton Oct 29, 2026
e4812b1
Merge remote-tracking branch 'origin/main' into feat/lane-arm-gate-wo…
kyle-sexton Oct 29, 2026
6daeb47
fix(pr-pipeline): read the floor base at the merge base, and refuse r…
kyle-sexton Oct 29, 2026
860916a
fix(pr-pipeline): judge babysit_risk.py rebinding on parsed trees, no…
kyle-sexton Oct 29, 2026
5f4b019
fix(pr-pipeline): count dunders, imports and frame names in the floor…
kyle-sexton Oct 29, 2026
c3ee34d
Merge remote-tracking branch 'origin/main' into feat/lane-arm-gate-wo…
kyle-sexton Oct 29, 2026
9563725
fix(pr-pipeline): judge floor-ast.py as code and count dynamic JS in …
kyle-sexton Oct 29, 2026
081662b
fix(pr-pipeline): lock lane-armed.mjs's token list and call-shape eval
kyle-sexton Oct 29, 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
11 changes: 11 additions & 0 deletions .changes/pr-pipeline/feat-lane-arm-gate-paths-7d41c2e9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
bump: minor
---

### Added

- `merge.lane-arm-gate` (on) and `merge.person-held` (owner decisions Q7 and Q5): a pull request held only by the merge gate's own code, `babysit_risk.py` and the `.github/scripts/pr-merge/` scripts, merges with no person once both review lanes are bound to the main-run review of its head with no finding, no review thread is open, and pr-merge has attested it. Every other `trust` and `gate` row stays with a person, and `scripts/sync-hold-floor.py` refuses a `gate` row outside the merge gate's code that `merge.person-held` does not list.
- pr-merge's `lane-armed` job (`lane-armed.mjs`) posts the check run `pr-merge/lane-armed` at a head whose gate passed its lane-armed paths and whose hold floor does not shrink against the base, then re-runs its red ci-status job. Each attestation is one `lane-armed {...}` record line, in the check run and as a notice; each refusal logs the failed condition.
- ci-status (`merge-holds.mjs` `laneArmedHold`) holds a lane-armed path until that check run is at the head and names a run of `pr-merge.yml` on the default branch, started by schedule or workflow_dispatch, whose commit is on that branch and which uploaded the run-scoped attestation artifact; a trusted person's approval still lifts it.
- `pr-review-main.yml` also reviews a pull request after a red ci-status when it changes main's lane-armed code, so neither the red status nor the human-gated label blocks the review the attestation needs. clear-escalations lifts that label at the same head once the gate's reader refuses nothing there.
- The review criteria `pr-review-main.yml` copies from main (`plugins/review/skills/**`, `plugins/review/reference/**`) are `enforcement` floor rows: a change to them waits for a clean bound security review.
18 changes: 18 additions & 0 deletions .changes/source-control/feat-lane-arm-gate-paths-a95e03b4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
bump: minor
---

### Added

- lane-arm the merge gate's own code on a clean main-run review

With `merge.lane-arm-gate` on, the merge gate's risk score drops the
`high-path:` hold on `babysit_risk.py` and the pr-merge scripts
(`LANE_ARMED_FLOOR`) when both review lanes are bound to a main-run review
of the head (`LaneEvidence.main_run`) with no open finding and the pull
request has no unresolved review thread (`RiskInput.open_threads`). Those
paths are lane sources, so only a main-run review binds them. The gate
records what pr-merge's attestation needs as `laneArmed`
(`babysit_risk.lane_arm_record`), with the first failed condition. Every
other floor path, a configured denied path and any unread input keep the
person hold.
10 changes: 10 additions & 0 deletions .github/artifact-retention.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
"reason": "The post-merge check reads the dispatch record of every merge it judges, back to the merges it has not yet judged.",
"pending-cut": "Under owner review (poteto): about 5,660 artifacts at 90 days."
},
"pr-merge-lane-armed-*": {
"retention-days": 7,
"readers": [
".github/scripts/pr-merge/merge-holds.mjs laneArmedHold: ci-status passes the merge gate's own code only on this artifact of the pr-merge.yml run its pr-merge/lane-armed check run names"
],
"reason": "Read by every ci-status run of the head, the merge queue's included, until it merges; past 7 days the next sweep attests the head again.",
"uploads": [
".github/workflows/pr-merge.yml#lane-armed"
]
},
"pr-merge-rerun-*": {
"retention-days": "max",
"readers": [
Expand Down
78 changes: 67 additions & 11 deletions .github/scripts/pr-merge/clear-escalations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,17 @@
// that head: a person removes the label, or a push clears it as head-moved
// above and enable-auto-merge labels the new head again if the cause holds.
// An unread answer and a failed read keep it; so does a hold or cost path,
// which no reader answer clears. At most NATIVE_READS_PER_RUN reads a run,
// which no reader answer clears, except under merge.lane-arm-gate (owner
// decisions Q7 and Q5): a refusal recorded only on the merge gate's own code
// the switch lane-arms (merge-holds LANE_ARMED_FLOOR; `armedCause`) is read
// again at that head and comes off as `refusal-cleared` only when the reader
// refuses nothing there, which needs both review lanes bound to a main-run
// review of the head with no finding and no open review thread; pr-review-main
// runs that review while the label keeps ci-status red. A person-held
// (merge.person-held) path in it keeps the label. ci-status still holds the
// head until pr-merge's `pr-merge/lane-armed` attestation, and reads the removal as no
// approval: merge-holds counts only a trusted person's approving review, and
// its review-lane hold check fails a review lane's hold an App lifted. At most NATIVE_READS_PER_RUN reads a run,
// starting at an offset that moves with the run number (rotated), so labels
// past the cap are read on a later run.
//
Expand Down Expand Up @@ -162,8 +172,8 @@ import {
spoken,
} from "../pr-unblock/gate-source.mjs";
import { isProtectedPath } from "../pr-unblock/thread-scope.mjs";
import { LANES_APP_LOGIN as DEFAULT_BOT } from "./merge-holds.mjs";
import { pythonVerify, READER_HOLDS, REFUSAL_MARKER, refusalBody, rotated, verifyHold } from "./enable-auto-merge.mjs";
import { LANES_APP_LOGIN as DEFAULT_BOT, HOLD_PATH_CLASSES, LANE_ARMED_FLOOR, matchesHoldPath } from "./merge-holds.mjs";
import { holdPathOnly, pythonVerify, READER_HOLDS, REFUSAL_MARKER, refusalBody, rotated, verifyHold } from "./enable-auto-merge.mjs";

const REASON = /^- Reason: `([a-z-]+)`$/m;
const clearedMarker = (reason, head) => `<!-- pr-pipeline needs-human-cleared reason=${reason} head=${head} -->`;
Expand Down Expand Up @@ -230,10 +240,44 @@ export const LANE_CAUSES = Object.freeze({
// Whether the `kind` reason (verifyHold's) names only causes the lanes clear:
// each "; "-separated code on LANE_CAUSES[kind].
function laneCause(kind, reason) {
const codes = reason.slice(READER_HOLDS[kind].length, -1).split("; ").map((part) => part.split(": ")[0]);
return codes.every((code) => LANE_CAUSES[kind].some((entry) => (entry.endsWith(":") ? code.startsWith(entry) : code === entry)));
return reasonCodes(kind, reason).every((code) => onLaneCauses(kind, code));
}

const reasonCodes = (kind, reason) => reason.slice(READER_HOLDS[kind].length, -1).split("; ").map((part) => part.split(": ")[0]);
const onLaneCauses = (kind, code) => LANE_CAUSES[kind].some((entry) => (entry.endsWith(":") ? code.startsWith(entry) : code === entry));

// Owner decisions Q7 and Q5 (#10743): with merge.lane-arm-gate on, a hold the
// switch lane-arms (merge-holds LANE_ARMED_FLOOR) was a person's when it was
// recorded and is the lanes' now. A refusal recorded on such a hold alone is
// judged again at its head, and lifted only when the gate's reader refuses
// nothing there (`armedLift`): a person-held path, any other
// person code, or a reader answer short of a pass keeps the label.
// - A risk refusal: each code is on LANE_CAUSES.risk or `high-path:<glob>`
// with the glob on LANE_ARMED_FLOOR, and at least one is the latter.
// - A hold-path refusal (merge-holds pathHoldKind's "it changes <path>, a
// hold path", every reason line one): each path matches a LANE_ARMED_FLOOR
// glob and no other `trust` or `gate` floor glob.
// `armed` is LANE_ARMED_FLOOR, a parameter so tests can give it rows.
export function armedCause(refusal, armed = LANE_ARMED_FLOOR) {
if (armed.length === 0 || refusal === null) {
return false;
}
const armedCode = (code) => code.startsWith("high-path:") && armed.includes(code.slice("high-path:".length));
if (refusal.kind === "path") {
const held = HOLD_PATH_CLASSES.filter(([glob, kind]) => kind !== "enforcement" && !armed.includes(glob)).map(([glob]) => glob);
return refusal.paths.length > 0 && refusal.paths.every((path) => armed.some((glob) => matchesHoldPath(path, glob)) && !held.some((glob) => matchesHoldPath(path, glob)));
}
if (refusal.kind !== "risk") {
return false;
}
const codes = reasonCodes(refusal.kind, refusal.reason);
return codes.some(armedCode) && codes.every((code) => armedCode(code) || onLaneCauses("risk", code));
}

// Whether the reader's `evidence` at `head` lifts a refusal `armedCause`
// accepted: the gate refuses nothing at that head.
export const armedLift = (evidence, head) => verifyHold(evidence, head) === null;


// The workflow whose lanes ci-status aggregates. An advisory lane's failure
// (pr-test-windows) leaves ci-status green once the label is off, so only
Expand Down Expand Up @@ -462,10 +506,17 @@ function nativeRefusal(appComments, merged, label, head) {
if (!(Date.parse(newest.updated_at) <= Date.parse(newest.created_at))) {
return null;
}
// A refusal of hold paths alone (enable-auto-merge holdPathOnly), which
// only `armedCause` ever lifts here.
if (holdPathOnly(newest.body, head, label)) {
const paths = lines(newest.body).flatMap((line) => HOLD_PATH_REASON.exec(line)?.slice(1, 2) ?? []);
return { kind: "path", reason: paths.map((path) => `it changes ${path}, a hold path`).join("; "), paths };
}
const reason = lines(newest.body).slice(5, -1).join("\n");
const kind = Object.keys(READER_HOLDS).find((key) => reason.startsWith(READER_HOLDS[key]));
return kind !== undefined && refusalBody(true, reason, head, label) === newest.body ? { kind, reason } : null;
}
const HOLD_PATH_REASON = /^it changes (.+), a hold path$/;

// Whether the reader's `evidence` at `head` lifts the `kind` refusal, whose
// recorded cause the caller has found the lanes' (laneCause): nothing
Expand Down Expand Up @@ -508,7 +559,7 @@ function pairedWithPrMerge(appComments, since) {
}

// What to clear on `number`, or { reason } saying why nothing is.
async function verdict({ github, checks, repo, number, label, bot, mainSha, lanePrs, verify, reads, reader, trusted }) {
async function verdict({ github, checks, repo, number, label, bot, mainSha, lanePrs, verify, reads, reader, trusted, laneArmed }) {
const timeline = await paginate(github, `/repos/${repo}/issues/${number}/timeline`);
const { labeled, since, lifted } = labelHistory(timeline, label);
if (!isApp(labeled?.actor, bot)) {
Expand Down Expand Up @@ -572,7 +623,8 @@ async function verdict({ github, checks, repo, number, label, bot, mainSha, lane
const native = lanePrs === "native" && isApp(pull.user, bot) ? nativeRefusal(appComments, merged, label, pull.head.sha) : null;
// A person's recorded cause stays until a person removes the label or the
// head moves (owner decision Q-CLEAR B): no reader answer lifts it.
if (native !== null && !laneCause(native.kind, native.reason)) {
const armed = armedCause(native, laneArmed);
if (native !== null && !armed && (native.kind === "path" || !laneCause(native.kind, native.reason))) {
return { reason: `enable-auto-merge's refusal at ${pull.head.sha} is one a person clears (${native.reason})` };
}
if (native !== null) {
Expand All @@ -593,13 +645,17 @@ async function verdict({ github, checks, repo, number, label, bot, mainSha, lane
return { reason: `the gate's reader did not answer: ${escapeCommand(error?.message ?? error)}` };
}
const held = verifyHold(evidence, pull.head.sha);
if (!liftsRefusal(native.kind, evidence, pull.head.sha)) {
if (armed ? !armedLift(evidence, pull.head.sha) : !liftsRefusal(native.kind, evidence, pull.head.sha)) {
return { reason: `enable-auto-merge's refusal still needs a person at ${pull.head.sha}: ${held}` };
Comment thread
kyle-sexton marked this conversation as resolved.
}
return {
...cleared(timeline, bot, since, [REFUSAL_CLEARED], pull.head.sha, [
`it refused this head (${native.reason})`,
held === null ? "the gate now takes its issue link, verifier verdict and risk score" : `what refuses now is one the lanes clear: ${held}`,
held === null
? armed
? "merge.lane-arm-gate lane-arms the merge gate's own code it changes, and the gate now takes its issue link, verifier verdict and risk score: both review lanes are bound to a main-run review of this head with no finding and no review thread is open"
: "the gate now takes its issue link, verifier verdict and risk score"
: `what refuses now is one the lanes clear: ${held}`,
]),
threadsRead: true,
};
Expand Down Expand Up @@ -679,7 +735,7 @@ function trustedIds(path) {
}
}

export async function main({ env = process.env, github, checks, verify, reader } = {}) {
export async function main({ env = process.env, github, checks, verify, reader, laneArmed = LANE_ARMED_FLOOR } = {}) {
try {
if (!REPO.test(env.GITHUB_REPOSITORY ?? "")) {
throw new Error("GITHUB_REPOSITORY is not owner/repo");
Expand Down Expand Up @@ -716,7 +772,7 @@ export async function main({ env = process.env, github, checks, verify, reader }
continue;
}
try {
const result = await verdict({ github, checks, repo, number, label, bot, mainSha, lanePrs: env.LANE_PRS, verify, reads, reader, trusted });
const result = await verdict({ github, checks, repo, number, label, bot, mainSha, lanePrs: env.LANE_PRS, verify, reads, reader, trusted, laneArmed });
if (result.reason !== null) {
console.log(`clear-escalations: #${number} kept: ${result.reason}`);
continue;
Expand Down
81 changes: 79 additions & 2 deletions .github/scripts/pr-merge/clear-escalations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ function world({ timeline, mainFiles = [{ filename: "README.md" }], mergeable =
return { github, checks, verify, reader, reads, calls, writes: () => calls.filter((call) => call.method !== "GET") };
}

const run = async (w, env = {}) => {
const run = async (w, env = {}, laneArmed = undefined) => {
const logs = [];
const original = console.log;
console.log = (line) => logs.push(line);
try {
return { code: await main({ env: { ...ENV, ...env }, github: w.github, checks: w.checks, verify: w.verify, reader: w.reader }), logs };
return { code: await main({ env: { ...ENV, ...env }, github: w.github, checks: w.checks, verify: w.verify, reader: w.reader, ...(laneArmed ? { laneArmed } : {}) }), logs };
} finally {
console.log = original;
}
Expand Down Expand Up @@ -789,6 +789,83 @@ test("a lane-class label comes off at its head once nothing refuses", async () =
}
});

// Owner decisions Q7 and Q5 (#10743): with merge.lane-arm-gate on, a refusal
// recorded only on the merge gate's own code ("it changes <path>, a hold
// path" from before it was lane-armed, or the risk score's `high-path:` on
// one) is read again at its head and comes off only when the reader refuses
// nothing there. ci-status still holds the head until pr-merge's
// attestation, which the label no longer blocks: pr-review-main reviews a
// lane-armed head after a red ci-status too.
const WHO = ".github/scripts/pr-merge/who.mjs";
const RISK_PY = "plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py";
const AGENTS_PATH = `it changes ${WHO}, a hold path`;
const AGENTS_RISK = risk(`high-path:${WHO}: ${WHO}`);
test("a refusal on lane-armed gate paths alone comes off at its head once the gate's reader refuses nothing", async () => {
for (const reason of [
AGENTS_PATH,
`${AGENTS_PATH}\nit changes ${RISK_PY}, a hold path`,
AGENTS_RISK,
risk(`high-path:${RISK_PY}: ${RISK_PY}`),
risk(`high-path:${WHO}: ${WHO}; evidence-unbound:claude-review: not bound: rerun-allowed`),
]) {
const w = world({ timeline: nativeEscalated(reason), author: BOT, evidence: clean });
const { code, logs } = await run(w, NATIVE);
assert.equal(code, 0, logs.join("\n"));
assert.equal(w.reads.length, 1, reason);
assert.equal(w.writes().length, 2, reason);
assert.equal(w.writes()[0].body.body.split("\n")[0], `<!-- pr-pipeline needs-human-cleared reason=refusal-cleared head=${HEAD} -->`, reason);
assert.match(w.writes()[0].body.body, /merge\.lane-arm-gate lane-arms the merge gate's own code it changes/, reason);
assert.deepEqual(w.writes()[1], DELETE, reason);
}
});

test("a refusal on lane-armed gate paths stays while the gate's reader refuses anything at its head", async () => {
for (const evidence of [
// Still the person's high-path hold: a lane unbound or not main-run, a finding, an open thread.
reviewsNow(`high-path:${WHO}: ${WHO}`),
// A lane cause a lane-class label would tolerate is not enough here.
reviewsNow("evidence-unbound:claude-review: not bound: rerun-allowed"),
reviewsNow("unreadable: no agent identity"),
refusing("verify:fail"),
refusing("verify:missing"),
(pull) => ({ ...clean(pull), head: "c".repeat(40) }),
]) {
for (const reason of [AGENTS_PATH, AGENTS_RISK]) {
const w = world({ timeline: nativeEscalated(reason), author: BOT, evidence });
const { code, logs } = await run(w, NATIVE);
assert.equal(code, 0, logs.join("\n"));
assert.equal(w.reads.length, 1, reason);
assert.deepEqual(w.writes(), [], `${reason} ${evidence}`);
assert.match(logs.join("\n"), /still needs a person/, reason);
}
}
});

test("a refusal naming a person-held path is never read or lifted at its head", async () => {
for (const reason of [
"it changes .github/workflows/pr-require-checks.yml, a hold path",
"it changes AGENTS.md, a hold path",
"it changes .claude/settings.json, a hold path",
"it changes plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py, a hold path",
"it changes .github/workflows/pr-review-main.yml, a hold path",
`${AGENTS_PATH}\nit changes .github/workflows/pr-merge.yml, a hold path`,
risk("high-path:.github/workflows/pr-require-checks.yml: .github/workflows/pr-require-checks.yml"),
risk("high-path:.github/actions/request-lane-token/**: .github/actions/request-lane-token/action.yml"),
risk(`high-path:${WHO}: ${WHO}; high-path:docs/conventions/pr-pipeline.yaml: docs/conventions/pr-pipeline.yaml`),
risk(`high-path:${WHO}: ${WHO}; security-finding: 1 open`),
// A path off the floor is a configured denied path: a person's.
"it changes docs/held.md, a hold path",
risk("high-path:**/AGENTS.md: AGENTS.md"),
]) {
const w = world({ timeline: nativeEscalated(reason), author: BOT, evidence: clean });
const { code, logs } = await run(w, NATIVE);
assert.equal(code, 0, logs.join("\n"));
assert.deepEqual(w.reads, [], reason);
assert.deepEqual(w.writes(), [], reason);
assert.match(logs.join("\n"), /one a person clears/, reason);
}
});

// A risk score is read only when the verdict passes (babysit_verify_evidence.py
// `evidence`), so a refusal or an unread answer hides a lane's recorded cause:
// the label stays.
Expand Down
Loading
Loading