Commit43cf9b0unknown_key
fix(hooks): point deploy webhook at Crontech's new endpoint
4 files changed+229−343cf9b02729ca2f51b92f25ad3c8d0cbacdfa7f0
4 changed files+229−3
Modified.env.example+4−1View fileUnifiedSplit
@@ -8,7 +8,10 @@ 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/trpc/tenant.deploy
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.
14GLUECRON_WEBHOOK_SECRET=
1215ANTHROPIC_API_KEY=
1316# Email (Block A8). Provider=log just writes to stderr (safe default).
1417# Switch to "resend" in prod and set RESEND_API_KEY.
Addedsrc/__tests__/crontech-deploy.test.ts+177−0View fileUnifiedSplit
@@ -0,0 +1,177 @@
1/**
2 * Crontech deploy-webhook sender — Finding 1.
3 *
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:
7 *
8 * POST https://crontech.ai/api/hooks/gluecron/push
9 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
10 * Content-Type: application/json
11 * body = { repository, sha, branch, ref, source, timestamp }
12 *
13 * The helper swallows DB errors, so these tests work without a real DB.
14 */
15
16import { afterEach, beforeEach, describe, expect, it } from "bun:test";
17import { __test } from "../hooks/post-receive";
18
19const { triggerCrontechDeploy } = __test;
20
21interface CapturedCall {
22 url: string;
23 init: RequestInit;
24}
25
26const origFetch = globalThis.fetch;
27const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
28const origUrl = process.env.CRONTECH_DEPLOY_URL;
29
30function installFetchCapture(
31 respond: () => Response = () =>
32 new Response(
33 JSON.stringify({ ok: true, deploymentId: "d1", status: "queued" }),
34 { status: 200, headers: { "Content-Type": "application/json" } }
35 )
36): CapturedCall[] {
37 const calls: CapturedCall[] = [];
38 // @ts-expect-error — override global fetch for test
39 globalThis.fetch = async (
40 input: RequestInfo | URL,
41 init: RequestInit = {}
42 ): Promise<Response> => {
43 calls.push({ url: String(input), init });
44 return respond();
45 };
46 return calls;
47}
48
49function restoreFetch(): void {
50 globalThis.fetch = origFetch;
51}
52
53describe("hooks/post-receive — triggerCrontechDeploy (Finding 1 sender)", () => {
54 beforeEach(() => {
55 // Unset overrides so each test owns its env state.
56 delete process.env.GLUECRON_WEBHOOK_SECRET;
57 delete process.env.CRONTECH_DEPLOY_URL;
58 });
59
60 afterEach(() => {
61 restoreFetch();
62 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
63 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
64 if (origUrl === undefined) delete process.env.CRONTECH_DEPLOY_URL;
65 else process.env.CRONTECH_DEPLOY_URL = origUrl;
66 });
67
68 it("is exported from __test", () => {
69 expect(typeof triggerCrontechDeploy).toBe("function");
70 });
71
72 it("POSTs to the new Crontech hooks endpoint (not the old tRPC URL)", async () => {
73 const calls = installFetchCapture();
74
75 await triggerCrontechDeploy(
76 "alice",
77 "widgets",
78 "a".repeat(40),
79 "00000000-0000-0000-0000-000000000000"
80 );
81
82 expect(calls.length).toBe(1);
83 expect(calls[0]!.url).toBe(
84 "https://crontech.ai/api/hooks/gluecron/push"
85 );
86 expect(calls[0]!.url).not.toContain("/api/trpc/tenant.deploy");
87 expect(calls[0]!.init.method).toBe("POST");
88 });
89
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();
93
94 await triggerCrontechDeploy(
95 "alice",
96 "widgets",
97 "b".repeat(40),
98 "00000000-0000-0000-0000-000000000000"
99 );
100
101 expect(calls.length).toBe(1);
102 const headers = calls[0]!.init.headers as Record<string, string>;
103 expect(headers["Authorization"]).toBe("Bearer s3cret-token");
104 expect(headers["Content-Type"]).toBe("application/json");
105 });
106
107 it("omits the Authorization header when no secret is configured", async () => {
108 // GLUECRON_WEBHOOK_SECRET deliberately unset by beforeEach.
109 const calls = installFetchCapture();
110
111 await triggerCrontechDeploy(
112 "alice",
113 "widgets",
114 "c".repeat(40),
115 "00000000-0000-0000-0000-000000000000"
116 );
117
118 expect(calls.length).toBe(1);
119 const headers = calls[0]!.init.headers as Record<string, string>;
120 expect(headers["Authorization"]).toBeUndefined();
121 expect(headers["Content-Type"]).toBe("application/json");
122 });
123
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);
127
128 await triggerCrontechDeploy(
129 "acme",
130 "api",
131 sha,
132 "00000000-0000-0000-0000-000000000000"
133 );
134
135 expect(calls.length).toBe(1);
136 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");
145 });
146
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();
151
152 await triggerCrontechDeploy(
153 "alice",
154 "widgets",
155 "e".repeat(40),
156 "00000000-0000-0000-0000-000000000000"
157 );
158
159 expect(calls.length).toBe(1);
160 expect(calls[0]!.url).toBe(
161 "https://staging.crontech.ai/api/hooks/gluecron/push"
162 );
163 });
164
165 it("does not throw when Crontech responds 401 (unset secret path)", async () => {
166 const calls = installFetchCapture(() => new Response("", { status: 401 }));
167 await expect(
168 triggerCrontechDeploy(
169 "alice",
170 "widgets",
171 "f".repeat(40),
172 "00000000-0000-0000-0000-000000000000"
173 )
174 ).resolves.toBeUndefined();
175 expect(calls.length).toBe(1);
176 });
177});
Modifiedsrc/hooks/post-receive.ts+39−1View fileUnifiedSplit
@@ -373,6 +373,33 @@ export async function onPostReceive(
373373 await Promise.allSettled(promises);
374374}
375375
376/**
377 * Trigger Crontech auto-deploy via the outbound webhook.
378 *
379 * Wire contract (Gluecron's copy — do not import from Crontech):
380 *
381 * POST https://crontech.ai/api/hooks/gluecron/push
382 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
383 * Content-Type: application/json
384 *
385 * {
386 * "repository": "owner/name",
387 * "sha": "<40-hex>",
388 * "branch": "main",
389 * "ref": "refs/heads/main",
390 * "source": "gluecron",
391 * "timestamp": "<ISO-8601>"
392 * }
393 *
394 * → 200 { ok: true, deploymentId, status: "queued" | "skipped" }
395 * → 401 invalid bearer token
396 * → 400 malformed payload
397 * → 404 repository not configured for auto-deploy on Crontech
398 *
399 * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization
400 * header — Crontech will then respond 401, which we treat as a failed deploy
401 * row exactly like any other non-ok HTTP response.
402 */
376403async function triggerCrontechDeploy(
377404 owner: string,
378405 repo: string,
@@ -398,14 +425,22 @@ async function triggerCrontechDeploy(
398425 }
399426
400427 try {
428 const headers: Record<string, string> = {
429 "Content-Type": "application/json",
430 };
431 if (config.gluecronWebhookSecret) {
432 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
433 }
401434 const response = await fetch(config.crontechDeployUrl, {
402435 method: "POST",
403 headers: { "Content-Type": "application/json" },
436 headers,
404437 body: JSON.stringify({
405438 repository: `${owner}/${repo}`,
406439 sha,
407440 branch: "main",
441 ref: "refs/heads/main",
408442 source: "gluecron",
443 timestamp: new Date().toISOString(),
409444 }),
410445 });
411446 console.log(
@@ -476,3 +511,6 @@ async function fanoutWebhooks(
476511 // best-effort
477512 }
478513}
514
515/** Test-only access to internal helpers. */
516export const __test = { triggerCrontechDeploy };
Modifiedsrc/lib/config.ts+9−1View fileUnifiedSplit
@@ -19,9 +19,17 @@ export const config = {
1919 get crontechDeployUrl() {
2020 return (
2121 process.env.CRONTECH_DEPLOY_URL ||
22 "https://crontech.ai/api/trpc/tenant.deploy"
22 "https://crontech.ai/api/hooks/gluecron/push"
2323 );
2424 },
25 /**
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).
29 */
30 get gluecronWebhookSecret() {
31 return process.env.GLUECRON_WEBHOOK_SECRET || "";
32 },
2533 get anthropicApiKey() {
2634 return process.env.ANTHROPIC_API_KEY || "";
2735 },
2836