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