Skip to content

g1t/scripts/deploy/report.mjs

97 lines3,821 bytesCodeBlame
1// Tells g1t about a deploy run by hand, so the repository's Deployments
2// page and its production card say what runs. In g1t Actions the job's
3// `environment:` does this already, so nothing is sent from there.
4//
5// Needs a g1t token with deployments:write: G1T_DEPLOY_TOKEN, or the file
6// .credentials/g1t-deploy-token. Without one, nothing is sent. Reporting
7// never fails a deploy: a problem is one line in the log.
8
9import { execFileSync } from "node:child_process";
10import { existsSync, readFileSync } from "node:fs";
11import { join } from "node:path";
12
13import { ROOT } from "./stack.mjs";
14
15/** Where production answers, as the Deployments page links it. */
16const PRODUCTION_URL = process.env.G1T_DEPLOY_URL || "https://g1t.sh";
17
18/** The token, or null when there is none to use. */
19export function deployToken(env = process.env, root = ROOT) {
20 if (env.G1T_DEPLOY_TOKEN) return env.G1T_DEPLOY_TOKEN.trim();
21 const file = join(root, ".credentials", "g1t-deploy-token");
22 return existsSync(file) ? readFileSync(file, "utf8").trim() || null : null;
23}
24
25/** Whether this runs inside a workflow, which reports for itself. */
26export function inWorkflow(env = process.env) {
27 return env.GITHUB_ACTIONS === "true" || env.G1T_ACTIONS === "true";
28}
29
30/** `{ api, repo }` from a remote on g1t (e.g. https://g1t.sh/flagon-io/g1t.git), or null. */
31export function g1tRemote(urls) {
32 for (const url of urls) {
33 const match = /^https:\/\/(g1t\.[a-z.]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url.trim());
34 if (match) return { api: `https://api.${match[1]}`, repo: `${match[2]}/${match[3]}` };
35 }
36 return null;
37}
38
39function remoteUrls() {
40 try {
41 const names = execFileSync("git", ["remote"], { cwd: ROOT, encoding: "utf8" }).split("\n").filter(Boolean);
42 return names.map((name) => execFileSync("git", ["remote", "get-url", name], { cwd: ROOT, encoding: "utf8" }).trim());
43 } catch {
44 return [];
45 }
46}
47
48/**
49 * Starts a production deployment for `head` and returns a function that
50 * settles it (true: success), or null when nothing is reported.
51 */
52export async function reportDeployment({ head, subject, units, log, fetchImpl = fetch, env = process.env }) {
53 if (inWorkflow(env)) return null;
54 const token = deployToken(env);
55 const remote = g1tRemote(remoteUrls());
56 if (!token || !remote) return null;
57 const headers = { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "g1t-deploy" };
58 const base = `${remote.api}/repos/${remote.repo}/deployments`;
59 try {
60 const made = await fetchImpl(base, {
61 method: "POST",
62 headers,
63 body: JSON.stringify({
64 ref: head,
65 sha: head,
66 environment: "production",
67 production_environment: true,
68 description: `${subject} (${units.join(", ")}), deployed by hand`,
69 }),
70 });
71 if (!made.ok) {
72 log(`(the deployment was not reported to g1t: ${made.status})`);
73 return null;
74 }
75 const { id } = await made.json();
76 const status = async (state) => {
77 const sent = await fetchImpl(`${base}/${id}/statuses`, {
78 method: "POST",
79 headers,
80 body: JSON.stringify({ state, environment_url: PRODUCTION_URL, description: state === "in_progress" ? "Deploying" : undefined }),
81 });
82 if (!sent.ok) log(`(the deployment's ${state} was not reported to g1t: ${sent.status})`);
83 };
84 await status("in_progress");
85 log(`Reported to g1t as deployment ${id} of ${remote.repo}.`);
86 return async (ok) => {
87 try {
88 await status(ok ? "success" : "failure");
89 } catch (error) {
90 log(`(the deployment's result was not reported to g1t: ${error.message ?? error})`);
91 }
92 };
93 } catch (error) {
94 log(`(the deployment was not reported to g1t: ${error.message ?? error})`);
95 return null;
96 }
97}