Skip to content
307 linesCodeBlameRaw
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", "smoke"]);
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 // Check and plan run side by side: plan waits for nothing.
165 let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap();
166 assert!(plan.needs.is_empty(), "{:?}", plan.needs);
167 for id in ["migrate", "core", "edge", "front", "smoke"] {
168 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
169 assert!(job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"), "{id}: {:?}", job.needs);
170 }
171 // A failed check, though plan succeeded: nothing migrates or deploys,
172 // and failure() still sees the check's failure through skipped jobs.
173 assert!(!starts(&deploy, "migrate", &[("check", "failure"), ("plan", "success")], &all, push.clone(), false));
174 assert!(!starts(&deploy, "core", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped")], &all, push.clone(), false));
175 assert!(!starts(&deploy, "front", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")], &all, push.clone(), false));
176 let skipped = [("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")];
177 assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true));
178 assert!(!starts(&deploy, "smoke", &[("check", "failure"), ("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, push.clone(), false));
179 // A failed plan stops everything too.
180 assert!(!starts(&deploy, "migrate", &[("plan", "failure")], &all, push.clone(), false));
181 assert!(!starts(&deploy, "core", &[("plan", "failure"), ("migrate", "skipped")], &all, push.clone(), false));
182
183 // Smoke follows the last stage that ran, and not a failed one.
184 assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "success"), ("front", "success")], &all, push.clone(), false));
185 assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "skipped"), ("front", "success")], &none, push.clone(), false));
186 assert!(!starts(&deploy, "smoke", &[("core", "success"), ("edge", "failure"), ("front", "skipped")], &all, push.clone(), false));
187 // Nothing deployed: nothing to smoke-test.
188 let nothing = plan_outputs(false, &[], &[], &[]);
189 assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &nothing, push.clone(), false));
190
191 // A dry run plans and stops.
192 let dry = json!({ "dry_run": true, "units": "", "all": false });
193 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
194 assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry.clone(), false));
195 assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, dry, false));
196}
197
198#[test]
199fn deploy_stage_matrices_come_from_the_plan() {
200 let deploy = read("deploy.yml");
201 let outputs = plan_outputs(false, &[("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)], &[], &[]);
202 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
203 assert!(!core.fail_fast);
204 assert_eq!(core.max_parallel, Some(4));
205 let mut contexts = Map::new();
206 contexts.insert("needs".into(), json!({ "plan": { "result": "success", "outputs": outputs } }));
207 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
208 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
209 let jobs = matrix::expand(&value).unwrap();
210 let groups: Vec<(&str, &str, bool)> = jobs
211 .iter()
212 .map(|c| (c["group"].as_str().unwrap(), c["units"].as_str().unwrap(), c["rust"].as_bool().unwrap()))
213 .collect();
214 assert_eq!(groups, [("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)]);
215}
216
217#[test]
218fn deploy_builds_rust_on_the_larger_machine_with_its_target_cached() {
219 let deploy = read("deploy.yml");
220 for stage in ["core", "edge", "front"] {
221 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
222 let on = |rust: bool, image: bool| {
223 let mut contexts = Map::new();
224 contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image }));
225 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
226 expr::interpolate_value(&job.runs_on, &scope).unwrap()
227 };
228 assert_eq!(on(true, false), json!("g1t-4core"), "{stage}");
229 // The runner's image is built with the job's own Docker Engine.
230 assert_eq!(on(false, true), json!("g1t-4core"), "{stage}");
231 assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}");
232 }
233 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
234 assert!(source.contains("target/wasm32-unknown-unknown/release"));
235 assert!(source.contains("!target/**/incremental"));
236 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
237}
238
239/// Whether a step runs, for a job going `status`, with `matrix`.
240fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool {
241 let mut contexts = Map::new();
242 contexts.insert("matrix".into(), matrix);
243 let scope = Scope { contexts: &contexts, status, hash_files: None };
244 expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
245}
246
247#[test]
248fn rust_builds_go_through_sccache_before_cargo_and_report_after() {
249 let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) {
250 let at = job.steps.iter().position(|s| s.run.as_deref() == Some(run)).unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
251 (at, job.steps[at].clone())
252 };
253 let deploy = read("deploy.yml");
254 for stage in ["core", "edge", "front"] {
255 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
256 let (install_at, install) = step(job, "bash scripts/sccache.sh install");
257 let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats");
258 // Before anything runs Cargo (worker-build's install, the build),
259 // and the statistics last.
260 let install_step = job.steps.iter().position(|s| s.name.as_deref() == Some("Install")).unwrap();
261 assert!(install_at < install_step, "{stage}");
262 assert_eq!(stats_at, job.steps.len() - 1, "{stage}");
263 for (rust, image, uses) in [(true, false, true), (false, true, true), (false, false, false)] {
264 let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image });
265 assert_eq!(step_runs(&install, matrix.clone(), Status::Success), uses, "{stage} rust={rust} image={image}");
266 assert_eq!(step_runs(&stats, matrix.clone(), Status::Success), uses, "{stage}");
267 // A failed build still says what was cached.
268 assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}");
269 }
270 }
271 let ci = read("ci.yml");
272 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
273 let (install_at, _) = step(rust, "bash scripts/sccache.sh install");
274 let (tests_at, _) = step(rust, "cargo test --workspace --locked --quiet");
275 let (stats_at, stats) = step(rust, "bash scripts/sccache.sh stats");
276 assert!(install_at < tests_at && tests_at < stats_at);
277 assert!(step_runs(&stats, json!({}), Status::Failure));
278 // main's runs keep the caches pull requests restore from: only Rust.
279 assert!(ci.trigger("push").unwrap().branches.allows("main"));
280 assert!(ci.trigger("pull_request").is_some());
281 for (id, on_push) in [("rust", true), ("typescript", false), ("build", false)] {
282 let job = ci.jobs.iter().find(|j| j.id == id).unwrap();
283 for (event, expected) in [("push", on_push), ("pull_request", true)] {
284 let mut contexts = Map::new();
285 contexts.insert("github".into(), json!({ "event_name": event, "ref": "refs/heads/main" }));
286 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
287 assert_eq!(expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(), expected, "{id} on {event}");
288 }
289 }
290 // The download is pinned by version and checksum.
291 let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap();
292 assert!(script.contains("VERSION=0.18.0"));
293 assert!(script.lines().any(|line| line.strip_prefix("SHA256=").is_some_and(|sum| sum.len() == 64)));
294 assert!(script.contains("sha256sum -c"));
295 assert!(script.contains("RUSTC_WRAPPER="));
296 assert!(script.contains("GITHUB_STEP_SUMMARY"));
297}
298
299#[test]
300fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
301 let base = read("runner-base.yml");
302 assert!(base.trigger("schedule").is_some());
303 assert!(base.trigger("workflow_dispatch").is_some());
304 assert!(base.trigger("push").unwrap().branches.allows("main"));
305 let job = base.jobs.iter().find(|j| j.id == "build").unwrap();
306 assert_eq!(job.runs_on, json!(["self-hosted", "docker"]));
307}