A project can build with a workflow. Turning on **Build with a workflow** under Settings, Deployments installs a managed workflow named `g1t` that calls g1t's reusable deploy workflow (`g1t/workflows/.g1t/workflows/deploy.yml@v1`, published from `workflows/` by the actions release) with the project's settings, and pushes and pull requests then start it rather than a build of Deployments' own: the same previews and production, at the same addresses, reported as `g1t / deploy`, now the check of the workflow's job, listed on the Actions page with its runs and logs. A pull request from a fork runs it with a read-only token, as on GitHub, and still gets its preview without the project's build secrets: a read-only job token may open a preview build only from a run of a managed workflow. Fork pull requests run the repository's managed workflows too. Its jobs are Actions time, at cost plus 20% on g1t's machines and free on a self-hosted runner. A workflow named `g1t` in `.g1t/workflows/` takes its place. The deployments and Actions guides say so.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
16 files+328−190/16 viewed
| 5 | 5 | # `uses: g1t/deploy@v1`. A change to an action that breaks how it is | |
| 6 | 6 | # used gets a new major tag here (MAJOR below), and the old one stays. | |
| 7 | 7 | # | |
| 8 | − | # Runs on a push to main that changes actions/, and by hand. | |
| 8 | + | # g1t's reusable workflows, in workflows/, go to g1t/workflows the same | |
| 9 | + | # way (workflows/.g1t/workflows/deploy.yml is | |
| 10 | + | # `g1t/workflows/.g1t/workflows/deploy.yml@v1`). | |
| 11 | + | # | |
| 12 | + | # Runs on a push to main that changes actions/ or workflows/, and by hand. | |
| 9 | 13 | # | |
| 10 | 14 | # Needs the repository secret G1T_ACTIONS_PUBLISH_TOKEN: an access token of | |
| 11 | 15 | # the g1t workspace with contents: write on its action repositories | |
| ⋯ | |||
| 19 | 23 | branches: [main] | |
| 20 | 24 | paths: | |
| 21 | 25 | - actions/** | |
| 26 | + | - workflows/** | |
| 22 | 27 | workflow_dispatch: | |
| 23 | 28 | ||
| 24 | 29 | permissions: | |
| ⋯ | |||
| 65 | 70 | git -c http.extraHeader="${auth}" push --force "${remote}" "${commit}:refs/tags/${MAJOR}" | |
| 66 | 71 | echo "- \`${NAMESPACE}/${name}@${MAJOR}\` is ${commit}" >> "${GITHUB_STEP_SUMMARY}" | |
| 67 | 72 | done | |
| 73 | + | # g1t's reusable workflows, from workflows/, as g1t/workflows. | |
| 74 | + | if [ -d workflows/.g1t/workflows ]; then | |
| 75 | + | commit="$(git subtree split --prefix workflows HEAD)" | |
| 76 | + | remote="${SERVER}/${NAMESPACE}/workflows.git" | |
| 77 | + | echo "Publishing workflows (${commit}) to ${NAMESPACE}/workflows" | |
| 78 | + | git -c http.extraHeader="${auth}" push "${remote}" "${commit}:refs/heads/main" | |
| 79 | + | git -c http.extraHeader="${auth}" push --force "${remote}" "${commit}:refs/tags/${MAJOR}" | |
| 80 | + | echo "- \`${NAMESPACE}/workflows@${MAJOR}\` is ${commit}" >> "${GITHUB_STEP_SUMMARY}" | |
| 81 | + | fi | |
| 167 | 167 | g1t keeps any other key under `g1t:` as written, and does nothing with it | |
| 168 | 168 | yet. | |
| 169 | 169 | ||
| 170 | + | ## Managed workflows | |
| 171 | + | ||
| 172 | + | Some workflows run in a repository without a file in it: g1t installs | |
| 173 | + | them for a feature you turn on, such as a project's | |
| 174 | + | [build with a workflow](/guides/deployments/#build-with-a-workflow). A | |
| 175 | + | managed workflow runs like any other: on the **Actions** page, with its | |
| 176 | + | runs, logs, re-runs and checks, on g1t's machines or your | |
| 177 | + | [self-hosted runners](/guides/self-hosted-runners/). Its page says it is | |
| 178 | + | managed by g1t. | |
| 179 | + | ||
| 180 | + | A workflow of the same `name:` in `.g1t/workflows/` runs in its place, so | |
| 181 | + | to change one, commit your own version of it. | |
| 182 | + | ||
| 170 | 183 | ## Schedules | |
| 171 | 184 | ||
| 172 | 185 | A workflow with `on: schedule` runs on the default branch's latest commit, |
| 306 | 306 | exit 1 | |
| 307 | 307 | ``` | |
| 308 | 308 | ||
| 309 | + | ### Build with a workflow | |
| 310 | + | ||
| 311 | + | Turn on **Build with a workflow** under **Settings → Deployments** and the | |
| 312 | + | project's builds run as a workflow g1t manages for it, instead of as | |
| 313 | + | builds Deployments starts by itself. Nothing else changes: the same | |
| 314 | + | previews and production, at the same addresses, with the same | |
| 315 | + | **g1t / deploy** check, which is now the check of the workflow's job. | |
| 316 | + | ||
| 317 | + | - The workflow is listed on the repository's **Actions** page, named | |
| 318 | + | `g1t`, with its runs, logs and re-runs like any other. It is not a file | |
| 319 | + | in the repository: it is made from the project's settings, and follows | |
| 320 | + | them when they change. | |
| 321 | + | - It calls g1t's reusable deploy workflow, | |
| 322 | + | `g1t/workflows/.g1t/workflows/deploy.yml@v1`, which checks out the | |
| 323 | + | commit and runs [`g1t/deploy`](#deploying-from-a-workflow). As g1t | |
| 324 | + | improves how projects are built, that workflow improves for every | |
| 325 | + | project that uses it. | |
| 326 | + | - A pull request from a fork runs it with a read-only token, as on | |
| 327 | + | GitHub, and still gets its preview, without the project's build | |
| 328 | + | secrets. | |
| 329 | + | - To change it, commit a workflow named `g1t` to `.g1t/workflows/`: that | |
| 330 | + | file runs in its place. Start from the one the **Actions** page shows, | |
| 331 | + | or call the reusable workflow yourself: | |
| 332 | + | ||
| 333 | + | ```yaml | |
| 334 | + | # .g1t/workflows/g1t.yml | |
| 335 | + | name: g1t | |
| 336 | + | ||
| 337 | + | on: | |
| 338 | + | push: | |
| 339 | + | branches: [main] | |
| 340 | + | pull_request: | |
| 341 | + | ||
| 342 | + | permissions: | |
| 343 | + | contents: read | |
| 344 | + | deployments: write | |
| 345 | + | ||
| 346 | + | jobs: | |
| 347 | + | g1t: | |
| 348 | + | name: g1t | |
| 349 | + | uses: g1t/workflows/.g1t/workflows/deploy.yml@v1 | |
| 350 | + | with: | |
| 351 | + | project: web | |
| 352 | + | secrets: inherit | |
| 353 | + | ``` | |
| 354 | + | ||
| 355 | + | Its jobs are [workflow jobs](/guides/actions/#machine-sizes): on g1t's | |
| 356 | + | machines they are charged as Actions time, at cost plus 20% like every | |
| 357 | + | build, and on a [self-hosted runner](/guides/self-hosted-runners/) they | |
| 358 | + | cost nothing. | |
| 359 | + | ||
| 309 | 360 | ## Custom domains | |
| 310 | 361 | ||
| 311 | 362 | Production can be served at a domain of your own, such as `example.com` or | |
| ⋯ | |||
| 427 | 478 | | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. | | |
| 428 | 479 | | Output directory | Found by itself | What a static site serves. | | |
| 429 | 480 | | Idle days | 7 | 1 to 90. A preview no one visits this long comes down. | | |
| 481 | + | | Build with a workflow | Off | Build through the deploy workflow g1t manages for the project; see [build with a workflow](#build-with-a-workflow). | | |
| 430 | 482 | ||
| 431 | 483 | ## Secrets and variables | |
| 432 | 484 | ||
| 50 | 50 | buildCommand: String(form.get("buildCommand") ?? ""), | |
| 51 | 51 | outputDir: String(form.get("outputDir") ?? ""), | |
| 52 | 52 | idleDays: Number(form.get("idleDays")), | |
| 53 | + | viaWorkflow: on("viaWorkflow"), | |
| 53 | 54 | }); | |
| 54 | 55 | return saved.ok ? { notice: "Saved." } : { error: saved.error.message }; | |
| 55 | 56 | } | |
| ⋯ | |||
| 150 | 151 | A preview for every branch with an open pull request, linked on it. | |
| 151 | 152 | </Check> | |
| 152 | 153 | </div> | |
| 154 | + | <Check name="viaWorkflow" on={settings.viaWorkflow ?? false} title="Build with a workflow"> | |
| 155 | + | Build through a workflow g1t manages for this project, listed under Actions with its runs and logs. It runs on g1t's machines like any workflow, and a workflow named g1t in .g1t/workflows takes its place. | |
| 156 | + | </Check> | |
| 153 | 157 | <div className="grid gap-4 md:grid-cols-3"> | |
| 154 | 158 | <Field label="Build command" hint="Instead of the project's own build script."> | |
| 155 | 159 | <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" /> | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | 159 | #[test] | |
| 160 | + | fn the_called_workflow_takes_every_input_the_deploy_workflow_gives() { | |
| 161 | + | let called = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../workflows/.g1t/workflows/deploy.yml")).unwrap(); | |
| 162 | + | let parsed = workflow::parse(&called).unwrap(); | |
| 163 | + | assert!(!parsed.notes.iter().any(|note| note.severity != Severity::Info), "{:?}", parsed.notes); | |
| 164 | + | let raw: serde_json::Value = serde_yaml::from_str(&called).unwrap(); | |
| 165 | + | let inputs = &raw["on"]["workflow_call"]["inputs"]; | |
| 166 | + | let caller = workflow::parse(&deploy_workflow(&settings()).unwrap()).unwrap(); | |
| 167 | + | for (key, _) in caller.jobs[0].raw["with"].as_object().unwrap() { | |
| 168 | + | assert!(inputs.get(key).is_some(), "the called workflow takes no `{key}`"); | |
| 169 | + | } | |
| 170 | + | // Its job is `deploy`, so the caller's `g1t` reports `g1t / deploy`. | |
| 171 | + | assert_eq!(parsed.jobs[0].raw["name"], "deploy"); | |
| 172 | + | assert!(DEPLOY_CALLS.starts_with("g1t/workflows/.g1t/workflows/deploy.yml@")); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 160 | 176 | fn what_a_project_turns_off_starts_nothing() { | |
| 161 | 177 | let previews_only = deploy_workflow(&DeploySettings { production: false, ..settings() }).unwrap(); | |
| 162 | 178 | let parsed = workflow::parse(&previews_only).unwrap(); |
| 782 | 782 | pub origin: String, | |
| 783 | 783 | } | |
| 784 | 784 | ||
| 785 | + | /// `set_managed_deploy`: installs, replaces or removes the deploy workflow | |
| 786 | + | /// a project's deployments run through (`g1t_actions::managed`), named | |
| 787 | + | /// `deploy-<project>`, from the project's settings. Nothing deploys (both | |
| 788 | + | /// `previews` and `production` off) removes it. For the deployments | |
| 789 | + | /// service only. Returns `bool`, whether one is installed now. | |
| 790 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 791 | + | #[serde(rename_all = "camelCase")] | |
| 792 | + | pub struct SetManagedDeployArgs { | |
| 793 | + | pub repo_id: String, | |
| 794 | + | pub project: String, | |
| 795 | + | pub production_branch: String, | |
| 796 | + | #[serde(default)] | |
| 797 | + | pub root_dir: String, | |
| 798 | + | #[serde(default)] | |
| 799 | + | pub build_command: String, | |
| 800 | + | #[serde(default)] | |
| 801 | + | pub output_dir: String, | |
| 802 | + | pub previews: bool, | |
| 803 | + | pub production: bool, | |
| 804 | + | } | |
| 805 | + | ||
| 785 | 806 | /// `remove_managed_workflow`: takes a managed workflow out of a repository. | |
| 786 | 807 | /// Its runs stay. Returns `bool`, whether there was one. | |
| 787 | 808 | #[derive(Debug, Serialize, Deserialize)] |
| 31 | 31 | outputDir: string | null; | |
| 32 | 32 | /** A preview no one has visited in this many days is taken down. */ | |
| 33 | 33 | idleDays: number; | |
| 34 | + | /** | |
| 35 | + | * Whether it builds through the deploy workflow g1t manages for it, a | |
| 36 | + | * workflow run on each push and pull request like any other, rather | |
| 37 | + | * than through Deployments' own build. | |
| 38 | + | */ | |
| 39 | + | viaWorkflow?: boolean; | |
| 34 | 40 | /** Where production is served. */ | |
| 35 | 41 | productionUrl: string; | |
| 36 | 42 | /** |
| 255 | 255 | // Managed workflows: g1t's services' side only. | |
| 256 | 256 | "set_managed_workflow" => reply(&service.set_managed_workflow(args(body)?).await?), | |
| 257 | 257 | "remove_managed_workflow" => reply(&service.remove_managed_workflow(args(body)?).await?), | |
| 258 | + | "set_managed_deploy" => reply(&service.set_managed_deploy(args(body)?).await?), | |
| 258 | 259 | "job_spec" => reply(&service.job_spec(args(body)?).await?), | |
| 259 | 260 | "job_auth" => reply(&service.job_auth(args(body)?).await?), | |
| 260 | 261 | "job_action" => reply(&service.job_action(args(body)?).await?), |
| 6 | 6 | ||
| 7 | 7 | use g1t_actions::managed; | |
| 8 | 8 | use g1t_actions::workflow; | |
| 9 | − | use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedWorkflowArgs}; | |
| 9 | + | use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedDeployArgs, SetManagedWorkflowArgs}; | |
| 10 | 10 | use g1t_contracts::time::rfc3339; | |
| 11 | 11 | use g1t_contracts::{FailureCode, Outcome}; | |
| 12 | 12 | use g1t_kit::now_ms; | |
| ⋯ | |||
| 67 | 67 | Ok(Outcome::Ok(removed)) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | /// `set_managed_deploy`. | |
| 71 | + | pub async fn set_managed_deploy(&self, a: SetManagedDeployArgs) -> Result<Outcome<bool>> { | |
| 72 | + | let name = format!("deploy-{}", a.project); | |
| 73 | + | let settings = managed::DeploySettings { | |
| 74 | + | project: a.project, | |
| 75 | + | production_branch: a.production_branch, | |
| 76 | + | root_dir: a.root_dir, | |
| 77 | + | build_command: a.build_command, | |
| 78 | + | output_dir: a.output_dir, | |
| 79 | + | previews: a.previews, | |
| 80 | + | production: a.production, | |
| 81 | + | }; | |
| 82 | + | match managed::deploy_workflow(&settings) { | |
| 83 | + | Some(source) => self.set_managed_workflow(SetManagedWorkflowArgs { repo_id: a.repo_id, name, source, origin: "deployments".to_owned() }).await, | |
| 84 | + | None => { | |
| 85 | + | self.remove_managed_workflow(RemoveManagedWorkflowArgs { repo_id: a.repo_id, name }).await?; | |
| 86 | + | Ok(Outcome::Ok(false)) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 70 | 91 | /// The managed workflows that run beside `files`, a repository's own: | |
| 71 | 92 | /// each one no file of the same `name:` takes the place of. | |
| 72 | 93 | pub(crate) async fn managed_beside(&self, repo_id: &str, files: &[WorkflowFile]) -> Result<Vec<WorkflowFile>> { | |
| 656 | 656 | let read = if subject.source == Self::repo_path(&repo) { | |
| 657 | 657 | self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await? | |
| 658 | 658 | } else { | |
| 659 | − | self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await? | |
| 659 | + | // And this repository's managed workflows: they are its own, | |
| 660 | + | // whatever the fork's files say (managed.rs). | |
| 661 | + | let mut read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?; | |
| 662 | + | let managed = self.managed_beside(&repo.id, &read.files).await?; | |
| 663 | + | read.files.extend(managed); | |
| 664 | + | read | |
| 660 | 665 | }; | |
| 661 | 666 | // A pull request's head runs each workflow once, however many | |
| 662 | 667 | // events say it is there (marked ready, and pushed). |
| 1 | + | -- Whether a project builds through the deploy workflow g1t manages for it | |
| 2 | + | -- (a managed workflow in the actions service, `managed/deploy-<project>.yml`) | |
| 3 | + | -- rather than through this service's own build on each push and pull request. | |
| 4 | + | ALTER TABLE settings ADD COLUMN via_workflow INTEGER NOT NULL DEFAULT 0; |
| 91 | 91 | import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries"; | |
| 92 | 92 | import { appHost, appUrl, label, uniqueLabel } from "./names"; | |
| 93 | 93 | import { RepoDeployments, sourceOf } from "./repo-deployments"; | |
| 94 | − | import { commitOf, jobOf, projectNamed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds"; | |
| 94 | + | import { NEEDS_WRITE, commitOf, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds"; | |
| 95 | 95 | ||
| 96 | 96 | type Env = { | |
| 97 | 97 | DB: D1Database; | |
| ⋯ | |||
| 198 | 198 | build_command: string | null; | |
| 199 | 199 | output_dir: string | null; | |
| 200 | 200 | idle_days: number; | |
| 201 | + | /** Whether it builds through its managed deploy workflow; see `syncManagedDeploy`. */ | |
| 202 | + | via_workflow?: number; | |
| 201 | 203 | /** Set while its repository is deleted (restorable); see `repoDeleted`. */ | |
| 202 | 204 | repo_deleted_at: string | null; | |
| 203 | 205 | /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */ | |
| ⋯ | |||
| 435 | 437 | buildCommand: row?.build_command ?? null, | |
| 436 | 438 | outputDir: row?.output_dir ?? null, | |
| 437 | 439 | idleDays: row?.idle_days ?? 7, | |
| 440 | + | viaWorkflow: !!row?.via_workflow, | |
| 438 | 441 | productionUrl: appUrl(await this.scriptFor(project, null)), | |
| 439 | 442 | primaryDomain: await this.domains.primary(project.id).catch(() => null), | |
| 440 | 443 | detected: await this.lastDetected(project.id), | |
| ⋯ | |||
| 500 | 503 | await this.db | |
| 501 | 504 | .prepare( | |
| 502 | 505 | `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command, | |
| 503 | − | output_dir, idle_days, updated_by, updated_at) | |
| 504 | − | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) | |
| 506 | + | output_dir, idle_days, updated_by, updated_at, via_workflow) | |
| 507 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) | |
| 505 | 508 | ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6, | |
| 506 | − | production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`, | |
| 509 | + | production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12, | |
| 510 | + | via_workflow = ?13`, | |
| 507 | 511 | ) | |
| 508 | 512 | .bind( | |
| 509 | 513 | project.id, | |
| ⋯ | |||
| 518 | 522 | idleDays, | |
| 519 | 523 | a.actor.username, | |
| 520 | 524 | now(), | |
| 525 | + | next.viaWorkflow ? 1 : 0, | |
| 521 | 526 | ) | |
| 522 | 527 | .run(); | |
| 528 | + | // The managed deploy workflow follows the settings: installed while the | |
| 529 | + | // project builds through it, removed when it stops. | |
| 530 | + | if (next.viaWorkflow || before.viaWorkflow) { | |
| 531 | + | const installed = await this.syncManagedDeploy(project, next); | |
| 532 | + | if (!installed.ok) return installed; | |
| 533 | + | } | |
| 523 | 534 | // Projects keeps whether it deploys, so a project with Deployments on is an app. | |
| 524 | 535 | if (next.enabled !== before.enabled) { | |
| 525 | 536 | await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error)); | |
| ⋯ | |||
| 537 | 548 | return ok(await this.toSettings(project, await this.settingsRow(project.id))); | |
| 538 | 549 | } | |
| 539 | 550 | ||
| 551 | + | /** | |
| 552 | + | * Installs, replaces or removes the project's managed deploy workflow | |
| 553 | + | * (the actions service's `set_managed_deploy`) to match `settings`: it | |
| 554 | + | * runs while Deployments are on and the project builds through it. | |
| 555 | + | */ | |
| 556 | + | private async syncManagedDeploy(project: Project, settings: DeploySettings): Promise<Result<boolean>> { | |
| 557 | + | const on = settings.enabled && settings.viaWorkflow; | |
| 558 | + | const repo = repoOf(project); | |
| 559 | + | const response = await this.env.ACTIONS.fetch("https://actions/rpc/set_managed_deploy", { | |
| 560 | + | method: "POST", | |
| 561 | + | headers: { "content-type": "application/json" }, | |
| 562 | + | body: JSON.stringify({ | |
| 563 | + | repoId: repo.id, | |
| 564 | + | project: project.slug, | |
| 565 | + | productionBranch: repo.defaultBranch, | |
| 566 | + | rootDir: project.source.kind === "hosted" ? (project.source.rootDir ?? "") : "", | |
| 567 | + | buildCommand: settings.buildCommand ?? "", | |
| 568 | + | outputDir: settings.outputDir ?? "", | |
| 569 | + | previews: on && settings.previews, | |
| 570 | + | production: on && settings.production, | |
| 571 | + | }), | |
| 572 | + | }); | |
| 573 | + | const answer = (await response.json().catch(() => null)) as { ok?: boolean; value?: boolean; error?: { message?: string } } | null; | |
| 574 | + | if (!response.ok || !answer || answer.ok === false) { | |
| 575 | + | return fail("unavailable", answer?.error?.message ?? "The deploy workflow could not be installed. Try again in a moment."); | |
| 576 | + | } | |
| 577 | + | return ok(!!answer.value); | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | /** Whether `projectId` builds through its managed deploy workflow, so pushes and pull requests start no build here. */ | |
| 581 | + | private async buildsViaWorkflow(projectId: string): Promise<boolean> { | |
| 582 | + | return !!(await this.settingsRow(projectId))?.via_workflow; | |
| 583 | + | } | |
| 584 | + | ||
| 540 | 585 | /** For projects: whether Deployments are on for a project. Reads only this service's own table. */ | |
| 541 | 586 | async isEnabled(a: { projectId: string }): Promise<boolean> { | |
| 542 | 587 | return !!(await this.settingsRow(a.projectId))?.enabled; | |
| ⋯ | |||
| 912 | 957 | if ("error" in chosen) return fail(chosen.code, chosen.error); | |
| 913 | 958 | const project = chosen; | |
| 914 | 959 | 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)); | |
| 960 | + | // A token that writes deployments needs the role a redeploy does; one | |
| 961 | + | // that does not is let through below only for a managed preview. | |
| 962 | + | if (job.writes && !can(a.actor, repoRef(project), NEEDS.redeploy)) return fail("forbidden", needs(NEEDS.redeploy)); | |
| 916 | 963 | const settings = rows.get(project.id); | |
| 917 | 964 | if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`); | |
| 918 | 965 | const repo = repoOf(project); | |
| ⋯ | |||
| 930 | 977 | ||
| 931 | 978 | const target = targetOf(run, repo.defaultBranch); | |
| 932 | 979 | if ("error" in target) return fail("invalid", target.error); | |
| 980 | + | if (!job.writes && !readOnlyAllowed(run, target)) return fail("forbidden", NEEDS_WRITE); | |
| 933 | 981 | let kind: DeployKind = "production"; | |
| 934 | 982 | let branch: string | null = null; | |
| 935 | 983 | let number: number | null = null; | |
| ⋯ | |||
| 954 | 1002 | kind = "preview"; | |
| 955 | 1003 | branch = target.branch; | |
| 956 | 1004 | } | |
| 1005 | + | // A read-only token's build never gets the project's build secrets. | |
| 1006 | + | if (!job.writes) trusted = false; | |
| 957 | 1007 | if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`); | |
| 958 | 1008 | if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`); | |
| 959 | 1009 | ||
| ⋯ | |||
| 1451 | 1501 | case "pull.updated": | |
| 1452 | 1502 | case "pull.reopened": | |
| 1453 | 1503 | for (const project of await this.projects.byRepo(event.data.repoId)) { | |
| 1504 | + | // Its managed deploy workflow builds it instead (`syncManagedDeploy`). | |
| 1505 | + | if (await this.buildsViaWorkflow(project.id)) continue; | |
| 1454 | 1506 | await this.deployPreview(project, event.data.number, "g1t"); | |
| 1455 | 1507 | } | |
| 1456 | 1508 | break; | |
| ⋯ | |||
| 1470 | 1522 | // failover, the remote's own deploys stand (see contracts mirrors). | |
| 1471 | 1523 | if (!mirrorWritable(event.data.mirror)) break; | |
| 1472 | 1524 | for (const project of await this.projects.byRepo(event.data.repoId)) { | |
| 1525 | + | if (await this.buildsViaWorkflow(project.id)) continue; | |
| 1473 | 1526 | await this.deployProduction(project, event.data.after, event.actor ?? "g1t"); | |
| 1474 | 1527 | } | |
| 1475 | 1528 | break; | |
| 20 | 20 | } | |
| 21 | 21 | }, | |
| 22 | 22 | }); | |
| 23 | − | const { commitOf, jobOf, projectNamed, runTrusted, targetOf } = await import("./workflow-builds.ts"); | |
| 23 | + | const { commitOf, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf } = await import("./workflow-builds.ts"); | |
| 24 | 24 | ||
| 25 | 25 | const repo = { namespace: "acme", name: "web" }; | |
| 26 | 26 | const jobToken = (token: Partial<NonNullable<User["token"]>>): User => | |
| ⋯ | |||
| 34 | 34 | }) as User; | |
| 35 | 35 | ||
| 36 | 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" }); | |
| 37 | + | assert.deepEqual(jobOf(jobToken({}), repo), { runId: "run_1", jobId: "job_1", writes: true }); | |
| 38 | 38 | // write-all, and contents with deployments. | |
| 39 | 39 | assert.ok(!("error" in jobOf(jobToken({ scopes: ["contents:read", "deployments:write"] }), repo))); | |
| 40 | 40 | // A person's token, however wide, is not a job's. | |
| 41 | 41 | const person = { id: "usr_1", username: "ada", kind: "user", verified: true, token: { token_id: "tok_2", scopes: null } } as User; | |
| 42 | 42 | assert.match((jobOf(person, repo) as { error: string }).error, /g1t Actions job's token/); | |
| 43 | 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/); | |
| 44 | + | // Read only: what a pull request from outside gets. Its job is named, | |
| 45 | + | // and only a managed workflow's preview lets it through. | |
| 46 | + | assert.deepEqual(jobOf(jobToken({ scopes: ["deployments:read", "contents:read"] }), repo), { runId: "run_1", jobId: "job_1", writes: false }); | |
| 46 | 47 | // Another repository's job. | |
| 47 | 48 | assert.match((jobOf(jobToken({ repo: "acme/api" }), repo) as { error: string }).error, /acme\/api, not acme\/web/); | |
| 48 | 49 | // Case does not matter in names. | |
| ⋯ | |||
| 92 | 93 | assert.deepEqual(projectNamed([docs], on, undefined), { error: "No project of this repository has Deployments on.", code: "conflict" }); | |
| 93 | 94 | assert.deepEqual(projectNamed([web, api], on, ""), { error: "Name the project to build: web, api.", code: "invalid" }); | |
| 94 | 95 | }); | |
| 96 | + | ||
| 97 | + | test("a read-only token opens only a preview, and only from the deploy workflow g1t manages", () => { | |
| 98 | + | const managed = { path: "managed/deploy-web.yml" }; | |
| 99 | + | const own = { path: ".g1t/workflows/deploy.yml" }; | |
| 100 | + | assert.ok(readOnlyAllowed(managed, { kind: "pull", number: 4 })); | |
| 101 | + | assert.ok(readOnlyAllowed(managed, { kind: "branch", branch: "fix" })); | |
| 102 | + | assert.ok(!readOnlyAllowed(managed, { kind: "production" })); | |
| 103 | + | assert.ok(!readOnlyAllowed(own, { kind: "pull", number: 4 })); | |
| 104 | + | assert.ok(!readOnlyAllowed(managed, { error: "a tag" })); | |
| 105 | + | }); | |
| 8 | 8 | * and what the project's settings give it) and puts what the job sends. | |
| 9 | 9 | */ | |
| 10 | 10 | ||
| 11 | − | import { tokenAllows, type RepoPath, type User } from "@g1t/contracts"; | |
| 11 | + | import { isManagedWorkflow, tokenAllows, type RepoPath, type User } from "@g1t/contracts"; | |
| 12 | 12 | ||
| 13 | 13 | /** The run a job token was made for, as far as these rules read it. */ | |
| 14 | 14 | export type RunFacts = { | |
| ⋯ | |||
| 26 | 26 | | { kind: "pull"; number: number } | |
| 27 | 27 | | { error: string }; | |
| 28 | 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 | + | ||
| 29 | 32 | /** | |
| 30 | 33 | * 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. | |
| 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`. | |
| 32 | 36 | */ | |
| 33 | − | export function jobOf(actor: User | null | undefined, repo: RepoPath): { runId: string; jobId: string } | { error: string } { | |
| 37 | + | export function jobOf( | |
| 38 | + | actor: User | null | undefined, | |
| 39 | + | repo: RepoPath, | |
| 40 | + | ): { runId: string; jobId: string; writes: boolean } | { error: string } { | |
| 34 | 41 | const token = actor?.token; | |
| 35 | 42 | if (!token?.job?.run_id || !token.job.job_id) { | |
| 36 | 43 | return { error: "Only a g1t Actions job's token (G1T_TOKEN) can open a g1t.page build." }; | |
| ⋯ | |||
| 39 | 46 | if (!token.repo || token.repo.toLowerCase() !== own) { | |
| 40 | 47 | return { error: `This job's token is for ${token.repo ?? "another repository"}, not ${repo.namespace}/${repo.name}.` }; | |
| 41 | 48 | } | |
| 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 }; | |
| 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"; | |
| 46 | 61 | } | |
| 47 | 62 | ||
| 48 | 63 | /** | |
| 1 | + | # g1t's reusable deploy workflow: what the deploy workflow g1t manages for | |
| 2 | + | # a project calls (crates/actions/src/managed.rs), as | |
| 3 | + | # `g1t/workflows/.g1t/workflows/deploy.yml@v1`. Any workflow can call it | |
| 4 | + | # the same way. It checks out the commit and builds it onto g1t.page with | |
| 5 | + | # g1t/deploy: production from the project's production branch, a preview | |
| 6 | + | # from a pull request or another branch. | |
| 7 | + | # | |
| 8 | + | # Its job is named `deploy`, so a caller job named `g1t` reports the | |
| 9 | + | # check `g1t / deploy`. | |
| 10 | + | name: g1t deploy | |
| 11 | + | ||
| 12 | + | on: | |
| 13 | + | workflow_call: | |
| 14 | + | inputs: | |
| 15 | + | project: | |
| 16 | + | description: The project's slug; the repository's one project with Deployments on when empty. | |
| 17 | + | type: string | |
| 18 | + | default: "" | |
| 19 | + | root-dir: | |
| 20 | + | description: Where the project is in the repository; the project's own setting when empty. | |
| 21 | + | type: string | |
| 22 | + | default: "" | |
| 23 | + | build-command: | |
| 24 | + | description: What builds it; the project's own setting, else what the build finds. | |
| 25 | + | type: string | |
| 26 | + | default: "" | |
| 27 | + | output-dir: | |
| 28 | + | description: The folder a static build writes; found by itself when empty. | |
| 29 | + | type: string | |
| 30 | + | default: "" | |
| 31 | + | outputs: | |
| 32 | + | url: | |
| 33 | + | description: Where the app is served, on g1t.page. | |
| 34 | + | value: ${{ jobs.deploy.outputs.url }} | |
| 35 | + | ||
| 36 | + | jobs: | |
| 37 | + | deploy: | |
| 38 | + | name: deploy | |
| 39 | + | runs-on: ubuntu-latest | |
| 40 | + | timeout-minutes: 30 | |
| 41 | + | # A pull request from a fork gets a read-only token, as on GitHub; g1t | |
| 42 | + | # still builds its preview, without the project's build secrets. | |
| 43 | + | permissions: | |
| 44 | + | contents: read | |
| 45 | + | deployments: write | |
| 46 | + | outputs: | |
| 47 | + | url: ${{ steps.deploy.outputs.url }} | |
| 48 | + | steps: | |
| 49 | + | - uses: actions/checkout@v5 | |
| 50 | + | - id: deploy | |
| 51 | + | uses: g1t/deploy@v1 | |
| 52 | + | with: | |
| 53 | + | project: ${{ inputs.project }} | |
| 54 | + | root-dir: ${{ inputs.root-dir }} | |
| 55 | + | build-command: ${{ inputs.build-command }} | |
| 56 | + | output-dir: ${{ inputs.output-dir }} |
| 1 | + | # g1t/workflows | |
| 2 | + | ||
| 3 | + | g1t's own reusable workflows, published from this folder of g1t's | |
| 4 | + | repository to `g1t/workflows` on g1t.sh by `.g1t/workflows/actions-release.yml`. | |
| 5 | + | ||
| 6 | + | | Workflow | What it does | | |
| 7 | + | | --- | --- | | |
| 8 | + | | `.g1t/workflows/deploy.yml` | Builds the commit onto g1t.page with `g1t/deploy`. The deploy workflow g1t manages for a project calls it. | | |
| 9 | + | ||
| 10 | + | ```yaml | |
| 11 | + | jobs: | |
| 12 | + | g1t: | |
| 13 | + | uses: g1t/workflows/.g1t/workflows/deploy.yml@v1 | |
| 14 | + | with: | |
| 15 | + | project: web | |
| 16 | + | secrets: inherit | |
| 17 | + | ``` |