Skip to content

Commit

A repository can run a managed workflow: one installed by g1t's own services without a file in the repository. It is listed, started, run and re-run like any workflow in `.g1t/workflows/`, under the path `managed/<name>.yml`, and a workflow of the same `name:` in the repository runs in its place, so moving one into the repository is committing its source. The Actions page says a managed workflow is managed by g1t rather than linking to a file. Nothing installs one yet; the deploy workflow deployments will install, which calls g1t's reusable deploy workflow with a project's settings as its inputs and reports as `g1t / deploy`, is written and tested but not installed.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parentd4e4a46Browse files
10 files+379−190/10 viewed
+7−5
3030 import { type ReactNode, useEffect, useRef, useState } from "react";
3131 import { Form, Link, useLoaderData, useSearchParams } from "react-router";
3232
33−import type { Annotation, EnvironmentReviewer, Job, JobSummary, PendingDeployment, RunApproval, RunAttempt, StepState } from "@g1t/contracts";
33+import { type Annotation, type EnvironmentReviewer, type Job, type JobSummary, type PendingDeployment, type RunApproval, type RunAttempt, type StepState, isManagedWorkflow } from "@g1t/contracts";
3434
3535 import type { Route } from "./+types/actions-run";
3636 import { page } from "../../lib/meta";
10131013 <NavUnit key={unit.key} unit={unit} selected={selected} jobHref={jobHref} />
10141014 ))}
10151015 <p className={heading}>Run details</p>
1016− <Link to={`${base}/blob/${run.sha}/${run.path}`} className={navRow(false)}>
1017− <FileCode size={14} className="shrink-0" />
1018− Workflow file
1019− </Link>
1016+ {!isManagedWorkflow(run.path) && (
1017+ <Link to={`${base}/blob/${run.sha}/${run.path}`} className={navRow(false)}>
1018+ <FileCode size={14} className="shrink-0" />
1019+ Workflow file
1020+ </Link>
1021+ )}
10201022 </nav>
10211023 );
10221024 }
+12−5
22 import { useEffect, useRef, useState } from "react";
33 import { Form, Link, useSearchParams } from "react-router";
44
5−import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
5+import { type DispatchInput, type Workflow, type WorkflowRun, isManagedWorkflow } from "@g1t/contracts";
66
77 import type { Route } from "./+types/actions";
88 import { page } from "../../lib/meta";
303303 <div className="flex flex-wrap items-start justify-between gap-3">
304304 <div className="min-w-0">
305305 <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3>
306− <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
307− <FileCode2 size={12} />
308− {workflow.path}
309− </Link>
306+ {isManagedWorkflow(workflow.path) ? (
307+ <span className="inline-flex items-center gap-1 text-xs text-faint">
308+ <BadgeCheck size={12} />
309+ Managed by g1t: it runs here without a file in the repository. A workflow named {workflow.name} in .g1t/workflows/ takes its place.
310+ </span>
311+ ) : (
312+ <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
313+ <FileCode2 size={12} />
314+ {workflow.path}
315+ </Link>
316+ )}
310317 {workflow.events.length > 0 && (
311318 <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p>
312319 )}
+1−0
1313 pub mod events;
1414 pub mod expr;
1515 pub mod filter;
16+pub mod managed;
1617 pub mod mask;
1718 pub mod matrix;
1819 pub mod permissions;
+190−0
1+//! Managed workflows: workflows g1t (or a workspace) installs into a
2+//! repository without committing them. One runs like any workflow in
3+//! `.g1t/workflows/` (the same runs, logs, re-runs and checks), from a
4+//! source kept beside the repository rather than in it, under a path in
5+//! [`FOLDER`] that no file can have.
6+//!
7+//! A repository takes one over by having a workflow of the same `name:` in
8+//! `.g1t/workflows/`: that file runs instead ([`prefer_repository`]). So
9+//! moving a managed workflow into the repository is committing its source.
10+//!
11+//! [`deploy_workflow`] writes the one deployments install: it calls g1t's
12+//! own reusable deploy workflow with the project's settings as its inputs.
13+
14+use crate::workflow;
15+
16+/// Where managed workflows' paths start. Not a folder in any repository:
17+/// workflow files are read from `.g1t/workflows/` and `.github/workflows/`.
18+pub const FOLDER: &str = "managed";
19+
20+/// The `name:` of the workflow deployments install, and of its calling
21+/// job, so the called job's check is `g1t / deploy`, the name a g1t.page
22+/// build's status has always had.
23+pub const DEPLOY_NAME: &str = "g1t";
24+
25+/// The reusable workflow the deploy workflow calls.
26+pub const DEPLOY_CALLS: &str = "g1t/workflows/.g1t/workflows/deploy.yml@v1";
27+
28+/// The path a managed workflow called `name` runs under.
29+pub fn path(name: &str) -> String {
30+ format!("{FOLDER}/{name}.yml")
31+}
32+
33+/// Whether `path` is a managed workflow's.
34+pub fn is_managed(path: &str) -> bool {
35+ path.strip_prefix(FOLDER).is_some_and(|rest| rest.starts_with('/'))
36+}
37+
38+/// A project's deployment settings, as the deploy workflow's triggers and
39+/// inputs.
40+#[derive(Clone, Debug, Default, PartialEq, Eq)]
41+pub struct DeploySettings {
42+ /// The project's slug, which names its app.
43+ pub project: String,
44+ /// The branch production builds from: the repository's default branch.
45+ pub production_branch: String,
46+ /// The project's folder in the repository; empty or `.` for the root.
47+ pub root_dir: String,
48+ /// Empty: what deployments detect.
49+ pub build_command: String,
50+ /// Empty: what deployments detect.
51+ pub output_dir: String,
52+ /// Whether pull requests get previews.
53+ pub previews: bool,
54+ /// Whether pushes to the production branch deploy.
55+ pub production: bool,
56+}
57+
58+/// A string as YAML reads it back unchanged: JSON's quoting is YAML's.
59+fn quoted(text: &str) -> String {
60+ serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_owned())
61+}
62+
63+/// The workflow deployments install for a project, or None when it neither
64+/// previews nor deploys production, so nothing would start it.
65+pub fn deploy_workflow(settings: &DeploySettings) -> Option<String> {
66+ if !settings.previews && !settings.production {
67+ return None;
68+ }
69+ let mut on = String::new();
70+ if settings.production {
71+ on.push_str(&format!(" push:\n branches: [{}]\n", quoted(&settings.production_branch)));
72+ }
73+ if settings.previews {
74+ // Previews come down on the server when their pull request closes.
75+ on.push_str(" pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n");
76+ }
77+ let root_dir = if settings.root_dir.trim().is_empty() { "." } else { settings.root_dir.trim() };
78+ Some(format!(
79+ "# Managed by g1t, from the deployment settings of the project {project}.\n\
80+ # It is not in the repository. A workflow named \"{name}\" in .g1t/workflows/\n\
81+ # runs in its place.\n\
82+ name: {name}\n\
83+ on:\n\
84+ {on}\
85+ # A newer push replaces a build still running for the same branch or\n\
86+ # pull request.\n\
87+ concurrency:\n \
88+ group: ${{{{ github.workflow }}}}-{project_key}-${{{{ github.event.pull_request.number || github.ref }}}}\n \
89+ cancel-in-progress: true\n\
90+ permissions:\n \
91+ contents: read\n \
92+ deployments: write\n \
93+ id-token: write\n\
94+ jobs:\n \
95+ {name}:\n \
96+ name: {name}\n \
97+ uses: {calls}\n \
98+ with:\n \
99+ project: {project}\n \
100+ root-dir: {root_dir}\n \
101+ build-command: {build_command}\n \
102+ output-dir: {output_dir}\n \
103+ secrets: inherit\n",
104+ name = DEPLOY_NAME,
105+ calls = DEPLOY_CALLS,
106+ on = on,
107+ project = quoted(&settings.project),
108+ project_key = settings.project.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect::<String>(),
109+ root_dir = quoted(root_dir),
110+ build_command = quoted(settings.build_command.trim()),
111+ output_dir = quoted(settings.output_dir.trim()),
112+ ))
113+}
114+
115+/// A workflow's name, as it would be listed: its `name:`, else its path.
116+fn listed_name(path: &str, source: &str) -> String {
117+ workflow::parse(source).map(|parsed| parsed.display_name(path)).unwrap_or_else(|_| path.to_owned())
118+}
119+
120+/// The managed workflows that run beside a repository's own `files`, each
121+/// as `(path, source)`: every one, except those a file of the same `name:`
122+/// takes the place of.
123+pub fn prefer_repository<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>, managed: Vec<(String, String)>) -> Vec<(String, String)> {
124+ let theirs: Vec<String> = files.into_iter().map(|(path, source)| listed_name(path, source)).collect();
125+ managed.into_iter().filter(|(path, source)| !theirs.contains(&listed_name(path, source))).collect()
126+}
127+
128+#[cfg(test)]
129+mod tests {
130+ use super::*;
131+ use crate::workflow::Severity;
132+
133+ fn settings() -> DeploySettings {
134+ DeploySettings {
135+ project: "web".into(),
136+ production_branch: "main".into(),
137+ root_dir: "apps/web".into(),
138+ build_command: String::new(),
139+ output_dir: "dist".into(),
140+ previews: true,
141+ production: true,
142+ }
143+ }
144+
145+ #[test]
146+ fn the_deploy_workflow_is_a_workflow_g1t_runs_as_it_is() {
147+ let source = deploy_workflow(&settings()).unwrap();
148+ let parsed = workflow::parse(&source).unwrap();
149+ assert_eq!(parsed.name.as_deref(), Some("g1t"));
150+ assert!(parsed.trigger("push").is_some() && parsed.trigger("pull_request").is_some());
151+ assert!(!parsed.notes.iter().any(|note| note.severity != Severity::Info), "{:?}", parsed.notes);
152+ let job = &parsed.jobs[0];
153+ assert_eq!(job.id, "g1t");
154+ assert!(source.contains("uses: g1t/workflows/.g1t/workflows/deploy.yml@v1"));
155+ assert!(source.contains("root-dir: \"apps/web\"") && source.contains("output-dir: \"dist\"") && source.contains("build-command: \"\""));
156+ assert!(source.contains("branches: [\"main\"]"));
157+ }
158+
159+ #[test]
160+ fn what_a_project_turns_off_starts_nothing() {
161+ let previews_only = deploy_workflow(&DeploySettings { production: false, ..settings() }).unwrap();
162+ let parsed = workflow::parse(&previews_only).unwrap();
163+ assert!(parsed.trigger("push").is_none() && parsed.trigger("pull_request").is_some());
164+ let production_only = deploy_workflow(&DeploySettings { previews: false, ..settings() }).unwrap();
165+ assert!(workflow::parse(&production_only).unwrap().trigger("pull_request").is_none());
166+ assert_eq!(deploy_workflow(&DeploySettings { previews: false, production: false, ..settings() }), None);
167+ }
168+
169+ #[test]
170+ fn settings_are_quoted_so_they_stay_text() {
171+ let tricky = DeploySettings { build_command: "npm run build -- --base \"/x\": yes".into(), root_dir: String::new(), ..settings() };
172+ let source = deploy_workflow(&tricky).unwrap();
173+ let parsed = workflow::parse(&source).unwrap();
174+ let with = &parsed.jobs[0].raw["with"];
175+ assert_eq!(with["build-command"], "npm run build -- --base \"/x\": yes");
176+ assert_eq!(with["root-dir"], ".");
177+ }
178+
179+ #[test]
180+ fn a_repository_workflow_of_the_same_name_takes_its_place() {
181+ let deploy = (path("deploy"), deploy_workflow(&settings()).unwrap());
182+ let other = (path("scan"), "name: Scan\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n".to_owned());
183+ let own = "name: g1t\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n";
184+ let kept = prefer_repository([(".g1t/workflows/g1t.yml", own)], vec![deploy.clone(), other.clone()]);
185+ assert_eq!(kept, vec![other.clone()]);
186+ let kept = prefer_repository([(".g1t/workflows/ci.yml", "name: CI\non: push\njobs: {}\n")], vec![deploy.clone(), other]);
187+ assert_eq!(kept.len(), 2);
188+ assert!(is_managed(&deploy.0) && !is_managed(".g1t/workflows/g1t.yml") && !is_managed("managedx/a.yml"));
189+ }
190+}
+25−0
766766 pub note: Option<String>,
767767 }
768768
769+/// `set_managed_workflow`: installs, or replaces, a managed workflow in a
770+/// repository (`g1t_actions::managed`): one that runs as if it were in
771+/// `.g1t/workflows/` without being committed there, under the path
772+/// `managed/<name>.yml`. For g1t's own services; never reached from the API.
773+/// Refused when `source` is not a workflow g1t can read. Returns `bool`.
774+#[derive(Debug, Serialize, Deserialize)]
775+#[serde(rename_all = "camelCase")]
776+pub struct SetManagedWorkflowArgs {
777+ pub repo_id: String,
778+ /// Letters, digits and hyphens: the file name its path ends with.
779+ pub name: String,
780+ pub source: String,
781+ /// What keeps it: `deployments`, or a workspace's id.
782+ pub origin: String,
783+}
784+
785+/// `remove_managed_workflow`: takes a managed workflow out of a repository.
786+/// Its runs stay. Returns `bool`, whether there was one.
787+#[derive(Debug, Serialize, Deserialize)]
788+#[serde(rename_all = "camelCase")]
789+pub struct RemoveManagedWorkflowArgs {
790+ pub repo_id: String,
791+ pub name: String,
792+}
793+
769794 /// `resolve_settings`: the secrets and variables one reader gets, for the
770795 /// services that hand them out (the deployments service). Returns
771796 /// `ResolvedSettings`.
+9−0
430430 export type ArtifactBlob = { artifact: Artifact; object: string; blob: string };
431431
432432 export type ArtifactRetention = { days: number; maximum_allowed_days: number };
433+
434+/**
435+ * Whether a workflow's path is a managed workflow's: one g1t installed in
436+ * the repository without committing it (crates/actions/src/managed.rs), so
437+ * no file in the repository holds it.
438+ */
439+export function isManagedWorkflow(path: string): boolean {
440+ return path.startsWith("managed/");
441+}
+13−0
1+-- Managed workflows (src/managed.rs): workflows installed into a repository
2+-- without being committed to it, such as the one deployments install for a
3+-- project. Each runs under the path `managed/<name>.yml`; a workflow of the
4+-- same `name:` in the repository's own folder runs in its place.
5+CREATE TABLE IF NOT EXISTS managed_workflows (
6+ repo_id TEXT NOT NULL,
7+ name TEXT NOT NULL,
8+ source TEXT NOT NULL,
9+ -- What installed it and keeps it: `deployments`, or a workspace's id.
10+ origin TEXT NOT NULL,
11+ updated_at TEXT NOT NULL,
12+ PRIMARY KEY (repo_id, name)
13+);
+4−0
2727
2828 mod run_artifacts;
2929 mod cache;
30+mod managed;
3031 mod mirrored;
3132 mod payload;
3233 mod plan;
251252 "set_setting" => reply(&service.set_setting(args(body)?).await?),
252253 "delete_setting" => reply(&service.delete_setting(args(body)?).await?),
253254 "resolve_settings" => reply(&service.resolve_settings(args(body)?).await?),
255+ // Managed workflows: g1t's services' side only.
256+ "set_managed_workflow" => reply(&service.set_managed_workflow(args(body)?).await?),
257+ "remove_managed_workflow" => reply(&service.remove_managed_workflow(args(body)?).await?),
254258 "job_spec" => reply(&service.job_spec(args(body)?).await?),
255259 "job_auth" => reply(&service.job_auth(args(body)?).await?),
256260 "job_action" => reply(&service.job_action(args(body)?).await?),
+102−0
1+//! Managed workflows (`g1t_actions::managed`): workflows g1t's services
2+//! install into a repository without committing them. They are kept here,
3+//! read beside the repository's own files wherever its workflows are read
4+//! (`sync.rs` `read_for`), and so listed, started, run and re-run as any
5+//! other workflow is.
6+
7+use g1t_actions::managed;
8+use g1t_actions::workflow;
9+use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedWorkflowArgs};
10+use g1t_contracts::time::rfc3339;
11+use g1t_contracts::{FailureCode, Outcome};
12+use g1t_kit::now_ms;
13+use serde::Deserialize;
14+use worker::Result;
15+
16+use crate::sync::WorkflowFile;
17+use crate::{Actions, fail};
18+
19+/// Whether `name` can end a managed workflow's path.
20+fn valid_name(name: &str) -> bool {
21+ !name.is_empty() && name.len() <= 64 && name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
22+}
23+
24+#[derive(Deserialize)]
25+struct Row {
26+ name: String,
27+ source: String,
28+}
29+
30+impl Actions {
31+ /// `set_managed_workflow`.
32+ pub async fn set_managed_workflow(&self, a: SetManagedWorkflowArgs) -> Result<Outcome<bool>> {
33+ if !valid_name(&a.name) {
34+ return Ok(fail(FailureCode::Invalid, "A managed workflow's name is lowercase letters, digits and hyphens."));
35+ }
36+ if let Err(problem) = workflow::parse(&a.source) {
37+ return Ok(fail(FailureCode::Invalid, format!("The managed workflow does not read as a workflow: {problem}")));
38+ }
39+ let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
40+ return Ok(fail(FailureCode::NotFound, "No such repository."));
41+ };
42+ self.db
43+ .prepare(
44+ "INSERT INTO managed_workflows (repo_id, name, source, origin, updated_at) VALUES (?, ?, ?, ?, ?)
45+ ON CONFLICT (repo_id, name) DO UPDATE SET source = excluded.source, origin = excluded.origin, updated_at = excluded.updated_at",
46+ )
47+ .bind(&[a.repo_id.as_str().into(), a.name.as_str().into(), a.source.as_str().into(), a.origin.as_str().into(), rfc3339(now_ms()).into()])?
48+ .run()
49+ .await?;
50+ // Listed, and on schedule, at once.
51+ self.sync(&repo, &ws).await?;
52+ Ok(Outcome::Ok(true))
53+ }
54+
55+ /// `remove_managed_workflow`.
56+ pub async fn remove_managed_workflow(&self, a: RemoveManagedWorkflowArgs) -> Result<Outcome<bool>> {
57+ let removed = self
58+ .db
59+ .prepare("DELETE FROM managed_workflows WHERE repo_id = ? AND name = ? RETURNING name")
60+ .bind(&[a.repo_id.as_str().into(), a.name.as_str().into()])?
61+ .first::<serde_json::Value>(None)
62+ .await?
63+ .is_some();
64+ if removed && let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? {
65+ self.sync(&repo, &ws).await?;
66+ }
67+ Ok(Outcome::Ok(removed))
68+ }
69+
70+ /// The managed workflows that run beside `files`, a repository's own:
71+ /// each one no file of the same `name:` takes the place of.
72+ pub(crate) async fn managed_beside(&self, repo_id: &str, files: &[WorkflowFile]) -> Result<Vec<WorkflowFile>> {
73+ let rows = self
74+ .db
75+ .prepare("SELECT name, source FROM managed_workflows WHERE repo_id = ? ORDER BY name")
76+ .bind(&[repo_id.into()])?
77+ .all()
78+ .await?
79+ .results::<Row>()?;
80+ if rows.is_empty() {
81+ return Ok(Vec::new());
82+ }
83+ let installed = rows.into_iter().map(|row| (managed::path(&row.name), row.source)).collect();
84+ Ok(managed::prefer_repository(files.iter().map(|file| (file.path.as_str(), file.source.as_str())), installed)
85+ .into_iter()
86+ .map(|(path, source)| WorkflowFile { path, source })
87+ .collect())
88+ }
89+}
90+
91+#[cfg(test)]
92+mod tests {
93+ use super::*;
94+
95+ #[test]
96+ fn managed_workflows_are_named_as_files() {
97+ assert!(valid_name("deploy") && valid_name("code-scanning-2"));
98+ for bad in ["", "Deploy", "a/b", "a.yml", "../x", &"a".repeat(65)] {
99+ assert!(!valid_name(bad), "{bad}");
100+ }
101+ }
102+}
+16−9
6262
6363 /// The workflow files a repository runs: `.g1t/workflows`, and on a
6464 /// mirror in CI failover or taken over, `.github/workflows` too (see
65− /// mirrored.rs), where g1t's own stands in for one of the same name.
65+ /// mirrored.rs), where g1t's own stands in for one of the same name;
66+ /// then the managed workflows installed in it that none of those take
67+ /// the place of (managed.rs).
6668 pub async fn read_for(&self, repo: &Repo, actor: &User, git_ref: Option<&str>, github: bool) -> Result<Read> {
6769 let path = RepoPath {
6870 namespace: repo.namespace.clone(),
6971 name: repo.name.clone(),
7072 };
71− if !github {
72− return self.read_workflows(&path, actor, git_ref).await;
73− }
74− let read = self.read_folders(&path, actor, git_ref, &[FOLDER, crate::mirrored::GITHUB_FOLDER]).await?;
75− Ok(Read {
76− files: crate::mirrored::prefer_g1t(read.files),
77− head: read.head,
78− })
73+ let mut read = if github {
74+ let read = self.read_folders(&path, actor, git_ref, &[FOLDER, crate::mirrored::GITHUB_FOLDER]).await?;
75+ Read {
76+ files: crate::mirrored::prefer_g1t(read.files),
77+ head: read.head,
78+ }
79+ } else {
80+ self.read_workflows(&path, actor, git_ref).await?
81+ };
82+ // And the managed workflows installed beside them (managed.rs).
83+ let managed = self.managed_beside(&repo.id, &read.files).await?;
84+ read.files.extend(managed);
85+ Ok(read)
7986 }
8087
8188 async fn read_folders(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>, folders: &[&str]) -> Result<Read> {