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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- The post-merge check takes the newest green full run for a red run's affected set (`_Context.affected`) and the newest completed run for its baseline (`_Context._baseline`). It now picks only a run whose head commit is the branch's tip or an ancestor of `refs/heads/<branch>` (`_Context.on_branch`, through `_Context.contains`). The same check now filters the scheduled, dispatched and canary runs a merge is judged on, the runs read by commit to prove a listing incomplete, and the lane re-run probe, so a red run on such a tag can no longer fail a merge. The promotion evidence (`publish-promotion-evidence.mjs`) filters its post-merge runs the same way. Before, it trusted any run that reported `head_branch: main`, and a run on a tag named `main` reports that too. Such a run could check out a writer's own commit and run its plan script, or narrow the affected set so that a red main run read as unaffected and filed no demotion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- The post-merge check (`babysit_postmerge.py`) recognizes a native auto-merge of the agent identity's pull request: the identity opened it from a branch of this repository, and the newest auto-merge enable or disable event is the identity's enable, made no later than the merge-queue enqueue. Such a merge posts no `babysit-merge-pin` comment, so it no longer waits for a pin read-back and fails it at the dispatch deadline, which filed a demotion record and held pr-merge for owner and Dependabot pull requests. It is still the identity's merge, so post-merge-dispatch starts its full run, and it verifies on the dispatch and canary runs alone; a red full run or canary after it still fails it. The auto-merge events come from a second aliased `timelineItems` connection in the same merged-pull-request query, and a native merge skips the pin read, so no read is added. The promotion evidence (`publish-promotion-evidence.mjs`) applies the same rule, requiring one lane identity as author, enqueuer and armer, and refuses to publish when that identity's auto-merge events cannot be read, so its demotion counts still match. A gate merge keeps its read-back: the gate disarms an armed auto-merge before it pins, and an arm made after the enqueue does not count.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export function pr(
labels: page1(names),
closingIssuesReferences: page1(linked),
timelineItems: page1(timeline),
autoMerge: { nodes: [] },
author: actorOf(LANE),
isCrossRepository: false,
...patch,
},
list: {
Expand Down Expand Up @@ -85,6 +88,10 @@ export const wfRun = (id, event, sha, created, { conclusion = "success", status
// `workflowRuns`: {<workflow file>: [run]}; `attempts`: {<run id>: {<n>: run}}; `history`: the
// default branch's commits oldest first (linear, as a merge-queue branch is), defaulting to the
// merge commits in merge order.
// `offBranch`: commits not on the default branch that descend from it, as on a tag named main.
// `onTop`: commits on top of the branch tip but not on the branch (a tag named main one commit
// ahead), which a compare against the tip answers `behind`. `unknownCompare`: commits whose
// compare answers no status. `tip`: what `refs/heads/main` reads, default the last commit.
// `diverged`: head SHAs a compare reports as diverged. A PR number not in `pulls` answers with the
// recorded NOT_FOUND response.
// The lane posts a pin comment on every PR it enqueues and dispatches a full run after every merge
Expand Down Expand Up @@ -122,7 +129,7 @@ export const issueItem = (number, created, { pull = false } = {}) => ({
created_at: created,
pull_request: pull ? { url: `https://api.github.com/repos/${REPO}/pulls/${number}` } : null,
});
export function github({ pulls = [], commits = [], checkRuns = {}, statuses = {}, issueEvents = {}, reviews = {}, issues = {}, workflowRuns: explicitRuns = {}, attempts = {}, history, diverged = [], comments = {}, noDefaultDispatch = false, bumpOnPage2, deletedRuns = [], demotionIssues = [], issueList = [], goneIssues = [], missingIssues = [], discussions = {}, lateRuns = [] }) {
export function github({ pulls = [], commits = [], checkRuns = {}, statuses = {}, issueEvents = {}, reviews = {}, issues = {}, workflowRuns: explicitRuns = {}, attempts = {}, history, diverged = [], comments = {}, noDefaultDispatch = false, bumpOnPage2, deletedRuns = [], demotionIssues = [], issueList = [], goneIssues = [], missingIssues = [], discussions = {}, lateRuns = [], offBranch = [], onTop = [], unknownCompare = [], tip }) {
const defaultDispatch = noDefaultDispatch ? [] : pulls.map((p) => wfRun(900000 + p.graph.number, "workflow_dispatch", p.graph.mergeCommit.oid, later(p.graph.mergedAt, 60), { actor: LANE }));
const explicitIds = new Set(Object.values(explicitRuns).flat().map((r) => r.id));
const numbered = (runs) =>
Expand Down Expand Up @@ -224,7 +231,14 @@ export function github({ pulls = [], commits = [], checkRuns = {}, statuses = {}
return { total_count: Math.min(runs.length, 2500), workflow_runs: runs.slice((n - 1) * size, n * size) };
}
if ((m = /^actions\/runs\/(\d+)\/attempts\/(\d+)$/.exec(rest))) return attempts[m[1]][m[2]];
if (rest === "git/ref/heads/main") return { ref: "refs/heads/main", object: { sha: tip === undefined ? branch.at(-1) : tip } };
if ((m = /^compare\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})\?/.exec(rest))) {
// An off-branch commit (a tag named main) descends from every branch commit and is
// diverged from the tip.
if (unknownCompare.includes(m[1]) || unknownCompare.includes(m[2])) return { ...REC.compare, status: undefined };
if (onTop.includes(m[1])) return { ...REC.compare, status: "behind", ahead_by: 0, total_commits: 0, commits: [] };
if (offBranch.includes(m[2]) || onTop.includes(m[2])) return { ...REC.compare, status: "ahead", ahead_by: 1, total_commits: 1, commits: [] };
if (offBranch.includes(m[1])) return { ...REC.compare, status: "diverged", ahead_by: 0, total_commits: 0, commits: [] };
const [base, head] = [branch.indexOf(m[1]), branch.indexOf(m[2])];
assert.ok(base >= 0 && head >= 0, `compare outside the recorded history: ${rest}`);
const status = diverged.includes(m[2]) ? "diverged" : head > base ? "ahead" : head === base ? "identical" : "behind";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,13 @@ function ms(value, what) {

const PULL_QUERY =
"query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){" +
"number headRefOid mergedAt lastEditedAt mergeCommit{oid} mergedBy{__typename login} labels(first:100){pageInfo{hasNextPage} nodes{name}} " +
"number headRefOid mergedAt lastEditedAt mergeCommit{oid} mergedBy{__typename login} author{__typename login} isCrossRepository labels(first:100){pageInfo{hasNextPage} nodes{name}} " +
"closingIssuesReferences(first:50){pageInfo{hasNextPage} nodes{number labels(first:100){pageInfo{hasNextPage} nodes{name}}}} " +
"timelineItems(first:100,itemTypes:[CONNECTED_EVENT,DISCONNECTED_EVENT,ADDED_TO_MERGE_QUEUE_EVENT]){pageInfo{hasNextPage} nodes{__typename " +
"... on ConnectedEvent{createdAt subject{__typename ... on Issue{number}}} ... on DisconnectedEvent{createdAt subject{__typename ... on Issue{number}}} " +
"... on AddedToMergeQueueEvent{createdAt actor{__typename login} enqueuer{__typename login}}}}}}}";
"... on AddedToMergeQueueEvent{createdAt actor{__typename login} enqueuer{__typename login}}}} " +
"autoMerge:timelineItems(itemTypes:[AUTO_MERGE_ENABLED_EVENT,AUTO_MERGE_DISABLED_EVENT],last:1){nodes{__typename " +
"... on AutoMergeEnabledEvent{createdAt actor{__typename login}} ... on AutoMergeDisabledEvent{createdAt actor{__typename login}}}}}}}";
const CELLS = ["C2-auto-merge", "C3-ai-review-blocking", "C3-auto-merge"];

// A REST login (`app[bot]` for an App), compared exactly and case-insensitively.
Expand Down Expand Up @@ -257,8 +259,9 @@ function eventKey(event) {
// Where check() files a Failure, this emits a gate-failure, so demotion counts match: a red full
// or canary run (at the run's update time); no dispatch run by a lane identity created within
// DISPATCH_DEADLINE of the merge, once that deadline has passed (at the deadline); and, for a
// merge a lane identity enqueued, the pin read-back (read_pin): no unedited lane pin once the
// deadline has passed (at the deadline), or a pinned SHA that is not headRefOid (at the merge).
// merge a lane identity enqueued, other than its own native auto-merge, the pin read-back
// (read_pin): no unedited lane pin once the deadline has passed (at the deadline), or a pinned
// SHA that is not headRefOid (at the merge).
const FULL_RUN_WORKFLOW = "pr-require-checks.yml";
const CANARY_WORKFLOW = "post-merge-detect-silent-revert.yml";
const DISPATCH_DEADLINE_MS = 6 * 3_600_000;
Expand Down Expand Up @@ -300,10 +303,29 @@ function postMergeRuns(gh, repo, branch, merges, { lane, throughMs }) {
const floor = Math.min(...merges.map((m) => ms(m.mergedAt, `#${m.number} mergedAt`)));
const created = encodeURIComponent(`>=${new Date(floor).toISOString().replace(/\.\d{3}Z$/, "Z")}`);
const byCreated = (a, b) => a.createdMs - b.createdMs;
// check()'s _Context.on_branch: a run's head_branch is only a name, which a run on a tag named
// like the branch carries too, so a run counts only when its commit is on refs/heads/<branch>.
// Residual: the tip is read here, before the run listings, where check() reads it after them,
// so a run on a commit that lands between the two reads is off the branch here alone.
const ref = gh(`repos/${repo}/git/ref/heads/${branch.split("/").map(encodeURIComponent).join("/")}`);
const tip = ref?.object?.sha;
if (!/^[0-9a-f]{40}$/.test(tip ?? "")) throw new PublishError(`refusing to publish: branch ${branch} has no tip commit`);
const onBranches = new Map();
const onBranch = (sha) => {
if (!/^[0-9a-f]{40}$/.test(sha ?? "")) return false;
if (!onBranches.has(sha)) {
const status = sha === tip ? "identical" : gh(`repos/${repo}/compare/${sha}...${tip}?per_page=1&page=1`)?.status;
if (!["ahead", "identical", "behind", "diverged"].includes(status)) {
throw new PublishError(`refusing to publish: compare ${sha}...${tip} is ${JSON.stringify(status)}, so whether that run is on ${branch} is unknown`);
}
onBranches.set(sha, status === "ahead" || status === "identical");
}
return onBranches.get(sha);
};
const list = (workflow, event) =>
pages(gh, `repos/${repo}/actions/workflows/${workflow}/runs?event=${event}&branch=${encodeURIComponent(branch)}&created=${created}`, "workflow_runs")
.map((run) => ({ ...run, createdMs: ms(run.created_at, `run ${run.id}`), check: `${workflow} ${event}` }))
.filter((run) => run.head_branch === branch && run.createdMs >= floor);
.filter((run) => run.head_branch === branch && run.createdMs >= floor && onBranch(run.head_sha));
const fullRuns = [...list(FULL_RUN_WORKFLOW, "schedule"), ...list(FULL_RUN_WORKFLOW, "workflow_dispatch")].sort(byCreated);
const canaryRuns = list(CANARY_WORKFLOW, "push").sort(byCreated);

Expand Down Expand Up @@ -591,7 +613,20 @@ export function collect({ repo, since, through, laneLogins, gateChecks, aiReview
const queued = timeline.filter((e) => e.__typename === "AddedToMergeQueueEvent").sort((a, b) => ms(a.createdAt, "a merge-queue event") - ms(b.createdAt, "a merge-queue event"));
const actors = queued.length > 0 ? [queued.at(-1).actor, queued.at(-1).enqueuer] : [g.mergedBy];
const byIdentity = actors.some((actor) => actor && lane.has(actorKey(actor)));
return { autonomous: actors.some((actor) => !human(actor)) || lane.has(actorKey(g.mergedBy)), byIdentity };
// babysit_postmerge.py native_auto_merge: a lane identity's own native auto-merge posts no
// pin. One lane identity opened it from this repository, enqueued it, and made its newest
// auto-merge enable or disable, an enable no later than the enqueue. When that identity
// opened it from this repository, unreadable auto-merge events refuse the publish, as the
// check reads them unreadable.
const who = actorKey(g.author);
const owned = byIdentity && lane.has(who) && actors.some((actor) => actor && actorKey(actor) === who) && g.isCrossRepository === false;
if (owned && !Array.isArray(g.autoMerge?.nodes)) throw new PublishError(`refusing to publish: #${g.number} has no auto-merge events`);
const armed = owned ? g.autoMerge.nodes.at(-1) : undefined;
const native =
armed?.__typename === "AutoMergeEnabledEvent" &&
actorKey(armed.actor) === who &&
(queued.length === 0 || ms(armed.createdAt, "an auto-merge event") <= ms(queued.at(-1).createdAt, "a merge-queue event"));
return { autonomous: actors.some((actor) => !human(actor)) || lane.has(actorKey(g.mergedBy)), byIdentity, native };
};

const details = new Map();
Expand Down Expand Up @@ -705,7 +740,7 @@ export function collect({ repo, since, through, laneLogins, gateChecks, aiReview
// Then the post-merge workflow runs on later SHAs containing the merge (postMergeRuns).
for (const r of tracked.filter((c) => c.autonomous)) {
const wf = judgeWorkflows(r);
const pin = r.byIdentity ? readBack(r) : {};
const pin = r.byIdentity && !r.native ? readBack(r) : {};
if (pin.failure) wf.failures.push(pin.failure);
if (pin.pending) wf.unsettled = true;
for (const failure of wf.failures) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,53 @@ test("a red full run on a later SHA containing two merges yields gate-failure fo
);
});

test("a red run on a commit off the branch is not charged, as in _Context.on_branch", () => {
// A run on a tag named main reports head_branch main; its commit descends from the merge but
// is not on refs/heads/main.
const off = shaOf(0xa0ff);
const pulls = () => [pr(1601, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })];
const red = (offBranch) =>
run({
pulls: pulls(),
history: [shaOf(1601), off, shaOf(0xa100)],
offBranch,
workflowRuns: {
...verifiedEach(pulls()),
[FULL]: [...verifiedEach(pulls())[FULL], wfRun(7, "workflow_dispatch", off, "2026-09-01T00:30:00Z", { conclusion: "failure", actor: LANE })],
},
});
assert.deepEqual(red([off]).events, []);
// The same run with its commit on the branch is a gate failure.
assert.deepEqual(red([]).gateFailures.map((g) => [g.number, g.check]), [[1601, `${FULL} workflow_dispatch`]]);
});

test("a red run on a commit on top of the tip (compare behind) is not charged", () => {
// A tag named main one commit past refs/heads/main: compare <run>...<tip> answers behind.
const top = shaOf(0xa0fe);
const pulls = () => [pr(1602, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })];
const result = run({
pulls: pulls(),
onTop: [top],
workflowRuns: {
...verifiedEach(pulls()),
[FULL]: [...verifiedEach(pulls())[FULL], wfRun(8, "workflow_dispatch", top, "2026-09-01T00:30:00Z", { conclusion: "failure", actor: LANE })],
},
});
assert.deepEqual([result.events, result.gateFailures], [[], []]);
});

test("an unreadable branch tip or run ancestry refuses the publish", () => {
const pulls = () => [pr(1603, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })];
assert.throws(() => run({ pulls: pulls(), tip: null }), (error) => error instanceof PublishError && /has no tip commit/.test(error.message));
const odd = shaOf(0xa0fd);
const unknown = {
pulls: pulls(),
unknownCompare: [odd],
workflowRuns: { ...verifiedEach(pulls()), [FULL]: [...verifiedEach(pulls())[FULL], wfRun(9, "schedule", odd, "2026-09-01T00:30:00Z")] },
};
assert.throws(() => run(unknown), (error) => error instanceof PublishError && /whether that run is on main is unknown/.test(error.message));
});

test("a run created before the merge is not its evidence", () => {
// Run 3 on #1303's own SHA predates #1303's merge, so it is evidence for #1309 only.
const result = run({
Expand Down Expand Up @@ -139,6 +186,39 @@ test("a lane-enqueued merge passes only when its unedited lane pin names headRef
assert.deepEqual(gateOf(run({ pulls: bot, workflowRuns: verifiedEach(bot), comments: { 1503: [] } })), PASSED);
});

test("a lane identity's native auto-merge owes no pin, as in native_auto_merge", () => {
const armed = (kind, at, login = LANE) => ({ nodes: [{ __typename: kind, createdAt: at, actor: actorOf(login) }] });
const enqueue = [queued("2026-08-31T23:50:00Z", LANE)];
const pulls = (autoMerge, extra = {}, timeline = enqueue) => [pr(1505, "2026-09-01T00:00:00Z", { labels: [MECHANICAL], timeline, patch: { autoMerge, ...extra } })];
const judge = (autoMerge, extra, lanes = [LANE], timeline = enqueue) => {
const result = run({ pulls: pulls(autoMerge, extra, timeline), workflowRuns: verifiedEach(pulls(autoMerge, extra, timeline)), comments: { 1505: [] } }, { laneLogins: lanes });
return [gateOf(result), result.events];
};
const noPin = [FAILED, gateEvents("2026-09-01T06:00:00Z", "C2-auto-merge")];
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z")), [PASSED, []]);
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:50:00Z")), [PASSED, []]);
// The gate's disarm, an arm after the enqueue, a person's arm, or no events: the pin stays owed.
assert.deepEqual(judge(armed("AutoMergeDisabledEvent", "2026-08-31T23:40:00Z")), noPin);
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:55:00Z")), noPin);
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z", "kyle-sexton")), noPin);
// Unreadable auto-merge events refuse the publish, as the check reads them unreadable.
assert.throws(() => judge(undefined), (error) => error instanceof PublishError && /no auto-merge events/.test(error.message));
// One lane identity opened and armed it, another enqueued it: not one identity's native merge.
const lanes = [LANE, "other-lane[bot]"];
const byOther = [queued("2026-08-31T23:50:00Z", "other-lane[bot]")];
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z"), {}, lanes, byOther), noPin);
// One lane identity opened it and another armed it: not one identity's native merge.
const other = "other-lane[bot]";
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z", other), {}, [LANE, other]), noPin);
// A lane arm on a pull request the lane did not open from this repository is off the gate.
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z"), { author: actorOf("kyle-sexton") }), noPin);
assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z"), { isCrossRepository: true }), noPin);
// A red full run after a native merge is still its gate failure.
const native = pulls(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z"));
const red = run({ pulls: native, workflowRuns: { [FULL]: [wfRun(9, "schedule", shaOf(1505), "2026-09-01T02:00:00Z", { conclusion: "failure" })] }, comments: { 1505: [] } });
assert.deepEqual(red.gateFailures.map((g) => [g.number, g.check]), [[1505, `${FULL} schedule`]]);
});

test("a pin past the 30th comment page is no pin, as in read_pin", () => {
const pulls = () => [pr(1504, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })];
const filler = (count) => Array.from({ length: count }, () => REC.commentsPage.data.repository.pullRequest.comments.nodes[0]);
Expand Down
Loading
Loading