Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

2 commits

5 files+143−20/5 viewed
+8−2
6060 type PipelineStage,
6161 PIPELINE,
6262 TIME,
63+ actorIds,
6364 ageBuckets,
6465 eventItem,
6566 firstPassRate,
6667 groupActivity,
68+ nameActor,
6769 passRate,
6870 pipelineStage,
6971 queuedNumbers,
8789 } from "../../lib/project-kind";
8890 import { AboutEditor, KindMenu, LinkList } from "../../components/project-about";
8991 import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns } from "../../components/project-head";
90−import { actions, agents, deployments, events as eventLog, packages, projects, repos, work } from "../../lib/services.server";
92+import { actions, agents, deployments, events as eventLog, identity, packages, projects, repos, work } from "../../lib/services.server";
9193 import { madeByG1t } from "../../lib/opened-by";
9294 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
9395 import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server";
323325
324326 // --- Activity and health -------------------------------------------------------
325327 const eventList: G1tEvent[] = recent ?? [];
326− const items = eventList
328+ const logged = eventList
327329 .map((event) => eventItem(event, path))
328330 .filter((item): item is ActivityItem => item != null);
331+ // The log names people by account id: their usernames, in one lookup.
332+ const ids = actorIds(logged.map((item) => item.actor));
333+ const names = ids.length > 0 ? await soft(identity.usernames(ids)) : {};
334+ const items = logged.map((item) => ({ ...item, actor: nameActor(item.actor, names) }));
329335 const checkEvents = eventList.flatMap((event) =>
330336 event.type === "checks.completed" ? [{ repo: "", number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
331337 );
+19−0
530530 that does not exist yet may be refused; deploy that service first with
531531 `--only`.
532532
533+## Deployments on g1t
534+
535+Every deploy shows on the repository's Deployments page, so its production
536+card says which commit runs.
537+
538+- **From the workflow**, the deploy jobs name `environment: {name:
539+ production, url: https://g1t.sh}`, and g1t Actions records one production
540+ deployment per run.
541+- **By hand**, `deploy` reports one itself: in progress once migrations are
542+ in, then success or failure. It needs a g1t token with `deployments:write`
543+ in `G1T_DEPLOY_TOKEN` or the file `.credentials/g1t-deploy-token`; without
544+ one, or from a dirty tree or a dry run, it sends nothing. It finds the
545+ repository from the git remote on g1t.sh. A report that fails is one line
546+ in the log and never fails the deploy.
547+
548+When CI cannot finish a deploy, for example a runner image that must be
549+built (hosted runners have no Docker), deploy from a machine with Docker.
550+The report records it, and production shows the commit that really runs.
551+
533552 ## Rolling back
534553
535554 - **One unit, at once:** `npx wrangler rollback` in its folder (or
+5−0
5959 writeDeployConfig,
6060 } from "./deploy/image.mjs";
6161 import { decide, git, planJson, pool, table } from "./deploy/plan.mjs";
62+import { reportDeployment } from "./deploy/report.mjs";
6263 import { ROOT, byStage, codeStages, findWranglerConfigs, npmCiArgs, npmWorkspace, pick, problems, resolvedStack } from "./deploy/stack.mjs";
6364
6465 const USAGE = "usage: node scripts/deploy.mjs plan|deploy|build|migrate|manifest|doctor|install|build-base|image [--all] [--only a,b] [--skip a,b] [--force] [--rollback] [--concurrency N] [--stage S] [--json]";
409410 if (touched.some((u) => u.kind === "rust-worker")) ensureWorkerBuild();
410411 const docker = touched.some((u) => u.image) ? await dockerAvailable() : false;
411412 const context = { head, subject: git.subject(), dirty, dryRun, docker, rebuildImage: opts.rebuildImage, rebuildBase: opts.rebuildBase };
413+ // A deploy run by hand shows on the repository's Deployments page; a
414+ // dirty tree is not a commit anyone can look at, so it is left out.
415+ const settle = dryRun || dirty ? null : await reportDeployment({ head, subject: context.subject, units: touched.map((u) => u.id), log });
412416
413417 let failed = false;
414418 for (const { stage, units } of byStage(stack, touched)) {
422426 failed = shipped.some((r) => !r.ok);
423427 }
424428 summary(results);
429+ await settle?.(!failed);
425430 console.log(`\n${failed ? "Failed" : dryRun ? "Built" : "Deployed"} in ${seconds(Date.now() - started)}.`);
426431 return !failed;
427432 }
+14−0
577577 assert.equal(meant[0].deploy, true);
578578 assert.match(meant[0].reason, /rolling back/);
579579 });
580+
581+test("a deploy by hand reports to the repository on g1t, and a workflow's does not", async () => {
582+ const { g1tRemote, inWorkflow, deployToken, reportDeployment } = await import("./report.mjs");
583+ assert.deepEqual(g1tRemote(["git@github.com:flagon-io/g1t.git", "https://g1t.sh/flagon-io/g1t.git"]), { api: "https://api.g1t.sh", repo: "flagon-io/g1t" });
584+ assert.equal(g1tRemote(["git@github.com:flagon-io/g1t.git"]), null);
585+ assert.equal(inWorkflow({ GITHUB_ACTIONS: "true" }), true);
586+ assert.equal(inWorkflow({}), false);
587+ assert.equal(deployToken({ G1T_DEPLOY_TOKEN: " g1t_x \n" }, "/nowhere"), "g1t_x");
588+ assert.equal(deployToken({}, "/nowhere"), null);
589+ let called = false;
590+ const settle = await reportDeployment({ head: "abc", subject: "s", units: ["web"], log: () => {}, env: { GITHUB_ACTIONS: "true", G1T_DEPLOY_TOKEN: "t" }, fetchImpl: () => { called = true; } });
591+ assert.equal(settle, null);
592+ assert.equal(called, false);
593+});
+97−0
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+
9+import { execFileSync } from "node:child_process";
10+import { existsSync, readFileSync } from "node:fs";
11+import { join } from "node:path";
12+
13+import { ROOT } from "./stack.mjs";
14+
15+/** Where production answers, as the Deployments page links it. */
16+const PRODUCTION_URL = process.env.G1T_DEPLOY_URL || "https://g1t.sh";
17+
18+/** The token, or null when there is none to use. */
19+export 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. */
26+export 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. */
31+export 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+function 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+ */
52+export 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+}