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}
121
122impl ReleaseConfig {
123 /// Default release-name template (`"{{Tag}}"`).
124 /// Anodize uses Tera-style `{{ Tag }}` (no dot prefix); the rendered
125 /// value is identical for any tag the project produces.
126 pub const DEFAULT_NAME_TEMPLATE: &'static str = "{{ Tag }}";
127
128 /// Default release `mode` (empty string is treated as
129 /// "keep-existing" — keep current release notes, don't overwrite).
130 pub const DEFAULT_MODE: &'static str = "keep-existing";
131
132 /// Valid `mode:` values. Anything else is a config error.
133 pub const VALID_MODES: &[&'static str] = &["keep-existing", "append", "prepend", "replace"];
134
135 /// Resolve the `name_template`, falling back to
136 /// [`Self::DEFAULT_NAME_TEMPLATE`].
137 pub fn resolved_name_template(&self) -> &str {
138 self.name_template
139 .as_deref()
140 .unwrap_or(Self::DEFAULT_NAME_TEMPLATE)
141 }
142
143 /// Resolve the release `mode`, validating and falling back to
144 /// [`Self::DEFAULT_MODE`] when unset or empty. Returns an error when
145 /// the user supplied a value outside [`Self::VALID_MODES`] so the
146 /// invalid mode surfaces at the call site instead of producing a
147 /// silent no-op publish.
148 pub fn resolved_mode(&self) -> anyhow::Result<&str> {
149 match self.mode.as_deref() {
150 None | Some("") => Ok(Self::DEFAULT_MODE),
151 Some(m) if Self::VALID_MODES.contains(&m) => Ok(m),
152 Some(other) => Err(anyhow::anyhow!(
153 "release: invalid mode '{}', must be one of: {}",
154 other,
155 Self::VALID_MODES.join(", ")
156 )),
157 }
158 }
159
160 /// Resolve `draft`, falling back to `false`.
161 pub fn resolved_draft(&self) -> bool {
162 self.draft.unwrap_or(false)
163 }
164
165 /// Resolve `replace_existing_draft`, falling back to `false`.
166 pub fn resolved_replace_existing_draft(&self) -> bool {
167 self.replace_existing_draft.unwrap_or(false)
168 }
169
170 /// Resolve `replace_existing_artifacts`, falling back to `false`.
171 pub fn resolved_replace_existing_artifacts(&self) -> bool {
172 self.replace_existing_artifacts.unwrap_or(false)
173 }
174
175 /// Resolve `include_meta`, falling back to `false` (don't upload
176 /// metadata.json / artifacts.json as release assets by default).
177 pub fn resolved_include_meta(&self) -> bool {
178 self.include_meta.unwrap_or(false)
179 }
180
181 /// Resolve `use_existing_draft`, falling back to `false` (always
182 /// create a fresh draft when one isn't found by default).
183 pub fn resolved_use_existing_draft(&self) -> bool {
184 self.use_existing_draft.unwrap_or(false)
185 }
186}
187
188/// Schema for prerelease: "auto" or boolean.
189fn prerelease_schema(
190 _generator: &mut schemars::r#gen::SchemaGenerator,
191) -> schemars::schema::Schema {
192 use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
193 Schema::Object(SchemaObject {
194 subschemas: Some(Box::new(SubschemaValidation {
195 one_of: Some(vec![
196 Schema::Object(SchemaObject {
197 instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
198 enum_values: Some(vec![serde_json::json!("auto")]),
199 ..Default::default()
200 }),
201 Schema::Object(SchemaObject {
202 instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
203 ..Default::default()
204 }),
205 ]),
206 ..Default::default()
207 })),
208 ..Default::default()
209 })
210}
211
212/// Schema for make_latest: "auto" or boolean.
213fn make_latest_schema(
214 _generator: &mut schemars::r#gen::SchemaGenerator,
215) -> schemars::schema::Schema {
216 use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
217 Schema::Object(SchemaObject {
218 subschemas: Some(Box::new(SubschemaValidation {
219 one_of: Some(vec![
220 Schema::Object(SchemaObject {
221 instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
222 enum_values: Some(vec![serde_json::json!("auto")]),
223 ..Default::default()
224 }),
225 Schema::Object(SchemaObject {
226 instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
227 ..Default::default()
228 }),
229 ]),
230 ..Default::default()
231 })),
232 ..Default::default()
233 })
234}
235
236/// Schema for skip_push: "auto" or boolean.
237pub(super) fn skip_push_schema(
238 _generator: &mut schemars::r#gen::SchemaGenerator,
239) -> schemars::schema::Schema {
240 use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation};
241 Schema::Object(SchemaObject {
242 subschemas: Some(Box::new(SubschemaValidation {
243 one_of: Some(vec![
244 Schema::Object(SchemaObject {
245 instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
246 enum_values: Some(vec![serde_json::json!("auto")]),
247 ..Default::default()
248 }),
249 Schema::Object(SchemaObject {
250 instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Boolean))),
251 ..Default::default()
252 }),
253 ]),
254 ..Default::default()
255 })),
256 ..Default::default()
257 })
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
261#[serde(deny_unknown_fields)]
262pub struct ScmRepoConfig {
263 /// Repository owner (user or organization).
264 pub owner: String,
265 /// Repository name.
266 pub name: String,
267}
268
269/// Backward-compatible alias — existing code can continue to use `GitHubConfig`.
270pub type GitHubConfig = ScmRepoConfig;
271
272// ---------------------------------------------------------------------------
273// ForceTokenKind
274// ---------------------------------------------------------------------------
275
276/// Which SCM token to force for authentication, overriding automatic detection.
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
278#[serde(rename_all = "lowercase")]
279pub enum ForceTokenKind {
280 GitHub,
281 GitLab,
282 Gitea,
283}
284
285// ---------------------------------------------------------------------------
286// Platform URL configs (GitHub Enterprise, GitLab self-hosted, Gitea)
287// ---------------------------------------------------------------------------
288
289/// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
290/// GitHub API/download URL overrides.
291#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
292#[serde(default, deny_unknown_fields)]
293pub struct GitHubUrlsConfig {
294 /// GitHub API base URL (e.g. `https://github.example.com/api/v3/`).
295 pub api: Option<String>,
296 /// GitHub upload URL for release assets (e.g. `https://github.example.com/api/uploads/`).
297 pub upload: Option<String>,
298 /// GitHub download URL for release assets (e.g. `https://github.example.com/`).
299 pub download: Option<String>,
300 /// When true, skip TLS certificate verification for the custom URLs.
301 pub skip_tls_verify: Option<bool>,
302}
303
304/// Custom GitLab API/download URLs for self-hosted GitLab installations.
305/// GitLab API/download URL overrides.
306#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
307#[serde(default, deny_unknown_fields)]
308pub struct GitLabUrlsConfig {
309 /// GitLab API base URL (e.g. `https://gitlab.example.com/api/v4/`).
310 pub api: Option<String>,
311 /// GitLab download URL for release assets.
312 pub download: Option<String>,
313 /// When true, skip TLS certificate verification for the custom URLs.
314 pub skip_tls_verify: Option<bool>,
315 /// When true, use the GitLab Package Registry for uploads instead of Generic Packages.
316 pub use_package_registry: Option<bool>,
317 /// When true, use the CI_JOB_TOKEN for authentication instead of a personal token.
318 pub use_job_token: Option<bool>,
319}
320
321/// Custom Gitea API/download URLs for self-hosted Gitea installations.
322/// Gitea API/download URL overrides.
323#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
324#[serde(default, deny_unknown_fields)]
325pub struct GiteaUrlsConfig {
326 /// Gitea API base URL (e.g. `https://gitea.example.com/api/v1/`).
327 pub api: Option<String>,
328 /// Gitea download URL for release assets.
329 pub download: Option<String>,
330 /// When true, skip TLS certificate verification for the custom URLs.
331 pub skip_tls_verify: Option<bool>,
332}
333
334// ---------------------------------------------------------------------------
335// "auto" | bool enum — shared serde implementation
336// ---------------------------------------------------------------------------
337
338/// Generates `Serialize` and `Deserialize` impls for enums with `Auto` and
339/// `Bool(bool)` variants that accept the string `"auto"` or a boolean in YAML.
340macro_rules! impl_auto_or_bool_serde {
341 ($ty:ty, $auto:path, $bool_variant:path) => {
342 impl Serialize for $ty {
343 fn serialize<S: serde::Serializer>(
344 &self,
345 serializer: S,
346 ) -> std::result::Result<S::Ok, S::Error> {
347 match self {
348 $auto => serializer.serialize_str("auto"),
349 $bool_variant(b) => serializer.serialize_bool(*b),
350 }
351 }
352 }
353
354 impl<'de> Deserialize<'de> for $ty {
355 fn deserialize<D: serde::Deserializer<'de>>(
356 deserializer: D,
357 ) -> std::result::Result<Self, D::Error> {
358 struct Visitor;
359 impl serde::de::Visitor<'_> for Visitor {
360 type Value = $ty;
361 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 write!(f, "\"auto\" or a boolean")
363 }
364 fn visit_bool<E: serde::de::Error>(
365 self,
366 v: bool,
367 ) -> std::result::Result<$ty, E> {
368 Ok($bool_variant(v))
369 }
370 fn visit_str<E: serde::de::Error>(
371 self,
372 v: &str,
373 ) -> std::result::Result<$ty, E> {
374 if v == "auto" {
375 Ok($auto)
376 } else {
377 Err(E::custom(format!("expected \"auto\", got \"{}\"", v)))
378 }
379 }
380 }
381 deserializer.deserialize_any(Visitor)
382 }
383 }
384 };
385}
386
387/// `prerelease` can be the string `"auto"` or a boolean.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub enum PrereleaseConfig {
390 Auto,
391 Bool(bool),
392}
393
394impl_auto_or_bool_serde!(
395 PrereleaseConfig,
396 PrereleaseConfig::Auto,
397 PrereleaseConfig::Bool
398);
399
400/// `make_latest` can be the string `"auto"`, a boolean, or a template string.
401/// This field is rendered through the template engine at publish time,
402/// so we accept arbitrary strings (e.g. `"{{ if .IsSnapshot }}false{{ else }}true{{ end }}"`)
403/// and defer resolution to the release stage.
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub enum MakeLatestConfig {
406 Auto,
407 Bool(bool),
408 /// An arbitrary template string to be rendered at publish time.
409 String(String),
410}
411
412impl Serialize for MakeLatestConfig {
413 fn serialize<S: serde::Serializer>(
414 &self,
415 serializer: S,
416 ) -> std::result::Result<S::Ok, S::Error> {
417 match self {
418 MakeLatestConfig::Auto => serializer.serialize_str("auto"),
419 MakeLatestConfig::Bool(b) => serializer.serialize_bool(*b),
420 MakeLatestConfig::String(s) => serializer.serialize_str(s),
421 }
422 }
423}
424
425impl<'de> Deserialize<'de> for MakeLatestConfig {
426 fn deserialize<D: serde::Deserializer<'de>>(
427 deserializer: D,
428 ) -> std::result::Result<Self, D::Error> {
429 struct Visitor;
430 impl serde::de::Visitor<'_> for Visitor {
431 type Value = MakeLatestConfig;
432 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 write!(f, "\"auto\", a boolean, or a template string")
434 }
435 fn visit_bool<E: serde::de::Error>(
436 self,
437 v: bool,
438 ) -> std::result::Result<MakeLatestConfig, E> {
439 Ok(MakeLatestConfig::Bool(v))
440 }
441 fn visit_str<E: serde::de::Error>(
442 self,
443 v: &str,
444 ) -> std::result::Result<MakeLatestConfig, E> {
445 match v {
446 "auto" => Ok(MakeLatestConfig::Auto),
447 "true" => Ok(MakeLatestConfig::Bool(true)),
448 "false" => Ok(MakeLatestConfig::Bool(false)),
449 other => Ok(MakeLatestConfig::String(other.to_string())),
450 }
451 }
452 }
453 deserializer.deserialize_any(Visitor)
454 }
455}
456
457/// `skip_push` can be `"auto"` (skip for prereleases), a boolean, or a template string.
458/// Template expressions like `"{{ if .IsSnapshot }}true{{ end }}"` are accepted.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub enum SkipPushConfig {
461 Auto,
462 Bool(bool),
463 /// Arbitrary template string — rendered at runtime, truthy result means skip push.
464 Template(String),
465}
466
467impl Serialize for SkipPushConfig {
468 fn serialize<S: serde::Serializer>(
469 &self,
470 serializer: S,
471 ) -> std::result::Result<S::Ok, S::Error> {
472 match self {
473 SkipPushConfig::Auto => serializer.serialize_str("auto"),
474 SkipPushConfig::Bool(b) => serializer.serialize_bool(*b),
475 SkipPushConfig::Template(s) => serializer.serialize_str(s),
476 }
477 }
478}
479
480impl<'de> Deserialize<'de> for SkipPushConfig {
481 fn deserialize<D: serde::Deserializer<'de>>(
482 deserializer: D,
483 ) -> std::result::Result<Self, D::Error> {
484 struct Visitor;
485 impl serde::de::Visitor<'_> for Visitor {
486 type Value = SkipPushConfig;
487 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488 write!(f, "\"auto\", a boolean, or a template string")
489 }
490 fn visit_bool<E: serde::de::Error>(
491 self,
492 v: bool,
493 ) -> std::result::Result<SkipPushConfig, E> {
494 Ok(SkipPushConfig::Bool(v))
495 }
496 fn visit_str<E: serde::de::Error>(
497 self,
498 v: &str,
499 ) -> std::result::Result<SkipPushConfig, E> {
500 match v {
501 "auto" => Ok(SkipPushConfig::Auto),
502 "true" => Ok(SkipPushConfig::Bool(true)),
503 "false" => Ok(SkipPushConfig::Bool(false)),
504 other => Ok(SkipPushConfig::Template(other.to_string())),
505 }
506 }
507 }
508 deserializer.deserialize_any(Visitor)
509 }
510}