github_actions_models/workflow/
job.rs

1//! Workflow jobs.
2
3use indexmap::IndexMap;
4use serde::Deserialize;
5use serde_yaml::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    pub timeout_minutes: Option<LoE<u64>>,
33    pub strategy: Option<Strategy>,
34    #[serde(default)]
35    pub continue_on_error: BoE,
36    pub container: Option<Container>,
37    #[serde(default)]
38    pub services: IndexMap<String, Container>,
39}
40
41#[derive(Deserialize, Debug, PartialEq)]
42#[serde(rename_all = "kebab-case", untagged, remote = "Self")]
43pub enum RunsOn {
44    #[serde(deserialize_with = "crate::common::scalar_or_vector")]
45    Target(Vec<String>),
46    Group {
47        group: Option<String>,
48        // NOTE(ww): serde struggles with the null/empty case for custom
49        // deserializers, so we help it out by telling it that it can default
50        // to Vec::default.
51        #[serde(deserialize_with = "crate::common::scalar_or_vector", default)]
52        labels: Vec<String>,
53    },
54}
55
56impl<'de> Deserialize<'de> for RunsOn {
57    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
58    where
59        D: serde::Deserializer<'de>,
60    {
61        let runs_on = Self::deserialize(deserializer)?;
62
63        // serde lacks the ability to do inter-field invariants at the derive
64        // layer, so we enforce the invariant that a `RunsOn::Group`
65        // has either a `group` or at least one label here.
66        if let RunsOn::Group { group, labels } = &runs_on
67            && group.is_none()
68            && labels.is_empty()
69        {
70            return Err(custom_error::<D>(
71                "runs-on must provide either `group` or one or more `labels`",
72            ));
73        }
74
75        Ok(runs_on)
76    }
77}
78
79#[derive(Deserialize, Debug)]
80#[serde(rename_all = "kebab-case", untagged)]
81pub enum DeploymentEnvironment {
82    Name(String),
83    NameURL { name: String, url: Option<String> },
84}
85
86#[derive(Deserialize, Debug)]
87#[serde(rename_all = "kebab-case")]
88pub struct Step {
89    /// An optional ID for this step.
90    pub id: Option<String>,
91
92    /// An optional expression that prevents this step from running unless it evaluates to `true`.
93    pub r#if: Option<If>,
94
95    /// An optional name for this step.
96    pub name: Option<String>,
97
98    /// An optional timeout for this step, in minutes.
99    pub timeout_minutes: Option<LoE<u64>>,
100
101    /// An optional boolean or expression that, if `true`, prevents the job from failing when
102    /// this step fails.
103    #[serde(default)]
104    pub continue_on_error: BoE,
105
106    /// An optional environment mapping for this step.
107    #[serde(default)]
108    pub env: LoE<Env>,
109
110    /// The `run:` or `uses:` body for this step.
111    #[serde(flatten)]
112    pub body: StepBody,
113}
114
115#[derive(Deserialize, Debug)]
116#[serde(rename_all = "kebab-case", untagged)]
117pub enum StepBody {
118    Uses {
119        /// The GitHub Action being used.
120        #[serde(deserialize_with = "crate::common::step_uses")]
121        uses: Uses,
122
123        /// Any inputs to the action being used.
124        #[serde(default)]
125        with: Env,
126    },
127    Run {
128        /// The command to run.
129        #[serde(deserialize_with = "crate::common::bool_is_string")]
130        run: String,
131
132        /// An optional working directory to run [`StepBody::Run::run`] from.
133        working_directory: Option<String>,
134
135        /// An optional shell to run in. Defaults to the job or workflow's
136        /// default shell.
137        shell: Option<LoE<String>>,
138    },
139}
140
141#[derive(Deserialize, Debug)]
142#[serde(rename_all = "kebab-case")]
143pub struct Strategy {
144    pub matrix: Option<LoE<Matrix>>,
145    pub fail_fast: Option<BoE>,
146    pub max_parallel: Option<LoE<u64>>,
147}
148
149#[derive(Deserialize, Debug)]
150#[serde(rename_all = "kebab-case")]
151pub struct Matrix {
152    #[serde(default)]
153    pub include: LoE<Vec<IndexMap<String, Value>>>,
154    #[serde(default)]
155    pub exclude: LoE<Vec<IndexMap<String, Value>>>,
156    #[serde(flatten)]
157    pub dimensions: LoE<IndexMap<String, LoE<Vec<Value>>>>,
158}
159
160#[derive(Deserialize, Debug)]
161#[serde(rename_all = "kebab-case", untagged)]
162pub enum Container {
163    Name(String),
164    Container {
165        #[serde(deserialize_with = "crate::common::docker_uses")]
166        image: DockerUses,
167        credentials: Option<DockerCredentials>,
168        #[serde(default)]
169        env: LoE<Env>,
170        // TODO: model `ports`?
171        #[serde(default)]
172        volumes: Vec<String>,
173        options: Option<String>,
174    },
175}
176
177#[derive(Deserialize, Debug)]
178pub struct DockerCredentials {
179    pub username: Option<String>,
180    pub password: Option<String>,
181}
182
183#[derive(Deserialize, Debug)]
184#[serde(rename_all = "kebab-case")]
185pub struct ReusableWorkflowCallJob {
186    pub name: Option<String>,
187    #[serde(default)]
188    pub permissions: Permissions,
189    #[serde(default, deserialize_with = "crate::common::scalar_or_vector")]
190    pub needs: Vec<String>,
191    pub r#if: Option<If>,
192    #[serde(deserialize_with = "crate::common::reusable_step_uses")]
193    pub uses: Uses,
194    #[serde(default)]
195    pub with: Env,
196    pub secrets: Option<Secrets>,
197}
198
199#[derive(Deserialize, Debug, PartialEq)]
200#[serde(rename_all = "kebab-case")]
201pub enum Secrets {
202    Inherit,
203    #[serde(untagged)]
204    Env(#[serde(default)] Env),
205}
206
207#[cfg(test)]
208mod tests {
209    use crate::{
210        common::{EnvValue, expr::LoE},
211        workflow::job::{Matrix, Secrets},
212    };
213
214    use super::{RunsOn, Strategy};
215
216    #[test]
217    fn test_secrets() {
218        assert_eq!(
219            serde_yaml::from_str::<Secrets>("inherit").unwrap(),
220            Secrets::Inherit
221        );
222
223        let secrets = "foo-secret: bar";
224        let Secrets::Env(secrets) = serde_yaml::from_str::<Secrets>(secrets).unwrap() else {
225            panic!("unexpected secrets variant");
226        };
227        assert_eq!(secrets["foo-secret"], EnvValue::String("bar".into()));
228    }
229
230    #[test]
231    fn test_strategy_matrix_expressions() {
232        let strategy = "matrix: ${{ 'foo' }}";
233        let Strategy {
234            matrix: Some(LoE::Expr(expr)),
235            ..
236        } = serde_yaml::from_str::<Strategy>(strategy).unwrap()
237        else {
238            panic!("unexpected matrix variant");
239        };
240
241        assert_eq!(expr.as_curly(), "${{ 'foo' }}");
242
243        let strategy = r#"
244matrix:
245  foo: ${{ 'foo' }}
246"#;
247
248        let Strategy {
249            matrix:
250                Some(LoE::Literal(Matrix {
251                    include: _,
252                    exclude: _,
253                    dimensions: LoE::Literal(dims),
254                })),
255            ..
256        } = serde_yaml::from_str::<Strategy>(strategy).unwrap()
257        else {
258            panic!("unexpected matrix variant");
259        };
260
261        assert!(matches!(dims.get("foo"), Some(LoE::Expr(_))));
262    }
263
264    #[test]
265    fn test_runson_invalid_state() {
266        let runson = "group: \nlabels: []";
267
268        assert_eq!(
269            serde_yaml::from_str::<RunsOn>(runson)
270                .unwrap_err()
271                .to_string(),
272            "runs-on must provide either `group` or one or more `labels`"
273        );
274    }
275}