Skip to content
206 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.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
14use 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/`.
18pub 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.
23pub const DEPLOY_NAME: &str = "g1t";
24
25/// The reusable workflow the deploy workflow calls.
26pub const DEPLOY_CALLS: &str = "g1t/workflows/.g1t/workflows/deploy.yml@v1";
27
28/// The path a managed workflow called `name` runs under.
29pub fn path(name: &str) -> String {
30 format!("{FOLDER}/{name}.yml")
31}
32
33/// Whether `path` is a managed workflow's.
34pub 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)]
41pub 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.
59fn 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.
65pub 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.
116fn 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.
123pub 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)]
129mod 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 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]
176 fn what_a_project_turns_off_starts_nothing() {
177 let previews_only = deploy_workflow(&DeploySettings { production: false, ..settings() }).unwrap();
178 let parsed = workflow::parse(&previews_only).unwrap();
179 assert!(parsed.trigger("push").is_none() && parsed.trigger("pull_request").is_some());
180 let production_only = deploy_workflow(&DeploySettings { previews: false, ..settings() }).unwrap();
181 assert!(workflow::parse(&production_only).unwrap().trigger("pull_request").is_none());
182 assert_eq!(deploy_workflow(&DeploySettings { previews: false, production: false, ..settings() }), None);
183 }
184
185 #[test]
186 fn settings_are_quoted_so_they_stay_text() {
187 let tricky = DeploySettings { build_command: "npm run build -- --base \"/x\": yes".into(), root_dir: String::new(), ..settings() };
188 let source = deploy_workflow(&tricky).unwrap();
189 let parsed = workflow::parse(&source).unwrap();
190 let with = &parsed.jobs[0].raw["with"];
191 assert_eq!(with["build-command"], "npm run build -- --base \"/x\": yes");
192 assert_eq!(with["root-dir"], ".");
193 }
194
195 #[test]
196 fn a_repository_workflow_of_the_same_name_takes_its_place() {
197 let deploy = (path("deploy"), deploy_workflow(&settings()).unwrap());
198 let other = (path("scan"), "name: Scan\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n".to_owned());
199 let own = "name: g1t\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n";
200 let kept = prefer_repository([(".g1t/workflows/g1t.yml", own)], vec![deploy.clone(), other.clone()]);
201 assert_eq!(kept, vec![other.clone()]);
202 let kept = prefer_repository([(".g1t/workflows/ci.yml", "name: CI\non: push\njobs: {}\n")], vec![deploy.clone(), other]);
203 assert_eq!(kept.len(), 2);
204 assert!(is_managed(&deploy.0) && !is_managed(".g1t/workflows/g1t.yml") && !is_managed("managedx/a.yml"));
205 }
206}

This file's history is long; its oldest lines are credited to the oldest commit read.