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.

g1t.sh
1,142 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}
62
63#[test]
64fn every_workflow_reads_and_runs_on_g1t() {
65 let mut count = 0;
66 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
67 let path = entry.unwrap().path();
68 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
69 continue;
70 }
71 let name = path.file_name().unwrap().to_string_lossy().to_string();
72 let workflow = read(&name);
73 let unsupported: Vec<_> = workflow
74 .notes
75 .iter()
76 .filter(|n| n.severity != Severity::Info)
77 .collect();
78 assert!(unsupported.is_empty(), "{name}: {unsupported:?}");
79 count += 1;
80 }
81 assert!(count >= 1);
82}
83
84/// The plan job's outputs for a deploy of `stages` (each with its jobs).
85fn plan_outputs(
86 migrate: bool,
87 core: &[(&str, &str, bool)],
88 edge: &[(&str, &str, bool)],
89 front: &[(&str, &str, bool)],
90) -> Value {
91 let matrix = |jobs: &[(&str, &str, bool)]| {
92 let include: Vec<Value> = if jobs.is_empty() {
93 vec![json!({ "group": "none", "units": "" })]
94 } else {
95 jobs.iter()
96 .map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust }))
97 .collect()
98 };
99 json!({ "include": include }).to_string()
100 };
101 json!({
102 "migrate": migrate.to_string(),
103 "migrate_units": if migrate { "events" } else { "" },
104 "has_core": (!core.is_empty()).to_string(),
105 "core": matrix(core),
106 "has_edge": (!edge.is_empty()).to_string(),
107 "edge": matrix(edge),
108 "has_front": (!front.is_empty()).to_string(),
109 "front": matrix(front),
110 })
111}
112
113/// Whether `job` starts, given its needs' results, as the actions service
114/// decides it (services/actions/src/plan.rs `decide`, with
115/// `expr::job_status`): a need that was skipped is not a failure, and a
116/// failure anywhere before the job is.
117fn starts(
118 workflow: &Workflow,
119 job: &str,
120 needs: &[(&str, &str)],
121 outputs: &Value,
122 inputs: Value,
123 cancelled: bool,
124) -> bool {
125 starts_after(workflow, job, needs, outputs, inputs, cancelled, false)
126}
127
128/// `starts`, where `failed_before` says a job further back failed (one the
129/// needs were skipped for).
130fn starts_after(
131 workflow: &Workflow,
132 job: &str,
133 needs: &[(&str, &str)],
134 outputs: &Value,
135 inputs: Value,
136 cancelled: bool,
137 failed_before: bool,
138) -> bool {
139 let job = workflow.jobs.iter().find(|j| j.id == job).unwrap();
140 let mut needs_ctx = Map::new();
141 let mut results = Vec::new();
142 for need in &job.needs {
143 let result = needs
144 .iter()
145 .find(|(name, _)| name == need)
146 .map(|(_, r)| *r)
147 .unwrap_or("success");
148 results.push(result);
149 let outputs = if need == "plan" {
150 outputs.clone()
151 } else {
152 json!({})
153 };
154 needs_ctx.insert(
155 need.clone(),
156 json!({ "result": result, "outputs": outputs }),
157 );
158 }
159 let status = expr::job_status(results, failed_before, cancelled);
160 let mut contexts = Map::new();
161 contexts.insert("needs".into(), Value::Object(needs_ctx));
162 contexts.insert("inputs".into(), inputs);
163 contexts.insert(
164 "github".into(),
165 json!({ "event_name": "push", "ref": "refs/heads/main" }),
166 );
167 let scope = Scope {
168 contexts: &contexts,
169 status,
170 hash_files: None,
171 };
172 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap()
173}
174
175#[test]
176fn deploy_runs_on_main_and_by_hand_one_at_a_time() {
177 let deploy = read("deploy.yml");
178 let push = deploy.trigger("push").unwrap();
179 assert!(push.branches.allows("main"));
180 assert!(!push.branches.allows("feature"));
181 let dispatch = deploy.trigger("workflow_dispatch").unwrap();
182 for input in ["units", "all", "dry_run"] {
183 assert!(dispatch.inputs.contains_key(input), "{input}");
184 }
185 let concurrency = deploy.concurrency.as_ref().unwrap();
186 assert_eq!(concurrency.group, "deploy-production");
187 assert_eq!(concurrency.cancel_in_progress, json!(false));
188 assert_eq!(
189 deploy.job_order(),
190 ["check", "plan", "migrate", "core", "edge", "front", "smoke"]
191 );
192 // The stages share their steps (a YAML alias), and read the token only
193 // where they deploy.
194 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
195 assert_eq!(steps("core"), steps("edge"));
196 assert_eq!(steps("core"), steps("front"));
197 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
198 assert!(!source.contains("cancel-in-progress: true"));
199}
200
201/// Whether a step's `run` installs the whole lockfile: `npm ci` with no
202/// workspace narrowing it, and not the deploy tool's install of some units.
203fn installs_everything(run: &str) -> bool {
204 run.split('\n').any(|line| {
205 let line = line.trim();
206 // Installs go through npm-ci.mjs, which runs `npm ci` again when a connection drops.
207 (line.starts_with("npm ci") || line.starts_with("node scripts/deploy/npm-ci.mjs"))
208 && !line.contains(" -w ")
209 && !line.contains("--workspace")
210 })
211}
212
213#[test]
214fn npm_downloads_are_cached_whole_and_only_whole_installs_save_them() {
215 // Every job's npm cache is the whole lockfile's, under one key. A job
216 // that installs part of it only restores: had it saved, its part would
217 // stand in for the whole under that key (the first job to finish used
218 // to save Wrangler alone, and every later install downloaded the rest).
219 let mut saving = Vec::new();
220 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
221 let path = entry.unwrap().path();
222 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
223 continue;
224 }
225 let name = path.file_name().unwrap().to_string_lossy().to_string();
226 for job in read(&name).jobs {
227 let whole = job
228 .steps
229 .iter()
230 .any(|s| s.run.as_deref().is_some_and(installs_everything));
231 for step in &job.steps {
232 let Some(uses) = step.uses.as_deref() else {
233 continue;
234 };
235 if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm")
236 {
237 continue;
238 }
239 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
240 assert_eq!(
241 key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}",
242 "{name} {}",
243 job.id
244 );
245 if let Some(prefix) = step.raw["with"]["restore-keys"].as_str() {
246 assert_eq!(prefix, "npm-full-${{ runner.os }}-", "{name} {}", job.id);
247 }
248 let saves =
249 uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@");
250 assert!(
251 !saves || whole,
252 "{name} {}: saves npm's cache but installs only part of the lockfile",
253 job.id
254 );
255 if saves {
256 saving.push(format!("{name} {}", job.id));
257 }
258 }
259 }
260 }
261 // main's whole cache is kept by the Caches workflow.
262 assert!(saving.contains(&"caches.yml npm".to_owned()), "{saving:?}");
263}
264
265/// Every `actions/cache` step of every workflow: (file, job, step).
266fn cache_steps() -> Vec<(String, workflow::Job, workflow::Step)> {
267 let mut out = Vec::new();
268 let mut names: Vec<String> = std::fs::read_dir(workflows_dir())
269 .unwrap()
270 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
271 .filter(|n| n.ends_with(".yml"))
272 .collect();
273 names.sort();
274 for name in names {
275 for job in read(&name).jobs {
276 for step in &job.steps {
277 if step
278 .uses
279 .as_deref()
280 .is_some_and(|u| u.starts_with("actions/cache"))
281 {
282 out.push((name.clone(), job.clone(), step.clone()));
283 }
284 }
285 }
286 }
287 out
288}
289
290/// What a cache step's key starts with before its first expression: the
291/// entry it is, whatever its inputs (`cargo-test-`, `apt-tauri-windows-`).
292fn family(key: &str) -> String {
293 key.split("${{").next().unwrap_or_default().to_owned()
294}
295
296fn saves(step: &workflow::Step) -> bool {
297 let uses = step.uses.as_deref().unwrap_or_default();
298 (uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@"))
299 && step.raw["with"]["lookup-only"] != json!(true)
300}
301
302fn paths(step: &workflow::Step) -> Vec<String> {
303 let text = step.raw["with"]["path"].as_str().unwrap_or_default();
304 text.lines()
305 .map(str::trim)
306 .filter(|l| !l.is_empty())
307 .map(str::to_owned)
308 .collect()
309}
310
311#[test]
312fn each_cache_has_one_set_of_paths_wherever_it_is_used() {
313 // An entry's version is the hash of its paths: a step that names the
314 // same key with other paths finds nothing, silently. So every step of
315 // an entry, in every workflow, names the same paths.
316 let mut seen: std::collections::BTreeMap<String, (Vec<String>, String)> = Default::default();
317 for (file, job, step) in cache_steps() {
318 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
319 let family = family(key);
320 assert!(!family.is_empty(), "{file} {}: a key starts with its name", job.id);
321 let here = format!("{file} {}", job.id);
322 match seen.get(&family) {
323 Some((first, at)) => assert_eq!(
324 &paths(&step),
325 first,
326 "{here} names other paths for {family} than {at}"
327 ),
328 None => {
329 seen.insert(family, (paths(&step), here));
330 }
331 }
332 }
333 for family in [
334 "npm-full-",
335 "cargo-crates-",
336 "cargo-test-",
337 "cargo-workers-",
338 "runner-musl-",
339 "runner-release-",
340 "desktop-check-",
341 "desktop-release-linux-",
342 "desktop-release-windows-",
343 "apt-tauri-",
344 "apt-tauri-windows-",
345 "xwin-",
346 "zig-0.13.0-zigbuild-",
347 "cli-release-",
348 ] {
349 assert!(seen.contains_key(family), "{family}: {:?}", seen.keys());
350 }
351}
352
353#[test]
354fn main_saves_every_cache_that_pull_requests_and_releases_restore() {
355 // A run restores from its own ref, its pull request's base and main;
356 // a release tag's run never reads another tag's entries. So every
357 // entry any step restores is saved by a workflow that runs on pushes
358 // to main, and the release workflows only restore.
359 let on_main = ["ci.yml", "caches.yml", "deploy.yml"];
360 for file in on_main {
361 let push = read(file).trigger("push").cloned().unwrap();
362 assert!(push.branches.allows("main"), "{file}");
363 }
364 let releases = [
365 "cli-release.yml",
366 "desktop-release.yml",
367 "mobile-build.yml",
368 "mobile-release.yml",
369 "runner-release.yml",
370 ];
371 let steps = cache_steps();
372 let saved_on_main: std::collections::BTreeSet<String> = steps
373 .iter()
374 .filter(|(file, _, step)| on_main.contains(&file.as_str()) && saves(step))
375 .map(|(_, _, step)| family(step.raw["with"]["key"].as_str().unwrap_or_default()))
376 .collect();
377 for (file, job, step) in &steps {
378 let family = family(step.raw["with"]["key"].as_str().unwrap_or_default());
379 // The conformance run restores what its own first job saved.
380 if family == "conformance-" {
381 continue;
382 }
383 assert!(
384 saved_on_main.contains(&family),
385 "{file} {}: nothing on main saves {family}",
386 job.id
387 );
388 if releases.contains(&file.as_str()) {
389 assert!(!saves(step), "{file} {}: a release saves {family}, which only its own tag would read", job.id);
390 }
391 }
392}
393
394#[test]
395fn rust_caches_are_keyed_by_the_toolchain_and_the_dependencies() {
396 // Not by Cargo.lock as a whole: 25 of its 31 changes in a week were a
397 // release's version bump, and each saved every Rust cache again.
398 for (file, job, step) in cache_steps() {
399 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
400 assert!(
401 !key.contains("Cargo.lock") && !key.contains("base.json"),
402 "{file} {}: {key}",
403 job.id
404 );
405 let target = paths(&step).iter().any(|p| p.contains("target"));
406 if target {
407 // The toolchain that built it: this machine's rustc, or for
408 // the CLI's release, the cargo-zigbuild image it builds in.
409 let toolchain = if paths(&step).iter().any(|p| p.starts_with("target/zig")) {
410 "steps.keys.outputs.zigbuild"
411 } else {
412 "steps.keys.outputs.rust"
413 };
414 assert!(key.contains(toolchain), "{file} {}: {key}", job.id);
415 // A restore key never reaches another toolchain's entries.
416 for prefix in step.raw["with"]["restore-keys"]
417 .as_str()
418 .unwrap_or_default()
419 .lines()
420 .filter(|l| !l.trim().is_empty())
421 {
422 assert!(prefix.contains(toolchain), "{file} {}: {prefix}", job.id);
423 }
424 }
425 if key.contains("steps.keys.outputs.") {
426 let at = job.steps.iter().position(|s| s == &step).unwrap();
427 let keys = job
428 .steps
429 .iter()
430 .position(|s| s.id.as_deref() == Some("keys"))
431 .unwrap_or_else(|| panic!("{file} {}: no keys step", job.id));
432 assert!(keys < at, "{file} {}", job.id);
433 assert_eq!(
434 job.steps[keys].run.as_deref(),
435 Some("node scripts/deploy/ci-cache.mjs keys")
436 );
437 }
438 }
439}
440
441#[test]
442fn a_cargo_target_restored_from_an_older_entry_is_pruned_before_it_is_saved() {
443 // Otherwise the old versions of what changed ride along forever: CI's
444 // test build grew from 671 MB to 1,687 MB in a day.
445 for (file, id, cache) in [
446 ("ci.yml", "rust", "test-build"),
447 ("ci.yml", "desktop", "desktop-build"),
448 ("caches.yml", "runner", "runner-build"),
449 ] {
450 let workflow = read(file);
451 let job = workflow.jobs.iter().find(|j| j.id == id).unwrap();
452 let at = job
453 .steps
454 .iter()
455 .position(|s| s.id.as_deref() == Some(cache))
456 .unwrap();
457 assert!(saves(&job.steps[at]), "{file} {id}");
458 let prune = job
459 .steps
460 .iter()
461 .position(|s| {
462 s.run
463 .as_deref()
464 .is_some_and(|r| r.starts_with("node scripts/deploy/ci-cache.mjs prune "))
465 })
466 .unwrap_or_else(|| panic!("{file} {id}: no prune"));
467 assert!(at < prune, "{file} {id}");
468 assert_eq!(
469 job.steps[prune].condition.as_deref().map(str::trim),
470 Some(format!("${{{{ steps.{cache}.outputs.cache-hit != 'true' }}}}").as_str()),
471 "{file} {id}"
472 );
473 }
474 // The CLI's build is made in the builder image, whose Cargo the host
475 // cannot ask what it built: so it is never started from an older entry,
476 // and has nothing old to prune.
477 let caches = read("caches.yml");
478 let cli = caches.jobs.iter().find(|j| j.id == "cli").unwrap();
479 let build = cli
480 .steps
481 .iter()
482 .find(|s| s.raw["with"]["key"].as_str().is_some_and(|k| k.starts_with("cli-release-")))
483 .unwrap();
484 assert!(saves(build));
485 assert!(build.raw["with"].get("restore-keys").is_none());
486 let ci = read("ci.yml");
487 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
488 assert_eq!(
489 rust.raw["env"]["CARGO_PROFILE_DEV_DEBUG"],
490 json!("line-tables-only")
491 );
492}
493
494#[test]
495fn the_caches_workflow_builds_only_what_is_missing() {
496 let caches = read("caches.yml");
497 let push = caches.trigger("push").unwrap();
498 assert!(push.branches.allows("main"));
499 assert!(!push.branches.allows("feature"));
500 // Never cut off halfway: a build half done saves nothing.
501 assert_eq!(
502 caches.concurrency.as_ref().unwrap().cancel_in_progress,
503 json!(false)
504 );
505 let look = caches.jobs.iter().find(|j| j.id == "look").unwrap();
506 // Looked up, not downloaded.
507 for step in &look.steps {
508 if step.uses.as_deref().is_some_and(|u| u.starts_with("actions/cache")) {
509 assert_eq!(step.uses.as_deref(), Some("actions/cache/restore@v4"));
510 assert_eq!(step.raw["with"]["lookup-only"], json!(true), "{:?}", step.id);
511 }
512 }
513 for job in &caches.jobs {
514 if job.id != "look" {
515 assert_eq!(job.needs, ["look"], "{}", job.id);
516 assert!(
517 job.condition
518 .as_deref()
519 .is_some_and(|c| c.contains("needs.look.outputs.")),
520 "{}",
521 job.id
522 );
523 }
524 }
525}
526
527#[test]
528fn deploy_stages_follow_one_another() {
529 let deploy = read("deploy.yml");
530 let all = plan_outputs(
531 true,
532 &[("rust", "events,repos", true), ("ts", "projects", false)],
533 &[("rust", "api", true)],
534 &[("web", "web", false)],
535 );
536 let push = json!({});
537
538 // Everything succeeds: each stage runs after the last.
539 assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
540 assert!(starts(
541 &deploy,
542 "core",
543 &[("migrate", "success")],
544 &all,
545 push.clone(),
546 false
547 ));
548 assert!(starts(
549 &deploy,
550 "edge",
551 &[("migrate", "success"), ("core", "success")],
552 &all,
553 push.clone(),
554 false
555 ));
556 assert!(starts(
557 &deploy,
558 "front",
559 &[
560 ("migrate", "success"),
561 ("core", "success"),
562 ("edge", "success")
563 ],
564 &all,
565 push.clone(),
566 false
567 ));
568
569 // No migrations: the migrate job is skipped, and core still runs.
570 let none = plan_outputs(
571 false,
572 &[("ts", "projects", false)],
573 &[],
574 &[("web", "web", false)],
575 );
576 assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
577 assert!(starts(
578 &deploy,
579 "core",
580 &[("migrate", "skipped")],
581 &none,
582 push.clone(),
583 false
584 ));
585 // An empty stage is skipped, and the next one still runs.
586 assert!(!starts(
587 &deploy,
588 "edge",
589 &[("migrate", "skipped"), ("core", "success")],
590 &none,
591 push.clone(),
592 false
593 ));
594 assert!(starts(
595 &deploy,
596 "front",
597 &[
598 ("migrate", "skipped"),
599 ("core", "success"),
600 ("edge", "skipped")
601 ],
602 &none,
603 push.clone(),
604 false
605 ));
606
607 // A failure stops every later stage.
608 assert!(!starts(
609 &deploy,
610 "core",
611 &[("migrate", "failure")],
612 &all,
613 push.clone(),
614 false
615 ));
616 assert!(!starts(
617 &deploy,
618 "edge",
619 &[("migrate", "success"), ("core", "failure")],
620 &all,
621 push.clone(),
622 false
623 ));
624 assert!(!starts(
625 &deploy,
626 "front",
627 &[
628 ("migrate", "success"),
629 ("core", "success"),
630 ("edge", "failure")
631 ],
632 &all,
633 push.clone(),
634 false
635 ));
636 assert!(!starts(
637 &deploy,
638 "front",
639 &[
640 ("migrate", "success"),
641 ("core", "failure"),
642 ("edge", "skipped")
643 ],
644 &all,
645 push.clone(),
646 false
647 ));
648 // A cancelled run starts nothing more.
649 assert!(!starts(
650 &deploy,
651 "edge",
652 &[("migrate", "success"), ("core", "success")],
653 &all,
654 push.clone(),
655 true
656 ));
657 // Check and plan run side by side: plan waits for nothing.
658 let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap();
659 assert!(plan.needs.is_empty(), "{:?}", plan.needs);
660 for id in ["migrate", "core", "edge", "front", "smoke"] {
661 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
662 assert!(
663 job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"),
664 "{id}: {:?}",
665 job.needs
666 );
667 }
668 // A failed check, though plan succeeded: nothing migrates or deploys,
669 // and failure() still sees the check's failure through skipped jobs.
670 assert!(!starts(
671 &deploy,
672 "migrate",
673 &[("check", "failure"), ("plan", "success")],
674 &all,
675 push.clone(),
676 false
677 ));
678 assert!(!starts(
679 &deploy,
680 "core",
681 &[
682 ("check", "failure"),
683 ("plan", "success"),
684 ("migrate", "skipped")
685 ],
686 &all,
687 push.clone(),
688 false
689 ));
690 assert!(!starts(
691 &deploy,
692 "front",
693 &[
694 ("check", "failure"),
695 ("plan", "success"),
696 ("migrate", "skipped"),
697 ("core", "skipped"),
698 ("edge", "skipped")
699 ],
700 &all,
701 push.clone(),
702 false
703 ));
704 let skipped = [
705 ("migrate", "skipped"),
706 ("core", "skipped"),
707 ("edge", "skipped"),
708 ];
709 assert!(!starts_after(
710 &deploy,
711 "front",
712 &skipped,
713 &all,
714 push.clone(),
715 false,
716 true
717 ));
718 assert!(!starts(
719 &deploy,
720 "smoke",
721 &[
722 ("check", "failure"),
723 ("core", "skipped"),
724 ("edge", "skipped"),
725 ("front", "skipped")
726 ],
727 &all,
728 push.clone(),
729 false
730 ));
731 // A failed plan stops everything too.
732 assert!(!starts(
733 &deploy,
734 "migrate",
735 &[("plan", "failure")],
736 &all,
737 push.clone(),
738 false
739 ));
740 assert!(!starts(
741 &deploy,
742 "core",
743 &[("plan", "failure"), ("migrate", "skipped")],
744 &all,
745 push.clone(),
746 false
747 ));
748
749 // Smoke follows the last stage that ran, and not a failed one.
750 assert!(starts(
751 &deploy,
752 "smoke",
753 &[
754 ("core", "success"),
755 ("edge", "success"),
756 ("front", "success")
757 ],
758 &all,
759 push.clone(),
760 false
761 ));
762 assert!(starts(
763 &deploy,
764 "smoke",
765 &[
766 ("core", "success"),
767 ("edge", "skipped"),
768 ("front", "success")
769 ],
770 &none,
771 push.clone(),
772 false
773 ));
774 assert!(!starts(
775 &deploy,
776 "smoke",
777 &[
778 ("core", "success"),
779 ("edge", "failure"),
780 ("front", "skipped")
781 ],
782 &all,
783 push.clone(),
784 false
785 ));
786 // Nothing deployed: nothing to smoke-test.
787 let nothing = plan_outputs(false, &[], &[], &[]);
788 assert!(!starts(
789 &deploy,
790 "smoke",
791 &[
792 ("core", "skipped"),
793 ("edge", "skipped"),
794 ("front", "skipped")
795 ],
796 &nothing,
797 push.clone(),
798 false
799 ));
800
801 // A dry run plans and stops.
802 let dry = json!({ "dry_run": true, "units": "", "all": false });
803 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
804 assert!(!starts(
805 &deploy,
806 "core",
807 &[("migrate", "skipped")],
808 &all,
809 dry.clone(),
810 false
811 ));
812 assert!(!starts(
813 &deploy,
814 "smoke",
815 &[
816 ("core", "skipped"),
817 ("edge", "skipped"),
818 ("front", "skipped")
819 ],
820 &all,
821 dry,
822 false
823 ));
824}
825
826#[test]
827fn deploy_stage_matrices_come_from_the_plan() {
828 let deploy = read("deploy.yml");
829 let outputs = plan_outputs(
830 false,
831 &[
832 ("rust-1", "events,work", true),
833 ("rust-2", "repos", true),
834 ("ts", "projects,og", false),
835 ],
836 &[],
837 &[],
838 );
839 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
840 assert!(!core.fail_fast);
841 assert_eq!(core.max_parallel, Some(4));
842 let mut contexts = Map::new();
843 contexts.insert(
844 "needs".into(),
845 json!({ "plan": { "result": "success", "outputs": outputs } }),
846 );
847 let scope = Scope {
848 contexts: &contexts,
849 status: Status::Success,
850 hash_files: None,
851 };
852 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
853 let jobs = matrix::expand(&value).unwrap();
854 let groups: Vec<(&str, &str, bool)> = jobs
855 .iter()
856 .map(|c| {
857 (
858 c["group"].as_str().unwrap(),
859 c["units"].as_str().unwrap(),
860 c["rust"].as_bool().unwrap(),
861 )
862 })
863 .collect();
864 assert_eq!(
865 groups,
866 [
867 ("rust-1", "events,work", true),
868 ("rust-2", "repos", true),
869 ("ts", "projects,og", false)
870 ]
871 );
872}
873
874#[test]
875fn deploy_builds_rust_on_the_larger_machine_with_its_target_cached() {
876 let deploy = read("deploy.yml");
877 for stage in ["core", "edge", "front"] {
878 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
879 let on = |rust: bool, image: bool| {
880 let mut contexts = Map::new();
881 contexts.insert(
882 "matrix".into(),
883 json!({ "group": "g", "units": "u", "rust": rust, "image": image }),
884 );
885 let scope = Scope {
886 contexts: &contexts,
887 status: Status::Success,
888 hash_files: None,
889 };
890 expr::interpolate_value(&job.runs_on, &scope).unwrap()
891 };
892 assert_eq!(on(true, false), json!("g1t-4core"), "{stage}");
893 // The runner's image is built with the job's own Docker Engine.
894 assert_eq!(on(false, true), json!("g1t-4core"), "{stage}");
895 assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}");
896 }
897 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
898 assert!(source.contains("target/wasm32-unknown-unknown/release"));
899 assert!(source.contains("!target/**/incremental"));
900 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
901}
902
903/// Whether a step runs, for a job going `status`, with `matrix`.
904fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool {
905 let mut contexts = Map::new();
906 contexts.insert("matrix".into(), matrix);
907 let scope = Scope {
908 contexts: &contexts,
909 status,
910 hash_files: None,
911 };
912 expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
913}
914
915#[test]
916fn rust_builds_go_through_sccache_before_cargo_and_report_after() {
917 let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) {
918 let at = job
919 .steps
920 .iter()
921 .position(|s| s.run.as_deref() == Some(run))
922 .unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
923 (at, job.steps[at].clone())
924 };
925 let deploy = read("deploy.yml");
926 for stage in ["core", "edge", "front"] {
927 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
928 let (install_at, install) = step(job, "bash scripts/sccache.sh install");
929 let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats");
930 // Before anything runs Cargo (worker-build's install, the build),
931 // and the statistics last.
932 let install_step = job
933 .steps
934 .iter()
935 .position(|s| s.name.as_deref() == Some("Install"))
936 .unwrap();
937 assert!(install_at < install_step, "{stage}");
938 assert_eq!(stats_at, job.steps.len() - 1, "{stage}");
939 for (rust, image, uses) in [
940 (true, false, true),
941 (false, true, true),
942 (false, false, false),
943 ] {
944 let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image });
945 assert_eq!(
946 step_runs(&install, matrix.clone(), Status::Success),
947 uses,
948 "{stage} rust={rust} image={image}"
949 );
950 assert_eq!(
951 step_runs(&stats, matrix.clone(), Status::Success),
952 uses,
953 "{stage}"
954 );
955 // A failed build still says what was cached.
956 assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}");
957 }
958 }
959 let ci = read("ci.yml");
960 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
961 let (install_at, _) = step(rust, "bash scripts/sccache.sh install");
962 let (tests_at, _) = step(rust, "cargo test --workspace --locked --quiet");
963 let (stats_at, stats) = step(rust, "bash scripts/sccache.sh stats");
964 assert!(install_at < tests_at && tests_at < stats_at);
965 assert!(step_runs(&stats, json!({}), Status::Failure));
966 // main's runs keep the caches pull requests restore from: only Rust.
967 assert!(ci.trigger("push").unwrap().branches.allows("main"));
968 assert!(ci.trigger("pull_request").is_some());
969 for (id, on_push) in [("rust", true), ("typescript", false), ("build", false)] {
970 let job = ci.jobs.iter().find(|j| j.id == id).unwrap();
971 for (event, expected) in [("push", on_push), ("pull_request", true)] {
972 let mut contexts = Map::new();
973 contexts.insert(
974 "github".into(),
975 json!({ "event_name": event, "ref": "refs/heads/main" }),
976 );
977 let scope = Scope {
978 contexts: &contexts,
979 status: Status::Success,
980 hash_files: None,
981 };
982 assert_eq!(
983 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(),
984 expected,
985 "{id} on {event}"
986 );
987 }
988 }
989 // The download is pinned by version and checksum.
990 let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap();
991 assert!(script.contains("VERSION=0.18.0"));
992 assert!(script.lines().any(|line| {
993 line.strip_prefix("SHA256=")
994 .is_some_and(|sum| sum.len() == 64)
995 }));
996 assert!(script.contains("sha256sum -c"));
997 assert!(script.contains("RUSTC_WRAPPER="));
998 assert!(script.contains("GITHUB_STEP_SUMMARY"));
999 // A tag's run reads main's entries and saves none, which only its own
1000 // tag could read; the mode reaches the steps after it too.
1001 assert!(script.contains("if [ \"${GITHUB_REF_TYPE:-}\" = tag ]; then\n export SCCACHE_GHA_RW_MODE=READ_ONLY"));
1002 assert!(script.contains("echo \"SCCACHE_GHA_RW_MODE=$SCCACHE_GHA_RW_MODE\""));
1003 let tagged = read("runner-release.yml");
1004 assert!(tagged.trigger("push").unwrap().tags.allows("runner-v0.4.0"));
1005}
1006
1007#[test]
1008fn g1ts_own_actions_are_published_when_they_change() {
1009 let release = read("actions-release.yml");
1010 let push = release.trigger("push").unwrap();
1011 assert!(push.branches.allows("main"));
1012 assert!(!push.branches.allows("feature"));
1013 assert!(release.trigger("workflow_dispatch").is_some());
1014 let source = std::fs::read_to_string(workflows_dir().join("actions-release.yml")).unwrap();
1015 assert!(source.contains("actions/**"));
1016 assert!(source.contains("secrets.ACTIONS_PUBLISH_TOKEN"));
1017 // Every action is a composite one with its README, and the copies of
1018 // the script that finds g1t-runner are the same file.
1019 let actions = workflows_dir().join("../../actions");
1020 let mut finders = Vec::new();
1021 for entry in std::fs::read_dir(&actions).unwrap() {
1022 let dir = entry.unwrap().path();
1023 let manifest = std::fs::read_to_string(dir.join("action.yml"))
1024 .unwrap_or_else(|_| panic!("{} has no action.yml", dir.display()));
1025 let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap();
1026 assert_eq!(
1027 manifest["runs"]["using"].as_str(),
1028 Some("composite"),
1029 "{}",
1030 dir.display()
1031 );
1032 assert!(dir.join("README.md").exists(), "{}", dir.display());
1033 finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap());
1034 }
1035 assert!(finders.len() >= 2);
1036 assert!(
1037 finders.windows(2).all(|pair| pair[0] == pair[1]),
1038 "actions/*/runner.sh differ"
1039 );
1040 let deploy: serde_yaml::Value =
1041 serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap())
1042 .unwrap();
1043 assert_eq!(
1044 deploy["inputs"]["token"]["default"].as_str(),
1045 Some("${{ github.token }}")
1046 );
1047 for output in ["url", "deployment-id"] {
1048 assert!(deploy["outputs"][output]["value"].is_string(), "{output}");
1049 }
1050}
1051
1052#[test]
1053fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
1054 let base = read("runner-base.yml");
1055 assert!(base.trigger("schedule").is_some());
1056 assert!(base.trigger("workflow_dispatch").is_some());
1057 assert!(base.trigger("push").unwrap().branches.allows("main"));
1058 let job = base.jobs.iter().find(|j| j.id == "build").unwrap();
1059 assert_eq!(job.runs_on, json!(["self-hosted", "docker"]));
1060}
1061
1062#[test]
1063fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() {
1064 let root = workflows_dir()
1065 .parent()
1066 .unwrap()
1067 .parent()
1068 .unwrap()
1069 .to_path_buf();
1070 let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap();
1071 let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap();
1072 assert_eq!(
1073 ours, theirs,
1074 "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml"
1075 );
1076 // It runs only by hand, and reads nothing it could change.
1077 let workflow = read("conformance.yml");
1078 assert_eq!(
1079 workflow
1080 .triggers
1081 .iter()
1082 .map(|t| t.event.as_str())
1083 .collect::<Vec<_>>(),
1084 ["workflow_dispatch"]
1085 );
1086 assert!(!ours.contains("secrets."));
1087}
1088
1089/// No workflow of g1t's asks for a secret no one can set. Secret names
1090/// starting with G1T_ or GITHUB_ are g1t's own and refused when saved, so a
1091/// workflow reading one gets nothing: the Actions release asked for
1092/// G1T_ACTIONS_PUBLISH_TOKEN, which could never exist. G1T_TOKEN, the job's
1093/// own token, is the one g1t gives every job.
1094#[test]
1095fn workflows_read_only_secrets_that_can_be_set() {
1096 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
1097 let path = entry.unwrap().path();
1098 if path.extension().is_none_or(|ext| ext != "yml") {
1099 continue;
1100 }
1101 let source = std::fs::read_to_string(&path).unwrap();
1102 for (at, _) in source.match_indices("secrets.") {
1103 let name: String = source[at + "secrets.".len()..]
1104 .chars()
1105 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1106 .collect();
1107 let reserved = name.starts_with("G1T_") || name.starts_with("GITHUB_");
1108 assert!(
1109 !reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN",
1110 "{} reads secrets.{name}, a name g1t keeps for its own",
1111 path.display()
1112 );
1113 }
1114 }
1115}
1116
1117#[test]
1118fn every_install_runs_again_when_a_connection_drops() {
1119 // npm does not retry a body that breaks off midway; npm-ci.mjs does.
1120 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
1121 let path = entry.unwrap().path();
1122 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
1123 continue;
1124 }
1125 let name = path.file_name().unwrap().to_string_lossy().to_string();
1126 for job in read(&name).jobs {
1127 for step in &job.steps {
1128 let Some(run) = step.run.as_deref() else {
1129 continue;
1130 };
1131 for line in run.lines() {
1132 assert!(
1133 !line.trim().starts_with("npm ci"),
1134 "{name} {}: `{}` should be `node scripts/deploy/npm-ci.mjs …`",
1135 job.id,
1136 line.trim()
1137 );
1138 }
1139 }
1140 }
1141 }
1142}