A g1t Actions job can now open a g1t.page build of its repository's project and build it in its own checkout: the deployments service's start_from_workflow takes the job's own token, which needs deployments: write and reaches only that repository, looks the run up with the actions service while the job is still running, and builds production for a run on the project's production branch, a pull request's preview for a pull request's run (pr-<n> for one from a fork), or a branch's preview for a run on any other branch. A pull request from a fork or from someone who cannot push, and a run that had to be approved, get no build secrets. The plan and the spend limit are checked as for any build, the commit's g1t / deploy status and the build's row are set as they are now, and nothing is reserved or charged for the build's seconds, which the job already pays for as Actions time; the build reports with its own token as a sandbox's does. Opening a build is shared with the sandbox path, which now only adds the reservation and the runner's start.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
5 files+732−620/5 viewed
| 1 | + | -- A build a g1t Actions job runs in its own checkout (`start_from_workflow`, | |
| 2 | + | -- `g1t-runner deploy`): the run and job that opened it. The job pays for | |
| 3 | + | -- its own time, so such a build's seconds are not charged again here. | |
| 4 | + | -- Null for builds g1t's sandboxes ran. | |
| 5 | + | ALTER TABLE deployments ADD COLUMN run_id TEXT; | |
| 6 | + | ALTER TABLE deployments ADD COLUMN job_id TEXT; |
| 12 | 12 | * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by | |
| 13 | 13 | * hostname. | |
| 14 | 14 | * | |
| 15 | + | * A g1t Actions job can open a build too (`startFromWorkflow`, for | |
| 16 | + | * `POST /repos/{owner}/{repo}/deployments/builds` and the `g1t/deploy` | |
| 17 | + | * action) and build it in its own checkout with `g1t-runner deploy`; it | |
| 18 | + | * reports the same way, and its time is the job's, not a sandbox's. | |
| 19 | + | * | |
| 15 | 20 | * Nothing here is free, and nothing is capped. On the plan, every build | |
| 16 | 21 | * second is charged as it happens; requests, CPU time and custom domains | |
| 17 | 22 | * are charged from the first once the month is over, all at cost plus the | |
| ⋯ | |||
| 32 | 37 | DEPLOYMENT_COSTS, | |
| 33 | 38 | SLUG_HOLD_DAYS, | |
| 34 | 39 | ComputeGate, | |
| 40 | + | actionsClient, | |
| 35 | 41 | allows, | |
| 36 | 42 | billingClient, | |
| 37 | 43 | can, | |
| ⋯ | |||
| 85 | 91 | import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries"; | |
| 86 | 92 | import { appHost, appUrl, label, uniqueLabel } from "./names"; | |
| 87 | 93 | import { RepoDeployments, sourceOf } from "./repo-deployments"; | |
| 94 | + | import { commitOf, jobOf, projectNamed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds"; | |
| 88 | 95 | ||
| 89 | 96 | type Env = { | |
| 90 | 97 | DB: D1Database; | |
| ⋯ | |||
| 219 | 226 | created_by: string; | |
| 220 | 227 | created_at: string; | |
| 221 | 228 | finished_at: string | null; | |
| 229 | + | /** Set for a build a g1t Actions job runs (migration 0011): the job pays for its time. */ | |
| 230 | + | run_id?: string | null; | |
| 231 | + | job_id?: string | null; | |
| 232 | + | }; | |
| 233 | + | ||
| 234 | + | /** What opening any build takes; see `open`. */ | |
| 235 | + | type OpenInput = { | |
| 236 | + | project: Project; | |
| 237 | + | kind: DeployKind; | |
| 238 | + | branch: string | null; | |
| 239 | + | number: number | null; | |
| 240 | + | commit: string; | |
| 241 | + | createdBy: string; | |
| 242 | + | settings: SettingsRow; | |
| 243 | + | /** A push, or work by a member or an agent; see `insider`. */ | |
| 244 | + | trusted: boolean; | |
| 245 | + | /** The g1t Actions job that builds it, for a build opened from a workflow. */ | |
| 246 | + | workflow?: { runId: string; jobId: string }; | |
| 222 | 247 | }; | |
| 223 | 248 | ||
| 249 | + | /** Why a build was not opened: the plan or the limit, or g1t itself. */ | |
| 250 | + | type Refusal = "payment_required" | "conflict"; | |
| 251 | + | ||
| 252 | + | /** A sandbox's reservation with billing; none for a workflow job's build. */ | |
| 253 | + | type Compute = { reservation: string | null; microsPerSecond: number; maxRunMinutes: number | null }; | |
| 254 | + | ||
| 255 | + | type Opened = | |
| 256 | + | | { refused: string; code: Refusal; id: string } | |
| 257 | + | | { | |
| 258 | + | refused: null; | |
| 259 | + | id: string; | |
| 260 | + | token: string; | |
| 261 | + | script: string; | |
| 262 | + | build: { secrets: Record<string, string>; variables: Record<string, string> }; | |
| 263 | + | compute: Compute; | |
| 264 | + | }; | |
| 265 | + | ||
| 224 | 266 | type AppRow = { | |
| 225 | 267 | script: string; | |
| 226 | 268 | project_id: string; | |
| ⋯ | |||
| 689 | 731 | // ---- Starting builds ----------------------------------------------- | |
| 690 | 732 | ||
| 691 | 733 | /** | |
| 692 | − | * Opens a deployment and starts its build. Skipped, with the reason | |
| 693 | − | * recorded, when the workspace's plan is off. | |
| 734 | + | * Opens a deployment and starts its build in a sandbox. Skipped, with the | |
| 735 | + | * reason recorded, when the workspace's plan is off. | |
| 694 | 736 | */ | |
| 695 | − | private async start(input: { | |
| 696 | − | project: Project; | |
| 697 | − | kind: DeployKind; | |
| 698 | − | branch: string | null; | |
| 699 | − | number: number | null; | |
| 700 | − | commit: string; | |
| 737 | + | private async start(input: OpenInput & { | |
| 738 | + | /** Where the build reads the commit from: the project, or a pull request's fork. */ | |
| 701 | 739 | source: RepoPath; | |
| 740 | + | /** Who reads it: the workspace, or whoever the pull request is for. */ | |
| 702 | 741 | reader: User; | |
| 703 | − | createdBy: string; | |
| 704 | − | settings: SettingsRow; | |
| 705 | − | /** A push, or work by a member or an agent; see `insider`. */ | |
| 706 | − | trusted: boolean; | |
| 707 | 742 | }): Promise<Result<Deployment>> { | |
| 708 | 743 | const { project } = input; | |
| 709 | 744 | const repo = repoOf(project); | |
| 745 | + | const opened = await this.open(input, { reserve: true }); | |
| 746 | + | if (opened.refused !== null) return ok(toDeployment((await this.deploymentRow(opened.id))!)); | |
| 747 | + | const { build, compute } = opened; | |
| 748 | + | const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", { | |
| 749 | + | method: "POST", | |
| 750 | + | headers: { "content-type": "application/json" }, | |
| 751 | + | body: JSON.stringify({ | |
| 752 | + | deployId: opened.id, | |
| 753 | + | token: opened.token, | |
| 754 | + | workspace: project.workspace, | |
| 755 | + | reservation: compute.reservation, | |
| 756 | + | microsPerSecond: compute.microsPerSecond, | |
| 757 | + | maxRunMinutes: compute.maxRunMinutes, | |
| 758 | + | actor: input.reader, | |
| 759 | + | source: input.source, | |
| 760 | + | // The project, whose guardrails the build runs under: a preview's | |
| 761 | + | // source is its pull request's working copy, not the project. | |
| 762 | + | repo: repo.path, | |
| 763 | + | repoId: repo.id, | |
| 764 | + | commit: input.commit, | |
| 765 | + | rootDir: project.source.rootDir, | |
| 766 | + | buildCommand: input.settings.build_command, | |
| 767 | + | outputDir: input.settings.output_dir, | |
| 768 | + | buildEnv: build.variables, | |
| 769 | + | buildSecrets: build.secrets, | |
| 770 | + | }), | |
| 771 | + | }); | |
| 772 | + | const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`); | |
| 773 | + | if (!started.ok) { | |
| 774 | + | // Never reached a sandbox: what was reserved is given back. | |
| 775 | + | if (compute.reservation) await gateFor(this.env.BILLING).settle(compute.reservation, 0); | |
| 776 | + | await this.finishFailed(opened.id, started.error.message, null, null); | |
| 777 | + | } | |
| 778 | + | return ok(toDeployment((await this.deploymentRow(opened.id))!)); | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | /** | |
| 782 | + | * What every build does before anything builds it: checks the plan and | |
| 783 | + | * the spend limit (and, for a sandbox, reserves its compute with | |
| 784 | + | * billing), records the deployment with a token for that build alone, | |
| 785 | + | * replaces older builds of the same app, sets the commit's status, and | |
| 786 | + | * reads what the project's secrets and variables give builds of its | |
| 787 | + | * kind. A refusal is the deployment's status, saying what to do; nothing | |
| 788 | + | * is resolved for a refused build. | |
| 789 | + | */ | |
| 790 | + | private async open(input: OpenInput, options: { reserve: boolean }): Promise<Opened> { | |
| 791 | + | const { project } = input; | |
| 792 | + | const repo = repoOf(project); | |
| 710 | 793 | const script = await this.scriptFor(project, input.branch); | |
| 711 | 794 | const id = newId("dpl"); | |
| 712 | 795 | const token = randomToken(); | |
| 713 | 796 | const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments"); | |
| 714 | 797 | const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace); | |
| 798 | + | const stopped = limit.ok && limit.value.state === "stopped"; | |
| 715 | 799 | const cloudflare = this.cloudflare; | |
| 716 | 800 | let refused = !plan.ok | |
| 717 | 801 | ? plan.error.message | |
| 718 | − | : limit.ok && limit.value.state === "stopped" | |
| 802 | + | : stopped | |
| 719 | 803 | ? (limit.value.message ?? "The workspace reached its usage limit.") | |
| 720 | 804 | : !cloudflare | |
| 721 | 805 | ? "Deployments are not set up on this g1t: it has no Cloudflare token." | |
| 722 | 806 | : null; | |
| 723 | − | // A build is compute: reserved with billing before it starts, and | |
| 724 | − | // settled by its sandbox when it stops. A refusal is the deployment's | |
| 725 | − | // status, saying what to do. | |
| 726 | − | const compute = gateFor(this.env.BILLING); | |
| 727 | − | let reservation: string | null = null; | |
| 728 | − | let microsPerSecond = 0; | |
| 729 | − | let maxRunMinutes: number | null = null; | |
| 730 | − | if (!refused) { | |
| 731 | − | const ent = await compute.entitlements(project.workspace); | |
| 732 | − | microsPerSecond = await compute.microsPerSecond(); | |
| 733 | − | maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null; | |
| 807 | + | let code: Refusal = plan.ok && !stopped ? "conflict" : "payment_required"; | |
| 808 | + | // A sandbox's build is compute: reserved with billing before it starts, | |
| 809 | + | // and settled by its sandbox when it stops. A workflow job's build runs | |
| 810 | + | // in the job, which pays for its own time. | |
| 811 | + | const compute: Compute = { reservation: null, microsPerSecond: 0, maxRunMinutes: null }; | |
| 812 | + | if (!refused && options.reserve) { | |
| 813 | + | const gate = gateFor(this.env.BILLING); | |
| 814 | + | const ent = await gate.entitlements(project.workspace); | |
| 815 | + | compute.microsPerSecond = await gate.microsPerSecond(); | |
| 816 | + | compute.maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null; | |
| 734 | 817 | const isPrivate = await reposClient(this.env.REPOS) | |
| 735 | 818 | .get(repo.path, null) | |
| 736 | 819 | .then((found) => !found.ok || found.value.isPrivate) | |
| 737 | 820 | .catch(() => true); | |
| 738 | − | const admitted = await compute.admit( | |
| 821 | + | const admitted = await gate.admit( | |
| 739 | 822 | { | |
| 740 | 823 | workspace: project.workspace, | |
| 741 | 824 | repo: repo.path, | |
| 742 | 825 | public: !isPrivate, | |
| 743 | 826 | kind: "deploy", | |
| 744 | − | estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, maxRunMinutes ?? BUILD_MINUTES), microsPerSecond), | |
| 827 | + | estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, compute.maxRunMinutes ?? BUILD_MINUTES), compute.microsPerSecond), | |
| 745 | 828 | }, | |
| 746 | 829 | ent, | |
| 747 | 830 | ); | |
| 748 | − | if (admitted.ok) reservation = admitted.reservation?.id ?? null; | |
| 749 | − | else refused = admitted.message; | |
| 831 | + | if (admitted.ok) compute.reservation = admitted.reservation?.id ?? null; | |
| 832 | + | else { | |
| 833 | + | refused = admitted.message; | |
| 834 | + | code = "payment_required"; | |
| 835 | + | } | |
| 750 | 836 | } | |
| 751 | 837 | await this.db | |
| 752 | 838 | .prepare( | |
| 753 | 839 | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, | |
| 754 | − | script, status, error, token_hash, trusted, created_by, created_at, finished_at) | |
| 755 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 840 | + | script, status, error, token_hash, trusted, created_by, created_at, finished_at, run_id, job_id) | |
| 841 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 756 | 842 | ) | |
| 757 | 843 | .bind( | |
| 758 | 844 | id, | |
| ⋯ | |||
| 773 | 859 | input.createdBy, | |
| 774 | 860 | now(), | |
| 775 | 861 | refused ? now() : null, | |
| 862 | + | input.workflow?.runId ?? null, | |
| 863 | + | input.workflow?.jobId ?? null, | |
| 776 | 864 | ) | |
| 777 | 865 | .run(); | |
| 778 | − | if (refused) return ok(toDeployment((await this.deploymentRow(id))!)); | |
| 866 | + | if (refused) return { refused, code, id }; | |
| 779 | 867 | // Older builds of the same app are replaced by this one. | |
| 780 | 868 | await this.db | |
| 781 | 869 | .prepare( | |
| ⋯ | |||
| 792 | 880 | input.kind, | |
| 793 | 881 | input.trusted, | |
| 794 | 882 | ); | |
| 795 | − | const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", { | |
| 796 | − | method: "POST", | |
| 797 | − | headers: { "content-type": "application/json" }, | |
| 798 | − | body: JSON.stringify({ | |
| 799 | − | deployId: id, | |
| 800 | − | token, | |
| 801 | − | workspace: project.workspace, | |
| 802 | − | reservation, | |
| 803 | − | microsPerSecond, | |
| 804 | − | maxRunMinutes, | |
| 805 | − | actor: input.reader, | |
| 806 | − | source: input.source, | |
| 807 | − | // The project, whose guardrails the build runs under: a preview's | |
| 808 | − | // source is its pull request's working copy, not the project. | |
| 809 | − | repo: repo.path, | |
| 810 | − | repoId: repo.id, | |
| 811 | − | commit: input.commit, | |
| 812 | − | rootDir: project.source.rootDir, | |
| 813 | − | buildCommand: input.settings.build_command, | |
| 814 | − | outputDir: input.settings.output_dir, | |
| 815 | − | buildEnv: build.variables, | |
| 816 | − | buildSecrets: build.secrets, | |
| 817 | − | }), | |
| 818 | − | }); | |
| 819 | − | const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`); | |
| 820 | − | if (!started.ok) { | |
| 821 | − | // Never reached a sandbox: what was reserved is given back. | |
| 822 | − | if (reservation) await compute.settle(reservation, 0); | |
| 823 | − | await this.finishFailed(id, started.error.message, null, null); | |
| 883 | + | return { refused: null, id, token, script, build, compute }; | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | /** | |
| 887 | + | * For a g1t Actions job (`POST /repos/{owner}/{repo}/deployments/builds`): | |
| 888 | + | * opens a g1t.page build of one of the repository's projects, which the | |
| 889 | + | * job builds in its own checkout with `g1t-runner deploy`. The caller is | |
| 890 | + | * the job's own token (`token.job`), which needs `deployments: write` and | |
| 891 | + | * reaches this repository only. The run decides what is built: a run on | |
| 892 | + | * the project's production branch builds production, a pull request's | |
| 893 | + | * run its preview, a run on another branch that branch's preview. A pull | |
| 894 | + | * request from a fork or from someone who cannot push here, and a run | |
| 895 | + | * that needed approval, get no build secrets. The job pays for its own | |
| 896 | + | * time, so nothing is reserved or charged here for the build; the plan | |
| 897 | + | * and the spend limit are checked as for any build. | |
| 898 | + | */ | |
| 899 | + | async startFromWorkflow(a: { actor: User; repo: RepoPath; project?: string | null; sha?: string | null }): Promise<Result<WorkflowBuild>> { | |
| 900 | + | const repoPath = { namespace: String(a.repo?.namespace ?? ""), name: String(a.repo?.name ?? "") }; | |
| 901 | + | const job = jobOf(a.actor, repoPath); | |
| 902 | + | if ("error" in job) return fail("forbidden", job.error); | |
| 903 | + | const actor = await this.workspaceActor(repoPath.namespace); | |
| 904 | + | if (!actor) return fail("not_found", "There is no such repository."); | |
| 905 | + | const found = await reposClient(this.env.REPOS).get(repoPath, actor); | |
| 906 | + | if (!found.ok) return fail("not_found", "There is no such repository."); | |
| 907 | + | const projects = await this.projects.byRepo(found.value.id); | |
| 908 | + | const rows = new Map(await Promise.all(projects.map(async (p) => [p.id, await this.settingsRow(p.id)] as const))); | |
| 909 | + | const live = (row: SettingsRow | null | undefined): row is SettingsRow => | |
| 910 | + | !!row?.enabled && !row.repo_deleted_at && !row.workspace_deleted_at; | |
| 911 | + | const chosen = projectNamed(projects, (p) => live(rows.get(p.id)), a.project); | |
| 912 | + | if ("error" in chosen) return fail(chosen.code, chosen.error); | |
| 913 | + | const project = chosen; | |
| 914 | + | if (project.source.kind !== "hosted") return fail("conflict", "Only projects hosted on g1t deploy so far."); | |
| 915 | + | if (!can(a.actor, repoRef(project), NEEDS.redeploy)) return fail("forbidden", needs(NEEDS.redeploy)); | |
| 916 | + | const settings = rows.get(project.id); | |
| 917 | + | if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`); | |
| 918 | + | const repo = repoOf(project); | |
| 919 | + | ||
| 920 | + | // The run and its job, as the actions service has them: still running. | |
| 921 | + | const detail = await actionsClient(this.env.ACTIONS).run(repo.path, actor, job.runId); | |
| 922 | + | if (!detail.ok) return fail("not_found", "The job's run was not found."); | |
| 923 | + | const { run } = detail.value; | |
| 924 | + | const running = detail.value.jobs.find((j) => j.id === job.jobId); | |
| 925 | + | if (run.status === "completed" || !running || running.status === "completed") { | |
| 926 | + | return fail("conflict", "The job has finished: a build is opened from a job while it runs."); | |
| 824 | 927 | } | |
| 825 | − | return ok(toDeployment((await this.deploymentRow(id))!)); | |
| 928 | + | const commit = commitOf(a.sha, run); | |
| 929 | + | if (typeof commit !== "string") return fail("invalid", commit.error); | |
| 930 | + | ||
| 931 | + | const target = targetOf(run, repo.defaultBranch); | |
| 932 | + | if ("error" in target) return fail("invalid", target.error); | |
| 933 | + | let kind: DeployKind = "production"; | |
| 934 | + | let branch: string | null = null; | |
| 935 | + | let number: number | null = null; | |
| 936 | + | let trusted = runTrusted({ approval: detail.value.approval, pull: null, insider: true }); | |
| 937 | + | if (target.kind === "pull") { | |
| 938 | + | const pull = await workClient(this.env.WORK).getPull(repo.path, target.number, actor); | |
| 939 | + | if (!pull.ok) return fail("not_found", `Pull request #${target.number} was not found.`); | |
| 940 | + | const open = pull.value.pull; | |
| 941 | + | if (open.status !== "open" && open.status !== "draft") { | |
| 942 | + | return fail("conflict", `Pull request #${target.number} is not open, so it has no preview.`); | |
| 943 | + | } | |
| 944 | + | kind = "preview"; | |
| 945 | + | // A pull request from a fork has no branch here (see `deployPreview`). | |
| 946 | + | branch = open.branch ?? `pr-${target.number}`; | |
| 947 | + | number = target.number; | |
| 948 | + | trusted = runTrusted({ | |
| 949 | + | approval: detail.value.approval, | |
| 950 | + | pull: open, | |
| 951 | + | insider: await this.insider(repo.path, workOwner(open), actor), | |
| 952 | + | }); | |
| 953 | + | } else if (target.kind === "branch") { | |
| 954 | + | kind = "preview"; | |
| 955 | + | branch = target.branch; | |
| 956 | + | } | |
| 957 | + | if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`); | |
| 958 | + | if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`); | |
| 959 | + | ||
| 960 | + | const opened = await this.open( | |
| 961 | + | { project, kind, branch, number, commit, createdBy: run.actor ?? "g1t", settings, trusted, workflow: job }, | |
| 962 | + | { reserve: false }, | |
| 963 | + | ); | |
| 964 | + | if (opened.refused !== null) return fail(opened.code, opened.refused); | |
| 965 | + | return ok({ | |
| 966 | + | deploy_id: opened.id, | |
| 967 | + | token: opened.token, | |
| 968 | + | kind, | |
| 969 | + | project: project.slug, | |
| 970 | + | branch, | |
| 971 | + | number, | |
| 972 | + | commit, | |
| 973 | + | url: appUrl(opened.script), | |
| 974 | + | trusted, | |
| 975 | + | root_dir: project.source.rootDir ?? "", | |
| 976 | + | build_command: settings.build_command, | |
| 977 | + | output_dir: settings.output_dir, | |
| 978 | + | build_env: opened.build.variables, | |
| 979 | + | build_secrets: opened.build.secrets, | |
| 980 | + | }); | |
| 826 | 981 | } | |
| 827 | 982 | ||
| 828 | 983 | private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> { | |
| ⋯ | |||
| 1128 | 1283 | .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason))); | |
| 1129 | 1284 | } | |
| 1130 | 1285 | ||
| 1131 | − | /** Each build is charged by the second, from the first, at the container price plus the margin. */ | |
| 1286 | + | /** | |
| 1287 | + | * Each build is charged by the second, from the first, at the container | |
| 1288 | + | * price plus the margin. A build a workflow job ran is not: the job's | |
| 1289 | + | * time is charged as Actions time. | |
| 1290 | + | */ | |
| 1132 | 1291 | private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> { | |
| 1292 | + | if (row.run_id) return; | |
| 1133 | 1293 | const costs = await this.costs(); | |
| 1134 | 1294 | const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond); | |
| 1135 | 1295 | if (cost <= 0) return; | |
| ⋯ | |||
| 2213 | 2373 | return service.get(args); | |
| 2214 | 2374 | case "redeploy": | |
| 2215 | 2375 | return service.redeploy(args); | |
| 2376 | + | // For the API: a g1t Actions job opening a build it runs itself. | |
| 2377 | + | case "start_from_workflow": | |
| 2378 | + | return service.startFromWorkflow(args); | |
| 2216 | 2379 | case "take_down": | |
| 2217 | 2380 | return service.takeDown(args); | |
| 2218 | 2381 | case "overview": | |
| 1 | + | /** | |
| 2 | + | * The whole service, on Node, opening builds for g1t Actions jobs | |
| 3 | + | * (`start_from_workflow`): a D1 made of node:sqlite with the service's | |
| 4 | + | * migrations, the other services as fakes that answer by method, and | |
| 5 | + | * Cloudflare's API as a recorded fetch. See moves.flow.test.ts. | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | import assert from "node:assert/strict"; | |
| 9 | + | import { readdirSync, readFileSync } from "node:fs"; | |
| 10 | + | import { registerHooks } from "node:module"; | |
| 11 | + | import { DatabaseSync } from "node:sqlite"; | |
| 12 | + | import { beforeEach, test } from "node:test"; | |
| 13 | + | ||
| 14 | + | registerHooks({ | |
| 15 | + | resolve(specifier, context, next) { | |
| 16 | + | try { | |
| 17 | + | return next(specifier, context); | |
| 18 | + | } catch (error) { | |
| 19 | + | if (specifier.startsWith(".")) return next(`${specifier}.ts`, context); | |
| 20 | + | throw error; | |
| 21 | + | } | |
| 22 | + | }, | |
| 23 | + | }); | |
| 24 | + | const { default: worker } = await import("./index.ts"); | |
| 25 | + | ||
| 26 | + | function d1(db: DatabaseSync) { | |
| 27 | + | const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v); | |
| 28 | + | const statement = (sql: string, params: unknown[] = []) => ({ | |
| 29 | + | sql, | |
| 30 | + | params, | |
| 31 | + | bind: (...args: unknown[]) => statement(sql, args.map(value)), | |
| 32 | + | async first<T>() { | |
| 33 | + | return (db.prepare(sql).get(...(params as never[])) as T) ?? null; | |
| 34 | + | }, | |
| 35 | + | async all<T>() { | |
| 36 | + | return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true }; | |
| 37 | + | }, | |
| 38 | + | async run() { | |
| 39 | + | const r = db.prepare(sql).run(...(params as never[])); | |
| 40 | + | return { success: true, meta: { changes: Number(r.changes) } }; | |
| 41 | + | }, | |
| 42 | + | }); | |
| 43 | + | return { | |
| 44 | + | prepare: (sql: string) => statement(sql), | |
| 45 | + | async batch(statements: ReturnType<typeof statement>[]) { | |
| 46 | + | db.exec("BEGIN"); | |
| 47 | + | try { | |
| 48 | + | const out = statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) })); | |
| 49 | + | db.exec("COMMIT"); | |
| 50 | + | return out; | |
| 51 | + | } catch (error) { | |
| 52 | + | db.exec("ROLLBACK"); | |
| 53 | + | throw error; | |
| 54 | + | } | |
| 55 | + | }, | |
| 56 | + | }; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | const REPO = "rep_web"; | |
| 60 | + | const PROJECT = "prj_web"; | |
| 61 | + | const SHA = "4b8d0f2a6c1e3579bd02468ace13579bdf02468a"; | |
| 62 | + | ||
| 63 | + | function world() { | |
| 64 | + | const sqlite = new DatabaseSync(":memory:"); | |
| 65 | + | const dir = new URL("../migrations/", import.meta.url); | |
| 66 | + | for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 67 | + | const state = { | |
| 68 | + | sqlite, | |
| 69 | + | run: { id: "run_1", event: "push", ref: "refs/heads/main", sha: SHA, pull: null as number | null, status: "in_progress", actor: "ada" }, | |
| 70 | + | jobStatus: "in_progress", | |
| 71 | + | approval: null as null | { state: string }, | |
| 72 | + | pull: { status: "open", headCommit: SHA, branch: "feature" as string | null, fork: null as null | { namespace: string; name: string } }, | |
| 73 | + | collaborator: "write", | |
| 74 | + | plan: true, | |
| 75 | + | /** What other services were asked, as `SERVICE method`. */ | |
| 76 | + | calls: [] as string[], | |
| 77 | + | /** What `resolve_settings` was asked for. */ | |
| 78 | + | resolved: [] as { environment: string; trusted: boolean }[], | |
| 79 | + | }; | |
| 80 | + | const project = () => ({ | |
| 81 | + | id: PROJECT, | |
| 82 | + | workspace: "acme", | |
| 83 | + | slug: "web", | |
| 84 | + | name: "web", | |
| 85 | + | primary: true, | |
| 86 | + | source: { kind: "hosted", repoId: REPO, repo: { namespace: "acme", name: "web" }, defaultBranch: "main", rootDir: "site" }, | |
| 87 | + | }); | |
| 88 | + | const ok = (value: unknown) => ({ ok: true, value }); | |
| 89 | + | const service = (name: string, answer: (method: string, args: any) => unknown) => ({ | |
| 90 | + | async fetch(input: string | Request, init?: RequestInit) { | |
| 91 | + | const url = typeof input === "string" ? input : input.url; | |
| 92 | + | const method = new URL(url).pathname.split("/").pop()!; | |
| 93 | + | state.calls.push(`${name} ${method}`); | |
| 94 | + | const args = init?.body ? JSON.parse(String(init.body)) : {}; | |
| 95 | + | return Response.json((await answer(method, args)) ?? ok(null)); | |
| 96 | + | }, | |
| 97 | + | }); | |
| 98 | + | const env = { | |
| 99 | + | DB: d1(sqlite), | |
| 100 | + | CLOUDFLARE_API_TOKEN: "test", | |
| 101 | + | CLOUDFLARE_ACCOUNT_ID: "acct", | |
| 102 | + | DISPATCH_NAMESPACE: "g1t-deployments", | |
| 103 | + | SITE: "https://g1t.sh", | |
| 104 | + | REPOS: service("REPOS", (method) => { | |
| 105 | + | if (method === "get") return ok({ id: REPO, namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" }); | |
| 106 | + | return ok([]); | |
| 107 | + | }), | |
| 108 | + | PROJECTS: service("PROJECTS", (method) => (method === "by_repo" ? [project()] : ok(project()))), | |
| 109 | + | IDENTITY: service("IDENTITY", (method, args) => { | |
| 110 | + | if (method === "get_workspace") return { id: `wsp_${args.slug}`, slug: args.slug }; | |
| 111 | + | if (method === "collaborator_permission") return ok({ role: state.collaborator }); | |
| 112 | + | return ok(null); | |
| 113 | + | }), | |
| 114 | + | BILLING: service("BILLING", (method) => { | |
| 115 | + | if (method === "has_feature") return state.plan ? ok(true) : { ok: false, error: { code: "payment_required", message: "Deployments need the Team plan." } }; | |
| 116 | + | if (method === "check_limit") return ok({ state: "ok" }); | |
| 117 | + | if (method === "prices") return { prices: [] }; | |
| 118 | + | return ok(null); | |
| 119 | + | }), | |
| 120 | + | WORK: service("WORK", (method) => { | |
| 121 | + | if (method === "get_pull") return ok({ pull: { ...state.pull, number: 9, author: { username: "eve", kind: "user" } } }); | |
| 122 | + | return ok(null); | |
| 123 | + | }), | |
| 124 | + | ACTIONS: service("ACTIONS", (method, args) => { | |
| 125 | + | if (method === "run") { | |
| 126 | + | return ok({ run: state.run, jobs: [{ id: "job_1", status: state.jobStatus }], notes: [], approval: state.approval }); | |
| 127 | + | } | |
| 128 | + | if (method === "resolve_settings") { | |
| 129 | + | state.resolved.push({ environment: args.environment, trusted: args.trusted }); | |
| 130 | + | return { secrets: { API_KEY: "s3cret-value" }, variables: { PUBLIC_URL: "https://example.com" } }; | |
| 131 | + | } | |
| 132 | + | return ok(null); | |
| 133 | + | }), | |
| 134 | + | RUNNER: service("RUNNER", () => ok(true)), | |
| 135 | + | }; | |
| 136 | + | return { state, env }; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | let w: ReturnType<typeof world>; | |
| 140 | + | const realFetch = globalThis.fetch; | |
| 141 | + | ||
| 142 | + | beforeEach(() => { | |
| 143 | + | w = world(); | |
| 144 | + | globalThis.fetch = (async (input: string | Request) => { | |
| 145 | + | const url = new URL(typeof input === "string" ? input : input.url); | |
| 146 | + | w.state.calls.push(`CLOUDFLARE ${url.pathname}`); | |
| 147 | + | if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } }); | |
| 148 | + | return Response.json({ success: true, result: { jwt: "done", buckets: [] }, result_info: { total_pages: 1 } }); | |
| 149 | + | }) as typeof fetch; | |
| 150 | + | w.state.sqlite | |
| 151 | + | .prepare( | |
| 152 | + | `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at) | |
| 153 | + | VALUES (?, ?, 'acme', 'web', 1, 1, 1, 7, '2026-10-05T00:00:00Z')`, | |
| 154 | + | ) | |
| 155 | + | .run(PROJECT, REPO); | |
| 156 | + | }); | |
| 157 | + | ||
| 158 | + | process.on("exit", () => { | |
| 159 | + | globalThis.fetch = realFetch; | |
| 160 | + | }); | |
| 161 | + | ||
| 162 | + | const jobActor = (scopes: string[] = ["contents:read", "deployments:write"]) => ({ | |
| 163 | + | id: "wsp_acme", | |
| 164 | + | username: "acme", | |
| 165 | + | kind: "workspace", | |
| 166 | + | verified: true, | |
| 167 | + | workspaces: [{ slug: "acme", role: "member" }], | |
| 168 | + | token: { token_id: "tok_job", repo: "acme/web", job: { run_id: "run_1", job_id: "job_1" }, scopes }, | |
| 169 | + | }); | |
| 170 | + | ||
| 171 | + | async function post(path: string, body: unknown): Promise<any> { | |
| 172 | + | const response = await worker.fetch( | |
| 173 | + | new Request(`https://deployments${path}`, { method: "POST", body: JSON.stringify(body) }), | |
| 174 | + | w.env as never, | |
| 175 | + | {} as never, | |
| 176 | + | ); | |
| 177 | + | return response.json(); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | const open = (body: Record<string, unknown> = {}) => | |
| 181 | + | post("/rpc/start_from_workflow", { actor: jobActor(), repo: { namespace: "acme", name: "web" }, ...body }); | |
| 182 | + | ||
| 183 | + | const rows = (sql: string, ...params: unknown[]) => | |
| 184 | + | (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row })); | |
| 185 | + | ||
| 186 | + | test("a push to the production branch opens a production build the job runs, with no sandbox and nothing reserved", async () => { | |
| 187 | + | const answer = await open(); | |
| 188 | + | assert.equal(answer.ok, true, JSON.stringify(answer)); | |
| 189 | + | const build = answer.value; | |
| 190 | + | assert.equal(build.kind, "production"); | |
| 191 | + | assert.equal(build.project, "web"); | |
| 192 | + | assert.equal(build.commit, SHA); | |
| 193 | + | assert.equal(build.url, "https://web-acme.g1t.page"); | |
| 194 | + | assert.equal(build.trusted, true); | |
| 195 | + | assert.equal(build.root_dir, "site"); | |
| 196 | + | assert.deepEqual(build.build_secrets, { API_KEY: "s3cret-value" }); | |
| 197 | + | assert.deepEqual(build.build_env, { PUBLIC_URL: "https://example.com" }); | |
| 198 | + | assert.match(build.token, /^[0-9a-f]{64}$/); | |
| 199 | + | assert.deepEqual(rows("SELECT kind, status, run_id, job_id, created_by, trusted FROM deployments"), [ | |
| 200 | + | { kind: "production", status: "queued", run_id: "run_1", job_id: "job_1", created_by: "ada", trusted: 1 }, | |
| 201 | + | ]); | |
| 202 | + | // The job runs it: no sandbox, and no compute reserved for it. | |
| 203 | + | assert.ok(!w.state.calls.includes("RUNNER start_deploy")); | |
| 204 | + | assert.ok(!w.state.calls.some((call) => call === "BILLING reserve" || call === "BILLING entitlements")); | |
| 205 | + | // The commit's status says it is building. | |
| 206 | + | assert.ok(w.state.calls.includes("WORK set_commit_status")); | |
| 207 | + | }); | |
| 208 | + | ||
| 209 | + | test("the job reports with the build's token, and its seconds are not charged again", async () => { | |
| 210 | + | const { value: build } = await open(); | |
| 211 | + | assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, true); | |
| 212 | + | const finished = await post(`/jobs/${build.deploy_id}/finish`, { | |
| 213 | + | token: build.token, | |
| 214 | + | worker: { compatibilityDate: "2026-09-26", vars: {}, notFoundHandling: "single-page-application" }, | |
| 215 | + | completionJwt: "done", | |
| 216 | + | buildSeconds: 42, | |
| 217 | + | detected: "static", | |
| 218 | + | }); | |
| 219 | + | assert.equal(finished.ok, true, JSON.stringify(finished)); | |
| 220 | + | assert.equal(rows("SELECT error FROM deployments")[0].error, null); | |
| 221 | + | assert.deepEqual(rows("SELECT status, build_seconds, detected FROM deployments"), [{ status: "ready", build_seconds: 42, detected: "static" }]); | |
| 222 | + | assert.deepEqual(rows("SELECT script, kind FROM apps"), [{ script: "web-acme", kind: "production" }]); | |
| 223 | + | assert.ok(!w.state.calls.includes("BILLING charge_feature")); | |
| 224 | + | assert.deepEqual(rows("SELECT build_seconds FROM meters WHERE build_seconds > 0"), []); | |
| 225 | + | // The token was for that build alone, until it finished. | |
| 226 | + | assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, false); | |
| 227 | + | }); | |
| 228 | + | ||
| 229 | + | test("another branch builds its preview; a pull request's run builds the pull request's", async () => { | |
| 230 | + | w.state.run = { ...w.state.run, ref: "refs/heads/feature/x" }; | |
| 231 | + | const branch = (await open()).value; | |
| 232 | + | assert.deepEqual([branch.kind, branch.branch, branch.number], ["preview", "feature/x", null]); | |
| 233 | + | w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }; | |
| 234 | + | const pull = (await open()).value; | |
| 235 | + | assert.deepEqual([pull.kind, pull.branch, pull.number, pull.trusted], ["preview", "feature", 9, true]); | |
| 236 | + | assert.deepEqual(w.state.resolved.map((r) => r.environment), ["preview", "preview"]); | |
| 237 | + | }); | |
| 238 | + | ||
| 239 | + | test("a pull request from a fork, or one that had to be approved, gets no build secrets", async () => { | |
| 240 | + | w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }; | |
| 241 | + | w.state.pull = { ...w.state.pull, branch: null, fork: { namespace: "eve", name: "web" } }; | |
| 242 | + | const fork = (await open()).value; | |
| 243 | + | assert.deepEqual([fork.branch, fork.trusted, fork.build_secrets], ["pr-9", false, {}]); | |
| 244 | + | w.state.pull = { ...w.state.pull, branch: "feature", fork: null }; | |
| 245 | + | w.state.approval = { state: "approved" }; | |
| 246 | + | const approved = (await open()).value; | |
| 247 | + | assert.deepEqual([approved.trusted, approved.build_secrets], [false, {}]); | |
| 248 | + | assert.deepEqual(w.state.resolved.map((r) => r.trusted), [false, false]); | |
| 249 | + | }); | |
| 250 | + | ||
| 251 | + | test("it is refused without a job's write token, after the job, without the plan, or with previews off", async () => { | |
| 252 | + | const refused = async (body: Record<string, unknown>) => (await open(body)).error?.code; | |
| 253 | + | assert.equal(await refused({ actor: jobActor(["deployments:read"]) }), "forbidden"); | |
| 254 | + | assert.equal(await refused({ actor: { ...jobActor(), token: { token_id: "tok_person", scopes: null } } }), "forbidden"); | |
| 255 | + | assert.equal(await refused({ repo: { namespace: "acme", name: "api" } }), "forbidden"); | |
| 256 | + | assert.equal(await refused({ project: "docs" }), "not_found"); | |
| 257 | + | w.state.jobStatus = "completed"; | |
| 258 | + | assert.equal(await refused({}), "conflict"); | |
| 259 | + | w.state.jobStatus = "in_progress"; | |
| 260 | + | w.state.run = { ...w.state.run, ref: "refs/tags/v1.0.0" }; | |
| 261 | + | assert.equal(await refused({}), "invalid"); | |
| 262 | + | w.state.run = { ...w.state.run, ref: "refs/heads/main" }; | |
| 263 | + | w.state.plan = false; | |
| 264 | + | const unpaid = await open(); | |
| 265 | + | assert.deepEqual([unpaid.ok, unpaid.error.code], [false, "payment_required"]); | |
| 266 | + | assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "skipped" }]); | |
| 267 | + | w.state.plan = true; | |
| 268 | + | w.state.sqlite.prepare("UPDATE settings SET previews = 0").run(); | |
| 269 | + | w.state.run = { ...w.state.run, ref: "refs/heads/feature" }; | |
| 270 | + | assert.equal(await refused({}), "conflict"); | |
| 271 | + | assert.ok(!w.state.calls.includes("RUNNER start_deploy")); | |
| 272 | + | }); |
| 1 | + | /** | |
| 2 | + | * Who may open a build from a workflow, what a run builds, and whether it | |
| 3 | + | * is trusted. `@g1t/contracts` imports its modules without extensions, so | |
| 4 | + | * a resolve hook adds them (as in moves.flow.test.ts). | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | import assert from "node:assert/strict"; | |
| 8 | + | import { registerHooks } from "node:module"; | |
| 9 | + | import { test } from "node:test"; | |
| 10 | + | ||
| 11 | + | import type { User } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | registerHooks({ | |
| 14 | + | resolve(specifier, context, next) { | |
| 15 | + | try { | |
| 16 | + | return next(specifier, context); | |
| 17 | + | } catch (error) { | |
| 18 | + | if (specifier.startsWith(".")) return next(`${specifier}.ts`, context); | |
| 19 | + | throw error; | |
| 20 | + | } | |
| 21 | + | }, | |
| 22 | + | }); | |
| 23 | + | const { commitOf, jobOf, projectNamed, runTrusted, targetOf } = await import("./workflow-builds.ts"); | |
| 24 | + | ||
| 25 | + | const repo = { namespace: "acme", name: "web" }; | |
| 26 | + | const jobToken = (token: Partial<NonNullable<User["token"]>>): User => | |
| 27 | + | ({ | |
| 28 | + | id: "wsp_acme", | |
| 29 | + | username: "acme", | |
| 30 | + | kind: "workspace", | |
| 31 | + | verified: true, | |
| 32 | + | workspaces: [{ slug: "acme", role: "member" }], | |
| 33 | + | token: { token_id: "tok_1", repo: "acme/web", job: { run_id: "run_1", job_id: "job_1" }, scopes: ["deployments:write"], ...token }, | |
| 34 | + | }) as User; | |
| 35 | + | ||
| 36 | + | test("only a workflow job's token for the repository, with deployments: write, opens a build", () => { | |
| 37 | + | assert.deepEqual(jobOf(jobToken({}), repo), { runId: "run_1", jobId: "job_1" }); | |
| 38 | + | // write-all, and contents with deployments. | |
| 39 | + | assert.ok(!("error" in jobOf(jobToken({ scopes: ["contents:read", "deployments:write"] }), repo))); | |
| 40 | + | // A person's token, however wide, is not a job's. | |
| 41 | + | const person = { id: "usr_1", username: "ada", kind: "user", verified: true, token: { token_id: "tok_2", scopes: null } } as User; | |
| 42 | + | assert.match((jobOf(person, repo) as { error: string }).error, /g1t Actions job's token/); | |
| 43 | + | assert.match((jobOf(null, repo) as { error: string }).error, /g1t Actions job's token/); | |
| 44 | + | // Read only: what a pull request from outside gets. | |
| 45 | + | assert.match((jobOf(jobToken({ scopes: ["deployments:read", "contents:read"] }), repo) as { error: string }).error, /deployments: write/); | |
| 46 | + | // Another repository's job. | |
| 47 | + | assert.match((jobOf(jobToken({ repo: "acme/api" }), repo) as { error: string }).error, /acme\/api, not acme\/web/); | |
| 48 | + | // Case does not matter in names. | |
| 49 | + | assert.ok(!("error" in jobOf(jobToken({ repo: "Acme/Web" }), repo))); | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test("a run on the production branch builds production; another branch, its preview; a pull request, its own", () => { | |
| 53 | + | for (const event of ["push", "workflow_dispatch", "schedule"]) { | |
| 54 | + | assert.deepEqual(targetOf({ event, ref: "refs/heads/main", pull: null }, "main"), { kind: "production" }); | |
| 55 | + | } | |
| 56 | + | assert.deepEqual(targetOf({ event: "push", ref: "refs/heads/feature/x", pull: null }, "main"), { kind: "branch", branch: "feature/x" }); | |
| 57 | + | assert.deepEqual(targetOf({ event: "push", ref: "main", pull: null }, "main"), { kind: "production" }); | |
| 58 | + | assert.deepEqual(targetOf({ event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }, "main"), { kind: "pull", number: 9 }); | |
| 59 | + | assert.deepEqual(targetOf({ event: "pull_request_target", ref: "refs/heads/main", pull: 9 }, "main"), { kind: "pull", number: 9 }); | |
| 60 | + | // The production branch is the project's, whatever it is called. | |
| 61 | + | assert.deepEqual(targetOf({ event: "push", ref: "refs/heads/main", pull: null }, "trunk"), { kind: "branch", branch: "main" }); | |
| 62 | + | // A tag deploys nothing. | |
| 63 | + | assert.match((targetOf({ event: "push", ref: "refs/tags/v1.0.0", pull: null }, "main") as { error: string }).error, /refs\/tags\/v1.0.0/); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("a run from a fork, from someone who cannot push, or one that needed approval gets no secrets", () => { | |
| 67 | + | assert.equal(runTrusted({ approval: null, pull: null, insider: true }), true); | |
| 68 | + | assert.equal(runTrusted({ approval: null, pull: { fork: null }, insider: true }), true); | |
| 69 | + | assert.equal(runTrusted({ approval: null, pull: { fork: { namespace: "eve", name: "web" } }, insider: true }), false); | |
| 70 | + | assert.equal(runTrusted({ approval: null, pull: { fork: null }, insider: false }), false); | |
| 71 | + | assert.equal(runTrusted({ approval: { state: "approved" }, pull: { fork: null }, insider: true }), false); | |
| 72 | + | assert.equal(runTrusted({ approval: { state: "required" }, pull: null, insider: true }), false); | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | test("the commit is the run's unless one is given", () => { | |
| 76 | + | const run = { sha: "4b8d0f2a6c1e3579bd02468ace13579bdf02468a" }; | |
| 77 | + | assert.equal(commitOf(undefined, run), run.sha); | |
| 78 | + | assert.equal(commitOf("", run), run.sha); | |
| 79 | + | assert.equal(commitOf(" ABCDEF1 ", run), "abcdef1"); | |
| 80 | + | assert.ok(typeof commitOf("main", run) === "object"); | |
| 81 | + | assert.ok(typeof commitOf(7, run) === "object"); | |
| 82 | + | }); | |
| 83 | + | ||
| 84 | + | test("the project is the one named, or the only one with Deployments on", () => { | |
| 85 | + | const web = { slug: "web", on: true }; | |
| 86 | + | const docs = { slug: "docs", on: false }; | |
| 87 | + | const api = { slug: "api", on: true }; | |
| 88 | + | const on = (p: { on: boolean }) => p.on; | |
| 89 | + | assert.equal(projectNamed([web, docs], on, null), web); | |
| 90 | + | assert.equal(projectNamed([web, docs], on, "Docs"), docs); | |
| 91 | + | assert.deepEqual(projectNamed([web, docs], on, "nope"), { error: "This repository has no project nope.", code: "not_found" }); | |
| 92 | + | assert.deepEqual(projectNamed([docs], on, undefined), { error: "No project of this repository has Deployments on.", code: "conflict" }); | |
| 93 | + | assert.deepEqual(projectNamed([web, api], on, ""), { error: "Name the project to build: web, api.", code: "invalid" }); | |
| 94 | + | }); |
| 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 { 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 | + | /** | |
| 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 | + | */ | |
| 33 | + | export 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 | + | */ | |
| 54 | + | export 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 | + | */ | |
| 70 | + | export 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. */ | |
| 82 | + | export 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 | + | */ | |
| 94 | + | export 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 | + | */ | |
| 114 | + | export 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 | + | }; |
Comments
Sign in to comment.