Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit61897c4

seed

ccanty labs committed on July 15, 2026Parent: b7b5f75
3 files changed+353061897c470ba9ca00c0fcf758b6eaad07384f757d
3 changed files+353−0
Addedsrc/__tests__/push-workflow-sync.test.ts+203−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/push-workflow-sync.ts — the fix for the missing
3 * "push actually triggers CI" wiring (2026-07-15). Before this, nothing
4 * synced `.gluecron/workflows/*.yml` into the `workflows` table on push,
5 * so `on: push` triggers never fired automatically — only manual dispatch,
6 * PR slash commands, and scheduled (cron) runs worked.
7 *
8 * git tree/blob reads and enqueueRun are passed in via the injectable
9 * `deps` param rather than mock.module()'d — those two modules
10 * (../git/repository, ./workflow-runner) are imported by dozens of
11 * unrelated test files, and a top-level mock.module() on either leaks
12 * globally across the whole `bun test` run and breaks them (verified:
13 * an earlier version of this file did exactly that and broke 15 tests
14 * in git.test.ts/api-v2/web-routes). Only ../db is mocked, matching the
15 * established, already-safe pattern in push.test.ts / notify-activity-feed.test.ts.
16 */
17
18import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
19
20const _realDb = await import("../db");
21
22let _insertedRows: Array<{ id: string; disabled: boolean }> = [];
23let _upsertCalls: any[] = [];
24
25mock.module("../db", () => ({
26 db: {
27 insert: () => ({
28 values: (vals: any) => ({
29 onConflictDoUpdate: (args: any) => {
30 _upsertCalls.push({ vals, args });
31 const row = _insertedRows.shift();
32 return {
33 returning: async () => (row ? [row] : []),
34 };
35 },
36 }),
37 }),
38 },
39}));
40
41const { syncAndEnqueuePushWorkflows } = await import("../lib/push-workflow-sync");
42
43afterAll(() => {
44 mock.module("../db", () => _realDb);
45});
46
47let _treeEntries: Array<{ mode: string; type: string; sha: string; size?: number; name: string }> = [];
48let _blobs: Record<string, { content: string; size: number; isBinary: boolean }> = {};
49let _enqueueCalls: any[] = [];
50let _enqueueShouldThrow = false;
51
52const fakeDeps = {
53 getTree: async () => _treeEntries,
54 getBlob: async (_owner: string, _repo: string, _ref: string, path: string) =>
55 _blobs[path] ?? null,
56 enqueueRun: async (opts: any) => {
57 if (_enqueueShouldThrow) throw new Error("enqueue boom");
58 _enqueueCalls.push(opts);
59 return "run-id-1";
60 },
61} as any;
62
63beforeEach(() => {
64 _treeEntries = [];
65 _blobs = {};
66 _insertedRows = [];
67 _upsertCalls = [];
68 _enqueueCalls = [];
69 _enqueueShouldThrow = false;
70});
71
72const CI_YML = `
73name: CI
74on: push
75jobs:
76 build:
77 steps:
78 - run: echo hi
79`;
80
81const DISPATCH_ONLY_YML = `
82name: Manual only
83on: workflow_dispatch
84jobs:
85 build:
86 steps:
87 - run: echo manual
88`;
89
90const BROKEN_YML = `
91jobs:
92 - this is not valid: [
93`;
94
95function baseOpts(overrides: Partial<Parameters<typeof syncAndEnqueuePushWorkflows>[0]> = {}) {
96 return {
97 owner: "acme",
98 repo: "widgets",
99 repositoryId: "repo-1",
100 branch: "main",
101 commitSha: "a".repeat(40),
102 triggeredBy: "user-1",
103 ...overrides,
104 };
105}
106
107describe("syncAndEnqueuePushWorkflows", () => {
108 it("returns zeros when .gluecron/workflows is empty/missing", async () => {
109 _treeEntries = [];
110 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
111 expect(result).toEqual({ synced: 0, enqueued: 0, errors: [] });
112 expect(_upsertCalls).toHaveLength(0);
113 expect(_enqueueCalls).toHaveLength(0);
114 });
115
116 it("syncs and enqueues a workflow with an on:push trigger", async () => {
117 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
118 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
119 _insertedRows = [{ id: "wf-1", disabled: false }];
120
121 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
122
123 expect(result.synced).toBe(1);
124 expect(result.enqueued).toBe(1);
125 expect(result.errors).toEqual([]);
126 expect(_enqueueCalls[0]).toMatchObject({
127 workflowId: "wf-1",
128 repositoryId: "repo-1",
129 event: "push",
130 ref: "main",
131 commitSha: "a".repeat(40),
132 triggeredBy: "user-1",
133 });
134 // Upsert wrote the parsed shape + onEvents.
135 expect(_upsertCalls[0].vals.path).toBe(".gluecron/workflows/ci.yml");
136 expect(JSON.parse(_upsertCalls[0].vals.onEvents)).toEqual(["push"]);
137 });
138
139 it("syncs but does NOT enqueue a workflow with no push trigger", async () => {
140 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "manual.yml" }];
141 _blobs[".gluecron/workflows/manual.yml"] = {
142 content: DISPATCH_ONLY_YML,
143 size: DISPATCH_ONLY_YML.length,
144 isBinary: false,
145 };
146 _insertedRows = [{ id: "wf-2", disabled: false }];
147
148 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
149
150 expect(result.synced).toBe(1);
151 expect(result.enqueued).toBe(0);
152 expect(_enqueueCalls).toHaveLength(0);
153 });
154
155 it("syncs but does NOT enqueue a disabled workflow", async () => {
156 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
157 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
158 _insertedRows = [{ id: "wf-3", disabled: true }];
159
160 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
161
162 expect(result.synced).toBe(1);
163 expect(result.enqueued).toBe(0);
164 expect(_enqueueCalls).toHaveLength(0);
165 });
166
167 it("records a parse error per-file without crashing or blocking other files", async () => {
168 _treeEntries = [
169 { mode: "100644", type: "blob", sha: "x", name: "broken.yml" },
170 { mode: "100644", type: "blob", sha: "y", name: "ci.yml" },
171 ];
172 _blobs[".gluecron/workflows/broken.yml"] = { content: BROKEN_YML, size: 1, isBinary: false };
173 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
174 _insertedRows = [{ id: "wf-4", disabled: false }];
175
176 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
177
178 // The good file still gets synced + enqueued despite the broken one.
179 expect(result.synced).toBe(1);
180 expect(result.enqueued).toBe(1);
181 });
182
183 it("ignores non-yaml entries in the workflows directory", async () => {
184 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "README.md" }];
185 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
186 expect(result.synced).toBe(0);
187 expect(_upsertCalls).toHaveLength(0);
188 });
189
190 it("records an error but keeps synced count when enqueueRun throws", async () => {
191 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
192 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
193 _insertedRows = [{ id: "wf-5", disabled: false }];
194 _enqueueShouldThrow = true;
195
196 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
197
198 expect(result.synced).toBe(1);
199 expect(result.enqueued).toBe(0);
200 expect(result.errors.length).toBe(1);
201 expect(result.errors[0]).toContain("enqueue");
202 });
203});
Modifiedsrc/hooks/post-receive.ts+29−0View fileUnifiedSplit
4040import { fireCloudDeploys } from "../lib/cloud-deploy";
4141import { ensureRepoOnboarding } from "../lib/repo-onboarding";
4242import { audit, logActivity } from "../lib/notify";
43import { syncAndEnqueuePushWorkflows } from "../lib/push-workflow-sync";
4344
4445// ---------------------------------------------------------------------------
4546// Test isolation layer — mount /__tests__/ paths read-only during repair loops
154155 });
155156 }
156157
158 // 0b. Workflow sync + push-triggered CI. Discover .gluecron/workflows/
159 // *.yml in the pushed tree, upsert them into the `workflows` table, and
160 // enqueue a run for every non-disabled workflow whose `on:` includes
161 // `push`. This was previously missing entirely — see
162 // src/lib/push-workflow-sync.ts's header comment for how it was found.
163 if (repoId) {
164 try {
165 const wfResult = await syncAndEnqueuePushWorkflows({
166 owner,
167 repo,
168 repositoryId: repoId,
169 branch: branchName,
170 commitSha: ref.newSha,
171 triggeredBy: pusherUserId || null,
172 });
173 if (wfResult.synced > 0 || wfResult.enqueued > 0) {
174 console.log(
175 `[workflow-sync] ${owner}/${repo}@${branchName}: synced ${wfResult.synced}, enqueued ${wfResult.enqueued}`
176 );
177 }
178 if (wfResult.errors.length > 0) {
179 console.warn(`[workflow-sync] ${owner}/${repo}@${branchName} errors:`, wfResult.errors);
180 }
181 } catch (err) {
182 console.error(`[workflow-sync] error:`, err);
183 }
184 }
185
157186 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
158187 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
159188 try {
Addedsrc/lib/push-workflow-sync.ts+121−0View fileUnifiedSplit
1/**
2 * Sync `.gluecron/workflows/*.yml` from a push into the `workflows` table,
3 * then enqueue a run for every non-disabled workflow whose `on:` triggers
4 * include `push`.
5 *
6 * This is the missing half of "push-triggered CI": nothing previously
7 * wrote rows into `workflows` on push, so the table only ever got
8 * populated by manual/test insertion, and `on: push` triggers never
9 * actually fired automatically — only `workflow_dispatch` (manual/MCP),
10 * PR slash commands, and `on: schedule` (autopilot tick) worked. Discovered
11 * 2026-07-15 while auditing why a sibling platform's Gluecron migration
12 * mirror never produced a running CI pipeline.
13 *
14 * Best-effort per file: a broken workflow YAML is recorded in `errors`
15 * and skipped, never thrown — a bug here must not break the push itself.
16 */
17
18import { db } from "../db";
19import { workflows } from "../db/schema";
20import { getTree as realGetTree, getBlob as realGetBlob } from "../git/repository";
21import { parseWorkflow } from "./workflow-parser";
22import { enqueueRun as realEnqueueRun } from "./workflow-runner";
23
24const WORKFLOWS_DIR = ".gluecron/workflows";
25
26export interface PushWorkflowSyncResult {
27 synced: number;
28 enqueued: number;
29 errors: string[];
30}
31
32export async function syncAndEnqueuePushWorkflows(
33 opts: {
34 owner: string;
35 repo: string;
36 repositoryId: string;
37 branch: string;
38 commitSha: string;
39 triggeredBy?: string | null;
40 },
41 // Injectable for tests — avoids mock.module() on ../git/repository and
42 // ./workflow-runner, both of which are imported by dozens of unrelated
43 // test files and would leak a global mock across the whole test run.
44 deps: {
45 getTree: typeof realGetTree;
46 getBlob: typeof realGetBlob;
47 enqueueRun: typeof realEnqueueRun;
48 } = { getTree: realGetTree, getBlob: realGetBlob, enqueueRun: realEnqueueRun }
49): Promise<PushWorkflowSyncResult> {
50 const result: PushWorkflowSyncResult = { synced: 0, enqueued: 0, errors: [] };
51
52 // getTree never throws — it returns [] for a missing path/exec failure.
53 const entries = await deps.getTree(opts.owner, opts.repo, opts.commitSha, WORKFLOWS_DIR);
54 const ymlFiles = entries.filter(
55 (e) => e.type === "blob" && /\.ya?ml$/i.test(e.name)
56 );
57
58 for (const file of ymlFiles) {
59 const path = `${WORKFLOWS_DIR}/${file.name}`;
60 try {
61 const blob = await deps.getBlob(opts.owner, opts.repo, opts.commitSha, path);
62 if (!blob || blob.isBinary) continue;
63
64 const parseResult = parseWorkflow(blob.content);
65 if (!parseResult.ok) {
66 result.errors.push(`${file.name}: ${parseResult.error}`);
67 continue;
68 }
69 const { workflow } = parseResult;
70
71 const [row] = await db
72 .insert(workflows)
73 .values({
74 repositoryId: opts.repositoryId,
75 name: workflow.name || file.name,
76 path,
77 yaml: blob.content,
78 parsed: JSON.stringify(workflow),
79 onEvents: JSON.stringify(workflow.on),
80 })
81 .onConflictDoUpdate({
82 target: [workflows.repositoryId, workflows.path],
83 set: {
84 name: workflow.name || file.name,
85 yaml: blob.content,
86 parsed: JSON.stringify(workflow),
87 onEvents: JSON.stringify(workflow.on),
88 updatedAt: new Date(),
89 },
90 })
91 .returning({ id: workflows.id, disabled: workflows.disabled });
92
93 if (!row) continue;
94 result.synced += 1;
95
96 if (!row.disabled && workflow.on.includes("push")) {
97 try {
98 await deps.enqueueRun({
99 workflowId: row.id,
100 repositoryId: opts.repositoryId,
101 event: "push",
102 ref: opts.branch,
103 commitSha: opts.commitSha,
104 triggeredBy: opts.triggeredBy ?? null,
105 });
106 result.enqueued += 1;
107 } catch (err) {
108 result.errors.push(
109 `enqueue ${file.name}: ${err instanceof Error ? err.message : String(err)}`
110 );
111 }
112 }
113 } catch (err) {
114 result.errors.push(
115 `${file.name}: ${err instanceof Error ? err.message : String(err)}`
116 );
117 }
118 }
119
120 return result;
121}
0122