Skip to content
150 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 { isManagedWorkflow, 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/** Why a token that cannot write deployments is refused, where nothing lets it through. */
30export const NEEDS_WRITE = "The job's token needs `deployments: write` in the workflow's or job's `permissions:`.";
31
32/**
33 * The run and job a caller's token was made for, if it is a workflow job's
34 * token for `repo`; otherwise why not. `writes` says whether it may write
35 * deployments: one that may not is let through only by `readOnlyAllowed`.
36 */
37export function jobOf(
38 actor: User | null | undefined,
39 repo: RepoPath,
40): { runId: string; jobId: string; writes: boolean } | { error: string } {
41 const token = actor?.token;
42 if (!token?.job?.run_id || !token.job.job_id) {
43 return { error: "Only a g1t Actions job's token (G1T_TOKEN) can open a g1t.page build." };
44 }
45 const own = `${repo.namespace}/${repo.name}`.toLowerCase();
46 if (!token.repo || token.repo.toLowerCase() !== own) {
47 return { error: `This job's token is for ${token.repo ?? "another repository"}, not ${repo.namespace}/${repo.name}.` };
48 }
49 return { runId: token.job.run_id, jobId: token.job.job_id, writes: tokenAllows(token.scopes, "deployments:write") };
50}
51
52/**
53 * Whether a job whose token may not write deployments may still open a
54 * build: only a preview, from the deploy workflow g1t manages for the
55 * project (a run of `managed/…`). A pull request from a fork runs with a
56 * read-only token, as on GitHub, and still gets the preview g1t.page has
57 * always built for it, without the project's build secrets.
58 */
59export function readOnlyAllowed(run: { path?: string | null }, target: RunTarget): boolean {
60 return isManagedWorkflow(run.path ?? "") && "kind" in target && target.kind !== "production";
61}
62
63/**
64 * What a run builds. A run for a pull request builds that pull request's
65 * preview; a run on the project's production branch (a push, a dispatch
66 * or a schedule there) builds production; a run on any other branch
67 * builds that branch's preview. A tag deploys nothing: give the branch.
68 */
69export function targetOf(run: Pick<RunFacts, "event" | "ref" | "pull">, productionBranch: string): RunTarget {
70 if (run.pull != null && run.event.startsWith("pull_request")) return { kind: "pull", number: run.pull };
71 const ref = run.ref.trim();
72 const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref.startsWith("refs/") ? null : ref;
73 if (!branch) {
74 return { error: `This run is for ${ref || "no ref"}: g1t.page deploys branches and pull requests. Run the workflow on a branch.` };
75 }
76 return branch === productionBranch ? { kind: "production" } : { kind: "branch", branch };
77}
78
79/**
80 * Whether a run's build gets the project's build secrets: never for a
81 * pull request from a fork, one whose author could not push here, or a
82 * run that had to be approved to start. A run on a branch of the
83 * repository is trusted: only people who can push make one.
84 */
85export function runTrusted(input: {
86 approval: { state: string } | null | undefined;
87 pull: { fork?: RepoPath | null } | null;
88 /** Whether whoever the pull request is for can push to the repository. */
89 insider: boolean;
90}): boolean {
91 if (input.approval) return false;
92 if (input.pull) return !input.pull.fork && input.insider;
93 return true;
94}
95
96/** The commit to build: the one given (a whole or abbreviated hex id), else the run's. */
97export function commitOf(given: unknown, run: Pick<RunFacts, "sha">): string | { error: string } {
98 if (given == null || given === "") return run.sha;
99 if (typeof given !== "string" || !/^[0-9a-f]{7,64}$/i.test(given.trim())) {
100 return { error: "`sha` is a commit's id, in hex." };
101 }
102 return given.trim().toLowerCase();
103}
104
105/**
106 * The project to build: the one named, or the repository's one project
107 * with Deployments on when none is named.
108 */
109export function projectNamed<P extends { slug: string }>(
110 projects: P[],
111 enabled: (project: P) => boolean,
112 slug: string | null | undefined,
113): P | { error: string; code: "not_found" | "invalid" | "conflict" } {
114 const wanted = typeof slug === "string" ? slug.trim().toLowerCase() : "";
115 if (wanted) {
116 return projects.find((p) => p.slug.toLowerCase() === wanted) ?? { error: `This repository has no project ${wanted}.`, code: "not_found" };
117 }
118 const on = projects.filter(enabled);
119 if (on.length === 1) return on[0];
120 if (on.length === 0) return { error: "No project of this repository has Deployments on.", code: "conflict" };
121 return { error: `Name the project to build: ${on.map((p) => p.slug).join(", ")}.`, code: "invalid" };
122}
123
124/**
125 * What `start_from_workflow` answers, in `snake_case` as the API sends it:
126 * the build's id and token for `g1t-runner deploy`, what it is, where it
127 * will be served, and what the project gives its build.
128 */
129export type WorkflowBuild = {
130 deploy_id: string;
131 /** For this build alone, until it finishes: `DEPLOY_TOKEN`. */
132 token: string;
133 kind: "production" | "preview";
134 project: string;
135 branch: string | null;
136 number: number | null;
137 commit: string;
138 /** The app's address on g1t.page. */
139 url: string;
140 /** Whether the build got the project's build secrets. */
141 trusted: boolean;
142 /** The project's settings, for a build that names none of its own. */
143 root_dir: string;
144 build_command: string | null;
145 output_dir: string | null;
146 /** The variables builds of this kind get. */
147 build_env: Record<string, string>;
148 /** The secrets builds of this kind get; empty for an untrusted run. */
149 build_secrets: Record<string, string>;
150};