Skip to content
135 linesCodeBlameRaw
1/**
2 * Builds opened from a g1t Actions job (`start_from_workflow`): who may
3 * open one, what the run builds, and whether it is trusted with the
4 * project's build secrets. Pure, so it is tested apart from the service.
5 *
6 * The job builds in its own checkout and pays for its own time; the
7 * service only opens the build (its row, its token, the commit's status
8 * and what the project's settings give it) and puts what the job sends.
9 */
10
11import { tokenAllows, type RepoPath, type User } from "@g1t/contracts";
12
13/** The run a job token was made for, as far as these rules read it. */
14export type RunFacts = {
15 event: string;
16 ref: string;
17 sha: string;
18 pull: number | null;
19 status: string;
20};
21
22/** What a run deploys: production, a branch's preview, or a pull request's (its branch looked up). */
23export type RunTarget =
24 | { kind: "production" }
25 | { kind: "branch"; branch: string }
26 | { kind: "pull"; number: number }
27 | { error: string };
28
29/**
30 * The run and job a caller's token was made for, if it is a workflow job's
31 * token for `repo` that may write deployments; otherwise why not.
32 */
33export function jobOf(actor: User | null | undefined, repo: RepoPath): { runId: string; jobId: string } | { error: string } {
34 const token = actor?.token;
35 if (!token?.job?.run_id || !token.job.job_id) {
36 return { error: "Only a g1t Actions job's token (G1T_TOKEN) can open a g1t.page build." };
37 }
38 const own = `${repo.namespace}/${repo.name}`.toLowerCase();
39 if (!token.repo || token.repo.toLowerCase() !== own) {
40 return { error: `This job's token is for ${token.repo ?? "another repository"}, not ${repo.namespace}/${repo.name}.` };
41 }
42 if (!tokenAllows(token.scopes, "deployments:write")) {
43 return { error: "The job's token needs `deployments: write` in the workflow's or job's `permissions:`." };
44 }
45 return { runId: token.job.run_id, jobId: token.job.job_id };
46}
47
48/**
49 * What a run builds. A run for a pull request builds that pull request's
50 * preview; a run on the project's production branch (a push, a dispatch
51 * or a schedule there) builds production; a run on any other branch
52 * builds that branch's preview. A tag deploys nothing: give the branch.
53 */
54export function targetOf(run: Pick<RunFacts, "event" | "ref" | "pull">, productionBranch: string): RunTarget {
55 if (run.pull != null && run.event.startsWith("pull_request")) return { kind: "pull", number: run.pull };
56 const ref = run.ref.trim();
57 const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref.startsWith("refs/") ? null : ref;
58 if (!branch) {
59 return { error: `This run is for ${ref || "no ref"}: g1t.page deploys branches and pull requests. Run the workflow on a branch.` };
60 }
61 return branch === productionBranch ? { kind: "production" } : { kind: "branch", branch };
62}
63
64/**
65 * Whether a run's build gets the project's build secrets: never for a
66 * pull request from a fork, one whose author could not push here, or a
67 * run that had to be approved to start. A run on a branch of the
68 * repository is trusted: only people who can push make one.
69 */
70export function runTrusted(input: {
71 approval: { state: string } | null | undefined;
72 pull: { fork?: RepoPath | null } | null;
73 /** Whether whoever the pull request is for can push to the repository. */
74 insider: boolean;
75}): boolean {
76 if (input.approval) return false;
77 if (input.pull) return !input.pull.fork && input.insider;
78 return true;
79}
80
81/** The commit to build: the one given (a whole or abbreviated hex id), else the run's. */
82export function commitOf(given: unknown, run: Pick<RunFacts, "sha">): string | { error: string } {
83 if (given == null || given === "") return run.sha;
84 if (typeof given !== "string" || !/^[0-9a-f]{7,64}$/i.test(given.trim())) {
85 return { error: "`sha` is a commit's id, in hex." };
86 }
87 return given.trim().toLowerCase();
88}
89
90/**
91 * The project to build: the one named, or the repository's one project
92 * with Deployments on when none is named.
93 */
94export function projectNamed<P extends { slug: string }>(
95 projects: P[],
96 enabled: (project: P) => boolean,
97 slug: string | null | undefined,
98): P | { error: string; code: "not_found" | "invalid" | "conflict" } {
99 const wanted = typeof slug === "string" ? slug.trim().toLowerCase() : "";
100 if (wanted) {
101 return projects.find((p) => p.slug.toLowerCase() === wanted) ?? { error: `This repository has no project ${wanted}.`, code: "not_found" };
102 }
103 const on = projects.filter(enabled);
104 if (on.length === 1) return on[0];
105 if (on.length === 0) return { error: "No project of this repository has Deployments on.", code: "conflict" };
106 return { error: `Name the project to build: ${on.map((p) => p.slug).join(", ")}.`, code: "invalid" };
107}
108
109/**
110 * What `start_from_workflow` answers, in `snake_case` as the API sends it:
111 * the build's id and token for `g1t-runner deploy`, what it is, where it
112 * will be served, and what the project gives its build.
113 */
114export type WorkflowBuild = {
115 deploy_id: string;
116 /** For this build alone, until it finishes: `DEPLOY_TOKEN`. */
117 token: string;
118 kind: "production" | "preview";
119 project: string;
120 branch: string | null;
121 number: number | null;
122 commit: string;
123 /** The app's address on g1t.page. */
124 url: string;
125 /** Whether the build got the project's build secrets. */
126 trusted: boolean;
127 /** The project's settings, for a build that names none of its own. */
128 root_dir: string;
129 build_command: string | null;
130 output_dir: string | null;
131 /** The variables builds of this kind get. */
132 build_env: Record<string, string>;
133 /** The secrets builds of this kind get; empty for an untrusted run. */
134 build_secrets: Record<string, string>;
135};