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::{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<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        image: String,
166        credentials: Option<DockerCredentials>,
167        #[serde(default)]
168        env: LoE<Env>,
169        // TODO: model `ports`?
170        #[serde(default)]
171        volumes: Vec<String>,
172        options: Option<String>,
173    },
174}
175
176#[derive(Deserialize, Debug)]
177pub struct DockerCredentials {
178    pub username: Option<String>,
179    pub password: Option<String>,
180}
181
182#[derive(Deserialize, Debug)]
183#[serde(rename_all = "kebab-case")]
184pub struct ReusableWorkflowCallJob {
185    pub name: Option<String>,
186    #[serde(default)]
187    pub permissions: Permissions,
188    #[serde(default, deserialize_with = "crate::common::scalar_or_vector")]
189    pub needs: Vec<String>,
190    pub r#if: Option<If>,
191    #[serde(deserialize_with = "crate::common::reusable_step_uses")]
192    pub uses: Uses,
193    #[serde(default)]
194    pub with: Env,
195    pub secrets: Option<Secrets>,
196}
197
198#[derive(Deserialize, Debug, PartialEq)]
199#[serde(rename_all = "kebab-case")]
200pub enum Secrets {
201    Inherit,
202    #[serde(untagged)]
203    Env(#[serde(default)] Env),
204}
205
206#[cfg(test)]
207mod tests {
208    use crate::{
209        common::{EnvValue, expr::LoE},
210        workflow::job::{Matrix, Secrets},
211    };
212
213    use super::{RunsOn, Strategy};
214
215    #[test]
216    fn test_secrets() {
217        assert_eq!(
218            serde_yaml::from_str::<Secrets>("inherit").unwrap(),
219            Secrets::Inherit
220        );
221
222        let secrets = "foo-secret: bar";
223        let Secrets::Env(secrets) = serde_yaml::from_str::<Secrets>(secrets).unwrap() else {
224            panic!("unexpected secrets variant");
225        };
226        assert_eq!(secrets["foo-secret"], EnvValue::String("bar".into()));
227    }
228
229    #[test]
230    fn test_strategy_matrix_expressions() {
231        let strategy = "matrix: ${{ 'foo' }}";
232        let Strategy {
233            matrix: Some(LoE::Expr(expr)),
234            ..
235        } = serde_yaml::from_str::<Strategy>(strategy).unwrap()
236        else {
237            panic!("unexpected matrix variant");
238        };
239
240        assert_eq!(expr.as_curly(), "${{ 'foo' }}");
241
242        let strategy = r#"
243matrix:
244  foo: ${{ 'foo' }}
245"#;
246
247        let Strategy {
248            matrix:
249                Some(LoE::Literal(Matrix {
250                    include: _,
251                    exclude: _,
252                    dimensions: LoE::Literal(dims),
253                })),
254            ..
255        } = serde_yaml::from_str::<Strategy>(strategy).unwrap()
256        else {
257            panic!("unexpected matrix variant");
258        };
259
260        assert!(matches!(dims.get("foo"), Some(LoE::Expr(_))));
261    }
262
263    #[test]
264    fn test_runson_invalid_state() {
265        let runson = "group: \nlabels: []";
266
267        assert_eq!(
268            serde_yaml::from_str::<RunsOn>(runson)
269                .unwrap_err()
270                .to_string(),
271            "runs-on must provide either `group` or one or more `labels`"
272        );
273    }
274}