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