Skip to content
171 linesCodeBlameRaw
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
12use g1t_actions::workflow::Workflow;
13use g1t_contracts::mirrors::{MirrorState, RepoMirror};
14
15/// GitHub's own folder, read on a mirror in CI failover or taken over.
16pub const GITHUB_FOLDER: &str = ".github/workflows";
17
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub 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.
31pub 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.
45pub 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.
56pub 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.
67pub 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.
74pub 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.
79pub 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.
87pub 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)]
101mod 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}