anodizer_core/config/snapshot_nightly.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{CommitAuthorConfig, ContentSource, MakeLatestConfig};
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 /// Mark the nightly release as a prerelease. Default: `true`.
77 ///
78 /// Unlike [`Self::draft`], `None` does NOT fall through to
79 /// `release.prerelease` — that field resolves to `false` for a nightly
80 /// (an unset config is `false`, and `prerelease: auto` cannot help
81 /// because the default `nightly` tag is not a parseable semver tag), so
82 /// falling through would publish every nightly as a stable release.
83 /// Set `false` to opt out.
84 pub prerelease: Option<bool>,
85 /// Override `release.make_latest` for nightly runs only. Default:
86 /// `false` — a rolling nightly must not displace the newest stable
87 /// release in the GitHub UI, in `releases/latest`, or for the install
88 /// scripts and `gh release download` invocations that resolve it.
89 ///
90 /// `None` does not fall through to `release.make_latest` for the same
91 /// reason as [`Self::prerelease`]: unset there means GitHub's own
92 /// default, which IS latest.
93 #[schemars(schema_with = "super::release::make_latest_schema")]
94 pub make_latest: Option<MakeLatestConfig>,
95}
96
97/// Retention policy for nightly releases on the publish repo.
98///
99/// `keep_last: N` keeps the N newest nightly releases (matched by the
100/// nightly tag/name) and deletes the older ones, including the git tags
101/// anodizer created for them.
102#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
103#[serde(default, deny_unknown_fields)]
104pub struct RetentionConfig {
105 /// Number of newest nightly releases to keep. `0` is treated as `1`
106 /// (never delete every nightly), matching the `keep_single_release`
107 /// floor. nushell keeps 10.
108 pub keep_last: usize,
109}
110
111impl NightlyConfig {
112 /// Resolve the effective `keep_last` retention count, or `None` when no
113 /// retention is requested.
114 ///
115 /// Precedence: an explicit `retention:` block wins over the legacy
116 /// `keep_single_release:` alias. `keep_single_release: true` (with no
117 /// `retention`) maps to `keep_last: 1`; `false` maps to `None`. A
118 /// `retention.keep_last: 0` is floored to `1` so a retention sweep
119 /// never deletes the just-created release.
120 pub fn resolved_keep_last(&self) -> Option<usize> {
121 if let Some(r) = self.retention.as_ref() {
122 return Some(r.keep_last.max(1));
123 }
124 if self.keep_single_release == Some(true) {
125 return Some(1);
126 }
127 None
128 }
129}
130
131/// Validate `nightly.publish_repo` is `"owner/repo"` shaped.
132///
133/// Returns `Ok(())` when unset or well-formed. A malformed value (missing
134/// `/`, empty owner/repo, extra path segments, or whitespace) is a
135/// config-time error rather than a confusing 404 at publish time.
136pub fn validate_nightly_publish_repo(config: &crate::config::Config) -> Result<(), String> {
137 let Some(nightly) = config.nightly.as_ref() else {
138 return Ok(());
139 };
140 let Some(repo) = nightly.publish_repo.as_deref() else {
141 return Ok(());
142 };
143 let parts: Vec<&str> = repo.split('/').collect();
144 let well_formed = parts.len() == 2
145 && parts
146 .iter()
147 .all(|p| !p.trim().is_empty() && !p.contains(char::is_whitespace));
148 if well_formed {
149 Ok(())
150 } else {
151 Err(format!(
152 "nightly.publish_repo must be in \"owner/repo\" form (got {repo:?})"
153 ))
154 }
155}
156
157// ---------------------------------------------------------------------------
158// MetadataConfig
159// ---------------------------------------------------------------------------
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
162#[serde(default, deny_unknown_fields)]
163pub struct MetadataConfig {
164 /// Human-readable project description (exposed as `{{ Metadata.Description }}`).
165 pub description: Option<String>,
166 /// Project homepage URL (exposed as `{{ Metadata.Homepage }}`).
167 pub homepage: Option<String>,
168 /// Project documentation URL, e.g. a docs.rs or hosted-docs link
169 /// (exposed as `{{ Metadata.Documentation }}`). Derived from
170 /// `Cargo.toml [package].documentation` when unset.
171 pub documentation: Option<String>,
172 /// Project license identifier, e.g. "MIT" or "Apache-2.0" (exposed as `{{ Metadata.License }}`).
173 pub license: Option<String>,
174 /// Project source-repository URL, e.g. a GitHub URL (exposed as
175 /// `{{ Metadata.Repository }}`). Derived from `Cargo.toml [package].repository`
176 /// when unset; feeds the npm `package.json` `repository` field, which npm
177 /// provenance validates against the OIDC-claimed repository.
178 // Omit-when-None so adding this field leaves `dist/config.yaml` byte-identical
179 // for configs that don't set it — a republish/backfill of an older tag's
180 // preserved dist re-renders config.yaml and hash-verifies it against the
181 // determinism shards, which a new always-emitted `repository: null` line would
182 // break. The sibling fields predate this invariant and stay as-is.
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub repository: Option<String>,
185 /// List of project maintainers (exposed as `{{ Metadata.Maintainers }}`).
186 pub maintainers: Option<Vec<String>>,
187 /// Global modification timestamp for metadata output files (metadata.json and artifacts.json).
188 /// Template string (e.g. "{{ CommitTimestamp }}") or unix timestamp.
189 /// When set, rendered late in the pipeline and applied as file mtime.
190 /// Exposed as `{{ Metadata.ModTimestamp }}`.
191 pub mod_timestamp: Option<String>,
192 /// Long-form project description. Supports inline
193 /// string, `from_file`, or `from_url`. Exposed as `{{ Metadata.FullDescription }}`.
194 /// FromUrl is resolved lazily (requires the release stage); FromFile is resolved
195 /// at context-populate time with template-rendered path.
196 pub full_description: Option<ContentSource>,
197 /// Commit author identity for commit workflows.
198 /// Reuses the shared `CommitAuthorConfig` (name + email + optional signing).
199 /// Exposed as `{{ Metadata.CommitAuthor.Name }}` / `{{ Metadata.CommitAuthor.Email }}`.
200 pub commit_author: Option<CommitAuthorConfig>,
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::config::Config;
207
208 #[test]
209 fn resolved_keep_last_maps_keep_single_release_alias() {
210 let n = NightlyConfig {
211 keep_single_release: Some(true),
212 ..Default::default()
213 };
214 assert_eq!(n.resolved_keep_last(), Some(1));
215 }
216
217 #[test]
218 fn resolved_keep_last_none_when_neither_set() {
219 let n = NightlyConfig::default();
220 assert_eq!(n.resolved_keep_last(), None);
221 let n = NightlyConfig {
222 keep_single_release: Some(false),
223 ..Default::default()
224 };
225 assert_eq!(n.resolved_keep_last(), None);
226 }
227
228 #[test]
229 fn resolved_keep_last_retention_wins_over_alias() {
230 // Both set: retention.keep_last wins over the legacy alias.
231 let n = NightlyConfig {
232 keep_single_release: Some(true),
233 retention: Some(RetentionConfig { keep_last: 10 }),
234 ..Default::default()
235 };
236 assert_eq!(n.resolved_keep_last(), Some(10));
237 }
238
239 #[test]
240 fn resolved_keep_last_floors_zero_to_one() {
241 let n = NightlyConfig {
242 retention: Some(RetentionConfig { keep_last: 0 }),
243 ..Default::default()
244 };
245 assert_eq!(n.resolved_keep_last(), Some(1));
246 }
247
248 #[test]
249 fn keep_single_release_yaml_alias_round_trips() {
250 // Back-compat: an existing keep_single_release config still parses
251 // and resolves to keep_last: 1.
252 let n: NightlyConfig = serde_yaml_ng::from_str("keep_single_release: true").unwrap();
253 assert_eq!(n.resolved_keep_last(), Some(1));
254 }
255
256 fn config_with_publish_repo(repo: Option<&str>) -> Config {
257 Config {
258 nightly: Some(NightlyConfig {
259 publish_repo: repo.map(str::to_string),
260 ..Default::default()
261 }),
262 ..Default::default()
263 }
264 }
265
266 #[test]
267 fn validate_publish_repo_accepts_owner_repo() {
268 let c = config_with_publish_repo(Some("nushell/nightly"));
269 assert!(validate_nightly_publish_repo(&c).is_ok());
270 }
271
272 #[test]
273 fn validate_publish_repo_ok_when_unset() {
274 assert!(validate_nightly_publish_repo(&config_with_publish_repo(None)).is_ok());
275 assert!(validate_nightly_publish_repo(&Config::default()).is_ok());
276 }
277
278 #[test]
279 fn validate_publish_repo_rejects_malformed() {
280 for bad in ["nushell", "a/b/c", "/nightly", "nushell/", "owner repo/x"] {
281 let c = config_with_publish_repo(Some(bad));
282 assert!(
283 validate_nightly_publish_repo(&c).is_err(),
284 "expected {bad:?} to be rejected"
285 );
286 }
287 }
288
289 #[test]
290 fn metadata_repository_omitted_when_none_keeps_config_yaml_byte_stable() {
291 // A metadata block that does NOT set repository must serialize WITHOUT a
292 // `repository` key, so introducing the field leaves dist/config.yaml
293 // byte-identical and a backfill's hash-verify against older preserved
294 // determinism shards still passes.
295 let m = MetadataConfig {
296 homepage: Some("https://example.com".into()),
297 ..Default::default()
298 };
299 let yaml = serde_yaml_ng::to_string(&m).expect("serialize");
300 assert!(
301 !yaml.contains("repository"),
302 "unset repository must be omitted, got:\n{yaml}"
303 );
304 // When set, it is emitted (npm provenance / template var still work).
305 let m2 = MetadataConfig {
306 repository: Some("https://github.com/x/y".into()),
307 ..Default::default()
308 };
309 assert!(
310 serde_yaml_ng::to_string(&m2)
311 .expect("serialize")
312 .contains("repository: https://github.com/x/y"),
313 );
314 }
315}