flagon-io/g1t

public

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/crates/actions/src/matrix.rs

177 lines7,046 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.

GitHub Actions on g1t, part one: reading workflows1//! `strategy.matrix`: every combination of its values, less `exclude`,
2//! plus `include`, the way GitHub expands it.
3
4use serde_json::{Map, Value};
5
6/// The most jobs one matrix may make, as on GitHub.
7pub const MAX_JOBS: usize = 256;
8
9/// One combination: the matrix's keys, in the file's order, and values.
10pub type Combination = Map<String, Value>;
11
12/// Expands a matrix. `Ok(vec![])` means it made no jobs, which GitHub
13/// treats as an error the caller reports.
14pub fn expand(matrix: &Value) -> Result<Vec<Combination>, String> {
15 let Value::Object(matrix) = matrix else {
16 return Err("`strategy.matrix` is a mapping of names to lists of values.".to_owned());
17 };
18 let mut combinations: Vec<Combination> = vec![Map::new()];
19 let mut dimensions = 0;
20 for (key, values) in matrix {
21 if key == "include" || key == "exclude" {
22 continue;
23 }
24 let Value::Array(values) = values else {
25 return Err(format!("`matrix.{key}` is a list of values."));
26 };
27 dimensions += 1;
28 let mut next = Vec::with_capacity(combinations.len() * values.len());
29 for combination in &combinations {
30 for value in values {
31 let mut extended = combination.clone();
32 extended.insert(key.clone(), value.clone());
33 next.push(extended);
34 }
35 }
36 combinations = next;
37 if combinations.len() > MAX_JOBS {
38 return Err(format!("The matrix makes more than {MAX_JOBS} jobs."));
39 }
40 }
41 if dimensions == 0 {
42 combinations.clear();
43 }
44
45 if let Some(exclude) = matrix.get("exclude") {
46 let Value::Array(excludes) = exclude else {
47 return Err("`matrix.exclude` is a list of combinations.".to_owned());
48 };
49 for exclude in excludes {
50 let Value::Object(exclude) = exclude else {
51 return Err("Each `matrix.exclude` entry is a mapping.".to_owned());
52 };
53 combinations.retain(|combination| !partial_match(combination, exclude));
54 }
55 }
56
57 if let Some(include) = matrix.get("include") {
58 let Value::Array(includes) = include else {
59 return Err("`matrix.include` is a list of combinations.".to_owned());
60 };
61 // The keys of the original matrix, whose values an include may not change.
62 let originals: Vec<&String> = matrix.keys().filter(|key| *key != "include" && *key != "exclude").collect();
63 let base_count = combinations.len();
64 for include in includes {
65 let Value::Object(include) = include else {
66 return Err("Each `matrix.include` entry is a mapping.".to_owned());
67 };
68 let mut added = false;
69 for combination in combinations.iter_mut().take(base_count) {
70 let overwrites_original = include
71 .iter()
72 .any(|(key, value)| originals.contains(&key) && combination.get(key).is_some_and(|existing| existing != value));
73 if !overwrites_original {
74 for (key, value) in include {
75 combination.insert(key.clone(), value.clone());
76 }
77 added = true;
78 }
79 }
80 if !added {
81 combinations.push(include.clone());
82 }
83 }
84 }
85 if combinations.len() > MAX_JOBS {
86 return Err(format!("The matrix makes more than {MAX_JOBS} jobs."));
87 }
88 Ok(combinations)
89}
90
91fn partial_match(combination: &Combination, pattern: &Map<String, Value>) -> bool {
92 pattern.iter().all(|(key, value)| match (combination.get(key), value) {
93 (Some(Value::Object(inner)), Value::Object(pattern)) => partial_match(inner, pattern),
94 (Some(actual), expected) => actual == expected,
95 (None, _) => false,
96 })
97}
98
99/// A job's name with its combination, as GitHub shows it:
100/// `test (ubuntu-latest, 18)`. Objects in the combination are left out.
101pub fn job_name(name: &str, combination: &Combination) -> String {
102 let values: Vec<String> = combination
103 .values()
104 .filter_map(|value| match value {
105 Value::String(text) => Some(text.clone()),
106 Value::Number(number) => Some(number.to_string()),
107 Value::Bool(flag) => Some(flag.to_string()),
108 _ => None,
109 })
110 .collect();
111 if values.is_empty() { name.to_owned() } else { format!("{name} ({})", values.join(", ")) }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use serde_json::json;
118
119 fn names(combinations: &[Combination]) -> Vec<String> {
120 combinations.iter().map(|c| job_name("test", c)).collect()
121 }
122
123 #[test]
124 fn products_in_file_order() {
125 let jobs = expand(&json!({ "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] })).unwrap();
126 assert_eq!(
127 names(&jobs),
128 ["test (ubuntu-latest, 18)", "test (ubuntu-latest, 20)", "test (windows-latest, 18)", "test (windows-latest, 20)"]
129 );
130 }
131
132 #[test]
133 fn excludes_partial_matches() {
134 let jobs = expand(&json!({
135 "os": ["macos", "windows"], "version": [12, 14, 16], "environment": ["staging", "production"],
136 "exclude": [{ "os": "macos", "version": 12, "environment": "production" }, { "os": "windows", "version": 16 }]
137 }))
138 .unwrap();
139 assert_eq!(jobs.len(), 12 - 1 - 2);
140 }
141
142 #[test]
143 fn includes_extend_or_add_as_github_documents() {
144 // GitHub's own example.
145 let jobs = expand(&json!({
146 "fruit": ["apple", "pear"], "animal": ["cat", "dog"],
147 "include": [
148 { "color": "green" },
149 { "color": "pink", "animal": "cat" },
150 { "fruit": "apple", "shape": "circle" },
151 { "fruit": "banana" },
152 { "fruit": "banana", "animal": "cat" }
153 ]
154 }))
155 .unwrap();
156 let expected = vec![
157 json!({ "fruit": "apple", "animal": "cat", "color": "pink", "shape": "circle" }),
158 json!({ "fruit": "apple", "animal": "dog", "color": "green", "shape": "circle" }),
159 json!({ "fruit": "pear", "animal": "cat", "color": "pink" }),
160 json!({ "fruit": "pear", "animal": "dog", "color": "green" }),
161 json!({ "fruit": "banana" }),
162 json!({ "fruit": "banana", "animal": "cat" }),
163 ];
164 let got: Vec<Value> = jobs.into_iter().map(Value::Object).collect();
165 assert_eq!(got, expected);
166 }
167
168 #[test]
169 fn include_only_and_limits() {
170 let jobs = expand(&json!({ "include": [{ "site": "a" }, { "site": "b" }] })).unwrap();
171 assert_eq!(names(&jobs), ["test (a)", "test (b)"]);
172 let big: Vec<u32> = (0..20).collect();
173 assert!(expand(&json!({ "a": big, "b": big })).unwrap_err().contains("256"));
174 assert!(expand(&json!({ "os": "linux" })).is_err());
175 assert!(expand(&json!({})).unwrap().is_empty());
176 }
177}