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