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