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
8 changes: 8 additions & 0 deletions .changes/pr-pipeline/agent-ac72c39ea4f0fbf44-4968e884.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: minor
---

### Changed

- resolve-config no longer adds `.github/**` to `merge.diff-check.denied-paths`; it still adds the config file's own path and the trusted-actor list. A lanes App pull request touching `.github/scripts`, `.github/actions` or another `.github` path off the merge gate's HIGH floor is no longer held by report-outcome's `Hold:` line, `enable-auto-merge` or `ci-status`, and merges on the two review lanes (owner direction 2026-10-23). The lanes App's token never carries the `workflows` permission, so no lanes App push changes a workflow file; another identity with write access can still push one after auto-merge is armed. Hold paths now match case-insensitively, as the merge gate's `glob_match` does, so `.github/codeowners` or `.github/actions/Resolve-Config/` holds. The floor also holds `.github/actions/check-kill-switch/**`, whose output writer check-trusted-trigger and resolve-config load; a test walks both actions' imports and fails on any file the floor does not hold.
- `merge-holds.mjs` no longer holds every `plugin.json` and `marketplace.json` path-wide. Its hold-path floor is the forced paths plus the merge gate's HIGH floor (`babysit_risk.HIGH_FLOOR`), and `ci-status` compares a changed manifest base against head as the gate's manifest review does (`manifestChanges`, the rule of `babysit_risk._manifest_changes`): an executable `plugin.json` key or `experimental.monitors`, a marketplace plugin added, removed, renamed or re-sourced or a marketplace `name` change, a removed, renamed or copied manifest, and a side that does not read or parse or repeats a key hold; a `userConfig` default or version change merges. `enable-auto-merge` arms such a change once the gate's risk score (`evidence --lane-reviews`) allows it. ci-status now hands the module each file's status and the base commit.
7 changes: 7 additions & 0 deletions .changes/source-control/agent-ac72c39ea4f0fbf44-57679074.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- The HIGH path floor (`babysit_risk.HIGH_FLOOR`) also holds `.github/actions/check-kill-switch/**`: check-trusted-trigger and resolve-config write their verdicts through its `writeOutputs`, so a change there could rewrite the trust check's result.
2 changes: 1 addition & 1 deletion .github/actions/resolve-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ trusted-actor list or the lanes App, and a field with no counted value reads as
known; an option no entry maps fails the step (`unmapped-dimension`); an applying slot becomes
`research-first`, `probe-first` or `split-required` in that order
([README, Models](../../../docs/conventions/pr-pipeline/README.md#models)). A slot's `applies-when` replaces its activity's. `merge.diff-check.denied-paths` always
gains the config path, `.github/**` and the trusted-actor list.
gains the config path and the trusted-actor list.

The lane's slots are its leaves: each composite is replaced by its children, depth first in list
order. A leaf is enabled only when the lane, every slot on its path and its own slot are; it must
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/resolve-config/merge-authority.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const rejectedFor = (config, pattern) =>
// forced denied paths.
const VALID_MERGE = {
rung: "off",
"diff-check": { "denied-paths": [CONFIG_PATH, ".github/**", TRUSTED_ACTORS] },
"diff-check": { "denied-paths": [CONFIG_PATH, TRUSTED_ACTORS] },
"stack-landing": "manual",
"lane-prs": "native",
};
Expand Down
1 change: 0 additions & 1 deletion .github/actions/resolve-config/resolve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,6 @@ function resolveMerge(merge = {}, configPath) {
...new Set([
...(diffCheck["denied-paths"] ?? []),
configPath,
".github/**",
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
TRUSTED_ACTORS_PATH,
]),
];
Expand Down
6 changes: 3 additions & 3 deletions .github/actions/resolve-config/resolve.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ const TRUSTED_ACTORS = ".github/standards/trusted-actors/trusted-actors.json";
test("forced denied paths are added when the config sets no merge section", () => {
assert.deepEqual(
run(fixture("valid.yaml")).merge["diff-check"]["denied-paths"],
[CONFIG_PATH, ".github/**", TRUSTED_ACTORS],
[CONFIG_PATH, TRUSTED_ACTORS],
);
});

Expand All @@ -311,14 +311,14 @@ test("forced denied paths are added after the config's own, once each", () => {
doc.merge = {
rung: "C2",
"diff-check": {
"denied-paths": [".claude/**", ".github/**"],
"denied-paths": [".claude/**", CONFIG_PATH],
"max-changed-lines": 400,
},
};
});
const resolved = run(config);
assert.deepEqual(resolved.merge["diff-check"], {
"denied-paths": [".claude/**", ".github/**", CONFIG_PATH, TRUSTED_ACTORS],
"denied-paths": [".claude/**", CONFIG_PATH, TRUSTED_ACTORS],
"max-changed-lines": 400,
});
});
Expand Down
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 @@ -376,28 +376,38 @@ test("the comment veto agrees with the gate's HUMAN_MERGE_VETO_RE", () => {
// a later push can add a held path to a pull request that is already armed.
test("a pull request whose head now changes a hold path is not armed, or is disarmed", async () => {
const { writes } = await run([
pull(1, { files: [{ filename: ".github/workflows/x.yml" }] }),
pull(2, { autoMergeRequest: {}, files: [{ filename: "plugins/a/README.md" }, { filename: "docs/a.md", previous_filename: ".github/a.md" }] }),
pull(1, { files: [{ filename: "docs/conventions/pr-pipeline.yaml" }] }),
pull(2, {
autoMergeRequest: {},
files: [{ filename: "plugins/a/README.md" }, { filename: "docs/a.md", previous_filename: ".github/standards/trusted-actors/trusted-actors.json" }],
}),
pull(3, { files: Array.from({ length: 301 }, (_, index) => ({ filename: `docs/${index}.md` })) }),
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/merge-holds.mjs" }] }),
]);
assert.deepEqual(writes, [
{ mutation: "disarm", id: "PR_2" },
{ mutation: "arm", id: "PR_5", head: HEAD },
{ mutation: "arm", id: "PR_6", head: HEAD },
]);
});

// The gate scores the marketplace manifest HIGH (babysit_risk.is_manifest_path),
// so it is a hold path like every plugin manifest.
test("a pull request whose head changes the marketplace manifest is not armed, or is disarmed", () => {
// A manifest is no hold path: the gate's reader scores it (`evidence
// --lane-reviews`, babysit_risk manifest-review-required), so a manifest
// change the gate reviews is not armed, or is disarmed, and one it does not,
// such as a plugin.json userConfig default (#9483), arms.
test("a manifest change arms only when the gate's risk score allows it", () => {
const options = { bot: LANES.login, humanGated: "needs-human", holdPaths: [] };
const files = ["docs/a.md", ".claude-plugin/marketplace.json"];
assert.deepEqual(decide(pull(1), options, { ...detail(), files, verify: PASSED }), {
const files = ["docs/a.md", ".claude-plugin/marketplace.json", "plugins/a/.claude-plugin/plugin.json"];
const review = { ...PASSED, reviews: "manifest-review-required: plugins/a/.claude-plugin/plugin.json: hooks" };
assert.deepEqual(decide(pull(1), options, { ...detail(), files, verify: review }), {
action: "none",
reason: "it changes .claude-plugin/marketplace.json, a hold path",
reason: "pr-merge's gate holds it on its risk score (manifest-review-required: plugins/a/.claude-plugin/plugin.json: hooks)",
});
assert.equal(decide(pull(1, { autoMergeRequest: {} }), options, { ...detail(), files, verify: PASSED }).action, "disarm");
assert.equal(decide(pull(1, { autoMergeRequest: {} }), options, { ...detail(), files, verify: review }).action, "disarm");
assert.deepEqual(decide(pull(1), options, { ...detail(), files, verify: PASSED }), { action: "arm", reason: "no hold" });
});

// The gate holds a cost path (babysit_risk.COST_PATHS) until the body links
Expand Down Expand Up @@ -446,12 +456,15 @@ test("merge-holds.mjs holds a changed or renamed-from hold path from the command
return spawnSync(process.execPath, [fileURLToPath(new URL("./merge-holds.mjs", import.meta.url)), ...args], { encoding: "utf8" });
};
assert.equal(cli([["docs/a.md"]], ["docs/held.md"]).status, 0);
assert.match(cli([[".github/workflows/.x.yml"]], []).stdout, /it changes \.github\/workflows\/\.x\.yml, a hold path/);
assert.match(cli([[".github/actions/resolve-config/.x.mjs"]], []).stdout, /it changes \.github\/actions\/resolve-config\/\.x\.mjs, a hold path/);
assert.equal(cli([[".github/workflows/x.yml"]], []).status, 0);
// A manifest's sides are read at BASE_SHA and the head, none given here.
assert.match(cli([{ filename: "plugins/a/.claude-plugin/plugin.json", status: "modified" }], []).stdout, /plugin\.json \(contents unreadable\), a manifest change/);
assert.match(cli([["plugins/a/.hidden"]], ["plugins/**"]).stdout, /it changes plugins\/a\/\.hidden, a hold path/);
assert.equal(cli([["plugins/a/x.md"]], ["plugins/*.md"]).status, 0);
const renamed = cli([["docs/a.md", ".github/a.md"]], []);
const renamed = cli([["docs/a.md", ".github/actions/check-trusted-trigger/a.md"]], []);
assert.equal(renamed.status, 1);
assert.match(renamed.stdout, /it changes \.github\/a\.md, a hold path/);
assert.match(renamed.stdout, /it changes \.github\/actions\/check-trusted-trigger\/a\.md, a hold path/);
assert.match(cli([["docs/held.md"]], ["docs/held.md"]).stdout, /it changes docs\/held\.md, a hold path/);
assert.match(cli([["docs/a.md"]], "docs/held.md").stdout, /the hold paths could not be read/);
const approve = (id, commit) => ({ user: { login: `u${id}`, id, type: "User" }, state: "APPROVED", commit_id: commit, submitted_at: "2026-10-23T11:00:00Z" });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"merge": {
"diff-check": {
"denied-paths": [".github/**", "docs/held.md"]
"denied-paths": ["docs/held.md", "docs/conventions/pr-pipeline.yaml", ".github/standards/trusted-actors/trusted-actors.json"]
}
}
}
Loading
Loading