ghactions_core/actions/
models.rs

1//! # Models
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize, Serializer};
5use std::{
6    collections::HashMap,
7    fmt::{Display, Formatter},
8    io::Write,
9    os::unix::fs::FileExt,
10    path::PathBuf,
11};
12
13use crate::ActionsError;
14
15const GHACTIONS_ROOT: &str = env!("CARGO_MANIFEST_DIR");
16
17/// Action Mode
18#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
19pub enum ActionMode {
20    /// Default Mode
21    #[default]
22    Default,
23    /// Container/Docker Mode
24    Container,
25    /// Installer Mode
26    Installer,
27    /// Entrypoint Mode
28    Entrypoint,
29    /// Custom Composite Action
30    CustomComposite,
31}
32
33/// Action YAML file structure
34///
35/// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions
36#[derive(Debug, PartialEq, Serialize, Deserialize)]
37pub struct ActionYML {
38    /// Action Mode
39    #[serde(skip)]
40    pub mode: ActionMode,
41
42    /// Action Path
43    #[serde(skip)]
44    pub path: Option<PathBuf>,
45
46    /// Action Name
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub name: Option<String>,
49    /// Action Description
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub description: Option<String>,
52    /// Action Author
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub author: Option<String>,
55
56    /// Action Branding
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub branding: Option<ActionBranding>,
59
60    /// Action Inputs
61    pub inputs: IndexMap<String, ActionInput>,
62    /// Action Outputs
63    pub outputs: IndexMap<String, ActionOutput>,
64    /// Output Value Step ID
65    #[serde(skip)]
66    pub output_value_step_id: Option<String>,
67
68    /// Action Runs
69    pub runs: ActionRuns,
70}
71
72impl Default for ActionYML {
73    fn default() -> Self {
74        ActionYML {
75            mode: ActionMode::Default,
76            path: None,
77            name: Some(env!("CARGO_PKG_NAME").to_string()),
78            description: None,
79            author: None,
80            branding: None,
81            inputs: IndexMap::new(),
82            outputs: IndexMap::new(),
83            output_value_step_id: Some("cargo-run".to_string()),
84            runs: ActionRuns::default(),
85        }
86    }
87}
88
89impl ActionYML {
90    /// Set the Action to a Container Image based Action
91    pub fn set_container_image(&mut self, image: String) {
92        self.runs.using = ActionRunUsing::Docker;
93        self.runs.image = Some(image);
94        self.runs.steps = None;
95        // Docker based action doesn't need to set the output value step id
96        self.output_value_step_id = None;
97    }
98
99    /// This mode uses a composite action with `gh` cli to install the action
100    /// on the runner.
101    ///
102    /// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context
103    pub fn add_installer_step(&mut self) {
104        if self.runs.steps.is_none() {
105            self.runs.steps = Some(vec![]);
106        }
107
108        let binary_name =
109            std::env::var("CARGO_BIN_NAME").unwrap_or_else(|_| "${{ github.action }}".to_string());
110
111        let env = IndexMap::from([
112            (
113                "ACTION_REPOSITORY".to_string(),
114                "${{ github.action_repository }}".to_string(),
115            ),
116            (
117                "ACTION_REF".to_string(),
118                "${{ github.action_ref }}".to_string(),
119            ),
120            ("BINARY_NAME".to_string(), binary_name.to_string()),
121            ("GH_TOKEN".to_string(), "${{ github.token }}".to_string()),
122            ("RUNNER_OS".to_string(), "${{ runner.os }}".to_string()),
123            ("RUNNER_ARCH".to_string(), "${{ runner.arch }}".to_string()),
124        ]);
125
126        if let Some(ref mut steps) = self.runs.steps {
127            // Linux / MacOS
128            steps.push(ActionRunStep {
129                name: Some("Install the Action".to_string()),
130                id: Some("install-action".to_string()),
131                shell: Some("bash".to_string()),
132                condition: Some("${{ runner.os == 'Linux' || runner.os == 'macOS' }}".to_string()),
133                env: Some(env.clone()),
134                run: Some(include_str!("installer.sh").to_string()),
135            });
136            // TODO: Add Windows support
137        }
138    }
139
140    /// Add a custom installer script to the Action
141    pub fn add_script(&mut self, script: &str, id: Option<&str>) {
142        if self.runs.steps.is_none() {
143            self.runs.steps = Some(vec![]);
144        }
145
146        if let Some(ref mut steps) = self.runs.steps {
147            // Add script inline in the step
148            steps.push(ActionRunStep {
149                id: id.map(|s| s.to_string()),
150                name: Some("Installing Action".to_string()),
151                shell: Some("bash".to_string()),
152                run: Some(script.to_string()),
153                ..Default::default()
154            });
155        }
156    }
157
158    /// Add run step to the Action
159    pub fn add_script_run(&mut self) {
160        if self.runs.steps.is_none() {
161            self.runs.steps = Some(vec![]);
162        }
163
164        if let Some(ref mut steps) = self.runs.steps {
165            self.output_value_step_id = Some("cargo-run".to_string());
166
167            let binary_name = std::env::var("CARGO_BIN_NAME")
168                .unwrap_or_else(|_| "${{ github.action }}".to_string());
169            let script = format!("set -e\n{}", binary_name);
170            // Add script inline in the step
171            steps.push(ActionRunStep {
172                name: Some("Run the Action".to_string()),
173                id: Some("cargo-run".to_string()),
174                shell: Some("bash".to_string()),
175                run: Some(script.to_string()),
176                ..Default::default()
177            });
178        }
179    }
180
181    /// Add cargo install step
182    pub fn add_cargo_install_step(&mut self, binary_name: &str) {
183        if self.runs.steps.is_none() {
184            self.runs.steps = Some(vec![]);
185        }
186
187        if let Some(ref mut steps) = self.runs.steps {
188            let cmd = if binary_name == "." {
189                "cargo install --path .".to_string()
190            } else {
191                format!("cargo install \"{binary_name}\"")
192            };
193
194            steps.push(ActionRunStep {
195                name: Some("Cargo Install".to_string()),
196                shell: Some("bash".to_string()),
197                run: Some(cmd),
198                ..Default::default()
199            });
200        }
201    }
202
203    /// Load the Action YAML file
204    pub fn load_action(path: String) -> Result<ActionYML, Box<dyn std::error::Error>> {
205        let fhandle = std::fs::File::open(&path)?;
206        let mut action_yml: ActionYML = serde_yaml::from_reader(fhandle)?;
207        action_yml.path = Some(PathBuf::from(path.clone()));
208        Ok(action_yml)
209    }
210
211    /// Write the Action YAML file
212    pub fn write(&self) -> Result<PathBuf, ActionsError> {
213        if let Some(ref path) = self.path {
214            if !path.exists() {
215                let parent = path.parent().unwrap();
216                std::fs::create_dir_all(parent)
217                    .map_err(|err| ActionsError::IOError(err.to_string()))?;
218            }
219
220            let mut content = String::new();
221            content.push_str("# This file is generated by ghactions\n");
222            if self.mode == ActionMode::CustomComposite {
223                content.push_str(
224                    "# `ghactions` is generating all parts but not composite action steps\n",
225                );
226            } else {
227                content.push_str(
228                "# Do not edit this file manually unless you disable the `generate` feature.\n\n",
229            );
230            }
231            content.push_str(
232                serde_yaml::to_string(self)
233                    .map_err(|err| ActionsError::IOError(err.to_string()))?
234                    .as_str(),
235            );
236
237            // Create or Open the file
238            let mut fhandle = std::fs::OpenOptions::new()
239                .write(true)
240                .create(true)
241                .truncate(true)
242                .open(path)
243                .map_err(|err| ActionsError::IOError(err.to_string()))?;
244            fhandle
245                .write_all(content.as_bytes())
246                .map_err(|err| ActionsError::IOError(err.to_string()))?;
247
248            Ok(path.clone())
249        } else {
250            Err(ActionsError::NotImplemented)
251        }
252    }
253}
254
255/// Action Input structure
256#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
257pub struct ActionInput {
258    /// [internal] Action Field Name
259    #[serde(skip)]
260    pub action_name: String,
261    /// [internal] Struct Field Name
262    #[serde(skip)]
263    pub field_name: String,
264    /// [internal] Input Type
265    #[serde(skip)]
266    pub r#type: String,
267
268    /// Input Description
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub description: Option<String>,
271    /// Input Required or not
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub required: Option<bool>,
274    /// Input Default value
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub default: Option<String>,
277    /// Deprecation Message
278    #[serde(rename = "deprecationMessage", skip_serializing_if = "Option::is_none")]
279    pub deprecation_message: Option<String>,
280
281    // Other internal fields
282    /// Separator
283    #[serde(skip)]
284    pub separator: Option<String>,
285}
286
287/// Action Output structure
288#[derive(Debug, PartialEq, Default, Serialize, Deserialize)]
289pub struct ActionOutput {
290    /// Output Description
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub description: Option<String>,
293
294    /// Output Value
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub value: Option<String>,
297}
298
299/// Action Branding
300///
301/// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#branding
302#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
303pub struct ActionBranding {
304    /// Color
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub color: Option<String>,
307    /// Icon
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub icon: Option<String>,
310}
311
312/// Action Runs structure
313#[derive(Debug, PartialEq, Serialize, Deserialize)]
314pub struct ActionRuns {
315    /// Action Name
316    pub using: ActionRunUsing,
317
318    /// Container Image (container actions only)
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub image: Option<String>,
321    /// Arguments (container actions only)
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub args: Option<Vec<String>>,
324
325    /// Steps (composite actions only)
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub steps: Option<Vec<ActionRunStep>>,
328}
329
330impl Default for ActionRuns {
331    fn default() -> Self {
332        Self {
333            using: ActionRunUsing::Composite,
334            image: None,
335            args: None,
336            steps: None,
337        }
338    }
339}
340
341fn default_composite_steps() -> Vec<ActionRunStep> {
342    // Binary Name
343    let binary_name = std::env::var("CARGO_BIN_NAME").unwrap_or_else(|_| "action".to_string());
344    vec![
345        // Step 1 - Checking for Cargo/Rust (needs to be installed by the user)
346        // ActionRunStep {
347        //     name: Some("Checking for Cargo/Rust".to_string()),
348        //     shell: Some("bash".to_string()),
349        //     run: Some("".to_string()),
350        //     ..Default::default()
351        // },
352        // Step 2 - Compile the Action
353        ActionRunStep {
354            name: Some("Compile / Install the Action binary".to_string()),
355            shell: Some("bash".to_string()),
356            run: Some("set -e\ncargo install --path \"${{ github.action_path }}\"".to_string()),
357            ..Default::default()
358        },
359        // Step 3 - Run the Action
360        ActionRunStep {
361            id: Some("cargo-run".to_string()),
362            name: Some("Run the Action".to_string()),
363            shell: Some("bash".to_string()),
364            run: Some(format!("set -e\n{}", binary_name)),
365            ..Default::default()
366        },
367    ]
368}
369
370/// Action Run Using Enum
371#[derive(Debug, PartialEq, Deserialize)]
372pub enum ActionRunUsing {
373    /// Docker / Container Image
374    Docker,
375    /// Composite Action
376    Composite,
377}
378
379impl From<&str> for ActionRunUsing {
380    fn from(value: &str) -> Self {
381        match value {
382            "docker" => ActionRunUsing::Docker,
383            "composite" => ActionRunUsing::Composite,
384            _ => ActionRunUsing::Composite,
385        }
386    }
387}
388
389impl From<String> for ActionRunUsing {
390    fn from(value: String) -> Self {
391        Self::from(value.as_str())
392    }
393}
394
395impl Serialize for ActionRunUsing {
396    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
397    where
398        S: Serializer,
399    {
400        match self {
401            ActionRunUsing::Docker => serializer.serialize_str("docker"),
402            ActionRunUsing::Composite => serializer.serialize_str("composite"),
403        }
404    }
405}
406
407/// Action Run Step
408#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
409pub struct ActionRunStep {
410    /// Step Name
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub name: Option<String>,
413    /// Step ID
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub id: Option<String>,
416    /// Run if condition
417    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
418    pub condition: Option<String>,
419
420    /// Environment Variables
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub env: Option<IndexMap<String, String>>,
423
424    /// Shell to use (if any)
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub shell: Option<String>,
427    /// Run command
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub run: Option<String>,
430}