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

feat(onboarding): a new repository now arrives working instead of empty

feat(onboarding): a new repository now arrives working instead of empty

POST /new called bootstrapRepository(), which creates the DB side only —
gate settings, branch protection, labels, welcome issue. It created no git
content. So a brand-new user landed on an EMPTY repository: no commit, no
README, no workflow, and therefore no CI run to be green. Every push-triggered
feature the product sells had nothing to act on, and the first thing a new
user saw was an empty state telling them to go and configure something.

New repos are now seeded with a README describing what was already switched
on, and .gluecron/workflows/ci.yml — a workflow with no external dependencies
so it passes on a bare runner. The point is proving the pipeline works end to
end, not guessing the user's stack; anything needing a package manager would
hand a new user a red X as their first result.

The step that is easy to miss and impossible to notice: files written through
the git plumbing API do NOT fire the post-receive hook, so the workflow would
sit on disk with no `workflows` row and no run — a repo that looks configured
and does nothing. The scaffold therefore calls the same
syncAndEnqueuePushWorkflows() seam post-receive calls, so a scaffolded repo
takes exactly the same path as a pushed one.

Dependencies are injectable. My first version mocked ../git/repository and
../lib/push-workflow-sync with mock.module() and broke six tests in
push-workflow-sync.test.ts — which is precisely what that file's own header
comment warns about: both modules are imported by dozens of unrelated test
files, so a module mock leaks globally across the run. Injection is the
pattern that file already established, and this now follows it.

Best-effort throughout: a repo with no README is a disappointment, a failed
repo creation is a broken product. Every step is caught and reported, sync is
skipped when the workflow never committed (syncing a commit with no workflow
in it would report a phantom run), and the whole block is wrapped so it
cannot break creation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 28, 2026Parent: 8ae1b1f
3 files changed+391047b1dc700bfdbd07eed19c0bf0d7116e5b1a4fd3
3 changed files+391−0
Addedsrc/__tests__/first-repo-scaffold.test.ts+148−0View fileUnifiedSplit
1/**
2 * A new repository should arrive working, not empty.
3 *
4 * POST /new called bootstrapRepository(), which creates the DB side only —
5 * gate settings, branch protection, labels, welcome issue. It created no git
6 * content, so a new user landed on an EMPTY repository: no commit, no README,
7 * no workflow, and therefore no CI run to be green. Every push-triggered
8 * feature the product sells had nothing to act on, and the first thing a new
9 * user saw was an empty state telling them to go and configure something.
10 *
11 * scaffoldFirstRepo seeds a README and a CI workflow, then calls the SAME
12 * syncAndEnqueuePushWorkflows() seam the git post-receive hook calls. That
13 * last step is the one that is easy to miss and impossible to notice: files
14 * written through the plumbing API do not fire the git hook, so without it
15 * the workflow sits on disk with no `workflows` row and no run — the repo
16 * looks configured and does nothing.
17 */
18
19import { describe, expect, it } from "bun:test";
20import { readFileSync } from "fs";
21
22const SRC = readFileSync("src/lib/first-repo-scaffold.ts", "utf8");
23const WEB = readFileSync("src/routes/web.tsx", "utf8");
24
25describe("wiring", () => {
26 it("POST /new scaffolds after bootstrapping", () => {
27 const handler = WEB.slice(
28 WEB.indexOf('web.post("/new"'),
29 WEB.indexOf("// Daily brief")
30 );
31 expect(handler.length).toBeGreaterThan(200);
32 expect(handler).toContain("bootstrapRepository");
33 expect(handler).toContain("scaffoldFirstRepo");
34 // Bootstrap first: the scaffold's CI run is gated by settings that
35 // bootstrapRepository creates.
36 expect(handler.indexOf("bootstrapRepository")).toBeLessThan(
37 handler.indexOf("scaffoldFirstRepo")
38 );
39 });
40
41 it("a scaffold failure cannot break repo creation", () => {
42 const handler = WEB.slice(
43 WEB.indexOf('web.post("/new"'),
44 WEB.indexOf("// Daily brief")
45 );
46 const block = handler.slice(handler.indexOf("scaffoldFirstRepo") - 400);
47 expect(block).toContain("catch");
48 });
49
50 it("uses the same sync seam as the post-receive hook", () => {
51 // Writing via the plumbing API does not fire the git hook, so the
52 // workflow would never be discovered without calling this directly.
53 expect(SRC).toContain("syncAndEnqueuePushWorkflows");
54 const hook = readFileSync("src/hooks/post-receive.ts", "utf8");
55 expect(hook).toContain("syncAndEnqueuePushWorkflows");
56 });
57});
58
59describe("what gets seeded", () => {
60 it("writes a README and a CI workflow at the discovered path", () => {
61 expect(SRC).toContain('"README.md"');
62 // workflows.tsx documents .gluecron/workflows/ as the discovery dir;
63 // a workflow written anywhere else is invisible.
64 expect(SRC).toContain('".gluecron/workflows/ci.yml"');
65 });
66
67 it("the workflow triggers on push", () => {
68 // syncAndEnqueuePushWorkflows only enqueues workflows whose `on:`
69 // includes push — a workflow without it syncs but never runs.
70 const wf = SRC.slice(SRC.indexOf("function ciWorkflow"), SRC.indexOf("function readme"));
71 expect(wf).toMatch(/on:\s*\n\s*- push/);
72 });
73
74 it("the workflow has no external dependencies", () => {
75 // It runs on a bare runner. The point is proving the pipeline works end
76 // to end, not guessing the user's stack — anything needing a package
77 // manager would fail and hand a new user a red X as their first result.
78 const wf = SRC.slice(SRC.indexOf("function ciWorkflow"), SRC.indexOf("function readme"));
79 expect(wf).not.toMatch(/npm |yarn |pnpm |bun install|pip |cargo /);
80 });
81});
82
83describe("fault isolation", () => {
84 const ok = async () => ({ commitSha: "abc", blobSha: "b", parentSha: null });
85 const base = {
86 owner: "alice",
87 repoName: "demo",
88 repositoryId: "r1",
89 defaultBranch: "main",
90 authorName: "alice",
91 authorEmail: "a@b.c",
92 userId: "u1",
93 };
94
95 it("reports a write failure instead of throwing", async () => {
96 const { scaffoldFirstRepo } = await import("../lib/first-repo-scaffold");
97 const r = await scaffoldFirstRepo(base, {
98 createOrUpdateFileOnBranch: async () => ({ error: "write-failed" }),
99 syncAndEnqueuePushWorkflows: async () => ({ synced: 0, enqueued: 0, errors: [] }),
100 });
101 expect(r.readmeCommitted).toBe(false);
102 expect(r.workflowCommitted).toBe(false);
103 expect(r.errors.join(" ")).toContain("write-failed");
104 });
105
106 it("does not attempt workflow sync when the workflow never committed", async () => {
107 let syncCalled = false;
108 const { scaffoldFirstRepo } = await import("../lib/first-repo-scaffold");
109 const r = await scaffoldFirstRepo(base, {
110 createOrUpdateFileOnBranch: async (i) =>
111 i.filePath === "README.md" ? await ok() : { error: "write-failed" },
112 syncAndEnqueuePushWorkflows: async () => {
113 syncCalled = true;
114 return { synced: 1, enqueued: 1, errors: [] };
115 },
116 });
117 expect(r.readmeCommitted).toBe(true);
118 expect(r.workflowCommitted).toBe(false);
119 // Syncing a commit with no workflow in it would report a phantom run.
120 expect(syncCalled).toBe(false);
121 expect(r.runsEnqueued).toBe(0);
122 });
123
124 it("surfaces a sync failure without losing the commits", async () => {
125 const { scaffoldFirstRepo } = await import("../lib/first-repo-scaffold");
126 const r = await scaffoldFirstRepo(base, {
127 createOrUpdateFileOnBranch: ok,
128 syncAndEnqueuePushWorkflows: async () => {
129 throw new Error("sync exploded");
130 },
131 });
132 expect(r.readmeCommitted).toBe(true);
133 expect(r.workflowCommitted).toBe(true);
134 expect(r.errors.join(" ")).toContain("sync exploded");
135 });
136
137 it("reports the enqueued run on the happy path", async () => {
138 const { scaffoldFirstRepo } = await import("../lib/first-repo-scaffold");
139 const r = await scaffoldFirstRepo(base, {
140 createOrUpdateFileOnBranch: ok,
141 syncAndEnqueuePushWorkflows: async () => ({ synced: 1, enqueued: 1, errors: [] }),
142 });
143 expect(r.workflowsSynced).toBe(1);
144 // This is the whole point: a green CI run with zero configuration.
145 expect(r.runsEnqueued).toBe(1);
146 expect(r.errors).toEqual([]);
147 });
148});
Addedsrc/lib/first-repo-scaffold.ts+218−0View fileUnifiedSplit
1/**
2 * Seed a brand-new repository so it arrives working rather than empty.
3 *
4 * `POST /new` already calls bootstrapRepository(), which creates the DB side —
5 * gate settings, branch protection, labels, a welcome issue. But it creates no
6 * git content, so a new user landed on an empty repository: no commit, no
7 * README, no workflow, and therefore no CI run to be green. Everything the
8 * product does on push had nothing to act on, and the first thing a new user
9 * saw was an empty-state page telling them to go and configure something.
10 *
11 * This writes an initial commit containing:
12 *
13 * README.md what the repo is, and what the platform
14 * already did to it
15 * .gluecron/workflows/ci.yml a workflow that runs on push and passes
16 *
17 * ...then calls the same syncAndEnqueuePushWorkflows() the git post-receive
18 * hook calls. That matters: these files are written through the git plumbing
19 * API rather than an actual push, so the post-receive hook never fires and
20 * the workflow would otherwise sit undiscovered on disk. Calling the shared
21 * seam means scaffolded repos take exactly the same path as pushed ones.
22 *
23 * Best-effort throughout. A repository that exists but has no README is a
24 * cosmetic disappointment; a failed repo creation is a broken product. So
25 * every step is caught and reported, never thrown.
26 */
27
28export interface ScaffoldResult {
29 readmeCommitted: boolean;
30 workflowCommitted: boolean;
31 /** Workflows discovered and upserted by the shared sync seam. */
32 workflowsSynced: number;
33 /** CI runs enqueued — this is what makes the repo show a green check. */
34 runsEnqueued: number;
35 errors: string[];
36}
37
38/** The CI workflow every new repo starts with. Deliberately trivial and
39 * dependency-free so it passes on a bare runner: the point is to prove the
40 * pipeline works end to end, not to guess the user's stack. */
41function ciWorkflow(repoName: string): string {
42 return `name: CI
43
44on:
45 - push
46
47jobs:
48 verify:
49 runs-on: ubuntu-latest
50 steps:
51 - name: Say hello
52 run: echo "CI is running for ${repoName}"
53 - name: Check the workspace exists
54 run: ls -a
55`;
56}
57
58function readme(owner: string, repoName: string): string {
59 return `# ${repoName}
60
61Created on Gluecron. This repository was set up for you — nothing here needed
62configuring.
63
64## What is already switched on
65
66- **Branch protection** on the default branch
67- **Secret + security scanning** on every push
68- **AI review** on every pull request
69- **CI** — see \`.gluecron/workflows/ci.yml\`, which ran when this commit landed
70
71## Next
72
73Push some code:
74
75\`\`\`bash
76git clone https://gluecron.com/${owner}/${repoName}.git
77cd ${repoName}
78# ...
79git push
80\`\`\`
81
82Every push is scanned, reviewed and gated automatically.
83`;
84}
85
86/**
87 * Injectable dependencies.
88 *
89 * Deliberately mirrors the pattern in push-workflow-sync.ts. Both of these
90 * modules are imported by dozens of unrelated test files, so a
91 * `mock.module()` on either leaks a global mock across the entire test run —
92 * mocking them from this file's tests broke six tests in
93 * push-workflow-sync.test.ts, which is precisely what that file's header
94 * comment warns about.
95 */
96export interface ScaffoldDeps {
97 createOrUpdateFileOnBranch: (input: {
98 owner: string;
99 name: string;
100 branch: string;
101 filePath: string;
102 bytes: Uint8Array;
103 message: string;
104 authorName: string;
105 authorEmail: string;
106 }) => Promise<
107 { commitSha: string; blobSha: string; parentSha: string | null } | { error: string }
108 >;
109 syncAndEnqueuePushWorkflows: (opts: {
110 owner: string;
111 repo: string;
112 repositoryId: string;
113 branch: string;
114 commitSha: string;
115 triggeredBy?: string | null;
116 }) => Promise<{ synced: number; enqueued: number; errors: string[] }>;
117}
118
119async function realDeps(): Promise<ScaffoldDeps> {
120 const { createOrUpdateFileOnBranch } = await import("../git/repository");
121 const { syncAndEnqueuePushWorkflows } = await import("./push-workflow-sync");
122 return {
123 createOrUpdateFileOnBranch: createOrUpdateFileOnBranch as ScaffoldDeps["createOrUpdateFileOnBranch"],
124 syncAndEnqueuePushWorkflows,
125 };
126}
127
128export async function scaffoldFirstRepo(
129 opts: {
130 owner: string;
131 repoName: string;
132 repositoryId: string;
133 defaultBranch: string;
134 authorName: string;
135 authorEmail: string;
136 userId: string;
137 },
138 injected?: ScaffoldDeps
139): Promise<ScaffoldResult> {
140 const result: ScaffoldResult = {
141 readmeCommitted: false,
142 workflowCommitted: false,
143 workflowsSynced: 0,
144 runsEnqueued: 0,
145 errors: [],
146 };
147
148 const deps = injected ?? (await realDeps());
149 const { createOrUpdateFileOnBranch } = deps;
150 const enc = new TextEncoder();
151 let headSha: string | null = null;
152
153 const write = async (filePath: string, body: string, message: string) => {
154 const res = await createOrUpdateFileOnBranch({
155 owner: opts.owner,
156 name: opts.repoName,
157 branch: opts.defaultBranch,
158 filePath,
159 bytes: enc.encode(body),
160 message,
161 authorName: opts.authorName,
162 authorEmail: opts.authorEmail,
163 });
164 if ("error" in res) throw new Error(`${filePath}: ${res.error}`);
165 return res.commitSha;
166 };
167
168 try {
169 headSha = await write(
170 "README.md",
171 readme(opts.owner, opts.repoName),
172 "Add README"
173 );
174 result.readmeCommitted = true;
175 } catch (err) {
176 result.errors.push(
177 `readme: ${err instanceof Error ? err.message : String(err)}`
178 );
179 }
180
181 try {
182 headSha = await write(
183 ".gluecron/workflows/ci.yml",
184 ciWorkflow(opts.repoName),
185 "Add CI workflow"
186 );
187 result.workflowCommitted = true;
188 } catch (err) {
189 result.errors.push(
190 `workflow: ${err instanceof Error ? err.message : String(err)}`
191 );
192 }
193
194 // Discover + enqueue via the SAME seam post-receive uses. Without this the
195 // workflow file exists on disk but no `workflows` row and no run are ever
196 // created, because writing through the plumbing API does not fire the git
197 // hook — the repo would look configured and do nothing.
198 if (result.workflowCommitted && headSha) {
199 try {
200 const sync = await deps.syncAndEnqueuePushWorkflows({
201 owner: opts.owner,
202 repo: opts.repoName,
203 repositoryId: opts.repositoryId,
204 branch: opts.defaultBranch,
205 commitSha: headSha,
206 triggeredBy: opts.userId,
207 });
208 result.workflowsSynced = sync.synced ?? 0;
209 result.runsEnqueued = sync.enqueued ?? 0;
210 } catch (err) {
211 result.errors.push(
212 `sync: ${err instanceof Error ? err.message : String(err)}`
213 );
214 }
215 }
216
217 return result;
218}
Modifiedsrc/routes/web.tsx+25−0View fileUnifiedSplit
22232223 ownerUserId: user.id,
22242224 defaultBranch: "main",
22252225 });
2226
2227 // bootstrapRepository sets up the DB side only — settings, protection,
2228 // labels, welcome issue — so the repository itself was still EMPTY. A new
2229 // user landed on an empty-state page telling them to go configure
2230 // something, and everything the platform does on push had nothing to act
2231 // on. Seed a README and a working CI workflow, then run the same workflow
2232 // discovery the post-receive hook runs, so the repo has a green CI run
2233 // before the user has done anything at all.
2234 //
2235 // Best-effort: a repo with no README is a disappointment, a failed repo
2236 // creation is a broken product. Never let this throw past here.
2237 try {
2238 const { scaffoldFirstRepo } = await import("../lib/first-repo-scaffold");
2239 await scaffoldFirstRepo({
2240 owner: user.username,
2241 repoName: name,
2242 repositoryId: newRepo.id,
2243 defaultBranch: "main",
2244 authorName: user.username,
2245 authorEmail: user.email || `${user.username}@users.noreply.gluecron.com`,
2246 userId: user.id,
2247 });
2248 } catch (err) {
2249 console.error("[new-repo] scaffold failed:", err);
2250 }
22262251 }
22272252
22282253 return c.redirect(`/${user.username}/${name}`);
22292254