Skip to main content

anodizer_core/config/
release.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{
5    ContentSource, ExtraFileSpec, HumanDuration, StringOrBool, TemplatedExtraFile,
6    deserialize_string_or_bool_opt,
7};
8
9// ---------------------------------------------------------------------------
10// ReleaseConfig
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
14#[serde(default, deny_unknown_fields)]
15pub struct ReleaseConfig {
16    /// GitHub repository to release to (owner and name).
17    pub github: Option<ScmRepoConfig>,
18    /// GitLab repository to release to (owner and name).
19    pub gitlab: Option<ScmRepoConfig>,
20    /// Gitea repository to release to (owner and name).
21    pub gitea: Option<ScmRepoConfig>,
22    /// When true, create the release as a draft (unpublished).
23    pub draft: Option<bool>,
24    #[schemars(schema_with = "prerelease_schema")]
25    /// Mark release as pre-release: true, false, or "auto" (inferred from tag).
26    pub prerelease: Option<PrereleaseConfig>,
27    #[schemars(schema_with = "make_latest_schema")]
28    /// Mark release as latest: true, false, or "auto" (latest non-prerelease).
29    pub make_latest: Option<MakeLatestConfig>,
30    /// Release title template (supports templates).
31    pub name_template: Option<String>,
32    /// Text prepended to the release body (inline string, from_file, or from_url).
33    pub header: Option<ContentSource>,
34    /// Text appended to the release body (inline string, from_file, or from_url).
35    ///
36    /// REPLACES anodizer's default attribution footer rather than stacking
37    /// with it. Set to `""` for no footer at all.
38    pub footer: Option<ContentSource>,
39    /// Append a derived `**Full Changelog**: <compare-url>` line to the
40    /// release body, below the changelog and above the footer. The compare
41    /// range is `<previous tag>...<tag>` on the repository this release
42    /// publishes to. Suppressed automatically when no previous tag exists
43    /// (a first release) or when the changelog body already carries such a
44    /// line (`changelog.use: github-native`). Default: `true`.
45    ///
46    /// ```yaml
47    /// release:
48    ///   full_changelog_link: false   # do not append the derived compare link
49    /// ```
50    pub full_changelog_link: Option<bool>,
51    /// Extra files to upload to the release beyond build artifacts.
52    ///
53    /// Paths / globs are resolved relative to the project root. `..`
54    /// segments are accepted, so an entry
55    /// like `../sibling/dist/*` will reach outside the project tree —
56    /// security-conscious users should keep the entries inside the repo or
57    /// canonicalise them before invoking the release pipeline.
58    pub extra_files: Option<Vec<ExtraFileSpec>>,
59    /// Extra files whose contents are rendered through the template engine before upload.
60    /// Unlike `extra_files` which copy as-is, template variables like `{{ Tag }}` are expanded.
61    ///
62    /// Same path-traversal caveat as `extra_files`: `..` segments reach
63    /// outside the project tree.
64    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
65    /// Skip uploading artifacts: true, false, or "auto" (skip for snapshots).
66    /// Accepts bool or template string.
67    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
68    pub skip_upload: Option<StringOrBool>,
69    /// When true, replace an existing draft release instead of failing.
70    pub replace_existing_draft: Option<bool>,
71    /// When true, replace existing release artifacts with the same name.
72    ///
73    /// A nightly run with `nightly.tag_name` set behaves as if this were
74    /// `true`: the pinned tag is rolling, so every run after the first meets
75    /// the assets its predecessor uploaded onto the same release.
76    pub replace_existing_artifacts: Option<bool>,
77    /// Skip the release stage. Accepts bool or template string
78    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional skip).
79    /// Template strings are supported here.
80    /// Accepts the legacy `disable:` spelling via serde alias for back-compat
81    /// with imported configs (the legacy `disable:` spelling).
82    #[serde(
83        default,
84        alias = "disable",
85        deserialize_with = "deserialize_string_or_bool_opt"
86    )]
87    pub skip: Option<StringOrBool>,
88    /// Release mode: "keep-existing", "append", "prepend", or "replace".
89    pub mode: Option<String>,
90    /// Artifact IDs filter for uploads. Release-wide artifacts (checksums,
91    /// source archive, extra files, metadata) always upload regardless of
92    /// the filter, and derived artifacts (signatures, certificates, SBOMs)
93    /// inherit the verdict of the artifact they derive from — a signature
94    /// uploads iff the artifact it signs uploads.
95    pub ids: Option<Vec<String>>,
96    /// Glob patterns matched against each release asset's file name; anodizer
97    /// drops any asset whose name matches at least one glob before attaching it
98    /// to THIS GitHub release only (a mirror configured elsewhere is
99    /// unaffected). Use it to keep heavy sidecars (checksums, signatures,
100    /// SBOMs) off the GitHub release while archives still attach. Composes with
101    /// `ids:` (both filters apply). `None`/empty keeps everything.
102    ///
103    /// ```yaml
104    /// release:
105    ///   github: { owner: my-org, name: my-repo }
106    ///   exclude: ["*.sha256", "*.sig", "*.cdx.json"]
107    /// ```
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub exclude: Option<Vec<String>>,
110    /// Target branch or SHA for the release tag.
111    pub target_commitish: Option<String>,
112    /// GitHub Discussion category name for the release.
113    pub discussion_category_name: Option<String>,
114    /// Upload `dist/metadata.json` as a release asset. The sibling
115    /// `dist/artifacts.json` manifest is never uploaded — it stays local to
116    /// the dist directory.
117    pub include_meta: Option<bool>,
118    /// Reuse an existing draft release instead of creating a new one.
119    pub use_existing_draft: Option<bool>,
120    /// Override the release tag (template string). When set, this tag is used
121    /// as the `tag_name` in the GitHub release API instead of the crate's
122    /// `tag_template`. Useful in monorepo setups to strip a tag prefix
123    /// (e.g. `"{{ Tag }}"` to publish `v1.0.0` instead of `myapp/v1.0.0`).
124    /// A cross-platform publishing feature provided for free by anodizer.
125    pub tag: Option<String>,
126    /// Maximum number of asset-upload requests in flight simultaneously.
127    /// Applies to asset uploads on every release forge (GitHub, GitLab,
128    /// Gitea).
129    ///
130    /// GitHub's secondary rate-limit is triggered by burst traffic. Keeping
131    /// this value low avoids tripping the limit even for releases with many
132    /// artifacts. Default: 4. Override at runtime with
133    /// `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`.
134    pub upload_concurrency: Option<u32>,
135    /// Minimum interval between successive asset-upload *starts* (a humantime
136    /// string, e.g. `"200ms"`, `"1s"`, `"0s"`). Applies to asset uploads on
137    /// every release forge (GitHub, GitLab, Gitea).
138    ///
139    /// This is a *proactive* pace that smooths the initial burst of upload
140    /// requests, layered on top of [`Self::upload_concurrency`] (the
141    /// concurrency cap) and the reactive secondary-rate-limit backoff. With
142    /// the concurrency cap alone, the first N uploads fire in the same instant
143    /// — exactly the burst pattern that trips GitHub's secondary rate limit.
144    /// Spacing each upload's *start* by this interval (with ±20% jitter so
145    /// concurrent releases don't synchronise) makes the burst far less likely
146    /// to trip the limit in the first place.
147    ///
148    /// Default: `"200ms"` — at the default concurrency of 4 this caps the
149    /// initial start rate at ~5/s, which is below the burst threshold yet adds
150    /// negligible wall-clock to a normal release (upload time is dominated by
151    /// transfer, not start-spacing). Set to `"0s"` to disable pacing entirely
152    /// (rely on the concurrency cap + reactive backoff). Override at runtime
153    /// with `ANODIZER_GITHUB_UPLOAD_PACE_MS` (integer milliseconds; `0`
154    /// disables).
155    pub upload_pace: Option<HumanDuration>,
156    /// Override whether this publisher failing should fail the overall release.
157    ///
158    /// Default: `true` — a failure here aborts the release.
159    /// Set to `false` to log failures but continue.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub required: Option<bool>,
162    /// Explicit publish target — the SCM provider whose `release.<provider>`
163    /// block the publisher uses. When set, overrides the implicit
164    /// token-type fallback chain in
165    /// [`crate::scm::resolve_token_type`].
166    ///
167    /// Use this for **cross-platform publishing**
168    /// pattern: source repo on one provider (e.g. GitLab) but releases
169    /// reach another (e.g. GitHub). Without it, the publish target
170    /// is inferred from which `*_TOKEN` env-var is set — fine for
171    /// single-provider setups but ambiguous when both tokens are
172    /// available.
173    ///
174    /// ```yaml
175    /// release:
176    ///   provider: github
177    ///   github:
178    ///     owner: my-org
179    ///     name: my-app
180    /// ```
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub provider: Option<ForceTokenKind>,
183    /// When `true`, a triggered rollback leaves this publisher's work in
184    /// place rather than attempting to undo it. Default `false`.
185    pub retain_on_rollback: Option<bool>,
186    /// In-process failure policy: what `anodizer release` does after a
187    /// release-pipeline failure. `hold` is the only accepted value, and it
188    /// describes what the pipeline now does unconditionally — leave
189    /// everything in place for forensics. Recovery is a re-run (publishers
190    /// reconcile and self-skip, so an identical command converges on
191    /// already-published state) or, for deliberate withdrawal,
192    /// `anodizer tag rollback`. `rollback` is rejected at config load
193    /// (`validate_on_failure_not_rollback`) — automatic rollback was
194    /// removed. Because the value drives no branch, nothing reads this
195    /// field at runtime; it exists so a config carrying the removed policy
196    /// fails loudly instead of being silently downgraded. Root-level policy
197    /// — in workspace configs (lockstep or per-crate) the top-level
198    /// `release.on_failure` governs the whole run; setting it in a
199    /// crate-level `release:` block is rejected at config load
200    /// (`validate_on_failure_root_only`).
201    pub on_failure: Option<OnFailureConfig>,
202}
203
204impl ReleaseConfig {
205    /// Default release-name template (`"{{Tag}}"`).
206    /// Anodizer uses Tera-style `{{ Tag }}` (no dot prefix); the rendered
207    /// value is identical for any tag the project produces.
208    pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ Tag }}";
209
210    /// Default release `mode` (empty string is treated as
211    /// "keep-existing" — keep current release notes, don't overwrite).
212    pub const DEFAULT_MODE: &'static str = "keep-existing";
213
214    /// Default release-body footer: the anodizer attribution line. Used when
215    /// neither `release.footer` nor `changelog.footer` is set. An explicit
216    /// `release.footer: ""` suppresses it.
217    pub const DEFAULT_FOOTER: &'static str =
218        "Released with [anodizer](https://github.com/tj-smith47/anodizer) 🦀";
219
220    /// Default minimum interval between successive asset-upload starts
221    /// (see [`Self::upload_pace`]). 200 ms smooths the initial burst at the
222    /// default concurrency of 4 without meaningfully slowing a release.
223    pub const DEFAULT_UPLOAD_PACE: std::time::Duration = std::time::Duration::from_millis(200);
224
225    /// Valid `mode:` values. Anything else is a config error.
226    pub const VALID_MODES: &[&'static str] = &["keep-existing", "append", "prepend", "replace"];
227
228    /// Resolve the `name_template`, falling back to
229    /// [`Self::DEFAULT_NAME_TEMPLATE`].
230    pub fn resolved_name_template(&self) -> &str {
231        self.name_template
232            .as_deref()
233            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
234    }
235
236    /// Resolve the release `mode`, validating and falling back to
237    /// [`Self::DEFAULT_MODE`] when unset or empty. Returns an error when
238    /// the user supplied a value outside [`Self::VALID_MODES`] so the
239    /// invalid mode surfaces at the call site instead of producing a
240    /// silent no-op publish.
241    pub fn resolved_mode(&self) -> anyhow::Result<&str> {
242        match self.mode.as_deref() {
243            None | Some("") => Ok(Self::DEFAULT_MODE),
244            Some(m) if Self::VALID_MODES.contains(&m) => Ok(m),
245            Some(other) => Err(anyhow::anyhow!(
246                "release: invalid mode '{}', must be one of: {}",
247                other,
248                Self::VALID_MODES.join(", ")
249            )),
250        }
251    }
252
253    /// Resolve `draft`, falling back to `false`.
254    pub fn resolved_draft(&self) -> bool {
255        self.draft.unwrap_or(false)
256    }
257
258    /// Resolve `replace_existing_draft`, falling back to `false`.
259    pub fn resolved_replace_existing_draft(&self) -> bool {
260        self.replace_existing_draft.unwrap_or(false)
261    }
262
263    /// Resolve `replace_existing_artifacts`, falling back to `false`.
264    pub fn resolved_replace_existing_artifacts(&self) -> bool {
265        self.replace_existing_artifacts.unwrap_or(false)
266    }
267
268    /// Resolve `full_changelog_link`, falling back to `true` — the derived
269    /// compare link is opt-out, not opt-in.
270    pub fn resolved_full_changelog_link(&self) -> bool {
271        self.full_changelog_link.unwrap_or(true)
272    }
273
274    /// Resolve `include_meta`, falling back to `false` (don't upload
275    /// metadata.json as a release asset by default).
276    pub fn resolved_include_meta(&self) -> bool {
277        self.include_meta.unwrap_or(false)
278    }
279
280    /// Resolve `use_existing_draft`, falling back to `false` (always
281    /// create a fresh draft when one isn't found by default).
282    pub fn resolved_use_existing_draft(&self) -> bool {
283        self.use_existing_draft.unwrap_or(false)
284    }
285
286    /// Resolve the upload pace (minimum inter-upload-start interval) from the
287    /// config, applying [`Self::DEFAULT_UPLOAD_PACE`] when unset. A configured
288    /// `"0s"` resolves to `Duration::ZERO`, which the upload loop treats as
289    /// "pacing disabled".
290    ///
291    /// Note: the runtime env override `ANODIZER_GITHUB_UPLOAD_PACE_MS` takes
292    /// precedence and is applied at the call site (it needs the request-scoped
293    /// [`crate::context::Context`]), mirroring how `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`
294    /// overrides [`Self::upload_concurrency`].
295    pub fn resolved_upload_pace(&self) -> std::time::Duration {
296        self.upload_pace
297            .map(|d| d.duration())
298            .unwrap_or(Self::DEFAULT_UPLOAD_PACE)
299    }
300}
301
302/// In-process failure policy for `anodizer release`. See
303/// [`ReleaseConfig::on_failure`].
304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305#[serde(rename_all = "lowercase")]
306pub enum OnFailureConfig {
307    /// Removed: automatic rollback (delete the run's release tags, revert
308    /// the version-bump commit). Rejected at config load
309    /// (`validate_on_failure_not_rollback`) — re-running `anodizer release`
310    /// converges on already-published state, and `anodizer tag rollback`
311    /// handles deliberate withdrawal. Kept as a parseable variant so the
312    /// validator can name it explicitly in the migration error rather than
313    /// failing an opaque deserialize.
314    Rollback,
315    /// Leave everything in place for forensics; exit nonzero with a
316    /// pointer at `anodizer tag rollback`.
317    #[default]
318    Hold,
319}
320
321impl std::fmt::Display for OnFailureConfig {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        match self {
324            OnFailureConfig::Rollback => f.write_str("rollback"),
325            OnFailureConfig::Hold => f.write_str("hold"),
326        }
327    }
328}
329
330/// Schema for prerelease: "auto" or boolean.
331fn prerelease_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
332    auto_or_bool_schema()
333}
334
335/// Schema for make_latest: "auto" or boolean.
336pub(super) fn make_latest_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
337    auto_or_bool_schema()
338}
339
340/// Schema for skip_push: "auto" or boolean.
341pub(super) fn skip_push_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
342    auto_or_bool_schema()
343}
344
345/// A `oneOf` schema accepting the literal string `"auto"` or any boolean — the
346/// shape shared by `prerelease`, `make_latest`, and `skip_push` (each a
347/// tri-state where `"auto"` defers the value to release-time inference).
348fn auto_or_bool_schema() -> schemars::Schema {
349    schemars::json_schema!({
350        "oneOf": [
351            { "type": "string", "enum": ["auto"] },
352            { "type": "boolean" }
353        ]
354    })
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
358#[serde(deny_unknown_fields)]
359pub struct ScmRepoConfig {
360    /// Repository owner (user or organization).
361    pub owner: String,
362    /// Repository name.
363    pub name: String,
364    /// Auth token override for this repository, instead of the default one.
365    /// Useful when the release repository requires a different token than
366    /// the one used to build from — for instance, when publishing to a
367    /// repository in another organization. Supports templates
368    /// (environment variables). Unset falls back to the pipeline token.
369    ///
370    /// ```yaml
371    /// release:
372    ///   github:
373    ///     owner: my-org
374    ///     name: my-repo
375    ///     token: "{{ .Env.RELEASE_GITHUB_TOKEN }}"
376    /// ```
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub token: Option<String>,
379}
380
381/// Backward-compatible alias — existing code can continue to use `GitHubConfig`.
382pub type GitHubConfig = ScmRepoConfig;
383
384// ---------------------------------------------------------------------------
385// ForceTokenKind
386// ---------------------------------------------------------------------------
387
388/// Which SCM token to force for authentication, overriding automatic detection.
389#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
390#[serde(rename_all = "lowercase")]
391pub enum ForceTokenKind {
392    GitHub,
393    GitLab,
394    Gitea,
395}
396
397// ---------------------------------------------------------------------------
398// Platform URL configs (GitHub Enterprise, GitLab self-hosted, Gitea)
399// ---------------------------------------------------------------------------
400
401/// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
402/// GitHub API/download URL overrides.
403#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
404#[serde(default, deny_unknown_fields)]
405pub struct GitHubUrlsConfig {
406    /// GitHub API base URL (e.g. `https://github.example.com/api/v3/`).
407    pub api: Option<String>,
408    /// GitHub upload URL for release assets (e.g. `https://github.example.com/api/uploads/`).
409    pub upload: Option<String>,
410    /// GitHub download URL for release assets (e.g. `https://github.example.com/`).
411    pub download: Option<String>,
412    /// When true, skip TLS certificate verification for the custom URLs.
413    pub skip_tls_verify: Option<bool>,
414}
415
416/// Custom GitLab API/download URLs for self-hosted GitLab installations.
417/// GitLab API/download URL overrides.
418#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
419#[serde(default, deny_unknown_fields)]
420pub struct GitLabUrlsConfig {
421    /// GitLab API base URL (e.g. `https://gitlab.example.com/api/v4/`).
422    pub api: Option<String>,
423    /// GitLab download URL for release assets.
424    pub download: Option<String>,
425    /// When true, skip TLS certificate verification for the custom URLs.
426    pub skip_tls_verify: Option<bool>,
427    /// When true, use the GitLab Package Registry for uploads instead of Generic Packages.
428    pub use_package_registry: Option<bool>,
429    /// When true, use the CI_JOB_TOKEN for authentication instead of a personal token.
430    pub use_job_token: Option<bool>,
431}
432
433/// Custom Gitea API/download URLs for self-hosted Gitea installations.
434/// Gitea API/download URL overrides.
435#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
436#[serde(default, deny_unknown_fields)]
437pub struct GiteaUrlsConfig {
438    /// Gitea instance URL (e.g. `https://gitea.example.com`).
439    ///
440    /// The release backend appends `/api/v1/…` itself; a value that already
441    /// ends in `/api/v1` is accepted and trimmed back to the instance root.
442    pub api: Option<String>,
443    /// Gitea download URL for release assets.
444    pub download: Option<String>,
445    /// When true, skip TLS certificate verification for the custom URLs.
446    pub skip_tls_verify: Option<bool>,
447}
448
449// ---------------------------------------------------------------------------
450// "auto" | bool enum — shared serde implementation
451// ---------------------------------------------------------------------------
452
453/// Generates `Serialize` and `Deserialize` impls for enums with `Auto` and
454/// `Bool(bool)` variants that accept the string `"auto"` or a boolean in YAML.
455macro_rules! impl_auto_or_bool_serde {
456    ($ty:ty, $auto:path, $bool_variant:path) => {
457        impl Serialize for $ty {
458            fn serialize<S: serde::Serializer>(
459                &self,
460                serializer: S,
461            ) -> std::result::Result<S::Ok, S::Error> {
462                match self {
463                    $auto => serializer.serialize_str("auto"),
464                    $bool_variant(b) => serializer.serialize_bool(*b),
465                }
466            }
467        }
468
469        impl<'de> Deserialize<'de> for $ty {
470            fn deserialize<D: serde::Deserializer<'de>>(
471                deserializer: D,
472            ) -> std::result::Result<Self, D::Error> {
473                struct Visitor;
474                impl serde::de::Visitor<'_> for Visitor {
475                    type Value = $ty;
476                    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477                        write!(f, "\"auto\" or a boolean")
478                    }
479                    fn visit_bool<E: serde::de::Error>(
480                        self,
481                        v: bool,
482                    ) -> std::result::Result<$ty, E> {
483                        Ok($bool_variant(v))
484                    }
485                    fn visit_str<E: serde::de::Error>(
486                        self,
487                        v: &str,
488                    ) -> std::result::Result<$ty, E> {
489                        if v == "auto" {
490                            Ok($auto)
491                        } else {
492                            Err(E::custom(format!("expected \"auto\", got \"{}\"", v)))
493                        }
494                    }
495                }
496                deserializer.deserialize_any(Visitor)
497            }
498        }
499    };
500}
501
502/// `prerelease` can be the string `"auto"` or a boolean.
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub enum PrereleaseConfig {
505    Auto,
506    Bool(bool),
507}
508
509impl_auto_or_bool_serde!(
510    PrereleaseConfig,
511    PrereleaseConfig::Auto,
512    PrereleaseConfig::Bool
513);
514
515/// `make_latest` can be the string `"auto"`, a boolean, or a template string.
516/// This field is rendered through the template engine at publish time,
517/// so arbitrary strings are accepted (e.g. `"{{ if .IsSnapshot }}false{{ else }}true{{ end }}"`)
518/// and defer resolution to the release stage.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub enum MakeLatestConfig {
521    Auto,
522    Bool(bool),
523    /// An arbitrary template string to be rendered at publish time.
524    String(String),
525}
526
527impl Serialize for MakeLatestConfig {
528    fn serialize<S: serde::Serializer>(
529        &self,
530        serializer: S,
531    ) -> std::result::Result<S::Ok, S::Error> {
532        match self {
533            MakeLatestConfig::Auto => serializer.serialize_str("auto"),
534            MakeLatestConfig::Bool(b) => serializer.serialize_bool(*b),
535            MakeLatestConfig::String(s) => serializer.serialize_str(s),
536        }
537    }
538}
539
540impl<'de> Deserialize<'de> for MakeLatestConfig {
541    fn deserialize<D: serde::Deserializer<'de>>(
542        deserializer: D,
543    ) -> std::result::Result<Self, D::Error> {
544        struct Visitor;
545        impl serde::de::Visitor<'_> for Visitor {
546            type Value = MakeLatestConfig;
547            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548                write!(f, "\"auto\", a boolean, or a template string")
549            }
550            fn visit_bool<E: serde::de::Error>(
551                self,
552                v: bool,
553            ) -> std::result::Result<MakeLatestConfig, E> {
554                Ok(MakeLatestConfig::Bool(v))
555            }
556            fn visit_str<E: serde::de::Error>(
557                self,
558                v: &str,
559            ) -> std::result::Result<MakeLatestConfig, E> {
560                match v {
561                    "auto" => Ok(MakeLatestConfig::Auto),
562                    "true" => Ok(MakeLatestConfig::Bool(true)),
563                    "false" => Ok(MakeLatestConfig::Bool(false)),
564                    other => Ok(MakeLatestConfig::String(other.to_string())),
565                }
566            }
567        }
568        deserializer.deserialize_any(Visitor)
569    }
570}
571
572/// `skip_push` can be `"auto"` (skip for prereleases), a boolean, or a template string.
573/// Template expressions like `"{{ if .IsSnapshot }}true{{ end }}"` are accepted.
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub enum SkipPushConfig {
576    Auto,
577    Bool(bool),
578    /// Arbitrary template string — rendered at runtime, truthy result means skip push.
579    Template(String),
580}
581
582impl Serialize for SkipPushConfig {
583    fn serialize<S: serde::Serializer>(
584        &self,
585        serializer: S,
586    ) -> std::result::Result<S::Ok, S::Error> {
587        match self {
588            SkipPushConfig::Auto => serializer.serialize_str("auto"),
589            SkipPushConfig::Bool(b) => serializer.serialize_bool(*b),
590            SkipPushConfig::Template(s) => serializer.serialize_str(s),
591        }
592    }
593}
594
595impl<'de> Deserialize<'de> for SkipPushConfig {
596    fn deserialize<D: serde::Deserializer<'de>>(
597        deserializer: D,
598    ) -> std::result::Result<Self, D::Error> {
599        struct Visitor;
600        impl serde::de::Visitor<'_> for Visitor {
601            type Value = SkipPushConfig;
602            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603                write!(f, "\"auto\", a boolean, or a template string")
604            }
605            fn visit_bool<E: serde::de::Error>(
606                self,
607                v: bool,
608            ) -> std::result::Result<SkipPushConfig, E> {
609                Ok(SkipPushConfig::Bool(v))
610            }
611            fn visit_str<E: serde::de::Error>(
612                self,
613                v: &str,
614            ) -> std::result::Result<SkipPushConfig, E> {
615                match v {
616                    "auto" => Ok(SkipPushConfig::Auto),
617                    "true" => Ok(SkipPushConfig::Bool(true)),
618                    "false" => Ok(SkipPushConfig::Bool(false)),
619                    other => Ok(SkipPushConfig::Template(other.to_string())),
620                }
621            }
622        }
623        deserializer.deserialize_any(Visitor)
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    // The three `"auto" | bool | [template]` config enums share a hand-written
632    // serde `Visitor` per type; each arm (string-auto, bool, template fallback,
633    // and the bad-string error) is exercised here through a YAML round-trip.
634
635    #[test]
636    fn prerelease_config_parses_auto_and_bools() {
637        assert_eq!(
638            serde_yaml_ng::from_str::<PrereleaseConfig>("auto").unwrap(),
639            PrereleaseConfig::Auto
640        );
641        assert_eq!(
642            serde_yaml_ng::from_str::<PrereleaseConfig>("true").unwrap(),
643            PrereleaseConfig::Bool(true)
644        );
645        assert_eq!(
646            serde_yaml_ng::from_str::<PrereleaseConfig>("false").unwrap(),
647            PrereleaseConfig::Bool(false)
648        );
649    }
650
651    #[test]
652    fn prerelease_config_rejects_other_strings() {
653        // Unlike the templated siblings, `prerelease` only accepts "auto".
654        assert!(serde_yaml_ng::from_str::<PrereleaseConfig>("maybe").is_err());
655    }
656
657    #[test]
658    fn prerelease_config_round_trips_through_serialize() {
659        for v in [
660            PrereleaseConfig::Auto,
661            PrereleaseConfig::Bool(true),
662            PrereleaseConfig::Bool(false),
663        ] {
664            let yaml = serde_yaml_ng::to_string(&v).unwrap();
665            assert_eq!(
666                serde_yaml_ng::from_str::<PrereleaseConfig>(&yaml).unwrap(),
667                v
668            );
669        }
670    }
671
672    #[test]
673    fn make_latest_config_parses_all_arms() {
674        assert_eq!(
675            serde_yaml_ng::from_str::<MakeLatestConfig>("auto").unwrap(),
676            MakeLatestConfig::Auto
677        );
678        assert_eq!(
679            serde_yaml_ng::from_str::<MakeLatestConfig>("true").unwrap(),
680            MakeLatestConfig::Bool(true)
681        );
682        assert_eq!(
683            serde_yaml_ng::from_str::<MakeLatestConfig>("false").unwrap(),
684            MakeLatestConfig::Bool(false)
685        );
686        // A non-keyword string falls through to a deferred template.
687        assert_eq!(
688            serde_yaml_ng::from_str::<MakeLatestConfig>(
689                "\"{{ if .IsSnapshot }}false{{ else }}true{{ end }}\""
690            )
691            .unwrap(),
692            MakeLatestConfig::String(
693                "{{ if .IsSnapshot }}false{{ else }}true{{ end }}".to_string()
694            )
695        );
696    }
697
698    #[test]
699    fn make_latest_config_round_trips_through_serialize() {
700        for v in [
701            MakeLatestConfig::Auto,
702            MakeLatestConfig::Bool(false),
703            MakeLatestConfig::String("{{ .Env.LATEST }}".to_string()),
704        ] {
705            let yaml = serde_yaml_ng::to_string(&v).unwrap();
706            assert_eq!(
707                serde_yaml_ng::from_str::<MakeLatestConfig>(&yaml).unwrap(),
708                v
709            );
710        }
711    }
712
713    #[test]
714    fn skip_push_config_parses_all_arms() {
715        assert_eq!(
716            serde_yaml_ng::from_str::<SkipPushConfig>("auto").unwrap(),
717            SkipPushConfig::Auto
718        );
719        assert_eq!(
720            serde_yaml_ng::from_str::<SkipPushConfig>("true").unwrap(),
721            SkipPushConfig::Bool(true)
722        );
723        assert_eq!(
724            serde_yaml_ng::from_str::<SkipPushConfig>("false").unwrap(),
725            SkipPushConfig::Bool(false)
726        );
727        assert_eq!(
728            serde_yaml_ng::from_str::<SkipPushConfig>("\"{{ .IsSnapshot }}\"").unwrap(),
729            SkipPushConfig::Template("{{ .IsSnapshot }}".to_string())
730        );
731    }
732
733    #[test]
734    fn skip_push_config_round_trips_through_serialize() {
735        for v in [
736            SkipPushConfig::Auto,
737            SkipPushConfig::Bool(true),
738            SkipPushConfig::Template("{{ .IsSnapshot }}".to_string()),
739        ] {
740            let yaml = serde_yaml_ng::to_string(&v).unwrap();
741            assert_eq!(serde_yaml_ng::from_str::<SkipPushConfig>(&yaml).unwrap(), v);
742        }
743    }
744
745    #[test]
746    fn resolved_upload_pace_defaults_and_honors_override() {
747        // Unset → the 200ms default that smooths the initial upload burst.
748        assert_eq!(
749            ReleaseConfig::default().resolved_upload_pace(),
750            ReleaseConfig::DEFAULT_UPLOAD_PACE
751        );
752        // A configured humantime value is used verbatim.
753        let cfg = ReleaseConfig {
754            upload_pace: Some(HumanDuration(std::time::Duration::from_secs(1))),
755            ..Default::default()
756        };
757        assert_eq!(
758            cfg.resolved_upload_pace(),
759            std::time::Duration::from_secs(1)
760        );
761        // "0s" disables pacing (resolves to ZERO, not the default).
762        let disabled = ReleaseConfig {
763            upload_pace: Some(HumanDuration(std::time::Duration::ZERO)),
764            ..Default::default()
765        };
766        assert_eq!(disabled.resolved_upload_pace(), std::time::Duration::ZERO);
767    }
768
769    #[test]
770    fn on_failure_config_display_and_default() {
771        // `Hold` is the only surviving behavior; `Rollback` still parses
772        // (so config validation can name it in the migration error) but
773        // is no longer the default and is rejected at load time.
774        assert_eq!(OnFailureConfig::default(), OnFailureConfig::Hold);
775        assert_eq!(OnFailureConfig::Rollback.to_string(), "rollback");
776        assert_eq!(OnFailureConfig::Hold.to_string(), "hold");
777        // The lowercase serde form round-trips.
778        assert_eq!(
779            serde_yaml_ng::from_str::<OnFailureConfig>("hold").unwrap(),
780            OnFailureConfig::Hold
781        );
782        assert_eq!(
783            serde_yaml_ng::from_str::<OnFailureConfig>("rollback").unwrap(),
784            OnFailureConfig::Rollback
785        );
786    }
787}