Skip to content

g1t/crates/actions/tests/repository_workflows.rs

226 lines11,767 bytesCodeBlameRaw
1//! g1t's own workflows (`.g1t/workflows/*.yml`), read by the same parser
2//! and expressions the actions service runs them with: each reads, nothing
3//! in it is unsupported, and the deploy workflow's jobs start, wait and
4//! stop as docs/DEPLOYING.md says.
5
6use std::path::PathBuf;
7
8use g1t_actions::expr::{self, Scope, Status};
9use g1t_actions::matrix;
10use g1t_actions::workflow::{self, Severity, Workflow};
11use serde_json::{Map, Value, json};
12
13fn workflows_dir() -> PathBuf {
14 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.g1t/workflows")
15}
16
17fn read(name: &str) -> Workflow {
18 let source = std::fs::read_to_string(workflows_dir().join(name)).unwrap();
19 workflow::parse(&source).unwrap_or_else(|problem| panic!("{name}: {problem}"))
20}
21
22#[test]
23fn every_workflow_asks_for_only_what_its_token_does() {
24 use g1t_actions::permissions::{Access, TokenDefault};
25 let job = |name: &str, id: &str| {
26 let workflow = read(name);
27 let job = workflow.jobs.iter().find(|job| job.id == id).unwrap().clone();
28 job.permissions(&workflow, TokenDefault::Restricted)
29 };
30 // CI and Deploy only read: nothing they do writes with the token.
31 for (name, id) in [("ci.yml", "rust"), ("deploy.yml", "core"), ("runner-release.yml", "binaries")] {
32 let permissions = job(name, id);
33 assert!(permissions.listed().iter().all(|(_, access)| *access <= Access::Read), "{name} {id}");
34 }
35 // The runner base pushes a branch and opens a pull request.
36 let base = job("runner-base.yml", "build");
37 assert_eq!(base.get("contents"), Access::Write);
38 assert_eq!(base.get("pull-requests"), Access::Write);
39 assert_eq!(base.get("packages"), Access::None);
40 // The runner's image goes to g1t's registry.
41 let image = job("runner-release.yml", "image");
42 assert_eq!(image.get("packages"), Access::Write);
43 assert_eq!(image.get("contents"), Access::Read);
44}
45
46#[test]
47fn every_workflow_reads_and_runs_on_g1t() {
48 let mut count = 0;
49 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
50 let path = entry.unwrap().path();
51 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
52 continue;
53 }
54 let name = path.file_name().unwrap().to_string_lossy().to_string();
55 let workflow = read(&name);
56 let unsupported: Vec<_> = workflow.notes.iter().filter(|n| n.severity != Severity::Info).collect();
57 assert!(unsupported.is_empty(), "{name}: {unsupported:?}");
58 count += 1;
59 }
60 assert!(count >= 1);
61}
62
63/// The plan job's outputs for a deploy of `stages` (each with its jobs).
64fn plan_outputs(migrate: bool, core: &[(&str, &str, bool)], edge: &[(&str, &str, bool)], front: &[(&str, &str, bool)]) -> Value {
65 let matrix = |jobs: &[(&str, &str, bool)]| {
66 let include: Vec<Value> = if jobs.is_empty() {
67 vec![json!({ "group": "none", "units": "" })]
68 } else {
69 jobs.iter().map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust })).collect()
70 };
71 json!({ "include": include }).to_string()
72 };
73 json!({
74 "migrate": migrate.to_string(),
75 "migrate_units": if migrate { "events" } else { "" },
76 "has_core": (!core.is_empty()).to_string(),
77 "core": matrix(core),
78 "has_edge": (!edge.is_empty()).to_string(),
79 "edge": matrix(edge),
80 "has_front": (!front.is_empty()).to_string(),
81 "front": matrix(front),
82 })
83}
84
85/// Whether `job` starts, given its needs' results, as the actions service
86/// decides it (services/actions/src/plan.rs `decide`, with
87/// `expr::job_status`): a need that was skipped is not a failure, and a
88/// failure anywhere before the job is.
89fn starts(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool) -> bool {
90 starts_after(workflow, job, needs, outputs, inputs, cancelled, false)
91}
92
93/// `starts`, where `failed_before` says a job further back failed (one the
94/// needs were skipped for).
95fn starts_after(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool, failed_before: bool) -> bool {
96 let job = workflow.jobs.iter().find(|j| j.id == job).unwrap();
97 let mut needs_ctx = Map::new();
98 let mut results = Vec::new();
99 for need in &job.needs {
100 let result = needs.iter().find(|(name, _)| name == need).map(|(_, r)| *r).unwrap_or("success");
101 results.push(result);
102 let outputs = if need == "plan" { outputs.clone() } else { json!({}) };
103 needs_ctx.insert(need.clone(), json!({ "result": result, "outputs": outputs }));
104 }
105 let status = expr::job_status(results, failed_before, cancelled);
106 let mut contexts = Map::new();
107 contexts.insert("needs".into(), Value::Object(needs_ctx));
108 contexts.insert("inputs".into(), inputs);
109 contexts.insert("github".into(), json!({ "event_name": "push", "ref": "refs/heads/main" }));
110 let scope = Scope { contexts: &contexts, status, hash_files: None };
111 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap()
112}
113
114#[test]
115fn deploy_runs_on_main_and_by_hand_one_at_a_time() {
116 let deploy = read("deploy.yml");
117 let push = deploy.trigger("push").unwrap();
118 assert!(push.branches.allows("main"));
119 assert!(!push.branches.allows("feature"));
120 let dispatch = deploy.trigger("workflow_dispatch").unwrap();
121 for input in ["units", "all", "dry_run"] {
122 assert!(dispatch.inputs.contains_key(input), "{input}");
123 }
124 let concurrency = deploy.concurrency.as_ref().unwrap();
125 assert_eq!(concurrency.group, "deploy-production");
126 assert_eq!(concurrency.cancel_in_progress, json!(false));
127 assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front"]);
128 // The stages share their steps (a YAML alias), and read the token only
129 // where they deploy.
130 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
131 assert_eq!(steps("core"), steps("edge"));
132 assert_eq!(steps("core"), steps("front"));
133 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
134 assert!(!source.contains("cancel-in-progress: true"));
135}
136
137#[test]
138fn deploy_stages_follow_one_another() {
139 let deploy = read("deploy.yml");
140 let all = plan_outputs(true, &[("rust", "events,repos", true), ("ts", "projects", false)], &[("rust", "api", true)], &[("web", "web", false)]);
141 let push = json!({});
142
143 // Everything succeeds: each stage runs after the last.
144 assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
145 assert!(starts(&deploy, "core", &[("migrate", "success")], &all, push.clone(), false));
146 assert!(starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), false));
147 assert!(starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "success")], &all, push.clone(), false));
148
149 // No migrations: the migrate job is skipped, and core still runs.
150 let none = plan_outputs(false, &[("ts", "projects", false)], &[], &[("web", "web", false)]);
151 assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
152 assert!(starts(&deploy, "core", &[("migrate", "skipped")], &none, push.clone(), false));
153 // An empty stage is skipped, and the next one still runs.
154 assert!(!starts(&deploy, "edge", &[("migrate", "skipped"), ("core", "success")], &none, push.clone(), false));
155 assert!(starts(&deploy, "front", &[("migrate", "skipped"), ("core", "success"), ("edge", "skipped")], &none, push.clone(), false));
156
157 // A failure stops every later stage.
158 assert!(!starts(&deploy, "core", &[("migrate", "failure")], &all, push.clone(), false));
159 assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "failure")], &all, push.clone(), false));
160 assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "failure")], &all, push.clone(), false));
161 assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "failure"), ("edge", "skipped")], &all, push.clone(), false));
162 // A cancelled run starts nothing more.
163 assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), true));
164 // A failed check: plan, and every stage after it, is skipped, and
165 // failure() still sees the check's failure through them.
166 let skipped = [("plan", "skipped"), ("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")];
167 assert!(!starts_after(&deploy, "plan", &[("check", "failure")], &all, push.clone(), false, false));
168 assert!(!starts_after(&deploy, "core", &skipped, &all, push.clone(), false, true));
169 assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true));
170
171 // A dry run plans and stops.
172 let dry = json!({ "dry_run": true, "units": "", "all": false });
173 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
174 assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry, false));
175}
176
177#[test]
178fn deploy_stage_matrices_come_from_the_plan() {
179 let deploy = read("deploy.yml");
180 let outputs = plan_outputs(false, &[("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)], &[], &[]);
181 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
182 assert!(!core.fail_fast);
183 assert_eq!(core.max_parallel, Some(4));
184 let mut contexts = Map::new();
185 contexts.insert("needs".into(), json!({ "plan": { "result": "success", "outputs": outputs } }));
186 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
187 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
188 let jobs = matrix::expand(&value).unwrap();
189 let groups: Vec<(&str, &str, bool)> = jobs
190 .iter()
191 .map(|c| (c["group"].as_str().unwrap(), c["units"].as_str().unwrap(), c["rust"].as_bool().unwrap()))
192 .collect();
193 assert_eq!(groups, [("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)]);
194}
195
196#[test]
197fn deploy_builds_rust_on_the_larger_machine_with_its_target_cached() {
198 let deploy = read("deploy.yml");
199 for stage in ["core", "edge", "front"] {
200 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
201 let on = |rust: bool, image: bool| {
202 let mut contexts = Map::new();
203 contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image }));
204 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
205 expr::interpolate_value(&job.runs_on, &scope).unwrap()
206 };
207 assert_eq!(on(true, false), json!("g1t-4core"), "{stage}");
208 // The runner's image is built with the job's own Docker Engine.
209 assert_eq!(on(false, true), json!("g1t-4core"), "{stage}");
210 assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}");
211 }
212 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
213 assert!(source.contains("target/wasm32-unknown-unknown/release"));
214 assert!(source.contains("!target/**/incremental"));
215 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
216}
217
218#[test]
219fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
220 let base = read("runner-base.yml");
221 assert!(base.trigger("schedule").is_some());
222 assert!(base.trigger("workflow_dispatch").is_some());
223 assert!(base.trigger("push").unwrap().branches.allows("main"));
224 let job = base.jobs.iter().find(|j| j.id == "build").unwrap();
225 assert_eq!(job.runs_on, json!(["self-hosted", "docker"]));
226}