Skip to main content

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