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
893 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 [
191 "check",
192 "npm-cache",
193 "plan",
194 "migrate",
195 "core",
196 "edge",
197 "front",
198 "smoke"
199 ]
200 );
201 // Nothing waits for npm's downloads to be saved.
202 assert!(
203 deploy
204 .jobs
205 .iter()
206 .all(|job| !job.needs.iter().any(|n| n == "npm-cache"))
207 );
208 // The stages share their steps (a YAML alias), and read the token only
209 // where they deploy.
210 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
211 assert_eq!(steps("core"), steps("edge"));
212 assert_eq!(steps("core"), steps("front"));
213 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
214 assert!(!source.contains("cancel-in-progress: true"));
215}
216
217/// Whether a step's `run` installs the whole lockfile: `npm ci` with no
218/// workspace narrowing it, and not the deploy tool's install of some units.
219fn installs_everything(run: &str) -> bool {
220 run.split('\n').any(|line| {
221 let line = line.trim();
222 // Installs go through npm-ci.mjs, which runs `npm ci` again when a connection drops.
223 (line.starts_with("npm ci") || line.starts_with("node scripts/deploy/npm-ci.mjs"))
224 && !line.contains(" -w ")
225 && !line.contains("--workspace")
226 })
227}
228
229#[test]
230fn npm_downloads_are_cached_whole_and_only_whole_installs_save_them() {
231 // Every job's npm cache is the whole lockfile's, under one key. A job
232 // that installs part of it only restores: had it saved, its part would
233 // stand in for the whole under that key (the first job to finish used
234 // to save Wrangler alone, and every later install downloaded the rest).
235 let mut saving = Vec::new();
236 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
237 let path = entry.unwrap().path();
238 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
239 continue;
240 }
241 let name = path.file_name().unwrap().to_string_lossy().to_string();
242 for job in read(&name).jobs {
243 let whole = job
244 .steps
245 .iter()
246 .any(|s| s.run.as_deref().is_some_and(installs_everything));
247 for step in &job.steps {
248 let Some(uses) = step.uses.as_deref() else {
249 continue;
250 };
251 if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm")
252 {
253 continue;
254 }
255 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
256 assert_eq!(
257 key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}",
258 "{name} {}",
259 job.id
260 );
261 if let Some(prefix) = step.raw["with"]["restore-keys"].as_str() {
262 assert_eq!(prefix, "npm-full-${{ runner.os }}-", "{name} {}", job.id);
263 }
264 let saves =
265 uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@");
266 assert!(
267 !saves || whole,
268 "{name} {}: saves npm's cache but installs only part of the lockfile",
269 job.id
270 );
271 if saves {
272 saving.push(format!("{name} {}", job.id));
273 }
274 }
275 }
276 }
277 // main's whole cache is kept by the deploy workflow, beside its check.
278 assert!(
279 saving.contains(&"deploy.yml npm-cache".to_owned()),
280 "{saving:?}"
281 );
282}
283
284#[test]
285fn deploy_stages_follow_one_another() {
286 let deploy = read("deploy.yml");
287 let all = plan_outputs(
288 true,
289 &[("rust", "events,repos", true), ("ts", "projects", false)],
290 &[("rust", "api", true)],
291 &[("web", "web", false)],
292 );
293 let push = json!({});
294
295 // Everything succeeds: each stage runs after the last.
296 assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
297 assert!(starts(
298 &deploy,
299 "core",
300 &[("migrate", "success")],
301 &all,
302 push.clone(),
303 false
304 ));
305 assert!(starts(
306 &deploy,
307 "edge",
308 &[("migrate", "success"), ("core", "success")],
309 &all,
310 push.clone(),
311 false
312 ));
313 assert!(starts(
314 &deploy,
315 "front",
316 &[
317 ("migrate", "success"),
318 ("core", "success"),
319 ("edge", "success")
320 ],
321 &all,
322 push.clone(),
323 false
324 ));
325
326 // No migrations: the migrate job is skipped, and core still runs.
327 let none = plan_outputs(
328 false,
329 &[("ts", "projects", false)],
330 &[],
331 &[("web", "web", false)],
332 );
333 assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
334 assert!(starts(
335 &deploy,
336 "core",
337 &[("migrate", "skipped")],
338 &none,
339 push.clone(),
340 false
341 ));
342 // An empty stage is skipped, and the next one still runs.
343 assert!(!starts(
344 &deploy,
345 "edge",
346 &[("migrate", "skipped"), ("core", "success")],
347 &none,
348 push.clone(),
349 false
350 ));
351 assert!(starts(
352 &deploy,
353 "front",
354 &[
355 ("migrate", "skipped"),
356 ("core", "success"),
357 ("edge", "skipped")
358 ],
359 &none,
360 push.clone(),
361 false
362 ));
363
364 // A failure stops every later stage.
365 assert!(!starts(
366 &deploy,
367 "core",
368 &[("migrate", "failure")],
369 &all,
370 push.clone(),
371 false
372 ));
373 assert!(!starts(
374 &deploy,
375 "edge",
376 &[("migrate", "success"), ("core", "failure")],
377 &all,
378 push.clone(),
379 false
380 ));
381 assert!(!starts(
382 &deploy,
383 "front",
384 &[
385 ("migrate", "success"),
386 ("core", "success"),
387 ("edge", "failure")
388 ],
389 &all,
390 push.clone(),
391 false
392 ));
393 assert!(!starts(
394 &deploy,
395 "front",
396 &[
397 ("migrate", "success"),
398 ("core", "failure"),
399 ("edge", "skipped")
400 ],
401 &all,
402 push.clone(),
403 false
404 ));
405 // A cancelled run starts nothing more.
406 assert!(!starts(
407 &deploy,
408 "edge",
409 &[("migrate", "success"), ("core", "success")],
410 &all,
411 push.clone(),
412 true
413 ));
414 // Check and plan run side by side: plan waits for nothing.
415 let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap();
416 assert!(plan.needs.is_empty(), "{:?}", plan.needs);
417 for id in ["migrate", "core", "edge", "front", "smoke"] {
418 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
419 assert!(
420 job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"),
421 "{id}: {:?}",
422 job.needs
423 );
424 }
425 // A failed check, though plan succeeded: nothing migrates or deploys,
426 // and failure() still sees the check's failure through skipped jobs.
427 assert!(!starts(
428 &deploy,
429 "migrate",
430 &[("check", "failure"), ("plan", "success")],
431 &all,
432 push.clone(),
433 false
434 ));
435 assert!(!starts(
436 &deploy,
437 "core",
438 &[
439 ("check", "failure"),
440 ("plan", "success"),
441 ("migrate", "skipped")
442 ],
443 &all,
444 push.clone(),
445 false
446 ));
447 assert!(!starts(
448 &deploy,
449 "front",
450 &[
451 ("check", "failure"),
452 ("plan", "success"),
453 ("migrate", "skipped"),
454 ("core", "skipped"),
455 ("edge", "skipped")
456 ],
457 &all,
458 push.clone(),
459 false
460 ));
461 let skipped = [
462 ("migrate", "skipped"),
463 ("core", "skipped"),
464 ("edge", "skipped"),
465 ];
466 assert!(!starts_after(
467 &deploy,
468 "front",
469 &skipped,
470 &all,
471 push.clone(),
472 false,
473 true
474 ));
475 assert!(!starts(
476 &deploy,
477 "smoke",
478 &[
479 ("check", "failure"),
480 ("core", "skipped"),
481 ("edge", "skipped"),
482 ("front", "skipped")
483 ],
484 &all,
485 push.clone(),
486 false
487 ));
488 // A failed plan stops everything too.
489 assert!(!starts(
490 &deploy,
491 "migrate",
492 &[("plan", "failure")],
493 &all,
494 push.clone(),
495 false
496 ));
497 assert!(!starts(
498 &deploy,
499 "core",
500 &[("plan", "failure"), ("migrate", "skipped")],
501 &all,
502 push.clone(),
503 false
504 ));
505
506 // Smoke follows the last stage that ran, and not a failed one.
507 assert!(starts(
508 &deploy,
509 "smoke",
510 &[
511 ("core", "success"),
512 ("edge", "success"),
513 ("front", "success")
514 ],
515 &all,
516 push.clone(),
517 false
518 ));
519 assert!(starts(
520 &deploy,
521 "smoke",
522 &[
523 ("core", "success"),
524 ("edge", "skipped"),
525 ("front", "success")
526 ],
527 &none,
528 push.clone(),
529 false
530 ));
531 assert!(!starts(
532 &deploy,
533 "smoke",
534 &[
535 ("core", "success"),
536 ("edge", "failure"),
537 ("front", "skipped")
538 ],
539 &all,
540 push.clone(),
541 false
542 ));
543 // Nothing deployed: nothing to smoke-test.
544 let nothing = plan_outputs(false, &[], &[], &[]);
545 assert!(!starts(
546 &deploy,
547 "smoke",
548 &[
549 ("core", "skipped"),
550 ("edge", "skipped"),
551 ("front", "skipped")
552 ],
553 &nothing,
554 push.clone(),
555 false
556 ));
557
558 // A dry run plans and stops.
559 let dry = json!({ "dry_run": true, "units": "", "all": false });
560 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
561 assert!(!starts(
562 &deploy,
563 "core",
564 &[("migrate", "skipped")],
565 &all,
566 dry.clone(),
567 false
568 ));
569 assert!(!starts(
570 &deploy,
571 "smoke",
572 &[
573 ("core", "skipped"),
574 ("edge", "skipped"),
575 ("front", "skipped")
576 ],
577 &all,
578 dry,
579 false
580 ));
581}
582
583#[test]
584fn deploy_stage_matrices_come_from_the_plan() {
585 let deploy = read("deploy.yml");
586 let outputs = plan_outputs(
587 false,
588 &[
589 ("rust-1", "events,work", true),
590 ("rust-2", "repos", true),
591 ("ts", "projects,og", false),
592 ],
593 &[],
594 &[],
595 );
596 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
597 assert!(!core.fail_fast);
598 assert_eq!(core.max_parallel, Some(4));
599 let mut contexts = Map::new();
600 contexts.insert(
601 "needs".into(),
602 json!({ "plan": { "result": "success", "outputs": outputs } }),
603 );
604 let scope = Scope {
605 contexts: &contexts,
606 status: Status::Success,
607 hash_files: None,
608 };
609 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
610 let jobs = matrix::expand(&value).unwrap();
611 let groups: Vec<(&str, &str, bool)> = jobs
612 .iter()
613 .map(|c| {
614 (
615 c["group"].as_str().unwrap(),
616 c["units"].as_str().unwrap(),
617 c["rust"].as_bool().unwrap(),
618 )
619 })
620 .collect();
621 assert_eq!(
622 groups,
623 [
624 ("rust-1", "events,work", true),
625 ("rust-2", "repos", true),
626 ("ts", "projects,og", false)
627 ]
628 );
629}
630
631#[test]
632fn deploy_builds_rust_on_the_larger_machine_with_its_target_cached() {
633 let deploy = read("deploy.yml");
634 for stage in ["core", "edge", "front"] {
635 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
636 let on = |rust: bool, image: bool| {
637 let mut contexts = Map::new();
638 contexts.insert(
639 "matrix".into(),
640 json!({ "group": "g", "units": "u", "rust": rust, "image": image }),
641 );
642 let scope = Scope {
643 contexts: &contexts,
644 status: Status::Success,
645 hash_files: None,
646 };
647 expr::interpolate_value(&job.runs_on, &scope).unwrap()
648 };
649 assert_eq!(on(true, false), json!("g1t-4core"), "{stage}");
650 // The runner's image is built with the job's own Docker Engine.
651 assert_eq!(on(false, true), json!("g1t-4core"), "{stage}");
652 assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}");
653 }
654 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
655 assert!(source.contains("target/wasm32-unknown-unknown/release"));
656 assert!(source.contains("!target/**/incremental"));
657 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
658}
659
660/// Whether a step runs, for a job going `status`, with `matrix`.
661fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool {
662 let mut contexts = Map::new();
663 contexts.insert("matrix".into(), matrix);
664 let scope = Scope {
665 contexts: &contexts,
666 status,
667 hash_files: None,
668 };
669 expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
670}
671
672#[test]
673fn rust_builds_go_through_sccache_before_cargo_and_report_after() {
674 let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) {
675 let at = job
676 .steps
677 .iter()
678 .position(|s| s.run.as_deref() == Some(run))
679 .unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
680 (at, job.steps[at].clone())
681 };
682 let deploy = read("deploy.yml");
683 for stage in ["core", "edge", "front"] {
684 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
685 let (install_at, install) = step(job, "bash scripts/sccache.sh install");
686 let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats");
687 // Before anything runs Cargo (worker-build's install, the build),
688 // and the statistics last.
689 let install_step = job
690 .steps
691 .iter()
692 .position(|s| s.name.as_deref() == Some("Install"))
693 .unwrap();
694 assert!(install_at < install_step, "{stage}");
695 assert_eq!(stats_at, job.steps.len() - 1, "{stage}");
696 for (rust, image, uses) in [
697 (true, false, true),
698 (false, true, true),
699 (false, false, false),
700 ] {
701 let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image });
702 assert_eq!(
703 step_runs(&install, matrix.clone(), Status::Success),
704 uses,
705 "{stage} rust={rust} image={image}"
706 );
707 assert_eq!(
708 step_runs(&stats, matrix.clone(), Status::Success),
709 uses,
710 "{stage}"
711 );
712 // A failed build still says what was cached.
713 assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}");
714 }
715 }
716 let ci = read("ci.yml");
717 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
718 let (install_at, _) = step(rust, "bash scripts/sccache.sh install");
719 let (tests_at, _) = step(rust, "cargo test --workspace --locked --quiet");
720 let (stats_at, stats) = step(rust, "bash scripts/sccache.sh stats");
721 assert!(install_at < tests_at && tests_at < stats_at);
722 assert!(step_runs(&stats, json!({}), Status::Failure));
723 // main's runs keep the caches pull requests restore from: only Rust.
724 assert!(ci.trigger("push").unwrap().branches.allows("main"));
725 assert!(ci.trigger("pull_request").is_some());
726 for (id, on_push) in [("rust", true), ("typescript", false), ("build", false)] {
727 let job = ci.jobs.iter().find(|j| j.id == id).unwrap();
728 for (event, expected) in [("push", on_push), ("pull_request", true)] {
729 let mut contexts = Map::new();
730 contexts.insert(
731 "github".into(),
732 json!({ "event_name": event, "ref": "refs/heads/main" }),
733 );
734 let scope = Scope {
735 contexts: &contexts,
736 status: Status::Success,
737 hash_files: None,
738 };
739 assert_eq!(
740 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(),
741 expected,
742 "{id} on {event}"
743 );
744 }
745 }
746 // The download is pinned by version and checksum.
747 let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap();
748 assert!(script.contains("VERSION=0.18.0"));
749 assert!(script.lines().any(|line| {
750 line.strip_prefix("SHA256=")
751 .is_some_and(|sum| sum.len() == 64)
752 }));
753 assert!(script.contains("sha256sum -c"));
754 assert!(script.contains("RUSTC_WRAPPER="));
755 assert!(script.contains("GITHUB_STEP_SUMMARY"));
756}
757
758#[test]
759fn g1ts_own_actions_are_published_when_they_change() {
760 let release = read("actions-release.yml");
761 let push = release.trigger("push").unwrap();
762 assert!(push.branches.allows("main"));
763 assert!(!push.branches.allows("feature"));
764 assert!(release.trigger("workflow_dispatch").is_some());
765 let source = std::fs::read_to_string(workflows_dir().join("actions-release.yml")).unwrap();
766 assert!(source.contains("actions/**"));
767 assert!(source.contains("secrets.ACTIONS_PUBLISH_TOKEN"));
768 // Every action is a composite one with its README, and the copies of
769 // the script that finds g1t-runner are the same file.
770 let actions = workflows_dir().join("../../actions");
771 let mut finders = Vec::new();
772 for entry in std::fs::read_dir(&actions).unwrap() {
773 let dir = entry.unwrap().path();
774 let manifest = std::fs::read_to_string(dir.join("action.yml"))
775 .unwrap_or_else(|_| panic!("{} has no action.yml", dir.display()));
776 let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap();
777 assert_eq!(
778 manifest["runs"]["using"].as_str(),
779 Some("composite"),
780 "{}",
781 dir.display()
782 );
783 assert!(dir.join("README.md").exists(), "{}", dir.display());
784 finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap());
785 }
786 assert!(finders.len() >= 2);
787 assert!(
788 finders.windows(2).all(|pair| pair[0] == pair[1]),
789 "actions/*/runner.sh differ"
790 );
791 let deploy: serde_yaml::Value =
792 serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap())
793 .unwrap();
794 assert_eq!(
795 deploy["inputs"]["token"]["default"].as_str(),
796 Some("${{ github.token }}")
797 );
798 for output in ["url", "deployment-id"] {
799 assert!(deploy["outputs"][output]["value"].is_string(), "{output}");
800 }
801}
802
803#[test]
804fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
805 let base = read("runner-base.yml");
806 assert!(base.trigger("schedule").is_some());
807 assert!(base.trigger("workflow_dispatch").is_some());
808 assert!(base.trigger("push").unwrap().branches.allows("main"));
809 let job = base.jobs.iter().find(|j| j.id == "build").unwrap();
810 assert_eq!(job.runs_on, json!(["self-hosted", "docker"]));
811}
812
813#[test]
814fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() {
815 let root = workflows_dir()
816 .parent()
817 .unwrap()
818 .parent()
819 .unwrap()
820 .to_path_buf();
821 let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap();
822 let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap();
823 assert_eq!(
824 ours, theirs,
825 "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml"
826 );
827 // It runs only by hand, and reads nothing it could change.
828 let workflow = read("conformance.yml");
829 assert_eq!(
830 workflow
831 .triggers
832 .iter()
833 .map(|t| t.event.as_str())
834 .collect::<Vec<_>>(),
835 ["workflow_dispatch"]
836 );
837 assert!(!ours.contains("secrets."));
838}
839
840/// No workflow of g1t's asks for a secret no one can set. Secret names
841/// starting with G1T_ or GITHUB_ are g1t's own and refused when saved, so a
842/// workflow reading one gets nothing: the Actions release asked for
843/// G1T_ACTIONS_PUBLISH_TOKEN, which could never exist. G1T_TOKEN, the job's
844/// own token, is the one g1t gives every job.
845#[test]
846fn workflows_read_only_secrets_that_can_be_set() {
847 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
848 let path = entry.unwrap().path();
849 if path.extension().is_none_or(|ext| ext != "yml") {
850 continue;
851 }
852 let source = std::fs::read_to_string(&path).unwrap();
853 for (at, _) in source.match_indices("secrets.") {
854 let name: String = source[at + "secrets.".len()..]
855 .chars()
856 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
857 .collect();
858 let reserved = name.starts_with("G1T_") || name.starts_with("GITHUB_");
859 assert!(
860 !reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN",
861 "{} reads secrets.{name}, a name g1t keeps for its own",
862 path.display()
863 );
864 }
865 }
866}
867
868#[test]
869fn every_install_runs_again_when_a_connection_drops() {
870 // npm does not retry a body that breaks off midway; npm-ci.mjs does.
871 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
872 let path = entry.unwrap().path();
873 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
874 continue;
875 }
876 let name = path.file_name().unwrap().to_string_lossy().to_string();
877 for job in read(&name).jobs {
878 for step in &job.steps {
879 let Some(run) = step.run.as_deref() else {
880 continue;
881 };
882 for line in run.lines() {
883 assert!(
884 !line.trim().starts_with("npm ci"),
885 "{name} {}: `{}` should be `node scripts/deploy/npm-ci.mjs …`",
886 job.id,
887 line.trim()
888 );
889 }
890 }
891 }
892 }
893}