Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

rollback-deploy.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

rollback-deploy.tsBlame228 lines · 1 contributor
9dd96b9Test User1/**
2 * Block R1 — Rollback helper for /admin/ops.
3 *
4 * Two thin, testable helpers:
5 *
6 * findPreviousSuccessfulDeploy(opts?)
7 * Reads `platform_deploys` and returns the most-recent succeeded
8 * deploy whose SHA differs from the current latest succeeded deploy.
9 * Null when no prior successful deploy exists.
10 *
11 * triggerRollback(args)
12 * Calls GitHub's workflow_dispatch endpoint with `ref: targetSha`
13 * and audits `admin.deploy.rollback_triggered`. Mirrors N4's pattern
14 * (`src/routes/admin-deploys.tsx`) for 401 / 422 / non-204 mapping
15 * so the operator gets a readable error string instead of a raw
16 * GitHub blob.
17 *
18 * Notes:
19 * - GITHUB_TOKEN is read from `process.env.GITHUB_TOKEN` at call time.
20 * Never bundled in source.
21 * - All DB / audit calls are wrapped in try/catch — the caller only
22 * sees `{ ok, error }` and never a raw thrown Error from this module.
23 */
24
25import { and, desc, eq, ne } from "drizzle-orm";
26import { db } from "../db";
27import { platformDeploys } from "../db/schema-deploys";
28import { audit } from "./notify";
29
30const GH_API = "https://api.github.com";
31
32export interface PreviousDeploy {
33 sha: string;
34 runId: string;
35 finishedAt: Date;
36}
37
38/**
39 * Return the most-recent `succeeded` deploy whose SHA differs from the
40 * current latest `succeeded` deploy. Returns null when:
41 * - the table is empty, or
42 * - there is only one succeeded deploy (nothing to roll back TO), or
43 * - every prior succeeded deploy has the same SHA as the latest.
44 *
45 * `skip` lets the caller fast-forward past N candidate rows — useful
46 * for "rollback to the one before that" if the immediate predecessor
47 * is also bad. Default 0.
48 */
49export async function findPreviousSuccessfulDeploy(opts?: {
50 skip?: number;
51}): Promise<PreviousDeploy | null> {
52 const skip = Math.max(0, opts?.skip ?? 0);
53 try {
54 // Latest succeeded deploy — this is what we're rolling back AWAY from.
55 const [latest] = await db
56 .select({
57 sha: platformDeploys.sha,
58 finishedAt: platformDeploys.finishedAt,
59 })
60 .from(platformDeploys)
61 .where(eq(platformDeploys.status, "succeeded"))
62 .orderBy(desc(platformDeploys.finishedAt))
63 .limit(1);
64 if (!latest) return null;
65
66 // Candidates: succeeded deploys with a different SHA, ordered by
67 // most recent. Apply skip + take 1.
68 const candidates = await db
69 .select({
70 sha: platformDeploys.sha,
71 runId: platformDeploys.runId,
72 finishedAt: platformDeploys.finishedAt,
73 })
74 .from(platformDeploys)
75 .where(
76 and(
77 eq(platformDeploys.status, "succeeded"),
78 ne(platformDeploys.sha, latest.sha)
79 )
80 )
81 .orderBy(desc(platformDeploys.finishedAt))
82 .limit(skip + 1);
83 const target = candidates[skip];
84 if (!target || !target.finishedAt) return null;
85 return {
86 sha: target.sha,
87 runId: target.runId,
88 finishedAt: target.finishedAt,
89 };
90 } catch (err) {
91 console.error("[rollback-deploy] findPreviousSuccessfulDeploy:", err);
92 return null;
93 }
94}
95
96export interface TriggerRollbackArgs {
97 targetSha: string;
98 triggeredByUserId: string;
99 /** Optional fetch override for tests. */
100 fetchImpl?: typeof fetch;
101 /** Optional repo override, defaults to ccantynz/Gluecron.com. */
102 repo?: string;
103 /** Optional workflow override, defaults to hetzner-deploy.yml. */
104 workflow?: string;
105 /** Optional GITHUB_TOKEN override (tests). */
106 githubToken?: string;
107}
108
109export interface TriggerRollbackResult {
110 ok: boolean;
111 runId?: string;
112 htmlUrl?: string;
113 error?: string;
114}
115
116/**
117 * Map a non-204 GitHub response to a friendly error message — mirrors
118 * the pattern used in `src/routes/admin-deploys.tsx`.
119 */
120function friendlyGithubError(status: number, raw: string): string {
121 let msg = raw;
122 try {
123 const j = JSON.parse(raw);
124 msg = j?.message || raw;
125 } catch {
126 // raw it is
127 }
128 if (status === 401) {
129 return `GitHub auth failed (401): ${msg || "bad credentials"}`;
130 }
131 if (status === 422) {
132 return `GitHub rejected the ref (422): ${msg || "invalid ref"}`;
133 }
134 if (status === 404) {
135 return `GitHub said not-found (404): ${msg || "workflow or repo missing"}`;
136 }
137 return `GitHub responded ${status}: ${msg || "request failed"}`;
138}
139
140/**
141 * Fire a workflow_dispatch on the configured deploy workflow with
142 * `ref` set to the target SHA. Records an audit row on success.
143 *
144 * Returns `{ ok: true }` on the GitHub 204; `{ ok: false, error }`
145 * with a human-readable string on any failure path.
146 */
147export async function triggerRollback(
148 args: TriggerRollbackArgs
149): Promise<TriggerRollbackResult> {
150 const targetSha = (args.targetSha || "").trim();
151 if (!targetSha) {
152 return { ok: false, error: "targetSha is required" };
153 }
154 if (!args.triggeredByUserId) {
155 return { ok: false, error: "triggeredByUserId is required" };
156 }
157
158 const token =
159 args.githubToken !== undefined
160 ? args.githubToken
161 : process.env.GITHUB_TOKEN;
162 if (!token) {
163 return {
164 ok: false,
165 error:
166 "GITHUB_TOKEN is not set on the server — configure GITHUB_TOKEN on the box first (e.g. /etc/gluecron.env).",
167 };
168 }
169
170 const repo = args.repo || "ccantynz/Gluecron.com";
171 const workflow = args.workflow || "hetzner-deploy.yml";
172 const [owner, name] = repo.split("/");
173 if (!owner || !name) {
174 return { ok: false, error: "expected repo as owner/name" };
175 }
176
177 const url = `${GH_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(
178 workflow
179 )}/dispatches`;
180
181 const f = args.fetchImpl ?? fetch;
182 let res: { status: number; ok: boolean; text(): Promise<string> };
183 try {
184 res = (await f(url, {
185 method: "POST",
186 headers: {
187 accept: "application/vnd.github+json",
188 authorization: `Bearer ${token}`,
189 "content-type": "application/json",
190 "x-github-api-version": "2022-11-28",
191 "user-agent": "gluecron-admin-ops",
192 },
193 body: JSON.stringify({ ref: targetSha }),
194 })) as any;
195 } catch (err) {
196 return {
197 ok: false,
198 error: `network error talking to GitHub: ${
199 err instanceof Error ? err.message : String(err)
200 }`,
201 };
202 }
203
204 if (res.status !== 204) {
205 const raw = await res.text().catch(() => "");
206 return { ok: false, error: friendlyGithubError(res.status, raw) };
207 }
208
209 // Audit — never let an audit failure mask a successful rollback dispatch.
210 try {
211 await audit({
212 userId: args.triggeredByUserId,
213 action: "admin.deploy.rollback_triggered",
214 targetType: "workflow",
215 targetId: `${repo}:${workflow}@${targetSha}`,
216 metadata: { repo, workflow, ref: targetSha },
217 });
218 } catch (err) {
219 console.error("[rollback-deploy] audit failed:", err);
220 }
221
222 return {
223 ok: true,
224 htmlUrl: `https://github.com/${owner}/${name}/actions/workflows/${encodeURIComponent(
225 workflow
226 )}`,
227 };
228}