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}