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.
| A repository can run a managed workflow: one installed by g1t's own services without a file in the repository. It is listed, started, run and re-run like any workflow in `.g1t/workflows/`, under the path `managed/<name>.yml`, and a workflow of the same `name:` in the repository runs in its place, so moving one into the repository is committing its source. The Actions page says a managed workflow is managed by g1t rather than linking to a file. Nothing installs one yet; the deploy workflow deployments will install, which calls g1t's reusable deploy workflow with a project's settings as its inputs and reports as `g1t / deploy`, is written and tested but not installed. | 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 what_a_project_turns_off_starts_nothing() { | |
| 161 | let previews_only = deploy_workflow(&DeploySettings { production: false, ..settings() }).unwrap(); | |
| 162 | let parsed = workflow::parse(&previews_only).unwrap(); | |
| 163 | assert!(parsed.trigger("push").is_none() && parsed.trigger("pull_request").is_some()); | |
| 164 | let production_only = deploy_workflow(&DeploySettings { previews: false, ..settings() }).unwrap(); | |
| 165 | assert!(workflow::parse(&production_only).unwrap().trigger("pull_request").is_none()); | |
| 166 | assert_eq!(deploy_workflow(&DeploySettings { previews: false, production: false, ..settings() }), None); | |
| 167 | } | |
| 168 | ||
| 169 | #[test] | |
| 170 | fn settings_are_quoted_so_they_stay_text() { | |
| 171 | let tricky = DeploySettings { build_command: "npm run build -- --base \"/x\": yes".into(), root_dir: String::new(), ..settings() }; | |
| 172 | let source = deploy_workflow(&tricky).unwrap(); | |
| 173 | let parsed = workflow::parse(&source).unwrap(); | |
| 174 | let with = &parsed.jobs[0].raw["with"]; | |
| 175 | assert_eq!(with["build-command"], "npm run build -- --base \"/x\": yes"); | |
| 176 | assert_eq!(with["root-dir"], "."); | |
| 177 | } | |
| 178 | ||
| 179 | #[test] | |
| 180 | fn a_repository_workflow_of_the_same_name_takes_its_place() { | |
| 181 | let deploy = (path("deploy"), deploy_workflow(&settings()).unwrap()); | |
| 182 | let other = (path("scan"), "name: Scan\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n".to_owned()); | |
| 183 | let own = "name: g1t\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n"; | |
| 184 | let kept = prefer_repository([(".g1t/workflows/g1t.yml", own)], vec![deploy.clone(), other.clone()]); | |
| 185 | assert_eq!(kept, vec![other.clone()]); | |
| 186 | let kept = prefer_repository([(".g1t/workflows/ci.yml", "name: CI\non: push\njobs: {}\n")], vec![deploy.clone(), other]); | |
| 187 | assert_eq!(kept.len(), 2); | |
| 188 | assert!(is_managed(&deploy.0) && !is_managed(".g1t/workflows/g1t.yml") && !is_managed("managedx/a.yml")); | |
| 189 | } | |
| 190 | } |