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,116 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 ] {
348 assert!(seen.contains_key(family), "{family}: {:?}", seen.keys());
349 }
350}
351
352#[test]
353fn main_saves_every_cache_that_pull_requests_and_releases_restore() {
354 // A run restores from its own ref, its pull request's base and main;
355 // a release tag's run never reads another tag's entries. So every
356 // entry any step restores is saved by a workflow that runs on pushes
357 // to main, and the release workflows only restore.
358 let on_main = ["ci.yml", "caches.yml", "deploy.yml"];
359 for file in on_main {
360 let push = read(file).trigger("push").cloned().unwrap();
361 assert!(push.branches.allows("main"), "{file}");
362 }
363 let releases = [
364 "cli-release.yml",
365 "desktop-release.yml",
366 "mobile-build.yml",
367 "mobile-release.yml",
368 "runner-release.yml",
369 ];
370 let steps = cache_steps();
371 let saved_on_main: std::collections::BTreeSet<String> = steps
372 .iter()
373 .filter(|(file, _, step)| on_main.contains(&file.as_str()) && saves(step))
374 .map(|(_, _, step)| family(step.raw["with"]["key"].as_str().unwrap_or_default()))
375 .collect();
376 for (file, job, step) in &steps {
377 let family = family(step.raw["with"]["key"].as_str().unwrap_or_default());
378 // The conformance run restores what its own first job saved.
379 if family == "conformance-" {
380 continue;
381 }
382 assert!(
383 saved_on_main.contains(&family),
384 "{file} {}: nothing on main saves {family}",
385 job.id
386 );
387 if releases.contains(&file.as_str()) {
388 assert!(!saves(step), "{file} {}: a release saves {family}, which only its own tag would read", job.id);
389 }
390 }
391}
392
393#[test]
394fn rust_caches_are_keyed_by_the_toolchain_and_the_dependencies() {
395 // Not by Cargo.lock as a whole: 25 of its 31 changes in a week were a
396 // release's version bump, and each saved every Rust cache again.
397 for (file, job, step) in cache_steps() {
398 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
399 assert!(
400 !key.contains("Cargo.lock") && !key.contains("base.json"),
401 "{file} {}: {key}",
402 job.id
403 );
404 let target = paths(&step).iter().any(|p| p.contains("target"));
405 if target {
406 assert!(key.contains("steps.keys.outputs.rust"), "{file} {}: {key}", job.id);
407 // A restore key never reaches another toolchain's entries.
408 for prefix in step.raw["with"]["restore-keys"]
409 .as_str()
410 .unwrap_or_default()
411 .lines()
412 .filter(|l| !l.trim().is_empty())
413 {
414 assert!(prefix.contains("steps.keys.outputs.rust"), "{file} {}: {prefix}", job.id);
415 }
416 }
417 if key.contains("steps.keys.outputs.") {
418 let at = job.steps.iter().position(|s| s == &step).unwrap();
419 let keys = job
420 .steps
421 .iter()
422 .position(|s| s.id.as_deref() == Some("keys"))
423 .unwrap_or_else(|| panic!("{file} {}: no keys step", job.id));
424 assert!(keys < at, "{file} {}", job.id);
425 assert_eq!(
426 job.steps[keys].run.as_deref(),
427 Some("node scripts/deploy/ci-cache.mjs keys")
428 );
429 }
430 }
431}
432
433#[test]
434fn a_cargo_target_restored_from_an_older_entry_is_pruned_before_it_is_saved() {
435 // Otherwise the old versions of what changed ride along forever: CI's
436 // test build grew from 671 MB to 1,687 MB in a day.
437 for (file, id, cache) in [
438 ("ci.yml", "rust", "test-build"),
439 ("ci.yml", "desktop", "desktop-build"),
440 ("caches.yml", "runner", "runner-build"),
441 ] {
442 let workflow = read(file);
443 let job = workflow.jobs.iter().find(|j| j.id == id).unwrap();
444 let at = job
445 .steps
446 .iter()
447 .position(|s| s.id.as_deref() == Some(cache))
448 .unwrap();
449 assert!(saves(&job.steps[at]), "{file} {id}");
450 let prune = job
451 .steps
452 .iter()
453 .position(|s| {
454 s.run
455 .as_deref()
456 .is_some_and(|r| r.starts_with("node scripts/deploy/ci-cache.mjs prune "))
457 })
458 .unwrap_or_else(|| panic!("{file} {id}: no prune"));
459 assert!(at < prune, "{file} {id}");
460 assert_eq!(
461 job.steps[prune].condition.as_deref().map(str::trim),
462 Some(format!("${{{{ steps.{cache}.outputs.cache-hit != 'true' }}}}").as_str()),
463 "{file} {id}"
464 );
465 }
466 let ci = read("ci.yml");
467 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
468 assert_eq!(
469 rust.raw["env"]["CARGO_PROFILE_DEV_DEBUG"],
470 json!("line-tables-only")
471 );
472}
473
474#[test]
475fn the_caches_workflow_builds_only_what_is_missing() {
476 let caches = read("caches.yml");
477 let push = caches.trigger("push").unwrap();
478 assert!(push.branches.allows("main"));
479 assert!(!push.branches.allows("feature"));
480 // Never cut off halfway: a build half done saves nothing.
481 assert_eq!(
482 caches.concurrency.as_ref().unwrap().cancel_in_progress,
483 json!(false)
484 );
485 let look = caches.jobs.iter().find(|j| j.id == "look").unwrap();
486 // Looked up, not downloaded.
487 for step in &look.steps {
488 if step.uses.as_deref().is_some_and(|u| u.starts_with("actions/cache")) {
489 assert_eq!(step.uses.as_deref(), Some("actions/cache/restore@v4"));
490 assert_eq!(step.raw["with"]["lookup-only"], json!(true), "{:?}", step.id);
491 }
492 }
493 for job in &caches.jobs {
494 if job.id != "look" {
495 assert_eq!(job.needs, ["look"], "{}", job.id);
496 assert!(
497 job.condition
498 .as_deref()
499 .is_some_and(|c| c.contains("needs.look.outputs.")),
500 "{}",
501 job.id
502 );
503 }
504 }
505}
506
507#[test]
508fn deploy_stages_follow_one_another() {
509 let deploy = read("deploy.yml");
510 let all = plan_outputs(
511 true,
512 &[("rust", "events,repos", true), ("ts", "projects", false)],
513 &[("rust", "api", true)],
514 &[("web", "web", false)],
515 );
516 let push = json!({});
517
518 // Everything succeeds: each stage runs after the last.
519 assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
520 assert!(starts(
521 &deploy,
522 "core",
523 &[("migrate", "success")],
524 &all,
525 push.clone(),
526 false
527 ));
528 assert!(starts(
529 &deploy,
530 "edge",
531 &[("migrate", "success"), ("core", "success")],
532 &all,
533 push.clone(),
534 false
535 ));
536 assert!(starts(
537 &deploy,
538 "front",
539 &[
540 ("migrate", "success"),
541 ("core", "success"),
542 ("edge", "success")
543 ],
544 &all,
545 push.clone(),
546 false
547 ));
548
549 // No migrations: the migrate job is skipped, and core still runs.
550 let none = plan_outputs(
551 false,
552 &[("ts", "projects", false)],
553 &[],
554 &[("web", "web", false)],
555 );
556 assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
557 assert!(starts(
558 &deploy,
559 "core",
560 &[("migrate", "skipped")],
561 &none,
562 push.clone(),
563 false
564 ));
565 // An empty stage is skipped, and the next one still runs.
566 assert!(!starts(
567 &deploy,
568 "edge",
569 &[("migrate", "skipped"), ("core", "success")],
570 &none,
571 push.clone(),
572 false
573 ));
574 assert!(starts(
575 &deploy,
576 "front",
577 &[
578 ("migrate", "skipped"),
579 ("core", "success"),
580 ("edge", "skipped")
581 ],
582 &none,
583 push.clone(),
584 false
585 ));
586
587 // A failure stops every later stage.
588 assert!(!starts(
589 &deploy,
590 "core",
591 &[("migrate", "failure")],
592 &all,
593 push.clone(),
594 false
595 ));
596 assert!(!starts(
597 &deploy,
598 "edge",
599 &[("migrate", "success"), ("core", "failure")],
600 &all,
601 push.clone(),
602 false
603 ));
604 assert!(!starts(
605 &deploy,
606 "front",
607 &[
608 ("migrate", "success"),
609 ("core", "success"),
610 ("edge", "failure")
611 ],
612 &all,
613 push.clone(),
614 false
615 ));
616 assert!(!starts(
617 &deploy,
618 "front",
619 &[
620 ("migrate", "success"),
621 ("core", "failure"),
622 ("edge", "skipped")
623 ],
624 &all,
625 push.clone(),
626 false
627 ));
628 // A cancelled run starts nothing more.
629 assert!(!starts(
630 &deploy,
631 "edge",
632 &[("migrate", "success"), ("core", "success")],
633 &all,
634 push.clone(),
635 true
636 ));
637 // Check and plan run side by side: plan waits for nothing.
638 let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap();
639 assert!(plan.needs.is_empty(), "{:?}", plan.needs);
640 for id in ["migrate", "core", "edge", "front", "smoke"] {
641 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
642 assert!(
643 job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"),
644 "{id}: {:?}",
645 job.needs
646 );
647 }
648 // A failed check, though plan succeeded: nothing migrates or deploys,
649 // and failure() still sees the check's failure through skipped jobs.
650 assert!(!starts(
651 &deploy,
652 "migrate",
653 &[("check", "failure"), ("plan", "success")],
654 &all,
655 push.clone(),
656 false
657 ));
658 assert!(!starts(
659 &deploy,
660 "core",
661 &[
662 ("check", "failure"),
663 ("plan", "success"),
664 ("migrate", "skipped")
665 ],
666 &all,
667 push.clone(),
668 false
669 ));
670 assert!(!starts(
671 &deploy,
672 "front",
673 &[
674 ("check", "failure"),
675 ("plan", "success"),
676 ("migrate", "skipped"),
677 ("core", "skipped"),
678 ("edge", "skipped")
679 ],
680 &all,
681 push.clone(),
682 false
683 ));
684 let skipped = [
685 ("migrate", "skipped"),
686 ("core", "skipped"),
687 ("edge", "skipped"),
688 ];
689 assert!(!starts_after(
690 &deploy,
691 "front",
692 &skipped,
693 &all,
694 push.clone(),
695 false,
696 true
697 ));
698 assert!(!starts(
699 &deploy,
700 "smoke",
701 &[
702 ("check", "failure"),
703 ("core", "skipped"),
704 ("edge", "skipped"),
705 ("front", "skipped")
706 ],
707 &all,
708 push.clone(),
709 false
710 ));
711 // A failed plan stops everything too.
712 assert!(!starts(
713 &deploy,
714 "migrate",
715 &[("plan", "failure")],
716 &all,
717 push.clone(),
718 false
719 ));
720 assert!(!starts(
721 &deploy,
722 "core",
723 &[("plan", "failure"), ("migrate", "skipped")],
724 &all,
725 push.clone(),
726 false
727 ));
728
729 // Smoke follows the last stage that ran, and not a failed one.
730 assert!(starts(
731 &deploy,
732 "smoke",
733 &[
734 ("core", "success"),
735 ("edge", "success"),
736 ("front", "success")
737 ],
738 &all,
739 push.clone(),
740 false
741 ));
742 assert!(starts(
743 &deploy,
744 "smoke",
745 &[
746 ("core", "success"),
747 ("edge", "skipped"),
748 ("front", "success")
749 ],
750 &none,
751 push.clone(),
752 false
753 ));
754 assert!(!starts(
755 &deploy,
756 "smoke",
757 &[
758 ("core", "success"),
759 ("edge", "failure"),
760 ("front", "skipped")
761 ],
762 &all,
763 push.clone(),
764 false
765 ));
766 // Nothing deployed: nothing to smoke-test.
767 let nothing = plan_outputs(false, &[], &[], &[]);
768 assert!(!starts(
769 &deploy,
770 "smoke",
771 &[
772 ("core", "skipped"),
773 ("edge", "skipped"),
774 ("front", "skipped")
775 ],
776 &nothing,
777 push.clone(),
778 false
779 ));
780
781 // A dry run plans and stops.
782 let dry = json!({ "dry_run": true, "units": "", "all": false });
783 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
784 assert!(!starts(
785 &deploy,
786 "core",
787 &[("migrate", "skipped")],
788 &all,
789 dry.clone(),
790 false
791 ));
792 assert!(!starts(
793 &deploy,
794 "smoke",
795 &[
796 ("core", "skipped"),
797 ("edge", "skipped"),
798 ("front", "skipped")
799 ],
800 &all,
801 dry,
802 false
803 ));
804}
805
806#[test]
807fn deploy_stage_matrices_come_from_the_plan() {
808 let deploy = read("deploy.yml");
809 let outputs = plan_outputs(
810 false,
811 &[
812 ("rust-1", "events,work", true),
813 ("rust-2", "repos", true),
814 ("ts", "projects,og", false),
815 ],
816 &[],
817 &[],
818 );
819 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
820 assert!(!core.fail_fast);
821 assert_eq!(core.max_parallel, Some(4));
822 let mut contexts = Map::new();
823 contexts.insert(
824 "needs".into(),
825 json!({ "plan": { "result": "success", "outputs": outputs } }),
826 );
827 let scope = Scope {
828 contexts: &contexts,
829 status: Status::Success,
830 hash_files: None,
831 };
832 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
833 let jobs = matrix::expand(&value).unwrap();
834 let groups: Vec<(&str, &str, bool)> = jobs
835 .iter()
836 .map(|c| {
837 (
838 c["group"].as_str().unwrap(),
839 c["units"].as_str().unwrap(),
840 c["rust"].as_bool().unwrap(),
841 )
842 })
843 .collect();
844 assert_eq!(
845 groups,
846 [
847 ("rust-1", "events,work", true),
848 ("rust-2", "repos", true),
849 ("ts", "projects,og", false)
850 ]
851 );
852}
853
854#[test]
855fn deploy_builds_rust_on_the_larger_machine_with_its_target_cached() {
856 let deploy = read("deploy.yml");
857 for stage in ["core", "edge", "front"] {
858 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
859 let on = |rust: bool, image: bool| {
860 let mut contexts = Map::new();
861 contexts.insert(
862 "matrix".into(),
863 json!({ "group": "g", "units": "u", "rust": rust, "image": image }),
864 );
865 let scope = Scope {
866 contexts: &contexts,
867 status: Status::Success,
868 hash_files: None,
869 };
870 expr::interpolate_value(&job.runs_on, &scope).unwrap()
871 };
872 assert_eq!(on(true, false), json!("g1t-4core"), "{stage}");
873 // The runner's image is built with the job's own Docker Engine.
874 assert_eq!(on(false, true), json!("g1t-4core"), "{stage}");
875 assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}");
876 }
877 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
878 assert!(source.contains("target/wasm32-unknown-unknown/release"));
879 assert!(source.contains("!target/**/incremental"));
880 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
881}
882
883/// Whether a step runs, for a job going `status`, with `matrix`.
884fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool {
885 let mut contexts = Map::new();
886 contexts.insert("matrix".into(), matrix);
887 let scope = Scope {
888 contexts: &contexts,
889 status,
890 hash_files: None,
891 };
892 expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
893}
894
895#[test]
896fn rust_builds_go_through_sccache_before_cargo_and_report_after() {
897 let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) {
898 let at = job
899 .steps
900 .iter()
901 .position(|s| s.run.as_deref() == Some(run))
902 .unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
903 (at, job.steps[at].clone())
904 };
905 let deploy = read("deploy.yml");
906 for stage in ["core", "edge", "front"] {
907 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
908 let (install_at, install) = step(job, "bash scripts/sccache.sh install");
909 let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats");
910 // Before anything runs Cargo (worker-build's install, the build),
911 // and the statistics last.
912 let install_step = job
913 .steps
914 .iter()
915 .position(|s| s.name.as_deref() == Some("Install"))
916 .unwrap();
917 assert!(install_at < install_step, "{stage}");
918 assert_eq!(stats_at, job.steps.len() - 1, "{stage}");
919 for (rust, image, uses) in [
920 (true, false, true),
921 (false, true, true),
922 (false, false, false),
923 ] {
924 let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image });
925 assert_eq!(
926 step_runs(&install, matrix.clone(), Status::Success),
927 uses,
928 "{stage} rust={rust} image={image}"
929 );
930 assert_eq!(
931 step_runs(&stats, matrix.clone(), Status::Success),
932 uses,
933 "{stage}"
934 );
935 // A failed build still says what was cached.
936 assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}");
937 }
938 }
939 let ci = read("ci.yml");
940 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
941 let (install_at, _) = step(rust, "bash scripts/sccache.sh install");
942 let (tests_at, _) = step(rust, "cargo test --workspace --locked --quiet");
943 let (stats_at, stats) = step(rust, "bash scripts/sccache.sh stats");
944 assert!(install_at < tests_at && tests_at < stats_at);
945 assert!(step_runs(&stats, json!({}), Status::Failure));
946 // main's runs keep the caches pull requests restore from: only Rust.
947 assert!(ci.trigger("push").unwrap().branches.allows("main"));
948 assert!(ci.trigger("pull_request").is_some());
949 for (id, on_push) in [("rust", true), ("typescript", false), ("build", false)] {
950 let job = ci.jobs.iter().find(|j| j.id == id).unwrap();
951 for (event, expected) in [("push", on_push), ("pull_request", true)] {
952 let mut contexts = Map::new();
953 contexts.insert(
954 "github".into(),
955 json!({ "event_name": event, "ref": "refs/heads/main" }),
956 );
957 let scope = Scope {
958 contexts: &contexts,
959 status: Status::Success,
960 hash_files: None,
961 };
962 assert_eq!(
963 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(),
964 expected,
965 "{id} on {event}"
966 );
967 }
968 }
969 // The download is pinned by version and checksum.
970 let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap();
971 assert!(script.contains("VERSION=0.18.0"));
972 assert!(script.lines().any(|line| {
973 line.strip_prefix("SHA256=")
974 .is_some_and(|sum| sum.len() == 64)
975 }));
976 assert!(script.contains("sha256sum -c"));
977 assert!(script.contains("RUSTC_WRAPPER="));
978 assert!(script.contains("GITHUB_STEP_SUMMARY"));
979}
980
981#[test]
982fn g1ts_own_actions_are_published_when_they_change() {
983 let release = read("actions-release.yml");
984 let push = release.trigger("push").unwrap();
985 assert!(push.branches.allows("main"));
986 assert!(!push.branches.allows("feature"));
987 assert!(release.trigger("workflow_dispatch").is_some());
988 let source = std::fs::read_to_string(workflows_dir().join("actions-release.yml")).unwrap();
989 assert!(source.contains("actions/**"));
990 assert!(source.contains("secrets.ACTIONS_PUBLISH_TOKEN"));
991 // Every action is a composite one with its README, and the copies of
992 // the script that finds g1t-runner are the same file.
993 let actions = workflows_dir().join("../../actions");
994 let mut finders = Vec::new();
995 for entry in std::fs::read_dir(&actions).unwrap() {
996 let dir = entry.unwrap().path();
997 let manifest = std::fs::read_to_string(dir.join("action.yml"))
998 .unwrap_or_else(|_| panic!("{} has no action.yml", dir.display()));
999 let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap();
1000 assert_eq!(
1001 manifest["runs"]["using"].as_str(),
1002 Some("composite"),
1003 "{}",
1004 dir.display()
1005 );
1006 assert!(dir.join("README.md").exists(), "{}", dir.display());
1007 finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap());
1008 }
1009 assert!(finders.len() >= 2);
1010 assert!(
1011 finders.windows(2).all(|pair| pair[0] == pair[1]),
1012 "actions/*/runner.sh differ"
1013 );
1014 let deploy: serde_yaml::Value =
1015 serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap())
1016 .unwrap();
1017 assert_eq!(
1018 deploy["inputs"]["token"]["default"].as_str(),
1019 Some("${{ github.token }}")
1020 );
1021 for output in ["url", "deployment-id"] {
1022 assert!(deploy["outputs"][output]["value"].is_string(), "{output}");
1023 }
1024}
1025
1026#[test]
1027fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
1028 let base = read("runner-base.yml");
1029 assert!(base.trigger("schedule").is_some());
1030 assert!(base.trigger("workflow_dispatch").is_some());
1031 assert!(base.trigger("push").unwrap().branches.allows("main"));
1032 let job = base.jobs.iter().find(|j| j.id == "build").unwrap();
1033 assert_eq!(job.runs_on, json!(["self-hosted", "docker"]));
1034}
1035
1036#[test]
1037fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() {
1038 let root = workflows_dir()
1039 .parent()
1040 .unwrap()
1041 .parent()
1042 .unwrap()
1043 .to_path_buf();
1044 let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap();
1045 let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap();
1046 assert_eq!(
1047 ours, theirs,
1048 "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml"
1049 );
1050 // It runs only by hand, and reads nothing it could change.
1051 let workflow = read("conformance.yml");
1052 assert_eq!(
1053 workflow
1054 .triggers
1055 .iter()
1056 .map(|t| t.event.as_str())
1057 .collect::<Vec<_>>(),
1058 ["workflow_dispatch"]
1059 );
1060 assert!(!ours.contains("secrets."));
1061}
1062
1063/// No workflow of g1t's asks for a secret no one can set. Secret names
1064/// starting with G1T_ or GITHUB_ are g1t's own and refused when saved, so a
1065/// workflow reading one gets nothing: the Actions release asked for
1066/// G1T_ACTIONS_PUBLISH_TOKEN, which could never exist. G1T_TOKEN, the job's
1067/// own token, is the one g1t gives every job.
1068#[test]
1069fn workflows_read_only_secrets_that_can_be_set() {
1070 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
1071 let path = entry.unwrap().path();
1072 if path.extension().is_none_or(|ext| ext != "yml") {
1073 continue;
1074 }
1075 let source = std::fs::read_to_string(&path).unwrap();
1076 for (at, _) in source.match_indices("secrets.") {
1077 let name: String = source[at + "secrets.".len()..]
1078 .chars()
1079 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1080 .collect();
1081 let reserved = name.starts_with("G1T_") || name.starts_with("GITHUB_");
1082 assert!(
1083 !reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN",
1084 "{} reads secrets.{name}, a name g1t keeps for its own",
1085 path.display()
1086 );
1087 }
1088 }
1089}
1090
1091#[test]
1092fn every_install_runs_again_when_a_connection_drops() {
1093 // npm does not retry a body that breaks off midway; npm-ci.mjs does.
1094 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
1095 let path = entry.unwrap().path();
1096 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
1097 continue;
1098 }
1099 let name = path.file_name().unwrap().to_string_lossy().to_string();
1100 for job in read(&name).jobs {
1101 for step in &job.steps {
1102 let Some(run) = step.run.as_deref() else {
1103 continue;
1104 };
1105 for line in run.lines() {
1106 assert!(
1107 !line.trim().starts_with("npm ci"),
1108 "{name} {}: `{}` should be `node scripts/deploy/npm-ci.mjs …`",
1109 job.id,
1110 line.trim()
1111 );
1112 }
1113 }
1114 }
1115 }
1116}