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
7 changes: 7 additions & 0 deletions .changes/pr-pipeline/fix-arm-on-head-approval-6a1f3c92.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- enable-auto-merge no longer holds a lanes App pull request on a hold path once a person on the trusted-actor list approved its current head, the same `pathLifters` rule `ci-status` lifts that hold by; its other checks then decide. An approval of an older head, an untrusted reviewer's approval, or a newer request for changes lifts nothing, and a hold-path list or file listing that could not be read, or one too long to check, still holds (`.github/scripts/pr-merge/enable-auto-merge.mjs`). The gate's risk score lifts the same paths' `high-path:` reasons on that approval, so such a pull request now arms end to end (source-control's `path_lifters`).
7 changes: 7 additions & 0 deletions .changes/source-control/fix-arm-on-head-approval-3d7e9b14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- The pr-merge lane's risk score (`babysit_verify_evidence.py evidence --lane-reviews`) drops a configured or floor high path (`high-path:<glob>`) once a person on the trusted-actor list approved the pull request's current head, by the same rule `ci-status` lifts the hold path with (`babysit_merge_policy.path_lifters`, held equal to `merge-holds.mjs` `pathLifters`). An approval of an older head, an untrusted approver, a newer request for changes or a dismissal lifts nothing, and so does a review list or trusted-actor list that does not read. An aliasing path, symlink, submodule or mode change, and every cost, trust-gate, manifest, file-cap and review-lane hold, still hold.
48 changes: 31 additions & 17 deletions .github/scripts/pr-merge/enable-auto-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
// wrote its Hold: line can add one. The rule is merge-holds.pathHold, which
// ci-status also runs on every lanes App pull request event and merge
// group, so a push after arming is held without waiting for this job. A
// config that does not read arms nothing and labels nothing; the next run
// reads it again.
// trusted person's approving review of the head lifts it, as ci-status
// lifts it (merge-holds.pathLifters); it lifts no listing or check that
// could not run. A config that does not read arms nothing and labels
// nothing; the next run reads it again.
// - Its head changes no cost path, unless its body links the owner's cost
// decision: merge-holds.costHold and costDecision, which ci-status runs too.
// - No person turned its auto-merge off: the newest auto-merge-disabled event
Expand Down Expand Up @@ -139,7 +141,7 @@
// label (default .base/.work-item-tracker.json)
// RESOLVED_CONFIG the resolve-config output, for the hold paths
// TRUSTED_ACTORS the base trusted-actors.json, for the owner of a cost
// decision (default .base/.github/standards/trusted-actors/trusted-actors.json)
// decision and the approvers who lift a hold path (default .base/.github/standards/trusted-actors/trusted-actors.json)
// EVENT_NAME the run's event; only READER_EVENTS read verdicts and arm
// POSTMERGE_RESULT this run's babysit_postmerge.py report, for lane-room
// POSTMERGE_CODE_SHA the commit whose code ran that report's check (empty
Expand Down Expand Up @@ -168,7 +170,7 @@ import { createGitHub, errorLabel, GitHubError } from "../../actions/check-trust
import { rateLimitLatch } from "../../actions/log-api-budget/rate-guard.mjs";
import { humanGatedLabelFrom } from "../pr-unblock/address-feedback.mjs";
import { readHoldPaths } from "../work-resolve-issue/report-outcome.mjs";
import { BODY_HOLD, costDecision, costHold, humansOf, LANE_HOLD, pathHoldKind, personWrote, vetoers } from "./merge-holds.mjs";
import { BODY_HOLD, costDecision, costHold, humansOf, LANE_HOLD, pathHoldKind, pathLifters, personWrote, vetoers } from "./merge-holds.mjs";

export { BODY_HOLD, HUMAN_VETO } from "./merge-holds.mjs";

Expand Down Expand Up @@ -204,7 +206,7 @@ export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: Strin
// One candidate's discussion: the newest 100 of each, and wholeDiscussion
// reads the rest of any list that has more.
const COMMENT_FIELDS = "databaseId author { __typename login } body createdAt updatedAt lastEditedAt authorAssociation";
const REVIEW_FIELDS = "author { __typename login } body submittedAt authorAssociation";
const REVIEW_FIELDS = "author { __typename login ... on User { databaseId } } state commit { oid } body submittedAt authorAssociation";
const THREAD_COMMENT_FIELDS = "author { __typename login } body createdAt authorAssociation";
const THREAD_FIELDS = `id comments(last: 100) { totalCount nodes { ${THREAD_COMMENT_FIELDS} } }`;
export const DETAIL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
Expand Down Expand Up @@ -403,19 +405,29 @@ export async function wholeDiscussion(github, variables, detail) {
return { ...read, reviewThreads: { ...read.reviewThreads, nodes: threads } };
}

// One DETAIL_QUERY comment or review as a merge-holds item (fromRest's shape).
const itemOf = (item) => ({
login: item?.author?.login,
userId: item?.author?.databaseId,
state: item?.state,
commitId: item?.commit?.oid,
isUser: item?.author?.__typename === "User",
association: item?.authorAssociation,
at: String(item?.submittedAt ?? item?.createdAt ?? ""),
body: String(item?.body ?? ""),
});

// The trusted people (`humans`) whose newest deciding review of `head` in
// `detail` approves it, as ci-status reads them (merge-holds.pathLifters).
const headLifters = (detail, humans, head) => pathLifters((detail?.reviews?.nodes ?? []).map(itemOf), humans, head);

// Why the discussion `detail` (DETAIL_QUERY's pullRequest) holds it, or null.
export function discussionHold(detail) {
const threads = detail?.reviewThreads;
if (![detail?.comments, detail?.reviews, threads].every(whole) || !threads.nodes.every((thread) => whole(thread?.comments))) {
return "its comments could not all be read";
}
const items = [...detail.comments.nodes, ...detail.reviews.nodes, ...threads.nodes.flatMap((thread) => thread.comments.nodes)].map((item) => ({
login: item?.author?.login,
isUser: item?.author?.__typename === "User",
association: item?.authorAssociation,
at: String(item?.submittedAt ?? item?.createdAt ?? ""),
body: String(item?.body ?? ""),
}));
const items = [...detail.comments.nodes, ...detail.reviews.nodes, ...threads.nodes.flatMap((thread) => thread.comments.nodes)].map(itemOf);
const vetoes = vetoers(items);
return vetoes.length ? `${vetoes.join(", ")} asked not to merge it` : null;
}
Expand Down Expand Up @@ -948,10 +960,11 @@ export function decide(pull, { bot, humanGated, holdPaths = null }, detail) {
if (detail.filesCapped === true) {
return { action: armed ? "disarm" : "none", reason: FILES_CAPPED_REASON, clears: "person" };
}
// A hold path a person clears; a listing or check that could not run is
// a lane's, which the next run reads again.
// A hold path a person clears, lifted by a trusted approval of the head
// (`lifters`) as ci-status lifts it; a listing or check that could not run
// is a lane's, which the next run reads again.
const pathHeld = pathHoldKind(detail.files, holdPaths);
if (pathHeld !== null) {
if (pathHeld !== null && !(pathHeld.kind === "hold-path" && detail.lifters?.length > 0)) {
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
return { action: armed ? "disarm" : "none", reason: pathHeld.reason, clears: pathHeld.kind === "hold-path" ? "person" : "lane" };
}
const costHeld = costHold(detail.files, detail.costDecision);
Expand Down Expand Up @@ -1187,8 +1200,9 @@ export async function main({ env = process.env, github, verify, room, sleep = (m
if (detail !== null) {
detail = await wholeDiscussion(github, variables, detail);
const { paths: files, capped } = await changedFiles(github, `${repo[1]}/${repo[2]}`, pull.number);
const decision = costHold(files, null) ? await costDecision(github, repo[0], pull.number, pull.body, trustedHumans(env), pull.headRefOid) : null;
detail = { ...detail, files, filesCapped: capped, costDecision: decision };
const humans = trustedHumans(env);
const decision = costHold(files, null) ? await costDecision(github, repo[0], pull.number, pull.body, humans, pull.headRefOid) : null;
detail = { ...detail, files, filesCapped: capped, costDecision: decision, lifters: headLifters(detail, humans, pull.headRefOid) };
}
} catch (error) {
// A read that fails decides nothing and writes nothing.
Expand Down
51 changes: 48 additions & 3 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,51 @@ test("a pull request whose head now changes a hold path is not armed, or is disa
]);
});

// ci-status lifts a hold path on a trusted person's approving review of the
// head (merge-holds.pathLifters); arming reads the same rule. Kyle (id 5) is
// on the trusted-actor list, id 9 is not.
const reviewed = (state, { id = 5, login = "kyle-sexton", oid = HEAD, at = "2026-10-26T16:41:00Z" } = {}) => ({
author: { __typename: "User", login, databaseId: id },
state,
commit: { oid },
body: "",
submittedAt: at,
authorAssociation: "OWNER",
});
function trustedKyle() {
const dir = mkdtempSync(join(tmpdir(), "enable-auto-merge-"));
writeFileSync(join(dir, "actors.json"), JSON.stringify({ actors: [{ kind: "human", login: "kyle-sexton", id: 5 }] }));
return { TRUSTED_ACTORS: join(dir, "actors.json") };
}
const HELD = [{ filename: "docs/held.md" }];

test("a hold path a trusted person approved at the head is armed, and an armed one stays armed", async () => {
const approved = { reviews: [reviewed("APPROVED")] };
const { writes } = await run([pull(50, { files: HELD, discussion: approved }), pull(51, { files: HELD, discussion: approved, autoMergeRequest: {} })], trustedKyle());
assert.deepEqual(writes, [{ mutation: "arm", id: "PR_50", head: HEAD }]);
});

test("a hold path stays held without a trusted approval of the head", async () => {
const { writes } = await run(
[
pull(52, { files: HELD, discussion: { reviews: [reviewed("APPROVED", { oid: "c".repeat(40) })] } }),
pull(53, { files: HELD, discussion: { reviews: [reviewed("APPROVED", { id: 9, login: "someone" })] } }),
pull(54, { files: HELD, discussion: { reviews: [reviewed("APPROVED"), reviewed("CHANGES_REQUESTED", { at: "2026-10-26T17:20:00Z" })] } }),
pull(55, { files: HELD, discussion: { reviews: [reviewed("APPROVED", { id: 9, login: "someone" })] }, autoMergeRequest: {} }),
],
trustedKyle(),
);
assert.deepEqual(writes, [{ mutation: "disarm", id: "PR_55" }]);
});

test("an approval lifts no unread hold-path check and no capped file listing", () => {
const options = { bot: LANES.login, humanGated: "needs-human", holdPaths: [] };
const armed = pull(1, { autoMergeRequest: {} });
const approved = { ...detail(), lifters: ["kyle-sexton"], verify: PASSED };
assert.deepEqual(decide(armed, options, { ...approved, files: null }), { action: "disarm", reason: "its changed files could not all be read", clears: "lane" });
assert.deepEqual(decide(armed, options, { ...approved, files: ["docs/a.md"], filesCapped: true }), { action: "disarm", reason: FILES_CAPPED_REASON, clears: "person" });
});

// 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,
Expand Down Expand Up @@ -1740,7 +1785,7 @@ def passes(self, number, head, pr=None):

argv = sys.argv[1:]
pr = lambda repo, number: {"number": number, "head": {"sha": argv[-1]}}
with mock.patch.object(policy, "gh_capture", gh), mock.patch.object(postmerge, "gh_capture", gh), mock.patch.object(ve.Reader, "evidence", passes), mock.patch.object(policy, "pull_request", pr), mock.patch.object(ve, "lane_risk_refusal", lambda *args: None), mock.patch.object(policy, "read_config_at", lambda repo, sha: policy.PipelineConfig(sha, "C2", "pr-merge", "agent[bot]", None, (), "c2-mechanical")), mock.patch.object(policy, "with_default_branch", lambda repo, base, *rest: base), mock.patch.object(babysit_merge, "read_merge_queue", lambda repo, number: {"readError": None, "failedEjections": 0}):
with mock.patch.object(policy, "gh_capture", gh), mock.patch.object(postmerge, "gh_capture", gh), mock.patch.object(ve.Reader, "evidence", passes), mock.patch.object(policy, "pull_request", pr), mock.patch.object(ve, "lane_risk_refusal", lambda *args, **kwargs: None), mock.patch.object(policy, "read_config_at", lambda repo, sha: policy.PipelineConfig(sha, "C2", "pr-merge", "agent[bot]", None, (), "c2-mechanical")), mock.patch.object(policy, "with_default_branch", lambda repo, base, *rest: base), mock.patch.object(babysit_merge, "read_merge_queue", lambda repo, number: {"readError": None, "failedEjections": 0}):
sys.exit(ve.main(argv))
`,
);
Expand Down Expand Up @@ -1825,7 +1870,7 @@ def passes(self, number, head, pr=None):

argv = sys.argv[1:]
pr = lambda repo, number: {"number": number, "head": {"sha": argv[-1]}}
with mock.patch.object(policy, "gh_capture", gh), mock.patch.object(postmerge, "gh_capture", gh), mock.patch.object(ve.Reader, "_read_listing", listing), mock.patch.object(ve.Reader, "_evidence", passes), mock.patch.object(policy, "pull_request", pr), mock.patch.object(ve, "lane_risk_refusal", lambda *args: None), mock.patch.object(policy, "read_config_at", lambda repo, sha: policy.PipelineConfig(sha, "C2", "pr-merge", "agent[bot]", None, (), "c2-mechanical")), mock.patch.object(policy, "with_default_branch", lambda repo, base, *rest: base), mock.patch.object(babysit_merge, "read_merge_queue", lambda repo, number: {"readError": None, "failedEjections": 0}):
with mock.patch.object(policy, "gh_capture", gh), mock.patch.object(postmerge, "gh_capture", gh), mock.patch.object(ve.Reader, "_read_listing", listing), mock.patch.object(ve.Reader, "_evidence", passes), mock.patch.object(policy, "pull_request", pr), mock.patch.object(ve, "lane_risk_refusal", lambda *args, **kwargs: None), mock.patch.object(policy, "read_config_at", lambda repo, sha: policy.PipelineConfig(sha, "C2", "pr-merge", "agent[bot]", None, (), "c2-mechanical")), mock.patch.object(policy, "with_default_branch", lambda repo, base, *rest: base), mock.patch.object(babysit_merge, "read_merge_queue", lambda repo, number: {"readError": None, "failedEjections": 0}):
sys.exit(ve.main(argv))
`,
);
Expand Down Expand Up @@ -2408,7 +2453,7 @@ with mock.patch.object(ve.Reader, "link_refusal", lambda self, n: None), \\
mock.patch.object(policy, "read_config_at", lambda repo, sha: config), \\
mock.patch.object(policy, "with_default_branch", lambda repo, base, *rest: base), \\
mock.patch.object(policy, "pull_request", lambda repo, n: pr), \\
mock.patch.object(ve, "lane_risk_refusal", lambda *args: None), \\
mock.patch.object(ve, "lane_risk_refusal", lambda *args, **kwargs: None), \\
mock.patch.object(babysit_merge, "read_merge_queue", lambda repo, n: {"readError": None, "failedEjections": COUNTS[str(n)]}), \\
mock.patch.object(babysit_merge, "_failed_merge_group_runs", lambda repo, branch, n: []):
sys.exit(ve.main(sys.argv[1:]))
Expand Down
11 changes: 9 additions & 2 deletions docs/conventions/pr-pipeline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1174,7 +1174,7 @@ collaborator) whose newest comment, review or inline comment with text says "do
gate's veto rule; a later comment lifts it), and whose head changes no path in the lane's
`merge.diff-check.denied-paths` or on the [path floor](#merge-authority) (`merge-holds.mjs`
`HOLD_PATH_FLOOR`, the floor's `trust` and `gate` globs, equal to `babysit_risk.HIGH_FLOOR`), read on the files the head changes at each
run, and whose
run, unless a person on the trusted-actor list approved that head as `ci-status` reads it, and whose
issue link and verifier verdict at that head pr-merge's gate would take:
`babysit_verify_evidence.py evidence --lane-link` refuses nothing, so the link is one the gate's
[eligibility](#the-pr-merge-lane) rule takes (one closing issue in this repository, or, with none,
Expand Down Expand Up @@ -1318,7 +1318,14 @@ request event and in the merge queue, where a red group removes the pull request
adds a hold path or such a widening fails `ci-status` on its own head. A person on the trusted-actor list
(as main has it) lifts either by approving that head: their newest review of it that approves, requests changes or was
dismissed must approve. Then re-run `ci-status`, since a review does not. The approval lifts no
body hold or veto, and arming still skips the pull request, so that person merges it.
body hold or veto. `enable-auto-merge` reads the same approval (`pathLifters`) and stops holding
the pull request on the hold path, and the gate's risk score (`evidence --lane-reviews`,
`lane_risk_refusal`) drops its `high-path:` reasons for a path on `babysit_risk.HIGH_FLOOR` or in
`merge.diff-check.denied-paths` on the same approval (`babysit_merge_policy.path_lifters`, which
`test_merge_holds_sync.py` holds equal to `pathLifters`), so its other checks decide. The approval
lifts no aliasing path, symlink, submodule or mode change (`high-path:**`), cost, trust-gate,
manifest, file-cap or review-lane hold, and a review list or trusted-actor list that does not read
lifts nothing.
Both also hold a pull request that changes one of the merge gate's cost paths
(`babysit_risk.COST_PATHS`: Pulumi, Terraform and Bicep files). An
approval does not lift that hold. Only the owner's cost decision does, read as the gate's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2509,6 +2509,34 @@ def _denied_path_refusal(
return f"denied-path:{','.join(hits)}" if hits else None


def path_lifters(
reviews: Sequence[Mapping[str, Any]], humans: Collection[int], head: str
) -> list[str]:
"""The logins of the trusted people (`humans` ids) whose newest deciding
review (approved, changes requested or dismissed) of `head` among the REST
`reviews` approves it, sorted: merge-holds.mjs `pathLifters` over
`fromRest`, which test_merge_holds_sync.py holds equal."""
newest: dict[int, tuple[str, Mapping[str, Any]]] = {}
for row in reviews:
user = json_object(row.get("user"))
uid = user.get("id")
if user.get("type") != "User" or type(uid) is not int or uid not in humans:
continue
if row.get("commit_id") != head:
continue
if row.get("state") not in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"):
continue
at = row.get("submitted_at")
at = str((row.get("created_at") if at is None else at) or "")
if uid not in newest or at >= newest[uid][0]:
newest[uid] = (at, row)
return sorted(
str(json_object(row.get("user")).get("login") or "")
for _, row in newest.values()
if row.get("state") == "APPROVED"
)


AUTHOR_NOT_OWNER = "author-not-owner"
# `trusted_actors` by (repo, ref), read once a run.
_TRUSTED_IDS: dict[tuple[str, ...], tuple[tuple[int, ...], tuple[int, ...]]] = {}
Expand Down
Loading
Loading