cuenv_github/workflow/
schema.rs

1//! GitHub Actions Workflow Schema Types
2//!
3//! Defines the data structures for GitHub Actions workflow YAML generation.
4//! See: <https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions>
5
6use indexmap::IndexMap;
7use serde::Serialize;
8
9/// A GitHub Actions workflow definition.
10///
11/// Represents the complete structure of a workflow file that can be committed
12/// to `.github/workflows/`.
13#[derive(Debug, Clone, Serialize)]
14pub struct Workflow {
15    /// Workflow name displayed in GitHub UI
16    pub name: String,
17
18    /// Trigger configuration
19    #[serde(rename = "on")]
20    pub on: WorkflowTriggers,
21
22    /// Concurrency settings to prevent duplicate runs
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub concurrency: Option<Concurrency>,
25
26    /// Default permissions for `GITHUB_TOKEN`
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub permissions: Option<Permissions>,
29
30    /// Environment variables available to all jobs
31    #[serde(skip_serializing_if = "IndexMap::is_empty")]
32    pub env: IndexMap<String, String>,
33
34    /// Job definitions (order preserved via `IndexMap`)
35    pub jobs: IndexMap<String, Job>,
36}
37
38/// Workflow trigger configuration.
39///
40/// Defines when the workflow should run.
41#[derive(Debug, Clone, Default, Serialize)]
42pub struct WorkflowTriggers {
43    /// Trigger on push events
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub push: Option<PushTrigger>,
46
47    /// Trigger on pull request events
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub pull_request: Option<PullRequestTrigger>,
50
51    /// Trigger on release events
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub release: Option<ReleaseTrigger>,
54
55    /// Manual trigger with optional inputs
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub workflow_dispatch: Option<WorkflowDispatchTrigger>,
58
59    /// Scheduled trigger (cron expressions)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub schedule: Option<Vec<ScheduleTrigger>>,
62}
63
64/// Push event trigger configuration.
65#[derive(Debug, Clone, Default, Serialize)]
66#[serde(rename_all = "kebab-case")]
67pub struct PushTrigger {
68    /// Branch patterns to trigger on
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    pub branches: Vec<String>,
71
72    /// Tag patterns to trigger on
73    #[serde(skip_serializing_if = "Vec::is_empty")]
74    pub tags: Vec<String>,
75
76    /// Path patterns that must be matched to trigger
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    pub paths: Vec<String>,
79
80    /// Path patterns to ignore
81    #[serde(skip_serializing_if = "Vec::is_empty")]
82    pub paths_ignore: Vec<String>,
83}
84
85/// Pull request event trigger configuration.
86#[derive(Debug, Clone, Default, Serialize)]
87#[serde(rename_all = "kebab-case")]
88pub struct PullRequestTrigger {
89    /// Branch patterns to trigger on (target branches)
90    #[serde(skip_serializing_if = "Vec::is_empty")]
91    pub branches: Vec<String>,
92
93    /// Activity types to trigger on (e.g., "opened", "synchronize")
94    #[serde(skip_serializing_if = "Vec::is_empty")]
95    pub types: Vec<String>,
96
97    /// Path patterns that must be matched to trigger
98    #[serde(skip_serializing_if = "Vec::is_empty")]
99    pub paths: Vec<String>,
100
101    /// Path patterns to ignore
102    #[serde(skip_serializing_if = "Vec::is_empty")]
103    pub paths_ignore: Vec<String>,
104}
105
106/// Release event trigger configuration.
107#[derive(Debug, Clone, Default, Serialize)]
108pub struct ReleaseTrigger {
109    /// Activity types to trigger on (e.g., "published", "created")
110    #[serde(skip_serializing_if = "Vec::is_empty")]
111    pub types: Vec<String>,
112}
113
114/// Manual workflow dispatch trigger configuration.
115#[derive(Debug, Clone, Default, Serialize)]
116pub struct WorkflowDispatchTrigger {
117    /// Input parameters for manual trigger
118    #[serde(skip_serializing_if = "IndexMap::is_empty")]
119    pub inputs: IndexMap<String, WorkflowInput>,
120}
121
122/// Input definition for `workflow_dispatch` triggers.
123#[derive(Debug, Clone, Serialize)]
124pub struct WorkflowInput {
125    /// Human-readable description of the input
126    pub description: String,
127
128    /// Whether the input is required
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub required: Option<bool>,
131
132    /// Default value for the input
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub default: Option<String>,
135
136    /// Input type (string, boolean, choice, environment)
137    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
138    pub input_type: Option<String>,
139
140    /// Options for choice-type inputs
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub options: Option<Vec<String>>,
143}
144
145/// Schedule trigger using cron expressions.
146#[derive(Debug, Clone, Serialize)]
147pub struct ScheduleTrigger {
148    /// Cron expression (e.g., "0 0 * * *" for daily at midnight)
149    pub cron: String,
150}
151
152/// Concurrency configuration to prevent duplicate workflow runs.
153#[derive(Debug, Clone, Serialize)]
154#[serde(rename_all = "kebab-case")]
155pub struct Concurrency {
156    /// Concurrency group name (use expressions like `${{ github.workflow }}`)
157    pub group: String,
158
159    /// Whether to cancel in-progress runs when a new run is triggered
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub cancel_in_progress: Option<bool>,
162}
163
164/// `GITHUB_TOKEN` permissions configuration.
165///
166/// Controls what the workflow can access using the automatic `GITHUB_TOKEN`.
167#[derive(Debug, Clone, Default, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub struct Permissions {
170    /// Repository contents permission
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub contents: Option<PermissionLevel>,
173
174    /// Check runs permission
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub checks: Option<PermissionLevel>,
177
178    /// Pull requests permission
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub pull_requests: Option<PermissionLevel>,
181
182    /// Issues permission
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub issues: Option<PermissionLevel>,
185
186    /// GitHub Packages permission
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub packages: Option<PermissionLevel>,
189
190    /// OIDC token permission (for cloud authentication)
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub id_token: Option<PermissionLevel>,
193
194    /// GitHub Actions permission
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub actions: Option<PermissionLevel>,
197}
198
199/// Permission level for `GITHUB_TOKEN` scopes.
200#[derive(Debug, Clone, Copy, Serialize)]
201#[serde(rename_all = "lowercase")]
202pub enum PermissionLevel {
203    /// Read-only access
204    Read,
205    /// Read and write access
206    Write,
207    /// No access
208    None,
209}
210
211/// A job in a GitHub Actions workflow.
212///
213/// Jobs run in parallel by default unless `needs` dependencies are specified.
214#[derive(Debug, Clone, Serialize)]
215#[serde(rename_all = "kebab-case")]
216pub struct Job {
217    /// Job display name (shown in GitHub UI)
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub name: Option<String>,
220
221    /// Runner label(s) specifying where to run
222    pub runs_on: RunsOn,
223
224    /// Job dependencies (these jobs must complete first)
225    #[serde(skip_serializing_if = "Vec::is_empty")]
226    pub needs: Vec<String>,
227
228    /// Conditional execution expression
229    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
230    pub if_condition: Option<String>,
231
232    /// Environment for deployment protection rules
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub environment: Option<Environment>,
235
236    /// Job-level environment variables
237    #[serde(skip_serializing_if = "IndexMap::is_empty")]
238    pub env: IndexMap<String, String>,
239
240    /// Job concurrency settings
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub concurrency: Option<Concurrency>,
243
244    /// Continue workflow if this job fails
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub continue_on_error: Option<bool>,
247
248    /// Job timeout in minutes
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub timeout_minutes: Option<u32>,
251
252    /// Job steps (executed sequentially)
253    pub steps: Vec<Step>,
254}
255
256/// Runner specification for where a job runs.
257#[derive(Debug, Clone, Serialize)]
258#[serde(untagged)]
259pub enum RunsOn {
260    /// Single runner label (e.g., "ubuntu-latest")
261    Label(String),
262    /// Multiple runner labels (job runs on runner matching all labels)
263    Labels(Vec<String>),
264}
265
266/// Environment for deployment protection rules.
267///
268/// Environments can require manual approval or have other protection rules.
269#[derive(Debug, Clone, Serialize)]
270#[serde(untagged)]
271pub enum Environment {
272    /// Simple environment name
273    Name(String),
274    /// Environment with deployment URL
275    WithUrl {
276        /// Environment name
277        name: String,
278        /// URL to the deployed environment
279        url: String,
280    },
281}
282
283/// A step in a job.
284///
285/// Steps can either `uses` an action or `run` a shell command.
286#[derive(Debug, Clone, Default, Serialize)]
287#[serde(rename_all = "kebab-case")]
288pub struct Step {
289    /// Step display name (shown in GitHub UI)
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub name: Option<String>,
292
293    /// Unique identifier for referencing step outputs
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub id: Option<String>,
296
297    /// Conditional execution expression
298    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
299    pub if_condition: Option<String>,
300
301    /// Action to use (e.g., "actions/checkout@v4")
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub uses: Option<String>,
304
305    /// Shell command(s) to run
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub run: Option<String>,
308
309    /// Working directory for run commands
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub working_directory: Option<String>,
312
313    /// Shell to use for run commands (e.g., "bash", "pwsh")
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub shell: Option<String>,
316
317    /// Action inputs (for `uses` steps)
318    #[serde(rename = "with", skip_serializing_if = "IndexMap::is_empty")]
319    pub with_inputs: IndexMap<String, serde_yaml::Value>,
320
321    /// Step environment variables
322    #[serde(skip_serializing_if = "IndexMap::is_empty")]
323    pub env: IndexMap<String, String>,
324
325    /// Continue on error (don't fail the job)
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub continue_on_error: Option<bool>,
328
329    /// Step timeout in minutes
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub timeout_minutes: Option<u32>,
332}
333
334impl Step {
335    /// Create a step that uses an action
336    pub fn uses(action: impl Into<String>) -> Self {
337        Self {
338            uses: Some(action.into()),
339            ..Default::default()
340        }
341    }
342
343    /// Create a step that runs a shell command
344    pub fn run(command: impl Into<String>) -> Self {
345        Self {
346            run: Some(command.into()),
347            ..Default::default()
348        }
349    }
350
351    /// Set the step name
352    #[must_use]
353    pub fn with_name(mut self, name: impl Into<String>) -> Self {
354        self.name = Some(name.into());
355        self
356    }
357
358    /// Set the step ID
359    #[must_use]
360    pub fn with_id(mut self, id: impl Into<String>) -> Self {
361        self.id = Some(id.into());
362        self
363    }
364
365    /// Add a with input
366    #[must_use]
367    pub fn with_input(
368        mut self,
369        key: impl Into<String>,
370        value: impl Into<serde_yaml::Value>,
371    ) -> Self {
372        self.with_inputs.insert(key.into(), value.into());
373        self
374    }
375
376    /// Add an environment variable
377    #[must_use]
378    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
379        self.env.insert(key.into(), value.into());
380        self
381    }
382
383    /// Set a condition
384    #[must_use]
385    pub fn with_if(mut self, condition: impl Into<String>) -> Self {
386        self.if_condition = Some(condition.into());
387        self
388    }
389
390    /// Set working directory
391    #[must_use]
392    pub fn with_working_directory(mut self, dir: impl Into<String>) -> Self {
393        self.working_directory = Some(dir.into());
394        self
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_step_builder() {
404        let step = Step::uses("actions/checkout@v4")
405            .with_name("Checkout")
406            .with_input("fetch-depth", serde_yaml::Value::Number(2.into()));
407
408        assert_eq!(step.name, Some("Checkout".to_string()));
409        assert_eq!(step.uses, Some("actions/checkout@v4".to_string()));
410        assert!(step.with_inputs.contains_key("fetch-depth"));
411    }
412
413    #[test]
414    fn test_workflow_serialization() {
415        let workflow = Workflow {
416            name: "CI".to_string(),
417            on: WorkflowTriggers {
418                push: Some(PushTrigger {
419                    branches: vec!["main".to_string()],
420                    ..Default::default()
421                }),
422                ..Default::default()
423            },
424            concurrency: Some(Concurrency {
425                group: "${{ github.workflow }}-${{ github.ref }}".to_string(),
426                cancel_in_progress: Some(true),
427            }),
428            permissions: Some(Permissions {
429                contents: Some(PermissionLevel::Read),
430                ..Default::default()
431            }),
432            env: IndexMap::new(),
433            jobs: IndexMap::new(),
434        };
435
436        let yaml = serde_yaml::to_string(&workflow).unwrap();
437        assert!(yaml.contains("name: CI"));
438        assert!(yaml.contains("push:"));
439        assert!(yaml.contains("branches:"));
440        assert!(yaml.contains("- main"));
441    }
442
443    #[test]
444    fn test_job_with_needs() {
445        let job = Job {
446            name: Some("Test".to_string()),
447            runs_on: RunsOn::Label("ubuntu-latest".to_string()),
448            needs: vec!["build".to_string()],
449            if_condition: None,
450            environment: None,
451            env: IndexMap::new(),
452            concurrency: None,
453            continue_on_error: None,
454            timeout_minutes: None,
455            steps: vec![],
456        };
457
458        let yaml = serde_yaml::to_string(&job).unwrap();
459        assert!(yaml.contains("name: Test"));
460        assert!(yaml.contains("runs-on: ubuntu-latest"));
461        assert!(yaml.contains("needs:"));
462        assert!(yaml.contains("- build"));
463    }
464
465    #[test]
466    fn test_environment_serialization() {
467        let env_simple = Environment::Name("production".to_string());
468        let yaml = serde_yaml::to_string(&env_simple).unwrap();
469        assert!(yaml.contains("production"));
470
471        let env_with_url = Environment::WithUrl {
472            name: "production".to_string(),
473            url: "https://example.com".to_string(),
474        };
475        let yaml = serde_yaml::to_string(&env_with_url).unwrap();
476        assert!(yaml.contains("name: production"));
477        assert!(yaml.contains("url:"));
478    }
479}