Skip to main content

anodizer_core/config/
release.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{
5    ContentSource, ExtraFileSpec, StringOrBool, TemplatedExtraFile, deserialize_string_or_bool_opt,
6};
7
8// ---------------------------------------------------------------------------
9// ReleaseConfig
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
13#[serde(default)]
14pub struct ReleaseConfig {
15    /// GitHub repository to release to (owner and name).
16    pub github: Option<ScmRepoConfig>,
17    /// GitLab repository to release to (owner and name).
18    pub gitlab: Option<ScmRepoConfig>,
19    /// Gitea repository to release to (owner and name).
20    pub gitea: Option<ScmRepoConfig>,
21    /// When true, create the release as a draft (unpublished).
22    pub draft: Option<bool>,
23    #[schemars(schema_with = "prerelease_schema")]
24    /// Mark release as pre-release: true, false, or "auto" (inferred from tag).
25    pub prerelease: Option<PrereleaseConfig>,
26    #[schemars(schema_with = "make_latest_schema")]
27    /// Mark release as latest: true, false, or "auto" (latest non-prerelease).
28    pub make_latest: Option<MakeLatestConfig>,
29    /// Release title template (supports templates).
30    pub name_template: Option<String>,
31    /// Text prepended to the release body (inline string, from_file, or from_url).
32    pub header: Option<ContentSource>,
33    /// Text appended to the release body (inline string, from_file, or from_url).
34    pub footer: Option<ContentSource>,
35    /// Extra files to upload to the release beyond build artifacts.
36    pub extra_files: Option<Vec<ExtraFileSpec>>,
37    /// Extra files whose contents are rendered through the template engine before upload.
38    /// Unlike `extra_files` which copy as-is, template variables like `{{ .Tag }}` are expanded.
39    /// GoReleaser Pro feature.
40    pub templated_extra_files: Option<Vec<TemplatedExtraFile>>,
41    /// Skip uploading artifacts: true, false, or "auto" (skip for snapshots).
42    /// Accepts bool or template string (GoReleaser uses string type).
43    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
44    pub skip_upload: Option<StringOrBool>,
45    /// When true, replace an existing draft release instead of failing.
46    pub replace_existing_draft: Option<bool>,
47    /// When true, replace existing release artifacts with the same name.
48    pub replace_existing_artifacts: Option<bool>,
49    /// Skip the release stage. Accepts bool or template string
50    /// (e.g. `"{{ if IsSnapshot }}true{{ endif }}"` for conditional skip).
51    /// GoReleaser supports template strings here since v1.15.0.
52    /// Accepts the legacy `disable:` spelling via serde alias for back-compat
53    /// with imported GoReleaser configs (GR's release config field is
54    /// `pkg/config/config.go:909` `Disable string`).
55    #[serde(
56        default,
57        alias = "disable",
58        deserialize_with = "deserialize_string_or_bool_opt"
59    )]
60    pub skip: Option<StringOrBool>,
61    /// Release mode: "keep-existing", "append", "prepend", or "replace".
62    pub mode: Option<String>,
63    /// Artifact IDs filter for uploads.
64    pub ids: Option<Vec<String>>,
65    /// Target branch or SHA for the release tag.
66    pub target_commitish: Option<String>,
67    /// GitHub Discussion category name for the release.
68    pub discussion_category_name: Option<String>,
69    /// Upload metadata.json and artifacts.json as release assets.
70    pub include_meta: Option<bool>,
71    /// Reuse an existing draft release instead of creating a new one.
72    pub use_existing_draft: Option<bool>,
73    /// Override the release tag (template string). When set, this tag is used
74    /// as the `tag_name` in the GitHub release API instead of the crate's
75    /// `tag_template`. Useful in monorepo setups to strip a tag prefix
76    /// (e.g. `"{{ .Tag }}"` to publish `v1.0.0` instead of `myapp/v1.0.0`).
77    /// This is a GoReleaser Pro feature provided for free by anodizer.
78    pub tag: Option<String>,
79}
80
81impl ReleaseConfig {
82    /// Default release-name template. Mirrors GoReleaser
83    /// `internal/pipe/release/release.go` (`cfg.NameTemplate = "{{.Tag}}"`).
84    /// Anodize uses Tera-style `{{ Tag }}` (no dot prefix); the rendered
85    /// value is identical for any tag the project produces.
86    pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ Tag }}";
87
88    /// Default release `mode`. Mirrors GoReleaser default
89    /// (`internal/pipe/release/release.go`: empty string is treated as
90    /// "keep-existing" — keep current release notes, don't overwrite).
91    pub const DEFAULT_MODE: &'static str = "keep-existing";
92
93    /// Valid `mode:` values. Anything else is a config error.
94    pub const VALID_MODES: &[&'static str] = &["keep-existing", "append", "prepend", "replace"];
95
96    /// Resolve the `name_template`, falling back to
97    /// [`Self::DEFAULT_NAME_TEMPLATE`].
98    pub fn resolved_name_template(&self) -> &str {
99        self.name_template
100            .as_deref()
101            .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
102    }
103
104    /// Resolve the release `mode`, validating and falling back to
105    /// [`Self::DEFAULT_MODE`] when unset or empty. Returns an error when
106    /// the user supplied a value outside [`Self::VALID_MODES`] so the
107    /// invalid mode surfaces at the call site instead of producing a
108    /// silent no-op publish.
109    pub fn resolved_mode(&self) -> anyhow::Result<&str> {
110        match self.mode.as_deref() {
111            None | Some("") => Ok(Self::DEFAULT_MODE),
112            Some(m) if Self::VALID_MODES.contains(&m) => Ok(m),
113            Some(other) => Err(anyhow::anyhow!(
114                "release: invalid mode '{}', must be one of: {}",
115                other,
116                Self::VALID_MODES.join(", ")
117            )),
118        }
119    }
120
121    /// Resolve `draft`, falling back to `false`.
122    pub fn resolved_draft(&self) -> bool {
123        self.draft.unwrap_or(false)
124    }
125
126    /// Resolve `replace_existing_draft`, falling back to `false`.
127    pub fn resolved_replace_existing_draft(&self) -> bool {
128        self.replace_existing_draft.unwrap_or(false)
129    }
130
131    /// Resolve `replace_existing_artifacts`, falling back to `false`.
132    pub fn resolved_replace_existing_artifacts(&self) -> bool {
133        self.replace_existing_artifacts.unwrap_or(false)
134    }
135
136    /// Resolve `include_meta`, falling back to `false` (don't upload
137    /// metadata.json / artifacts.json as release assets by default).
138    pub fn resolved_include_meta(&self) -> bool {
139        self.include_meta.unwrap_or(false)
140    }
141
142    /// Resolve `use_existing_draft`, falling back to `false` (always
143    /// create a fresh draft when one isn't found by default).
144    pub fn resolved_use_existing_draft(&self) -> bool {
145        self.use_existing_draft.unwrap_or(false)
146    }
147}
148
149/// Schema for prerelease: "auto" or boolean.
150fn prerelease_schema(
151    _generator: &mut schemars::r#gen::SchemaGenerator,
152) -> schemars::schema::Schema {
153    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
154    Schema::Object(SchemaObject {
155        subschemas: Some(Box::new(SubschemaValidation {
156            one_of: Some(vec![
157                Schema::Object(SchemaObject {
158                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
159                    enum_values: Some(vec![serde_json::json!("auto")]),
160                    ..Default::default()
161                }),
162                Schema::Object(SchemaObject {
163                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
164                    ..Default::default()
165                }),
166            ]),
167            ..Default::default()
168        })),
169        ..Default::default()
170    })
171}
172
173/// Schema for make_latest: "auto" or boolean.
174fn make_latest_schema(
175    _generator: &mut schemars::r#gen::SchemaGenerator,
176) -> schemars::schema::Schema {
177    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
178    Schema::Object(SchemaObject {
179        subschemas: Some(Box::new(SubschemaValidation {
180            one_of: Some(vec![
181                Schema::Object(SchemaObject {
182                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
183                    enum_values: Some(vec![serde_json::json!("auto")]),
184                    ..Default::default()
185                }),
186                Schema::Object(SchemaObject {
187                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
188                    ..Default::default()
189                }),
190            ]),
191            ..Default::default()
192        })),
193        ..Default::default()
194    })
195}
196
197/// Schema for skip_push: "auto" or boolean.
198pub(super) fn skip_push_schema(
199    _generator: &mut schemars::r#gen::SchemaGenerator,
200) -> schemars::schema::Schema {
201    use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
202    Schema::Object(SchemaObject {
203        subschemas: Some(Box::new(SubschemaValidation {
204            one_of: Some(vec![
205                Schema::Object(SchemaObject {
206                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
207                    enum_values: Some(vec![serde_json::json!("auto")]),
208                    ..Default::default()
209                }),
210                Schema::Object(SchemaObject {
211                    instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
212                    ..Default::default()
213                }),
214            ]),
215            ..Default::default()
216        })),
217        ..Default::default()
218    })
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
222pub struct ScmRepoConfig {
223    /// Repository owner (user or organization).
224    pub owner: String,
225    /// Repository name.
226    pub name: String,
227}
228
229/// Backward-compatible alias — existing code can continue to use `GitHubConfig`.
230pub type GitHubConfig = ScmRepoConfig;
231
232// ---------------------------------------------------------------------------
233// ForceTokenKind
234// ---------------------------------------------------------------------------
235
236/// Which SCM token to force for authentication, overriding automatic detection.
237#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
238#[serde(rename_all = "lowercase")]
239pub enum ForceTokenKind {
240    GitHub,
241    GitLab,
242    Gitea,
243}
244
245// ---------------------------------------------------------------------------
246// Platform URL configs (GitHub Enterprise, GitLab self-hosted, Gitea)
247// ---------------------------------------------------------------------------
248
249/// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
250/// Matches GoReleaser's `GitHubURLs` struct.
251#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
252#[serde(default, deny_unknown_fields)]
253pub struct GitHubUrlsConfig {
254    /// GitHub API base URL (e.g. `https://github.example.com/api/v3/`).
255    pub api: Option<String>,
256    /// GitHub upload URL for release assets (e.g. `https://github.example.com/api/uploads/`).
257    pub upload: Option<String>,
258    /// GitHub download URL for release assets (e.g. `https://github.example.com/`).
259    pub download: Option<String>,
260    /// When true, skip TLS certificate verification for the custom URLs.
261    pub skip_tls_verify: Option<bool>,
262}
263
264/// Custom GitLab API/download URLs for self-hosted GitLab installations.
265/// Matches GoReleaser's `GitLabURLs` struct.
266#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
267#[serde(default, deny_unknown_fields)]
268pub struct GitLabUrlsConfig {
269    /// GitLab API base URL (e.g. `https://gitlab.example.com/api/v4/`).
270    pub api: Option<String>,
271    /// GitLab download URL for release assets.
272    pub download: Option<String>,
273    /// When true, skip TLS certificate verification for the custom URLs.
274    pub skip_tls_verify: Option<bool>,
275    /// When true, use the GitLab Package Registry for uploads instead of Generic Packages.
276    pub use_package_registry: Option<bool>,
277    /// When true, use the CI_JOB_TOKEN for authentication instead of a personal token.
278    pub use_job_token: Option<bool>,
279}
280
281/// Custom Gitea API/download URLs for self-hosted Gitea installations.
282/// Matches GoReleaser's `GiteaURLs` struct.
283#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
284#[serde(default, deny_unknown_fields)]
285pub struct GiteaUrlsConfig {
286    /// Gitea API base URL (e.g. `https://gitea.example.com/api/v1/`).
287    pub api: Option<String>,
288    /// Gitea download URL for release assets.
289    pub download: Option<String>,
290    /// When true, skip TLS certificate verification for the custom URLs.
291    pub skip_tls_verify: Option<bool>,
292}
293
294// ---------------------------------------------------------------------------
295// "auto" | bool enum — shared serde implementation
296// ---------------------------------------------------------------------------
297
298/// Generates `Serialize` and `Deserialize` impls for enums with `Auto` and
299/// `Bool(bool)` variants that accept the string `"auto"` or a boolean in YAML.
300macro_rules! impl_auto_or_bool_serde {
301    ($ty:ty, $auto:path, $bool_variant:path) => {
302        impl Serialize for $ty {
303            fn serialize<S: serde::Serializer>(
304                &self,
305                serializer: S,
306            ) -> std::result::Result<S::Ok, S::Error> {
307                match self {
308                    $auto => serializer.serialize_str("auto"),
309                    $bool_variant(b) => serializer.serialize_bool(*b),
310                }
311            }
312        }
313
314        impl<'de> Deserialize<'de> for $ty {
315            fn deserialize<D: serde::Deserializer<'de>>(
316                deserializer: D,
317            ) -> std::result::Result<Self, D::Error> {
318                struct Visitor;
319                impl serde::de::Visitor<'_> for Visitor {
320                    type Value = $ty;
321                    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322                        write!(f, "\"auto\" or a boolean")
323                    }
324                    fn visit_bool<E: serde::de::Error>(
325                        self,
326                        v: bool,
327                    ) -> std::result::Result<$ty, E> {
328                        Ok($bool_variant(v))
329                    }
330                    fn visit_str<E: serde::de::Error>(
331                        self,
332                        v: &str,
333                    ) -> std::result::Result<$ty, E> {
334                        if v == "auto" {
335                            Ok($auto)
336                        } else {
337                            Err(E::custom(format!("expected \"auto\", got \"{}\"", v)))
338                        }
339                    }
340                }
341                deserializer.deserialize_any(Visitor)
342            }
343        }
344    };
345}
346
347/// `prerelease` can be the string `"auto"` or a boolean.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub enum PrereleaseConfig {
350    Auto,
351    Bool(bool),
352}
353
354impl_auto_or_bool_serde!(
355    PrereleaseConfig,
356    PrereleaseConfig::Auto,
357    PrereleaseConfig::Bool
358);
359
360/// `make_latest` can be the string `"auto"`, a boolean, or a template string.
361/// GoReleaser renders this field through its template engine at publish time,
362/// so we accept arbitrary strings (e.g. `"{{ if .IsSnapshot }}false{{ else }}true{{ end }}"`)
363/// and defer resolution to the release stage.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum MakeLatestConfig {
366    Auto,
367    Bool(bool),
368    /// An arbitrary template string to be rendered at publish time.
369    String(String),
370}
371
372impl Serialize for MakeLatestConfig {
373    fn serialize<S: serde::Serializer>(
374        &self,
375        serializer: S,
376    ) -> std::result::Result<S::Ok, S::Error> {
377        match self {
378            MakeLatestConfig::Auto => serializer.serialize_str("auto"),
379            MakeLatestConfig::Bool(b) => serializer.serialize_bool(*b),
380            MakeLatestConfig::String(s) => serializer.serialize_str(s),
381        }
382    }
383}
384
385impl<'de> Deserialize<'de> for MakeLatestConfig {
386    fn deserialize<D: serde::Deserializer<'de>>(
387        deserializer: D,
388    ) -> std::result::Result<Self, D::Error> {
389        struct Visitor;
390        impl serde::de::Visitor<'_> for Visitor {
391            type Value = MakeLatestConfig;
392            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393                write!(f, "\"auto\", a boolean, or a template string")
394            }
395            fn visit_bool<E: serde::de::Error>(
396                self,
397                v: bool,
398            ) -> std::result::Result<MakeLatestConfig, E> {
399                Ok(MakeLatestConfig::Bool(v))
400            }
401            fn visit_str<E: serde::de::Error>(
402                self,
403                v: &str,
404            ) -> std::result::Result<MakeLatestConfig, E> {
405                match v {
406                    "auto" => Ok(MakeLatestConfig::Auto),
407                    "true" => Ok(MakeLatestConfig::Bool(true)),
408                    "false" => Ok(MakeLatestConfig::Bool(false)),
409                    other => Ok(MakeLatestConfig::String(other.to_string())),
410                }
411            }
412        }
413        deserializer.deserialize_any(Visitor)
414    }
415}
416
417/// `skip_push` can be `"auto"` (skip for prereleases), a boolean, or a template string.
418/// GoReleaser accepts template expressions like `"{{ if .IsSnapshot }}true{{ end }}"`.
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub enum SkipPushConfig {
421    Auto,
422    Bool(bool),
423    /// Arbitrary template string — rendered at runtime, truthy result means skip push.
424    Template(String),
425}
426
427impl Serialize for SkipPushConfig {
428    fn serialize<S: serde::Serializer>(
429        &self,
430        serializer: S,
431    ) -> std::result::Result<S::Ok, S::Error> {
432        match self {
433            SkipPushConfig::Auto => serializer.serialize_str("auto"),
434            SkipPushConfig::Bool(b) => serializer.serialize_bool(*b),
435            SkipPushConfig::Template(s) => serializer.serialize_str(s),
436        }
437    }
438}
439
440impl<'de> Deserialize<'de> for SkipPushConfig {
441    fn deserialize<D: serde::Deserializer<'de>>(
442        deserializer: D,
443    ) -> std::result::Result<Self, D::Error> {
444        struct Visitor;
445        impl serde::de::Visitor<'_> for Visitor {
446            type Value = SkipPushConfig;
447            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448                write!(f, "\"auto\", a boolean, or a template string")
449            }
450            fn visit_bool<E: serde::de::Error>(
451                self,
452                v: bool,
453            ) -> std::result::Result<SkipPushConfig, E> {
454                Ok(SkipPushConfig::Bool(v))
455            }
456            fn visit_str<E: serde::de::Error>(
457                self,
458                v: &str,
459            ) -> std::result::Result<SkipPushConfig, E> {
460                match v {
461                    "auto" => Ok(SkipPushConfig::Auto),
462                    "true" => Ok(SkipPushConfig::Bool(true)),
463                    "false" => Ok(SkipPushConfig::Bool(false)),
464                    other => Ok(SkipPushConfig::Template(other.to_string())),
465                }
466            }
467        }
468        deserializer.deserialize_any(Visitor)
469    }
470}