| 1 | //! What runs on a mirror (see `g1t_contracts::mirrors`). |
| 2 | //! |
| 3 | //! A mirror standing by runs nothing: its workflows run where it is |
| 4 | //! mirrored from. Its owners can keep CI warm, which runs `.g1t/workflows` |
| 5 | //! on the pushes copied in. In CI failover the remote keeps the code and |
| 6 | //! g1t runs its workflows, GitHub's `.github/workflows` as well as g1t's |
| 7 | //! own; during a takeover it does too, unless the link says not to. A |
| 8 | //! workflow that deploys waits for approval in both, unless the link says |
| 9 | //! otherwise, so nothing deploys from two places. While a takeover is |
| 10 | //! handed back, nothing starts. |
| 11 | |
| 12 | use g1t_actions::workflow::Workflow; |
| 13 | use g1t_contracts::mirrors::{MirrorState, RepoMirror}; |
| 14 | |
| 15 | /// GitHub's own folder, read on a mirror in CI failover or taken over. |
| 16 | pub const GITHUB_FOLDER: &str = ".github/workflows"; |
| 17 | |
| 18 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 19 | pub struct Policy { |
| 20 | /// Whether workflows start at all. |
| 21 | pub runs: bool, |
| 22 | /// Whether `.github/workflows` is read as well as `.g1t/workflows`. |
| 23 | pub github: bool, |
| 24 | /// Set when a workflow that deploys waits for approval: the remote, as |
| 25 | /// people name it. |
| 26 | pub hold: Option<String>, |
| 27 | } |
| 28 | |
| 29 | /// What runs on a repository with `mirror`, for an event that was a push |
| 30 | /// copied in from the remote (`copied_in`) or anything else. |
| 31 | pub fn policy(mirror: Option<&RepoMirror>, copied_in: bool) -> Policy { |
| 32 | let Some(mirror) = mirror else { |
| 33 | return Policy { runs: true, github: false, hold: None }; |
| 34 | }; |
| 35 | let hold = mirror.hold_deploys.then(|| mirror.remote.clone()); |
| 36 | match mirror.state { |
| 37 | MirrorState::Standby => Policy { runs: copied_in && mirror.warm, github: false, hold: None }, |
| 38 | MirrorState::Ci => Policy { runs: true, github: true, hold }, |
| 39 | MirrorState::Takeover => Policy { runs: true, github: mirror.github_workflows, hold }, |
| 40 | MirrorState::HandingBack => Policy::default(), |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | /// Why nothing runs, for someone who asked for a run. |
| 45 | pub fn refused(namespace: &str, name: &str, mirror: &RepoMirror) -> String { |
| 46 | match mirror.state { |
| 47 | MirrorState::HandingBack => format!("{namespace}/{name} is handing back to {}. Workflows start again once that is done.", mirror.remote), |
| 48 | _ => format!( |
| 49 | "{namespace}/{name} is a standby mirror of {remote}: its workflows run there. Start CI failover or take over in Settings → Mirroring to run them on g1t.", |
| 50 | remote = mirror.remote |
| 51 | ), |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | /// The environment a workflow deploys to, if any job names one. |
| 56 | pub fn deploys_to(workflow: &Workflow) -> Option<String> { |
| 57 | workflow.jobs.iter().find_map(|job| match &job.raw["environment"] { |
| 58 | serde_json::Value::String(name) => Some(name.clone()), |
| 59 | serde_json::Value::Object(environment) => { |
| 60 | Some(environment.get("name").and_then(|name| name.as_str()).unwrap_or("an environment").to_owned()) |
| 61 | } |
| 62 | _ => None, |
| 63 | }) |
| 64 | } |
| 65 | |
| 66 | /// Why a deploying workflow waits. |
| 67 | pub fn held(remote: &str, environment: &str) -> String { |
| 68 | format!( |
| 69 | "This workflow deploys to {environment}. While {remote} may still deploy too, runs that deploy wait for approval so nothing deploys twice." |
| 70 | ) |
| 71 | } |
| 72 | |
| 73 | /// Whether a change touches what runs: workflows or local actions. |
| 74 | pub fn touches_workflows(paths: &[String]) -> bool { |
| 75 | paths.iter().any(|path| path.starts_with(".g1t/") || path.starts_with(".github/workflows/") || path.starts_with(".github/actions/")) |
| 76 | } |
| 77 | |
| 78 | /// Why a run from a push copied in waits. |
| 79 | pub fn copied_workflows(remote: &str) -> String { |
| 80 | format!( |
| 81 | "This push came from {remote} and changes workflows. Approve the run to let it use this repository's secrets and variables." |
| 82 | ) |
| 83 | } |
| 84 | |
| 85 | /// Drops `.github` workflows that a `.g1t` one of the same name stands in |
| 86 | /// for: g1t's own wins. |
| 87 | pub fn prefer_g1t(files: Vec<crate::sync::WorkflowFile>) -> Vec<crate::sync::WorkflowFile> { |
| 88 | let name = |file: &crate::sync::WorkflowFile| { |
| 89 | g1t_actions::workflow::parse(&file.source) |
| 90 | .map(|workflow| workflow.display_name(&file.path)) |
| 91 | .unwrap_or_else(|_| file.path.clone()) |
| 92 | }; |
| 93 | let ours: Vec<String> = files.iter().filter(|file| !file.path.starts_with(GITHUB_FOLDER)).map(name).collect(); |
| 94 | files |
| 95 | .into_iter() |
| 96 | .filter(|file| !file.path.starts_with(GITHUB_FOLDER) || !ours.contains(&name(file))) |
| 97 | .collect() |
| 98 | } |
| 99 | |
| 100 | #[cfg(test)] |
| 101 | mod tests { |
| 102 | use super::*; |
| 103 | |
| 104 | fn mirror(state: MirrorState) -> RepoMirror { |
| 105 | RepoMirror { |
| 106 | state, |
| 107 | remote: "github.com/acme/web".into(), |
| 108 | warm: false, |
| 109 | github_workflows: true, |
| 110 | hold_deploys: true, |
| 111 | ..RepoMirror::default() |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | #[test] |
| 116 | fn a_standby_mirror_runs_nothing_unless_kept_warm() { |
| 117 | assert_eq!(policy(None, false), Policy { runs: true, github: false, hold: None }); |
| 118 | assert!(!policy(Some(&mirror(MirrorState::Standby)), true).runs); |
| 119 | let warm = RepoMirror { warm: true, ..mirror(MirrorState::Standby) }; |
| 120 | assert_eq!(policy(Some(&warm), true), Policy { runs: true, github: false, hold: None }); |
| 121 | assert!(!policy(Some(&warm), false).runs, "only the pushes copied in"); |
| 122 | assert!(!policy(Some(&mirror(MirrorState::HandingBack)), true).runs); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn ci_failover_and_takeover_run_githubs_workflows_and_hold_deploys() { |
| 127 | let ci = policy(Some(&mirror(MirrorState::Ci)), true); |
| 128 | assert!(ci.runs && ci.github); |
| 129 | assert_eq!(ci.hold.as_deref(), Some("github.com/acme/web")); |
| 130 | let quiet = RepoMirror { github_workflows: false, hold_deploys: false, ..mirror(MirrorState::Takeover) }; |
| 131 | assert_eq!(policy(Some(&quiet), false), Policy { runs: true, github: false, hold: None }); |
| 132 | assert!(refused("acme", "web", &mirror(MirrorState::Standby)).contains("standby mirror of github.com/acme/web")); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn a_workflow_deploys_when_a_job_names_an_environment() { |
| 137 | let parse = |text: &str| g1t_actions::workflow::parse(text).unwrap(); |
| 138 | let build = parse("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"); |
| 139 | assert_eq!(deploys_to(&build), None); |
| 140 | let deploy = parse("on: push\njobs:\n ship:\n runs-on: ubuntu-latest\n environment: production\n steps:\n - run: echo\n"); |
| 141 | assert_eq!(deploys_to(&deploy).as_deref(), Some("production")); |
| 142 | let named = parse( |
| 143 | "on: push\njobs:\n ship:\n runs-on: ubuntu-latest\n environment:\n name: staging\n url: https://x\n steps:\n - run: echo\n", |
| 144 | ); |
| 145 | assert_eq!(deploys_to(&named).as_deref(), Some("staging")); |
| 146 | } |
| 147 | |
| 148 | #[test] |
| 149 | fn a_copied_push_that_changes_workflows_waits() { |
| 150 | let paths = |list: &[&str]| list.iter().map(|path| path.to_string()).collect::<Vec<_>>(); |
| 151 | assert!(touches_workflows(&paths(&["src/a.rs", ".g1t/workflows/ci.yml"]))); |
| 152 | assert!(touches_workflows(&paths(&[".github/workflows/deploy.yml"]))); |
| 153 | assert!(touches_workflows(&paths(&[".github/actions/setup/action.yml"]))); |
| 154 | assert!(!touches_workflows(&paths(&[".github/CODEOWNERS", "README.md"]))); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn g1ts_own_workflow_stands_in_for_githubs_of_the_same_name() { |
| 159 | let file = |path: &str, name: &str| crate::sync::WorkflowFile { |
| 160 | path: path.into(), |
| 161 | source: format!("name: {name}\non: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo\n"), |
| 162 | }; |
| 163 | let kept = prefer_g1t(vec![ |
| 164 | file(".g1t/workflows/ci.yml", "CI"), |
| 165 | file(".github/workflows/ci.yml", "CI"), |
| 166 | file(".github/workflows/lint.yml", "Lint"), |
| 167 | ]); |
| 168 | let paths: Vec<&str> = kept.iter().map(|file| file.path.as_str()).collect(); |
| 169 | assert_eq!(paths, [".g1t/workflows/ci.yml", ".github/workflows/lint.yml"]); |
| 170 | } |
| 171 | } |