g1t/crates/actions/tests/repository_workflows.rs

158 lines8,152 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1//! g1t's own workflows (`.g1t/workflows/*.yml`), read by the same parser
2//! and expressions the actions service runs them with: each reads, nothing
3//! in it is unsupported, and the deploy workflow's jobs start, wait and
4//! stop as docs/DEPLOYING.md says.
5
6use std::path::PathBuf;
7
8use g1t_actions::expr::{self, Scope, Status};
9use g1t_actions::matrix;
10use g1t_actions::workflow::{self, Severity, Workflow};
11use serde_json::{Map, Value, json};
12
13fn workflows_dir() -> PathBuf {
14 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.g1t/workflows")
15}
16
17fn read(name: &str) -> Workflow {
18 let source = std::fs::read_to_string(workflows_dir().join(name)).unwrap();
19 workflow::parse(&source).unwrap_or_else(|problem| panic!("{name}: {problem}"))
20}
21
22#[test]
23fn every_workflow_reads_and_runs_on_g1t() {
24 let mut count = 0;
25 for entry in std::fs::read_dir(workflows_dir()).unwrap() {
26 let path = entry.unwrap().path();
27 if path.extension().and_then(|e| e.to_str()) != Some("yml") {
28 continue;
29 }
30 let name = path.file_name().unwrap().to_string_lossy().to_string();
31 let workflow = read(&name);
32 let unsupported: Vec<_> = workflow.notes.iter().filter(|n| n.severity != Severity::Info).collect();
33 assert!(unsupported.is_empty(), "{name}: {unsupported:?}");
34 count += 1;
35 }
36 assert!(count >= 1);
37}
38
39/// The plan job's outputs for a deploy of `stages` (each with its jobs).
40fn plan_outputs(migrate: bool, core: &[(&str, &str, bool)], edge: &[(&str, &str, bool)], front: &[(&str, &str, bool)]) -> Value {
41 let matrix = |jobs: &[(&str, &str, bool)]| {
42 let include: Vec<Value> = if jobs.is_empty() {
43 vec![json!({ "group": "none", "units": "" })]
44 } else {
45 jobs.iter().map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust })).collect()
46 };
47 json!({ "include": include }).to_string()
48 };
49 json!({
50 "migrate": migrate.to_string(),
51 "migrate_units": if migrate { "events" } else { "" },
52 "has_core": (!core.is_empty()).to_string(),
53 "core": matrix(core),
54 "has_edge": (!edge.is_empty()).to_string(),
55 "edge": matrix(edge),
56 "has_front": (!front.is_empty()).to_string(),
57 "front": matrix(front),
58 })
59}
60
61/// Whether `job` starts, given its needs' results, as the actions service
62/// decides it (services/actions/src/plan.rs `decide`): any need that did
63/// not succeed makes the status a failure.
64fn starts(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool) -> bool {
65 let job = workflow.jobs.iter().find(|j| j.id == job).unwrap();
66 let mut needs_ctx = Map::new();
67 let mut status = if cancelled { Status::Cancelled } else { Status::Success };
68 for need in &job.needs {
69 let result = needs.iter().find(|(name, _)| name == need).map(|(_, r)| *r).unwrap_or("success");
70 if result != "success" && matches!(status, Status::Success) {
71 status = Status::Failure;
72 }
73 let outputs = if need == "plan" { outputs.clone() } else { json!({}) };
74 needs_ctx.insert(need.clone(), json!({ "result": result, "outputs": outputs }));
75 }
76 let mut contexts = Map::new();
77 contexts.insert("needs".into(), Value::Object(needs_ctx));
78 contexts.insert("inputs".into(), inputs);
79 contexts.insert("github".into(), json!({ "event_name": "push", "ref": "refs/heads/main" }));
80 let scope = Scope { contexts: &contexts, status, hash_files: None };
81 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap()
82}
83
84#[test]
85fn deploy_runs_on_main_and_by_hand_one_at_a_time() {
86 let deploy = read("deploy.yml");
87 let push = deploy.trigger("push").unwrap();
88 assert!(push.branches.allows("main"));
89 assert!(!push.branches.allows("feature"));
90 let dispatch = deploy.trigger("workflow_dispatch").unwrap();
91 for input in ["units", "all", "dry_run"] {
92 assert!(dispatch.inputs.contains_key(input), "{input}");
93 }
94 let concurrency = deploy.concurrency.as_ref().unwrap();
95 assert_eq!(concurrency.group, "deploy-production");
96 assert_eq!(concurrency.cancel_in_progress, json!(false));
97 assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front"]);
98 // The stages share their steps (a YAML alias), and read the token only
99 // where they deploy.
100 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
101 assert_eq!(steps("core"), steps("edge"));
102 assert_eq!(steps("core"), steps("front"));
103 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
104 assert!(!source.contains("cancel-in-progress: true"));
105}
106
107#[test]
108fn deploy_stages_follow_one_another() {
109 let deploy = read("deploy.yml");
110 let all = plan_outputs(true, &[("rust", "events,repos", true), ("ts", "projects", false)], &[("rust", "api", true)], &[("web", "web", false)]);
111 let push = json!({});
112
113 // Everything succeeds: each stage runs after the last.
114 assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
115 assert!(starts(&deploy, "core", &[("migrate", "success")], &all, push.clone(), false));
116 assert!(starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), false));
117 assert!(starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "success")], &all, push.clone(), false));
118
119 // No migrations: the migrate job is skipped, and core still runs.
120 let none = plan_outputs(false, &[("ts", "projects", false)], &[], &[("web", "web", false)]);
121 assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
122 assert!(starts(&deploy, "core", &[("migrate", "skipped")], &none, push.clone(), false));
123 // An empty stage is skipped, and the next one still runs.
124 assert!(!starts(&deploy, "edge", &[("migrate", "skipped"), ("core", "success")], &none, push.clone(), false));
125 assert!(starts(&deploy, "front", &[("migrate", "skipped"), ("core", "success"), ("edge", "skipped")], &none, push.clone(), false));
126
127 // A failure stops every later stage.
128 assert!(!starts(&deploy, "core", &[("migrate", "failure")], &all, push.clone(), false));
129 assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "failure")], &all, push.clone(), false));
130 assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "failure")], &all, push.clone(), false));
131 assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "failure"), ("edge", "skipped")], &all, push.clone(), false));
132 // A cancelled run starts nothing more.
133 assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), true));
134
135 // A dry run plans and stops.
136 let dry = json!({ "dry_run": true, "units": "", "all": false });
137 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
138 assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry, false));
139}
140
141#[test]
142fn deploy_stage_matrices_come_from_the_plan() {
143 let deploy = read("deploy.yml");
144 let outputs = plan_outputs(false, &[("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)], &[], &[]);
145 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
146 assert!(!core.fail_fast);
147 assert_eq!(core.max_parallel, Some(4));
148 let mut contexts = Map::new();
149 contexts.insert("needs".into(), json!({ "plan": { "result": "success", "outputs": outputs } }));
150 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
151 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
152 let jobs = matrix::expand(&value).unwrap();
153 let groups: Vec<(&str, &str, bool)> = jobs
154 .iter()
155 .map(|c| (c["group"].as_str().unwrap(), c["units"].as_str().unwrap(), c["rust"].as_bool().unwrap()))
156 .collect();
157 assert_eq!(groups, [("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)]);
158}