github_actions_models/workflow/
job.rs

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