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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
rollback-deploy.ts6.8 KB · 228 lines
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
    )}`,
  };
}