Commit059c2cbunknown_key
Merge pull request #26 from ccantynz-alt/claude/crontech-gluecron-deploy-7MIEC
Merge pull request #26 from ccantynz-alt/claude/crontech-gluecron-deploy-7MIEC feat(BLK-016): match Crontech gluecron-push receiver contract
5 files changed+488−220059c2cbd3d509c057f7683dea5d03a1f7cb21b51
5 changed files+488−220
Modified.env.example+9−3View fileUnifiedSplit
@@ -8,9 +8,15 @@ GATETEST_API_KEY=
88# Generate a secret with: openssl rand -hex 32
99GATETEST_CALLBACK_SECRET=
1010GATETEST_HMAC_SECRET=
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/hooks/gluecron/push
12# Bearer token sent on the outbound Crontech deploy webhook. Obtain from
13# Crontech (one per Gluecron install). Unset → Crontech rejects with 401.
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/webhooks/gluecron-push
12# BLK-016 — only fire the Crontech deploy webhook for pushes to this
13# `<owner>/<name>` (default `ccantynz-alt/crontech`). All other repo pushes
14# are ignored.
15CRONTECH_REPO=ccantynz-alt/crontech
16# Shared HMAC secret used to sign the outbound Crontech deploy webhook.
17# Sent as `X-Gluecron-Signature: sha256=<hex>` of the JSON body. Must match
18# the `GLUECRON_WEBHOOK_SECRET` configured on the Crontech deploy-agent
19# (Vultr box). Unset → header omitted and Crontech rejects with 401.
1420GLUECRON_WEBHOOK_SECRET=
1521# Inbound bearer token Crontech MUST present on deploy.succeeded /
1622# deploy.failed callbacks to POST /api/events/deploy (Signal Bus P1 — E3/E4).
ModifiedBUILD_BIBLE.md+13−0View fileUnifiedSplit
@@ -561,3 +561,16 @@ If a block is too large for a single session, split it into a sub-plan at the to
561561- `src/views/landing.tsx` — marketing landing for logged-out `/`. Accepts optional `stats` prop; `src/routes/web.tsx` queries public repo + user counts.
562562- `src/lib/demo-seed.ts` + tests — idempotent `ensureDemoContent()` seeds `demo` user + 3 public sample repos + seeded issues/PR. Boot flag `DEMO_SEED_ON_BOOT=1`. Site-admin reseed button on `/admin` (`POST /admin/demo/reseed`). Public `/demo` convenience redirect to `/demo/hello-python`.
563563- Doc sync: `README.md`, `DEPLOY.md`, `LAUNCH_TODAY.md` aligned with current reality.
564
565**BLK-016 Crontech-Gluecron deploy wire — Gluecron sender rewritten (pending live verification):**
566- `src/hooks/post-receive.ts triggerCrontechDeploy` now matches the Crontech receiver at `apps/api/src/webhooks/gluecron-push.ts`:
567 - Endpoint default `POST https://crontech.ai/api/webhooks/gluecron-push` (was `/api/hooks/gluecron/push`).
568 - Auth model: HMAC-SHA256 of the JSON body in `X-Gluecron-Signature: sha256=<hex>`, signed with `GLUECRON_WEBHOOK_SECRET` (no longer Bearer).
569 - Payload shape is GitHub-style: `{event, repository:{full_name}, ref, after, before, pusher:{name,email}, commits:[{id,message,timestamp}]}` plus ancillary `sent_at`/`source`. Receiver dedupes on `after`.
570 - At-least-once delivery: 6 attempts (initial + 5 backoffs at 1s/4s/16s/64s/256s). Stops on first 2xx; bails immediately on unrecoverable 4xx (except 408/429); retries on 5xx + network errors.
571 - `commits[]` + pusher derived via `commitsBetween(owner, repo, before, after)` from the bare repo (capped at 50, like GitHub). Empty when the git layer can't read the repo.
572- `onPostReceive` no longer hardcodes `refs/heads/main` — gated by `${owner}/${repo} === config.crontechRepo` (env `CRONTECH_REPO`, default `ccantynz-alt/crontech`) and the repo's actual default branch via `getDefaultBranch`. Handles `Main` (capital M) without a code change.
573- Config additions: `config.crontechRepo` (env `CRONTECH_REPO`); `config.crontechDeployUrl` default flipped; both reflected in `.env.example`.
574- Tests: `src/__tests__/crontech-deploy.test.ts` rewritten — 19 tests covering endpoint, HMAC signature, payload shape, branch-case carry-through (`Main`), retry-on-5xx, give-up-after-N, no-retry-on-4xx, retry-on-408/429, retry-on-network-error, default backoff schedule. `triggerCrontechDeploy` now takes a single `args` object and an optional `opts: {fetchImpl, sleep, retryDelaysMs, now}` for injectability.
575- Removed dead `fanoutWebhooks` helper (defined-but-uncalled, per audit).
576- **Live verification (the BLK-016 done-criterion) is out of scope for this session** — it requires SSH to the Vultr box, a real test push, and confirming the deploy-agent log + GitHub Actions silence. Do not flip `BLK-016 → ✅ SHIPPED` in the Crontech BUILD_BIBLE without owner authorisation per Crontech CLAUDE.md §0.7.
Modifiedsrc/__tests__/crontech-deploy.test.ts+249−93View fileUnifiedSplit
@@ -1,177 +1,333 @@
11/**
2 * Crontech deploy-webhook sender — Finding 1.
2 * BLK-016 — Crontech deploy webhook sender.
33 *
4 * Asserts that the outbound `triggerCrontechDeploy` call sent from
5 * `src/hooks/post-receive.ts` matches the wire contract documented at the
6 * top of that helper:
4 * Asserts that `triggerCrontechDeploy` (in `src/hooks/post-receive.ts`)
5 * matches the wire contract documented at the top of that helper, which
6 * is the inbound contract for Crontech's
7 * `apps/api/src/webhooks/gluecron-push.ts` receiver:
78 *
8 * POST https://crontech.ai/api/hooks/gluecron/push
9 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
9 * POST https://crontech.ai/api/webhooks/gluecron-push
1010 * Content-Type: application/json
11 * body = { repository, sha, branch, ref, source, timestamp }
11 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, secret))>
12 *
13 * body = {
14 * event: "push",
15 * repository: { full_name },
16 * ref, after, before,
17 * pusher: { name, email },
18 * commits: [...]
19 * }
20 *
21 * Plus at-least-once delivery: 5 attempts on 5xx with exponential backoff,
22 * stop on first 2xx or unrecoverable 4xx.
1223 *
1324 * The helper swallows DB errors, so these tests work without a real DB.
1425 */
1526
1627import { afterEach, beforeEach, describe, expect, it } from "bun:test";
28import { createHmac } from "crypto";
1729import { __test } from "../hooks/post-receive";
1830
19const { triggerCrontechDeploy } = __test;
31const { triggerCrontechDeploy, signBody } = __test;
2032
2133interface CapturedCall {
2234 url: string;
2335 init: RequestInit;
2436}
2537
26const origFetch = globalThis.fetch;
2738const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
2839const origUrl = process.env.CRONTECH_DEPLOY_URL;
40const origRepo = process.env.CRONTECH_REPO;
41
42const NULL_REPO_ID = "00000000-0000-0000-0000-000000000000";
43const ZERO_SHA = "0000000000000000000000000000000000000000";
2944
30function installFetchCapture(
31 respond: () => Response = () =>
45function makeArgs(overrides: Partial<{
46 owner: string;
47 repo: string;
48 before: string;
49 after: string;
50 ref: string;
51 branch: string;
52 repositoryId: string;
53}> = {}) {
54 return {
55 owner: "ccantynz-alt",
56 repo: "crontech",
57 before: ZERO_SHA,
58 after: "a".repeat(40),
59 ref: "refs/heads/Main",
60 branch: "Main",
61 repositoryId: NULL_REPO_ID,
62 ...overrides,
63 };
64}
65
66function captureFetch(
67 responder: (callIdx: number) => Response | Promise<Response> = () =>
3268 new Response(
33 JSON.stringify({ ok: true, deploymentId: "d1", status: "queued" }),
69 JSON.stringify({ ok: true, deploymentId: "d1" }),
3470 { status: 200, headers: { "Content-Type": "application/json" } }
3571 )
36): CapturedCall[] {
72): { calls: CapturedCall[]; fn: typeof fetch } {
3773 const calls: CapturedCall[] = [];
38 // @ts-expect-error — override global fetch for test
39 globalThis.fetch = async (
74 const fn = (async (
4075 input: RequestInfo | URL,
4176 init: RequestInit = {}
4277 ): Promise<Response> => {
78 const i = calls.length;
4379 calls.push({ url: String(input), init });
44 return respond();
45 };
46 return calls;
80 return responder(i);
81 }) as unknown as typeof fetch;
82 return { calls, fn };
4783}
4884
49function restoreFetch(): void {
50 globalThis.fetch = origFetch;
51}
85const noSleep = async (_ms: number) => {};
86
87describe("hooks/post-receive — signBody", () => {
88 it("returns null when no secret", () => {
89 expect(signBody("any body", "")).toBeNull();
90 });
91
92 it("produces sha256=<hex hmac>", () => {
93 const body = '{"event":"push"}';
94 const secret = "topsecret";
95 const expected =
96 "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
97 expect(signBody(body, secret)).toBe(expected);
98 });
99
100 it("is deterministic for the same input", () => {
101 const a = signBody("body", "k");
102 const b = signBody("body", "k");
103 expect(a).toBe(b);
104 });
52105
53describe("hooks/post-receive — triggerCrontechDeploy (Finding 1 sender)", () => {
106 it("changes when the body changes", () => {
107 const a = signBody("body1", "k");
108 const b = signBody("body2", "k");
109 expect(a).not.toBe(b);
110 });
111});
112
113describe("hooks/post-receive — triggerCrontechDeploy (BLK-016 sender)", () => {
54114 beforeEach(() => {
55 // Unset overrides so each test owns its env state.
56115 delete process.env.GLUECRON_WEBHOOK_SECRET;
57116 delete process.env.CRONTECH_DEPLOY_URL;
117 delete process.env.CRONTECH_REPO;
58118 });
59119
60120 afterEach(() => {
61 restoreFetch();
62121 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
63122 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
64123 if (origUrl === undefined) delete process.env.CRONTECH_DEPLOY_URL;
65124 else process.env.CRONTECH_DEPLOY_URL = origUrl;
125 if (origRepo === undefined) delete process.env.CRONTECH_REPO;
126 else process.env.CRONTECH_REPO = origRepo;
66127 });
67128
68129 it("is exported from __test", () => {
69130 expect(typeof triggerCrontechDeploy).toBe("function");
70131 });
71132
72 it("POSTs to the new Crontech hooks endpoint (not the old tRPC URL)", async () => {
73 const calls = installFetchCapture();
133 it("POSTs to /api/webhooks/gluecron-push (matches Crontech receiver path)", async () => {
134 const { calls, fn } = captureFetch();
74135
75 await triggerCrontechDeploy(
76 "alice",
77 "widgets",
78 "a".repeat(40),
79 "00000000-0000-0000-0000-000000000000"
80 );
136 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
81137
82138 expect(calls.length).toBe(1);
83139 expect(calls[0]!.url).toBe(
84 "https://crontech.ai/api/hooks/gluecron/push"
140 "https://crontech.ai/api/webhooks/gluecron-push"
85141 );
86 expect(calls[0]!.url).not.toContain("/api/trpc/tenant.deploy");
142 expect(calls[0]!.url).not.toContain("/api/hooks/gluecron/push");
87143 expect(calls[0]!.init.method).toBe("POST");
88144 });
89145
90 it("sends Authorization: Bearer <secret> when GLUECRON_WEBHOOK_SECRET is set", async () => {
91 process.env.GLUECRON_WEBHOOK_SECRET = "s3cret-token";
92 const calls = installFetchCapture();
146 it("respects CRONTECH_DEPLOY_URL override", async () => {
147 process.env.CRONTECH_DEPLOY_URL =
148 "https://staging.crontech.ai/api/webhooks/gluecron-push";
149 const { calls, fn } = captureFetch();
150
151 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
152
153 expect(calls[0]!.url).toBe(
154 "https://staging.crontech.ai/api/webhooks/gluecron-push"
155 );
156 });
157
158 it("sends GitHub-shaped payload (event, repository.full_name, ref, after, before, pusher, commits)", async () => {
159 const { calls, fn } = captureFetch();
160 const after = "d".repeat(40);
161 const before = "c".repeat(40);
93162
94163 await triggerCrontechDeploy(
95 "alice",
96 "widgets",
97 "b".repeat(40),
98 "00000000-0000-0000-0000-000000000000"
164 makeArgs({
165 owner: "acme",
166 repo: "api",
167 after,
168 before,
169 ref: "refs/heads/Main",
170 branch: "Main",
171 }),
172 { fetchImpl: fn, sleep: noSleep }
99173 );
100174
101 expect(calls.length).toBe(1);
175 const body = JSON.parse(String(calls[0]!.init.body));
176 expect(body.event).toBe("push");
177 expect(body.repository).toEqual({ full_name: "acme/api" });
178 expect(body.ref).toBe("refs/heads/Main");
179 expect(body.after).toBe(after);
180 expect(body.before).toBe(before);
181 expect(body.pusher).toBeDefined();
182 expect(typeof body.pusher.name).toBe("string");
183 expect(typeof body.pusher.email).toBe("string");
184 expect(Array.isArray(body.commits)).toBe(true);
185 expect(typeof body.sent_at).toBe("string");
186 expect(new Date(body.sent_at).toString()).not.toBe("Invalid Date");
187 expect(body.source).toBe("gluecron");
188 });
189
190 it("signs the body with HMAC-SHA256 in X-Gluecron-Signature when secret is set", async () => {
191 process.env.GLUECRON_WEBHOOK_SECRET = "shared-vultr-secret";
192 const { calls, fn } = captureFetch();
193
194 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
195
102196 const headers = calls[0]!.init.headers as Record<string, string>;
103 expect(headers["Authorization"]).toBe("Bearer s3cret-token");
197 const sentBody = String(calls[0]!.init.body);
198 const expected =
199 "sha256=" +
200 createHmac("sha256", "shared-vultr-secret")
201 .update(sentBody)
202 .digest("hex");
203 expect(headers["X-Gluecron-Signature"]).toBe(expected);
104204 expect(headers["Content-Type"]).toBe("application/json");
105205 });
106206
107 it("omits the Authorization header when no secret is configured", async () => {
108 // GLUECRON_WEBHOOK_SECRET deliberately unset by beforeEach.
109 const calls = installFetchCapture();
207 it("omits X-Gluecron-Signature when no secret is configured", async () => {
208 const { calls, fn } = captureFetch();
110209
111 await triggerCrontechDeploy(
112 "alice",
113 "widgets",
114 "c".repeat(40),
115 "00000000-0000-0000-0000-000000000000"
116 );
210 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
117211
118 expect(calls.length).toBe(1);
119212 const headers = calls[0]!.init.headers as Record<string, string>;
120 expect(headers["Authorization"]).toBeUndefined();
121 expect(headers["Content-Type"]).toBe("application/json");
213 expect(headers["X-Gluecron-Signature"]).toBeUndefined();
122214 });
123215
124 it("sends the wire-contract body shape (repository, sha, branch, ref, source, timestamp)", async () => {
125 const calls = installFetchCapture();
126 const sha = "d".repeat(40);
216 it("attaches X-Gluecron-Event=push and a non-empty X-Gluecron-Delivery id", async () => {
217 const { calls, fn } = captureFetch();
218
219 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
220
221 const headers = calls[0]!.init.headers as Record<string, string>;
222 expect(headers["X-Gluecron-Event"]).toBe("push");
223 expect(headers["X-Gluecron-Delivery"]).toBeDefined();
224 expect(headers["X-Gluecron-Delivery"]!.length).toBeGreaterThan(0);
225 });
226
227 it("ref carries the actual case of the branch (Main, not main)", async () => {
228 const { calls, fn } = captureFetch();
127229
128230 await triggerCrontechDeploy(
129 "acme",
130 "api",
131 sha,
132 "00000000-0000-0000-0000-000000000000"
231 makeArgs({ ref: "refs/heads/Main", branch: "Main" }),
232 { fetchImpl: fn, sleep: noSleep }
133233 );
134234
135 expect(calls.length).toBe(1);
136235 const body = JSON.parse(String(calls[0]!.init.body));
137 expect(body.repository).toBe("acme/api");
138 expect(body.sha).toBe(sha);
139 expect(body.branch).toBe("main");
140 expect(body.ref).toBe("refs/heads/main");
141 expect(body.source).toBe("gluecron");
142 expect(typeof body.timestamp).toBe("string");
143 // ISO-8601 sanity check
144 expect(new Date(body.timestamp).toString()).not.toBe("Invalid Date");
236 expect(body.ref).toBe("refs/heads/Main");
237 expect(body.ref).not.toBe("refs/heads/main");
145238 });
146239
147 it("respects CRONTECH_DEPLOY_URL override", async () => {
148 process.env.CRONTECH_DEPLOY_URL =
149 "https://staging.crontech.ai/api/hooks/gluecron/push";
150 const calls = installFetchCapture();
240 it("retries on 5xx with provided backoff schedule, stops on first 2xx", async () => {
241 const responses = [
242 new Response("", { status: 502 }),
243 new Response("", { status: 503 }),
244 new Response("", { status: 200 }),
245 ];
246 const { calls, fn } = captureFetch((i) => responses[i]!);
247 const sleeps: number[] = [];
151248
152 await triggerCrontechDeploy(
153 "alice",
154 "widgets",
155 "e".repeat(40),
156 "00000000-0000-0000-0000-000000000000"
157 );
249 await triggerCrontechDeploy(makeArgs(), {
250 fetchImpl: fn,
251 sleep: async (ms) => { sleeps.push(ms); },
252 retryDelaysMs: [10, 20, 30, 40, 50],
253 });
254
255 expect(calls.length).toBe(3);
256 // Two waits — between attempt 1→2 and 2→3. None after the successful 3rd.
257 expect(sleeps).toEqual([10, 20]);
258 });
259
260 it("gives up after the configured number of attempts on persistent 5xx", async () => {
261 const { calls, fn } = captureFetch(() => new Response("", { status: 500 }));
262 const sleeps: number[] = [];
263
264 await triggerCrontechDeploy(makeArgs(), {
265 fetchImpl: fn,
266 sleep: async (ms) => { sleeps.push(ms); },
267 retryDelaysMs: [1, 2, 3, 4, 5],
268 });
269
270 // 5 delays + 1 initial = 6 total attempts (consistent with at-least-once).
271 expect(calls.length).toBe(6);
272 expect(sleeps).toEqual([1, 2, 3, 4, 5]);
273 });
274
275 it("does not retry on unrecoverable 4xx (e.g. 401 invalid signature)", async () => {
276 const { calls, fn } = captureFetch(() => new Response("", { status: 401 }));
277 const sleeps: number[] = [];
278
279 await triggerCrontechDeploy(makeArgs(), {
280 fetchImpl: fn,
281 sleep: async (ms) => { sleeps.push(ms); },
282 retryDelaysMs: [1, 2, 3, 4, 5],
283 });
158284
159285 expect(calls.length).toBe(1);
160 expect(calls[0]!.url).toBe(
161 "https://staging.crontech.ai/api/hooks/gluecron/push"
162 );
286 expect(sleeps).toEqual([]);
163287 });
164288
165 it("does not throw when Crontech responds 401 (unset secret path)", async () => {
166 const calls = installFetchCapture(() => new Response("", { status: 401 }));
289 it("does retry 408 (request timeout) and 429 (rate limit)", async () => {
290 const responses = [
291 new Response("", { status: 429 }),
292 new Response("", { status: 408 }),
293 new Response("", { status: 200 }),
294 ];
295 const { calls, fn } = captureFetch((i) => responses[i]!);
296
297 await triggerCrontechDeploy(makeArgs(), {
298 fetchImpl: fn,
299 sleep: noSleep,
300 retryDelaysMs: [1, 2, 3, 4, 5],
301 });
302
303 expect(calls.length).toBe(3);
304 });
305
306 it("retries on network errors (fetch throws)", async () => {
307 let callCount = 0;
308 const fn = (async () => {
309 callCount++;
310 if (callCount < 3) throw new Error("ECONNREFUSED");
311 return new Response("", { status: 200 });
312 }) as unknown as typeof fetch;
313
314 await triggerCrontechDeploy(makeArgs(), {
315 fetchImpl: fn,
316 sleep: noSleep,
317 retryDelaysMs: [1, 2, 3, 4, 5],
318 });
319
320 expect(callCount).toBe(3);
321 });
322
323 it("does not throw when receiver responds 401 (unconfigured-secret path)", async () => {
324 const { fn } = captureFetch(() => new Response("", { status: 401 }));
167325 await expect(
168 triggerCrontechDeploy(
169 "alice",
170 "widgets",
171 "f".repeat(40),
172 "00000000-0000-0000-0000-000000000000"
173 )
326 triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep })
174327 ).resolves.toBeUndefined();
175 expect(calls.length).toBe(1);
328 });
329
330 it("uses a default exponential-backoff schedule of 1s/4s/16s/64s/256s", () => {
331 expect(__test.RETRY_DELAYS_MS).toEqual([1_000, 4_000, 16_000, 64_000, 256_000]);
176332 });
177333});
Modifiedsrc/hooks/post-receive.ts+203−120View fileUnifiedSplit
@@ -10,6 +10,7 @@
1010 * 6. Webhooks — fire registered webhook URLs
1111 */
1212
13import { createHmac } from "crypto";
1314import { and, eq } from "drizzle-orm";
1415import { config } from "../lib/config";
1516import { autoRepair } from "../lib/autorepair";
@@ -17,6 +18,7 @@ import { analyzePush, computeHealthScore } from "../lib/intelligence";
1718import { db } from "../db";
1819import { deployments, repositories, users } from "../db/schema";
1920import { onDeployFailure } from "../lib/ai-incident";
21import { commitsBetween, getDefaultBranch } from "../git/repository";
2022
2123interface PushRef {
2224 oldSha: string;
@@ -78,73 +80,156 @@ export async function onPostReceive(
7880 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
7981 // triggerGateTest helper is slated for the intelligence rework.
8082
81 // 5. Crontech deploy on push to main
82 const mainPush = refs.find(
83 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
84 );
85 if (mainPush) {
86 let repositoryId = "";
87 try {
88 const [row] = await db
89 .select({ id: repositories.id })
90 .from(repositories)
91 .innerJoin(users, eq(repositories.ownerId, users.id))
92 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
93 .limit(1);
94 repositoryId = row?.id || "";
95 } catch {
96 /* ignore */
97 }
98 if (repositoryId) {
99 triggerCrontechDeploy(owner, repo, mainPush.newSha, repositoryId).catch(
100 (err: unknown) => console.error(`[crontech] error:`, err),
101 );
83 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
84 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
85 // default branch. The branch case (`Main` vs `main`) is determined by
86 // the bare repo's HEAD, not hardcoded.
87 if (`${owner}/${repo}` === config.crontechRepo) {
88 let defaultBranch = (await getDefaultBranch(owner, repo).catch(() => null)) || "main";
89 const targetRef = `refs/heads/${defaultBranch}`;
90 const deployPush = refs.find(
91 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
92 );
93 if (deployPush) {
94 let repositoryId = "";
95 try {
96 const [row] = await db
97 .select({ id: repositories.id })
98 .from(repositories)
99 .innerJoin(users, eq(repositories.ownerId, users.id))
100 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
101 .limit(1);
102 repositoryId = row?.id || "";
103 } catch {
104 /* ignore */
105 }
106 if (repositoryId) {
107 triggerCrontechDeploy({
108 owner,
109 repo,
110 before: deployPush.oldSha,
111 after: deployPush.newSha,
112 ref: targetRef,
113 branch: defaultBranch,
114 repositoryId,
115 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
116 }
102117 }
103118 }
104119}
105120
106121/**
107 * Trigger Crontech auto-deploy via the outbound webhook.
122 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
108123 *
109 * Wire contract (Gluecron's copy — do not import from Crontech):
124 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
110125 *
111 * POST https://crontech.ai/api/hooks/gluecron/push
112 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
126 * POST https://crontech.ai/api/webhooks/gluecron-push
113127 * Content-Type: application/json
128 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
114129 *
115130 * {
116 * "repository": "owner/name",
117 * "sha": "<40-hex>",
118 * "branch": "main",
119 * "ref": "refs/heads/main",
120 * "source": "gluecron",
121 * "timestamp": "<ISO-8601>"
131 * "event": "push",
132 * "repository": { "full_name": "ccantynz-alt/crontech" },
133 * "ref": "refs/heads/Main",
134 * "after": "<40-hex commit SHA>",
135 * "before": "<40-hex previous SHA>",
136 * "pusher": { "name": "<author>", "email": "<email>" },
137 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
122138 * }
123139 *
124 * → 200 { ok: true, deploymentId, status: "queued" | "skipped" }
125 * → 401 invalid bearer token
126 * → 400 malformed payload
127 * → 404 repository not configured for auto-deploy on Crontech
140 * The `after` SHA is the dedupe key on the receiver side (idempotent).
128141 *
129 * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization
130 * header — Crontech will then respond 401, which we treat as a failed deploy
131 * row exactly like any other non-ok HTTP response.
142 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
143 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
144 * is unset the signature header is omitted and Crontech is expected to reject —
145 * we still record the deploy row as failed.
132146 */
147const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
148
149interface TriggerArgs {
150 owner: string;
151 repo: string;
152 before: string;
153 after: string;
154 ref: string;
155 branch: string;
156 repositoryId: string;
157}
158
159interface TriggerOptions {
160 fetchImpl?: typeof fetch;
161 sleep?: (ms: number) => Promise<void>;
162 retryDelaysMs?: number[];
163 now?: () => Date;
164}
165
166function signBody(body: string, secret: string): string | null {
167 if (!secret) return null;
168 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
169}
170
171async function buildPayload(args: TriggerArgs, now: Date): Promise<{
172 payload: Record<string, unknown>;
173 pusherName: string;
174 pusherEmail: string;
175}> {
176 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
177 // `before` may be all-zeros for a first push to the branch — commitsBetween
178 // handles that by treating null `from` as "everything reachable from `to`".
179 const fromSha = /^0+$/.test(args.before) ? null : args.before;
180 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
181 let pusherName = "gluecron";
182 let pusherEmail = "noreply@gluecron.local";
183 try {
184 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
185 commits = list.slice(0, 50).map((c) => ({
186 id: c.sha,
187 message: c.message,
188 timestamp: c.date,
189 }));
190 if (list[0]) {
191 pusherName = list[0].author || pusherName;
192 pusherEmail = list[0].authorEmail || pusherEmail;
193 }
194 } catch {
195 /* ignore — payload still valid with empty commits[] */
196 }
197 return {
198 payload: {
199 event: "push",
200 repository: { full_name: `${args.owner}/${args.repo}` },
201 ref: args.ref,
202 after: args.after,
203 before: args.before,
204 pusher: { name: pusherName, email: pusherEmail },
205 commits,
206 // Ancillary fields — receiver may ignore but they're useful for logs:
207 sent_at: now.toISOString(),
208 source: "gluecron",
209 },
210 pusherName,
211 pusherEmail,
212 };
213}
214
133215async function triggerCrontechDeploy(
134 owner: string,
135 repo: string,
136 sha: string,
137 repositoryId: string
216 args: TriggerArgs,
217 opts: TriggerOptions = {}
138218): Promise<void> {
219 const fetchImpl = opts.fetchImpl ?? fetch;
220 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
221 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
222 const now = opts.now ?? (() => new Date());
223
139224 let deployId = "";
140225 try {
141226 const [row] = await db
142227 .insert(deployments)
143228 .values({
144 repositoryId,
229 repositoryId: args.repositoryId,
145230 environment: "production",
146 commitSha: sha,
147 ref: "refs/heads/main",
231 commitSha: args.after,
232 ref: args.ref,
148233 status: "pending",
149234 target: "crontech",
150235 })
@@ -154,93 +239,91 @@ async function triggerCrontechDeploy(
154239 /* ignore */
155240 }
156241
157 try {
158 const headers: Record<string, string> = {
159 "Content-Type": "application/json",
160 };
161 if (config.gluecronWebhookSecret) {
162 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
163 }
164 const response = await fetch(config.crontechDeployUrl, {
165 method: "POST",
166 headers,
167 body: JSON.stringify({
168 repository: `${owner}/${repo}`,
169 sha,
170 branch: "main",
171 ref: "refs/heads/main",
172 source: "gluecron",
173 timestamp: new Date().toISOString(),
174 }),
175 });
176 console.log(
177 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
178 );
179 if (deployId) {
180 await db
181 .update(deployments)
182 .set({
183 status: response.ok ? "success" : "failed",
184 completedAt: new Date(),
185 })
186 .where(eq(deployments.id, deployId));
242 const { payload } = await buildPayload(args, now());
243 const body = JSON.stringify(payload);
244 const signature = signBody(body, config.gluecronWebhookSecret);
245
246 const headers: Record<string, string> = {
247 "Content-Type": "application/json",
248 "User-Agent": "gluecron-webhook/1",
249 "X-Gluecron-Event": "push",
250 "X-Gluecron-Delivery": cryptoRandomId(),
251 };
252 if (signature) headers["X-Gluecron-Signature"] = signature;
253
254 let lastStatus = 0;
255 let lastError = "";
256 let success = false;
257
258 // Up to delays.length + 1 attempts (initial try + each delay).
259 const totalAttempts = delays.length + 1;
260 for (let attempt = 0; attempt < totalAttempts; attempt++) {
261 try {
262 const response = await fetchImpl(config.crontechDeployUrl, {
263 method: "POST",
264 headers,
265 body,
266 });
267 lastStatus = response.status;
268 console.log(
269 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
270 );
271 if (response.ok) {
272 success = true;
273 break;
274 }
275 // 4xx (except 408/429) is unrecoverable — stop retrying.
276 if (response.status >= 400 && response.status < 500 &&
277 response.status !== 408 && response.status !== 429) {
278 break;
279 }
280 } catch (err) {
281 lastError = err instanceof Error ? err.message : String(err);
282 console.error(
283 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
284 );
187285 }
188 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
189 // incident responder AFTER the deployment row is flipped to "failed".
190 if (!response.ok && deployId) {
191 void onDeployFailure({
192 repositoryId,
193 deploymentId: deployId,
194 ref: "refs/heads/main",
195 commitSha: sha,
196 target: "crontech",
197 errorMessage: `HTTP ${response.status}`,
198 }).catch((e) => console.error("[ai-incident]", e));
286 const nextDelay = delays[attempt];
287 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
288 await sleep(nextDelay);
199289 }
200 } catch (err) {
201 console.error(`[crontech] failed to trigger deploy:`, err);
202 if (deployId) {
290 }
291
292 if (deployId) {
293 try {
203294 await db
204295 .update(deployments)
205296 .set({
206 status: "failed",
207 blockedReason: (err as Error).message,
297 status: success ? "success" : "failed",
298 blockedReason: success
299 ? null
300 : (lastError ? lastError : `HTTP ${lastStatus}`),
208301 completedAt: new Date(),
209302 })
210303 .where(eq(deployments.id, deployId));
211 // D4: fire-and-forget incident analysis AFTER marking the row failed.
212 void onDeployFailure({
213 repositoryId,
214 deploymentId: deployId,
215 ref: "refs/heads/main",
216 commitSha: sha,
217 target: "crontech",
218 errorMessage: (err as Error).message,
219 }).catch((e) => console.error("[ai-incident]", e));
304 } catch {
305 /* ignore */
220306 }
221307 }
222}
223308
224async function fanoutWebhooks(
225 repositoryId: string,
226 owner: string,
227 repo: string,
228 refs: PushRef[]
229): Promise<void> {
230 try {
231 const { fireWebhooks } = await import("../routes/webhooks");
232 await fireWebhooks(repositoryId, "push", {
233 repository: `${owner}/${repo}`,
234 refs: refs.map((r) => ({
235 ref: r.refName,
236 before: r.oldSha,
237 after: r.newSha,
238 })),
239 });
240 } catch {
241 // best-effort
309 if (!success && deployId) {
310 void onDeployFailure({
311 repositoryId: args.repositoryId,
312 deploymentId: deployId,
313 ref: args.ref,
314 commitSha: args.after,
315 target: "crontech",
316 errorMessage: lastError || `HTTP ${lastStatus}`,
317 }).catch((e) => console.error("[ai-incident]", e));
242318 }
243319}
244320
321function cryptoRandomId(): string {
322 // Short opaque delivery ID for log correlation. Not security-sensitive.
323 const bytes = new Uint8Array(8);
324 crypto.getRandomValues(bytes);
325 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
326}
327
245328/** Test-only access to internal helpers. */
246export const __test = { triggerCrontechDeploy };
329export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };
Modifiedsrc/lib/config.ts+14−4View fileUnifiedSplit
@@ -19,13 +19,23 @@ export const config = {
1919 get crontechDeployUrl() {
2020 return (
2121 process.env.CRONTECH_DEPLOY_URL ||
22 "https://crontech.ai/api/hooks/gluecron/push"
22 "https://crontech.ai/api/webhooks/gluecron-push"
2323 );
2424 },
2525 /**
26 * Bearer token sent on outbound deploy webhook to Crontech's
27 * `POST /api/hooks/gluecron/push` endpoint. Default empty → header is
28 * omitted and Crontech will reject with 401 (treated as a failed deploy).
26 * BLK-016 — only fire the Crontech deploy webhook for pushes to this
27 * `<owner>/<name>`. Every other repo's push is ignored. Override per
28 * environment via `CRONTECH_REPO`.
29 */
30 get crontechRepo() {
31 return process.env.CRONTECH_REPO || "ccantynz-alt/crontech";
32 },
33 /**
34 * Shared HMAC secret for the outbound deploy webhook to Crontech's
35 * `POST /api/webhooks/gluecron-push` endpoint. Used to compute the
36 * `X-Gluecron-Signature: sha256=<hex>` header on every fire. Default
37 * empty → header is omitted and Crontech will reject with 401 (treated
38 * as a failed deploy).
2939 */
3040 get gluecronWebhookSecret() {
3141 return process.env.GLUECRON_WEBHOOK_SECRET || "";
3242