anodizer_core/config/mod.rs
1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7// ---------------------------------------------------------------------------
8// Include specification types
9// ---------------------------------------------------------------------------
10
11/// An include specification: either a plain path string or a structured from_file/from_url.
12///
13/// YAML examples:
14/// ```yaml
15/// includes:
16/// - ./defaults.yaml # plain string (backward compat)
17/// - from_file:
18/// path: ./config/goreleaser.yaml # structured file path
19/// - from_url:
20/// url: https://example.com/config.yaml # URL fetch
21/// headers:
22/// x-api-token: "${MYCOMPANY_TOKEN}" # env var expansion in headers
23/// ```
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
25#[serde(untagged)]
26pub enum IncludeSpec {
27 /// Plain string path (backward compatible): "path/to/file.yaml"
28 Path(String),
29 /// Structured file include with `from_file.path`.
30 FromFile { from_file: IncludeFilePath },
31 /// Structured URL include with `from_url.url` and optional headers.
32 FromUrl { from_url: IncludeUrlConfig },
33}
34
35/// File path for a structured include.
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
37pub struct IncludeFilePath {
38 /// Path to the include file (relative to the config file).
39 pub path: String,
40}
41
42/// URL configuration for a structured include.
43#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
44pub struct IncludeUrlConfig {
45 /// URL to fetch. If it does not start with `http://` or `https://`,
46 /// `https://raw.githubusercontent.com/` is prepended (GitHub shorthand).
47 pub url: String,
48 /// Optional HTTP headers. Values support `${VAR_NAME}` environment variable expansion.
49 pub headers: Option<HashMap<String, String>>,
50}
51
52// ---------------------------------------------------------------------------
53// Top-level config
54// ---------------------------------------------------------------------------
55
56/// `deny_unknown_fields` rejects typos and unknown config
57/// fields at parse time, matching GoReleaser's `yaml.UnmarshalStrict`.
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59#[serde(default, deny_unknown_fields)]
60pub struct Config {
61 /// Schema version. Currently supports 1 (implicit default) and 2.
62 pub version: Option<u32>,
63 /// Human-readable project name used in templates and release titles.
64 pub project_name: String,
65 /// Output directory for build artifacts (default: ./dist).
66 #[serde(default = "default_dist")]
67 pub dist: PathBuf,
68 /// Additional config files to merge into this config.
69 /// Supports plain string paths, `from_file:` for structured file paths,
70 /// and `from_url:` for fetching configs from URLs with optional headers.
71 pub includes: Option<Vec<IncludeSpec>>,
72 /// Environment file configuration. Accepts either:
73 /// - A list of `.env` file paths: `[".env", ".release.env"]`
74 /// - A struct with token file paths: `{ github_token: "~/.config/goreleaser/github_token" }`
75 pub env_files: Option<EnvFilesConfig>,
76 /// Default values applied to all crates unless overridden.
77 pub defaults: Option<Defaults>,
78 /// Hooks run before the release pipeline starts.
79 pub before: Option<HooksConfig>,
80 /// Hooks run after the release pipeline completes.
81 pub after: Option<HooksConfig>,
82 /// List of crates in this project.
83 pub crates: Vec<CrateConfig>,
84 /// Changelog generation configuration.
85 pub changelog: Option<ChangelogConfig>,
86 /// Signing configurations for binaries, archives, and checksums.
87 #[serde(default, deserialize_with = "deserialize_signs")]
88 #[schemars(schema_with = "signs_schema")]
89 pub signs: Vec<SignConfig>,
90 /// Binary-specific signing configs (same shape as `signs` but only for
91 /// binary artifacts). The `artifacts` field on each entry is constrained
92 /// at parse time to `binary` / `none` (or omitted) — a broader filter on
93 /// `binary_signs` would silently match nothing because the loop only
94 /// iterates Binary artifacts. Constraint lives in `deserialize_binary_signs`.
95 #[serde(default, deserialize_with = "deserialize_binary_signs")]
96 #[schemars(schema_with = "signs_schema")]
97 pub binary_signs: Vec<SignConfig>,
98 /// Docker image signing configurations.
99 pub docker_signs: Option<Vec<DockerSignConfig>>,
100 // No `alias` attribute needed: unlike `signs`/`sign`, "upx" is already
101 // both singular and plural, so a separate alias adds no value.
102 /// UPX binary compression configurations.
103 #[serde(default, deserialize_with = "deserialize_upx")]
104 #[schemars(schema_with = "upx_schema")]
105 pub upx: Vec<UpxConfig>,
106 /// Snapshot release configuration (local/non-tag builds).
107 pub snapshot: Option<SnapshotConfig>,
108 /// Nightly release configuration.
109 pub nightly: Option<NightlyConfig>,
110 /// Announcement configuration (Slack, Discord, email, etc.).
111 pub announce: Option<AnnounceConfig>,
112 /// When true, log artifact file sizes after building.
113 pub report_sizes: Option<bool>,
114 /// Environment variables available to all template expressions.
115 ///
116 /// List of `KEY=VALUE` strings (matches GoReleaser):
117 /// `env: ["MY_VAR=hello", "DEPLOY_ENV=staging"]`. Order is preserved so
118 /// chained env applications (sign + sbom + notarize) see entries in
119 /// declared order. Values are rendered through the template engine before
120 /// being set, so expressions like `{{ .Tag }}` or `{{ .Date }}` are
121 /// expanded.
122 #[serde(default)]
123 pub env: Option<Vec<String>>,
124 /// Custom template variables accessible as {{ .Var.key }} in templates.
125 /// Provides a way to define reusable values, especially useful with config includes.
126 pub variables: Option<HashMap<String, String>>,
127 /// Generic artifact publisher configurations.
128 pub publishers: Option<Vec<PublisherConfig>>,
129 /// DockerHub description sync configurations.
130 pub dockerhub: Option<Vec<DockerHubConfig>>,
131 /// Artifactory upload configurations.
132 pub artifactories: Option<Vec<ArtifactoryConfig>>,
133 /// CloudSmith publisher configurations.
134 pub cloudsmiths: Option<Vec<CloudSmithConfig>>,
135 /// Top-level Homebrew Cask configurations.
136 /// `homebrew_casks` is a top-level array with its own
137 /// repository, commit_author, directory, skip_upload, hooks, dependencies,
138 /// conflicts, completions, manpages, structured uninstall/zap, etc.
139 pub homebrew_casks: Option<Vec<HomebrewCaskConfig>>,
140 /// Automatic semantic version tagging configuration.
141 pub tag: Option<TagConfig>,
142 /// Git-level tag discovery and sorting settings.
143 pub git: Option<GitConfig>,
144 /// Partial/split build configuration for fan-out CI pipelines.
145 pub partial: Option<PartialConfig>,
146 /// Independent workspace roots in a monorepo.
147 pub workspaces: Option<Vec<WorkspaceConfig>>,
148 /// Source archive configuration.
149 pub source: Option<SourceConfig>,
150 /// Software bill of materials (SBOM) generation configurations.
151 #[serde(default, deserialize_with = "deserialize_sboms")]
152 #[schemars(schema_with = "sboms_schema")]
153 pub sboms: Vec<SbomConfig>,
154 /// GitHub release configuration shared by all crates.
155 pub release: Option<ReleaseConfig>,
156 /// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
157 pub github_urls: Option<GitHubUrlsConfig>,
158 /// Custom GitLab API/download URLs for self-hosted GitLab installations.
159 pub gitlab_urls: Option<GitLabUrlsConfig>,
160 /// Custom Gitea API/download URLs for self-hosted Gitea installations.
161 pub gitea_urls: Option<GiteaUrlsConfig>,
162 /// Force a specific token type for authentication.
163 /// When set, overrides automatic token detection from environment variables.
164 pub force_token: Option<ForceTokenKind>,
165 /// macOS code signing and notarization configuration.
166 pub notarize: Option<NotarizeConfig>,
167 /// Project metadata configuration (applied to metadata.json output files).
168 pub metadata: Option<MetadataConfig>,
169 /// Template files to render and include as release artifacts.
170 /// File contents are processed through the template engine.
171 pub template_files: Option<Vec<TemplateFileConfig>>,
172 /// GoReleaser Pro monorepo configuration.
173 /// When configured, tag discovery filters by tag_prefix and the working
174 /// directory is scoped to dir.
175 pub monorepo: Option<MonorepoConfig>,
176 /// Makeself self-extracting archive configurations.
177 #[serde(default, deserialize_with = "deserialize_makeselfs")]
178 #[schemars(schema_with = "makeselfs_schema")]
179 pub makeselfs: Vec<MakeselfConfig>,
180 /// Source RPM configuration. Renamed from `srpm:` (singular) for spelling
181 /// parity with `Defaults.srpms` and the rest of the plural-name packaging
182 /// fields. The `srpm:` spelling is still accepted via serde alias for
183 /// back-compat.
184 #[serde(alias = "srpm")]
185 pub srpms: Option<SrpmConfig>,
186 /// Milestone closing configurations.
187 pub milestones: Option<Vec<MilestoneConfig>>,
188 /// Generic HTTP upload configurations.
189 pub uploads: Option<Vec<UploadConfig>>,
190 /// AUR source package publishing configurations (source-only PKGBUILD, not -bin).
191 pub aur_sources: Option<Vec<AurSourceConfig>>,
192 /// Top-level retry configuration applied to network-bound operations
193 /// (announcers, git providers, HTTP uploads, docker pipes). When omitted,
194 /// `RetryConfig::default()` is used (10 attempts, 10s base, 5m cap —
195 /// matching GoReleaser `Project.Retry`).
196 pub retry: Option<RetryConfig>,
197 /// MCP (Model Context Protocol) server registry publishing
198 /// configuration. When `name` is empty (the default), the publisher is
199 /// skipped. Mirrors GoReleaser's `mcp:` block.
200 #[serde(default)]
201 pub mcp: McpConfig,
202}
203
204/// Helper schema function for the signs field (accepts object or array).
205fn signs_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
206 let mut schema = generator.subschema_for::<Vec<SignConfig>>();
207 if let schemars::schema::Schema::Object(ref mut obj) = schema {
208 obj.metadata().description = Some("Artifact signing configurations (cosign, GPG, etc.). Accepts a single object or array.".to_owned());
209 }
210 schema
211}
212
213/// Helper schema function for the upx field (accepts object or array).
214fn upx_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
215 let mut schema = generator.subschema_for::<Vec<UpxConfig>>();
216 if let schemars::schema::Schema::Object(ref mut obj) = schema {
217 obj.metadata().description = Some(
218 "UPX binary compression configurations. Accepts a single object or array.".to_owned(),
219 );
220 }
221 schema
222}
223
224/// Helper schema function for the sboms field (accepts object or array).
225fn sboms_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
226 let mut schema = generator.subschema_for::<Vec<SbomConfig>>();
227 if let schemars::schema::Schema::Object(ref mut obj) = schema {
228 obj.metadata().description =
229 Some("SBOM generation configurations. Accepts a single object or array.".to_owned());
230 }
231 schema
232}
233
234fn default_dist() -> PathBuf {
235 PathBuf::from("./dist")
236}
237
238impl Default for Config {
239 fn default() -> Self {
240 Config {
241 version: None,
242 project_name: String::new(),
243 dist: default_dist(),
244 includes: None,
245 env_files: None,
246 defaults: None,
247 before: None,
248 after: None,
249 crates: Vec::new(),
250 changelog: None,
251 signs: Vec::new(),
252 binary_signs: Vec::new(),
253 docker_signs: None,
254 upx: Vec::new(),
255 snapshot: None,
256 nightly: None,
257 announce: None,
258 report_sizes: None,
259 env: None,
260 variables: None,
261 publishers: None,
262 dockerhub: None,
263 artifactories: None,
264 cloudsmiths: None,
265 homebrew_casks: None,
266 tag: None,
267 git: None,
268 partial: None,
269 workspaces: None,
270 source: None,
271 sboms: Vec::new(),
272 release: None,
273 github_urls: None,
274 gitlab_urls: None,
275 gitea_urls: None,
276 force_token: None,
277 notarize: None,
278 metadata: None,
279 template_files: None,
280 monorepo: None,
281 makeselfs: Vec::new(),
282 srpms: None,
283 milestones: None,
284 uploads: None,
285 aur_sources: None,
286 retry: None,
287 mcp: McpConfig::default(),
288 }
289 }
290}
291
292impl Config {
293 /// Return the monorepo tag prefix, if configured.
294 ///
295 /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())`.
296 pub fn monorepo_tag_prefix(&self) -> Option<&str> {
297 self.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())
298 }
299
300 /// Return the monorepo working directory, if configured.
301 ///
302 /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.dir.as_deref())`.
303 pub fn monorepo_dir(&self) -> Option<&str> {
304 self.monorepo.as_ref().and_then(|m| m.dir.as_deref())
305 }
306
307 // --- Project metadata defaulting helpers (GoReleaser Pro parity) ---
308 //
309 // Publishers that expose homepage/license/description/maintainer fields
310 // should fall back to these when their own field is unset, so a project
311 // only needs to declare metadata once. Pattern:
312 //
313 // let homepage = nfpm_cfg.homepage
314 // .as_deref()
315 // .or_else(|| cfg.meta_homepage());
316 //
317 // Returns None if the `metadata` section is missing or the field is unset.
318
319 /// Project homepage from `metadata.homepage` (Pro default source for publishers).
320 pub fn meta_homepage(&self) -> Option<&str> {
321 self.metadata.as_ref().and_then(|m| m.homepage.as_deref())
322 }
323
324 /// Project license from `metadata.license`.
325 pub fn meta_license(&self) -> Option<&str> {
326 self.metadata.as_ref().and_then(|m| m.license.as_deref())
327 }
328
329 /// Project description from `metadata.description`.
330 pub fn meta_description(&self) -> Option<&str> {
331 self.metadata
332 .as_ref()
333 .and_then(|m| m.description.as_deref())
334 }
335
336 /// Project maintainers from `metadata.maintainers`.
337 pub fn meta_maintainers(&self) -> &[String] {
338 self.metadata
339 .as_ref()
340 .and_then(|m| m.maintainers.as_deref())
341 .unwrap_or(&[])
342 }
343
344 /// First maintainer as "Name <email>" or just "Name" (publisher convention).
345 /// Returns None when no maintainers are configured.
346 pub fn meta_first_maintainer(&self) -> Option<&str> {
347 self.meta_maintainers().first().map(|s| s.as_str())
348 }
349
350 /// `true` when any top-level / workspace `signs:` or `binary_signs:`
351 /// entry will invoke gpg (via `SignConfig::is_gpg()`).
352 ///
353 /// Used by preflight to decide whether to probe
354 /// `gpg --faked-system-time` support. `docker_signs:` is excluded
355 /// because that driver only ever invokes cosign.
356 pub fn has_gpg_sign_configured(&self) -> bool {
357 let top_level = self
358 .signs
359 .iter()
360 .chain(self.binary_signs.iter())
361 .any(|s| s.is_gpg());
362 if top_level {
363 return true;
364 }
365 // Workspaces inherit their own signs:/binary_signs: lists.
366 self.workspaces.iter().flatten().any(|w| {
367 w.signs
368 .iter()
369 .chain(w.binary_signs.iter())
370 .any(|s| s.is_gpg())
371 })
372 }
373}
374
375/// Run a deserialization closure on a worker thread sized large enough that
376/// the `Config` derive (60+ `Option<NestedStruct>` fields) cannot exhaust
377/// the host's main-thread stack.
378///
379/// Background: debug builds of `serde_yaml_ng::from_value::<Config>` and
380/// `toml::from_str::<Config>` consume several MiB of stack because each
381/// generated visitor branch for the giant struct lives in a single
382/// monomorphised frame and debug builds neither inline nor tail-call. The
383/// Windows main-thread default reservation is 1 MiB, so any debug-built
384/// integration test that triggers full-config deserialization overflows
385/// before reaching the visitor's body.
386///
387/// Routing every full-`Config` deserialization through this helper keeps
388/// every entry-point platform-agnostic without resorting to per-platform
389/// linker flags or `RUST_MIN_STACK`.
390pub fn deserialize_on_worker<F, T>(f: F) -> anyhow::Result<T>
391where
392 F: FnOnce() -> anyhow::Result<T> + Send + 'static,
393 T: Send + 'static,
394{
395 use anyhow::Context as _;
396
397 // 8 MiB matches the Linux/macOS process default and comfortably exceeds
398 // the ~2 MiB peak observed for debug `Config` deserialization.
399 const WORKER_STACK_SIZE: usize = 8 * 1024 * 1024;
400
401 let handle = std::thread::Builder::new()
402 .stack_size(WORKER_STACK_SIZE)
403 .name("anodizer-config-deserialize".to_string())
404 .spawn(f)
405 .context("failed to spawn config deserialization worker thread")?;
406 match handle.join() {
407 Ok(result) => result,
408 Err(payload) => std::panic::resume_unwind(payload),
409 }
410}
411
412/// Validate the config schema version. Accepts version 1 (default) and 2.
413/// Returns an error for unknown versions.
414pub fn validate_version(config: &Config) -> Result<(), String> {
415 match config.version {
416 None | Some(1) | Some(2) => Ok(()),
417 Some(v) => Err(format!(
418 "unsupported config version: {}. Supported versions are 1 and 2.",
419 v
420 )),
421 }
422}
423
424/// Validate `git.tag_sort` if present. Accepted values:
425/// - `"-version:refname"` (default, lexicographic version sort)
426/// - `"-version:creatordate"` (sort by tag creation date, newest first)
427///
428/// Returns an error for unrecognized values.
429pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
430 if let Some(ref git) = config.git
431 && let Some(ref sort) = git.tag_sort
432 {
433 match sort.as_str() {
434 "-version:refname" | "-version:creatordate" => {}
435 other => {
436 return Err(format!(
437 "unsupported git.tag_sort value: \"{}\". \
438 Accepted values: \"-version:refname\", \"-version:creatordate\".",
439 other
440 ));
441 }
442 }
443 }
444 Ok(())
445}
446
447/// Known GOOS values accepted by `archives[].format_overrides[].goos`.
448/// Mirrors the Go runtime's `runtime.GOOS` values GoReleaser's archive pipe
449/// recognises; anything outside this set is almost always a typo
450/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
451const KNOWN_GOOS: &[&str] = &[
452 "aix",
453 "android",
454 "darwin",
455 "dragonfly",
456 "freebsd",
457 "illumos",
458 "ios",
459 "js",
460 "linux",
461 "netbsd",
462 "openbsd",
463 "plan9",
464 "solaris",
465 "wasip1",
466 "windows",
467];
468
469/// Validate that each crate's `release:` block configures at most one SCM
470/// backend. Matches GoReleaser release.go:41-53 `ErrMultipleReleases`, which
471/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
472/// runtime so a silently-ignored extra backend is easy to miss.
473pub fn validate_release_backends(config: &Config) -> Result<(), String> {
474 let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
475 let mut set = Vec::new();
476 if release.github.is_some() {
477 set.push("github");
478 }
479 if release.gitlab.is_some() {
480 set.push("gitlab");
481 }
482 if release.gitea.is_some() {
483 set.push("gitea");
484 }
485 if set.len() > 1 {
486 return Err(format!(
487 "crate {}: release config sets multiple mutually-exclusive SCM \
488 backends ({}). Pick one.",
489 crate_name,
490 set.join(" + ")
491 ));
492 }
493 Ok(())
494 };
495 for krate in &config.crates {
496 if let Some(ref release) = krate.release {
497 check(&krate.name, release)?;
498 }
499 }
500 if let Some(ws_list) = config.workspaces.as_ref() {
501 for ws in ws_list {
502 for krate in &ws.crates {
503 if let Some(ref release) = krate.release {
504 check(&krate.name, release)?;
505 }
506 }
507 }
508 }
509 Ok(())
510}
511
512/// Marker prefix for the axis-mismatch validation error class. Existing
513/// validators in this module return `Result<(), String>` rather than a
514/// typed enum, so we expose this constant (instead of a `ConfigError`
515/// variant) for callers that want to recognise the error class
516/// programmatically.
517///
518/// The prefix is emitted at the start of every error returned by
519/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
520/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
521/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
522/// exact human-readable wording.
523///
524/// ```ignore
525/// match validate_defaults_axis(&config) {
526/// Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
527/// // handle the axis-mismatch error class
528/// }
529/// other => other?,
530/// }
531/// ```
532///
533/// Future error-type unification can rename to
534/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
535/// match on this prefix.
536pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
537
538/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
539/// top-level axis.
540///
541/// Rules:
542/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
543/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
544/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
545/// (mutually exclusive).
546/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
547pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
548 let Some(ref defaults) = config.defaults else {
549 return Ok(());
550 };
551 let has_crate_block = defaults.crates.is_some();
552 let has_workspace_block = defaults.workspaces.is_some();
553
554 if has_crate_block && has_workspace_block {
555 return Err(format!(
556 "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
557 mutually exclusive — pick the axis that matches the top-level config \
558 (`crates:` or `workspaces:`)",
559 ));
560 }
561
562 let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
563 let top_uses_crates = !config.crates.is_empty();
564
565 if has_crate_block && !top_uses_crates {
566 return Err(format!(
567 "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
568 is {}; move defaults under `defaults.workspaces:` or remove the block",
569 if top_uses_workspaces {
570 "absent (top-level uses `workspaces:`)"
571 } else {
572 "absent"
573 },
574 ));
575 }
576 if has_workspace_block && !top_uses_workspaces {
577 return Err(format!(
578 "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
579 `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
580 if top_uses_crates {
581 "absent (top-level uses `crates:`)"
582 } else {
583 "absent"
584 },
585 ));
586 }
587
588 Ok(())
589}
590
591/// Validate `archives[].format_overrides[].goos` values reject unknown OSes.
592/// GoReleaser silently no-ops unknown overrides, which has burned users typing
593/// Rust triples like `apple` or `pc-windows-msvc`.
594///
595/// Walks every `archives[]` location in the config:
596/// - `crates[].archives:`
597/// - `workspaces[].crates[].archives:`
598/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
599/// and propagate to every inheriting crate at merge time).
600pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
601 let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
602 for (idx, archive) in archives.iter().enumerate() {
603 let Some(ref overrides) = archive.format_overrides else {
604 continue;
605 };
606 for over in overrides {
607 if !KNOWN_GOOS.contains(&over.os.as_str()) {
608 let archive_id = archive.id.as_deref().unwrap_or("default");
609 return Err(format!(
610 "{}: archives[{}] (id={}): format_overrides.goos=\"{}\" is not a recognised OS. \
611 Accepted values: {}.",
612 location,
613 idx,
614 archive_id,
615 over.os,
616 KNOWN_GOOS.join(", ")
617 ));
618 }
619 }
620 }
621 Ok(())
622 };
623 for krate in &config.crates {
624 if let ArchivesConfig::Configs(ref list) = krate.archives {
625 check(&format!("crate {}", krate.name), list)?;
626 }
627 }
628 if let Some(ws_list) = config.workspaces.as_ref() {
629 for ws in ws_list {
630 for krate in &ws.crates {
631 if let ArchivesConfig::Configs(ref list) = krate.archives {
632 check(&format!("crate {}", krate.name), list)?;
633 }
634 }
635 }
636 }
637 if let Some(ref defaults) = config.defaults
638 && let Some(ref archive) = defaults.archives
639 {
640 // defaults.archives is a single ArchiveConfig (not a list); wrap it
641 // into a one-element slice so the same checker walks it.
642 check("defaults.archives", std::slice::from_ref(archive))?;
643 }
644 Ok(())
645}
646
647/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
648/// `url.template` simultaneously — they are mutually exclusive shorthands
649/// for the same URL field and combining them is ambiguous.
650///
651/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
652/// - `homebrew_casks:` (top-level array)
653/// - `crates[].publish.homebrew_cask:`
654/// - `workspaces[].crates[].publish.homebrew_cask:`
655/// - `defaults.publish.homebrew_cask:`
656pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
657 let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
658 let has_url_template = cask.url_template.is_some();
659 let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
660 if has_url_template && has_url_dot_template {
661 return Err(format!(
662 "{location}: homebrew_cask sets both `url_template` and `url.template`. \
663 These are mutually exclusive — use one or the other."
664 ));
665 }
666 Ok(())
667 };
668
669 // Top-level homebrew_casks array
670 if let Some(ref casks) = config.homebrew_casks {
671 for (i, cask) in casks.iter().enumerate() {
672 check(&format!("homebrew_casks[{i}]"), cask)?;
673 }
674 }
675
676 // Per-crate publish.homebrew_cask
677 for krate in &config.crates {
678 if let Some(ref publish) = krate.publish
679 && let Some(ref cask) = publish.homebrew_cask
680 {
681 check(
682 &format!("crates[{}].publish.homebrew_cask", krate.name),
683 cask,
684 )?;
685 }
686 }
687
688 // Workspace crates
689 if let Some(ref workspaces) = config.workspaces {
690 for ws in workspaces {
691 for krate in &ws.crates {
692 if let Some(ref publish) = krate.publish
693 && let Some(ref cask) = publish.homebrew_cask
694 {
695 check(
696 &format!(
697 "workspaces[{}].crates[{}].publish.homebrew_cask",
698 ws.name, krate.name
699 ),
700 cask,
701 )?;
702 }
703 }
704 }
705 }
706
707 // defaults.publish.homebrew_cask
708 if let Some(ref defaults) = config.defaults
709 && let Some(ref publish) = defaults.publish
710 && let Some(ref cask) = publish.homebrew_cask
711 {
712 check("defaults.publish.homebrew_cask", cask)?;
713 }
714
715 Ok(())
716}
717
718/// Validate that `archives[].id` and `universal_binaries[].id` are unique
719/// within their respective lists.
720///
721/// Mirrors GoReleaser's `ids.New("archives").Inc(...).Validate()` pattern in
722/// `internal/pipe/archive/archive.go:56-102` and the equivalent
723/// `internal/pipe/universalbinary/universalbinary.go:36-50`. Two archive
724/// configs with the same `id` silently both set the same `id` metadata key
725/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
726/// build/sign stages already enforce id uniqueness; archive and
727/// universal_binary were missed.
728///
729/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
730/// - `crates[].archives:` / `crates[].universal_binaries:`
731/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
732/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
733/// itself is vacuously true; not walked here.
734///
735pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
736 fn check_unique<F>(
737 location: &str,
738 kind: &str,
739 ids: impl IntoIterator<Item = (usize, Option<String>)>,
740 empty_ok: F,
741 ) -> Result<(), String>
742 where
743 F: Fn() -> bool,
744 {
745 let _ = empty_ok;
746 let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
747 for (idx, maybe_id) in ids {
748 // GoReleaser stores empty as "default" for archives via Default-time
749 // assignment. Anodizer applies `default_archive_id` at deserialize
750 // time, so the option is normally `Some("default")`. A truly empty
751 // / None id here means the user explicitly cleared it; we still
752 // dedupe across `None` so two None-id'd entries collide just like
753 // two "default"-id'd entries would.
754 let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
755 if let Some(prev_idx) = seen.insert(key.clone(), idx) {
756 return Err(format!(
757 "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
758 ids must be unique within a {kind} list."
759 ));
760 }
761 }
762 Ok(())
763 }
764
765 let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
766 check_unique(
767 location,
768 "archives",
769 archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
770 || true,
771 )
772 };
773 let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
774 check_unique(
775 location,
776 "universal_binaries",
777 ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
778 || true,
779 )
780 };
781
782 for krate in &config.crates {
783 if let ArchivesConfig::Configs(ref list) = krate.archives {
784 check_archives(&format!("crates[{}].archives", krate.name), list)?;
785 }
786 if let Some(ref ubs) = krate.universal_binaries {
787 check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
788 }
789 }
790 if let Some(ws_list) = config.workspaces.as_ref() {
791 for ws in ws_list {
792 for krate in &ws.crates {
793 if let ArchivesConfig::Configs(ref list) = krate.archives {
794 check_archives(
795 &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
796 list,
797 )?;
798 }
799 if let Some(ref ubs) = krate.universal_binaries {
800 check_unibins(
801 &format!(
802 "workspaces[{}].crates[{}].universal_binaries",
803 ws.name, krate.name
804 ),
805 ubs,
806 )?;
807 }
808 }
809 }
810 }
811 Ok(())
812}
813
814/// No-op preserved for API stability; the legacy `format:` and `builds:`
815/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
816/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
817/// own — every alias hit was already announced at deserialize time.
818///
819pub fn apply_archive_legacy_aliases(_config: &mut Config) {
820 // Intentionally empty — see Deserialize impls.
821}
822
823/// Reject the GoReleaser V1 `dockers:` block at config-load time with a
824/// clear migration error.
825///
826/// anodizer is V2-only by design: it implements `docker_v2:` and the
827/// associated multi-arch buildx flow, but does not ship the V1
828/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
829/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
830/// "unknown field `dockers`" message that doesn't tell the user how to
831/// migrate. This explicit error names the field, points at `docker_v2:`,
832/// and references the rationale.
833///
834pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
835 if raw_yaml.get("dockers").is_some() {
836 return Err(
837 "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
838 docker_v2: only (multi-arch buildx flow). Port the config to `docker_v2:` per \
839 https://anodize.dev/docs/migration/docker.html."
840 .to_string(),
841 );
842 }
843 Ok(())
844}
845
846/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
847/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
848/// so this function only needs to emit the deprecation warning when the
849/// raw YAML key was the legacy one.
850///
851/// GR ref: `internal/pipe/snapshot/snapshot.go:25-28`. Because serde collapses
852/// the two spellings to a single field on parse, we lose the information
853/// about which key the user wrote. This function therefore consults the
854/// raw YAML pre-parse value (when supplied) to decide.
855///
856/// F3 — section 1.8 of the build-archive.md audit report.
857pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
858 if let Some(snap) = raw_yaml.get("snapshot")
859 && snap.get("name_template").is_some()
860 {
861 tracing::warn!(
862 "DEPRECATION: snapshot.name_template is deprecated; use \
863 snapshot.version_template instead. Both spellings are accepted \
864 but the legacy key will be removed in a future release."
865 );
866 }
867}
868
869/// Emit a deprecation warning for any `builds[].gobinary` field. The field
870/// is captured by [`BuildConfig::legacy_gobinary`] purely for back-compat
871/// YAML import; anodizer's tool is always `cargo` so the value is unused.
872///
873/// GR ref: `internal/pipe/build/build.go:93-95`.
874pub fn apply_build_legacy_aliases(config: &mut Config) {
875 let warn_one = |location: &str, legacy: &mut Option<String>| {
876 if let Some(go_bin) = legacy.take() {
877 tracing::warn!(
878 "DEPRECATION: {location}: 'gobinary: {go_bin}' is a Go-only field; anodizer \
879 builds with cargo unconditionally. The value has been ignored."
880 );
881 }
882 };
883 for krate in &mut config.crates {
884 if let Some(ref mut builds) = krate.builds {
885 for (i, b) in builds.iter_mut().enumerate() {
886 warn_one(
887 &format!("crates[{}].builds[{i}]", krate.name),
888 &mut b.legacy_gobinary,
889 );
890 }
891 }
892 }
893 if let Some(ref mut workspaces) = config.workspaces {
894 for ws in workspaces {
895 for krate in &mut ws.crates {
896 if let Some(ref mut builds) = krate.builds {
897 for (i, b) in builds.iter_mut().enumerate() {
898 warn_one(
899 &format!("workspaces[{}].crates[{}].builds[{i}]", ws.name, krate.name),
900 &mut b.legacy_gobinary,
901 );
902 }
903 }
904 }
905 }
906 }
907 if let Some(ref mut defaults) = config.defaults
908 && let Some(ref mut b) = defaults.builds
909 {
910 warn_one("defaults.builds", &mut b.legacy_gobinary);
911 }
912}
913
914// ---------------------------------------------------------------------------
915// EnvFilesConfig — accepts list of .env paths OR structured token file paths
916// ---------------------------------------------------------------------------
917
918mod env_files;
919pub use env_files::*;
920
921// ---------------------------------------------------------------------------
922// Defaults
923// ---------------------------------------------------------------------------
924
925mod defaults;
926pub use defaults::*;
927
928// ---------------------------------------------------------------------------
929// BuildIgnore — exclude specific os/arch combos from builds
930// ---------------------------------------------------------------------------
931
932mod build;
933pub use build::*;
934
935// ---------------------------------------------------------------------------
936// ArchivesConfig — untagged enum: false => Disabled, array => Configs
937// ---------------------------------------------------------------------------
938
939mod archives;
940pub use archives::*;
941
942// ---------------------------------------------------------------------------
943// ReleaseConfig
944// ---------------------------------------------------------------------------
945
946mod release;
947pub use release::*;
948
949// ---------------------------------------------------------------------------
950// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
951// ---------------------------------------------------------------------------
952
953mod publishers;
954pub use publishers::*;
955
956// ---------------------------------------------------------------------------
957// DockerV2Config
958// ---------------------------------------------------------------------------
959
960mod docker;
961pub use docker::*;
962
963// ---------------------------------------------------------------------------
964// NfpmConfig
965// ---------------------------------------------------------------------------
966
967mod nfpm;
968pub use nfpm::*;
969
970// ---------------------------------------------------------------------------
971// SnapcraftConfig
972// ---------------------------------------------------------------------------
973
974mod snapcraft;
975pub use snapcraft::*;
976// ---------------------------------------------------------------------------
977// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
978// ---------------------------------------------------------------------------
979
980mod installers;
981pub use installers::*;
982
983// ---------------------------------------------------------------------------
984// BlobConfig (S3/GCS/Azure cloud storage)
985// ---------------------------------------------------------------------------
986
987mod blob;
988pub use blob::*;
989
990// ---------------------------------------------------------------------------
991// PartialConfig (split/merge CI fan-out)
992// ---------------------------------------------------------------------------
993
994mod partial;
995pub use partial::*;
996
997// ---------------------------------------------------------------------------
998// BinstallConfig
999// ---------------------------------------------------------------------------
1000
1001mod binstall;
1002pub use binstall::*;
1003
1004// ---------------------------------------------------------------------------
1005// NotarizeConfig (macOS code signing and notarization)
1006// ---------------------------------------------------------------------------
1007
1008mod notarize;
1009pub use notarize::*;
1010// ---------------------------------------------------------------------------
1011// SourceConfig
1012// ---------------------------------------------------------------------------
1013
1014mod source;
1015pub use source::*;
1016
1017// ---------------------------------------------------------------------------
1018// SbomConfig
1019// ---------------------------------------------------------------------------
1020
1021mod sbom;
1022pub use sbom::*;
1023
1024// ---------------------------------------------------------------------------
1025// VersionSyncConfig
1026// ---------------------------------------------------------------------------
1027
1028mod version_sync;
1029pub use version_sync::*;
1030
1031// ---------------------------------------------------------------------------
1032// ChangelogConfig
1033// ---------------------------------------------------------------------------
1034
1035mod changelog;
1036pub use changelog::*;
1037// ---------------------------------------------------------------------------
1038// SignConfig / DockerSignConfig — lifted to `crate::signing`
1039// ---------------------------------------------------------------------------
1040//
1041// see `crate::signing` for the type definitions. The
1042// re-exports below preserve the historical
1043// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
1044// used by every stage that consumes a sign config.
1045
1046pub use crate::signing::{DockerSignConfig, SignConfig};
1047
1048// ---------------------------------------------------------------------------
1049// UpxConfig
1050// ---------------------------------------------------------------------------
1051
1052mod upx;
1053pub use upx::*;
1054
1055// ---------------------------------------------------------------------------
1056// SnapshotConfig
1057// ---------------------------------------------------------------------------
1058
1059mod snapshot_nightly;
1060pub use snapshot_nightly::*;
1061
1062// ---------------------------------------------------------------------------
1063// TemplateFileConfig
1064// ---------------------------------------------------------------------------
1065
1066mod templatefiles;
1067pub use templatefiles::*;
1068
1069// ---------------------------------------------------------------------------
1070// AnnounceConfig
1071// ---------------------------------------------------------------------------
1072mod announce;
1073pub use announce::*;
1074// ---------------------------------------------------------------------------
1075// DockerHub description sync
1076// ---------------------------------------------------------------------------
1077
1078mod dockerhub;
1079pub use dockerhub::*;
1080
1081// ---------------------------------------------------------------------------
1082// Artifactory publisher
1083// ---------------------------------------------------------------------------
1084
1085mod artifactory;
1086pub use artifactory::*;
1087
1088// ---------------------------------------------------------------------------
1089// CloudSmith publisher
1090// ---------------------------------------------------------------------------
1091
1092mod cloudsmith;
1093pub use cloudsmith::*;
1094
1095// ---------------------------------------------------------------------------
1096// PublisherConfig
1097// ---------------------------------------------------------------------------
1098
1099mod publisher;
1100pub use publisher::*;
1101
1102// ---------------------------------------------------------------------------
1103// HooksConfig
1104// ---------------------------------------------------------------------------
1105
1106mod hooks;
1107pub use hooks::*;
1108
1109// ---------------------------------------------------------------------------
1110// GitConfig
1111// ---------------------------------------------------------------------------
1112
1113mod git_config;
1114pub use git_config::*;
1115
1116// ---------------------------------------------------------------------------
1117// MonorepoConfig
1118// ---------------------------------------------------------------------------
1119
1120mod monorepo;
1121pub use monorepo::*;
1122
1123// ---------------------------------------------------------------------------
1124// TagConfig
1125// ---------------------------------------------------------------------------
1126
1127mod tag;
1128pub use tag::*;
1129
1130// ---------------------------------------------------------------------------
1131// WorkspaceConfig
1132// ---------------------------------------------------------------------------
1133
1134mod workspace;
1135pub use workspace::*;
1136
1137// ---------------------------------------------------------------------------
1138// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
1139// ---------------------------------------------------------------------------
1140
1141mod retry;
1142pub use retry::*;
1143
1144// ---------------------------------------------------------------------------
1145// PostPublishPollConfig (per-publisher post-publish polling)
1146// ---------------------------------------------------------------------------
1147
1148mod post_publish_poll;
1149pub use post_publish_poll::*;
1150
1151// ---------------------------------------------------------------------------
1152// StringOrBool — accepts bool or template string in YAML
1153// ---------------------------------------------------------------------------
1154
1155mod string_or_bool;
1156pub use string_or_bool::*;
1157
1158// ---------------------------------------------------------------------------
1159// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
1160// ---------------------------------------------------------------------------
1161//
1162// All packaging config types live in their own modules under
1163// `crate::packagers`. The re-exports below preserve the historical
1164// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
1165// import paths used by stages and tests.
1166
1167pub use crate::packagers::{MakeselfConfig, MakeselfFile, SrpmConfig};
1168pub(crate) use crate::packagers::{deserialize_makeselfs, makeselfs_schema};
1169
1170// ---------------------------------------------------------------------------
1171// MilestoneConfig
1172// ---------------------------------------------------------------------------
1173
1174mod milestone;
1175pub use milestone::*;
1176
1177// ---------------------------------------------------------------------------
1178// UploadConfig (generic HTTP upload)
1179// ---------------------------------------------------------------------------
1180
1181mod upload;
1182pub use upload::*;
1183
1184// ---------------------------------------------------------------------------
1185// AurSourceConfig
1186// ---------------------------------------------------------------------------
1187
1188mod aur_source;
1189pub use aur_source::*;
1190
1191// ---------------------------------------------------------------------------
1192// McpConfig (MCP registry publisher)
1193// ---------------------------------------------------------------------------
1194
1195mod mcp;
1196pub use mcp::*;
1197
1198// ---------------------------------------------------------------------------
1199// Tests
1200// ---------------------------------------------------------------------------
1201
1202#[cfg(test)]
1203mod tests;