Skip to main content

opal/pipeline/
artifacts.rs

1use crate::git;
2use crate::model::{ArtifactSourceOutcome, JobSpec};
3use crate::naming::{job_name_slug, project_slug};
4use anyhow::{Context, Result, anyhow};
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::sync::{Arc, Mutex};
11use tracing::warn;
12
13#[derive(Debug, Clone)]
14pub struct ArtifactManager {
15    root: PathBuf,
16}
17
18impl ArtifactManager {
19    pub fn new(root: PathBuf) -> Self {
20        Self { root }
21    }
22
23    pub fn prepare_targets(&self, job: &JobSpec) -> Result<()> {
24        if job.artifacts.paths.is_empty()
25            && !job.artifacts.untracked
26            && job.artifacts.report_dotenv.is_none()
27        {
28            return Ok(());
29        }
30        let root = self.job_artifacts_root(&job.name);
31        fs::create_dir_all(&root)
32            .with_context(|| format!("failed to prepare artifacts for {}", job.name))?;
33
34        for relative in &job.artifacts.paths {
35            let host = self.job_artifact_host_path(&job.name, relative);
36            match artifact_kind(relative) {
37                ArtifactPathKind::Directory => {
38                    fs::create_dir_all(&host).with_context(|| {
39                        format!("failed to prepare artifact directory {}", host.display())
40                    })?;
41                }
42                ArtifactPathKind::File => {
43                    if let Some(parent) = host.parent() {
44                        fs::create_dir_all(parent).with_context(|| {
45                            format!("failed to prepare artifact parent {}", parent.display())
46                        })?;
47                    }
48                }
49            }
50        }
51
52        if let Some(relative) = &job.artifacts.report_dotenv {
53            let host = self.job_artifact_host_path(&job.name, relative);
54            if let Some(parent) = host.parent() {
55                fs::create_dir_all(parent).with_context(|| {
56                    format!(
57                        "failed to prepare dotenv artifact parent {}",
58                        parent.display()
59                    )
60                })?;
61            }
62        }
63
64        Ok(())
65    }
66
67    pub fn job_mount_specs(&self, job: &JobSpec) -> Vec<(PathBuf, PathBuf)> {
68        use std::collections::HashSet;
69
70        let mut specs = Vec::new();
71        let mut seen = HashSet::new();
72        for relative in &job.artifacts.paths {
73            let rel_path = artifact_relative_path(relative);
74            let mount_rel = match artifact_kind(relative) {
75                ArtifactPathKind::Directory => rel_path.clone(),
76                ArtifactPathKind::File => match rel_path.parent() {
77                    Some(parent) if parent != Path::new("") && parent != Path::new(".") => {
78                        parent.to_path_buf()
79                    }
80                    _ => continue,
81                },
82            };
83            if seen.insert(mount_rel.clone()) {
84                let host = self.job_artifacts_root(&job.name).join(&mount_rel);
85                specs.push((host, mount_rel));
86            }
87        }
88        specs
89    }
90
91    pub fn collect_declared(&self, job: &JobSpec, workspace: &Path) -> Result<()> {
92        let exclude = build_exclude_matcher(&job.artifacts.exclude)?;
93        for relative in &job.artifacts.paths {
94            let src = workspace.join(relative);
95            if !src.exists() {
96                continue;
97            }
98            let dest = self.job_artifact_host_path(&job.name, relative);
99            copy_declared_path(&src, &dest, relative, exclude.as_ref())?;
100        }
101        Ok(())
102    }
103
104    pub fn dependency_mount_specs(
105        &self,
106        job_name: &str,
107        job: Option<&JobSpec>,
108        outcome: Option<ArtifactSourceOutcome>,
109        optional: bool,
110    ) -> Vec<(PathBuf, PathBuf)> {
111        let Some(dep_job) = job else {
112            return Vec::new();
113        };
114        let mut specs = Vec::new();
115        for relative in &dep_job.artifacts.paths {
116            let host = self.job_artifact_host_path(job_name, relative);
117            if !host.exists() {
118                if !optional {
119                    warn!(job = job_name, path = %relative.display(), "artifact missing");
120                }
121                continue;
122            }
123            if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
124                continue;
125            }
126            specs.push((host, relative.clone()));
127        }
128        if dep_job.artifacts.untracked {
129            for relative in self.read_untracked_manifest(job_name) {
130                let host = self.job_artifact_host_path(job_name, &relative);
131                if !host.exists() {
132                    continue;
133                }
134                if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
135                    continue;
136                }
137                specs.push((host, relative));
138            }
139        }
140
141        specs
142    }
143
144    pub fn job_artifact_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
145        self.job_artifacts_root(job_name)
146            .join(artifact_relative_path(artifact))
147    }
148
149    pub fn collect_dotenv_report(&self, job: &JobSpec, workspace: &Path) -> Result<()> {
150        let Some(relative) = &job.artifacts.report_dotenv else {
151            return Ok(());
152        };
153        let src = workspace.join(relative);
154        if !src.exists() {
155            return Ok(());
156        }
157        let dest = self.job_artifact_host_path(&job.name, relative);
158        if let Some(parent) = dest.parent() {
159            fs::create_dir_all(parent)
160                .with_context(|| format!("failed to create {}", parent.display()))?;
161        }
162        fs::copy(&src, &dest)
163            .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
164        Ok(())
165    }
166
167    pub fn job_artifacts_root(&self, job_name: &str) -> PathBuf {
168        self.root.join(job_name_slug(job_name)).join("artifacts")
169    }
170
171    pub fn job_dependency_root(&self, job_name: &str) -> PathBuf {
172        self.root.join(job_name_slug(job_name)).join("dependencies")
173    }
174
175    pub fn job_dependency_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
176        self.job_dependency_root(job_name)
177            .join(artifact_relative_path(artifact))
178    }
179
180    pub fn collect_untracked(&self, job: &JobSpec, workspace: &Path) -> Result<()> {
181        if !job.artifacts.untracked {
182            return Ok(());
183        }
184
185        let exclude = build_exclude_matcher(&job.artifacts.exclude)?;
186        let explicit_paths = &job.artifacts.paths;
187        let mut collected = Vec::new();
188        for relative in git::untracked_files(workspace)? {
189            let relative = PathBuf::from(relative);
190            if path_is_covered_by_explicit_artifacts(&relative, explicit_paths) {
191                continue;
192            }
193            if should_exclude(&relative, exclude.as_ref()) {
194                continue;
195            }
196
197            let src = workspace.join(&relative);
198            if !src.exists() {
199                continue;
200            }
201            copy_untracked_entry(
202                workspace,
203                &src,
204                self.job_artifact_host_path(&job.name, &relative),
205                &relative,
206                exclude.as_ref(),
207                &mut collected,
208            )?;
209        }
210
211        collected.sort();
212        collected.dedup();
213        self.write_untracked_manifest(&job.name, &collected)
214    }
215
216    fn write_untracked_manifest(&self, job_name: &str, paths: &[PathBuf]) -> Result<()> {
217        let manifest = self.job_untracked_manifest_path(job_name);
218        if let Some(parent) = manifest.parent() {
219            fs::create_dir_all(parent)
220                .with_context(|| format!("failed to create {}", parent.display()))?;
221        }
222        let content = if paths.is_empty() {
223            String::new()
224        } else {
225            let mut body = paths
226                .iter()
227                .map(|path| path.to_string_lossy().to_string())
228                .collect::<Vec<_>>()
229                .join("\n");
230            body.push('\n');
231            body
232        };
233        fs::write(&manifest, content)
234            .with_context(|| format!("failed to write {}", manifest.display()))
235    }
236
237    fn read_untracked_manifest(&self, job_name: &str) -> Vec<PathBuf> {
238        let manifest = self.job_untracked_manifest_path(job_name);
239        let Ok(contents) = fs::read_to_string(&manifest) else {
240            return Vec::new();
241        };
242        contents
243            .lines()
244            .filter(|line| !line.trim().is_empty())
245            .map(PathBuf::from)
246            .collect()
247    }
248
249    fn job_untracked_manifest_path(&self, job_name: &str) -> PathBuf {
250        self.root
251            .join(job_name_slug(job_name))
252            .join("untracked-manifest.txt")
253    }
254}
255
256#[derive(Debug, Clone, Copy)]
257pub enum ArtifactPathKind {
258    File,
259    Directory,
260}
261
262fn artifact_relative_path(artifact: &Path) -> PathBuf {
263    use std::path::Component;
264
265    let mut rel = PathBuf::new();
266    for component in artifact.components() {
267        match component {
268            Component::RootDir | Component::CurDir => continue,
269            Component::ParentDir => continue,
270            Component::Prefix(prefix) => rel.push(prefix.as_os_str()),
271            Component::Normal(seg) => rel.push(seg),
272        }
273    }
274
275    if rel.as_os_str().is_empty() {
276        rel.push("artifact");
277    }
278    rel
279}
280
281fn copy_declared_path(
282    src: &Path,
283    dest: &Path,
284    relative: &Path,
285    exclude: Option<&GlobSet>,
286) -> Result<()> {
287    let metadata =
288        fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
289    if metadata.is_dir() {
290        fs::create_dir_all(dest).with_context(|| format!("failed to create {}", dest.display()))?;
291        for entry in
292            fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
293        {
294            let entry = entry?;
295            let child_src = entry.path();
296            let child_rel = relative.join(entry.file_name());
297            let child_dest = dest.join(entry.file_name());
298            copy_declared_path(&child_src, &child_dest, &child_rel, exclude)?;
299        }
300        return Ok(());
301    }
302    if exclude.is_some_and(|glob| glob.is_match(relative)) {
303        return Ok(());
304    }
305    if let Some(parent) = dest.parent() {
306        fs::create_dir_all(parent)
307            .with_context(|| format!("failed to create {}", parent.display()))?;
308    }
309    fs::copy(src, dest)
310        .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
311    Ok(())
312}
313
314fn artifact_kind(path: &Path) -> ArtifactPathKind {
315    let text = path.to_string_lossy();
316    if text.ends_with(std::path::MAIN_SEPARATOR) {
317        ArtifactPathKind::Directory
318    } else {
319        ArtifactPathKind::File
320    }
321}
322
323fn artifact_path_has_content(path: &Path) -> bool {
324    match fs::metadata(path) {
325        Ok(metadata) if metadata.is_file() => true,
326        Ok(metadata) if metadata.is_dir() => fs::read_dir(path)
327            .ok()
328            .and_then(|mut entries| entries.next())
329            .is_some(),
330        _ => false,
331    }
332}
333
334fn build_exclude_matcher(patterns: &[String]) -> Result<Option<GlobSet>> {
335    if patterns.is_empty() {
336        return Ok(None);
337    }
338
339    let mut builder = GlobSetBuilder::new();
340    for pattern in patterns {
341        builder.add(
342            Glob::new(pattern)
343                .with_context(|| format!("invalid artifacts.exclude pattern '{pattern}'"))?,
344        );
345    }
346    Ok(Some(builder.build()?))
347}
348
349fn should_exclude(path: &Path, exclude: Option<&GlobSet>) -> bool {
350    exclude.is_some_and(|glob| glob.is_match(path))
351}
352
353fn path_is_covered_by_explicit_artifacts(path: &Path, explicit_paths: &[PathBuf]) -> bool {
354    explicit_paths
355        .iter()
356        .any(|artifact| match artifact_kind(artifact) {
357            ArtifactPathKind::Directory => {
358                let base = artifact_relative_path(artifact);
359                path == base || path.starts_with(&base)
360            }
361            ArtifactPathKind::File => path == artifact_relative_path(artifact),
362        })
363}
364
365fn copy_untracked_entry(
366    workspace: &Path,
367    src: &Path,
368    dest: PathBuf,
369    relative: &Path,
370    exclude: Option<&GlobSet>,
371    collected: &mut Vec<PathBuf>,
372) -> Result<()> {
373    let metadata =
374        fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
375    if metadata.is_dir() {
376        for entry in
377            fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
378        {
379            let entry = entry?;
380            let child_src = entry.path();
381            let child_relative = match child_src.strip_prefix(workspace) {
382                Ok(rel) => rel.to_path_buf(),
383                Err(_) => continue,
384            };
385            let child_dest = dest.join(entry.file_name());
386            copy_untracked_entry(
387                workspace,
388                &child_src,
389                child_dest,
390                &child_relative,
391                exclude,
392                collected,
393            )?;
394        }
395        return Ok(());
396    }
397
398    if should_exclude(relative, exclude) {
399        return Ok(());
400    }
401    if let Some(parent) = dest.parent() {
402        fs::create_dir_all(parent)
403            .with_context(|| format!("failed to create {}", parent.display()))?;
404    }
405    fs::copy(src, &dest)
406        .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
407    collected.push(relative.to_path_buf());
408    Ok(())
409}
410
411#[derive(Clone, Debug)]
412pub struct ExternalArtifactsManager {
413    inner: Arc<ExternalArtifactsInner>,
414}
415
416#[derive(Debug)]
417struct ExternalArtifactsInner {
418    root: PathBuf,
419    base_url: String,
420    token: String,
421    cache: Mutex<HashMap<String, PathBuf>>,
422}
423
424impl ExternalArtifactsManager {
425    pub fn new(root: PathBuf, base_url: String, token: String) -> Self {
426        let inner = ExternalArtifactsInner {
427            root,
428            base_url,
429            token,
430            cache: Mutex::new(HashMap::new()),
431        };
432        Self {
433            inner: Arc::new(inner),
434        }
435    }
436
437    pub fn ensure_artifacts(&self, project: &str, job: &str, reference: &str) -> Result<PathBuf> {
438        let key = format!("{project}::{reference}::{job}");
439        if let Ok(cache) = self.inner.cache.lock()
440            && let Some(path) = cache.get(&key)
441            && path.exists()
442        {
443            return Ok(path.clone());
444        }
445
446        let target = self.external_root(project, job, reference);
447        if target.exists() {
448            fs::remove_dir_all(&target)
449                .with_context(|| format!("failed to clear {}", target.display()))?;
450        }
451        fs::create_dir_all(&target)
452            .with_context(|| format!("failed to create {}", target.display()))?;
453        let archive_path = target.join("artifacts.zip");
454        self.download_artifacts(project, job, reference, &archive_path)?;
455        self.extract_artifacts(&archive_path, &target)?;
456        let _ = fs::remove_file(&archive_path);
457
458        if let Ok(mut cache) = self.inner.cache.lock() {
459            cache.insert(key, target.clone());
460        }
461
462        Ok(target)
463    }
464
465    fn external_root(&self, project: &str, job: &str, reference: &str) -> PathBuf {
466        let project_slug = project_slug(project);
467        let reference_slug = sanitize_reference(reference);
468        self.inner
469            .root
470            .join("external")
471            .join(project_slug)
472            .join(reference_slug)
473            .join(job_name_slug(job))
474    }
475
476    fn download_artifacts(
477        &self,
478        project: &str,
479        job: &str,
480        reference: &str,
481        dest: &Path,
482    ) -> Result<()> {
483        let base = self.inner.base_url.trim_end_matches('/');
484        let project_id = percent_encode(project);
485        let ref_id = percent_encode(reference);
486        let job_name = percent_encode(job);
487        let url = format!(
488            "{base}/api/v4/projects/{project_id}/jobs/artifacts/{ref_id}/download?job={job_name}"
489        );
490        let status = Command::new("curl")
491            .arg("--fail")
492            .arg("-sS")
493            .arg("-L")
494            .arg("-H")
495            .arg(format!("PRIVATE-TOKEN: {}", self.inner.token))
496            .arg("-o")
497            .arg(dest)
498            .arg(&url)
499            .status()
500            .with_context(|| "failed to invoke curl to download artifacts")?;
501        if !status.success() {
502            return Err(anyhow!(
503                "curl failed to download artifacts from {} (status {})",
504                url,
505                status.code().unwrap_or(-1)
506            ));
507        }
508        Ok(())
509    }
510
511    fn extract_artifacts(&self, archive: &Path, dest: &Path) -> Result<()> {
512        let unzip_status = Command::new("unzip")
513            .arg("-q")
514            .arg("-o")
515            .arg(archive)
516            .arg("-d")
517            .arg(dest)
518            .status();
519        match unzip_status {
520            Ok(status) if status.success() => return Ok(()),
521            Ok(_) | Err(_) => {
522                // fallback to python's zipfile
523                let script =
524                    "import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])";
525                let status = Command::new("python3")
526                    .arg("-c")
527                    .arg(script)
528                    .arg(archive)
529                    .arg(dest)
530                    .status()
531                    .with_context(|| "failed to invoke python3 to extract artifacts")?;
532                if status.success() {
533                    return Ok(());
534                }
535            }
536        }
537        Err(anyhow!(
538            "unable to extract artifacts archive {}",
539            archive.display()
540        ))
541    }
542}
543
544fn percent_encode(value: &str) -> String {
545    let mut encoded = String::new();
546    for byte in value.bytes() {
547        match byte {
548            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
549                encoded.push(byte as char)
550            }
551            _ => encoded.push_str(&format!("%{:02X}", byte)),
552        }
553    }
554    encoded
555}
556
557fn sanitize_reference(reference: &str) -> String {
558    let mut slug = String::new();
559    for ch in reference.chars() {
560        if ch.is_ascii_alphanumeric() {
561            slug.push(ch.to_ascii_lowercase());
562        } else if matches!(ch, '-' | '_' | '.') {
563            slug.push(ch);
564        } else {
565            slug.push('-');
566        }
567    }
568    if slug.is_empty() {
569        slug.push_str("ref");
570    }
571    slug
572}
573
574#[cfg(test)]
575mod tests {
576    use super::{
577        ArtifactManager, ArtifactPathKind, artifact_kind, artifact_path_has_content,
578        path_is_covered_by_explicit_artifacts,
579    };
580    use crate::model::{
581        ArtifactSourceOutcome, ArtifactSpec, ArtifactWhenSpec, JobSpec, RetryPolicySpec,
582    };
583    use std::collections::HashMap;
584    use std::fs;
585    use std::path::{Path, PathBuf};
586    use std::time::{SystemTime, UNIX_EPOCH};
587
588    #[test]
589    fn artifact_path_kind_treats_trailing_separator_as_directory() {
590        assert!(matches!(
591            artifact_kind(std::path::Path::new("tests-temp/build/")),
592            ArtifactPathKind::Directory
593        ));
594    }
595
596    #[test]
597    fn artifact_path_has_content_requires_directory_entries() {
598        let root = temp_path("artifact-content");
599        fs::create_dir_all(&root).expect("create dir");
600        assert!(!artifact_path_has_content(&root));
601        fs::write(root.join("marker.txt"), "ok").expect("write marker");
602        assert!(artifact_path_has_content(&root));
603        let _ = fs::remove_dir_all(root);
604    }
605
606    #[test]
607    fn dependency_mount_specs_allow_populated_artifacts_without_recorded_outcome() {
608        let root = temp_path("artifact-presence");
609        let manager = ArtifactManager::new(root.clone());
610        let relative = PathBuf::from("tests-temp/build/");
611        let job = job(
612            "build",
613            vec![relative.clone()],
614            Vec::new(),
615            false,
616            ArtifactWhenSpec::OnSuccess,
617        );
618        let host = manager.job_artifact_host_path("build", &relative);
619        fs::create_dir_all(&host).expect("create artifact dir");
620        fs::write(host.join("linux-release.txt"), "release").expect("write artifact");
621
622        let specs = manager.dependency_mount_specs("build", Some(&job), None, false);
623
624        assert_eq!(specs.len(), 1);
625        assert_eq!(specs[0].0, host);
626
627        let _ = fs::remove_dir_all(root);
628    }
629
630    #[test]
631    fn artifact_when_matches_expected_outcomes() {
632        assert!(ArtifactWhenSpec::Always.includes(None));
633        assert!(ArtifactWhenSpec::Always.includes(Some(ArtifactSourceOutcome::Success)));
634        assert!(ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Success)));
635        assert!(!ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Failed)));
636        assert!(ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Failed)));
637        assert!(!ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Skipped)));
638        assert!(!ArtifactWhenSpec::OnFailure.includes(None));
639    }
640
641    #[test]
642    fn collect_untracked_includes_ignored_workspace_files() {
643        let root = temp_path("artifact-untracked");
644        let workspace = root.join("workspace");
645        fs::create_dir_all(&workspace).expect("create workspace");
646        let repo = git2::Repository::init(&workspace).expect("init repo");
647        fs::write(workspace.join("README.md"), "opal\n").expect("write readme");
648        fs::write(workspace.join(".gitignore"), "tests-temp/\n").expect("write ignore");
649        let mut index = repo.index().expect("open index");
650        index
651            .add_path(Path::new("README.md"))
652            .expect("add readme to index");
653        index
654            .add_path(Path::new(".gitignore"))
655            .expect("add ignore to index");
656        let tree_id = index.write_tree().expect("write tree");
657        let tree = repo.find_tree(tree_id).expect("find tree");
658        let sig = git2::Signature::now("Opal Tests", "opal@example.com").expect("signature");
659        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
660            .expect("commit");
661
662        fs::create_dir_all(workspace.join("tests-temp")).expect("create ignored dir");
663        fs::write(workspace.join("tests-temp/generated.txt"), "hello").expect("write ignored");
664        fs::write(workspace.join("scratch.txt"), "hi").expect("write untracked");
665
666        let manager = ArtifactManager::new(root.join("artifacts"));
667        let job = job(
668            "build",
669            Vec::new(),
670            vec!["tests-temp/**/*.log".into()],
671            true,
672            ArtifactWhenSpec::OnSuccess,
673        );
674
675        manager
676            .prepare_targets(&job)
677            .expect("prepare artifact targets");
678        manager
679            .collect_untracked(&job, &workspace)
680            .expect("collect untracked artifacts");
681
682        let manifest = manager.read_untracked_manifest("build");
683        assert!(manifest.iter().any(|path| path == Path::new("scratch.txt")));
684        assert!(
685            manifest
686                .iter()
687                .any(|path| path == Path::new("tests-temp/generated.txt"))
688        );
689        assert!(
690            manager
691                .job_artifact_host_path("build", Path::new("scratch.txt"))
692                .exists()
693        );
694        assert!(
695            manager
696                .job_artifact_host_path("build", Path::new("tests-temp/generated.txt"))
697                .exists()
698        );
699
700        let _ = fs::remove_dir_all(root);
701    }
702
703    #[test]
704    fn path_is_covered_by_explicit_artifacts_matches_directory_and_file_paths() {
705        assert!(path_is_covered_by_explicit_artifacts(
706            Path::new("tests-temp/build/linux.txt"),
707            &[PathBuf::from("tests-temp/build/")]
708        ));
709        assert!(path_is_covered_by_explicit_artifacts(
710            Path::new("output/report.txt"),
711            &[PathBuf::from("output/report.txt")]
712        ));
713        assert!(!path_is_covered_by_explicit_artifacts(
714            Path::new("other.txt"),
715            &[PathBuf::from("output/report.txt")]
716        ));
717    }
718
719    fn job(
720        name: &str,
721        paths: Vec<PathBuf>,
722        exclude: Vec<String>,
723        untracked: bool,
724        when: ArtifactWhenSpec,
725    ) -> JobSpec {
726        JobSpec {
727            name: name.into(),
728            stage: "build".into(),
729            commands: vec!["echo ok".into()],
730            needs: Vec::new(),
731            explicit_needs: false,
732            dependencies: Vec::new(),
733            before_script: None,
734            after_script: None,
735            inherit_default_before_script: true,
736            inherit_default_after_script: true,
737            inherit_default_image: true,
738            inherit_default_cache: true,
739            inherit_default_services: true,
740            inherit_default_timeout: true,
741            inherit_default_retry: true,
742            inherit_default_interruptible: true,
743            when: None,
744            rules: Vec::new(),
745            only: Vec::new(),
746            except: Vec::new(),
747            artifacts: ArtifactSpec {
748                name: None,
749                paths,
750                exclude,
751                untracked,
752                when,
753                expire_in: None,
754                report_dotenv: None,
755            },
756            cache: Vec::new(),
757            image: None,
758            variables: HashMap::new(),
759            services: Vec::new(),
760            timeout: None,
761            retry: RetryPolicySpec::default(),
762            interruptible: false,
763            resource_group: None,
764            parallel: None,
765            tags: Vec::new(),
766            environment: None,
767        }
768    }
769
770    fn temp_path(prefix: &str) -> PathBuf {
771        let nanos = SystemTime::now()
772            .duration_since(UNIX_EPOCH)
773            .expect("system time before epoch")
774            .as_nanos();
775        std::env::temp_dir().join(format!("opal-{prefix}-{nanos}"))
776    }
777}