| 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 | |
| 11 | import { 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. */ |
| 14 | export 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). */ |
| 23 | export 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. */ |
| 30 | export 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 | */ |
| 37 | export 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 | */ |
| 59 | export 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 | */ |
| 69 | export 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 | */ |
| 85 | export 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. */ |
| 97 | export 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 | */ |
| 109 | export 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 | */ |
| 129 | export 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 | }; |