Skip to content

g1t/scripts/deploy/report.mjs

106 lines4,075 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
39/** The branch checked out, or null when detached. */
40function currentBranch() {
41 try {
42 return execFileSync("git", ["branch", "--show-current"], { cwd: ROOT, encoding: "utf8" }).trim() || null;
43 } catch {
44 return null;
45 }
46}
47
48function remoteUrls() {
49 try {
50 const names = execFileSync("git", ["remote"], { cwd: ROOT, encoding: "utf8" }).split("\n").filter(Boolean);
51 return names.map((name) => execFileSync("git", ["remote", "get-url", name], { cwd: ROOT, encoding: "utf8" }).trim());
52 } catch {
53 return [];
54 }
55}
56
57/**
58 * Starts a production deployment for `head` and returns a function that
59 * settles it (true: success), or null when nothing is reported.
60 */
61export async function reportDeployment({ head, subject, units, log, fetchImpl = fetch, env = process.env }) {
62 if (inWorkflow(env)) return null;
63 const token = deployToken(env);
64 const remote = g1tRemote(remoteUrls());
65 if (!token || !remote) return null;
66 const headers = { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "g1t-deploy" };
67 const base = `${remote.api}/repos/${remote.repo}/deployments`;
68 try {
69 const made = await fetchImpl(base, {
70 method: "POST",
71 headers,
72 body: JSON.stringify({
73 ref: currentBranch() ?? head,
74 sha: head,
75 environment: "production",
76 production_environment: true,
77 description: `${subject} (${units.join(", ")}), deployed by hand`,
78 }),
79 });
80 if (!made.ok) {
81 log(`(the deployment was not reported to g1t: ${made.status})`);
82 return null;
83 }
84 const { id } = await made.json();
85 const status = async (state) => {
86 const sent = await fetchImpl(`${base}/${id}/statuses`, {
87 method: "POST",
88 headers,
89 body: JSON.stringify({ state, environment_url: PRODUCTION_URL, description: state === "in_progress" ? "Deploying" : undefined }),
90 });
91 if (!sent.ok) log(`(the deployment's ${state} was not reported to g1t: ${sent.status})`);
92 };
93 await status("in_progress");
94 log(`Reported to g1t as deployment ${id} of ${remote.repo}.`);
95 return async (ok) => {
96 try {
97 await status(ok ? "success" : "failure");
98 } catch (error) {
99 log(`(the deployment's result was not reported to g1t: ${error.message ?? error})`);
100 }
101 };
102 } catch (error) {
103 log(`(the deployment was not reported to g1t: ${error.message ?? error})`);
104 return null;
105 }
106}