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    /// Target branch or SHA for the release tag.
78    pub target_commitish: Option<String>,
79    /// GitHub Discussion category name for the release.
80    pub discussion_category_name: Option<String>,
81    /// Upload metadata.json and artifacts.json as release assets.
82    pub include_meta: Option<bool>,
83    /// Reuse an existing draft release instead of creating a new one.
84    pub use_existing_draft: Option<bool>,
85    /// Override the release tag (template string). When set, this tag is used
86    /// as the `tag_name` in the GitHub release API instead of the crate's
87    /// `tag_template`. Useful in monorepo setups to strip a tag prefix
88    /// (e.g. `"{{ Tag }}"` to publish `v1.0.0` instead of `myapp/v1.0.0`).
89    /// A cross-platform publishing feature provided for free by anodizer.
90    pub tag: Option<String>,
91    /// Maximum number of asset-upload requests in flight simultaneously.
92    ///
93    /// GitHub's secondary rate-limit is triggered by burst traffic. Keeping
94    /// this value low avoids tripping the limit even for releases with many
95    /// artifacts. Default: 4. Override at runtime with
96    /// `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`.
97    pub upload_concurrency: Option<u32>,
98    /// Minimum interval between successive asset-upload *starts* (a humantime
99    /// string, e.g. `"200ms"`, `"1s"`, `"0s"`).
100    ///
101    /// This is a *proactive* pace that smooths the initial burst of upload
102    /// requests, layered on top of [`Self::upload_concurrency`] (the
103    /// concurrency cap) and the reactive secondary-rate-limit backoff. With
104    /// the concurrency cap alone, the first N uploads fire in the same instant
105    /// — exactly the burst pattern that trips GitHub's secondary rate limit.
106    /// Spacing each upload's *start* by this interval (with ±20% jitter so
107    /// concurrent releases don't synchronise) makes the burst far less likely
108    /// to trip the limit in the first place.
109    ///
110    /// Default: `"200ms"` — at the default concurrency of 4 this caps the
111    /// initial start rate at ~5/s, which is below the burst threshold yet adds
112    /// negligible wall-clock to a normal release (upload time is dominated by
113    /// transfer, not start-spacing). Set to `"0s"` to disable pacing entirely
114    /// (rely on the concurrency cap + reactive backoff). Override at runtime
115    /// with `ANODIZER_GITHUB_UPLOAD_PACE_MS` (integer milliseconds; `0`
116    /// disables).
117    pub upload_pace: Option<HumanDuration>,
118    /// Override whether this publisher failing should fail the overall release.
119    ///
120    /// Default: `true` — a failure here aborts the release.
121    /// Set to `false` to log failures but continue.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub required: Option<bool>,
124    /// Explicit publish target — the SCM provider whose `release.<provider>`
125    /// block the publisher uses. When set, overrides the implicit
126    /// token-type fallback chain in
127    /// [`crate::scm::resolve_token_type`].
128    ///
129    /// Use this for **cross-platform publishing**
130    /// pattern: source repo on one provider (e.g. GitLab) but releases
131    /// land on another (e.g. GitHub). Without it, the publish target
132    /// is inferred from which `*_TOKEN` env-var is set — fine for
133    /// single-provider setups but ambiguous when both tokens are
134    /// available.
135    ///
136    /// ```yaml
137    /// release:
138    ///   provider: github
139    ///   github:
140    ///     owner: my-org
141    ///     name: my-app
142    /// ```
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub provider: Option<ForceTokenKind>,
145    /// When `true`, a triggered rollback leaves this publisher's work in
146    /// place rather than attempting to undo it. Default `false`.
147    pub retain_on_rollback: Option<bool>,
148    /// In-process failure policy: what `anodizer release` does after a
149    /// release-pipeline failure. `rollback` (default) deletes the run's
150    /// release tag(s) and reverts the version-bump commit so the same
151    /// version can be re-cut; `hold` leaves everything in place for
152    /// forensics and manual recovery (`release --rollback-only
153    /// --from-run=<id>`). `rollback` automatically degrades to `hold`
154    /// the moment any one-way-door (Submitter) publisher has landed:
155    /// the version is burned at a registry that never accepts it twice,
156    /// so destructive rollback is refused and fix-forward is the only
157    /// path. Root-level policy — in workspace configs (lockstep or
158    /// per-crate) the top-level `release.on_failure` governs the whole
159    /// run; setting it in a crate-level `release:` block is rejected at
160    /// config load (`validate_on_failure_root_only`).
161    pub on_failure: Option<OnFailureConfig>,
162}
163
164impl ReleaseConfig {
165    /// Default release-name template (`"{{Tag}}"`).
166    /// Anodize uses Tera-style `{{ Tag }}` (no dot prefix); the rendered
167    /// value is identical for any tag the project produces.
168    pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ Tag }}";
169
170    /// Default release `mode` (empty string is treated as
171    /// "keep-existing" — keep current release notes, don't overwrite).
172    pub const DEFAULT_MODE: &'static str = "keep-existing";
173
174    /// Default minimum interval between successive asset-upload starts
175    /// (see [`Self::upload_pace`]). 200 ms smooths the initial burst at the
176    /// default concurrency of 4 without meaningfully slowing a release.
177    pub const DEFAULT_UPLOAD_PACE: std::time::Duration = std::time::Duration::from_millis(200);
178
179    /// Valid `mode:` values. Anything else is a config error.
180    pub const VALID_MODES: &[&'static str] = &["keep-existing", "append", "prepend", "replace"];
181
182    /// Resolve the `name_template`, falling back to
183    /// [`Self::DEFAULT_NAME_TEMPLATE`].
184    pub fn resolved_name_template(&self) -> &str {
185        self.name_template
186            .as_deref()
187            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
188    }
189
190    /// Resolve the release `mode`, validating and falling back to
191    /// [`Self::DEFAULT_MODE`] when unset or empty. Returns an error when
192    /// the user supplied a value outside [`Self::VALID_MODES`] so the
193    /// invalid mode surfaces at the call site instead of producing a
194    /// silent no-op publish.
195    pub fn resolved_mode(&self) -> anyhow::Result<&str> {
196        match self.mode.as_deref() {
197            None | Some("") => Ok(Self::DEFAULT_MODE),
198            Some(m) if Self::VALID_MODES.contains(&m) => Ok(m),
199            Some(other) => Err(anyhow::anyhow!(
200                "release: invalid mode '{}', must be one of: {}",
201                other,
202                Self::VALID_MODES.join(", ")
203            )),
204        }
205    }
206
207    /// Resolve `draft`, falling back to `false`.
208    pub fn resolved_draft(&self) -> bool {
209        self.draft.unwrap_or(false)
210    }
211
212    /// Resolve `replace_existing_draft`, falling back to `false`.
213    pub fn resolved_replace_existing_draft(&self) -> bool {
214        self.replace_existing_draft.unwrap_or(false)
215    }
216
217    /// Resolve `replace_existing_artifacts`, falling back to `false`.
218    pub fn resolved_replace_existing_artifacts(&self) -> bool {
219        self.replace_existing_artifacts.unwrap_or(false)
220    }
221
222    /// Resolve `include_meta`, falling back to `false` (don't upload
223    /// metadata.json / artifacts.json as release assets by default).
224    pub fn resolved_include_meta(&self) -> bool {
225        self.include_meta.unwrap_or(false)
226    }
227
228    /// Resolve `use_existing_draft`, falling back to `false` (always
229    /// create a fresh draft when one isn't found by default).
230    pub fn resolved_use_existing_draft(&self) -> bool {
231        self.use_existing_draft.unwrap_or(false)
232    }
233
234    /// Resolve `on_failure`, falling back to
235    /// [`OnFailureConfig::Rollback`].
236    pub fn resolved_on_failure(&self) -> OnFailureConfig {
237        self.on_failure.unwrap_or_default()
238    }
239
240    /// Resolve the upload pace (minimum inter-upload-start interval) from the
241    /// config, applying [`Self::DEFAULT_UPLOAD_PACE`] when unset. A configured
242    /// `"0s"` resolves to `Duration::ZERO`, which the upload loop treats as
243    /// "pacing disabled".
244    ///
245    /// Note: the runtime env override `ANODIZER_GITHUB_UPLOAD_PACE_MS` takes
246    /// precedence and is applied at the call site (it needs the request-scoped
247    /// [`crate::Context`]), mirroring how `ANODIZER_GITHUB_UPLOAD_CONCURRENCY`
248    /// overrides [`Self::upload_concurrency`].
249    pub fn resolved_upload_pace(&self) -> std::time::Duration {
250        self.upload_pace
251            .map(|d| d.duration())
252            .unwrap_or(Self::DEFAULT_UPLOAD_PACE)
253    }
254}
255
256/// In-process failure policy for `anodizer release`. See
257/// [`ReleaseConfig::on_failure`].
258#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "lowercase")]
260pub enum OnFailureConfig {
261    /// Roll back reversible state (delete the run's release tags, revert
262    /// the version-bump commit) so the version can be re-cut. Degrades to
263    /// `Hold` when any one-way-door publisher already landed.
264    #[default]
265    Rollback,
266    /// Leave everything in place for forensics; exit nonzero with a
267    /// pointer at `release --rollback-only --from-run=<id>`.
268    Hold,
269}
270
271impl std::fmt::Display for OnFailureConfig {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        match self {
274            OnFailureConfig::Rollback => f.write_str("rollback"),
275            OnFailureConfig::Hold => f.write_str("hold"),
276        }
277    }
278}
279
280/// Schema for prerelease: "auto" or boolean.
281fn prerelease_schema(
282    _generator: &mut schemars::r#gen::SchemaGenerator,
283) -> schemars::schema::Schema {
284    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
285    Schema::Object(SchemaObject {
286        subschemas: Some(Box::new(SubschemaValidation {
287            one_of: Some(vec![
288                Schema::Object(SchemaObject {
289                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
290                    enum_values: Some(vec![serde_json::json!("auto")]),
291                    ..Default::default()
292                }),
293                Schema::Object(SchemaObject {
294                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
295                    ..Default::default()
296                }),
297            ]),
298            ..Default::default()
299        })),
300        ..Default::default()
301    })
302}
303
304/// Schema for make_latest: "auto" or boolean.
305fn make_latest_schema(
306    _generator: &mut schemars::r#gen::SchemaGenerator,
307) -> schemars::schema::Schema {
308    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
309    Schema::Object(SchemaObject {
310        subschemas: Some(Box::new(SubschemaValidation {
311            one_of: Some(vec![
312                Schema::Object(SchemaObject {
313                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
314                    enum_values: Some(vec![serde_json::json!("auto")]),
315                    ..Default::default()
316                }),
317                Schema::Object(SchemaObject {
318                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
319                    ..Default::default()
320                }),
321            ]),
322            ..Default::default()
323        })),
324        ..Default::default()
325    })
326}
327
328/// Schema for skip_push: "auto" or boolean.
329pub(super) fn skip_push_schema(
330    _generator: &mut schemars::r#gen::SchemaGenerator,
331) -> schemars::schema::Schema {
332    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
333    Schema::Object(SchemaObject {
334        subschemas: Some(Box::new(SubschemaValidation {
335            one_of: Some(vec![
336                Schema::Object(SchemaObject {
337                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
338                    enum_values: Some(vec![serde_json::json!("auto")]),
339                    ..Default::default()
340                }),
341                Schema::Object(SchemaObject {
342                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
343                    ..Default::default()
344                }),
345            ]),
346            ..Default::default()
347        })),
348        ..Default::default()
349    })
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
353#[serde(deny_unknown_fields)]
354pub struct ScmRepoConfig {
355    /// Repository owner (user or organization).
356    pub owner: String,
357    /// Repository name.
358    pub name: String,
359}
360
361/// Backward-compatible alias — existing code can continue to use `GitHubConfig`.
362pub type GitHubConfig = ScmRepoConfig;
363
364// ---------------------------------------------------------------------------
365// ForceTokenKind
366// ---------------------------------------------------------------------------
367
368/// Which SCM token to force for authentication, overriding automatic detection.
369#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
370#[serde(rename_all = "lowercase")]
371pub enum ForceTokenKind {
372    GitHub,
373    GitLab,
374    Gitea,
375}
376
377// ---------------------------------------------------------------------------
378// Platform URL configs (GitHub Enterprise, GitLab self-hosted, Gitea)
379// ---------------------------------------------------------------------------
380
381/// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
382/// GitHub API/download URL overrides.
383#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
384#[serde(default, deny_unknown_fields)]
385pub struct GitHubUrlsConfig {
386    /// GitHub API base URL (e.g. `https://github.example.com/api/v3/`).
387    pub api: Option<String>,
388    /// GitHub upload URL for release assets (e.g. `https://github.example.com/api/uploads/`).
389    pub upload: Option<String>,
390    /// GitHub download URL for release assets (e.g. `https://github.example.com/`).
391    pub download: Option<String>,
392    /// When true, skip TLS certificate verification for the custom URLs.
393    pub skip_tls_verify: Option<bool>,
394}
395
396/// Custom GitLab API/download URLs for self-hosted GitLab installations.
397/// GitLab API/download URL overrides.
398#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
399#[serde(default, deny_unknown_fields)]
400pub struct GitLabUrlsConfig {
401    /// GitLab API base URL (e.g. `https://gitlab.example.com/api/v4/`).
402    pub api: Option<String>,
403    /// GitLab download URL for release assets.
404    pub download: Option<String>,
405    /// When true, skip TLS certificate verification for the custom URLs.
406    pub skip_tls_verify: Option<bool>,
407    /// When true, use the GitLab Package Registry for uploads instead of Generic Packages.
408    pub use_package_registry: Option<bool>,
409    /// When true, use the CI_JOB_TOKEN for authentication instead of a personal token.
410    pub use_job_token: Option<bool>,
411}
412
413/// Custom Gitea API/download URLs for self-hosted Gitea installations.
414/// Gitea API/download URL overrides.
415#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
416#[serde(default, deny_unknown_fields)]
417pub struct GiteaUrlsConfig {
418    /// Gitea API base URL (e.g. `https://gitea.example.com/api/v1/`).
419    pub api: Option<String>,
420    /// Gitea download URL for release assets.
421    pub download: Option<String>,
422    /// When true, skip TLS certificate verification for the custom URLs.
423    pub skip_tls_verify: Option<bool>,
424}
425
426// ---------------------------------------------------------------------------
427// "auto" | bool enum — shared serde implementation
428// ---------------------------------------------------------------------------
429
430/// Generates `Serialize` and `Deserialize` impls for enums with `Auto` and
431/// `Bool(bool)` variants that accept the string `"auto"` or a boolean in YAML.
432macro_rules! impl_auto_or_bool_serde {
433    ($ty:ty, $auto:path, $bool_variant:path) => {
434        impl Serialize for $ty {
435            fn serialize<S: serde::Serializer>(
436                &self,
437                serializer: S,
438            ) -> std::result::Result<S::Ok, S::Error> {
439                match self {
440                    $auto => serializer.serialize_str("auto"),
441                    $bool_variant(b) => serializer.serialize_bool(*b),
442                }
443            }
444        }
445
446        impl<'de> Deserialize<'de> for $ty {
447            fn deserialize<D: serde::Deserializer<'de>>(
448                deserializer: D,
449            ) -> std::result::Result<Self, D::Error> {
450                struct Visitor;
451                impl serde::de::Visitor<'_> for Visitor {
452                    type Value = $ty;
453                    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454                        write!(f, "\"auto\" or a boolean")
455                    }
456                    fn visit_bool<E: serde::de::Error>(
457                        self,
458                        v: bool,
459                    ) -> std::result::Result<$ty, E> {
460                        Ok($bool_variant(v))
461                    }
462                    fn visit_str<E: serde::de::Error>(
463                        self,
464                        v: &str,
465                    ) -> std::result::Result<$ty, E> {
466                        if v == "auto" {
467                            Ok($auto)
468                        } else {
469                            Err(E::custom(format!("expected \"auto\", got \"{}\"", v)))
470                        }
471                    }
472                }
473                deserializer.deserialize_any(Visitor)
474            }
475        }
476    };
477}
478
479/// `prerelease` can be the string `"auto"` or a boolean.
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub enum PrereleaseConfig {
482    Auto,
483    Bool(bool),
484}
485
486impl_auto_or_bool_serde!(
487    PrereleaseConfig,
488    PrereleaseConfig::Auto,
489    PrereleaseConfig::Bool
490);
491
492/// `make_latest` can be the string `"auto"`, a boolean, or a template string.
493/// This field is rendered through the template engine at publish time,
494/// so we accept arbitrary strings (e.g. `"{{ if .IsSnapshot }}false{{ else }}true{{ end }}"`)
495/// and defer resolution to the release stage.
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub enum MakeLatestConfig {
498    Auto,
499    Bool(bool),
500    /// An arbitrary template string to be rendered at publish time.
501    String(String),
502}
503
504impl Serialize for MakeLatestConfig {
505    fn serialize<S: serde::Serializer>(
506        &self,
507        serializer: S,
508    ) -> std::result::Result<S::Ok, S::Error> {
509        match self {
510            MakeLatestConfig::Auto => serializer.serialize_str("auto"),
511            MakeLatestConfig::Bool(b) => serializer.serialize_bool(*b),
512            MakeLatestConfig::String(s) => serializer.serialize_str(s),
513        }
514    }
515}
516
517impl<'de> Deserialize<'de> for MakeLatestConfig {
518    fn deserialize<D: serde::Deserializer<'de>>(
519        deserializer: D,
520    ) -> std::result::Result<Self, D::Error> {
521        struct Visitor;
522        impl serde::de::Visitor<'_> for Visitor {
523            type Value = MakeLatestConfig;
524            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
525                write!(f, "\"auto\", a boolean, or a template string")
526            }
527            fn visit_bool<E: serde::de::Error>(
528                self,
529                v: bool,
530            ) -> std::result::Result<MakeLatestConfig, E> {
531                Ok(MakeLatestConfig::Bool(v))
532            }
533            fn visit_str<E: serde::de::Error>(
534                self,
535                v: &str,
536            ) -> std::result::Result<MakeLatestConfig, E> {
537                match v {
538                    "auto" => Ok(MakeLatestConfig::Auto),
539                    "true" => Ok(MakeLatestConfig::Bool(true)),
540                    "false" => Ok(MakeLatestConfig::Bool(false)),
541                    other => Ok(MakeLatestConfig::String(other.to_string())),
542                }
543            }
544        }
545        deserializer.deserialize_any(Visitor)
546    }
547}
548
549/// `skip_push` can be `"auto"` (skip for prereleases), a boolean, or a template string.
550/// Template expressions like `"{{ if .IsSnapshot }}true{{ end }}"` are accepted.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub enum SkipPushConfig {
553    Auto,
554    Bool(bool),
555    /// Arbitrary template string — rendered at runtime, truthy result means skip push.
556    Template(String),
557}
558
559impl Serialize for SkipPushConfig {
560    fn serialize<S: serde::Serializer>(
561        &self,
562        serializer: S,
563    ) -> std::result::Result<S::Ok, S::Error> {
564        match self {
565            SkipPushConfig::Auto => serializer.serialize_str("auto"),
566            SkipPushConfig::Bool(b) => serializer.serialize_bool(*b),
567            SkipPushConfig::Template(s) => serializer.serialize_str(s),
568        }
569    }
570}
571
572impl<'de> Deserialize<'de> for SkipPushConfig {
573    fn deserialize<D: serde::Deserializer<'de>>(
574        deserializer: D,
575    ) -> std::result::Result<Self, D::Error> {
576        struct Visitor;
577        impl serde::de::Visitor<'_> for Visitor {
578            type Value = SkipPushConfig;
579            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580                write!(f, "\"auto\", a boolean, or a template string")
581            }
582            fn visit_bool<E: serde::de::Error>(
583                self,
584                v: bool,
585            ) -> std::result::Result<SkipPushConfig, E> {
586                Ok(SkipPushConfig::Bool(v))
587            }
588            fn visit_str<E: serde::de::Error>(
589                self,
590                v: &str,
591            ) -> std::result::Result<SkipPushConfig, E> {
592                match v {
593                    "auto" => Ok(SkipPushConfig::Auto),
594                    "true" => Ok(SkipPushConfig::Bool(true)),
595                    "false" => Ok(SkipPushConfig::Bool(false)),
596                    other => Ok(SkipPushConfig::Template(other.to_string())),
597                }
598            }
599        }
600        deserializer.deserialize_any(Visitor)
601    }
602}