Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2731084
feat(pr-pipeline): lift a lanes App needs-human once trusted pushes c…
kyle-sexton Oct 26, 2026
8620518
Merge remote-tracking branch 'origin/main' into feat/lanes-lift-stale…
kyle-sexton Oct 26, 2026
b86fa72
Merge remote-tracking branch 'origin/main' into feat/lanes-lift-stale…
kyle-sexton Oct 27, 2026
0f0cb29
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 27, 2026
97e1231
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 27, 2026
97de5c1
fix(pr-pipeline): lift only the owner-approved stops and floor the ne…
kyle-sexton Oct 27, 2026
59c153f
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 27, 2026
9bcfb25
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 27, 2026
cc5dd8b
chore: merge origin/main into feat/lanes-lift-stale-needs-human
kyle-sexton Oct 27, 2026
c81dd1b
fix(pr-pipeline): lift only on human pushes and passing checks, never…
kyle-sexton Oct 27, 2026
93c22ae
chore: merge origin/main into feat/lanes-lift-stale-needs-human
kyle-sexton Oct 27, 2026
8ed9cbc
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 27, 2026
0afa6ed
docs(pr-pipeline): say only human pushes make a trusted move in clear…
kyle-sexton Oct 28, 2026
1069436
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 28, 2026
6df14e7
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 28, 2026
b70cc23
Merge remote-tracking branch 'origin/main' into sweep-10389
kyle-sexton Oct 28, 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
7 changes: 7 additions & 0 deletions .changes/pr-pipeline/feat-lift-stale-needs-human-3b7d9c51.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Added

- `clear-escalations.mjs` lifts more pr-unblock stops once their cause is gone: `workflow-staged` also clears once a trusted move took the head off the resume key and the head contains main as it was at the escalation; the `ci-fix-*` stops other than `ci-fix-protected-path` clear once a trusted move took the head off the resume key and, at the new head, no check but `ci-status` fails, runs or was cancelled and each check's latest run ended `success`, `neutral` or `skipped`. A trusted move is read with the job token from the branch's activity, never from comment text: every push since the label is by a `human` of the base `trusted-actors.json`, matched by id, never the lanes App. Security, intent, decision, protected-path and own-patch stops still wait for a person.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- The merge gate's path floor (`babysit_risk.FLOOR_CLASSES`) holds `.github/scripts/pr-unblock/fix-ci-context.mjs` and `.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs` as `enforcement` paths, since `clear-escalations.mjs` now loads them: a change to either waits for a clean security review bound to the head.
203 changes: 185 additions & 18 deletions .github/scripts/pr-merge/clear-escalations.mjs
Comment thread
kyle-sexton marked this conversation as resolved.

Large diffs are not rendered by default.

230 changes: 225 additions & 5 deletions .github/scripts/pr-merge/clear-escalations.test.mjs
Comment thread
kyle-sexton marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// owner decision on #8387, a bot account speaking after the label (AI review,
// another lane) does not keep it.
import assert from "node:assert/strict";
import { test } from "node:test";
import { after, test } from "node:test";

import { main, NATIVE_READS_PER_RUN } from "./clear-escalations.mjs";
import { refusalBody } from "./enable-auto-merge.mjs";
Expand All @@ -32,7 +32,7 @@ const escalated = (reason = "workflow-staged", { user = BOT, at = "2026-10-18T10
// One open pull request (number 7) labeled needs-human; `timeline` is its timeline and
// `mainFiles` the files main changed since its merge base with the head.
// `evidence` is what the gate's reader (enable-auto-merge pythonVerify) answers.
function world({ timeline, mainFiles = [{ filename: "README.md" }], mergeable = false, pullFiles = [], reviewComments = [], reviews = [], threadsFail = false, author, evidence = () => null, numbers = [7] }) {
function world({ timeline, mainFiles = [{ filename: "README.md" }], mergeable = false, pullFiles = [], reviewComments = [], reviews = [], threadsFail = false, author, evidence = () => null, numbers = [7], headRepo = REPO, reader }) {
const calls = [];
const reads = [];
const verify = async (pull, rung) => {
Expand All @@ -53,7 +53,7 @@ function world({ timeline, mainFiles = [{ filename: "README.md" }], mergeable =
return path.endsWith("page=1") ? timeline : [];
}
if (method === "GET" && kind === "pulls" && numbers.includes(Number(number))) {
return { number: Number(number), state: "open", user: author, base: { ref: "main", sha: MAIN }, head: { sha: HEAD }, labels: [{ name: "needs-human" }], mergeable };
return { number: Number(number), state: "open", user: author, base: { ref: "main", sha: MAIN }, head: { sha: HEAD, ref: "feat/x", repo: { full_name: headRepo } }, labels: [{ name: "needs-human" }], mergeable };
}
if (method === "GET" && route === `/repos/${REPO}/compare/${HEAD}...${MAIN}`) {
return { files: mainFiles };
Expand All @@ -74,15 +74,15 @@ function world({ timeline, mainFiles = [{ filename: "README.md" }], mergeable =
}
throw new Error(`unexpected ${method} ${path}`);
};
return { github, verify, reads, calls, writes: () => calls.filter((call) => call.method !== "GET") };
return { github, verify, reader, reads, calls, writes: () => calls.filter((call) => call.method !== "GET") };
}

const run = async (w, env = {}) => {
const logs = [];
const original = console.log;
console.log = (line) => logs.push(line);
try {
return { code: await main({ env: { ...ENV, ...env }, github: w.github, verify: w.verify }), logs };
return { code: await main({ env: { ...ENV, ...env }, github: w.github, verify: w.verify, reader: w.reader }), logs };
} finally {
console.log = original;
}
Expand Down Expand Up @@ -817,6 +817,226 @@ test("labels past the read cap are read on a later run", async () => {
assert.deepEqual([...read].sort((a, b) => a - b), numbers);
});

// A trusted move: the head left every resume key through pushes the base
// trusted-actors.json lists as a `human` (never the lanes App's own entry),
// read with the job token (`reader`). Expected values come from the owner rule
// of 2026-10-26: a person is needed only for intent, design or a HIGH/CRITICAL
// security finding, so a mechanical stop comes off once trusted evidence shows
// its cause gone.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const PERSON_ID = 153232337;
const APP_ID = 337595936;
const STRANGER = { login: "drive-by", id: 99, type: "User" };
const PERSON = { login: "kyle-sexton", id: PERSON_ID, type: "User" };
const APP_ACTOR = { login: BOT.login, id: APP_ID, type: "Bot" };
const MAIN_THEN = "d".repeat(40);
const TRUSTED_DIR = mkdtempSync(join(tmpdir(), "clear-escalations-"));
after(() => rmSync(TRUSTED_DIR, { recursive: true, force: true }));
const TRUSTED = join(TRUSTED_DIR, "trusted-actors.json");
writeFileSync(
TRUSTED,
JSON.stringify({
version: 1,
actors: [
{ id: PERSON_ID, login: "kyle-sexton", kind: "human" },
{ id: APP_ID, login: BOT.login, kind: "bot" },
{ id: 209825114, login: "claude[bot]", kind: "bot" },
],
}),
);
const TRUST_ENV = { TRUSTED_ACTORS_PATH: TRUSTED, BABYSIT_VERIFY_GH_TOKEN: "job-token" };

// The branch's activity, newest first: created before the label, then `pushes`
// ({ actor, after, at }) after it.
const activityOf = (pushes) => [
...pushes.map(({ actor, after, at }) => ({ activity_type: "push", actor, after, before: KEY, timestamp: at })).reverse(),
{ activity_type: "branch_creation", actor: PERSON, after: KEY, before: "0".repeat(40), timestamp: "2026-10-17T09:00:00Z" },
];
const pushedBy = (actor, after = HEAD, at = "2026-10-18T11:00:00Z") => ({ actor, after, at });
const run_ = (name, conclusion, id, status = "completed") => ({ id, name, status, conclusion, app: { slug: "github-actions" } });
const GREEN = [run_("ci-status", "failure", 1), run_("test-node", "success", 2), run_("lint-repo", "skipped", 3)];

// The job-token reads: branch activity, main's tip when the lane escalated,
// whether the head contains it, and the head's check runs.
function readerFor({ pushes = [pushedBy(PERSON)], contains = true, checkRuns = GREEN, calls = [] } = {}) {
return async (method, path) => {
calls.push(path);
if (method !== "GET") {
throw new Error(`the reader writes nothing: ${method} ${path}`);
}
if (path === `/repos/${REPO}/activity?ref=${encodeURIComponent("refs/heads/feat/x")}&per_page=100`) {
return activityOf(pushes);
}
if (path === `/repos/${REPO}/commits?sha=main&until=${encodeURIComponent("2026-10-18T10:00:05.000Z")}&per_page=1`) {
return [{ sha: MAIN_THEN }];
}
if (path === `/repos/${REPO}/compare/${MAIN_THEN}...${HEAD}`) {
return contains ? { status: "ahead", behind_by: 0 } : { status: "diverged", behind_by: 3 };
}
if (path.startsWith(`/repos/${REPO}/commits/${HEAD}/check-runs?filter=all&per_page=100&page=`)) {
return { check_runs: path.endsWith("page=1") ? checkRuns : [] };
}
throw new Error(`unexpected reader ${method} ${path}`);
};
}

const WORKFLOW_MAIN = [{ filename: ".github/workflows/pr-merge.yml" }];
const stagedTimeline = () => [labeled(BOT, "2026-10-18T10:00:00Z"), escalated("workflow-staged")];

test("workflow-staged comes off once a trusted person moved the head past the resume key with main merged in, though main still changes workflows", async () => {
const w = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, reader: readerFor() });
await run(w, TRUST_ENV);
const [comment, removal] = w.writes();
assert.equal(w.writes().length, 2);
assert.equal(comment.body.body.split("\n")[0], `<!-- pr-pipeline needs-human-cleared reason=workflow-staged head=${HEAD} -->`);
assert.match(comment.body.body, /kyle-sexton/);
assert.match(comment.body.body, new RegExp(MAIN_THEN));
assert.equal(removal.method, "DELETE");
});

test("a lanes App push is not a trusted move: the App never lifts its own stop with its own push", async () => {
for (const pushes of [[pushedBy(APP_ACTOR)], [pushedBy(PERSON, "e".repeat(40), "2026-10-18T10:30:00Z"), pushedBy(APP_ACTOR)]]) {
for (const reason of ["workflow-staged", "ci-fix-escalated"]) {
const w = world({ timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), escalated(reason)], mainFiles: WORKFLOW_MAIN, reader: readerFor({ pushes }) });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), [], `${reason} ${JSON.stringify(pushes)}`);
}
}
});

test("workflow-staged stays when any push since the label is by an account off the trusted list", async () => {
for (const pushes of [
[pushedBy(STRANGER)],
[pushedBy(STRANGER, "e".repeat(40), "2026-10-18T10:30:00Z"), pushedBy(PERSON)],
[pushedBy({ login: "claude[bot]", id: 209825114, type: "Bot" })],
[pushedBy({ login: BOT.login, id: 1, type: "Bot" })],
]) {
const w = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, reader: readerFor({ pushes }) });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), [], JSON.stringify(pushes));
}
});

test("workflow-staged stays when the head does not contain main as it was at the escalation", async () => {
const w = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, reader: readerFor({ contains: false }) });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), []);
});

test("workflow-staged stays when the newest push is not the head, or no push since the label is read", async () => {
for (const pushes of [[pushedBy(PERSON, "e".repeat(40))], []]) {
const w = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, reader: readerFor({ pushes }) });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), []);
}
});

test("workflow-staged stays when the activity page does not reach back to the label", async () => {
const pushes = Array.from({ length: 100 }, (_, index) => pushedBy(PERSON, index === 99 ? HEAD : "e".repeat(40), new Date(Date.parse("2026-10-18T11:00:00Z") + index * 60_000).toISOString()));
const reader = async (method, path) =>
path.includes("/activity?") ? pushes.map(({ actor, after, at }) => ({ activity_type: "push", actor, after, timestamp: at })) : readerFor()(method, path);
const w = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, reader });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), []);
});

test("workflow-staged stays at the resume key, on a fork head, and without the trusted list or the job token", async () => {
const atKey = world({
timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), commented(BOT, escalationBody("workflow-staged").replaceAll(KEY, HEAD), "2026-10-18T10:00:05Z")],
mainFiles: WORKFLOW_MAIN,
reader: readerFor(),
});
await run(atKey, TRUST_ENV);
assert.deepEqual(atKey.writes(), []);
const fork = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, headRepo: "someone/fork", reader: readerFor() });
await run(fork, TRUST_ENV);
assert.deepEqual(fork.writes(), []);
for (const env of [{ BABYSIT_VERIFY_GH_TOKEN: "job-token", TRUSTED_ACTORS_PATH: "/nonexistent/trusted-actors.json" }, { TRUSTED_ACTORS_PATH: TRUSTED }]) {
const w = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, reader: env.BABYSIT_VERIFY_GH_TOKEN ? readerFor() : undefined });
await run(w, env);
assert.deepEqual(w.writes(), []);
}
});

test("an unreadable job-token read keeps the label without failing the run", async () => {
const reader = async () => {
throw new Error("HTTP 502");
};
const w = world({ timeline: stagedTimeline(), mainFiles: WORKFLOW_MAIN, reader });
const { code } = await run(w, TRUST_ENV);
assert.equal(code, 0);
assert.deepEqual(w.writes(), []);
});

const CI_FIX = ["ci-fix-round-cap", "ci-fix-empty", "ci-fix-invalid", "ci-fix-not-applied", "ci-fix-escalated"];

test("a ci-fix stop comes off once a trusted push moved the head and no check but ci-status fails there", async () => {
for (const reason of CI_FIX) {
const w = world({ timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), escalated(reason)], reader: readerFor() });
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/);
}
});

test("a ci-fix stop stays while a check fails, runs, or none has passed at the head, or the head never moved", async () => {
for (const checkRuns of [
[...GREEN, run_("test-bash (0)", "failure", 4)],
[...GREEN, run_("test-bash (0)", null, 4, "in_progress")],
[...GREEN, run_("test-python", "cancelled", 4)],
[run_("test-node", "failure", 2), run_("test-node", "skipped", 9)],
[run_("ci-status", "failure", 1), run_("lint-repo", "skipped", 3)],
[...GREEN, run_("deploy", "action_required", 4)],
[...GREEN, run_("test-go", "stale", 4)],
[...GREEN, run_("test-go", null, 4)],
[],
]) {
const w = world({ timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), escalated("ci-fix-escalated")], reader: readerFor({ checkRuns }) });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), [], JSON.stringify(checkRuns));
}
const unmoved = world({ timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), escalated("ci-fix-escalated")], reader: readerFor({ pushes: [pushedBy(STRANGER)] }) });
await run(unmoved, TRUST_ENV);
assert.deepEqual(unmoved.writes(), []);
});

test("a ci-fix stop passes with a neutral check at the head", async () => {
const w = world({ timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), escalated("ci-fix-escalated")], reader: readerFor({ checkRuns: [...GREEN, run_("pr-unblock / publish-feedback", "neutral", 4)] }) });
await run(w, TRUST_ENV);
assert.equal(w.writes().at(-1)?.method, "DELETE");
});

test("a protected-path or own-patch stop stays, even once the PR is mergeable after a trusted move with everything green", async () => {
for (const reason of ["ci-fix-protected-path", "unexpected-path-staged", "conflict-patch-invalid", "conflict-patch-not-applied", "conflict-patch-protected-path"]) {
const w = world({ timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), escalated(reason)], mergeable: true, reader: readerFor() });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), [], reason);
}
});

test("a security, intent or decision stop is never lifted, even after a trusted move with everything green", async () => {
for (const reason of [
"feedback-uncertain",
"feedback-disputed",
"feedback-unadjudicated",
"feedback-question-intent",
"feedback-question-design",
"feedback-question-security",
"feedback-round-cap",
"dependency-security-close",
"dependency-escalate",
"repeat-cap",
]) {
const w = world({ timeline: [labeled(BOT, "2026-10-18T10:00:00Z"), escalated(reason)], mergeable: true, reader: readerFor() });
await run(w, TRUST_ENV);
assert.deepEqual(w.writes(), [], reason);
}
});

const threadReads = (w) => w.calls.filter((call) => /\/pulls\/7\/(comments|reviews)/.test(call.path));
const inlineTimeline = () => [reviewed(ACTIONS_BOT, "2026-10-18T09:00:00Z"), labeled(BOT, "2026-10-18T10:00:00Z"), escalated()];

Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/pr-merge/merge-holds.mjs
Comment thread
kyle-sexton marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ export const HOLD_PATH_CLASSES = Object.freeze([
[".github/scripts/work-resolve-issue/report-outcome.mjs", "enforcement"],
[".github/scripts/work-resolve-issue/limits.mjs", "enforcement"],
[".github/scripts/work-resolve-issue/retry-fetch.mjs", "enforcement"],
[".github/scripts/pr-unblock/fix-ci-context.mjs", "enforcement"],
[".github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs", "enforcement"],
[".github/scripts/work-resolve-issue/stop-routes.json", "enforcement"],
[".github/scripts/work-resolve-issue/stop-routes.mjs", "enforcement"],
]);
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/pr-merge/merge-holds.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ const ENFORCEMENT_FILES = [
".github/scripts/work-resolve-issue/report-outcome.mjs",
".github/scripts/work-resolve-issue/limits.mjs",
".github/scripts/work-resolve-issue/retry-fetch.mjs",
".github/scripts/pr-unblock/fix-ci-context.mjs",
".github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs",
".github/scripts/work-resolve-issue/stop-routes.json",
".github/scripts/work-resolve-issue/stop-routes.mjs",
];
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/pr-merge.yml
Comment thread
kyle-sexton marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1720,7 +1720,10 @@ jobs:
# A pr-unblock escalation adds the human-gated label with a reason. On
# the schedule, this takes off a label the lanes App set once that
# reason no longer holds at the PR's head (workflow-staged, a conflict
# reason, feedback-protected-path), after a comment naming the reason
# reason, feedback-protected-path, a ci-fix stop other than
# ci-fix-protected-path once trusted human pushes moved the head and its
# checks pass, read with this
# job's own token and the base trusted-actor list), after a comment naming the reason
# and the head: never a label a person set, and never after anyone but
# the App commented or reviewed since it went on. At most five a run, and not
# while the lane is report-only or after the gate's rate-limit stop.
Expand All @@ -1747,6 +1750,7 @@ jobs:
API_BUDGET_OVERRIDE_TOKEN: github-token
KILL_SWITCH: ${{ steps.kill.outputs.proceed == 'true' && 'proceed' || '' }}
LANE_PRS: ${{ steps.mode.outputs.lane-prs }}
TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json
run: node .base/.github/scripts/pr-merge/clear-escalations.mjs

# The next run's base for its first listing: the run's last saved
Expand Down
Loading
Loading