github_actions_models/workflow/
mod.rs

1//! Data models for GitHub Actions workflow definitions.
2//!
3//! Resources:
4//! * [Workflow syntax for GitHub Actions]
5//! * [JSON Schema definition for workflows]
6//!
7//! [Workflow Syntax for GitHub Actions]: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions>
8//! [JSON Schema definition for workflows]: https://json.schemastore.org/github-workflow.json
9
10use indexmap::IndexMap;
11use serde::Deserialize;
12
13use crate::common::{
14    Env, Permissions,
15    expr::{BoE, LoE},
16};
17
18pub mod event;
19pub mod job;
20
21/// A single GitHub Actions workflow.
22#[derive(Deserialize, Debug)]
23#[serde(rename_all = "kebab-case")]
24pub struct Workflow {
25    pub name: Option<String>,
26    pub run_name: Option<String>,
27    pub on: Trigger,
28    #[serde(default)]
29    pub permissions: Permissions,
30    #[serde(default)]
31    pub env: LoE<Env>,
32    pub defaults: Option<Defaults>,
33    pub concurrency: Option<Concurrency>,
34    pub jobs: IndexMap<String, Job>,
35}
36
37/// The triggering condition or conditions for a workflow.
38///
39/// Workflow triggers take three forms:
40///
41/// 1. A single webhook event name:
42///
43///     ```yaml
44///     on: push
45///     ```
46/// 2. A list of webhook event names:
47///
48///     ```yaml
49///     on: [push, fork]
50///     ```
51///
52/// 3. A mapping of event names with (optional) configurations:
53///
54///     ```yaml
55///     on:
56///       push:
57///         branches: [main]
58///       pull_request:
59///     ```
60#[derive(Deserialize, Debug)]
61#[serde(rename_all = "snake_case", untagged)]
62pub enum Trigger {
63    BareEvent(event::BareEvent),
64    BareEvents(Vec<event::BareEvent>),
65    Events(Box<event::Events>),
66}
67
68#[derive(Deserialize, Debug)]
69#[serde(rename_all = "kebab-case")]
70pub struct Defaults {
71    pub run: Option<RunDefaults>,
72}
73
74#[derive(Deserialize, Debug)]
75#[serde(rename_all = "kebab-case")]
76pub struct RunDefaults {
77    pub shell: Option<String>,
78    pub working_directory: Option<String>,
79}
80
81#[derive(Deserialize, Debug)]
82#[serde(rename_all_fields = "kebab-case", untagged)]
83pub enum Concurrency {
84    Bare(String),
85    Rich {
86        group: String,
87        #[serde(default)]
88        cancel_in_progress: BoE,
89    },
90}
91
92#[derive(Deserialize, Debug)]
93#[serde(rename_all = "kebab-case", untagged)]
94pub enum Job {
95    NormalJob(Box<job::NormalJob>),
96    ReusableWorkflowCallJob(Box<job::ReusableWorkflowCallJob>),
97}
98
99impl Job {
100    /// Returns the optional `name` field common to both reusable and normal
101    /// job definitions.
102    pub fn name(&self) -> Option<&str> {
103        match self {
104            Self::NormalJob(job) => job.name.as_deref(),
105            Self::ReusableWorkflowCallJob(job) => job.name.as_deref(),
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use crate::{
113        common::expr::BoE,
114        workflow::event::{OptionalBody, WorkflowCall, WorkflowDispatch},
115    };
116
117    use super::{Concurrency, Trigger};
118
119    #[test]
120    fn test_concurrency() {
121        let bare = "foo";
122        let concurrency: Concurrency = serde_yaml::from_str(bare).unwrap();
123        assert!(matches!(concurrency, Concurrency::Bare(_)));
124
125        let rich = "group: foo\ncancel-in-progress: true";
126        let concurrency: Concurrency = serde_yaml::from_str(rich).unwrap();
127        assert!(matches!(
128            concurrency,
129            Concurrency::Rich {
130                group: _,
131                cancel_in_progress: BoE::Literal(true)
132            }
133        ));
134    }
135
136    #[test]
137    fn test_workflow_triggers() {
138        let on = "
139  issues:
140  workflow_dispatch:
141    inputs:
142      foo:
143        type: string
144  workflow_call:
145    inputs:
146      bar:
147        type: string
148  pull_request_target:
149        ";
150
151        let trigger: Trigger = serde_yaml::from_str(on).unwrap();
152        let Trigger::Events(events) = trigger else {
153            panic!("wrong trigger type");
154        };
155
156        assert!(matches!(events.issues, OptionalBody::Default));
157        assert!(matches!(
158            events.workflow_dispatch,
159            OptionalBody::Body(WorkflowDispatch { .. })
160        ));
161        assert!(matches!(
162            events.workflow_call,
163            OptionalBody::Body(WorkflowCall { .. })
164        ));
165        assert!(matches!(events.pull_request_target, OptionalBody::Default));
166    }
167}