Skip to content
206 linesCodeBlameRaw
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}