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.
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.