Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,577 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.g1t.sh/guides/deploy-to-cloudflare/ 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
28 .jobs
29 .iter()
30 .find(|job| job.id == id)
31 .unwrap()
32 .clone();
33 job.permissions(&workflow, TokenDefault::Restricted)
34 };
35 // CI and Deploy only read: nothing they do writes with the token.
36 // The actions release pushes with its own secret, never the job's token.
37 for (name, id) in [
38 ("ci.yml", "rust"),
39 ("deploy.yml", "core"),
40 ("runner-release.yml", "binaries"),
41 ("actions-release.yml", "publish"),
42 ] {
43 let permissions = job(name, id);
44 assert!(
45 permissions
46 .listed()
47 .iter()
48 .all(|(_, access)| *access <= Access::Read),
49 "{name} {id}"
50 );
51 }
52 // The runner base pushes a branch and opens a pull request.
53 let base = job("runner-base.yml", "build");
54 assert_eq!(base.get("contents"), Access::Write);
55 assert_eq!(base.get("pull-requests"), Access::Write);
56 assert_eq!(base.get("packages"), Access::None);
57 // The runner's image goes to g1t's registry.
58 let image = job("runner-release.yml", "image");
59 assert_eq!(image.get("packages"), Access::Write);
60 assert_eq!(image.get("contents"), Access::Read);
61 // Cache tidy reads the code and deletes cache entries, nothing else.
62 let tidy = job("cache-tidy.yml", "tidy");
63 assert_eq!(tidy.get("actions"), Access::Write);
64 assert_eq!(tidy.get("contents"), Access::Read);
65 assert_eq!(tidy.get("packages"), Access::None);
66}
67
68#[test]
69fn every_workflow_reads_and_runs_on_g1t() {
70 let mut count = 0;
71 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
72 let path = entry.unwrap().path();
73 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
74 continue;
75 }
76 let name = path.file_name().unwrap().to_string_lossy().to_string();
77 let workflow = read(&name);
78 let unsupported: Vec<_> = workflow
79 .notes
80 .iter()
81 .filter(|n| n.severity != Severity::Info)
82 .collect();
83 assert!(unsupported.is_empty(), "{name}: {unsupported:?}");
84 count += 1;
85 }
86 assert!(count >= 1);
87}
88
89/// The plan job's outputs for a deploy of `stages` (each with its jobs).
90fn plan_outputs(
91 migrate: bool,
92 core: &[(&str, &str, bool)],
93 edge: &[(&str, &str, bool)],
94 front: &[(&str, &str, bool)],
95) -> Value {
96 // As scripts/deploy.mjs writes them: a Rust group or a site is built
97 // ahead (`prebuilt`), and every stage's builds are one matrix.
98 let entries = |stage: &str, jobs: &[(&str, &str, bool)]| -> Vec<Value> {
99 jobs.iter()
100 .map(|(group, units, rust)| {
101 let prebuilt = *rust || *group == "web" || *group == "docs";
102 json!({ "group": group, "units": units, "rust": rust, "prebuilt": prebuilt, "stage": stage })
103 })
104 .collect()
105 };
106 let matrix = |include: Vec<Value>| {
107 let include = if include.is_empty() {
108 vec![json!({ "group": "none", "units": "" })]
109 } else {
110 include
111 };
112 json!({ "include": include }).to_string()
113 };
114 let builds: Vec<Value> = [("core", core), ("edge", edge), ("front", front)]
115 .iter()
116 .flat_map(|(stage, jobs)| entries(stage, jobs))
117 .filter(|job| job["prebuilt"] == json!(true))
118 .collect();
119 json!({
120 "migrate": migrate.to_string(),
121 "migrate_units": if migrate { "events" } else { "" },
122 "has_builds": (!builds.is_empty()).to_string(),
123 "builds": matrix(builds),
124 "has_core": (!core.is_empty()).to_string(),
125 "core": matrix(entries("core", core)),
126 "has_edge": (!edge.is_empty()).to_string(),
127 "edge": matrix(entries("edge", edge)),
128 "has_front": (!front.is_empty()).to_string(),
129 "front": matrix(entries("front", front)),
130 })
131}
132
133/// Whether `job` starts, given its needs' results, as the actions service
134/// decides it (services/actions/src/plan.rs `decide`, with
135/// `expr::job_status`): a need that was skipped is not a failure, and a
136/// failure anywhere before the job is.
137fn starts(
138 workflow: &Workflow,
139 job: &str,
140 needs: &[(&str, &str)],
141 outputs: &Value,
142 inputs: Value,
143 cancelled: bool,
144) -> bool {
145 starts_after(workflow, job, needs, outputs, inputs, cancelled, false)
146}
147
148/// `starts`, where `failed_before` says a job further back failed (one the
149/// needs were skipped for).
150fn starts_after(
151 workflow: &Workflow,
152 job: &str,
153 needs: &[(&str, &str)],
154 outputs: &Value,
155 inputs: Value,
156 cancelled: bool,
157 failed_before: bool,
158) -> bool {
159 let job = workflow.jobs.iter().find(|j| j.id == job).unwrap();
160 let mut needs_ctx = Map::new();
161 let mut results = Vec::new();
162 for need in &job.needs {
163 let result = needs
164 .iter()
165 .find(|(name, _)| name == need)
166 .map(|(_, r)| *r)
167 .unwrap_or("success");
168 results.push(result);
169 let outputs = if need == "plan" {
170 outputs.clone()
171 } else {
172 json!({})
173 };
174 needs_ctx.insert(
175 need.clone(),
176 json!({ "result": result, "outputs": outputs }),
177 );
178 }
179 let status = expr::job_status(results, failed_before, cancelled);
180 let mut contexts = Map::new();
181 contexts.insert("needs".into(), Value::Object(needs_ctx));
182 contexts.insert("inputs".into(), inputs);
183 contexts.insert(
184 "github".into(),
185 json!({ "event_name": "push", "ref": "refs/heads/main" }),
186 );
187 let scope = Scope {
188 contexts: &contexts,
189 status,
190 hash_files: None,
191 };
192 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap()
193}
194
195#[test]
196fn deploy_runs_on_main_and_by_hand_one_at_a_time() {
197 let deploy = read("deploy.yml");
198 let push = deploy.trigger("push").unwrap();
199 assert!(push.branches.allows("main"));
200 assert!(!push.branches.allows("feature"));
201 let dispatch = deploy.trigger("workflow_dispatch").unwrap();
202 for input in ["units", "all", "dry_run"] {
203 assert!(dispatch.inputs.contains_key(input), "{input}");
204 }
205 let concurrency = deploy.concurrency.as_ref().unwrap();
206 assert_eq!(concurrency.group, "deploy-production");
207 assert_eq!(concurrency.cancel_in_progress, json!(false));
208 assert_eq!(
209 deploy.job_order(),
210 ["check", "plan", "build", "migrate", "core", "edge", "front", "smoke"]
211 );
212 // The stages share their steps (a YAML alias), and read the token only
213 // where they deploy.
214 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
215 assert_eq!(steps("core"), steps("edge"));
216 assert_eq!(steps("core"), steps("front"));
217 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
218 assert!(!source.contains("cancel-in-progress: true"));
219}
220
221/// Whether a step's `run` installs the whole lockfile: `npm ci` with no
222/// workspace narrowing it, and not the deploy tool's install of some units.
223fn installs_everything(run: &str) -> bool {
224 run.split('\n').any(|line| {
225 let line = line.trim();
226 // Installs go through npm-ci.mjs, which runs `npm ci` again when a connection drops.
227 (line.starts_with("npm ci") || line.starts_with("node scripts/deploy/npm-ci.mjs"))
228 && !line.contains(" -w ")
229 && !line.contains("--workspace")
230 })
231}
232
233#[test]
234fn npm_downloads_are_cached_whole_and_only_whole_installs_save_them() {
235 // Every job's npm cache is the whole lockfile's, under one key. A job
236 // that installs part of it only restores: had it saved, its part would
237 // stand in for the whole under that key (the first job to finish used
238 // to save Wrangler alone, and every later install downloaded the rest).
239 let mut saving = Vec::new();
240 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
241 let path = entry.unwrap().path();
242 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
243 continue;
244 }
245 let name = path.file_name().unwrap().to_string_lossy().to_string();
246 for job in read(&name).jobs {
247 let whole = job
248 .steps
249 .iter()
250 .any(|s| s.run.as_deref().is_some_and(installs_everything));
251 for step in &job.steps {
252 let Some(uses) = step.uses.as_deref() else {
253 continue;
254 };
255 if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm")
256 {
257 continue;
258 }
259 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
260 assert_eq!(
261 key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}",
262 "{name} {}",
263 job.id
264 );
265 if let Some(prefix) = step.raw["with"]["restore-keys"].as_str() {
266 assert_eq!(prefix, "npm-full-${{ runner.os }}-", "{name} {}", job.id);
267 }
268 let saves =
269 uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@");
270 assert!(
271 !saves || whole,
272 "{name} {}: saves npm's cache but installs only part of the lockfile",
273 job.id
274 );
275 if saves {
276 saving.push(format!("{name} {}", job.id));
277 }
278 }
279 }
280 }
281 // main's whole cache is kept by the Caches workflow.
282 assert!(saving.contains(&"caches.yml npm".to_owned()), "{saving:?}");
283}
284
285/// Every `actions/cache` step of every workflow: (file, job, step).
286fn cache_steps() -> Vec<(String, workflow::Job, workflow::Step)> {
287 let mut out = Vec::new();
288 let mut names: Vec<String> = std::fs::read_dir(workflows_dir())
289 .unwrap()
290 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
291 .filter(|n| n.ends_with(".yml"))
292 .collect();
293 names.sort();
294 for name in names {
295 for job in read(&name).jobs {
296 for step in &job.steps {
297 if step
298 .uses
299 .as_deref()
300 .is_some_and(|u| u.starts_with("actions/cache"))
301 {
302 out.push((name.clone(), job.clone(), step.clone()));
303 }
304 }
305 }
306 }
307 out
308}
309
310/// What a cache step's key starts with before its first expression: the
311/// entry it is, whatever its inputs (`cargo-test-`, `apt-tauri-windows-`).
312fn family(key: &str) -> String {
313 key.split("${{").next().unwrap_or_default().to_owned()
314}
315
316fn saves(step: &workflow::Step) -> bool {
317 let uses = step.uses.as_deref().unwrap_or_default();
318 (uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@"))
319 && step.raw["with"]["lookup-only"] != json!(true)
320}
321
322fn paths(step: &workflow::Step) -> Vec<String> {
323 let text = step.raw["with"]["path"].as_str().unwrap_or_default();
324 text.lines()
325 .map(str::trim)
326 .filter(|l| !l.is_empty())
327 .map(str::to_owned)
328 .collect()
329}
330
331#[test]
332fn each_cache_has_one_set_of_paths_wherever_it_is_used() {
333 // An entry's version is the hash of its paths: a step that names the
334 // same key with other paths finds nothing, silently. So every step of
335 // an entry, in every workflow, names the same paths.
336 let mut seen: std::collections::BTreeMap<String, (Vec<String>, String)> = Default::default();
337 for (file, job, step) in cache_steps() {
338 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
339 let family = family(key);
340 assert!(!family.is_empty(), "{file} {}: a key starts with its name", job.id);
341 let here = format!("{file} {}", job.id);
342 match seen.get(&family) {
343 Some((first, at)) => assert_eq!(
344 &paths(&step),
345 first,
346 "{here} names other paths for {family} than {at}"
347 ),
348 None => {
349 seen.insert(family, (paths(&step), here));
350 }
351 }
352 }
353 for family in [
354 "npm-full-",
355 "cargo-crates-",
356 "cargo-test-",
357 "cargo-workers-",
358 "runner-musl-",
359 "runner-release-",
360 "desktop-check-",
361 "desktop-release-linux-",
362 "desktop-release-windows-",
363 "apt-tauri-",
364 "apt-tauri-windows-",
365 "xwin-",
366 "zig-0.13.0-zigbuild-",
367 "cli-release-",
368 ] {
369 assert!(seen.contains_key(family), "{family}: {:?}", seen.keys());
370 }
371}
372
373#[test]
374fn main_saves_every_cache_that_pull_requests_and_releases_restore() {
375 // A run restores from its own ref, its pull request's base and main;
376 // a release tag's run never reads another tag's entries. So every
377 // entry any step restores is saved by a workflow that runs on pushes
378 // to main, and the release workflows only restore.
379 let on_main = ["ci.yml", "caches.yml", "deploy.yml"];
380 for file in on_main {
381 let push = read(file).trigger("push").cloned().unwrap();
382 assert!(push.branches.allows("main"), "{file}");
383 }
384 let releases = [
385 "cli-release.yml",
386 "desktop-release.yml",
387 "mobile-build.yml",
388 "mobile-release.yml",
389 "runner-release.yml",
390 ];
391 let steps = cache_steps();
392 let saved_on_main: std::collections::BTreeSet<String> = steps
393 .iter()
394 .filter(|(file, _, step)| on_main.contains(&file.as_str()) && saves(step))
395 .map(|(_, _, step)| family(step.raw["with"]["key"].as_str().unwrap_or_default()))
396 .collect();
397 for (file, job, step) in &steps {
398 let family = family(step.raw["with"]["key"].as_str().unwrap_or_default());
399 // The conformance run restores what its own first job saved.
400 if family == "conformance-" {
401 continue;
402 }
403 assert!(
404 saved_on_main.contains(&family),
405 "{file} {}: nothing on main saves {family}",
406 job.id
407 );
408 if releases.contains(&file.as_str()) {
409 assert!(!saves(step), "{file} {}: a release saves {family}, which only its own tag would read", job.id);
410 }
411 }
412}
413
414#[test]
415fn rust_caches_are_keyed_by_the_toolchain_and_the_dependencies() {
416 // Not by Cargo.lock as a whole: 25 of its 31 changes in a week were a
417 // release's version bump, and each saved every Rust cache again.
418 for (file, job, step) in cache_steps() {
419 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
420 assert!(
421 !key.contains("Cargo.lock") && !key.contains("base.json"),
422 "{file} {}: {key}",
423 job.id
424 );
425 let target = paths(&step).iter().any(|p| p.contains("target"));
426 if target {
427 // The toolchain that built it: this machine's rustc, or for
428 // the CLI's release, the cargo-zigbuild image it builds in.
429 let toolchain = if paths(&step).iter().any(|p| p.starts_with("target/zig")) {
430 "steps.keys.outputs.zigbuild"
431 } else {
432 "steps.keys.outputs.rust"
433 };
434 assert!(key.contains(toolchain), "{file} {}: {key}", job.id);
435 // A restore key never reaches another toolchain's entries.
436 for prefix in step.raw["with"]["restore-keys"]
437 .as_str()
438 .unwrap_or_default()
439 .lines()
440 .filter(|l| !l.trim().is_empty())
441 {
442 assert!(prefix.contains(toolchain), "{file} {}: {prefix}", job.id);
443 }
444 }
445 if key.contains("steps.keys.outputs.") {
446 let at = job.steps.iter().position(|s| s == &step).unwrap();
447 let keys = job
448 .steps
449 .iter()
450 .position(|s| s.id.as_deref() == Some("keys"))
451 .unwrap_or_else(|| panic!("{file} {}: no keys step", job.id));
452 assert!(keys < at, "{file} {}", job.id);
453 assert_eq!(
454 job.steps[keys].run.as_deref(),
455 Some("node scripts/deploy/ci-cache.mjs keys")
456 );
457 }
458 }
459}
460
461#[test]
462fn a_cargo_target_restored_from_an_older_entry_is_pruned_before_it_is_saved() {
463 // Otherwise the old versions of what changed ride along forever: CI's
464 // test build grew from 671 MB to 1,687 MB in a day.
465 for (file, id, cache) in [
466 ("ci.yml", "rust", "test-build"),
467 ("ci.yml", "desktop", "desktop-build"),
468 ("caches.yml", "runner", "runner-build"),
469 ] {
470 let workflow = read(file);
471 let job = workflow.jobs.iter().find(|j| j.id == id).unwrap();
472 let at = job
473 .steps
474 .iter()
475 .position(|s| s.id.as_deref() == Some(cache))
476 .unwrap();
477 let prune = job
478 .steps
479 .iter()
480 .position(|s| {
481 s.run
482 .as_deref()
483 .is_some_and(|r| r.starts_with("node scripts/deploy/ci-cache.mjs prune "))
484 })
485 .unwrap_or_else(|| panic!("{file} {id}: no prune"));
486 assert!(at < prune, "{file} {id}");
487 let condition = job.steps[prune].condition.as_deref().map(str::trim).unwrap_or_default();
488 // The cache step saves in its post step, after the prune; or it only
489 // restores, and a save step of the same entry after the prune saves
490 // when it runs.
491 if saves(&job.steps[at]) {
492 // A job a pull request may pass without running (ci-affected.mjs
493 // --job) asks that too: skipped, the cache step says no hit.
494 let hit = format!("steps.{cache}.outputs.cache-hit != 'true' }}}}");
495 assert!(
496 [
497 format!("${{{{ {hit}"),
498 format!("${{{{ steps.affected.outputs.run == 'true' && {hit}"),
499 ]
500 .contains(&condition.to_string()),
501 "{file} {id}: {condition}"
502 );
503 } else {
504 let key = &job.steps[at].raw["with"]["key"];
505 let save = job
506 .steps
507 .iter()
508 .position(|s| saves(s) && &s.raw["with"]["key"] == key)
509 .unwrap_or_else(|| panic!("{file} {id}: nothing saves {key}"));
510 assert!(prune < save, "{file} {id}");
511 assert_eq!(paths(&job.steps[save]), paths(&job.steps[at]), "{file} {id}");
512 assert!(
513 condition.starts_with(&format!("${{{{ steps.{cache}.outputs.cache-hit != 'true' ")),
514 "{file} {id}: {condition}"
515 );
516 assert_eq!(job.steps[save].condition.as_deref().map(str::trim), Some(condition), "{file} {id}");
517 }
518 }
519 // The CLI's build is made in the builder image, whose Cargo the host
520 // cannot ask what it built: so it is never started from an older entry,
521 // and has nothing old to prune.
522 let caches = read("caches.yml");
523 let cli = caches.jobs.iter().find(|j| j.id == "cli").unwrap();
524 let build = cli
525 .steps
526 .iter()
527 .find(|s| s.raw["with"]["key"].as_str().is_some_and(|k| k.starts_with("cli-release-")))
528 .unwrap();
529 assert!(saves(build));
530 assert!(build.raw["with"].get("restore-keys").is_none());
531 let ci = read("ci.yml");
532 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
533 assert_eq!(
534 rust.raw["env"]["CARGO_PROFILE_DEV_DEBUG"],
535 json!("line-tables-only")
536 );
537}
538
539#[test]
540fn the_caches_workflow_builds_only_what_is_missing() {
541 let caches = read("caches.yml");
542 let push = caches.trigger("push").unwrap();
543 assert!(push.branches.allows("main"));
544 assert!(!push.branches.allows("feature"));
545 // Never cut off halfway: a build half done saves nothing.
546 assert_eq!(
547 caches.concurrency.as_ref().unwrap().cancel_in_progress,
548 json!(false)
549 );
550 let look = caches.jobs.iter().find(|j| j.id == "look").unwrap();
551 // Looked up, not downloaded.
552 for step in &look.steps {
553 if step.uses.as_deref().is_some_and(|u| u.starts_with("actions/cache")) {
554 assert_eq!(step.uses.as_deref(), Some("actions/cache/restore@v4"));
555 assert_eq!(step.raw["with"]["lookup-only"], json!(true), "{:?}", step.id);
556 }
557 }
558 for job in &caches.jobs {
559 if job.id != "look" {
560 assert_eq!(job.needs, ["look"], "{}", job.id);
561 assert!(
562 job.condition
563 .as_deref()
564 .is_some_and(|c| c.contains("needs.look.outputs.")),
565 "{}",
566 job.id
567 );
568 }
569 }
570}
571
572#[test]
573fn deploy_stages_follow_one_another() {
574 let deploy = read("deploy.yml");
575 let all = plan_outputs(
576 true,
577 &[("rust", "events,repos", true), ("ts", "projects", false)],
578 &[("rust", "api", true)],
579 &[("web", "web", false)],
580 );
581 let push = json!({});
582
583 // Everything succeeds: each stage runs after the last.
584 assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
585 assert!(starts(
586 &deploy,
587 "core",
588 &[("migrate", "success")],
589 &all,
590 push.clone(),
591 false
592 ));
593 assert!(starts(
594 &deploy,
595 "edge",
596 &[("migrate", "success"), ("core", "success")],
597 &all,
598 push.clone(),
599 false
600 ));
601 assert!(starts(
602 &deploy,
603 "front",
604 &[
605 ("migrate", "success"),
606 ("core", "success"),
607 ("edge", "success")
608 ],
609 &all,
610 push.clone(),
611 false
612 ));
613
614 // No migrations: the migrate job is skipped, and core still runs.
615 let none = plan_outputs(
616 false,
617 &[("ts", "projects", false)],
618 &[],
619 &[("web", "web", false)],
620 );
621 assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
622 assert!(starts(
623 &deploy,
624 "core",
625 &[("migrate", "skipped")],
626 &none,
627 push.clone(),
628 false
629 ));
630 // An empty stage is skipped, and the next one still runs.
631 assert!(!starts(
632 &deploy,
633 "edge",
634 &[("migrate", "skipped"), ("core", "success")],
635 &none,
636 push.clone(),
637 false
638 ));
639 assert!(starts(
640 &deploy,
641 "front",
642 &[
643 ("migrate", "skipped"),
644 ("core", "success"),
645 ("edge", "skipped")
646 ],
647 &none,
648 push.clone(),
649 false
650 ));
651
652 // A failure stops every later stage.
653 assert!(!starts(
654 &deploy,
655 "core",
656 &[("migrate", "failure")],
657 &all,
658 push.clone(),
659 false
660 ));
661 assert!(!starts(
662 &deploy,
663 "edge",
664 &[("migrate", "success"), ("core", "failure")],
665 &all,
666 push.clone(),
667 false
668 ));
669 assert!(!starts(
670 &deploy,
671 "front",
672 &[
673 ("migrate", "success"),
674 ("core", "success"),
675 ("edge", "failure")
676 ],
677 &all,
678 push.clone(),
679 false
680 ));
681 assert!(!starts(
682 &deploy,
683 "front",
684 &[
685 ("migrate", "success"),
686 ("core", "failure"),
687 ("edge", "skipped")
688 ],
689 &all,
690 push.clone(),
691 false
692 ));
693 // A cancelled run starts nothing more.
694 assert!(!starts(
695 &deploy,
696 "edge",
697 &[("migrate", "success"), ("core", "success")],
698 &all,
699 push.clone(),
700 true
701 ));
702 // Check and plan run side by side: plan waits for nothing.
703 let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap();
704 assert!(plan.needs.is_empty(), "{:?}", plan.needs);
705 for id in ["migrate", "core", "edge", "front", "smoke"] {
706 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
707 assert!(
708 job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"),
709 "{id}: {:?}",
710 job.needs
711 );
712 }
713 // A failed check, though plan succeeded: nothing migrates or deploys,
714 // and failure() still sees the check's failure through skipped jobs.
715 assert!(!starts(
716 &deploy,
717 "migrate",
718 &[("check", "failure"), ("plan", "success")],
719 &all,
720 push.clone(),
721 false
722 ));
723 assert!(!starts(
724 &deploy,
725 "core",
726 &[
727 ("check", "failure"),
728 ("plan", "success"),
729 ("migrate", "skipped")
730 ],
731 &all,
732 push.clone(),
733 false
734 ));
735 assert!(!starts(
736 &deploy,
737 "front",
738 &[
739 ("check", "failure"),
740 ("plan", "success"),
741 ("migrate", "skipped"),
742 ("core", "skipped"),
743 ("edge", "skipped")
744 ],
745 &all,
746 push.clone(),
747 false
748 ));
749 let skipped = [
750 ("migrate", "skipped"),
751 ("core", "skipped"),
752 ("edge", "skipped"),
753 ];
754 assert!(!starts_after(
755 &deploy,
756 "front",
757 &skipped,
758 &all,
759 push.clone(),
760 false,
761 true
762 ));
763 assert!(!starts(
764 &deploy,
765 "smoke",
766 &[
767 ("check", "failure"),
768 ("core", "skipped"),
769 ("edge", "skipped"),
770 ("front", "skipped")
771 ],
772 &all,
773 push.clone(),
774 false
775 ));
776 // A failed plan stops everything too.
777 assert!(!starts(
778 &deploy,
779 "migrate",
780 &[("plan", "failure")],
781 &all,
782 push.clone(),
783 false
784 ));
785 assert!(!starts(
786 &deploy,
787 "core",
788 &[("plan", "failure"), ("migrate", "skipped")],
789 &all,
790 push.clone(),
791 false
792 ));
793
794 // Smoke follows the last stage that ran, and not a failed one.
795 assert!(starts(
796 &deploy,
797 "smoke",
798 &[
799 ("core", "success"),
800 ("edge", "success"),
801 ("front", "success")
802 ],
803 &all,
804 push.clone(),
805 false
806 ));
807 assert!(starts(
808 &deploy,
809 "smoke",
810 &[
811 ("core", "success"),
812 ("edge", "skipped"),
813 ("front", "success")
814 ],
815 &none,
816 push.clone(),
817 false
818 ));
819 assert!(!starts(
820 &deploy,
821 "smoke",
822 &[
823 ("core", "success"),
824 ("edge", "failure"),
825 ("front", "skipped")
826 ],
827 &all,
828 push.clone(),
829 false
830 ));
831 // Nothing deployed: nothing to smoke-test.
832 let nothing = plan_outputs(false, &[], &[], &[]);
833 assert!(!starts(
834 &deploy,
835 "smoke",
836 &[
837 ("core", "skipped"),
838 ("edge", "skipped"),
839 ("front", "skipped")
840 ],
841 &nothing,
842 push.clone(),
843 false
844 ));
845
846 // Every stage builds at once, as soon as the plan is known: the build
847 // waits for nothing else, and every stage waits for it.
848 let build = deploy.jobs.iter().find(|j| j.id == "build").unwrap();
849 assert_eq!(build.needs, ["plan"]);
850 assert!(starts(&deploy, "build", &[], &all, push.clone(), false));
851 for id in ["core", "edge", "front"] {
852 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
853 assert!(job.needs.iter().any(|n| n == "build"), "{id}: {:?}", job.needs);
854 }
855 // A failed build deploys nothing.
856 assert!(!starts(
857 &deploy,
858 "core",
859 &[("build", "failure"), ("migrate", "success")],
860 &all,
861 push.clone(),
862 false
863 ));
864 // TypeScript Workers alone: nothing to build, and core still runs.
865 let bundled = plan_outputs(false, &[("ts", "projects", false)], &[], &[]);
866 assert!(!starts(&deploy, "build", &[], &bundled, push.clone(), false));
867 assert!(starts(
868 &deploy,
869 "core",
870 &[("build", "skipped"), ("migrate", "skipped")],
871 &bundled,
872 push.clone(),
873 false
874 ));
875 assert!(!starts(
876 &deploy,
877 "build",
878 &[],
879 &all,
880 json!({ "dry_run": true, "units": "", "all": false }),
881 false
882 ));
883
884 // A dry run plans and stops.
885 let dry = json!({ "dry_run": true, "units": "", "all": false });
886 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
887 assert!(!starts(
888 &deploy,
889 "core",
890 &[("migrate", "skipped")],
891 &all,
892 dry.clone(),
893 false
894 ));
895 assert!(!starts(
896 &deploy,
897 "smoke",
898 &[
899 ("core", "skipped"),
900 ("edge", "skipped"),
901 ("front", "skipped")
902 ],
903 &all,
904 dry,
905 false
906 ));
907}
908
909#[test]
910fn deploy_stage_matrices_come_from_the_plan() {
911 let deploy = read("deploy.yml");
912 let outputs = plan_outputs(
913 false,
914 &[
915 ("rust-1", "events,work", true),
916 ("rust-2", "repos", true),
917 ("ts", "projects,og", false),
918 ],
919 &[],
920 &[],
921 );
922 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
923 assert!(!core.fail_fast);
924 assert_eq!(core.max_parallel, Some(4));
925 let mut contexts = Map::new();
926 contexts.insert(
927 "needs".into(),
928 json!({ "plan": { "result": "success", "outputs": outputs } }),
929 );
930 let scope = Scope {
931 contexts: &contexts,
932 status: Status::Success,
933 hash_files: None,
934 };
935 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
936 let jobs = matrix::expand(&value).unwrap();
937 let groups: Vec<(&str, &str, bool)> = jobs
938 .iter()
939 .map(|c| {
940 (
941 c["group"].as_str().unwrap(),
942 c["units"].as_str().unwrap(),
943 c["rust"].as_bool().unwrap(),
944 )
945 })
946 .collect();
947 assert_eq!(
948 groups,
949 [
950 ("rust-1", "events,work", true),
951 ("rust-2", "repos", true),
952 ("ts", "projects,og", false)
953 ]
954 );
955
956 // The builds: every stage's Rust groups and sites in one matrix, each
957 // naming its stage, since two stages may each have a group `rust`.
958 let outputs = plan_outputs(
959 false,
960 &[("rust", "events", true), ("ts", "projects", false)],
961 &[("rust", "api", true)],
962 &[("web", "web", false)],
963 );
964 let build = deploy.jobs.iter().find(|j| j.id == "build").unwrap();
965 let mut contexts = Map::new();
966 contexts.insert(
967 "needs".into(),
968 json!({ "plan": { "result": "success", "outputs": outputs } }),
969 );
970 let scope = Scope {
971 contexts: &contexts,
972 status: Status::Success,
973 hash_files: None,
974 };
975 let value = expr::interpolate_value(build.matrix.as_ref().unwrap(), &scope).unwrap();
976 let builds: Vec<(String, String)> = matrix::expand(&value)
977 .unwrap()
978 .iter()
979 .map(|c| {
980 (
981 c["stage"].as_str().unwrap().to_owned(),
982 c["group"].as_str().unwrap().to_owned(),
983 )
984 })
985 .collect();
986 assert_eq!(
987 builds,
988 [
989 ("core".to_owned(), "rust".to_owned()),
990 ("edge".to_owned(), "rust".to_owned()),
991 ("front".to_owned(), "web".to_owned())
992 ]
993 );
994 // A build's artifact is the one its stage's job fetches.
995 let artifact = |job: &workflow::Job, action: &str| {
996 job.steps
997 .iter()
998 .find(|s| s.uses.as_deref().is_some_and(|u| u.starts_with(action)))
999 .map(|s| s.raw["with"]["name"].as_str().unwrap().to_owned())
1000 .unwrap_or_else(|| panic!("{}: no {action}", job.id))
1001 };
1002 let name = "build-${{ matrix.stage }}-${{ matrix.group }}";
1003 assert_eq!(artifact(build, "actions/upload-artifact@"), name);
1004 for stage in ["core", "edge", "front"] {
1005 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
1006 assert_eq!(artifact(job, "actions/download-artifact@"), name, "{stage}");
1007 }
1008 // The build reads no secret and makes no deployment.
1009 assert!(build.raw.get("environment").is_none());
1010 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
1011 assert!(!source.split("\n build:").nth(1).unwrap().split("\n migrate:").next().unwrap().contains("secrets."));
1012}
1013
1014#[test]
1015fn deploy_builds_on_ubuntu_latest_with_its_target_cached() {
1016 let deploy = read("deploy.yml");
1017 for stage in ["core", "edge", "front"] {
1018 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
1019 let on = |rust: bool, image: bool| {
1020 let mut contexts = Map::new();
1021 contexts.insert(
1022 "matrix".into(),
1023 json!({ "group": "g", "units": "u", "rust": rust, "image": image }),
1024 );
1025 let scope = Scope {
1026 contexts: &contexts,
1027 status: Status::Success,
1028 hash_files: None,
1029 };
1030 expr::interpolate_value(&job.runs_on, &scope).unwrap()
1031 };
1032 // Every job on ubuntu-latest, as on GitHub: 4 vCPU in this public
1033 // repository (g1t_contracts::actions::instance_for), Rust builds and
1034 // the runner's image included.
1035 assert_eq!(on(true, false), json!("ubuntu-latest"), "{stage}");
1036 assert_eq!(on(false, true), json!("ubuntu-latest"), "{stage}");
1037 assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}");
1038 }
1039 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
1040 assert!(source.contains("target/wasm32-unknown-unknown/release"));
1041 assert!(source.contains("!target/**/incremental"));
1042 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
1043}
1044
1045/// Whether a step runs, for a job going `status`, with `matrix`.
1046fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool {
1047 let mut contexts = Map::new();
1048 contexts.insert("matrix".into(), matrix);
1049 let scope = Scope {
1050 contexts: &contexts,
1051 status,
1052 hash_files: None,
1053 };
1054 expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
1055}
1056
1057/// Whether a step runs, for a job going `status`, with `contexts` (an
1058/// object of them, such as `steps`).
1059fn runs_with(step: &workflow::Step, contexts: &Value, status: Status) -> bool {
1060 let contexts = contexts.as_object().unwrap().clone();
1061 let scope = Scope {
1062 contexts: &contexts,
1063 status,
1064 hash_files: None,
1065 };
1066 expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
1067}
1068
1069#[test]
1070fn rust_builds_go_through_sccache_before_cargo_and_report_after() {
1071 let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) {
1072 let at = job
1073 .steps
1074 .iter()
1075 .position(|s| s.run.as_deref() == Some(run))
1076 .unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
1077 (at, job.steps[at].clone())
1078 };
1079 let deploy = read("deploy.yml");
1080 // The Workers' Rust is built by the build job; a stage's job builds
1081 // only the runner's binary, for its image.
1082 for (id, through) in [
1083 ("build", [true, false, false]),
1084 ("core", [false, true, false]),
1085 ("edge", [false, true, false]),
1086 ("front", [false, true, false]),
1087 ] {
1088 let stage = id;
1089 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
1090 let (install_at, install) = step(job, "bash scripts/sccache.sh install");
1091 let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats");
1092 // Before anything runs Cargo (worker-build's install, the build),
1093 // and the statistics last.
1094 let install_step = job
1095 .steps
1096 .iter()
1097 .position(|s| s.name.as_deref() == Some("Install"))
1098 .unwrap();
1099 assert!(install_at < install_step, "{stage}");
1100 assert_eq!(stats_at, job.steps.len() - 1, "{stage}");
1101 for (rust, image, uses) in [
1102 (true, false, through[0]),
1103 (false, true, through[1]),
1104 (false, false, through[2]),
1105 ] {
1106 let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image });
1107 assert_eq!(
1108 step_runs(&install, matrix.clone(), Status::Success),
1109 uses,
1110 "{stage} rust={rust} image={image}"
1111 );
1112 assert_eq!(
1113 step_runs(&stats, matrix.clone(), Status::Success),
1114 uses,
1115 "{stage}"
1116 );
1117 // A failed build still says what was cached.
1118 assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}");
1119 }
1120 }
1121 let ci = read("ci.yml");
1122 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
1123 let (install_at, _) = step(rust, "bash scripts/sccache.sh install");
1124 let (tests_at, _) = step(rust, "cargo test ${{ steps.affected.outputs.crates }} --locked --quiet");
1125 let (stats_at, stats) = step(rust, "bash scripts/sccache.sh stats");
1126 assert!(install_at < tests_at && tests_at < stats_at);
1127 let testing = json!({ "steps": { "affected": { "outputs": { "crates": "-p g1t-kit", "whole": "true" } } } });
1128 assert!(runs_with(&stats, &testing, Status::Failure));
1129 // main's runs keep the caches pull requests restore from: Rust's test
1130 // builds and tsc's build info; the site's build keeps nothing.
1131 assert!(ci.trigger("push").unwrap().branches.allows("main"));
1132 assert!(ci.trigger("pull_request").is_some());
1133 assert!(ci.trigger("merge_group").is_some());
1134 for (id, on_push) in [("rust", true), ("typescript", true), ("build", false)] {
1135 let job = ci.jobs.iter().find(|j| j.id == id).unwrap();
1136 for (event, expected) in [
1137 ("push", on_push),
1138 ("pull_request", true),
1139 ("merge_group", true),
1140 ] {
1141 let mut contexts = Map::new();
1142 contexts.insert(
1143 "github".into(),
1144 json!({ "event_name": event, "ref": "refs/heads/main" }),
1145 );
1146 let scope = Scope {
1147 contexts: &contexts,
1148 status: Status::Success,
1149 hash_files: None,
1150 };
1151 assert_eq!(
1152 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(),
1153 expected,
1154 "{id} on {event}"
1155 );
1156 }
1157 }
1158 // The download is pinned by version and checksum.
1159 let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap();
1160 assert!(script.contains("VERSION=0.18.0"));
1161 assert!(script.lines().any(|line| {
1162 line.strip_prefix("SHA256=")
1163 .is_some_and(|sum| sum.len() == 64)
1164 }));
1165 assert!(script.contains("sha256sum -c"));
1166 assert!(script.contains("RUSTC_WRAPPER="));
1167 assert!(script.contains("GITHUB_STEP_SUMMARY"));
1168 // A tag's run reads main's entries and saves none, which only its own
1169 // tag could read; the mode reaches the steps after it too.
1170 assert!(script.contains("if [ \"${GITHUB_REF_TYPE:-}\" = tag ]; then\n export SCCACHE_GHA_RW_MODE=READ_ONLY"));
1171 assert!(script.contains("echo \"SCCACHE_GHA_RW_MODE=$SCCACHE_GHA_RW_MODE\""));
1172 let tagged = read("runner-release.yml");
1173 assert!(tagged.trigger("push").unwrap().tags.allows("runner-v0.4.0"));
1174}
1175
1176#[test]
1177fn g1ts_own_actions_are_published_when_they_change() {
1178 let release = read("actions-release.yml");
1179 let push = release.trigger("push").unwrap();
1180 assert!(push.branches.allows("main"));
1181 assert!(!push.branches.allows("feature"));
1182 assert!(release.trigger("workflow_dispatch").is_some());
1183 let source = std::fs::read_to_string(workflows_dir().join("actions-release.yml")).unwrap();
1184 assert!(source.contains("actions/**"));
1185 assert!(source.contains("secrets.ACTIONS_PUBLISH_TOKEN"));
1186 // Every action is a composite one with its README, and the copies of
1187 // the script that finds g1t-runner are the same file.
1188 let actions = workflows_dir().join("../../actions");
1189 let mut finders = Vec::new();
1190 for entry in std::fs::read_dir(&actions).unwrap() {
1191 let dir = entry.unwrap().path();
1192 let manifest = std::fs::read_to_string(dir.join("action.yml"))
1193 .unwrap_or_else(|_| panic!("{} has no action.yml", dir.display()));
1194 let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap();
1195 assert_eq!(
1196 manifest["runs"]["using"].as_str(),
1197 Some("composite"),
1198 "{}",
1199 dir.display()
1200 );
1201 assert!(dir.join("README.md").exists(), "{}", dir.display());
1202 finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap());
1203 }
1204 assert!(finders.len() >= 2);
1205 assert!(
1206 finders.windows(2).all(|pair| pair[0] == pair[1]),
1207 "actions/*/runner.sh differ"
1208 );
1209 let deploy: serde_yaml::Value =
1210 serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap())
1211 .unwrap();
1212 assert_eq!(
1213 deploy["inputs"]["token"]["default"].as_str(),
1214 Some("${{ github.token }}")
1215 );
1216 for output in ["url", "deployment-id"] {
1217 assert!(deploy["outputs"][output]["value"].is_string(), "{output}");
1218 }
1219}
1220
1221#[test]
1222fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
1223 let base = read("runner-base.yml");
1224 assert!(base.trigger("schedule").is_some());
1225 assert!(base.trigger("workflow_dispatch").is_some());
1226 assert!(base.trigger("push").unwrap().branches.allows("main"));
1227 let job = base.jobs.iter().find(|j| j.id == "build").unwrap();
1228 assert_eq!(job.runs_on, json!(["self-hosted", "docker"]));
1229}
1230
1231#[test]
1232fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() {
1233 let root = workflows_dir()
1234 .parent()
1235 .unwrap()
1236 .parent()
1237 .unwrap()
1238 .to_path_buf();
1239 let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap();
1240 let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap();
1241 assert_eq!(
1242 ours, theirs,
1243 "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml"
1244 );
1245 // It runs only by hand, and reads nothing it could change.
1246 let workflow = read("conformance.yml");
1247 assert_eq!(
1248 workflow
1249 .triggers
1250 .iter()
1251 .map(|t| t.event.as_str())
1252 .collect::<Vec<_>>(),
1253 ["workflow_dispatch"]
1254 );
1255 assert!(!ours.contains("secrets."));
1256}
1257
1258/// No workflow of g1t's asks for a secret no one can set. Secret names
1259/// starting with G1T_ or GITHUB_ are g1t's own and refused when saved, so a
1260/// workflow reading one gets nothing: the Actions release asked for
1261/// G1T_ACTIONS_PUBLISH_TOKEN, which could never exist. G1T_TOKEN, the job's
1262/// own token, is the one g1t gives every job.
1263#[test]
1264fn workflows_read_only_secrets_that_can_be_set() {
1265 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
1266 let path = entry.unwrap().path();
1267 if path.extension().is_none_or(|ext| ext != "yml") {
1268 continue;
1269 }
1270 let source = std::fs::read_to_string(&path).unwrap();
1271 for (at, _) in source.match_indices("secrets.") {
1272 let name: String = source[at + "secrets.".len()..]
1273 .chars()
1274 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1275 .collect();
1276 let reserved = name.starts_with("G1T_") || name.starts_with("GITHUB_");
1277 assert!(
1278 !reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN",
1279 "{} reads secrets.{name}, a name g1t keeps for its own",
1280 path.display()
1281 );
1282 }
1283 }
1284}
1285
1286#[test]
1287fn every_install_runs_again_when_a_connection_drops() {
1288 // npm does not retry a body that breaks off midway; npm-ci.mjs does.
1289 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
1290 let path = entry.unwrap().path();
1291 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
1292 continue;
1293 }
1294 let name = path.file_name().unwrap().to_string_lossy().to_string();
1295 for job in read(&name).jobs {
1296 for step in &job.steps {
1297 let Some(run) = step.run.as_deref() else {
1298 continue;
1299 };
1300 for line in run.lines() {
1301 assert!(
1302 !line.trim().starts_with("npm ci"),
1303 "{name} {}: `{}` should be `node scripts/deploy/npm-ci.mjs …`",
1304 job.id,
1305 line.trim()
1306 );
1307 }
1308 }
1309 }
1310 }
1311}
1312
1313/// The workspace's crates, by package name: Cargo.toml's members, with
1314/// `crates/*` read from the folder.
1315fn workspace_crates() -> Vec<String> {
1316 let root = workflows_dir().join("../..");
1317 let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
1318 let members = manifest
1319 .lines()
1320 .find(|line| line.trim_start().starts_with("members"))
1321 .unwrap();
1322 let mut dirs = Vec::new();
1323 for member in members.split('"').skip(1).step_by(2) {
1324 match member.strip_suffix("/*") {
1325 Some(parent) => {
1326 for entry in std::fs::read_dir(root.join(parent)).unwrap() {
1327 let path = entry.unwrap().path();
1328 if path.join("Cargo.toml").exists() {
1329 dirs.push(path);
1330 }
1331 }
1332 }
1333 None => dirs.push(root.join(member)),
1334 }
1335 }
1336 let mut names: Vec<String> = dirs
1337 .iter()
1338 .map(|dir| {
1339 let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1340 let line = manifest
1341 .lines()
1342 .find(|line| line.trim_start().starts_with("name"))
1343 .unwrap_or_else(|| panic!("{} has no name", dir.display()));
1344 line.split('"').nth(1).unwrap().to_owned()
1345 })
1346 .collect();
1347 names.sort();
1348 names
1349}
1350
1351#[test]
1352fn ci_keeps_only_tsc_build_info_between_type_checks() {
1353 // tsc hashes every file it reads against its build info, so an entry
1354 // from another commit never hides an error; a generated type kept from
1355 // another commit could, so each run writes those again.
1356 let ci = read("ci.yml");
1357 let typescript = ci.jobs.iter().find(|j| j.id == "typescript").unwrap();
1358 let at = typescript
1359 .steps
1360 .iter()
1361 .position(|s| s.raw["with"]["key"].as_str().is_some_and(|k| k.starts_with("tsc-")))
1362 .unwrap();
1363 let kept = paths(&typescript.steps[at]);
1364 assert!(!kept.is_empty() && kept.iter().all(|p| p.ends_with("/*.tsbuildinfo")), "{kept:?}");
1365 let check = typescript
1366 .steps
1367 .iter()
1368 .position(|s| s.run.as_deref() == Some("npm run typecheck"))
1369 .unwrap();
1370 assert!(at < check);
1371}
1372
1373#[test]
1374fn ci_tests_every_crate_once_across_its_rust_parts() {
1375 let ci = read("ci.yml");
1376 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
1377 assert!(!rust.fail_fast);
1378 let parts = rust.raw["strategy"]["matrix"]["include"].as_array().unwrap();
1379 assert!(parts.len() >= 2, "{parts:?}");
1380 let mut tested: Vec<String> = Vec::new();
1381 for part in parts {
1382 let crates = part["crates"].as_str().unwrap();
1383 let mut words = crates.split_whitespace();
1384 while let Some(flag) = words.next() {
1385 assert_eq!(flag, "-p", "{crates}");
1386 tested.push(words.next().unwrap().to_owned());
1387 }
1388 }
1389 tested.sort();
1390 let mut once = tested.clone();
1391 once.dedup();
1392 assert_eq!(tested, once, "a crate is in more than one part");
1393 assert_eq!(tested, workspace_crates(), "the parts and the workspace's crates differ");
1394 // Each part keeps a test build of its own, and builds and prunes the same crates.
1395 let key = rust
1396 .steps
1397 .iter()
1398 .find(|s| s.id.as_deref() == Some("test-build"))
1399 .unwrap()
1400 .raw["with"]["key"]
1401 .as_str()
1402 .unwrap()
1403 .to_owned();
1404 assert!(key.contains("${{ matrix.part }}"), "{key}");
1405 // It tests the crates of the part a pull request reaches, and prunes
1406 // and saves only a build of all of them.
1407 for run in ["cargo test ${{ steps.affected.outputs.crates }} --locked --quiet", "node scripts/deploy/ci-cache.mjs prune target/debug -- test ${{ matrix.crates }} --locked --no-run"] {
1408 assert!(rust.steps.iter().any(|s| s.run.as_deref() == Some(run)), "{run}");
1409 }
1410}
1411
1412#[test]
1413fn ci_tests_only_the_crates_a_pull_request_reaches() {
1414 let ci = read("ci.yml");
1415 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
1416 // The check names stay those the rules may require.
1417 assert_eq!(rust.raw["name"], json!("Rust (${{ matrix.part }})"));
1418 // The test merge commit and its parents, to compare it with the base.
1419 let checkout = &rust.steps[0];
1420 assert!(checkout.uses.as_deref().is_some_and(|u| u.starts_with("actions/checkout@")));
1421 assert_eq!(checkout.raw["with"]["fetch-depth"], json!(2));
1422 let affected = &rust.steps[1];
1423 assert_eq!(affected.id.as_deref(), Some("affected"));
1424 assert_eq!(
1425 affected.run.as_deref(),
1426 Some("node scripts/deploy/ci-affected.mjs ${{ matrix.crates }}")
1427 );
1428 assert!(affected.condition.is_none());
1429 let outputs = |crates: &str, whole: bool| {
1430 json!({ "steps": {
1431 "affected": { "outputs": { "crates": crates, "whole": whole.to_string() } },
1432 "test-build": { "outputs": { "cache-hit": "false" } },
1433 } })
1434 };
1435 let (all, some, none) = (outputs("-p g1t-kit -p g1t", true), outputs("-p g1t", false), outputs("", false));
1436 for step in &rust.steps[2..] {
1437 let name = step.name.clone().unwrap_or_default();
1438 // A part with nothing to test does nothing more, and passes.
1439 assert!(!runs_with(step, &none, Status::Success), "{name}");
1440 assert!(!runs_with(step, &none, Status::Failure), "{name}");
1441 // Built only for a push the merge queue tested (below).
1442 if name == "Tests, compiled only" {
1443 continue;
1444 }
1445 assert!(runs_with(step, &all, Status::Success), "{name}");
1446 // Only a build of every crate of the part is pruned and saved.
1447 let keeps = name == "Only this build's files are saved" || saves(step) && step.id.as_deref() != Some("test-build") && step.raw["with"]["key"].as_str().is_some_and(|k| k.starts_with("cargo-test-"));
1448 assert_eq!(runs_with(step, &some, Status::Success), !keeps, "{name}");
1449 }
1450 // The script is the build's: a change to it, as to this workflow,
1451 // tests every crate (scripts/deploy/ci-affected.mjs EVERYTHING).
1452 let script = std::fs::read_to_string(workflows_dir().join("../../scripts/deploy/ci-affected.mjs")).unwrap();
1453 for path in ["\"Cargo.lock\"", "\".g1t/workflows/ci.yml\"", "\"scripts/sccache.sh\"", "\"scripts/deploy/ci-affected.mjs\""] {
1454 assert!(script.contains(path), "{path}");
1455 }
1456 // Anything but a pull request or a merge queue state tests every crate.
1457 assert!(script.contains("if (event === \"pull_request\") {"));
1458 assert!(script.contains("if (event === \"merge_group\") {"));
1459 assert!(script.contains("return { why: `a ${event ?? \"local\"} run tests every crate` };"));
1460}
1461
1462#[test]
1463fn a_push_the_merge_queue_tested_keeps_mains_caches_and_runs_no_tests_again() {
1464 let ci = read("ci.yml");
1465 // Its token may read runs: ci-affected.mjs asks whether the push's
1466 // commit passed a merge_group run of CI.
1467 assert_eq!(ci.raw["permissions"]["actions"], json!("read"));
1468 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
1469 let affected = rust
1470 .steps
1471 .iter()
1472 .find(|s| s.id.as_deref() == Some("affected"))
1473 .unwrap();
1474 assert_eq!(
1475 affected.raw["env"]["GITHUB_TOKEN"],
1476 json!("${{ github.token }}")
1477 );
1478 let outputs = |tested: bool| {
1479 json!({ "steps": {
1480 "affected": { "outputs": { "crates": "-p g1t-kit -p g1t", "whole": "true", "tested": tested.to_string() } },
1481 "test-build": { "outputs": { "cache-hit": "false" } },
1482 } })
1483 };
1484 let named = |name: &str| {
1485 rust.steps
1486 .iter()
1487 .find(|s| s.name.as_deref() == Some(name))
1488 .unwrap_or_else(|| panic!("no step {name}"))
1489 };
1490 let (tests, compiled) = (named("Tests"), named("Tests, compiled only"));
1491 assert_eq!(
1492 tests.run.as_deref(),
1493 Some("cargo test ${{ steps.affected.outputs.crates }} --locked --quiet")
1494 );
1495 assert_eq!(
1496 compiled.run.as_deref(),
1497 Some("cargo test ${{ steps.affected.outputs.crates }} --locked --quiet --no-run")
1498 );
1499 for (tested, test, compile) in [(false, true, false), (true, false, true)] {
1500 assert_eq!(
1501 runs_with(tests, &outputs(tested), Status::Success),
1502 test,
1503 "tested={tested}"
1504 );
1505 assert_eq!(
1506 runs_with(compiled, &outputs(tested), Status::Success),
1507 compile,
1508 "tested={tested}"
1509 );
1510 // Either way the whole build is pruned and saved for pull requests.
1511 assert!(runs_with(
1512 named("Only this build's files are saved"),
1513 &outputs(tested),
1514 Status::Success
1515 ));
1516 assert!(runs_with(
1517 named("Save the test build"),
1518 &outputs(tested),
1519 Status::Success
1520 ));
1521 }
1522 // A queue state's branch is gone once it lands: it saves no test build.
1523 let mut queued = outputs(false).as_object().unwrap().clone();
1524 queued.insert("github".into(), json!({ "event_name": "merge_group" }));
1525 assert!(!runs_with(
1526 named("Save the test build"),
1527 &Value::Object(queued),
1528 Status::Success
1529 ));
1530 // TypeScript type checks, saving tsc's build info, and runs no tests.
1531 let typescript = ci.jobs.iter().find(|j| j.id == "typescript").unwrap();
1532 let queue = typescript
1533 .steps
1534 .iter()
1535 .position(|s| s.id.as_deref() == Some("queue"))
1536 .unwrap();
1537 assert_eq!(
1538 typescript.steps[queue].run.as_deref(),
1539 Some("node scripts/deploy/ci-affected.mjs --tested")
1540 );
1541 assert_eq!(
1542 typescript.steps[queue].raw["env"]["GITHUB_TOKEN"],
1543 json!("${{ github.token }}")
1544 );
1545 let check = typescript
1546 .steps
1547 .iter()
1548 .position(|s| s.run.as_deref() == Some("npm run typecheck"))
1549 .unwrap();
1550 assert!(queue < check);
1551 let tested = |yes: bool| json!({ "steps": { "queue": { "outputs": { "tested": yes.to_string() } } }, "github": { "event_name": "push" } });
1552 for step in &typescript.steps[check..] {
1553 let name = step.name.clone().unwrap_or_default();
1554 assert!(runs_with(step, &tested(false), Status::Success), "{name}");
1555 let kept = step.run.as_deref() == Some("npm run typecheck") || saves(step);
1556 assert_eq!(
1557 runs_with(step, &tested(true), Status::Success),
1558 kept,
1559 "{name}"
1560 );
1561 }
1562 // The desktop job asks too, and passes without running (ci-affected.mjs).
1563 let desktop = ci.jobs.iter().find(|j| j.id == "desktop").unwrap();
1564 let asks = desktop
1565 .steps
1566 .iter()
1567 .find(|s| s.id.as_deref() == Some("affected"))
1568 .unwrap();
1569 assert_eq!(
1570 asks.run.as_deref(),
1571 Some("node scripts/deploy/ci-affected.mjs --job desktop")
1572 );
1573 assert_eq!(
1574 asks.raw["env"]["GITHUB_TOKEN"],
1575 json!("${{ github.token }}")
1576 );
1577}