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