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 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}