github_actions_models/workflow/
job.rs1use indexmap::IndexMap;
4use serde::Deserialize;
5use yaml_serde::Value;
6
7use crate::common::expr::{BoE, LoE};
8use crate::common::{DockerUses, Env, If, Permissions, Uses, custom_error};
9
10use super::{Concurrency, Defaults};
11
12#[derive(Deserialize, Debug)]
15#[serde(rename_all = "kebab-case")]
16pub struct NormalJob {
17 pub name: Option<String>,
18 #[serde(default)]
19 pub permissions: Permissions,
20 #[serde(default, deserialize_with = "crate::common::scalar_or_vector")]
21 pub needs: Vec<String>,
22 pub r#if: Option<If>,
23 pub runs_on: LoE<RunsOn>,
24 pub environment: Option<DeploymentEnvironment>,
25 pub concurrency: Option<Concurrency>,
26 #[serde(default)]
27 pub outputs: IndexMap<String, String>,
28 #[serde(default)]
29 pub env: LoE<Env>,
30 pub defaults: Option<Defaults>,
31 pub steps: Vec<Step>,
32 pub timeout_minutes: Option<LoE<f64>>,
35 pub strategy: Option<Strategy>,
36 #[serde(default)]
37 pub continue_on_error: BoE,
38 pub container: Option<Container>,
39 #[serde(default)]
40 pub services: IndexMap<String, Container>,
41}
42
43#[derive(Deserialize, Debug, PartialEq)]
44#[serde(rename_all = "kebab-case", untagged, remote = "Self")]
45pub enum RunsOn {
46 #[serde(deserialize_with = "crate::common::scalar_or_vector")]
47 Target(Vec<String>),
48 Group {
49 group: Option<String>,
50 #[serde(deserialize_with = "crate::common::scalar_or_vector", default)]
54 labels: Vec<String>,
55 },
56}
57
58impl<'de> Deserialize<'de> for RunsOn {
59 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
60 where
61 D: serde::Deserializer<'de>,
62 {
63 let runs_on = Self::deserialize(deserializer)?;
64
65 if let RunsOn::Group { group, labels } = &runs_on
69 && group.is_none()
70 && labels.is_empty()
71 {
72 return Err(custom_error::<D>(
73 "runs-on must provide either `group` or one or more `labels`",
74 ));
75 }
76
77 Ok(runs_on)
78 }
79}
80
81#[derive(Deserialize, Debug)]
82#[serde(rename_all = "kebab-case", untagged)]
83pub enum DeploymentEnvironment {
84 Name(String),
85 NameURL { name: String, url: Option<String> },
86}
87
88#[derive(Deserialize, Debug)]
89#[serde(rename_all_fields = "kebab-case", untagged)]
90pub enum Step {
91 Uses(UsesStep),
92 Run(RunStep),
93 Wait {
94 name: Option<String>,
96
97 id: Option<String>,
99
100 #[serde(deserialize_with = "crate::common::scalar_or_vector")]
102 wait: Vec<String>,
103 },
104 WaitAll {
105 name: Option<String>,
107
108 id: Option<String>,
110
111 #[serde(deserialize_with = "crate::common::bool_or_unit")]
113 wait_all: bool,
114 },
115 Cancel {
116 name: Option<String>,
118
119 id: Option<String>,
121
122 cancel: String,
124 },
125 Parallel {
126 parallel: Vec<ParallelStep>,
128 },
129}
130
131#[derive(Deserialize, Debug)]
133#[serde(untagged)]
134pub enum ParallelStep {
135 Uses(UsesStep),
136 Run(RunStep),
137}
138
139#[derive(Deserialize, Debug)]
141#[serde(rename_all = "kebab-case")]
142pub struct SharedStepFields {
143 pub id: Option<String>,
145
146 pub r#if: Option<If>,
148
149 pub name: Option<String>,
151
152 pub timeout_minutes: Option<LoE<f64>>,
155
156 #[serde(default)]
159 pub continue_on_error: BoE,
160
161 #[serde(default)]
164 pub background: bool,
165
166 #[serde(default)]
168 pub env: LoE<Env>,
169}
170
171#[derive(Deserialize, Debug)]
172#[serde(rename_all = "kebab-case")]
173pub struct UsesStep {
174 #[serde(deserialize_with = "crate::common::step_uses")]
176 pub uses: Uses,
177
178 #[serde(default)]
180 pub with: LoE<Env>,
181
182 #[serde(flatten)]
184 pub shared: SharedStepFields,
185}
186
187#[derive(Deserialize, Debug)]
188#[serde(rename_all = "kebab-case")]
189pub struct RunStep {
190 #[serde(deserialize_with = "crate::common::bool_is_string")]
192 pub run: String,
193
194 pub working_directory: Option<String>,
196
197 pub shell: Option<LoE<String>>,
200
201 #[serde(flatten)]
203 pub shared: SharedStepFields,
204}
205
206#[derive(Deserialize, Debug)]
207#[serde(rename_all = "kebab-case")]
208pub struct Strategy {
209 pub matrix: Option<LoE<Matrix>>,
210 pub fail_fast: Option<BoE>,
211 pub max_parallel: Option<LoE<u64>>,
212}
213
214#[derive(Deserialize, Debug)]
215#[serde(rename_all = "kebab-case")]
216pub struct Matrix {
217 #[serde(default)]
218 pub include: LoE<Vec<IndexMap<String, Value>>>,
219 #[serde(default)]
220 pub exclude: LoE<Vec<IndexMap<String, Value>>>,
221 #[serde(flatten)]
222 pub dimensions: LoE<IndexMap<String, LoE<Vec<Value>>>>,
223}
224
225#[derive(Deserialize, Debug)]
226#[serde(rename_all = "kebab-case", untagged)]
227pub enum Container {
228 Name(LoE<DockerUses>),
229 Container {
230 image: LoE<DockerUses>,
231 credentials: Option<DockerCredentials>,
232 #[serde(default)]
233 env: LoE<Env>,
234 #[serde(default)]
236 volumes: Vec<String>,
237 options: Option<String>,
238 },
239}
240
241#[derive(Deserialize, Debug)]
242pub struct DockerCredentials {
243 pub username: Option<String>,
244 pub password: Option<String>,
245}
246
247#[derive(Deserialize, Debug)]
248#[serde(rename_all = "kebab-case")]
249pub struct ReusableWorkflowCallJob {
250 pub name: Option<String>,
251 #[serde(default)]
252 pub permissions: Permissions,
253 #[serde(default, deserialize_with = "crate::common::scalar_or_vector")]
254 pub needs: Vec<String>,
255 pub r#if: Option<If>,
256 #[serde(deserialize_with = "crate::common::reusable_step_uses")]
257 pub uses: Uses,
258 #[serde(default)]
259 pub with: LoE<Env>,
260 pub secrets: Option<Secrets>,
261}
262
263#[derive(Deserialize, Debug, PartialEq)]
264#[serde(rename_all = "kebab-case")]
265pub enum Secrets {
266 Inherit,
267 #[serde(untagged)]
268 Env(#[serde(default)] Env),
269}
270
271#[cfg(test)]
272mod tests {
273 use crate::{
274 common::{EnvValue, expr::LoE},
275 workflow::job::{Matrix, Secrets, Step},
276 };
277
278 use super::{RunsOn, Strategy};
279
280 #[test]
281 fn test_secrets() {
282 assert_eq!(
283 yaml_serde::from_str::<Secrets>("inherit").unwrap(),
284 Secrets::Inherit
285 );
286
287 let secrets = "foo-secret: bar";
288 let Secrets::Env(secrets) = yaml_serde::from_str::<Secrets>(secrets).unwrap() else {
289 panic!("unexpected secrets variant");
290 };
291 assert_eq!(secrets["foo-secret"], EnvValue::String("bar".into()));
292 }
293
294 #[test]
295 fn test_strategy_matrix_expressions() {
296 let strategy = "matrix: ${{ 'foo' }}";
297 let Strategy {
298 matrix: Some(LoE::Expr(expr)),
299 ..
300 } = yaml_serde::from_str::<Strategy>(strategy).unwrap()
301 else {
302 panic!("unexpected matrix variant");
303 };
304
305 assert_eq!(expr.as_curly(), "${{ 'foo' }}");
306
307 let strategy = r#"
308matrix:
309 foo: ${{ 'foo' }}
310"#;
311
312 let Strategy {
313 matrix:
314 Some(LoE::Literal(Matrix {
315 include: _,
316 exclude: _,
317 dimensions: LoE::Literal(dims),
318 })),
319 ..
320 } = yaml_serde::from_str::<Strategy>(strategy).unwrap()
321 else {
322 panic!("unexpected matrix variant");
323 };
324
325 assert!(matches!(dims.get("foo"), Some(LoE::Expr(_))));
326 }
327
328 #[test]
329 fn test_runson_invalid_state() {
330 let runson = "group: \nlabels: []";
331
332 assert_eq!(
333 yaml_serde::from_str::<RunsOn>(runson)
334 .unwrap_err()
335 .to_string(),
336 "runs-on must provide either `group` or one or more `labels`"
337 );
338 }
339
340 #[test]
341 fn test_step_working_directory() {
342 let step = r#"
343name: test
344id: test-id
345run: foo
346working-directory: /tmp
347background: true
348"#;
349
350 insta::assert_debug_snapshot!(&yaml_serde::from_str::<Step>(step).unwrap(), @r#"
351 Run(
352 RunStep {
353 run: "foo",
354 working_directory: Some(
355 "/tmp",
356 ),
357 shell: None,
358 shared: SharedStepFields {
359 id: Some(
360 "test-id",
361 ),
362 if: None,
363 name: Some(
364 "test",
365 ),
366 timeout_minutes: None,
367 continue_on_error: Literal(
368 false,
369 ),
370 background: true,
371 env: Literal(
372 {},
373 ),
374 },
375 },
376 )
377 "#);
378 }
379}