| 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 | |
| 17 | use 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/`. |
| 21 | pub 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. |
| 26 | pub const DEPLOY_NAME: &str = "g1t"; |
| 27 | |
| 28 | /// The reusable workflow the deploy workflow calls. |
| 29 | pub const DEPLOY_CALLS: &str = "g1t/workflows/.g1t/workflows/deploy.yml@v1"; |
| 30 | |
| 31 | /// The path a managed workflow called `name` runs under. |
| 32 | pub fn path(name: &str) -> String { |
| 33 | format!("{FOLDER}/{name}.yml") |
| 34 | } |
| 35 | |
| 36 | /// Whether `path` is a managed workflow's. |
| 37 | pub 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)] |
| 44 | pub 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. |
| 62 | fn 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. |
| 68 | pub 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. |
| 119 | fn 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. |
| 125 | pub 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)] |
| 131 | pub 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 | |
| 141 | impl 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. |
| 159 | pub 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)] |
| 163 | pub 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 | |
| 173 | impl 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)] |
| 200 | pub 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. |
| 218 | pub 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)] |
| 254 | mod 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 | } |