Skip to main content

anodizer_stage_source/
run.rs

1//! `SourceStage` orchestration: source archive emission and SBOM generation.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context as _, Result};
7
8use anodizer_core::artifact::{Artifact, ArtifactKind};
9use anodizer_core::config::SourceFileEntry;
10use anodizer_core::context::Context;
11use anodizer_core::stage::Stage;
12
13use crate::archive::{SourceArchiveInputs, create_source_archive, get_repo_root};
14
15pub struct SourceStage;
16
17impl Stage for SourceStage {
18    fn name(&self) -> &str {
19        "source"
20    }
21
22    fn run(&self, ctx: &mut Context) -> Result<()> {
23        let log = ctx.logger("source");
24        let source_enabled = ctx
25            .config
26            .source
27            .as_ref()
28            .map(|s| s.is_enabled())
29            .unwrap_or(false);
30
31        if !source_enabled {
32            log.status("skipped source archive — not enabled");
33            return Ok(());
34        }
35
36        let dist = ctx.config.dist.clone();
37        if !ctx.is_dry_run() {
38            std::fs::create_dir_all(&dist).with_context(|| {
39                format!("source: failed to create dist dir: {}", dist.display())
40            })?;
41        }
42
43        self.run_source_archive(ctx, &dist)?;
44
45        Ok(())
46    }
47}
48
49impl SourceStage {
50    fn run_source_archive(&self, ctx: &mut Context, dist: &Path) -> Result<()> {
51        let source_cfg = ctx
52            .config
53            .source
54            .as_ref()
55            .context("source stage invoked without source config (programmer bug)")?;
56        let format = source_cfg.archive_format().to_string();
57
58        // Determine the archive name. Cloned (not borrowed) so the later
59        // `template_vars_mut()` write for `SourcePrefix` does not collide with
60        // a live immutable borrow of `ctx`.
61        let project_name = ctx.config.project_name.clone();
62        let version = ctx
63            .template_vars()
64            .get("Version")
65            .cloned()
66            .unwrap_or_else(|| "unknown".to_string());
67
68        let name = if let Some(ref tpl) = source_cfg.name_template {
69            ctx.render_template(tpl)
70                .with_context(|| format!("source: failed to render name_template '{}'", tpl))?
71        } else {
72            format!("{}-{}", project_name, version)
73        };
74        // The rendered `name` becomes the source-archive filename stem
75        // (`{name}.{format}` written under `dist/`). An empty stem would
76        // produce a hidden file like `dist/.tar.gz`, which `git archive`
77        // happily writes but downstream stages (checksum, sign, release
78        // upload) cannot locate by canonical name. Bail with an actionable
79        // hint instead of silently writing a hidden artifact.
80        if name.is_empty() {
81            anyhow::bail!(
82                "source: rendered source archive name is empty. The configured \
83                 `source.name_template` rendered to '' (or both `project_name` \
84                 and Version were empty when the template fell back to the \
85                 `<project>-<version>` default). An empty name produces a \
86                 hidden output path (`dist/.{}`) that downstream stages \
87                 (checksum, sign, release) cannot resolve. Set \
88                 `source.name_template:` explicitly or verify `project_name` is \
89                 populated in the config.",
90                format,
91            );
92        }
93
94        // Determine the archive prefix (directory name inside the archive).
95        // Defaults to empty (no prefix) when prefix_template is not configured.
96        let prefix = if let Some(ref tpl) = source_cfg.prefix_template {
97            ctx.render_template(tpl)
98                .with_context(|| format!("source: failed to render prefix_template '{}'", tpl))?
99        } else {
100            String::new()
101        };
102
103        // The source archive has a real top-level directory IFF the rendered
104        // prefix ends with `/` — that is the only form `git archive --prefix`
105        // treats as a directory. A slash-less prefix (`foo`) is glued onto
106        // every path (`foomain.rs`), yielding a FLAT archive with no top dir,
107        // exactly like an empty prefix. `SourcePrefix` therefore means "the
108        // archive's top-level directory, or empty when the archive is flat":
109        // the dir name (slash stripped) for a trailing-slash prefix, else
110        // empty. The empty case routes the srpm `%autosetup` to `-c`, which is
111        // correct for both flat shapes. Owned so the `source_cfg` borrow can
112        // end before the `template_vars_mut()` write.
113        let source_prefix = prefix
114            .strip_suffix('/')
115            .map(str::to_string)
116            .unwrap_or_default();
117
118        let log = ctx.logger("source");
119
120        let cwd = ctx
121            .options
122            .project_root
123            .clone()
124            .or_else(|| std::env::current_dir().ok())
125            .unwrap_or_else(|| PathBuf::from("."));
126        let repo_root = get_repo_root(&cwd, &log)?;
127
128        // Render and expand extra-file globs up front, even in dry-run mode,
129        // so users catch template typos and zero-match patterns before the
130        // real run.
131        let mut extra_files: Vec<SourceFileEntry> = Vec::new();
132        for entry in &source_cfg.files {
133            let rendered_src = ctx.render_template(&entry.src).with_context(|| {
134                format!("source: render extra files src template '{}'", entry.src)
135            })?;
136
137            let pattern = if Path::new(&rendered_src).is_absolute() {
138                rendered_src.clone()
139            } else {
140                repo_root.join(&rendered_src).to_string_lossy().into_owned()
141            };
142
143            let expanded_for_entry = match glob::glob(&pattern) {
144                Ok(paths) => {
145                    let expanded: Vec<_> = paths
146                        .filter_map(|p| p.ok())
147                        .filter(|p| p.is_file())
148                        .map(|p| SourceFileEntry {
149                            src: p.to_string_lossy().into_owned(),
150                            dst: entry.dst.clone(),
151                            strip_parent: entry.strip_parent,
152                            info: entry.info.clone(),
153                        })
154                        .collect();
155                    if expanded.is_empty() {
156                        if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
157                            log.warn(&format!("extra file pattern {pattern:?} matched no files"));
158                        }
159                        vec![SourceFileEntry {
160                            src: rendered_src,
161                            dst: entry.dst.clone(),
162                            strip_parent: entry.strip_parent,
163                            info: entry.info.clone(),
164                        }]
165                    } else {
166                        expanded
167                    }
168                }
169                Err(e) => {
170                    log.warn(&format!(
171                        "extra file pattern {pattern:?} is not a valid glob ({e}); \
172                         treating as literal path"
173                    ));
174                    vec![SourceFileEntry {
175                        src: rendered_src,
176                        dst: entry.dst.clone(),
177                        strip_parent: entry.strip_parent,
178                        info: entry.info.clone(),
179                    }]
180                }
181            };
182            extra_files.extend(expanded_for_entry);
183        }
184
185        // Publish the archive's top-level directory so later stages and user
186        // specs can reference `{{ SourcePrefix }}` — notably an srpm
187        // `%autosetup -n`, which must `cd` into the exact dir the tarball
188        // contains. Derived from the RAW `Version`, so it is unaffected by the
189        // srpm stage's scoped sanitized-`Version` override. Set before the
190        // dry-run gate so the var is available even when no file is written.
191        ctx.template_vars_mut().set("SourcePrefix", &source_prefix);
192
193        if ctx.is_dry_run() {
194            log.status(&format!(
195                "(dry-run) would create {}.{} archive",
196                name, format
197            ));
198            return Ok(());
199        }
200
201        log.status(&format!("creating {}.{} archive...", name, format));
202        // The source archive always passes
203        // `ctx.Git.FullCommit` (the resolved SHA) to `git archive`, never the
204        // literal `HEAD` ref. When `git_info` was not pre-populated by the
205        // git pipe (e.g. local `anodizer release --snapshot`), resolve HEAD
206        // ourselves via the allow-listed `core::git::get_head_commit` helper
207        // so the source archive is deterministic across consecutive commits.
208        let resolved_commit: String;
209        let commit: &str = match ctx.git_info.as_ref() {
210            Some(info) if !info.commit.is_empty() => info.commit.as_str(),
211            _ => {
212                resolved_commit = anodizer_core::git::get_head_commit()
213                    .with_context(|| "source: failed to resolve HEAD via `git rev-parse HEAD`")?;
214                resolved_commit.as_str()
215            }
216        };
217        let sde_mtime = ctx
218            .env_var("SOURCE_DATE_EPOCH")
219            .and_then(|s| s.parse::<u64>().ok());
220        let output_path = create_source_archive(&SourceArchiveInputs {
221            dist,
222            format: &format,
223            name: &name,
224            prefix: &prefix,
225            extra_files: &extra_files,
226            repo_root: &repo_root,
227            commit,
228            log: &log,
229            strict: ctx.is_strict(),
230            sde_mtime,
231        })?;
232
233        // The artifact name is the filename (e.g. "foo-1.0.0.tar.gz").
234        let artifact_name = output_path
235            .file_name()
236            .map(|n| n.to_string_lossy().to_string())
237            .unwrap_or_default();
238
239        let mut metadata = HashMap::new();
240        metadata.insert("format".to_string(), format);
241
242        ctx.artifacts.add(Artifact {
243            kind: ArtifactKind::SourceArchive,
244            name: artifact_name,
245            path: output_path,
246            target: None,
247            crate_name: project_name.clone(),
248            metadata,
249            size: None,
250        });
251
252        Ok(())
253    }
254}