Skip to content

Commit

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
Claudeagent and Claudeagentcommitted Parent044c088Browse files
16 files+328−190/16 viewed
+15−1
55 # `uses: g1t/deploy@v1`. A change to an action that breaks how it is
66 # used gets a new major tag here (MAJOR below), and the old one stays.
77 #
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.
913 #
1014 # Needs the repository secret G1T_ACTIONS_PUBLISH_TOKEN: an access token of
1115 # the g1t workspace with contents: write on its action repositories
1923 branches: [main]
2024 paths:
2125 - actions/**
26+ - workflows/**
2227 workflow_dispatch:
2328
2429 permissions:
6570 git -c http.extraHeader="${auth}" push --force "${remote}" "${commit}:refs/tags/${MAJOR}"
6671 echo "- \`${NAMESPACE}/${name}@${MAJOR}\` is ${commit}" >> "${GITHUB_STEP_SUMMARY}"
6772 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
+13−0
167167 g1t keeps any other key under `g1t:` as written, and does nothing with it
168168 yet.
169169
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+
170183 ## Schedules
171184
172185 A workflow with `on: schedule` runs on the default branch's latest commit,
+52−0
306306 exit 1
307307 ```
308308
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+
309360 ## Custom domains
310361
311362 Production can be served at a domain of your own, such as `example.com` or
427478 | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. |
428479 | Output directory | Found by itself | What a static site serves. |
429480 | 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). |
430482
431483 ## Secrets and variables
432484
+4−0
5050 buildCommand: String(form.get("buildCommand") ?? ""),
5151 outputDir: String(form.get("outputDir") ?? ""),
5252 idleDays: Number(form.get("idleDays")),
53+ viaWorkflow: on("viaWorkflow"),
5354 });
5455 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
5556 }
150151 A preview for every branch with an open pull request, linked on it.
151152 </Check>
152153 </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>
153157 <div className="grid gap-4 md:grid-cols-3">
154158 <Field label="Build command" hint="Instead of the project's own build script.">
155159 <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
+16−0
157157 }
158158
159159 #[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]
160176 fn what_a_project_turns_off_starts_nothing() {
161177 let previews_only = deploy_workflow(&DeploySettings { production: false, ..settings() }).unwrap();
162178 let parsed = workflow::parse(&previews_only).unwrap();
+21−0
782782 pub origin: String,
783783 }
784784
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+
785806 /// `remove_managed_workflow`: takes a managed workflow out of a repository.
786807 /// Its runs stay. Returns `bool`, whether there was one.
787808 #[derive(Debug, Serialize, Deserialize)]
+6−0
3131 outputDir: string | null;
3232 /** A preview no one has visited in this many days is taken down. */
3333 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;
3440 /** Where production is served. */
3541 productionUrl: string;
3642 /**
+1−0
255255 // Managed workflows: g1t's services' side only.
256256 "set_managed_workflow" => reply(&service.set_managed_workflow(args(body)?).await?),
257257 "remove_managed_workflow" => reply(&service.remove_managed_workflow(args(body)?).await?),
258+ "set_managed_deploy" => reply(&service.set_managed_deploy(args(body)?).await?),
258259 "job_spec" => reply(&service.job_spec(args(body)?).await?),
259260 "job_auth" => reply(&service.job_auth(args(body)?).await?),
260261 "job_action" => reply(&service.job_action(args(body)?).await?),
+22−1
66
77 use g1t_actions::managed;
88 use g1t_actions::workflow;
9−use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedWorkflowArgs};
9+use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedDeployArgs, SetManagedWorkflowArgs};
1010 use g1t_contracts::time::rfc3339;
1111 use g1t_contracts::{FailureCode, Outcome};
1212 use g1t_kit::now_ms;
6767 Ok(Outcome::Ok(removed))
6868 }
6969
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+
7091 /// The managed workflows that run beside `files`, a repository's own:
7192 /// each one no file of the same `name:` takes the place of.
7293 pub(crate) async fn managed_beside(&self, repo_id: &str, files: &[WorkflowFile]) -> Result<Vec<WorkflowFile>> {
+6−1
656656 let read = if subject.source == Self::repo_path(&repo) {
657657 self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await?
658658 } 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
660665 };
661666 // A pull request's head runs each workflow once, however many
662667 // events say it is there (marked ready, and pushed).
+4−0
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;
+58−5
9191 import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
9292 import { appHost, appUrl, label, uniqueLabel } from "./names";
9393 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";
9595
9696 type Env = {
9797 DB: D1Database;
198198 build_command: string | null;
199199 output_dir: string | null;
200200 idle_days: number;
201+ /** Whether it builds through its managed deploy workflow; see `syncManagedDeploy`. */
202+ via_workflow?: number;
201203 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
202204 repo_deleted_at: string | null;
203205 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
435437 buildCommand: row?.build_command ?? null,
436438 outputDir: row?.output_dir ?? null,
437439 idleDays: row?.idle_days ?? 7,
440+ viaWorkflow: !!row?.via_workflow,
438441 productionUrl: appUrl(await this.scriptFor(project, null)),
439442 primaryDomain: await this.domains.primary(project.id).catch(() => null),
440443 detected: await this.lastDetected(project.id),
500503 await this.db
501504 .prepare(
502505 `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)
505508 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`,
507511 )
508512 .bind(
509513 project.id,
518522 idleDays,
519523 a.actor.username,
520524 now(),
525+ next.viaWorkflow ? 1 : 0,
521526 )
522527 .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+ }
523534 // Projects keeps whether it deploys, so a project with Deployments on is an app.
524535 if (next.enabled !== before.enabled) {
525536 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
537548 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
538549 }
539550
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+
540585 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
541586 async isEnabled(a: { projectId: string }): Promise<boolean> {
542587 return !!(await this.settingsRow(a.projectId))?.enabled;
912957 if ("error" in chosen) return fail(chosen.code, chosen.error);
913958 const project = chosen;
914959 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));
916963 const settings = rows.get(project.id);
917964 if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`);
918965 const repo = repoOf(project);
930977
931978 const target = targetOf(run, repo.defaultBranch);
932979 if ("error" in target) return fail("invalid", target.error);
980+ if (!job.writes && !readOnlyAllowed(run, target)) return fail("forbidden", NEEDS_WRITE);
933981 let kind: DeployKind = "production";
934982 let branch: string | null = null;
935983 let number: number | null = null;
9541002 kind = "preview";
9551003 branch = target.branch;
9561004 }
1005+ // A read-only token's build never gets the project's build secrets.
1006+ if (!job.writes) trusted = false;
9571007 if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`);
9581008 if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`);
9591009
14511501 case "pull.updated":
14521502 case "pull.reopened":
14531503 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;
14541506 await this.deployPreview(project, event.data.number, "g1t");
14551507 }
14561508 break;
14701522 // failover, the remote's own deploys stand (see contracts mirrors).
14711523 if (!mirrorWritable(event.data.mirror)) break;
14721524 for (const project of await this.projects.byRepo(event.data.repoId)) {
1525+ if (await this.buildsViaWorkflow(project.id)) continue;
14731526 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
14741527 }
14751528 break;
+15−4
2020 }
2121 },
2222 });
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");
2424
2525 const repo = { namespace: "acme", name: "web" };
2626 const jobToken = (token: Partial<NonNullable<User["token"]>>): User =>
3434 }) as User;
3535
3636 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 });
3838 // write-all, and contents with deployments.
3939 assert.ok(!("error" in jobOf(jobToken({ scopes: ["contents:read", "deployments:write"] }), repo)));
4040 // A person's token, however wide, is not a job's.
4141 const person = { id: "usr_1", username: "ada", kind: "user", verified: true, token: { token_id: "tok_2", scopes: null } } as User;
4242 assert.match((jobOf(person, repo) as { error: string }).error, /g1t Actions job's token/);
4343 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 });
4647 // Another repository's job.
4748 assert.match((jobOf(jobToken({ repo: "acme/api" }), repo) as { error: string }).error, /acme\/api, not acme\/web/);
4849 // Case does not matter in names.
9293 assert.deepEqual(projectNamed([docs], on, undefined), { error: "No project of this repository has Deployments on.", code: "conflict" });
9394 assert.deepEqual(projectNamed([web, api], on, ""), { error: "Name the project to build: web, api.", code: "invalid" });
9495 });
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+});
+22−7
88 * and what the project's settings give it) and puts what the job sends.
99 */
1010
11−import { tokenAllows, type RepoPath, type User } from "@g1t/contracts";
11+import { isManagedWorkflow, tokenAllows, type RepoPath, type User } from "@g1t/contracts";
1212
1313 /** The run a job token was made for, as far as these rules read it. */
1414 export type RunFacts = {
2626 | { kind: "pull"; number: number }
2727 | { error: string };
2828
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+
2932 /**
3033 * 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`.
3236 */
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 } {
3441 const token = actor?.token;
3542 if (!token?.job?.run_id || !token.job.job_id) {
3643 return { error: "Only a g1t Actions job's token (G1T_TOKEN) can open a g1t.page build." };
3946 if (!token.repo || token.repo.toLowerCase() !== own) {
4047 return { error: `This job's token is for ${token.repo ?? "another repository"}, not ${repo.namespace}/${repo.name}.` };
4148 }
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";
4661 }
4762
4863 /**
+56−0
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 }}
+17−0
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+```