Skip to content
412 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 ([`arrange`]). So moving a
9//! managed workflow into the repository is committing its source. A
10//! workspace's managed workflows ([`Target`], [`Mode`]) are the exception
11//! when they are required: then the repository's file of that name is set
12//! aside instead, and no repository can turn the workflow off.
13//!
14//! [`deploy_workflow`] writes the one deployments install: it calls g1t's
15//! own reusable deploy workflow with the project's settings as its inputs.
16
17use crate::workflow;
18
19/// Where managed workflows' paths start. Not a folder in any repository:
20/// workflow files are read from `.g1t/workflows/` and `.github/workflows/`.
21pub const FOLDER: &str = "managed";
22
23/// The `name:` of the workflow deployments install, and of its calling
24/// job, so the called job's check is `g1t / deploy`, the name a g1t.page
25/// build's status has always had.
26pub const DEPLOY_NAME: &str = "g1t";
27
28/// The reusable workflow the deploy workflow calls.
29pub const DEPLOY_CALLS: &str = "g1t/workflows/.g1t/workflows/deploy.yml@v1";
30
31/// The path a managed workflow called `name` runs under.
32pub fn path(name: &str) -> String {
33 format!("{FOLDER}/{name}.yml")
34}
35
36/// Whether `path` is a managed workflow's.
37pub fn is_managed(path: &str) -> bool {
38 path.strip_prefix(FOLDER).is_some_and(|rest| rest.starts_with('/'))
39}
40
41/// A project's deployment settings, as the deploy workflow's triggers and
42/// inputs.
43#[derive(Clone, Debug, Default, PartialEq, Eq)]
44pub struct DeploySettings {
45 /// The project's slug, which names its app.
46 pub project: String,
47 /// The branch production builds from: the repository's default branch.
48 pub production_branch: String,
49 /// The project's folder in the repository; empty or `.` for the root.
50 pub root_dir: String,
51 /// Empty: what deployments detect.
52 pub build_command: String,
53 /// Empty: what deployments detect.
54 pub output_dir: String,
55 /// Whether pull requests get previews.
56 pub previews: bool,
57 /// Whether pushes to the production branch deploy.
58 pub production: bool,
59}
60
61/// A string as YAML reads it back unchanged: JSON's quoting is YAML's.
62fn quoted(text: &str) -> String {
63 serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_owned())
64}
65
66/// The workflow deployments install for a project, or None when it neither
67/// previews nor deploys production, so nothing would start it.
68pub fn deploy_workflow(settings: &DeploySettings) -> Option<String> {
69 if !settings.previews && !settings.production {
70 return None;
71 }
72 let mut on = String::new();
73 if settings.production {
74 on.push_str(&format!(" push:\n branches: [{}]\n", quoted(&settings.production_branch)));
75 }
76 if settings.previews {
77 // Previews come down on the server when their pull request closes.
78 on.push_str(" pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n");
79 }
80 let root_dir = if settings.root_dir.trim().is_empty() { "." } else { settings.root_dir.trim() };
81 Some(format!(
82 "# Managed by g1t, from the deployment settings of the project {project}.\n\
83 # It is not in the repository. A workflow named \"{name}\" in .g1t/workflows/\n\
84 # runs in its place.\n\
85 name: {name}\n\
86 on:\n\
87 {on}\
88 # A newer push replaces a build still running for the same branch or\n\
89 # pull request.\n\
90 concurrency:\n \
91 group: ${{{{ github.workflow }}}}-{project_key}-${{{{ github.event.pull_request.number || github.ref }}}}\n \
92 cancel-in-progress: true\n\
93 permissions:\n \
94 contents: read\n \
95 deployments: write\n \
96 id-token: write\n\
97 jobs:\n \
98 {name}:\n \
99 name: {name}\n \
100 uses: {calls}\n \
101 with:\n \
102 project: {project}\n \
103 root-dir: {root_dir}\n \
104 build-command: {build_command}\n \
105 output-dir: {output_dir}\n \
106 secrets: inherit\n",
107 name = DEPLOY_NAME,
108 calls = DEPLOY_CALLS,
109 on = on,
110 project = quoted(&settings.project),
111 project_key = settings.project.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect::<String>(),
112 root_dir = quoted(root_dir),
113 build_command = quoted(settings.build_command.trim()),
114 output_dir = quoted(settings.output_dir.trim()),
115 ))
116}
117
118/// A workflow's name, as it would be listed: its `name:`, else its path.
119fn listed_name(path: &str, source: &str) -> String {
120 workflow::parse(source).map(|parsed| parsed.display_name(path)).unwrap_or_else(|_| path.to_owned())
121}
122
123/// Whether `name` can end a managed workflow's path: lowercase letters,
124/// digits and hyphens, at most 64.
125pub fn valid_name(name: &str) -> bool {
126 !name.is_empty() && name.len() <= 64 && name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
127}
128
129/// How a workspace's managed workflow holds in its repositories.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub enum Mode {
132 /// It runs in every repository it targets: a workflow of the same
133 /// `name:` in the repository is set aside, and no repository can turn
134 /// it off, so its check can be required.
135 Required,
136 /// It runs where the repository has no workflow of the same `name:`,
137 /// and the repository's admins can turn it off.
138 Default,
139}
140
141impl Mode {
142 pub fn parse(text: &str) -> Option<Mode> {
143 match text.trim().to_ascii_lowercase().as_str() {
144 "required" => Some(Mode::Required),
145 "default" => Some(Mode::Default),
146 _ => None,
147 }
148 }
149
150 pub fn as_str(self) -> &'static str {
151 match self {
152 Mode::Required => "required",
153 Mode::Default => "default",
154 }
155 }
156}
157
158/// The visibilities a target can name.
159pub const VISIBILITIES: [&str; 3] = ["all", "public", "private"];
160
161/// Which of a workspace's repositories one of its managed workflows runs in.
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub struct Target {
164 /// Repository names and patterns (`*`, `[a-z]`; `!` excludes what an
165 /// earlier one included), read in order, the last match deciding,
166 /// without regard to case. Empty is every repository; only exclusions
167 /// is every repository but those.
168 pub repositories: Vec<String>,
169 /// One of [`VISIBILITIES`].
170 pub visibility: String,
171}
172
173impl Target {
174 /// Whether it takes in the repository `name`, private or not. A
175 /// workflow read from a private repository (`source_private`) never
176 /// runs in a public one, whose workflow page and logs anyone can read.
177 pub fn includes(&self, name: &str, private: bool, source_private: bool) -> bool {
178 if source_private && !private {
179 return false;
180 }
181 match self.visibility.as_str() {
182 "public" if private => return false,
183 "private" if !private => return false,
184 _ => {}
185 }
186 if self.repositories.is_empty() {
187 return true;
188 }
189 let lowered: Vec<String> = self.repositories.iter().map(|pattern| pattern.trim().to_ascii_lowercase()).collect();
190 let mut patterns = crate::filter::Patterns::new(&lowered);
191 if patterns.0.iter().all(|pattern| pattern.negated) {
192 patterns.0.insert(0, crate::filter::Pattern::parse("*"));
193 }
194 patterns.includes(&name.to_ascii_lowercase())
195 }
196}
197
198/// A workflow a repository runs, as [`arrange`] decides.
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct Arranged {
201 pub path: String,
202 pub source: String,
203 /// A workspace's required workflow: nothing turns it off.
204 pub required: bool,
205}
206
207/// The workflows a repository runs: its own `files`, and the managed
208/// workflows offered to it, those `installed` in it by g1t's services (such
209/// as a project's deploy workflow) and its workspace's (`workspace`, each
210/// with its [`Mode`]), all as `(path, source)`.
211///
212/// - The workspace's required workflows run, and a file of the same
213/// `name:` is set aside, so nothing else reports their checks.
214/// - Then the repository's own files.
215/// - Then each other managed workflow that no workflow before it of the
216/// same `name:` or path takes the place of: those installed in the
217/// repository first, then the workspace's defaults.
218pub fn arrange(files: Vec<(String, String)>, installed: Vec<(String, String)>, workspace: Vec<(String, String, Mode)>) -> Vec<Arranged> {
219 let mut names: Vec<String> = Vec::new();
220 let mut paths: Vec<String> = Vec::new();
221 let mut out: Vec<Arranged> = Vec::new();
222 let (required, defaults): (Vec<_>, Vec<_>) = workspace.into_iter().partition(|(_, _, mode)| *mode == Mode::Required);
223 let offer = |path: String, source: String, required: bool, out: &mut Vec<Arranged>, names: &mut Vec<String>, paths: &mut Vec<String>| {
224 let name = listed_name(&path, &source);
225 if names.contains(&name) || paths.contains(&path) {
226 return;
227 }
228 names.push(name);
229 paths.push(path.clone());
230 out.push(Arranged { path, source, required });
231 };
232 for (path, source, _) in required {
233 offer(path, source, true, &mut out, &mut names, &mut paths);
234 }
235 // A repository's files are set aside only for a required workflow's
236 // name, never for each other's.
237 let required_names = names.clone();
238 for (path, source) in files {
239 let name = listed_name(&path, &source);
240 if required_names.contains(&name) {
241 continue;
242 }
243 names.push(name);
244 paths.push(path.clone());
245 out.push(Arranged { path, source, required: false });
246 }
247 for (path, source) in installed.into_iter().chain(defaults.into_iter().map(|(path, source, _)| (path, source))) {
248 offer(path, source, false, &mut out, &mut names, &mut paths);
249 }
250 out
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::workflow::Severity;
257
258 fn settings() -> DeploySettings {
259 DeploySettings {
260 project: "web".into(),
261 production_branch: "main".into(),
262 root_dir: "apps/web".into(),
263 build_command: String::new(),
264 output_dir: "dist".into(),
265 previews: true,
266 production: true,
267 }
268 }
269
270 #[test]
271 fn the_deploy_workflow_is_a_workflow_g1t_runs_as_it_is() {
272 let source = deploy_workflow(&settings()).unwrap();
273 let parsed = workflow::parse(&source).unwrap();
274 assert_eq!(parsed.name.as_deref(), Some("g1t"));
275 assert!(parsed.trigger("push").is_some() && parsed.trigger("pull_request").is_some());
276 assert!(!parsed.notes.iter().any(|note| note.severity != Severity::Info), "{:?}", parsed.notes);
277 let job = &parsed.jobs[0];
278 assert_eq!(job.id, "g1t");
279 assert!(source.contains("uses: g1t/workflows/.g1t/workflows/deploy.yml@v1"));
280 assert!(source.contains("root-dir: \"apps/web\"") && source.contains("output-dir: \"dist\"") && source.contains("build-command: \"\""));
281 assert!(source.contains("branches: [\"main\"]"));
282 }
283
284 #[test]
285 fn the_called_workflow_takes_every_input_the_deploy_workflow_gives() {
286 let called = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../workflows/.g1t/workflows/deploy.yml")).unwrap();
287 let parsed = workflow::parse(&called).unwrap();
288 assert!(!parsed.notes.iter().any(|note| note.severity != Severity::Info), "{:?}", parsed.notes);
289 let raw: serde_json::Value = serde_yaml::from_str(&called).unwrap();
290 let inputs = &raw["on"]["workflow_call"]["inputs"];
291 let caller = workflow::parse(&deploy_workflow(&settings()).unwrap()).unwrap();
292 for (key, _) in caller.jobs[0].raw["with"].as_object().unwrap() {
293 assert!(inputs.get(key).is_some(), "the called workflow takes no `{key}`");
294 }
295 // Its job is `deploy`, so the caller's `g1t` reports `g1t / deploy`.
296 assert_eq!(parsed.jobs[0].raw["name"], "deploy");
297 assert!(DEPLOY_CALLS.starts_with("g1t/workflows/.g1t/workflows/deploy.yml@"));
298 }
299
300 #[test]
301 fn what_a_project_turns_off_starts_nothing() {
302 let previews_only = deploy_workflow(&DeploySettings { production: false, ..settings() }).unwrap();
303 let parsed = workflow::parse(&previews_only).unwrap();
304 assert!(parsed.trigger("push").is_none() && parsed.trigger("pull_request").is_some());
305 let production_only = deploy_workflow(&DeploySettings { previews: false, ..settings() }).unwrap();
306 assert!(workflow::parse(&production_only).unwrap().trigger("pull_request").is_none());
307 assert_eq!(deploy_workflow(&DeploySettings { previews: false, production: false, ..settings() }), None);
308 }
309
310 #[test]
311 fn settings_are_quoted_so_they_stay_text() {
312 let tricky = DeploySettings { build_command: "npm run build -- --base \"/x\": yes".into(), root_dir: String::new(), ..settings() };
313 let source = deploy_workflow(&tricky).unwrap();
314 let parsed = workflow::parse(&source).unwrap();
315 let with = &parsed.jobs[0].raw["with"];
316 assert_eq!(with["build-command"], "npm run build -- --base \"/x\": yes");
317 assert_eq!(with["root-dir"], ".");
318 }
319
320 fn own(path: &str, name: &str) -> (String, String) {
321 (path.to_owned(), format!("name: {name}\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{{ run: 'true' }}]\n"))
322 }
323
324 fn paths(arranged: &[Arranged]) -> Vec<&str> {
325 arranged.iter().map(|kept| kept.path.as_str()).collect()
326 }
327
328 #[test]
329 fn a_repository_workflow_of_the_same_name_takes_its_place() {
330 let deploy = (path("deploy"), deploy_workflow(&settings()).unwrap());
331 let other = own(&path("scan"), "Scan");
332 let kept = arrange(vec![own(".g1t/workflows/g1t.yml", "g1t")], vec![deploy.clone(), other.clone()], vec![]);
333 assert_eq!(paths(&kept), [".g1t/workflows/g1t.yml", "managed/scan.yml"]);
334 let kept = arrange(vec![own(".g1t/workflows/ci.yml", "CI")], vec![deploy.clone(), other], vec![]);
335 assert_eq!(kept.len(), 3);
336 assert!(kept.iter().all(|kept| !kept.required));
337 assert!(is_managed(&deploy.0) && !is_managed(".g1t/workflows/g1t.yml") && !is_managed("managedx/a.yml"));
338 }
339
340 #[test]
341 fn a_required_workflow_sets_aside_a_file_of_its_name() {
342 let files = vec![own(".g1t/workflows/scan.yml", "Scan"), own(".g1t/workflows/ci.yml", "CI")];
343 let kept = arrange(files, vec![], vec![(path("scan"), own("", "Scan").1, Mode::Required)]);
344 assert_eq!(paths(&kept), ["managed/scan.yml", ".g1t/workflows/ci.yml"]);
345 assert!(kept[0].required && !kept[1].required);
346 }
347
348 #[test]
349 fn a_default_workflow_gives_way_to_a_file_of_its_name() {
350 let files = vec![own(".g1t/workflows/lint.yml", "Lint")];
351 let workspace = vec![(path("lint"), own("", "Lint").1, Mode::Default), (path("audit"), own("", "Audit").1, Mode::Default)];
352 let kept = arrange(files, vec![], workspace);
353 assert_eq!(paths(&kept), [".g1t/workflows/lint.yml", "managed/audit.yml"]);
354 assert!(kept.iter().all(|kept| !kept.required));
355 }
356
357 #[test]
358 fn a_repository_keeps_two_files_of_one_name() {
359 let files = vec![own(".g1t/workflows/a.yml", "Same"), own(".g1t/workflows/b.yml", "Same")];
360 assert_eq!(arrange(files, vec![], vec![]).len(), 2);
361 }
362
363 #[test]
364 fn what_is_installed_in_the_repository_comes_between_required_and_default() {
365 // The same path: required beats installed, installed beats default.
366 let installed = vec![own(&path("deploy-web"), "Deploy")];
367 let kept = arrange(vec![], installed.clone(), vec![(path("deploy-web"), own("", "Policy").1, Mode::Required)]);
368 assert_eq!(kept.len(), 1);
369 assert!(kept[0].source.contains("Policy") && kept[0].required);
370 let kept = arrange(vec![], installed, vec![(path("deploy-web"), own("", "Policy").1, Mode::Default)]);
371 assert_eq!(kept.len(), 1);
372 assert!(kept[0].source.contains("Deploy") && !kept[0].required);
373 // The same name at another path: the first offered stays.
374 let kept = arrange(vec![], vec![own(&path("a"), "Same")], vec![(path("b"), own("", "Same").1, Mode::Default)]);
375 assert_eq!(paths(&kept), ["managed/a.yml"]);
376 }
377
378 #[test]
379 fn targets_take_names_patterns_and_visibility() {
380 let all = Target { repositories: vec![], visibility: "all".into() };
381 assert!(all.includes("web", true, false) && all.includes("web", false, false));
382 let some = Target { repositories: vec!["web".into(), "api-*".into()], visibility: "all".into() };
383 assert!(some.includes("web", false, false) && some.includes("API-gateway", false, false));
384 assert!(!some.includes("docs", false, false) && !some.includes("webx", false, false));
385 let but = Target { repositories: vec!["!sandbox-*".into()], visibility: "all".into() };
386 assert!(but.includes("web", false, false) && !but.includes("sandbox-1", false, false));
387 let then_back = Target { repositories: vec!["*".into(), "!sandbox-*".into(), "sandbox-keep".into()], visibility: "all".into() };
388 assert!(then_back.includes("sandbox-keep", false, false) && !then_back.includes("sandbox-2", false, false));
389 let private = Target { repositories: vec![], visibility: "private".into() };
390 assert!(private.includes("web", true, false) && !private.includes("web", false, false));
391 let public = Target { repositories: vec![], visibility: "public".into() };
392 assert!(!public.includes("web", true, false) && public.includes("web", false, false));
393 }
394
395 #[test]
396 fn a_private_source_never_runs_in_a_public_repository() {
397 let all = Target { repositories: vec!["*".into()], visibility: "all".into() };
398 assert!(all.includes("web", true, true));
399 assert!(!all.includes("site", false, true));
400 }
401
402 #[test]
403 fn modes_and_names_read_as_written() {
404 assert_eq!(Mode::parse("Required"), Some(Mode::Required));
405 assert_eq!(Mode::parse("default").map(Mode::as_str), Some("default"));
406 assert_eq!(Mode::parse("optional"), None);
407 assert!(valid_name("deploy") && valid_name("code-scanning-2"));
408 for bad in ["", "Deploy", "a/b", "a.yml", "../x", &"a".repeat(65)] {
409 assert!(!valid_name(bad), "{bad}");
410 }
411 }
412}