CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
admin-deploys.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f764c07 | 1 | /** |
| 2 | * Block N4 — Admin deploy trigger. | |
| 3 | * | |
| 4 | * POST /admin/deploys/trigger — kick off the hetzner-deploy.yml | |
| 5 | * workflow via GitHub workflow_dispatch. | |
| 6 | * | |
| 7 | * Reads `GITHUB_TOKEN` (operator-provided, repo+workflow scopes) from the | |
| 8 | * server environment. The CLI talks to GitHub directly; this route is the | |
| 9 | * "click a button on the admin page" equivalent so the operator never has to | |
| 10 | * leave Gluecron to ship a hot-fix. | |
| 11 | * | |
| 12 | * NOTE: The companion `/admin/deploys` page (Block N3) has not landed yet on | |
| 13 | * this branch. We ship the trigger endpoint now so the CLI + tests can land | |
| 14 | * cleanly; once N3 adds the page, its "Trigger deploy" button posts here. | |
| 15 | */ | |
| 16 | ||
| 17 | import { Hono } from "hono"; | |
| 18 | import { softAuth } from "../middleware/auth"; | |
| 19 | import type { AuthEnv } from "../middleware/auth"; | |
| 20 | import { isSiteAdmin } from "../lib/admin"; | |
| 21 | import { audit } from "../lib/notify"; | |
| 22 | ||
| 23 | const GH_API = "https://api.github.com"; | |
| 24 | ||
| 25 | /** | |
| 26 | * Dependency-injected fetcher so tests can drive the route without hitting | |
| 27 | * the real api.github.com. | |
| 28 | */ | |
| 29 | export type GithubFetch = ( | |
| 30 | url: string, | |
| 31 | init?: { method?: string; headers?: Record<string, string>; body?: string } | |
| 32 | ) => Promise<{ status: number; ok: boolean; text: () => Promise<string> }>; | |
| 33 | ||
| 34 | let _githubFetch: GithubFetch | null = null; | |
| 35 | let _envOverride: { GITHUB_TOKEN?: string } | null = null; | |
| 36 | ||
| 37 | /** Test-only: override the github fetcher. Pass `null` to restore default. */ | |
| 38 | export function __setGithubFetchForTests(f: GithubFetch | null): void { | |
| 39 | _githubFetch = f; | |
| 40 | } | |
| 41 | ||
| 42 | /** Test-only: override env reads. Pass `null` to fall back to process.env. */ | |
| 43 | export function __setEnvForTests(e: { GITHUB_TOKEN?: string } | null): void { | |
| 44 | _envOverride = e; | |
| 45 | } | |
| 46 | ||
| 47 | function ghToken(): string | undefined { | |
| 48 | if (_envOverride) return _envOverride.GITHUB_TOKEN; | |
| 49 | return process.env.GITHUB_TOKEN; | |
| 50 | } | |
| 51 | ||
| 52 | function ghFetch(): GithubFetch { | |
| 53 | return _githubFetch ?? ((fetch as unknown) as GithubFetch); | |
| 54 | } | |
| 55 | ||
| 56 | const admin = new Hono<AuthEnv>(); | |
| 57 | admin.use("*", softAuth); | |
| 58 | ||
| 59 | async function gate(c: any): Promise<{ user: any } | Response> { | |
| 60 | const user = c.get("user"); | |
| 61 | if (!user) return c.json({ error: "auth required" }, 401); | |
| 62 | if (!(await isSiteAdmin(user.id))) { | |
| 63 | return c.json({ error: "site admin required" }, 403); | |
| 64 | } | |
| 65 | return { user }; | |
| 66 | } | |
| 67 | ||
| 68 | admin.post("/admin/deploys/trigger", async (c) => { | |
| 69 | const g = await gate(c); | |
| 70 | if (g instanceof Response) return g; | |
| 71 | const { user } = g; | |
| 72 | ||
| 73 | const token = ghToken(); | |
| 74 | if (!token) { | |
| 75 | return c.json( | |
| 76 | { | |
| 77 | error: | |
| 78 | "GITHUB_TOKEN is not set on the server — configure GITHUB_TOKEN on the box first (e.g. /etc/gluecron.env).", | |
| 79 | }, | |
| 80 | 400 | |
| 81 | ); | |
| 82 | } | |
| 83 | ||
| 84 | // Body is optional — defaults are the hetzner deploy on main of this repo. | |
| 85 | let body: any = {}; | |
| 86 | try { | |
| 87 | body = await c.req.json(); | |
| 88 | } catch { | |
| 89 | body = {}; | |
| 90 | } | |
| 91 | const repo = String(body.repo || "ccantynz/Gluecron.com"); | |
| 92 | const workflow = String(body.workflow || "hetzner-deploy.yml"); | |
| 93 | const ref = String(body.ref || "main"); | |
| 94 | const [owner, name] = repo.split("/"); | |
| 95 | if (!owner || !name) { | |
| 96 | return c.json({ error: "expected repo as owner/name" }, 400); | |
| 97 | } | |
| 98 | ||
| 99 | const url = `${GH_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`; | |
| 100 | const res = await ghFetch()(url, { | |
| 101 | method: "POST", | |
| 102 | headers: { | |
| 103 | accept: "application/vnd.github+json", | |
| 104 | authorization: `Bearer ${token}`, | |
| 105 | "content-type": "application/json", | |
| 106 | "x-github-api-version": "2022-11-28", | |
| 107 | "user-agent": "gluecron-admin", | |
| 108 | }, | |
| 109 | body: JSON.stringify({ ref }), | |
| 110 | }); | |
| 111 | ||
| 112 | if (res.status !== 204) { | |
| 113 | const raw = await res.text(); | |
| 114 | let msg = raw; | |
| 115 | try { | |
| 116 | const j = JSON.parse(raw); | |
| 117 | msg = j?.message || raw; | |
| 118 | } catch { | |
| 119 | // raw it is | |
| 120 | } | |
| 121 | return c.json( | |
| 122 | { error: `github responded ${res.status}: ${msg || "request failed"}` }, | |
| 123 | 502 | |
| 124 | ); | |
| 125 | } | |
| 126 | ||
| 127 | await audit({ | |
| 128 | userId: user.id, | |
| 129 | action: "admin.deploy.triggered", | |
| 130 | targetType: "workflow", | |
| 131 | targetId: `${repo}:${workflow}@${ref}`, | |
| 132 | metadata: { repo, workflow, ref }, | |
| 133 | }); | |
| 134 | ||
| 135 | return c.json({ ok: true, repo, workflow, ref }); | |
| 136 | }); | |
| 137 | ||
| 138 | export default admin; |