Skip to main content

anodizer_core/config/
snapshot_nightly.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{CommitAuthorConfig, ContentSource};
5
6// ---------------------------------------------------------------------------
7// SnapshotConfig
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct SnapshotConfig {
13    /// Version string template for snapshot builds (e.g., "{{ Commit }}-SNAPSHOT").
14    /// Accepts the deprecated `name_template:` alias (renamed to
15    /// `version_template`): a non-empty `name_template` is folded into
16    /// `version_template`.
17    /// A deprecation warning is emitted at config-load time when the alias
18    /// is hit (see `apply_snapshot_legacy_aliases`).
19    #[serde(alias = "name_template")]
20    pub version_template: String,
21}
22
23// ---------------------------------------------------------------------------
24// NightlyConfig
25// ---------------------------------------------------------------------------
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
28#[serde(default, deny_unknown_fields)]
29pub struct NightlyConfig {
30    /// Template for the rendered version string the nightly run sets on
31    /// `Version` / `RawVersion`. Default:
32    /// `"{{ incpatch(v=Version) }}-{{ ShortCommit }}-nightly"` — produces
33    /// commit-immutable nightly versions (two same-day commits yield two
34    /// distinct nightly versions).
35    ///
36    /// The `{{ NightlyBuild }}` template var (a stateless per-base-version
37    /// build counter derived from `git rev-list --count <last-tag>..HEAD`)
38    /// enables nushell-style schemes such as
39    /// `"{{ Base }}-nightly.{{ NightlyBuild }}+{{ ShortCommit }}"`.
40    pub version_template: Option<String>,
41    /// Template for the release name. Default: `"{{ ProjectName }}-nightly"`.
42    pub name_template: Option<String>,
43    /// Tag name used for the nightly release. Default: `"nightly"`.
44    /// Templates allowed.
45    pub tag_name: Option<String>,
46    /// Whether to publish a GitHub Release at all. Default: `true`.
47    /// Set `false` for nightly-only docker pushes / blob uploads.
48    pub publish_release: Option<bool>,
49    /// Publish the nightly release to a DIFFERENT repository than the source
50    /// repo, in `"owner/repo"` form (e.g. `"nushell/nightly"`). Default
51    /// (`None`) publishes to the configured `release.github` repo, unchanged.
52    ///
53    /// When set, the nightly release create, asset upload, AND retention
54    /// (`keep_single_release` / `retention.keep_last`) delete calls all
55    /// target this repo. The active SCM token is assumed to have write
56    /// access to `publish_repo`. GitHub-only (the nushell adoption target).
57    pub publish_repo: Option<String>,
58    /// Delete the prior release that points at the same tag before
59    /// creating the new one. Default: `false`. Set `true` to maintain a
60    /// single rolling nightly release on GitHub.
61    ///
62    /// Back-compat alias for `retention: { keep_last: 1 }`. When both
63    /// `keep_single_release` and `retention` are set, `retention` wins.
64    /// Destructive: deletes a published release via the GitHub Releases API.
65    /// GitHub-only.
66    pub keep_single_release: Option<bool>,
67    /// Retention policy for nightly releases on GitHub. Generalizes
68    /// `keep_single_release` (which is `keep_last: 1`): keeps the N newest
69    /// nightly releases matching the nightly tag/name and deletes the rest
70    /// (releases + the tags anodizer created for them). Operates on
71    /// `publish_repo` when set. Default (`None`): no retention sweep.
72    pub retention: Option<RetentionConfig>,
73    /// Override `release.draft` for nightly runs only.
74    /// `None` falls through to `release.draft`; `Some(v)` overrides it.
75    pub draft: Option<bool>,
76}
77
78/// Retention policy for nightly releases on the publish repo.
79///
80/// `keep_last: N` keeps the N newest nightly releases (matched by the
81/// nightly tag/name) and deletes the older ones, including the git tags
82/// anodizer created for them.
83#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
84#[serde(default, deny_unknown_fields)]
85pub struct RetentionConfig {
86    /// Number of newest nightly releases to keep. `0` is treated as `1`
87    /// (never delete every nightly), matching the `keep_single_release`
88    /// floor. nushell keeps 10.
89    pub keep_last: usize,
90}
91
92impl NightlyConfig {
93    /// Resolve the effective `keep_last` retention count, or `None` when no
94    /// retention is requested.
95    ///
96    /// Precedence: an explicit `retention:` block wins over the legacy
97    /// `keep_single_release:` alias. `keep_single_release: true` (with no
98    /// `retention`) maps to `keep_last: 1`; `false` maps to `None`. A
99    /// `retention.keep_last: 0` is floored to `1` so a retention sweep
100    /// never deletes the just-created release.
101    pub fn resolved_keep_last(&self) -> Option<usize> {
102        if let Some(r) = self.retention.as_ref() {
103            return Some(r.keep_last.max(1));
104        }
105        if self.keep_single_release == Some(true) {
106            return Some(1);
107        }
108        None
109    }
110}
111
112/// Validate `nightly.publish_repo` is `"owner/repo"` shaped.
113///
114/// Returns `Ok(())` when unset or well-formed. A malformed value (missing
115/// `/`, empty owner/repo, extra path segments, or whitespace) is a
116/// config-time error rather than a confusing 404 at publish time.
117pub fn validate_nightly_publish_repo(config: &crate::config::Config) -> Result<(), String> {
118    let Some(nightly) = config.nightly.as_ref() else {
119        return Ok(());
120    };
121    let Some(repo) = nightly.publish_repo.as_deref() else {
122        return Ok(());
123    };
124    let parts: Vec<&str> = repo.split('/').collect();
125    let well_formed = parts.len() == 2
126        && parts
127            .iter()
128            .all(|p| !p.trim().is_empty() && !p.contains(char::is_whitespace));
129    if well_formed {
130        Ok(())
131    } else {
132        Err(format!(
133            "nightly.publish_repo must be in \"owner/repo\" form (got {repo:?})"
134        ))
135    }
136}
137
138// ---------------------------------------------------------------------------
139// MetadataConfig
140// ---------------------------------------------------------------------------
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
143#[serde(default, deny_unknown_fields)]
144pub struct MetadataConfig {
145    /// Human-readable project description (exposed as `{{ Metadata.Description }}`).
146    pub description: Option<String>,
147    /// Project homepage URL (exposed as `{{ Metadata.Homepage }}`).
148    pub homepage: Option<String>,
149    /// Project license identifier, e.g. "MIT" or "Apache-2.0" (exposed as `{{ Metadata.License }}`).
150    pub license: Option<String>,
151    /// List of project maintainers (exposed as `{{ Metadata.Maintainers }}`).
152    pub maintainers: Option<Vec<String>>,
153    /// Global modification timestamp for metadata output files (metadata.json and artifacts.json).
154    /// Template string (e.g. "{{ CommitTimestamp }}") or unix timestamp.
155    /// When set, rendered late in the pipeline and applied as file mtime.
156    /// Exposed as `{{ Metadata.ModTimestamp }}`.
157    pub mod_timestamp: Option<String>,
158    /// Long-form project description. Supports inline
159    /// string, `from_file`, or `from_url`. Exposed as `{{ Metadata.FullDescription }}`.
160    /// FromUrl is resolved lazily (requires the release stage); FromFile is resolved
161    /// at context-populate time with template-rendered path.
162    pub full_description: Option<ContentSource>,
163    /// Commit author identity for commit workflows.
164    /// Reuses the shared `CommitAuthorConfig` (name + email + optional signing).
165    /// Exposed as `{{ Metadata.CommitAuthor.Name }}` / `{{ Metadata.CommitAuthor.Email }}`.
166    pub commit_author: Option<CommitAuthorConfig>,
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::config::Config;
173
174    #[test]
175    fn resolved_keep_last_maps_keep_single_release_alias() {
176        let n = NightlyConfig {
177            keep_single_release: Some(true),
178            ..Default::default()
179        };
180        assert_eq!(n.resolved_keep_last(), Some(1));
181    }
182
183    #[test]
184    fn resolved_keep_last_none_when_neither_set() {
185        let n = NightlyConfig::default();
186        assert_eq!(n.resolved_keep_last(), None);
187        let n = NightlyConfig {
188            keep_single_release: Some(false),
189            ..Default::default()
190        };
191        assert_eq!(n.resolved_keep_last(), None);
192    }
193
194    #[test]
195    fn resolved_keep_last_retention_wins_over_alias() {
196        // Both set: retention.keep_last wins over the legacy alias.
197        let n = NightlyConfig {
198            keep_single_release: Some(true),
199            retention: Some(RetentionConfig { keep_last: 10 }),
200            ..Default::default()
201        };
202        assert_eq!(n.resolved_keep_last(), Some(10));
203    }
204
205    #[test]
206    fn resolved_keep_last_floors_zero_to_one() {
207        let n = NightlyConfig {
208            retention: Some(RetentionConfig { keep_last: 0 }),
209            ..Default::default()
210        };
211        assert_eq!(n.resolved_keep_last(), Some(1));
212    }
213
214    #[test]
215    fn keep_single_release_yaml_alias_round_trips() {
216        // Back-compat: an existing keep_single_release config still parses
217        // and resolves to keep_last: 1.
218        let n: NightlyConfig = serde_yaml_ng::from_str("keep_single_release: true").unwrap();
219        assert_eq!(n.resolved_keep_last(), Some(1));
220    }
221
222    fn config_with_publish_repo(repo: Option<&str>) -> Config {
223        Config {
224            nightly: Some(NightlyConfig {
225                publish_repo: repo.map(str::to_string),
226                ..Default::default()
227            }),
228            ..Default::default()
229        }
230    }
231
232    #[test]
233    fn validate_publish_repo_accepts_owner_repo() {
234        let c = config_with_publish_repo(Some("nushell/nightly"));
235        assert!(validate_nightly_publish_repo(&c).is_ok());
236    }
237
238    #[test]
239    fn validate_publish_repo_ok_when_unset() {
240        assert!(validate_nightly_publish_repo(&config_with_publish_repo(None)).is_ok());
241        assert!(validate_nightly_publish_repo(&Config::default()).is_ok());
242    }
243
244    #[test]
245    fn validate_publish_repo_rejects_malformed() {
246        for bad in ["nushell", "a/b/c", "/nightly", "nushell/", "owner repo/x"] {
247            let c = config_with_publish_repo(Some(bad));
248            assert!(
249                validate_nightly_publish_repo(&c).is_err(),
250                "expected {bad:?} to be rejected"
251            );
252        }
253    }
254}