CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(ci): server-side merges fire push workflows; gated chain gets the ancestry guard #5469

Merged⚡ AI-generatedXSccantynz wants to mergefix/merge-fires-push-workflowsmainopened 24d ago
6 changed files+222−10
Addedsrc/__tests__/merge-fires-push-workflows.test.ts+81−0View fileUnifiedSplit
1/**
2 * Server-side merges must fire `on: push` workflows.
3 *
4 * A merge advances the base branch without a git push, so the receive-pack
5 * hook that calls syncAndEnqueuePushWorkflows never sees it. Discovered
6 * 2026-08-08: PR #5468 merged to main and produced no verify-deploy run at
7 * all — on a platform where PR merges are the primary way main moves,
8 * `on: push: branches: [main]` effectively never fired.
9 *
10 * Structural, like sweepers-are-wired.test.ts: every merge execution path
11 * must reference the shared helper. There are FIVE merge sites (the
12 * "two sources of truth" pattern, times two and a half):
13 * 1. lib/pr-merge.ts performMerge — ai-loop, auto-merge, autopilot
14 * 2. lib/pr-merge-gated.ts — v2 endpoint + MCP merge
15 * 3. routes/pulls.tsx — the merge button
16 * 4. routes/merge-queue.tsx — TWO success exits (non-ff + ff)
17 */
18
19import { describe, expect, test } from "bun:test";
20
21const HELPER = "enqueuePushWorkflowsForBranchAdvance";
22
23async function read(path: string): Promise<string> {
24 return Bun.file(path).text();
25}
26
27describe("every merge path fires push workflows", () => {
28 test("the helper exists, resolves its own sha, and never throws", async () => {
29 const src = await read("src/lib/push-workflow-sync.ts");
30 const fn = src.slice(src.indexOf(`export async function ${HELPER}`));
31 expect(fn.length).toBeGreaterThan(0);
32 // Validates the resolved tip before enqueueing anything.
33 expect(fn).toContain("rev-parse");
34 expect(fn).toMatch(/\[0-9a-f\]\{40\}/);
35 // Best-effort contract: a workflow problem must not break a merge.
36 expect(fn).toContain("catch");
37 });
38
39 test("performMerge (ai-loop / auto-merge / autopilot) calls it", async () => {
40 const src = await read("src/lib/pr-merge.ts");
41 expect(src).toContain(HELPER);
42 });
43
44 test("the gated chain (v2 endpoint + MCP) calls it", async () => {
45 const src = await read("src/lib/pr-merge-gated.ts");
46 expect(src).toContain(HELPER);
47 });
48
49 test("the merge button (pulls.tsx) calls it", async () => {
50 const src = await read("src/routes/pulls.tsx");
51 expect(src).toContain(HELPER);
52 });
53
54 test("the merge queue calls it on BOTH success exits", async () => {
55 const src = await read("src/routes/merge-queue.tsx");
56 const first = src.indexOf(HELPER);
57 expect(first).toBeGreaterThan(-1);
58 expect(src.indexOf(HELPER, first + 1)).toBeGreaterThan(first);
59 });
60});
61
62describe("the gated chain's update-ref is ancestry-guarded", () => {
63 test("pr-merge-gated.ts checks merge-base --is-ancestor before update-ref", async () => {
64 // INCIDENT 2026-08-08: a bare `update-ref base head` REPLACES base and
65 // discards every commit merged since the branch diverged. The guard was
66 // added to pr-merge.ts and merge-queue.tsx on incident day; the gated
67 // chain (the path MCP merges take) was found still missing it later the
68 // same day. This pins all three.
69 for (const path of [
70 "src/lib/pr-merge-gated.ts",
71 "src/lib/pr-merge.ts",
72 "src/routes/merge-queue.tsx",
73 ]) {
74 const src = await read(path);
75 const guard = src.indexOf("--is-ancestor");
76 const updateRef = src.indexOf('"update-ref"');
77 expect(guard).toBeGreaterThan(-1);
78 expect(updateRef).toBeGreaterThan(guard);
79 }
80 });
81});
Modifiedsrc/lib/pr-merge-gated.ts+51−9View fileUnifiedSplit
283283 });
284284 }
285285 } else {
286 const ffProc = Bun.spawn(
286 // INCIDENT 2026-08-08 guard (same as pr-merge.ts executeGitMerge and
287 // merge-queue.tsx): a bare update-ref only IS a merge when base is an
288 // ancestor of head — for a branch cut from an older base it REPLACES
289 // the base and silently discards every commit merged since divergence.
290 // This chain (v2 endpoint + MCP merge) was the one path still missing
291 // the guard. mergeWithAutoResolve handles the clean non-ff case without
292 // any AI call, so this is safe even with AI down.
293 const ancestry = Bun.spawnSync(
287294 [
288295 "git",
289 "update-ref",
296 "merge-base",
297 "--is-ancestor",
290298 `refs/heads/${pr.baseBranch}`,
291299 `refs/heads/${pr.headBranch}`,
292300 ],
293 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
301 { cwd: repoDir }
294302 );
295 const ffExit = await ffProc.exited;
296 if (ffExit !== 0) {
297 return {
298 merged: false,
299 reason: "Merge failed — unable to update branch ref",
300 };
303 if (ancestry.exitCode !== 0) {
304 const mergeResult = await mergeWithAutoResolve(
305 owner,
306 repo,
307 pr.baseBranch,
308 pr.headBranch,
309 `Merge pull request #${pr.number}: ${pr.title}`
310 );
311 if (!mergeResult.success) {
312 return {
313 merged: false,
314 reason: mergeResult.error || "Merge failed (non-fast-forward)",
315 };
316 }
317 } else {
318 const ffProc = Bun.spawn(
319 [
320 "git",
321 "update-ref",
322 `refs/heads/${pr.baseBranch}`,
323 `refs/heads/${pr.headBranch}`,
324 ],
325 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
326 );
327 const ffExit = await ffProc.exited;
328 if (ffExit !== 0) {
329 return {
330 merged: false,
331 reason: "Merge failed — unable to update branch ref",
332 };
333 }
301334 }
302335 }
303336
343376 metadata: { source, sha: headSha },
344377 });
345378 void fireWebhooks(repoId, "pr", { action: "merged", number: pr.number });
379 void import("./push-workflow-sync").then((m) =>
380 m.enqueuePushWorkflowsForBranchAdvance({
381 owner,
382 repo,
383 repositoryId: repoId,
384 branch: pr.baseBranch,
385 triggeredBy: actorUserId,
386 })
387 );
346388
347389 // Resolved post-merge SHA (best effort).
348390 let mergedSha: string | null = null;
Modifiedsrc/lib/pr-merge.ts+12−0View fileUnifiedSplit
288288 action: "merged",
289289 number: args.pr.number,
290290 });
291 // A server-side merge advances the base branch with no git push, so the
292 // receive-pack hook never fires `on: push` workflows for it — enqueue
293 // them here. Best-effort like the webhook fire above.
294 void import("./push-workflow-sync").then((m) =>
295 m.enqueuePushWorkflowsForBranchAdvance({
296 owner: args.ownerName,
297 repo: args.repoName,
298 repositoryId: args.pr.repositoryId,
299 branch: args.pr.baseBranch,
300 triggeredBy: args.actorUserId,
301 })
302 );
291303
292304 return {
293305 ok: true,
Modifiedsrc/lib/push-workflow-sync.ts+46−1View fileUnifiedSplit
1818import { sql } from "drizzle-orm";
1919import { db } from "../db";
2020import { workflows } from "../db/schema";
21import { getTree as realGetTree, getBlob as realGetBlob } from "../git/repository";
21import {
22 getTree as realGetTree,
23 getBlob as realGetBlob,
24 getRepoPath,
25} from "../git/repository";
2226import { parseWorkflow, type ParsedWorkflow } from "./workflow-parser";
2327import { enqueueRun as realEnqueueRun } from "./workflow-runner";
2428
167171 }
168172 return result;
169173}
174
175/**
176 * Fire `on: push` workflows for a branch that advanced WITHOUT a git push.
177 *
178 * Server-side merges (merge button, gated v2/MCP chain, merge queue,
179 * autopilot) update the base ref directly, so the receive-pack path that
180 * calls syncAndEnqueuePushWorkflows never sees the advance — yet a merge
181 * landing on main is exactly the event `branches: [main]` workflows exist
182 * for. Discovered 2026-08-08: PR #5468 merged to main and produced no
183 * verify-deploy run at all, on a platform where PR merges are the primary
184 * way main moves.
185 *
186 * Best-effort by contract: resolves the branch tip itself, never throws —
187 * a workflow problem must not break a merge.
188 */
189export async function enqueuePushWorkflowsForBranchAdvance(opts: {
190 owner: string;
191 repo: string;
192 repositoryId: string;
193 branch: string;
194 triggeredBy?: string | null;
195}): Promise<void> {
196 try {
197 const tip = Bun.spawnSync(
198 ["git", "rev-parse", `refs/heads/${opts.branch}`],
199 { cwd: getRepoPath(opts.owner, opts.repo) }
200 );
201 const sha = tip.exitCode === 0 ? tip.stdout.toString().trim() : "";
202 if (!/^[0-9a-f]{40}$/.test(sha)) return;
203 await syncAndEnqueuePushWorkflows({
204 owner: opts.owner,
205 repo: opts.repo,
206 repositoryId: opts.repositoryId,
207 branch: opts.branch,
208 commitSha: sha,
209 triggeredBy: opts.triggeredBy ?? null,
210 });
211 } catch (err) {
212 console.error("[push-workflow-sync] branch-advance enqueue:", err);
213 }
214}
Modifiedsrc/routes/merge-queue.tsx+21−0View fileUnifiedSplit
10371037 );
10381038 }
10391039 await completeEntry(started.id, "merged");
1040 void import("../lib/push-workflow-sync").then((m) =>
1041 m.enqueuePushWorkflowsForBranchAdvance({
1042 owner,
1043 repo,
1044 repositoryId: repoRow.id,
1045 branch: pr.baseBranch,
1046 triggeredBy: user.id,
1047 })
1048 );
10401049 return c.redirect(`/${owner}/${repo}/queue`);
10411050 }
10421051 const proc = Bun.spawn(
10701079
10711080 await completeEntry(started.id, "merged");
10721081
1082 // A merge advances the base branch with no git push, so the receive-pack
1083 // hook never fires `on: push` workflows for it — enqueue them here.
1084 void import("../lib/push-workflow-sync").then((m) =>
1085 m.enqueuePushWorkflowsForBranchAdvance({
1086 owner,
1087 repo,
1088 repositoryId: repoRow.id,
1089 branch: pr.baseBranch,
1090 triggeredBy: user.id,
1091 })
1092 );
1093
10731094 await audit({
10741095 userId: user.id,
10751096 repositoryId: repoRow.id,
Modifiedsrc/routes/pulls.tsx+11−0View fileUnifiedSplit
65796579 number: pr.number,
65806580 mergeStrategy,
65816581 });
6582 // A merge advances the base branch with no git push, so the receive-pack
6583 // hook never fires `on: push` workflows for it — enqueue them here.
6584 void import("../lib/push-workflow-sync").then((m) =>
6585 m.enqueuePushWorkflowsForBranchAdvance({
6586 owner: ownerName,
6587 repo: repoName,
6588 repositoryId: resolved.repo.id,
6589 branch: pr.baseBranch,
6590 triggeredBy: user.id,
6591 })
6592 );
65826593
65836594 // Chat notifier — fan out merge event to Slack/Discord/Teams.
65846595 import("../lib/chat-notifier")
65856596
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts