Skip to main content

abbaye/builders/
script.rs

1use std::path::PathBuf;
2use std::process::Stdio;
3
4use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
5use crate::utils::expand_variables;
6use miette::{IntoDiagnostic, Result, miette};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tokio::process::Command;
11
12/// Configuration for [`ScriptBuilder`].
13#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
14pub struct ScriptBuilderConfig {
15    /// Shell commands to execute in order. Each line is passed to `sh -c`,
16    /// so any POSIX shell syntax is supported.
17    ///
18    /// The environment variable `ABBAYE_BUILDING_VERSION` is set to the version
19    /// being built. (e.g. the git tag `v0.1.0` or whatever. Not Abbaye's own version)
20    ///
21    /// The build fails immediately if any command exits with a non-zero status.
22    ///
23    /// ```toml
24    /// [[builders]]
25    /// type = "script"
26    /// script = [
27    ///   "make release",
28    ///   "strip target/mybin",
29    /// ]
30    /// outputs = ["target/mybin"]
31    /// ```
32    pub script: Vec<String>,
33
34    /// Paths of the files or directories produced by the script that should be
35    /// treated as release artifacts (copied to `dist/` and listed on the
36    /// release page). Each path is resolved relative to the working directory
37    /// in which `abbaye` is run.
38    ///
39    /// If a listed path is missing, the build fails.
40    pub outputs: Vec<ScriptBuilderOutput>,
41}
42
43/// Lets the user specify a path for a script output, optionally with a custom name.
44/// I need to check but it should enable the user to specify a directory as output.
45/// If that's a good idea is an other question.
46#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]
47#[serde(untagged)]
48pub enum ScriptBuilderOutput {
49    PathWithName { path: PathBuf, name: String },
50    Path(PathBuf),
51}
52
53/// Executes a script and treats the listed output paths as release artifacts.
54pub struct ScriptBuilder;
55
56impl Builder for ScriptBuilder {
57    type ConfigType = ScriptBuilderConfig;
58
59    async fn build(
60        &self,
61        config: Self::ConfigType,
62        version: &str,
63        log: LogSender,
64    ) -> Result<Vec<ArtifactPath>> {
65        for line in &config.script {
66            let mut child = Command::new("sh")
67                .args(["-c", line])
68                .env("ABBAYE_BUILDING_VERSION", version)
69                .stdout(Stdio::piped())
70                .stderr(Stdio::piped())
71                .spawn()
72                .into_diagnostic()?;
73
74            // Merge stdout and stderr into the log channel concurrently.
75            let stdout = child.stdout.take().expect("stdout piped");
76            let stderr = child.stderr.take().expect("stderr piped");
77            let log_out = log.clone();
78            let log_err = log.clone();
79            let stdout_task = tokio::spawn(async move {
80                let mut lines = BufReader::new(stdout).lines();
81                while let Ok(Some(l)) = lines.next_line().await {
82                    let _ = log_out.send(LogEvent::Line(l));
83                }
84            });
85            let stderr_task = tokio::spawn(async move {
86                let mut lines = BufReader::new(stderr).lines();
87                while let Ok(Some(l)) = lines.next_line().await {
88                    let _ = log_err.send(LogEvent::Line(l));
89                }
90            });
91
92            let status = child.wait().await.into_diagnostic()?;
93            // Drain I/O tasks before checking the exit code.
94            let _ = tokio::join!(stdout_task, stderr_task);
95
96            if !status.success() {
97                return Err(miette!(
98                    "script command failed (exit {}):\n  {}",
99                    status.code().unwrap_or(-1),
100                    line
101                ));
102            }
103        }
104
105        // Collect every declared output path as an artifact.
106        let mut artifacts = Vec::new();
107        for output in &config.outputs {
108            let vars = vec![("ABBAYE_BUILDING_VERSION", version)];
109
110            let (pattern, name_override) = match output {
111                ScriptBuilderOutput::Path(p) => (expand_variables(p, vars), None),
112                ScriptBuilderOutput::PathWithName { path: p, name } => {
113                    (expand_variables(p, vars), Some(name.clone()))
114                }
115            };
116
117            let pattern_str = pattern.to_string_lossy().to_string();
118
119            // Check if pattern contains glob characters
120            if pattern_str.contains(['*', '?', '[']) {
121                // Compile the glob pattern
122                let glob = globset::Glob::new(&pattern_str)
123                    .into_diagnostic()?
124                    .compile_matcher();
125
126                let mut matched = false;
127
128                // Walk the directory to find matching paths
129                let parent = pattern
130                    .parent()
131                    .unwrap_or_else(|| std::path::Path::new("."));
132                for entry in walkdir::WalkDir::new(parent)
133                    .into_iter()
134                    .filter_map(|e| e.ok())
135                {
136                    let path = entry.path();
137                    if glob.is_match(path) {
138                        matched = true;
139                        let name = name_override.clone().unwrap_or_else(|| {
140                            path.file_name()
141                                .unwrap_or_default()
142                                .to_string_lossy()
143                                .into_owned()
144                        });
145                        artifacts.push(ArtifactPath {
146                            path: path.to_path_buf(),
147                            name,
148                            hash: None,
149                        });
150                    }
151                }
152
153                if !matched {
154                    return Err(miette!("no files matched output pattern: {}", pattern_str));
155                }
156            } else {
157                // Regular path (non-glob)
158                if !pattern.exists() {
159                    return Err(miette!(
160                        "declared script output does not exist: {}",
161                        pattern.display()
162                    ));
163                }
164                let name = name_override.unwrap_or_else(|| {
165                    pattern
166                        .file_name()
167                        .unwrap_or_default()
168                        .to_string_lossy()
169                        .into_owned()
170                });
171                artifacts.push(ArtifactPath {
172                    path: pattern.clone(),
173                    name,
174                    hash: None,
175                });
176            }
177        }
178        Ok(artifacts)
179    }
180}