Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 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
8 changes: 8 additions & 0 deletions .changes/pr-pipeline/feat-hold-path-q26c-3b7e19d2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: minor
---

### Changed

- The pr-merge hold-path floor now gives each glob a class (owner decision Q26 option B). `trust` (check-trusted-trigger, check-kill-switch, request-lane-token, CODEOWNERS, .gitmodules) and `gate` (every pr-merge script, the pr-merge and ci-status workflows, `babysit_merge*.py`, `babysit_risk.py` and `babysit_review_evidence.py` (they compute the review's clear condition), the rate guard, resolve-config and the pipeline config) still hold for a person. `enforcement` (the other modules the floored scripts import and the Python that decides the holds) no longer needs a person: the merge gate holds such a path as `review-path:<glob>` until the claude-security-review evidence binds to the head with no open finding, then arms it. ci-status leaves those paths to the gate. One classed list on each side, `HOLD_PATH_CLASSES` (`.github/scripts/pr-merge/merge-holds.mjs`) and `babysit_risk.FLOOR_CLASSES`, pinned equal by `test_merge_holds_sync.py`.
- The rest of `.github/scripts/pr-merge/` (gate-check, rotation, who, lane-records and the other scripts pr-merge.yml runs) joins the floor as `gate`, and both review callers' `hold-paths` and ci-status's exempt-author `hold_patterns` name them.
8 changes: 8 additions & 0 deletions .changes/source-control/feat-hold-path-q26c-3b7e19d2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: minor
---

### Changed

- `babysit_risk` splits its path floor by class (`FLOOR_CLASSES`): `HIGH_FLOOR` is now the `trust` and `gate` globs, and an `enforcement` glob (`REVIEW_FLOOR`) scores HIGH as `review-path:<glob>` unless the security lane's evidence is bound to the head with no open finding (`security_review_clean`). A clean code-review lane alone clears nothing. `NEVER_FLOOR_EXEMPT` keeps every floor glob.
- An enforcement path in a pull request that also changes a lane source (a workflow, `CLAUDE.md`, `AGENTS.md`, `.claude/`, `docs/standards/`, `.gitattributes`) scores `review-path-unbindable:<glob>` instead: its security review can never bind at that head, so `person_reasons` names it and it goes to a person. An unbind a later review or read can clear stays the lane's `review-path:`.
37 changes: 25 additions & 12 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,23 @@ test("a refusal a person must clear, a cost path or a configured denied path, is
]);
});

// Owner decision Q26 option B: the gate holds an enforcement floor path as
// `review-path:` until the security review binds clean to the head; the lanes
// run that review, so the refusal gets the comment and no label.
test("an enforcement floor path the gate holds for its security review is a lane's refusal", async () => {
const reviews = "review-path:.github/scripts/work-resolve-issue/limits.mjs: .github/scripts/work-resolve-issue/limits.mjs; a clean claude-security-review bound to this head clears it";
const verdict = { refusal: null, needsHuman: false, reviewsPerson: false, head: HEAD, ineligible: null, reviews };
const { writes, notes } = await run([pull(1, { files: [{ filename: ".github/scripts/work-resolve-issue/limits.mjs" }], verdict })]);
assert.deepEqual(writes, []);
assert.deepEqual(notes, [{ method: "POST", path: `${ISSUES}/1/comments`, body: { body: laneNote(`pr-merge's gate holds it on its risk score (${reviews})`) } }]);
});

// Owner decisions Q-FLOOR (B) and Q-ENFORCE (A): a floor path, the files
// that enforce the floor included, holds with no configured glob naming it,
// as a cause a person must clear, so it gets the label; a trusted path off
// the floor arms.
// the floor arms. Owner decision Q26 option B: an `enforcement` floor path is
// left to the gate's verifier read, whose risk score holds it until a clean
// security review binds to the head, so it arms on a passing read.
test("a floor path no configured glob names is commented on and labeled human-gated; a trusted path off it arms", async () => {
const floor = [
".github/actions/resolve-config/resolve.mjs",
Expand All @@ -331,23 +344,23 @@ test("a floor path no configured glob names is commented on and labeled human-ga
".github/scripts/pr-merge/enable-auto-merge.mjs",
".github/scripts/pr-merge/superseded.mjs",
".github/scripts/pr-merge/clear-escalations.mjs",
"plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py",
".github/scripts/pr-merge/gate-check.mjs",
".github/scripts/pr-merge/who.mjs",
"plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py",
".github/workflows/pr-require-checks.yml",
".github/workflows/pr-merge.yml",
"docs/conventions/pr-pipeline.yaml",
".github/scripts/pr-unblock/address-feedback.mjs",
".github/scripts/pr-unblock/gate-source.mjs",
".github/scripts/pr-unblock/thread-scope.mjs",
".github/scripts/pr-unblock/retry-fetch.mjs",
".github/scripts/work-resolve-issue/report-outcome.mjs",
".github/scripts/work-resolve-issue/limits.mjs",
".github/scripts/work-resolve-issue/retry-fetch.mjs",
];
const { code, writes, notes } = await run([
...floor.map((filename, index) => pull(index + 1, { files: [{ filename }] })),
pull(99, { files: [{ filename: ".github/scripts/pr-merge/gate-check.mjs" }] }),
pull(98, { files: [{ filename: "plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py" }] }),
pull(99, { files: [{ filename: ".github/scripts/pr-unblock/other.mjs" }] }),
]);
assert.equal(code, 0);
assert.deepEqual(writes, [{ mutation: "arm", id: "PR_99", head: HEAD }]);
assert.deepEqual(writes, [
{ mutation: "arm", id: "PR_98", head: HEAD },
{ mutation: "arm", id: "PR_99", head: HEAD },
]);
assert.deepEqual(
notes,
floor.flatMap((filename, index) => [
Expand Down Expand Up @@ -1151,7 +1164,7 @@ test("a pull request whose head now changes a hold path is not armed, or is disa
pull(4, { files: [{ filename: "docs/held.md" }] }),
pull(5),
// The rest of .github merges on the review lanes.
pull(6, { files: [{ filename: ".github/workflows/x.yml" }, { filename: ".github/scripts/pr-merge/gate-check.mjs" }] }),
pull(6, { files: [{ filename: ".github/workflows/x.yml" }, { filename: ".github/scripts/pr-unblock/other.mjs" }] }),
]);
assert.deepEqual(writes, [
{ mutation: "disarm", id: "PR_2" },
Expand Down
133 changes: 76 additions & 57 deletions .github/scripts/pr-merge/merge-holds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
// (babysit_merge.HUMAN_MERGE_VETO_RE). A later one by the same person
// lifts it; an item with no text lifts nothing.
// - A hold path: a changed file, or a renamed file's old path, matching the
// config's merge.diff-check.denied-paths or HOLD_PATH_FLOOR. A person on
// config's merge.diff-check.denied-paths or HOLD_PATH_FLOOR (the floor's
// `trust` and `gate` globs; its `enforcement` globs are the merge gate's,
// which a clean security review clears). A person on
// the trusted-actor list lifts it by approving the head: their newest
// review of the head that approves, requests changes or was dismissed must
// approve. No other path is held for being trusted alone: the review lanes
Expand Down Expand Up @@ -231,63 +233,80 @@ export const fromRest = (row) => ({
const undot = (text) => String(text).toLowerCase().replaceAll(/(^|\/)\./g, "$1\u0001");
export const matchesHoldPath = (path, glob) => posix.matchesGlob(undot(path), undot(glob));

// The narrow floor every hold path check adds to the configured globs (owner
// decision Q-FLOOR (B)): the code main runs to mint and scope lane tokens
// and judge triggers (resolve-config, request-lane-token,
// check-trusted-trigger, check-kill-switch, the rate guard), code owners and
// submodules; and the files that enforce the floor (owner decision
// Q-ENFORCE (A)): this module, enable-auto-merge, superseded,
// clear-escalations, babysit_risk.py, the ci-status workflow, the pr-merge
// workflow that wires them, the pipeline config, every module those scripts
// import, and the babysit-prs modules
// babysit_risk.py and the Python those scripts run (babysit_verify_evidence.py)
// import (test_merge_holds_sync.py derives both closures and requires them
// here). babysit_risk.HIGH_FLOOR, which
// test_merge_holds_sync.py checks this equals. A constant of the module
// ci-status and enable-auto-merge run from the base commit, so a pull
// request cannot rewrite it.
export const HOLD_PATH_FLOOR = Object.freeze([
".github/actions/check-trusted-trigger/**",
"**/CODEOWNERS",
"**/.gitmodules",
".github/actions/resolve-config/**",
".github/actions/check-kill-switch/**",
".github/actions/request-lane-token/**",
".github/actions/log-api-budget/rate-guard.mjs",
".github/actions/log-api-budget/rate-limits.json",
".github/scripts/pr-merge/merge-holds.mjs",
".github/scripts/pr-merge/enable-auto-merge.mjs",
".github/scripts/pr-merge/superseded.mjs",
".github/scripts/pr-merge/clear-escalations.mjs",
"plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_check_inputs.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_checks.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_classify.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_dependabot.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_repo_config.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_run_cache.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_state.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_util.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py",
"plugins/source-control/skills/babysit-prs/scripts/babysit_whole_review.py",
".github/workflows/pr-require-checks.yml",
".github/workflows/pr-merge.yml",
"docs/conventions/pr-pipeline.yaml",
".github/scripts/pr-unblock/address-feedback.mjs",
".github/scripts/pr-unblock/gate-source.mjs",
".github/scripts/pr-unblock/thread-scope.mjs",
".github/scripts/pr-unblock/retry-fetch.mjs",
".github/scripts/work-resolve-issue/report-outcome.mjs",
".github/scripts/work-resolve-issue/limits.mjs",
".github/scripts/work-resolve-issue/retry-fetch.mjs",
// The narrow floor every hold path check adds to the configured globs, each
// glob with its class (owner decisions Q-FLOOR (B), Q-ENFORCE (A) and Q26
// option B): `trust`, what mints and scopes lane tokens and judges triggers
// (request-lane-token, check-trusted-trigger, check-kill-switch), code owners
// and submodules; `gate`, the merge gate's own code (every pr-merge script,
// the pr-merge and ci-status workflows, babysit_merge*, babysit_risk.py and
// babysit_review_evidence.py, which compute the review's clear condition,
// the rate guard) and
// the config the token broker loads (resolve-config, effect-grants.json
// included, and the pipeline config); `enforcement`, the other modules the
// floored scripts import and the Python that decides the holds
// (test_merge_holds_sync.py derives both closures and requires them here).
// babysit_risk.FLOOR_CLASSES, which test_merge_holds_sync.py checks this
// equals. A constant of the module ci-status and enable-auto-merge run from
// the base commit, so a pull request cannot rewrite it.
export const HOLD_PATH_CLASSES = Object.freeze([
[".github/actions/check-trusted-trigger/**", "trust"],
["**/CODEOWNERS", "trust"],
["**/.gitmodules", "trust"],
[".github/actions/resolve-config/**", "gate"],
[".github/actions/check-kill-switch/**", "trust"],
[".github/actions/request-lane-token/**", "trust"],
[".github/actions/log-api-budget/rate-guard.mjs", "gate"],
[".github/actions/log-api-budget/rate-limits.json", "gate"],
[".github/scripts/pr-merge/merge-holds.mjs", "gate"],
[".github/scripts/pr-merge/enable-auto-merge.mjs", "gate"],
[".github/scripts/pr-merge/superseded.mjs", "gate"],
[".github/scripts/pr-merge/clear-escalations.mjs", "gate"],
[".github/scripts/pr-merge/carry-run-cache.mjs", "gate"],
[".github/scripts/pr-merge/gate-check.mjs", "gate"],
[".github/scripts/pr-merge/head-ref-map.mjs", "gate"],
[".github/scripts/pr-merge/lane-records.mjs", "gate"],
[".github/scripts/pr-merge/rate-stop.bash", "gate"],
[".github/scripts/pr-merge/refire-refused.mjs", "gate"],
[".github/scripts/pr-merge/rotation.mjs", "gate"],
[".github/scripts/pr-merge/sweep-summary.mjs", "gate"],
[".github/scripts/pr-merge/who.mjs", "gate"],
[".github/scripts/pr-merge/post-unarmed-status.mjs", "gate"],
[".github/scripts/pr-merge/verify-answers.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"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_classify.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_dependabot.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py", "gate"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py", "gate"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_repo_config.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py", "gate"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_run_cache.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_state.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_util.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py", "enforcement"],
["plugins/source-control/skills/babysit-prs/scripts/babysit_whole_review.py", "enforcement"],
[".github/workflows/pr-require-checks.yml", "gate"],
[".github/workflows/pr-merge.yml", "gate"],
["docs/conventions/pr-pipeline.yaml", "gate"],
[".github/scripts/pr-unblock/address-feedback.mjs", "enforcement"],
[".github/scripts/pr-unblock/gate-source.mjs", "enforcement"],
[".github/scripts/pr-unblock/thread-scope.mjs", "enforcement"],
[".github/scripts/pr-unblock/retry-fetch.mjs", "enforcement"],
[".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"],
]);
// The `trust` and `gate` globs (babysit_risk.HIGH_FLOOR), which hold for a
// person. An `enforcement` glob (REVIEW_FLOOR) holds nothing here: the merge
// gate holds it (babysit_risk `review-path:`) until a clean security review
// binds to the head, and enable-auto-merge arms only a head the gate passed.
export const HOLD_PATH_FLOOR = Object.freeze(HOLD_PATH_CLASSES.filter(([, kind]) => kind !== "enforcement").map(([glob]) => glob));
Comment thread
kyle-sexton marked this conversation as resolved.
export const REVIEW_FLOOR = Object.freeze(HOLD_PATH_CLASSES.filter(([, kind]) => kind === "enforcement").map(([glob]) => glob));

// Why the head's changed `files` hold it, or null: one matching `holdPaths`
// or HOLD_PATH_FLOOR. Null `holdPaths` or `files` (unreadable) holds it.
Expand Down
Loading
Loading