| 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 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 | } |