Skip to content

Commit

A preview is live only while what it previews is: a branch's preview (one with no pull request) comes down when its branch is deleted, and the sweep takes down any whose branch is already gone. A pull request's preview has always come down when it closes or merges, but a branch's had nothing to take it down, so the previews that preview stacks built for another project's pull request, before projects stopped depending on each other (a9916137), stayed up and showed as a live Preview on projects with no pull requests at all, as flagon-io/automation-lab's v2 did. Moving a project no longer builds such a preview again (rebuildStack is gone with the stacks). A branch's preview from a project's deploy workflow stays while its branch is there, and one whose branches can't be read stays until they can. Checked: the deployments service's typecheck and its 88 tests (new: the sweep takes down a preview of a deleted branch and marks its deployment inactive while keeping a pull request's and a live branch's, and git.deleted takes a branch's preview down at once).

syntaqxcommitted Parentcb3fedbBrowse files
2 files+82−230/2 viewed
+50−23
17861786 ]);
17871787 }
17881788
1789+ private async takeDownBranchPreview(projectId: string, branch: string): Promise<void> {
1790+ const apps = await this.db
1791+ .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number IS NULL AND branch = ?")
1792+ .bind(projectId, branch)
1793+ .all<{ script: string }>();
1794+ for (const app of apps.results) await this.removeApp(app.script);
1795+ }
1796+
17891797 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
17901798 const apps = await this.db
17911799 .prepare(
18561864 for (const app of apps.results) await this.removeApp(app.script);
18571865 }
18581866 break;
1867+ // A branch deleted takes its own preview (not a pull request's) down.
1868+ case "git.deleted":
1869+ if (event.data.ref.startsWith("refs/heads/")) {
1870+ const branch = event.data.ref.slice("refs/heads/".length);
1871+ for (const project of await this.projects.byRepo(event.data.repoId)) await this.takeDownBranchPreview(project.id, branch);
1872+ }
1873+ break;
18591874 case "git.push":
18601875 if (!event.data.defaultBranch) break;
18611876 for (const project of await this.projects.byRepo(event.data.repoId)) {
21562171 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
21572172 const billing = billingClient(this.env.BILLING);
21582173 const open = new Map<string, boolean>();
2159− const actors = new Map<string, User | null>();
21602174
21612175 for (const target of targets) {
21622176 const row = settings.get(target.projectId)!;
22042218 result.waiting++;
22052219 continue;
22062220 }
2207− if (!actors.has(project.workspace)) actors.set(project.workspace, await this.workspaceActor(project.workspace));
22082221 const started =
22092222 target.kind === "production"
22102223 ? await this.deployProduction(project, target.commit, "g1t")
22112224 : target.number != null
22122225 ? await this.deployPreview(project, target.number, "g1t", true)
2213− : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
2226+ : // A preview no pull request holds is not built again; the sweep takes it down.
2227+ null;
22142228 const outcome = rebuildOutcome(started);
22152229 if (outcome === "queued") result.queued++;
22162230 else if (outcome === "failed") {
24662480 return { ...project, workspace: current, source };
24672481 }
24682482
2469− /** A stack's preview (no pull request of its own) built again at `commit`. */
2470− private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
2471− if (!actor || branch == null) return null;
2472− const settings = await this.settingsRow(project.id);
2473− if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
2474− return this.start({
2475− project,
2476− kind: "preview",
2477− branch,
2478− number: null,
2479− commit,
2480− source: repoOf(project).path,
2481− reader: actor,
2482− createdBy: "g1t",
2483− settings,
2484− // As `stack` built it: the project's own default branch.
2485− trusted: true,
2486− });
2487− }
2488−
24892483 // ---- The sweep -----------------------------------------------------
24902484
24912485 /**
25092503 .all<{ id: string }>();
25102504 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
25112505 });
2506+ await step("taking down previews no pull request holds", () => this.takeDownUnheld());
25122507 await step("taking down idle previews", () => this.takeDownIdle());
25132508 await step("charging months", () => this.chargeMonths());
25142509
27262721 }
27272722 }
27282723
2724+ /**
2725+ * Previews of a branch that is gone. A pull request's preview comes down
2726+ * when it closes or merges; a branch's preview (built by a project's
2727+ * deploy workflow, or by the preview stacks projects' dependencies once
2728+ * built) has no pull request, so it comes down once its branch is
2729+ * deleted, here for any whose deletion was missed. A deleted
2730+ * workspace's stay paused for a restore.
2731+ */
2732+ private async takeDownUnheld(): Promise<void> {
2733+ const unheld = await this.db
2734+ .prepare(
2735+ `SELECT apps.script, apps.project_id, apps.branch FROM apps JOIN settings ON settings.project_id = apps.project_id
2736+ WHERE apps.kind = 'preview' AND apps.number IS NULL AND settings.workspace_deleted_at IS NULL`,
2737+ )
2738+ .all<{ script: string; project_id: string; branch: string | null }>();
2739+ if (unheld.results.length === 0) return;
2740+ const projects = await this.projectsById([...new Set(unheld.results.map((app) => app.project_id))]);
2741+ const branchesOf = new Map<string, Set<string> | null>();
2742+ for (const app of unheld.results) {
2743+ const project = projects.get(app.project_id);
2744+ if (!project || project.source.kind !== "hosted") continue;
2745+ if (!branchesOf.has(project.id)) {
2746+ const actor = await this.workspaceActor(project.workspace);
2747+ const branches = actor ? await reposClient(this.env.REPOS).branches(repoOf(project).path, actor) : null;
2748+ // A read that failed says nothing: the app stays until one succeeds.
2749+ branchesOf.set(project.id, branches?.ok ? new Set(branches.value.map((b) => b.name)) : null);
2750+ }
2751+ const names = branchesOf.get(project.id);
2752+ if (names && (app.branch == null || !names.has(app.branch))) await this.removeApp(app.script);
2753+ }
2754+ }
2755+
27292756 /** Previews no one has visited in their project's idle days. */
27302757 private async takeDownIdle(): Promise<void> {
27312758 const idle = await this.db
+32−0
8686 /** Cloudflare API calls, as `METHOD path`. */
8787 cloudflare: [] as string[],
8888 kv: new Map<string, { value: string; expiration?: number }>(),
89+ /** The repository's branches. */
90+ branches: ["main", "v2"],
8991 };
9092 const project = () => ({
9193 id: PROJECT,
127129 REPOS: service((method) => {
128130 if (method === "path_by_id") return state.path;
129131 if (method === "get") return ok({ isPrivate: true });
132+ if (method === "branches") return ok(state.branches.map((name) => ({ name, hash: `${name}head` })));
130133 return ok([]);
131134 }),
132135 PROJECTS: service((method) => {
321324 await worker.scheduled({} as never, w.env as never);
322325 assert.ok(rows("SELECT paused_at FROM apps").every((r) => r.paused_at));
323326 });
327+
328+test("a branch's preview comes down once its branch is gone, and a pull request's stays", async () => {
329+ // Branch previews have no pull request: one the deploy workflow built for
330+ // a branch that is still there, and one a preview stack built (before
331+ // projects stopped depending on each other) for a branch since deleted.
332+ const app = w.state.sqlite.prepare(
333+ `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
334+ VALUES (?, ?, 'syntaqx', 'lab-api', 'preview', ?, NULL, 'abc1234', '2026-10-06T22:01:00Z', '2026-10-06T22:01:00Z')`,
335+ );
336+ app.run("lab-api-git-feature-syntaqx", PROJECT, "feature");
337+ app.run("lab-api-git-stack-syntaqx", PROJECT, "stack");
338+ w.state.sqlite
339+ .prepare(
340+ `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, script, status,
341+ trusted, created_by, created_at, finished_at)
342+ VALUES ('dpl_stack', ?, 'syntaqx', 'lab-api', ?, 'syntaqx/lab-api', 'preview', 'stack', NULL, 'abc1234', 'lab-api-git-stack-syntaqx', 'ready', 1, 'g1t', '2026-10-06T22:00:00Z', '2026-10-06T22:01:00Z')`,
343+ )
344+ .run(PROJECT, API);
345+ w.state.branches.push("feature");
346+ await worker.scheduled({} as never, w.env as never);
347+ const previews = () => rows("SELECT script FROM apps WHERE kind = 'preview' ORDER BY script").map((r) => r.script);
348+ assert.deepEqual(previews(), ["lab-api-git-feature-syntaqx", "lab-api-git-v2-syntaqx"]);
349+ assert.equal(rows("SELECT status FROM deployments WHERE id = 'dpl_stack'")[0].status, "down");
350+ // Deleting the branch takes its preview down at once.
351+ w.state.branches = w.state.branches.filter((name) => name !== "feature");
352+ const deleted = { ...transferred, id: "evt_2", type: "git.deleted", data: { repoId: API, ref: "refs/heads/feature", before: "abc1234" } };
353+ assert.equal(await deliver(deleted), true);
354+ assert.deepEqual(previews(), ["lab-api-git-v2-syntaqx"]);
355+});