Skip to main content

github_actions_models/workflow/
job.rs

1//! Workflow jobs.
2
3use 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/// A "normal" GitHub Actions workflow job, i.e. a job composed of one
13/// or more steps on a runner.
14#[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    /// An optional timeout for this job, in minutes.
33    /// GitHub takes the floor of any non-whole numeric value provided.
34    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        // NOTE(ww): serde struggles with the null/empty case for custom
51        // deserializers, so we help it out by telling it that it can default
52        // to Vec::default.
53        #[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        // serde lacks the ability to do inter-field invariants at the derive
66        // layer, so we enforce the invariant that a `RunsOn::Group`
67        // has either a `group` or at least one label here.
68        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        /// An optional name for this step.
95        name: Option<String>,
96
97        /// An optional ID for this step.
98        id: Option<String>,
99
100        /// One or more steps, by ID, that this step is blocked by (i.e. waits for).
101        #[serde(deserialize_with = "crate::common::scalar_or_vector")]
102        wait: Vec<String>,
103    },
104    WaitAll {
105        /// An optional name for this step.
106        name: Option<String>,
107
108        /// An optional ID for this step.
109        id: Option<String>,
110
111        /// A marker indicating that this step waits for all active background steps.
112        #[serde(deserialize_with = "crate::common::bool_or_unit")]
113        wait_all: bool,
114    },
115    Cancel {
116        /// An optional name for this step.
117        name: Option<String>,
118
119        /// An optional ID for this step.
120        id: Option<String>,
121
122        /// A background step, by ID, that this step terminates.
123        cancel: String,
124    },
125    Parallel {
126        /// One or more steps to run in parallel.
127        parallel: Vec<ParallelStep>,
128    },
129}
130
131/// The subset of steps that are valid within a `parallel:` block.
132#[derive(Deserialize, Debug)]
133#[serde(untagged)]
134pub enum ParallelStep {
135    Uses(UsesStep),
136    Run(RunStep),
137}
138
139/// Fields that are shared across both `uses:` and `run:` steps.
140#[derive(Deserialize, Debug)]
141#[serde(rename_all = "kebab-case")]
142pub struct SharedStepFields {
143    /// An optional ID for this step.
144    pub id: Option<String>,
145
146    /// An optional expression that prevents this step from running unless it evaluates to `true`.
147    pub r#if: Option<If>,
148
149    /// An optional name for this step.
150    pub name: Option<String>,
151
152    /// An optional timeout for this step, in minutes.
153    /// GitHub takes the floor of any non-whole numeric value provided.
154    pub timeout_minutes: Option<LoE<f64>>,
155
156    /// An optional boolean or expression that, if `true`, prevents the job from failing when
157    /// this step fails.
158    #[serde(default)]
159    pub continue_on_error: BoE,
160
161    /// Whether the step runs asynchronously, i.e. does not block its successor from running.
162    // TODO: Is this allowed to be an expression?
163    #[serde(default)]
164    pub background: bool,
165
166    /// An optional environment mapping for this step.
167    #[serde(default)]
168    pub env: LoE<Env>,
169}
170
171#[derive(Deserialize, Debug)]
172#[serde(rename_all = "kebab-case")]
173pub struct UsesStep {
174    /// The GitHub Action being used.
175    #[serde(deserialize_with = "crate::common::step_uses")]
176    pub uses: Uses,
177
178    /// Any inputs to the action being used.
179    #[serde(default)]
180    pub with: LoE<Env>,
181
182    /// Shared fields for this step.
183    #[serde(flatten)]
184    pub shared: SharedStepFields,
185}
186
187#[derive(Deserialize, Debug)]
188#[serde(rename_all = "kebab-case")]
189pub struct RunStep {
190    /// The command to run.
191    #[serde(deserialize_with = "crate::common::bool_is_string")]
192    pub run: String,
193
194    /// An optional working directory to run [`RunStep::run`] from.
195    pub working_directory: Option<String>,
196
197    /// An optional shell to run in. Defaults to the job or workflow's
198    /// default shell.
199    pub shell: Option<LoE<String>>,
200
201    /// Shared fields for this step.
202    #[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        // TODO: model `ports`?
235        #[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}