Skip to main content

anodizer_core/config/
mod.rs

1use std::collections::{BTreeMap, 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/release.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)]
37#[serde(deny_unknown_fields)]
38pub struct IncludeFilePath {
39    /// Path to the include file (relative to the config file).
40    pub path: String,
41}
42
43/// URL configuration for a structured include.
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
45#[serde(deny_unknown_fields)]
46pub struct IncludeUrlConfig {
47    /// URL to fetch. If it does not start with `http://` or `https://`,
48    /// `https://raw.githubusercontent.com/` is prepended (GitHub shorthand).
49    pub url: String,
50    /// Optional HTTP headers. Values support `${VAR_NAME}` environment variable expansion.
51    pub headers: Option<HashMap<String, String>>,
52}
53
54// ---------------------------------------------------------------------------
55// Top-level config
56// ---------------------------------------------------------------------------
57
58/// `deny_unknown_fields` rejects typos and unknown config
59/// fields at parse time (strict YAML unmarshalling).
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61#[serde(default, deny_unknown_fields)]
62pub struct Config {
63    /// Schema version. Currently supports 1 (implicit default) and 2.
64    pub version: Option<u32>,
65    /// Human-readable project name used in templates and release titles.
66    pub project_name: String,
67    /// Output directory for build artifacts (default: ./dist).
68    #[serde(default = "default_dist")]
69    pub dist: PathBuf,
70    /// Additional config files to merge into this config.
71    /// Supports plain string paths, `from_file:` for structured file paths,
72    /// and `from_url:` for fetching configs from URLs with optional headers.
73    pub includes: Option<Vec<IncludeSpec>>,
74    /// Environment file configuration. Accepts either:
75    /// - A list of `.env` file paths: `[".env", ".release.env"]`
76    /// - A struct with token file paths: `{ github_token: "~/.config/goreleaser/github_token" }`
77    pub env_files: Option<EnvFilesConfig>,
78    /// Default values applied to all crates unless overridden.
79    pub defaults: Option<Defaults>,
80    /// Hooks run before the release pipeline starts.
81    pub before: Option<HooksConfig>,
82    /// Hooks run after the release pipeline completes.
83    pub after: Option<HooksConfig>,
84    /// Hooks run after build/archive/sign/sbom/checksum complete but
85    /// immediately before the publish phase dispatches any publisher.
86    ///
87    /// Use cases: smoke-test artifacts against the staged dist tree,
88    /// run external validators (antivirus, vulnerability scanners),
89    /// stage external state, or abort the release before any
90    /// publisher writes to a registry.
91    ///
92    /// A non-zero exit code from any hook aborts the release before
93    /// publish runs. Hooks fire in declared order. Use `--skip=before-publish`
94    /// to bypass.
95    pub before_publish: Option<HooksConfig>,
96    /// List of crates in this project.
97    pub crates: Vec<CrateConfig>,
98    /// Changelog generation configuration.
99    pub changelog: Option<ChangelogConfig>,
100    /// Signing configurations for binaries, archives, and checksums.
101    #[serde(default, deserialize_with = "deserialize_signs")]
102    #[schemars(schema_with = "signs_schema")]
103    pub signs: Vec<SignConfig>,
104    /// Binary-specific signing configs (same shape as `signs` but only for
105    /// binary artifacts). The `artifacts` field on each entry is constrained
106    /// at parse time to `binary` / `none` (or omitted) — a broader filter on
107    /// `binary_signs` would silently match nothing because the loop only
108    /// iterates Binary artifacts. Constraint lives in `deserialize_binary_signs`.
109    #[serde(default, deserialize_with = "deserialize_binary_signs")]
110    #[schemars(schema_with = "signs_schema")]
111    pub binary_signs: Vec<SignConfig>,
112    /// Docker image signing configurations.
113    pub docker_signs: Option<Vec<DockerSignConfig>>,
114    // No `alias` attribute needed: unlike `signs`/`sign`, "upx" is already
115    // both singular and plural, so a separate alias adds no value.
116    /// UPX binary compression configurations.
117    #[serde(default, deserialize_with = "deserialize_upx")]
118    #[schemars(schema_with = "upx_schema")]
119    pub upx: Vec<UpxConfig>,
120    /// Snapshot release configuration (local/non-tag builds).
121    pub snapshot: Option<SnapshotConfig>,
122    /// Nightly release configuration.
123    pub nightly: Option<NightlyConfig>,
124    /// Announcement configuration (Slack, Discord, email, etc.).
125    pub announce: Option<AnnounceConfig>,
126    /// When true, log artifact file sizes after building.
127    pub report_sizes: Option<bool>,
128    /// Environment variables available to all template expressions.
129    ///
130    /// List of `KEY=VALUE` strings:
131    /// `env: ["MY_VAR=hello", "DEPLOY_ENV=staging"]`. Order is preserved so
132    /// chained env applications (sign + sbom + notarize) see entries in
133    /// declared order. Values are rendered through the template engine before
134    /// being set, so expressions like `{{ Tag }}` or `{{ Date }}` are
135    /// expanded.
136    #[serde(default)]
137    pub env: Option<Vec<String>>,
138    /// Custom template variables accessible as `{{ Var.<key> }}` in templates.
139    /// Provides a way to define reusable values, especially useful with config includes.
140    ///
141    /// Stored as a `BTreeMap` so rendering iterates in deterministic
142    /// (sorted) key order — without this guarantee, a value that references
143    /// another variable (`b: "{{ Var.a }}_v2"`) could render before its
144    /// dependency on a different process / host. The current resolver is
145    /// single-pass (one render per value), so cross-variable references
146    /// only resolve when the referenced key sorts earlier.
147    pub variables: Option<BTreeMap<String, String>>,
148    /// Generic artifact publisher configurations.
149    pub publishers: Option<Vec<PublisherConfig>>,
150    /// DockerHub description sync configurations.
151    pub dockerhub: Option<Vec<DockerHubConfig>>,
152    /// Artifactory upload configurations.
153    pub artifactories: Option<Vec<ArtifactoryConfig>>,
154    /// CloudSmith publisher configurations.
155    pub cloudsmiths: Option<Vec<CloudSmithConfig>>,
156    /// Top-level Homebrew Cask configurations.
157    /// `homebrew_casks` is a top-level array with its own
158    /// repository, commit_author, directory, skip_upload, hooks, dependencies,
159    /// conflicts, completions, manpages, structured uninstall/zap, etc.
160    pub homebrew_casks: Option<Vec<HomebrewCaskConfig>>,
161    /// Repo-committed files that embed the release version outside
162    /// `Cargo.toml` (e.g. a Helm `Chart.yaml`, an install doc, a README
163    /// badge), given as repo-root-relative path strings. At `tag` time each
164    /// listed file has its occurrences of the old version rewritten to the new
165    /// version — both the bare (`0.1.0`) and `v`-prefixed (`v0.1.0`) forms,
166    /// word-boundary anchored — and is staged into the same bump commit as
167    /// `Cargo.toml` / `Cargo.lock`, so these files never drift from the tag.
168    ///
169    /// ```yaml
170    /// version_files:
171    ///   - charts/cfgd/Chart.yaml
172    ///   - docs/installation.md
173    /// ```
174    pub version_files: Option<Vec<String>>,
175    /// Automatic semantic version tagging configuration.
176    pub tag: Option<TagConfig>,
177    /// Git-level tag discovery and sorting settings.
178    pub git: Option<GitConfig>,
179    /// Partial/split build configuration for fan-out CI pipelines.
180    pub partial: Option<PartialConfig>,
181    /// Independent workspace roots in a monorepo.
182    pub workspaces: Option<Vec<WorkspaceConfig>>,
183    /// Source archive configuration.
184    pub source: Option<SourceConfig>,
185    /// Software bill of materials (SBOM) generation configurations.
186    #[serde(default, deserialize_with = "deserialize_sboms")]
187    #[schemars(schema_with = "sboms_schema")]
188    pub sboms: Vec<SbomConfig>,
189    /// SLSA build-provenance / attestation configuration for binaries and
190    /// archives. In the default `subjects` mode, anodizer writes a subjects
191    /// manifest for `actions/attest-build-provenance`; in `emit` mode it
192    /// generates and signs a self-contained in-toto SLSA provenance statement.
193    /// When omitted (or `enabled: false`), the attestation stage is a no-op.
194    pub attestations: Option<AttestationConfig>,
195    /// GitHub release configuration shared by all crates.
196    pub release: Option<ReleaseConfig>,
197    /// Custom GitHub API/upload/download URLs for GitHub Enterprise installations.
198    pub github_urls: Option<GitHubUrlsConfig>,
199    /// Custom GitLab API/download URLs for self-hosted GitLab installations.
200    pub gitlab_urls: Option<GitLabUrlsConfig>,
201    /// Custom Gitea API/download URLs for self-hosted Gitea installations.
202    pub gitea_urls: Option<GiteaUrlsConfig>,
203    /// Force a specific token type for authentication.
204    /// When set, overrides automatic token detection from environment variables.
205    pub force_token: Option<ForceTokenKind>,
206    /// macOS code signing and notarization configuration.
207    pub notarize: Option<NotarizeConfig>,
208    /// Project metadata configuration (applied to metadata.json output files).
209    pub metadata: Option<MetadataConfig>,
210    /// Template files to render and include as release artifacts.
211    /// File contents are processed through the template engine.
212    pub template_files: Option<Vec<TemplateFileConfig>>,
213    /// Monorepo configuration.
214    /// When configured, tag discovery filters by tag_prefix and the working
215    /// directory is scoped to dir.
216    pub monorepo: Option<MonorepoConfig>,
217    /// Makeself self-extracting archive configurations.
218    #[serde(default, deserialize_with = "deserialize_makeselfs")]
219    #[schemars(schema_with = "makeselfs_schema")]
220    pub makeselfs: Vec<MakeselfConfig>,
221    /// AppImage configurations. Each entry bundles a built Linux binary plus
222    /// its desktop integration into a single self-contained `.AppImage` via
223    /// linuxdeploy.
224    #[serde(default, deserialize_with = "deserialize_appimages")]
225    #[schemars(schema_with = "appimages_schema")]
226    pub appimages: Vec<AppImageConfig>,
227    /// Opt-in post-release verification gate. Runs LAST (after the release is
228    /// created and every publisher has run) and REPORTS post-publish defects —
229    /// missing assets, failed install smoke-tests, glibc-ceiling violations.
230    /// Because it runs after the irreversible publish, a failure exits
231    /// non-zero to flag CI but never undoes the release. Off unless
232    /// `verify_release.enabled: true`.
233    #[serde(default)]
234    pub verify_release: VerifyReleaseConfig,
235    /// Source RPM configuration. Renamed from `srpm:` (singular) for spelling
236    /// parity with `Defaults.srpms` and the rest of the plural-name packaging
237    /// fields. The `srpm:` spelling is still accepted via serde alias for
238    /// back-compat.
239    #[serde(alias = "srpm")]
240    pub srpms: Option<SrpmConfig>,
241    /// Milestone closing configurations.
242    pub milestones: Option<Vec<MilestoneConfig>>,
243    /// Generic HTTP upload configurations.
244    pub uploads: Option<Vec<UploadConfig>>,
245    /// AUR source package publishing configurations (source-only PKGBUILD, not -bin).
246    pub aur_sources: Option<Vec<AurSourceConfig>>,
247    /// Top-level retry configuration applied to network-bound operations
248    /// (announcers, git providers, HTTP uploads, docker pipes). When omitted,
249    /// `RetryConfig::default()` is used (10 attempts, 10s base, 5m cap —
250    /// the project-level retry policy).
251    pub retry: Option<RetryConfig>,
252    /// MCP (Model Context Protocol) server registry publishing
253    /// configuration. When `name` is empty (the default), the publisher is
254    /// skipped. The `mcp:` publisher block.
255    #[serde(default)]
256    pub mcp: McpConfig,
257    /// SchemaStore publisher. Registers the project's JSON Schema(s) on
258    /// SchemaStore at release time. When `schemas` is empty (the default),
259    /// the publisher is skipped. The `schemastore:` publisher block.
260    #[serde(default)]
261    pub schemastore: crate::config::publishers::SchemastoreConfig,
262    /// NPM package registry publishing configurations. One entry per
263    /// published package. In the default `optional-deps` mode anodizer emits
264    /// npm's native per-platform packages (biome / git-cliff pattern); in
265    /// `postinstall` mode it emits a download shim (the `npms:`
266    /// parity).
267    pub npms: Option<Vec<NpmConfig>>,
268    /// GemFury (fury.io) deb/rpm/apk publishing configurations. Mirrors
269    /// The `gemfury:` block. The legacy spelling
270    /// `furies:` is accepted via serde alias; a one-time deprecation
271    /// warning is emitted by [`warn_on_legacy_furies_alias`].
272    #[serde(alias = "furies")]
273    pub gemfury: Option<Vec<GemFuryConfig>>,
274    /// Per-crate metadata derived from each crate's `Cargo.toml [package]`
275    /// table (description / license / homepage / authors). Populated at
276    /// config-load time by [`Config::populate_derived_metadata`], keyed by
277    /// crate name. NOT a user-facing YAML field — it backs the
278    /// crate-aware `meta_*_for` accessors so a plain Rust project gets its
279    /// publisher metadata without repeating it in a top-level `metadata:`
280    /// block. A hand-written `metadata:` field and per-publisher overrides
281    /// still win.
282    #[serde(skip)]
283    #[schemars(skip)]
284    pub derived_metadata: BTreeMap<String, MetadataConfig>,
285}
286
287/// Helper schema function for the signs field (accepts object or array).
288fn signs_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
289    let mut schema = generator.subschema_for::<Vec<SignConfig>>();
290    if let schemars::schema::Schema::Object(ref mut obj) = schema {
291        obj.metadata().description = Some("Artifact signing configurations (cosign, GPG, etc.). Accepts a single object or array.".to_owned());
292    }
293    schema
294}
295
296/// Helper schema function for the upx field (accepts object or array).
297fn upx_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
298    let mut schema = generator.subschema_for::<Vec<UpxConfig>>();
299    if let schemars::schema::Schema::Object(ref mut obj) = schema {
300        obj.metadata().description = Some(
301            "UPX binary compression configurations. Accepts a single object or array.".to_owned(),
302        );
303    }
304    schema
305}
306
307/// Helper schema function for the sboms field (accepts object or array).
308fn sboms_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
309    let mut schema = generator.subschema_for::<Vec<SbomConfig>>();
310    if let schemars::schema::Schema::Object(ref mut obj) = schema {
311        obj.metadata().description =
312            Some("SBOM generation configurations. Accepts a single object or array.".to_owned());
313    }
314    schema
315}
316
317fn default_dist() -> PathBuf {
318    PathBuf::from("./dist")
319}
320
321impl Default for Config {
322    fn default() -> Self {
323        Config {
324            version: None,
325            project_name: String::new(),
326            dist: default_dist(),
327            includes: None,
328            env_files: None,
329            defaults: None,
330            before: None,
331            after: None,
332            before_publish: None,
333            crates: Vec::new(),
334            changelog: None,
335            signs: Vec::new(),
336            binary_signs: Vec::new(),
337            docker_signs: None,
338            upx: Vec::new(),
339            snapshot: None,
340            nightly: None,
341            announce: None,
342            report_sizes: None,
343            env: None,
344            variables: None,
345            publishers: None,
346            dockerhub: None,
347            artifactories: None,
348            cloudsmiths: None,
349            homebrew_casks: None,
350            version_files: None,
351            tag: None,
352            git: None,
353            partial: None,
354            workspaces: None,
355            source: None,
356            sboms: Vec::new(),
357            attestations: None,
358            release: None,
359            github_urls: None,
360            gitlab_urls: None,
361            gitea_urls: None,
362            force_token: None,
363            notarize: None,
364            metadata: None,
365            template_files: None,
366            monorepo: None,
367            makeselfs: Vec::new(),
368            appimages: Vec::new(),
369            verify_release: VerifyReleaseConfig::default(),
370            srpms: None,
371            milestones: None,
372            uploads: None,
373            aur_sources: None,
374            retry: None,
375            mcp: McpConfig::default(),
376            schemastore: crate::config::publishers::SchemastoreConfig::default(),
377            npms: None,
378            gemfury: None,
379            derived_metadata: BTreeMap::new(),
380        }
381    }
382}
383
384impl Config {
385    /// Return the monorepo tag prefix, if configured.
386    ///
387    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())`.
388    pub fn monorepo_tag_prefix(&self) -> Option<&str> {
389        self.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())
390    }
391
392    /// Return the monorepo working directory, if configured.
393    ///
394    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.dir.as_deref())`.
395    pub fn monorepo_dir(&self) -> Option<&str> {
396        self.monorepo.as_ref().and_then(|m| m.dir.as_deref())
397    }
398
399    // --- Project metadata defaulting helpers ---
400    //
401    // Publishers that expose homepage/license/description/maintainer fields
402    // fall back to these when their own field is unset, so a project only
403    // needs to declare metadata once. Resolution precedence (highest first):
404    //
405    //   1. the per-publisher override (the publisher's own config field)
406    //   2. a hand-written top-level `metadata:` YAML field
407    //   3. the value derived from the crate's `Cargo.toml [package]` table
408    //      (populated by `populate_derived_metadata`)
409    //
410    // Steps 1 is enforced by the publisher's `or_else(|| cfg.meta_*_for(..))`
411    // chain; steps 2-3 are enforced inside the `meta_*_for` accessors. A
412    // publisher that knows which crate it is publishing for should call the
413    // crate-aware `meta_*_for(crate_name)` variant so workspace/per-crate
414    // configs resolve each crate's OWN Cargo.toml metadata. The crate-agnostic
415    // `meta_*` variants resolve the top-level `metadata:` block only (no
416    // Cargo.toml fallback) and exist for truly project-level callers.
417
418    /// Per-crate derived metadata for `crate_name`, if `Cargo.toml` supplied any.
419    fn derived_for(&self, crate_name: &str) -> Option<&MetadataConfig> {
420        self.derived_metadata.get(crate_name)
421    }
422
423    /// Name of the primary crate (first declared `crates:` entry, else the
424    /// first workspace crate). Used as the metadata-derivation source for
425    /// project-level publishers (e.g. top-level `homebrew_casks:`) that are
426    /// not bound to a single crate.
427    fn primary_crate_name(&self) -> Option<&str> {
428        self.crates
429            .first()
430            .or_else(|| {
431                self.workspaces
432                    .iter()
433                    .flatten()
434                    .flat_map(|w| w.crates.iter())
435                    .next()
436            })
437            .map(|c| c.name.as_str())
438    }
439
440    /// Project homepage: top-level `metadata.homepage` wins, else the primary
441    /// crate's `Cargo.toml`-derived homepage. For project-level publishers
442    /// (top-level casks) with no owning crate.
443    pub fn meta_homepage_project(&self) -> Option<&str> {
444        self.meta_homepage()
445            .or_else(|| self.meta_homepage_for(self.primary_crate_name()?))
446    }
447
448    /// Project description: top-level `metadata.description` wins, else the
449    /// primary crate's `Cargo.toml`-derived description.
450    pub fn meta_description_project(&self) -> Option<&str> {
451        self.meta_description()
452            .or_else(|| self.meta_description_for(self.primary_crate_name()?))
453    }
454
455    /// Project homepage from `metadata.homepage` (top-level YAML only).
456    pub fn meta_homepage(&self) -> Option<&str> {
457        self.metadata.as_ref().and_then(|m| m.homepage.as_deref())
458    }
459
460    /// Project license from `metadata.license` (top-level YAML only).
461    pub fn meta_license(&self) -> Option<&str> {
462        self.metadata.as_ref().and_then(|m| m.license.as_deref())
463    }
464
465    /// Project description from `metadata.description` (top-level YAML only).
466    pub fn meta_description(&self) -> Option<&str> {
467        self.metadata
468            .as_ref()
469            .and_then(|m| m.description.as_deref())
470    }
471
472    /// Project maintainers from `metadata.maintainers` (top-level YAML only).
473    pub fn meta_maintainers(&self) -> &[String] {
474        self.metadata
475            .as_ref()
476            .and_then(|m| m.maintainers.as_deref())
477            .unwrap_or(&[])
478    }
479
480    /// First maintainer as "Name <email>" or just "Name" (publisher convention).
481    /// Returns None when no maintainers are configured.
482    pub fn meta_first_maintainer(&self) -> Option<&str> {
483        self.meta_maintainers().first().map(|s| s.as_str())
484    }
485
486    /// Homepage for `crate_name`: top-level `metadata.homepage` wins, else the
487    /// value derived from the crate's `Cargo.toml [package]`.
488    pub fn meta_homepage_for(&self, crate_name: &str) -> Option<&str> {
489        self.meta_homepage()
490            .or_else(|| self.derived_for(crate_name)?.homepage.as_deref())
491    }
492
493    /// License for `crate_name`: top-level `metadata.license` wins, else the
494    /// crate's `Cargo.toml [package].license` (never synthesised from
495    /// `license-file`).
496    pub fn meta_license_for(&self, crate_name: &str) -> Option<&str> {
497        self.meta_license()
498            .or_else(|| self.derived_for(crate_name)?.license.as_deref())
499    }
500
501    /// Description for `crate_name`: top-level `metadata.description` wins, else
502    /// the crate's `Cargo.toml [package].description`.
503    pub fn meta_description_for(&self, crate_name: &str) -> Option<&str> {
504        self.meta_description()
505            .or_else(|| self.derived_for(crate_name)?.description.as_deref())
506    }
507
508    /// Maintainers for `crate_name`: top-level `metadata.maintainers` wins
509    /// (when non-empty), else the crate's `Cargo.toml [package].authors`.
510    pub fn meta_maintainers_for(&self, crate_name: &str) -> &[String] {
511        let top = self.meta_maintainers();
512        if !top.is_empty() {
513            return top;
514        }
515        self.derived_for(crate_name)
516            .and_then(|m| m.maintainers.as_deref())
517            .unwrap_or(&[])
518    }
519
520    /// First maintainer for `crate_name` as "Name <email>" or just "Name".
521    pub fn meta_first_maintainer_for(&self, crate_name: &str) -> Option<&str> {
522        self.meta_maintainers_for(crate_name)
523            .first()
524            .map(|s| s.as_str())
525    }
526
527    /// Populate [`Config::derived_metadata`] by reading each crate's
528    /// `Cargo.toml [package]` table (description / license / homepage /
529    /// authors), so publishers resolve a plain Rust project's metadata without
530    /// requiring a top-level `metadata:` YAML block.
531    ///
532    /// Covers every crate the config knows about: top-level `crates:` plus
533    /// every `workspaces[].crates[]`, so single-crate, workspace-lockstep, and
534    /// per-crate configs all populate. Each crate is read from
535    /// `<crate.path>/Cargo.toml` relative to `base_dir` (the directory the
536    /// config was loaded from / the monorepo working directory).
537    ///
538    /// Idempotent and non-destructive: only fills entries; existing
539    /// `derived_metadata` keys are overwritten with a fresh read. Crates whose
540    /// `Cargo.toml` is missing or supplies nothing contribute an all-`None`
541    /// entry (harmless — the accessors treat it as "no value").
542    pub fn populate_derived_metadata(&mut self, base_dir: &std::path::Path) {
543        let crate_paths: Vec<(String, String)> = self
544            .crates
545            .iter()
546            .chain(
547                self.workspaces
548                    .iter()
549                    .flatten()
550                    .flat_map(|w| w.crates.iter()),
551            )
552            .map(|c| (c.name.clone(), c.path.clone()))
553            .collect();
554        for (name, path) in crate_paths {
555            let crate_dir = base_dir.join(&path);
556            let derived = derive_metadata_from_cargo_toml(&crate_dir);
557            self.derived_metadata.insert(name, derived);
558        }
559    }
560
561    /// `true` when any top-level / workspace `signs:` or `binary_signs:`
562    /// entry will invoke gpg (via `SignConfig::is_gpg()`).
563    ///
564    /// Used by preflight to decide whether to probe
565    /// `gpg --faked-system-time` support. `docker_signs:` is excluded
566    /// because that driver only ever invokes cosign.
567    pub fn has_gpg_sign_configured(&self) -> bool {
568        let top_level = self
569            .signs
570            .iter()
571            .chain(self.binary_signs.iter())
572            .any(|s| s.is_gpg());
573        if top_level {
574            return true;
575        }
576        // Workspaces inherit their own signs:/binary_signs: lists.
577        self.workspaces.iter().flatten().any(|w| {
578            w.signs
579                .iter()
580                .chain(w.binary_signs.iter())
581                .any(|s| s.is_gpg())
582        })
583    }
584}
585
586/// Run a deserialization closure on a worker thread sized large enough that
587/// the `Config` derive (60+ `Option<NestedStruct>` fields) cannot exhaust
588/// the host's main-thread stack.
589///
590/// Background: debug builds of `serde_yaml_ng::from_value::<Config>` and
591/// `toml::from_str::<Config>` consume several MiB of stack because each
592/// generated visitor branch for the giant struct lives in a single
593/// monomorphised frame and debug builds neither inline nor tail-call. The
594/// Windows main-thread default reservation is 1 MiB, so any debug-built
595/// integration test that triggers full-config deserialization overflows
596/// before reaching the visitor's body.
597///
598/// Routing every full-`Config` deserialization through this helper keeps
599/// every entry-point platform-agnostic without resorting to per-platform
600/// linker flags or `RUST_MIN_STACK`.
601pub fn deserialize_on_worker<F, T>(f: F) -> anyhow::Result<T>
602where
603    F: FnOnce() -> anyhow::Result<T> + Send + 'static,
604    T: Send + 'static,
605{
606    use anyhow::Context as _;
607
608    // 8 MiB matches the Linux/macOS process default and comfortably exceeds
609    // the ~2 MiB peak observed for debug `Config` deserialization.
610    const WORKER_STACK_SIZE: usize = 8 * 1024 * 1024;
611
612    let handle = std::thread::Builder::new()
613        .stack_size(WORKER_STACK_SIZE)
614        .name("anodizer-config-deserialize".to_string())
615        .spawn(f)
616        .context("failed to spawn config deserialization worker thread")?;
617    match handle.join() {
618        Ok(result) => result,
619        Err(payload) => std::panic::resume_unwind(payload),
620    }
621}
622
623/// Validate the config schema version. Accepts version 1 (default) and 2.
624/// Returns an error for unknown versions.
625pub fn validate_version(config: &Config) -> Result<(), String> {
626    match config.version {
627        None | Some(1) | Some(2) => Ok(()),
628        Some(v) => Err(format!(
629            "unsupported config version: {}. Supported versions are 1 and 2.",
630            v
631        )),
632    }
633}
634
635/// Validate `git.tag_sort` if present. Accepted values:
636/// - `"-version:refname"` (default, lexicographic version sort)
637/// - `"-version:creatordate"` (sort by tag creation date, newest first)
638/// - `"semver"` (Rust-side strict SemVer 2.0.0 ordering, prereleases sort
639///   below their release per spec section 11)
640/// - `"smartsemver"` (same ordering as `semver`, but when the current version
641///   is non-prerelease, prerelease tags are skipped when picking the previous
642///   tag — avoids selecting `v0.2.0-beta.3` as the predecessor of `v0.2.0`)
643///
644/// Returns an error for unrecognized values.
645pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
646    if let Some(ref git) = config.git
647        && let Some(ref sort) = git.tag_sort
648    {
649        match sort.as_str() {
650            "-version:refname" | "-version:creatordate" | "semver" | "smartsemver" => {}
651            other => {
652                return Err(format!(
653                    "unsupported git.tag_sort value: \"{}\". \
654                     Accepted values: \"-version:refname\", \"-version:creatordate\", \
655                     \"semver\", \"smartsemver\".",
656                    other
657                ));
658            }
659        }
660    }
661    Ok(())
662}
663
664/// Validate `partial.by` up front so a stale value is rejected at config-load
665/// time regardless of which target-resolution path runs.
666///
667/// `partial.by` is read in two unrelated places: the host-detection branch of
668/// [`crate::partial::resolve_partial_target`] (which already rejects unknown
669/// values) and the split-matrix generator (which treats anything that is not
670/// `"os"` as `"target"`). Those two readers disagree on an out-of-set value
671/// like the pre-rename `"goos"`: one errors, the other silently mis-groups the
672/// matrix. Centralising the check means a typo fails loudly once, before
673/// either reader can diverge.
674pub fn validate_partial(config: &Config) -> Result<(), String> {
675    if let Some(ref partial) = config.partial
676        && let Some(ref by) = partial.by
677    {
678        match by.as_str() {
679            "os" | "target" => {}
680            other => {
681                return Err(format!(
682                    "unsupported partial.by value: \"{}\". \
683                     Accepted values: \"os\", \"target\".",
684                    other
685                ));
686            }
687        }
688    }
689    Ok(())
690}
691
692/// Known OS values accepted by `archives[].format_overrides[].os`.
693/// The Go runtime's `runtime.GOOS` values the archive pipe
694/// recognises; anything outside this set is almost always a typo
695/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
696const KNOWN_OS: &[&str] = &[
697    "aix",
698    "android",
699    "darwin",
700    "dragonfly",
701    "freebsd",
702    "illumos",
703    "ios",
704    "js",
705    "linux",
706    "netbsd",
707    "openbsd",
708    "plan9",
709    "solaris",
710    "wasip1",
711    "windows",
712];
713
714/// Validate that each crate's `release:` block configures at most one SCM
715/// backend. A multiple-releases error, which
716/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
717/// runtime so a silently-ignored extra backend is easy to miss.
718pub fn validate_release_backends(config: &Config) -> Result<(), String> {
719    let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
720        let mut set = Vec::new();
721        if release.github.is_some() {
722            set.push("github");
723        }
724        if release.gitlab.is_some() {
725            set.push("gitlab");
726        }
727        if release.gitea.is_some() {
728            set.push("gitea");
729        }
730        if set.len() > 1 {
731            return Err(format!(
732                "crate {}: release config sets multiple mutually-exclusive SCM \
733                 backends ({}). Pick one.",
734                crate_name,
735                set.join(" + ")
736            ));
737        }
738        Ok(())
739    };
740    for krate in &config.crates {
741        if let Some(ref release) = krate.release {
742            check(&krate.name, release)?;
743        }
744    }
745    if let Some(ws_list) = config.workspaces.as_ref() {
746        for ws in ws_list {
747            for krate in &ws.crates {
748                if let Some(ref release) = krate.release {
749                    check(&krate.name, release)?;
750                }
751            }
752        }
753    }
754    Ok(())
755}
756
757/// Validate that `release.on_failure` is set only at the root.
758///
759/// The failure policy is one process-wide decision per run, resolved
760/// from the top-level `release:` block alone. Crate-level `release:`
761/// blocks share the `ReleaseConfig` struct, so the field parses there
762/// — but it would never be read; rejecting the misplacement at config
763/// load keeps a policy choice from being silently ignored.
764pub fn validate_on_failure_root_only(config: &Config) -> Result<(), String> {
765    let mut offenders: Vec<&str> = config
766        .crates
767        .iter()
768        .chain(
769            config
770                .workspaces
771                .iter()
772                .flatten()
773                .flat_map(|ws| ws.crates.iter()),
774        )
775        .filter(|c| c.release.as_ref().is_some_and(|r| r.on_failure.is_some()))
776        .map(|c| c.name.as_str())
777        .collect();
778    offenders.sort_unstable();
779    offenders.dedup();
780    if offenders.is_empty() {
781        return Ok(());
782    }
783    Err(format!(
784        "release.on_failure is a root-level policy and cannot be set per crate \
785         (set on: {}). Move it to the top-level `release:` block.",
786        offenders.join(", ")
787    ))
788}
789
790/// Marker prefix for the axis-mismatch validation error class. Existing
791/// validators in this module return `Result<(), String>` rather than a
792/// typed enum, so we expose this constant (instead of a `ConfigError`
793/// variant) for callers that want to recognise the error class
794/// programmatically.
795///
796/// The prefix is emitted at the start of every error returned by
797/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
798/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
799/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
800/// exact human-readable wording.
801///
802/// ```ignore
803/// match validate_defaults_axis(&config) {
804///     Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
805///         // handle the axis-mismatch error class
806///     }
807///     other => other?,
808/// }
809/// ```
810///
811/// Future error-type unification can rename to
812/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
813/// match on this prefix.
814pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
815
816/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
817/// top-level axis.
818///
819/// Rules:
820/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
821/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
822/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
823///   (mutually exclusive).
824/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
825pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
826    let Some(ref defaults) = config.defaults else {
827        return Ok(());
828    };
829    let has_crate_block = defaults.crates.is_some();
830    let has_workspace_block = defaults.workspaces.is_some();
831
832    if has_crate_block && has_workspace_block {
833        return Err(format!(
834            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
835             mutually exclusive — pick the axis that matches the top-level config \
836             (`crates:` or `workspaces:`)",
837        ));
838    }
839
840    let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
841    let top_uses_crates = !config.crates.is_empty();
842
843    if has_crate_block && !top_uses_crates {
844        return Err(format!(
845            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
846             is {}; move defaults under `defaults.workspaces:` or remove the block",
847            if top_uses_workspaces {
848                "absent (top-level uses `workspaces:`)"
849            } else {
850                "absent"
851            },
852        ));
853    }
854    if has_workspace_block && !top_uses_workspaces {
855        return Err(format!(
856            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
857             `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
858            if top_uses_crates {
859                "absent (top-level uses `crates:`)"
860            } else {
861                "absent"
862            },
863        ));
864    }
865
866    Ok(())
867}
868
869/// Validate `archives[].format_overrides[].os` values reject unknown OSes.
870/// Silently no-op-ing unknown overrides has burned users typing
871/// Rust triples like `apple` or `pc-windows-msvc`.
872///
873/// Walks every `archives[]` location in the config:
874/// - `crates[].archives:`
875/// - `workspaces[].crates[].archives:`
876/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
877///   and propagate to every inheriting crate at merge time).
878pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
879    let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
880        for (idx, archive) in archives.iter().enumerate() {
881            let Some(ref overrides) = archive.format_overrides else {
882                continue;
883            };
884            for over in overrides {
885                if !KNOWN_OS.contains(&over.os.as_str()) {
886                    let archive_id = archive.id.as_deref().unwrap_or("default");
887                    return Err(format!(
888                        "{}: archives[{}] (id={}): format_overrides.os=\"{}\" is not a recognised OS. \
889                         Accepted values: {}.",
890                        location,
891                        idx,
892                        archive_id,
893                        over.os,
894                        KNOWN_OS.join(", ")
895                    ));
896                }
897            }
898        }
899        Ok(())
900    };
901    for krate in &config.crates {
902        if let ArchivesConfig::Configs(ref list) = krate.archives {
903            check(&format!("crate {}", krate.name), list)?;
904        }
905    }
906    if let Some(ws_list) = config.workspaces.as_ref() {
907        for ws in ws_list {
908            for krate in &ws.crates {
909                if let ArchivesConfig::Configs(ref list) = krate.archives {
910                    check(&format!("crate {}", krate.name), list)?;
911                }
912            }
913        }
914    }
915    if let Some(ref defaults) = config.defaults
916        && let Some(ref archive) = defaults.archives
917    {
918        // defaults.archives is a single ArchiveConfig (not a list); wrap it
919        // into a one-element slice so the same checker walks it.
920        check("defaults.archives", std::slice::from_ref(archive))?;
921    }
922    Ok(())
923}
924
925/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
926/// `url.template` simultaneously — they are mutually exclusive shorthands
927/// for the same URL field and combining them is ambiguous.
928///
929/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
930/// - `homebrew_casks:` (top-level array)
931/// - `crates[].publish.homebrew_cask:`
932/// - `workspaces[].crates[].publish.homebrew_cask:`
933/// - `defaults.publish.homebrew_cask:`
934pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
935    let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
936        let has_url_template = cask.url_template.is_some();
937        let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
938        if has_url_template && has_url_dot_template {
939            return Err(format!(
940                "{location}: homebrew_cask sets both `url_template` and `url.template`. \
941                 These are mutually exclusive — use one or the other."
942            ));
943        }
944        Ok(())
945    };
946
947    // Top-level homebrew_casks list (not nested under publish:) — not a
948    // publish axis, so it is scanned separately from the visitor.
949    if let Some(ref casks) = config.homebrew_casks {
950        for (i, cask) in casks.iter().enumerate() {
951            check(&format!("homebrew_casks[{i}]"), cask)?;
952        }
953    }
954
955    try_for_each_crate_publish(config, |axis, publish| {
956        if let Some(cask) = publish.homebrew_cask() {
957            check(&axis.homebrew_cask_location(), cask)?;
958        }
959        Ok(())
960    })
961}
962
963/// Validate that `archives[].id` and `universal_binaries[].id` are unique
964/// within their respective lists.
965///
966/// The id-uniqueness validation for archives and universal binaries.
967/// Two archive
968/// configs with the same `id` silently both set the same `id` metadata key
969/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
970/// build/sign stages already enforce id uniqueness; archive and
971/// universal_binary were missed.
972///
973/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
974/// - `crates[].archives:` / `crates[].universal_binaries:`
975/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
976/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
977///   itself is vacuously true; not walked here.
978///
979pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
980    fn check_unique<F>(
981        location: &str,
982        kind: &str,
983        ids: impl IntoIterator<Item = (usize, Option<String>)>,
984        empty_ok: F,
985    ) -> Result<(), String>
986    where
987        F: Fn() -> bool,
988    {
989        let _ = empty_ok;
990        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
991        for (idx, maybe_id) in ids {
992            // Empty is stored as "default" for archives via Default-time
993            // assignment. Anodizer applies `default_archive_id` at deserialize
994            // time, so the option is normally `Some("default")`. A truly empty
995            // / None id here means the user explicitly cleared it; we still
996            // dedupe across `None` so two None-id'd entries collide just like
997            // two "default"-id'd entries would.
998            let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
999            if let Some(prev_idx) = seen.insert(key.clone(), idx) {
1000                return Err(format!(
1001                    "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
1002                     ids must be unique within a {kind} list."
1003                ));
1004            }
1005        }
1006        Ok(())
1007    }
1008
1009    let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1010        check_unique(
1011            location,
1012            "archives",
1013            archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
1014            || true,
1015        )
1016    };
1017    let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
1018        check_unique(
1019            location,
1020            "universal_binaries",
1021            ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
1022            || true,
1023        )
1024    };
1025
1026    for krate in &config.crates {
1027        if let ArchivesConfig::Configs(ref list) = krate.archives {
1028            check_archives(&format!("crates[{}].archives", krate.name), list)?;
1029        }
1030        if let Some(ref ubs) = krate.universal_binaries {
1031            check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
1032        }
1033    }
1034    if let Some(ws_list) = config.workspaces.as_ref() {
1035        for ws in ws_list {
1036            for krate in &ws.crates {
1037                if let ArchivesConfig::Configs(ref list) = krate.archives {
1038                    check_archives(
1039                        &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
1040                        list,
1041                    )?;
1042                }
1043                if let Some(ref ubs) = krate.universal_binaries {
1044                    check_unibins(
1045                        &format!(
1046                            "workspaces[{}].crates[{}].universal_binaries",
1047                            ws.name, krate.name
1048                        ),
1049                        ubs,
1050                    )?;
1051                }
1052            }
1053        }
1054    }
1055    Ok(())
1056}
1057
1058/// Validate `builds[]` entries that opt into `builder: prebuilt`.
1059///
1060/// `builder: prebuilt` skips `cargo build` and imports a binary the
1061/// operator staged elsewhere. The validation rules below follow the
1062/// `prebuilt` builder contract (`/customization/builds/builders/prebuilt.md`):
1063///
1064/// 1. `prebuilt:` block MUST be set and `prebuilt.path` MUST be non-empty.
1065/// 2. `targets:` MUST be explicit on the build entry — no `defaults.targets`
1066///    fallback. Without this rule the build matrix has no rows.
1067/// 3. Cargo-only knobs are rejected as mutually exclusive: `cross_tool`,
1068///    `features`, `no_default_features`, `command`. The crate-level
1069///    `cross:` strategy is also rejected when any build on the crate is
1070///    prebuilt (the strategy has no meaning when nothing is being
1071///    compiled).
1072/// 4. `builder: cargo` (the default) with a `prebuilt:` block set warns —
1073///    the block has no effect and likely indicates a forgotten
1074///    `builder: prebuilt`.
1075pub fn validate_builds(config: &Config) -> Result<(), String> {
1076    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1077        let Some(ref builds) = krate.builds else {
1078            return Ok(());
1079        };
1080        let crate_is_prebuilt = builds
1081            .iter()
1082            .any(|b| matches!(b.builder, Some(BuilderKind::Prebuilt)));
1083        if crate_is_prebuilt && krate.cross.is_some() {
1084            return Err(format!(
1085                "{location}: crate-level `cross:` strategy is set but at least one \
1086                 build uses `builder: prebuilt`; remove `cross:` (prebuilt imports a \
1087                 binary instead of compiling) or change the build's builder to `cargo`."
1088            ));
1089        }
1090        for (idx, build) in builds.iter().enumerate() {
1091            match build.builder {
1092                Some(BuilderKind::Prebuilt) => {
1093                    let path = build.prebuilt.as_ref().map(|p| p.path.trim()).unwrap_or("");
1094                    if path.is_empty() {
1095                        return Err(format!(
1096                            "{location}.builds[{idx}]: `builder: prebuilt` requires a non-empty \
1097                             `prebuilt.path` template. Example: \
1098                             `prebuilt: {{ path: \"output/mybin_{{{{ .Target }}}}\" }}`"
1099                        ));
1100                    }
1101                    let targets_explicit = build.targets.as_ref().is_some_and(|t| !t.is_empty());
1102                    if !targets_explicit {
1103                        return Err(format!(
1104                            "{location}.builds[{idx}] has `builder: prebuilt` but no explicit \
1105                             `targets:` — the prebuilt builder requires per-build target triples \
1106                             (no `defaults.targets:` fallback). Add `targets: [<triple>, ...]`."
1107                        ));
1108                    }
1109                    if build.cross_tool.as_ref().is_some_and(|s| !s.is_empty()) {
1110                        return Err(format!(
1111                            "{location}.builds[{idx}]: `cross_tool` is set with \
1112                             `builder: prebuilt` — the two are mutually exclusive. \
1113                             `cross_tool` controls how cargo cross-compiles; `prebuilt` \
1114                             imports an already-built binary. Drop `cross_tool` or use \
1115                             `builder: cargo`."
1116                        ));
1117                    }
1118                    if build.command.as_ref().is_some_and(|s| !s.is_empty()) {
1119                        return Err(format!(
1120                            "{location}.builds[{idx}]: `command:` override is set with \
1121                             `builder: prebuilt` — the override selects the cargo \
1122                             subcommand, which is not invoked under the prebuilt \
1123                             builder. Drop `command:` or use `builder: cargo`."
1124                        ));
1125                    }
1126                    if build.features.as_ref().is_some_and(|f| !f.is_empty()) {
1127                        return Err(format!(
1128                            "{location}.builds[{idx}]: `features:` is set with \
1129                             `builder: prebuilt` — Cargo features are evaluated at \
1130                             compile time, which the prebuilt builder skips. \
1131                             Drop `features:` or use `builder: cargo`."
1132                        ));
1133                    }
1134                    if build.no_default_features.is_some() {
1135                        return Err(format!(
1136                            "{location}.builds[{idx}]: `no_default_features:` is set with \
1137                             `builder: prebuilt` — Cargo feature flags are evaluated at \
1138                             compile time, which the prebuilt builder skips. \
1139                             Drop the flag or use `builder: cargo`."
1140                        ));
1141                    }
1142                }
1143                Some(BuilderKind::Cargo) | None => {
1144                    if build.prebuilt.is_some() {
1145                        tracing::warn!(
1146                            "{location}: build[{idx}] has a `prebuilt:` block but `builder:` \
1147                             is not `prebuilt`; the block is ignored. Set `builder: prebuilt` \
1148                             or remove the block."
1149                        );
1150                    }
1151                }
1152            }
1153        }
1154        Ok(())
1155    };
1156
1157    for krate in &config.crates {
1158        check_crate(&format!("crates[{}]", krate.name), krate)?;
1159    }
1160    if let Some(ws_list) = config.workspaces.as_ref() {
1161        for ws in ws_list {
1162            for krate in &ws.crates {
1163                check_crate(
1164                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1165                    krate,
1166                )?;
1167            }
1168        }
1169    }
1170    Ok(())
1171}
1172
1173/// Returns `true` if every build entry on every crate has
1174/// `builder: prebuilt`. Used by the determinism harness to short-circuit:
1175/// when no target compiles, there is nothing for the harness to rebuild
1176/// and compare across runs.
1177pub fn all_builds_prebuilt(config: &Config) -> bool {
1178    let crate_all_prebuilt = |krate: &CrateConfig| -> Option<bool> {
1179        let builds = krate.builds.as_ref()?;
1180        if builds.is_empty() {
1181            return None;
1182        }
1183        Some(
1184            builds
1185                .iter()
1186                .all(|b| matches!(b.builder, Some(BuilderKind::Prebuilt))),
1187        )
1188    };
1189
1190    let mut saw_any = false;
1191    for krate in &config.crates {
1192        match crate_all_prebuilt(krate) {
1193            Some(true) => saw_any = true,
1194            Some(false) => return false,
1195            None => {}
1196        }
1197    }
1198    if let Some(ws_list) = config.workspaces.as_ref() {
1199        for ws in ws_list {
1200            for krate in &ws.crates {
1201                match crate_all_prebuilt(krate) {
1202                    Some(true) => saw_any = true,
1203                    Some(false) => return false,
1204                    None => {}
1205                }
1206            }
1207        }
1208    }
1209    saw_any
1210}
1211
1212/// Validate the depth of `changelog.groups[].groups`.
1213///
1214/// Subgroups are capped at ONE level
1215/// (`/customization/publish/changelog.md`: "There can only be one level of
1216/// subgroups"). Anodizer's renderer can technically handle deeper nesting
1217/// (capped at 6 to match Markdown's heading limit), but accepting deeper
1218/// configs silently is a footgun: a config that works in anodizer but is
1219/// rejected here breaks parity for users migrating in.
1220///
1221/// Rejects any `changelog.groups[i].groups[j].groups[..]` configuration
1222/// with a clear error pointing at the offending parent group title.
1223pub fn validate_changelog_groups_depth(config: &Config) -> Result<(), String> {
1224    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1225        let Some(ref groups) = cfg.groups else {
1226            return Ok(());
1227        };
1228        for g in groups {
1229            if let Some(ref subs) = g.groups {
1230                for sub in subs {
1231                    if sub.groups.as_ref().is_some_and(|s| !s.is_empty()) {
1232                        return Err(format!(
1233                            "{location}: changelog group '{}' > '{}' nests further \
1234                             subgroups; GoReleaser permits only one level of subgroups \
1235                             (see https://goreleaser.com/customization/changelog/). \
1236                             Flatten the inner groups into the parent or split into \
1237                             sibling top-level groups.",
1238                            g.title, sub.title
1239                        ));
1240                    }
1241                }
1242            }
1243        }
1244        Ok(())
1245    };
1246    if let Some(ref cfg) = config.changelog {
1247        check("changelog", cfg)?;
1248    }
1249    if let Some(ref ws_list) = config.workspaces {
1250        for ws in ws_list {
1251            if let Some(ref cfg) = ws.changelog {
1252                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1253            }
1254        }
1255    }
1256    Ok(())
1257}
1258
1259/// Validate `changelog.paths[]` syntax.
1260///
1261/// Path patterns are passed straight to `git log -- <path>` (or the
1262/// per-SCM equivalent). Two patterns are always wrong:
1263/// - Leading `/` — git pathspec treats this as anchored-to-CWD which is
1264///   almost never what the user wrote and produces empty changelogs.
1265/// - Empty string — silently matches everything; rejected so a typo
1266///   doesn't disable filtering.
1267///
1268/// Globs containing `**` are accepted (git accepts them) but the docs
1269/// note their semantics differ from gitignore; that's a docs concern,
1270/// not a hard error.
1271pub fn validate_changelog_paths(config: &Config) -> Result<(), String> {
1272    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1273        let Some(ref paths) = cfg.paths else {
1274            return Ok(());
1275        };
1276        for (idx, p) in paths.iter().enumerate() {
1277            if p.is_empty() {
1278                return Err(format!(
1279                    "{location}: changelog.paths[{idx}] is empty; remove the entry \
1280                     or set a real path (empty string matches everything and \
1281                     disables filtering)"
1282                ));
1283            }
1284            if p.starts_with('/') {
1285                return Err(format!(
1286                    "{location}: changelog.paths[{idx}] = {:?} starts with '/'; \
1287                     git pathspec is repo-root-relative — write {:?} instead",
1288                    p,
1289                    p.trim_start_matches('/')
1290                ));
1291            }
1292        }
1293        Ok(())
1294    };
1295    if let Some(ref cfg) = config.changelog {
1296        check("changelog", cfg)?;
1297    }
1298    if let Some(ref ws_list) = config.workspaces {
1299        for ws in ws_list {
1300            if let Some(ref cfg) = ws.changelog {
1301                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1302            }
1303        }
1304    }
1305    Ok(())
1306}
1307
1308// ---------------------------------------------------------------------------
1309// Per-crate publish visitor
1310// ---------------------------------------------------------------------------
1311
1312/// Identifies which of the three publish-config axes a visited block came from.
1313///
1314/// The config-validation walkers each format their own location string from
1315/// this identity, so different walkers can keep their distinct location wording
1316/// (`crate '{name}'` vs `crates[{name}].publish.homebrew_cask`) while sharing a
1317/// single iteration order: crates, then workspaces, then defaults.
1318pub(crate) enum PublishAxis<'a> {
1319    /// A top-level `crates[].publish` block, carrying the crate name.
1320    Crate { name: &'a str },
1321    /// A `workspaces[].crates[].publish` block, carrying the workspace and
1322    /// crate names.
1323    Workspace {
1324        workspace: &'a str,
1325        crate_name: &'a str,
1326    },
1327    /// The `defaults.publish` block.
1328    Defaults,
1329}
1330
1331impl PublishAxis<'_> {
1332    /// Location string in the bare publish-block wording shared by the
1333    /// submitter-required and legacy-Homebrew-Formula warnings:
1334    /// `crate '{name}'`, `workspaces[{ws}].crates[{krate}]`, or
1335    /// `defaults.publish`.
1336    pub(crate) fn location(&self) -> String {
1337        match self {
1338            PublishAxis::Crate { name } => format!("crate '{name}'"),
1339            PublishAxis::Workspace {
1340                workspace,
1341                crate_name,
1342            } => format!("workspaces[{workspace}].crates[{crate_name}]"),
1343            PublishAxis::Defaults => "defaults.publish".to_string(),
1344        }
1345    }
1346
1347    /// Location string in the cask-block wording used by the legacy
1348    /// Homebrew-Cask singular fold: `crates[{name}].publish.homebrew_cask`,
1349    /// `workspaces[{ws}].crates[{krate}].publish.homebrew_cask`, or
1350    /// `defaults.publish.homebrew_cask`.
1351    pub(crate) fn homebrew_cask_location(&self) -> String {
1352        match self {
1353            PublishAxis::Crate { name } => {
1354                format!("crates[{name}].publish.homebrew_cask")
1355            }
1356            PublishAxis::Workspace {
1357                workspace,
1358                crate_name,
1359            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.homebrew_cask"),
1360            PublishAxis::Defaults => "defaults.publish.homebrew_cask".to_string(),
1361        }
1362    }
1363}
1364
1365/// Shared, immutable view over the publisher sub-configs that appear on both
1366/// [`PublishConfig`] (the `crates[].publish` axis) and [`PublishDefaults`] (the
1367/// `defaults.publish` axis). The two underlying structs are distinct types, so
1368/// this enum erases the difference for read-only walkers.
1369pub(crate) enum PublishRef<'a> {
1370    /// A per-crate `publish:` block.
1371    Crate(&'a PublishConfig),
1372    /// The `defaults.publish:` block.
1373    Defaults(&'a PublishDefaults),
1374}
1375
1376impl PublishRef<'_> {
1377    pub(crate) fn homebrew(&self) -> Option<&HomebrewConfig> {
1378        match self {
1379            PublishRef::Crate(p) => p.homebrew.as_ref(),
1380            PublishRef::Defaults(p) => p.homebrew.as_ref(),
1381        }
1382    }
1383
1384    pub(crate) fn chocolatey(&self) -> Option<&ChocolateyConfig> {
1385        match self {
1386            PublishRef::Crate(p) => p.chocolatey.as_ref(),
1387            PublishRef::Defaults(p) => p.chocolatey.as_ref(),
1388        }
1389    }
1390
1391    pub(crate) fn winget(&self) -> Option<&WingetConfig> {
1392        match self {
1393            PublishRef::Crate(p) => p.winget.as_ref(),
1394            PublishRef::Defaults(p) => p.winget.as_ref(),
1395        }
1396    }
1397
1398    pub(crate) fn aur_source(&self) -> Option<&AurSourceConfig> {
1399        match self {
1400            PublishRef::Crate(p) => p.aur_source.as_ref(),
1401            PublishRef::Defaults(p) => p.aur_source.as_ref(),
1402        }
1403    }
1404
1405    pub(crate) fn homebrew_cask(&self) -> Option<&HomebrewCaskConfig> {
1406        match self {
1407            PublishRef::Crate(p) => p.homebrew_cask.as_ref(),
1408            PublishRef::Defaults(p) => p.homebrew_cask.as_ref(),
1409        }
1410    }
1411}
1412
1413/// Shared, mutable view over the publisher sub-configs that appear on both
1414/// [`PublishConfig`] and [`PublishDefaults`]. The `_mut` companion to
1415/// [`PublishRef`], for walkers that fold or rewrite a publisher block in place.
1416pub(crate) enum PublishMut<'a> {
1417    /// A per-crate `publish:` block.
1418    Crate(&'a mut PublishConfig),
1419    /// The `defaults.publish:` block.
1420    Defaults(&'a mut PublishDefaults),
1421}
1422
1423impl PublishMut<'_> {
1424    pub(crate) fn homebrew_cask_mut(&mut self) -> Option<&mut HomebrewCaskConfig> {
1425        match self {
1426            PublishMut::Crate(p) => p.homebrew_cask.as_mut(),
1427            PublishMut::Defaults(p) => p.homebrew_cask.as_mut(),
1428        }
1429    }
1430}
1431
1432/// Visit every `publish:` block across all three config axes — `crates[]`,
1433/// `workspaces[].crates[]`, then `defaults` — in that fixed order, passing each
1434/// block's [`PublishAxis`] identity and a read-only [`PublishRef`] view to
1435/// `visit`. Axes with no `publish:` block are skipped.
1436pub(crate) fn for_each_crate_publish<F>(config: &Config, mut visit: F)
1437where
1438    F: FnMut(PublishAxis<'_>, PublishRef<'_>),
1439{
1440    for krate in &config.crates {
1441        if let Some(ref publish) = krate.publish {
1442            visit(
1443                PublishAxis::Crate { name: &krate.name },
1444                PublishRef::Crate(publish),
1445            );
1446        }
1447    }
1448
1449    if let Some(ref workspaces) = config.workspaces {
1450        for ws in workspaces {
1451            for krate in &ws.crates {
1452                if let Some(ref publish) = krate.publish {
1453                    visit(
1454                        PublishAxis::Workspace {
1455                            workspace: &ws.name,
1456                            crate_name: &krate.name,
1457                        },
1458                        PublishRef::Crate(publish),
1459                    );
1460                }
1461            }
1462        }
1463    }
1464
1465    if let Some(ref defaults) = config.defaults
1466        && let Some(ref publish) = defaults.publish
1467    {
1468        visit(PublishAxis::Defaults, PublishRef::Defaults(publish));
1469    }
1470}
1471
1472/// Fallible companion to [`for_each_crate_publish`]: visits the same three axes
1473/// in the same fixed order, but short-circuits on the first `Err` the callback
1474/// returns, propagating it to the caller. For validators that early-exit on the
1475/// first offending block.
1476pub(crate) fn try_for_each_crate_publish<F, E>(config: &Config, mut visit: F) -> Result<(), E>
1477where
1478    F: FnMut(PublishAxis<'_>, PublishRef<'_>) -> Result<(), E>,
1479{
1480    for krate in &config.crates {
1481        if let Some(ref publish) = krate.publish {
1482            visit(
1483                PublishAxis::Crate { name: &krate.name },
1484                PublishRef::Crate(publish),
1485            )?;
1486        }
1487    }
1488
1489    if let Some(ref workspaces) = config.workspaces {
1490        for ws in workspaces {
1491            for krate in &ws.crates {
1492                if let Some(ref publish) = krate.publish {
1493                    visit(
1494                        PublishAxis::Workspace {
1495                            workspace: &ws.name,
1496                            crate_name: &krate.name,
1497                        },
1498                        PublishRef::Crate(publish),
1499                    )?;
1500                }
1501            }
1502        }
1503    }
1504
1505    if let Some(ref defaults) = config.defaults
1506        && let Some(ref publish) = defaults.publish
1507    {
1508        visit(PublishAxis::Defaults, PublishRef::Defaults(publish))?;
1509    }
1510
1511    Ok(())
1512}
1513
1514/// Mutable companion to [`for_each_crate_publish`]: visits the same three axes
1515/// in the same fixed order, passing a [`PublishMut`] view so the callback can
1516/// rewrite the publisher block in place.
1517pub(crate) fn for_each_crate_publish_mut<F>(config: &mut Config, mut visit: F)
1518where
1519    F: FnMut(PublishAxis<'_>, PublishMut<'_>),
1520{
1521    for krate in &mut config.crates {
1522        if let Some(ref mut publish) = krate.publish {
1523            visit(
1524                PublishAxis::Crate { name: &krate.name },
1525                PublishMut::Crate(publish),
1526            );
1527        }
1528    }
1529
1530    if let Some(ref mut workspaces) = config.workspaces {
1531        for ws in workspaces {
1532            for krate in &mut ws.crates {
1533                if let Some(ref mut publish) = krate.publish {
1534                    visit(
1535                        PublishAxis::Workspace {
1536                            workspace: &ws.name,
1537                            crate_name: &krate.name,
1538                        },
1539                        PublishMut::Crate(publish),
1540                    );
1541                }
1542            }
1543        }
1544    }
1545
1546    if let Some(ref mut defaults) = config.defaults
1547        && let Some(ref mut publish) = defaults.publish
1548    {
1549        visit(PublishAxis::Defaults, PublishMut::Defaults(publish));
1550    }
1551}
1552
1553/// Emit a `tracing::warn!` for each publisher configured with `required: true`
1554/// whose group is Submitter (chocolatey, winget, aur_source).
1555///
1556/// `required: true` on a submitter still fails the release when the
1557/// submission itself fails (it feeds `required_failures()`), but the
1558/// external moderation outcome resolves after the release run and cannot
1559/// be gated on. The warning is non-fatal and clarifies which half of the
1560/// semantics applies. Cargo is excluded: its default is already
1561/// `required: true` and the warning would be noise.
1562///
1563/// Covers all three publish axes — `crates[].publish`,
1564/// `workspaces[].crates[].publish`, and `defaults.publish` (via
1565/// [`for_each_crate_publish`]) — plus the top-level `aur_sources:` list.
1566pub fn warn_on_submitter_required(config: &Config) {
1567    for msg in submitter_required_warnings(config) {
1568        tracing::warn!("{}", msg);
1569    }
1570}
1571
1572/// Pure helper: returns the warning strings without emitting them. Exposed
1573/// for tests; production callers use [`warn_on_submitter_required`].
1574pub(crate) fn submitter_required_warnings(config: &Config) -> Vec<String> {
1575    fn submitter_warning(location: &str, name: &str) -> String {
1576        format!(
1577            "{location}: publisher '{name}' submits to an external moderation queue; \
1578             `required: true` fails the release when the submission itself fails, \
1579             but the eventual moderation outcome happens outside the release run \
1580             and cannot be gated."
1581        )
1582    }
1583
1584    let mut warnings = Vec::new();
1585
1586    for_each_crate_publish(config, |axis, publish| {
1587        let loc = axis.location();
1588        if publish.chocolatey().and_then(|c| c.required) == Some(true) {
1589            warnings.push(submitter_warning(&loc, "chocolatey"));
1590        }
1591        if publish.winget().and_then(|w| w.required) == Some(true) {
1592            warnings.push(submitter_warning(&loc, "winget"));
1593        }
1594        if publish.aur_source().and_then(|a| a.required) == Some(true) {
1595            warnings.push(submitter_warning(&loc, "aur_source"));
1596        }
1597    });
1598
1599    // Top-level aur_sources list (not nested under publish:) — no crate axis,
1600    // distinguish via the index in the list so two top-level entries collide cleanly.
1601    if let Some(ref sources) = config.aur_sources {
1602        for (idx, src) in sources.iter().enumerate() {
1603            if src.required == Some(true) {
1604                let loc = format!("top-level aur_sources[{idx}]");
1605                warnings.push(submitter_warning(&loc, "aur_source"));
1606            }
1607        }
1608    }
1609
1610    warnings
1611}
1612
1613/// No-op preserved for API stability; the legacy `format:` and `builds:`
1614/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
1615/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
1616/// own — every alias hit was already announced at deserialize time.
1617///
1618pub fn apply_archive_legacy_aliases(_config: &mut Config) {
1619    // Intentionally empty — see Deserialize impls.
1620}
1621
1622/// Reject the legacy V1 `dockers:` block at config-load time with a
1623/// clear migration error.
1624///
1625/// anodizer is V2-only by design: it implements `dockers_v2:` and the
1626/// associated multi-arch buildx flow, but does not ship the V1
1627/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
1628/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
1629/// "unknown field `dockers`" message that doesn't tell the user how to
1630/// migrate. This explicit error names the field, points at `dockers_v2:`,
1631/// and references the rationale.
1632///
1633pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
1634    if raw_yaml.get("dockers").is_some() {
1635        return Err(
1636            "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
1637             dockers_v2: only (multi-arch buildx flow). Port the config to `dockers_v2:` per \
1638             https://anodize.dev/docs/migration/docker.html."
1639                .to_string(),
1640        );
1641    }
1642    Ok(())
1643}
1644
1645/// Emit a `tracing::warn!` for each `publish.homebrew:` (Homebrew Formula)
1646/// occurrence in the loaded config. The upstream deprecated the
1647/// Formula publisher in favour of `homebrew_casks:`; anodizer mirrors the
1648/// upstream deprecation so users following the change-log see the
1649/// same migration prompt.
1650///
1651/// Covers three placement axes (matching how `publish.homebrew` may appear):
1652///   * `crates[].publish.homebrew`
1653///   * `workspaces[].crates[].publish.homebrew`
1654///   * `defaults.publish.homebrew`
1655///
1656/// There is no top-level `homebrew:` or `brews:` field on anodizer's
1657/// `Config` — only `homebrew_casks:` lives at the top level — so this
1658/// function does not need a top-level scan.
1659pub fn warn_on_legacy_homebrew_formula(config: &Config) {
1660    for msg in legacy_homebrew_formula_warnings(config) {
1661        tracing::warn!("{}", msg);
1662    }
1663}
1664
1665/// Pure helper: returns the warning strings without emitting them.
1666/// Exposed for tests; production callers use
1667/// [`warn_on_legacy_homebrew_formula`].
1668pub(crate) fn legacy_homebrew_formula_warnings(config: &Config) -> Vec<String> {
1669    fn formula_warning(location: &str) -> String {
1670        format!(
1671            "DEPRECATION: {location}: publish.homebrew (Homebrew Formula) is deprecated upstream \
1672             in GoReleaser v2.16; migrate to homebrew_casks. Cask is now the canonical Homebrew \
1673             distribution channel for pre-compiled binaries. See \
1674             https://anodize.dev/docs/publish/homebrew-casks/ for migration."
1675        )
1676    }
1677
1678    let mut warnings = Vec::new();
1679
1680    for_each_crate_publish(config, |axis, publish| {
1681        if publish.homebrew().is_some() {
1682            warnings.push(formula_warning(&axis.location()));
1683        }
1684    });
1685
1686    warnings
1687}
1688
1689/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
1690/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
1691/// so this function only needs to emit the deprecation warning when the
1692/// raw YAML key was the legacy one.
1693///
1694/// Because serde collapses the two spellings to a single field on parse, we
1695/// lose the information about which key the user wrote. This function
1696/// therefore consults the raw YAML pre-parse value (when supplied) to decide.
1697pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
1698    if let Some(snap) = raw_yaml.get("snapshot")
1699        && snap.get("name_template").is_some()
1700    {
1701        tracing::warn!(
1702            "DEPRECATION: snapshot.name_template is deprecated; use \
1703             snapshot.version_template instead. Both spellings are accepted \
1704             but the legacy key will be removed in a future release."
1705        );
1706    }
1707}
1708
1709/// Emit a one-time deprecation warning when a config uses the legacy
1710/// `furies:` top-level key. Serde transparently folds `furies:` into
1711/// `gemfury:` via `#[serde(alias)]`, so this function consults the raw YAML
1712/// pre-parse value to detect the legacy spelling.
1713///
1714/// The `furies → gemfury` rename messaging.
1715pub fn warn_on_legacy_furies_alias(raw_yaml: &serde_yaml_ng::Value) {
1716    if raw_yaml.get("furies").is_some() {
1717        tracing::warn!(
1718            "DEPRECATION: the top-level `furies:` config key is deprecated since GoReleaser \
1719             Pro v2.14; rename it to `gemfury:`. Both spellings are accepted but the legacy \
1720             key will be removed in a future release."
1721        );
1722    }
1723}
1724
1725/// Emit a one-time deprecation warning for each nfpm config object that uses
1726/// the legacy `builds:` key. Serde transparently folds `builds:` into `ids:`
1727/// via `#[serde(alias = "builds")]` on [`NfpmConfig::ids`], so this function
1728/// consults the raw YAML pre-parse value to detect the legacy spelling that the
1729/// typed parse would otherwise erase.
1730///
1731/// The deprecated `NFPM.Builds` field (use `ids` instead).
1732///
1733/// nfpm config objects appear under the key `nfpm` or `nfpms` (a single map or
1734/// a sequence of maps) at multiple nesting depths — top-level, under
1735/// `defaults:`, under each `crates[]` entry, and under each
1736/// `workspaces[].crates[]` entry. Rather than enumerate every path, this walks
1737/// the tree recursively and inspects a node as an nfpm config only when it is
1738/// the value of an `nfpm:`/`nfpms:` key, so an unrelated `builds:` key
1739/// elsewhere (e.g. archives) is not double-counted.
1740pub fn warn_on_legacy_nfpm_builds(raw_yaml: &serde_yaml_ng::Value) {
1741    fn warn_for_nfpm_value(value: &serde_yaml_ng::Value) {
1742        match value {
1743            serde_yaml_ng::Value::Mapping(_) => {
1744                if value.get("builds").is_some() {
1745                    tracing::warn!(
1746                        "DEPRECATION: nfpm `builds:` is deprecated; use `ids:` instead. \
1747                         Both spellings are accepted but the legacy key will be removed in \
1748                         a future release."
1749                    );
1750                }
1751            }
1752            serde_yaml_ng::Value::Sequence(items) => {
1753                for item in items {
1754                    warn_for_nfpm_value(item);
1755                }
1756            }
1757            _ => {}
1758        }
1759    }
1760
1761    fn descend(value: &serde_yaml_ng::Value) {
1762        match value {
1763            serde_yaml_ng::Value::Mapping(map) => {
1764                for (key, child) in map {
1765                    if matches!(key.as_str(), Some("nfpm") | Some("nfpms")) {
1766                        warn_for_nfpm_value(child);
1767                    }
1768                    descend(child);
1769                }
1770            }
1771            serde_yaml_ng::Value::Sequence(items) => {
1772                for item in items {
1773                    descend(item);
1774                }
1775            }
1776            _ => {}
1777        }
1778    }
1779
1780    descend(raw_yaml);
1781}
1782
1783/// Emit a one-time deprecation warning for each block that carries the legacy
1784/// `disable:` spelling of the canonical `skip:` field. Many config blocks
1785/// (`release`, `changelog`, `snapcraft`, the docker / installer / packager
1786/// blocks, …) accept `disable:` via `#[serde(alias = "disable")]` for
1787/// back-compat with imported configs; serde folds the alias into
1788/// `skip` on parse, erasing which spelling the user wrote. This helper
1789/// consults the raw YAML pre-parse value so porting users get a migration
1790/// prompt pointing at the canonical `skip:`.
1791///
1792/// Detection is allow-listed by enclosing block key, NOT a blind tree walk,
1793/// because free-form string-keyed maps would otherwise produce false
1794/// positives:
1795///   * Free-form string-keyed maps (`variables`, `derived_metadata`,
1796///     `build_args`, `labels`, `annotations`, `env`, header maps, …) let a
1797///     user legitimately name a key `disable`. Matching only when the key's
1798///     immediate enclosing block is allow-listed skips those — the nearest
1799///     named ancestor of such a key is the map's own key (e.g. `build_args`),
1800///     never an allow-listed block.
1801///
1802/// Axis-agnostic: the enclosing block key is identical whether the block sits
1803/// at the top level, under `defaults.<block>`, under `crates[].<block>`, or
1804/// under `workspaces[].crates[].<block>`, so a single nearest-named-ancestor
1805/// rule covers every placement.
1806pub fn warn_on_legacy_disable_alias(raw_yaml: &serde_yaml_ng::Value) {
1807    for msg in legacy_disable_alias_warnings(raw_yaml) {
1808        tracing::warn!("{}", msg);
1809    }
1810}
1811
1812/// Pure helper: returns one warning string per offending `disable:` key,
1813/// each naming the YAML path to the key. Exposed for tests; production callers
1814/// use [`warn_on_legacy_disable_alias`].
1815pub(crate) fn legacy_disable_alias_warnings(raw_yaml: &serde_yaml_ng::Value) -> Vec<String> {
1816    // Block key names whose struct exposes `skip` with `#[serde(alias =
1817    // "disable")]`. Resolved from the field's serde key on its parent (see the
1818    // `alias = "disable"` sites in core). `makeselfs` (top-level) and
1819    // `makeselves` (defaults.) both map to MakeselfConfig, so both are listed;
1820    // `gemfury` and its legacy `furies` alias both map to GemFuryConfig.
1821    const ALLOWLIST: &[&str] = &[
1822        "mcp",
1823        "makeselfs",
1824        "makeselves",
1825        "appimages",
1826        "msis",
1827        "pkgs",
1828        "nsis",
1829        "dockerhub",
1830        "release",
1831        "dockers_v2",
1832        "docker_v2",
1833        "changelog",
1834        "snapcrafts",
1835        "npms",
1836        "gemfury",
1837        "furies",
1838        "publishers",
1839        "sboms",
1840        "aur",
1841        "aur_source",
1842        "aur_sources",
1843        "blobs",
1844        "docker_digest",
1845        "checksum",
1846        "flatpaks",
1847    ];
1848
1849    fn disable_warning(path: &str) -> String {
1850        format!(
1851            "DEPRECATION: {path}: legacy `disable:` is deprecated; rename it to `skip:`. \
1852             Both spellings are accepted but the legacy key will be removed in a future release."
1853        )
1854    }
1855
1856    // `enclosing_block`: the nearest named (non-list-index) ancestor key — the
1857    // block the `disable:` key belongs to. Only warn when it is allow-listed.
1858    fn descend(
1859        value: &serde_yaml_ng::Value,
1860        path: &str,
1861        enclosing_block: Option<&str>,
1862        warnings: &mut Vec<String>,
1863    ) {
1864        match value {
1865            serde_yaml_ng::Value::Mapping(map) => {
1866                for (key, child) in map {
1867                    let Some(key) = key.as_str() else { continue };
1868                    let child_path = if path.is_empty() {
1869                        key.to_string()
1870                    } else {
1871                        format!("{path}.{key}")
1872                    };
1873                    if key == "disable"
1874                        && enclosing_block.is_some_and(|block| ALLOWLIST.contains(&block))
1875                    {
1876                        warnings.push(disable_warning(&child_path));
1877                    }
1878                    descend(child, &child_path, Some(key), warnings);
1879                }
1880            }
1881            serde_yaml_ng::Value::Sequence(items) => {
1882                for (idx, item) in items.iter().enumerate() {
1883                    let item_path = format!("{path}[{idx}]");
1884                    // A list index is not a named ancestor: keep the enclosing
1885                    // block (the list's own key) so e.g. `snapcrafts[0].disable`
1886                    // still resolves to the `snapcrafts` block.
1887                    descend(item, &item_path, enclosing_block, warnings);
1888                }
1889            }
1890            _ => {}
1891        }
1892    }
1893
1894    let mut warnings = Vec::new();
1895    descend(raw_yaml, "", None, &mut warnings);
1896    warnings
1897}
1898
1899/// Reject the legacy nested `mcp.github:` block with a
1900/// clear migration error.
1901///
1902/// The registry metadata that used to live under
1903/// `mcp.github:` (repository owner/name/url) to the top-level `mcp:` block
1904/// (canonical surface: `mcp.repository:`, `mcp.name:`, etc.). Anodizer
1905/// never carried the nested shim — its `McpConfig` has `deny_unknown_fields`
1906/// so the key would otherwise produce a generic "unknown field" message.
1907/// This pre-parse check intercepts the legacy spelling so the user sees a
1908/// migration pointer rather than a schema-shape error.
1909pub fn validate_no_mcp_github(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
1910    if raw_yaml.get("mcp").and_then(|m| m.get("github")).is_some() {
1911        return Err(
1912            "config: nested `mcp.github:` block is not supported — anodizer mirrors GoReleaser \
1913             v2.13.1+ where registry metadata moved to top-level `mcp:` fields (`mcp.name`, \
1914             `mcp.repository.url`, `mcp.repository.source`). Port the nested keys to the \
1915             canonical surface."
1916                .to_string(),
1917        );
1918    }
1919    Ok(())
1920}
1921
1922/// Emit a one-time deprecation warning for each `dockers_v2[].retry:` or
1923/// `docker_manifests[].retry:` block at config-load time. The per-pipe
1924/// `retry:` field is the legacy shape (retry handling moved to
1925/// the top-level `retry:` block); the per-pipe value is still honored at
1926/// resolve-time (see `stage-docker::resolve_retry_params`) but a top-level
1927/// `retry:` is the canonical surface for retry policy. Warning fires once
1928/// per occurrence so users porting from older configs see a clear
1929/// pointer at load time without waiting for the docker pipe to execute.
1930pub fn warn_on_legacy_docker_retry(config: &Config) {
1931    for msg in legacy_docker_retry_warnings(config) {
1932        tracing::warn!("{}", msg);
1933    }
1934}
1935
1936/// Pure helper: returns the warning strings without emitting them. Exposed
1937/// for tests; production callers use [`warn_on_legacy_docker_retry`].
1938pub(crate) fn legacy_docker_retry_warnings(config: &Config) -> Vec<String> {
1939    fn pipe_warning(location: &str, kind: &str) -> String {
1940        format!(
1941            "DEPRECATION: {location}: nested `{kind}.retry:` is deprecated since GoReleaser \
1942             v2.15.3; move retry settings to the top-level `retry:` block. The per-pipe \
1943             value still wins at resolve time for back-compat, but the legacy spelling will \
1944             be removed in a future release."
1945        )
1946    }
1947
1948    let mut warnings = Vec::new();
1949
1950    let scan_crate = |krate: &CrateConfig, prefix: &str, warnings: &mut Vec<String>| {
1951        if let Some(ref v2) = krate.dockers_v2 {
1952            for (i, cfg) in v2.iter().enumerate() {
1953                if cfg.retry.is_some() {
1954                    warnings.push(pipe_warning(
1955                        &format!("{prefix}.dockers_v2[{i}]"),
1956                        "dockers_v2",
1957                    ));
1958                }
1959            }
1960        }
1961        if let Some(ref manifests) = krate.docker_manifests {
1962            for (i, cfg) in manifests.iter().enumerate() {
1963                if cfg.retry.is_some() {
1964                    warnings.push(pipe_warning(
1965                        &format!("{prefix}.docker_manifests[{i}]"),
1966                        "docker_manifests",
1967                    ));
1968                }
1969            }
1970        }
1971    };
1972
1973    for krate in &config.crates {
1974        scan_crate(krate, &format!("crates[{}]", krate.name), &mut warnings);
1975    }
1976
1977    if let Some(ref workspaces) = config.workspaces {
1978        for ws in workspaces {
1979            for krate in &ws.crates {
1980                scan_crate(
1981                    krate,
1982                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1983                    &mut warnings,
1984                );
1985            }
1986        }
1987    }
1988
1989    if let Some(ref defaults) = config.defaults
1990        && let Some(ref v2) = defaults.dockers_v2
1991        && v2.retry.is_some()
1992    {
1993        warnings.push(pipe_warning("defaults.dockers_v2", "dockers_v2"));
1994    }
1995
1996    warnings
1997}
1998
1999/// Fold the deprecated singular Homebrew Cask fields into their canonical
2000/// plural lists and emit a one-time deprecation warning per folded field:
2001///
2002/// - `binary: <name>` → [`HomebrewCaskConfig::binaries`] (the upstream
2003///   renamed `binary:` to `binaries:`).
2004/// - `manpage: <page>` → [`HomebrewCaskConfig::manpages`].
2005///
2006/// anodizer accepts both spellings so imported configs keep parsing.
2007/// The captured values are moved out of [`HomebrewCaskConfig::legacy_binary`]
2008/// and [`HomebrewCaskConfig::legacy_manpage`] so downstream code only ever
2009/// reads the canonical plural fields.
2010///
2011/// The two folds use different insertion order: a legacy
2012/// `binary` is **prepended** to `binaries` so any explicit `binaries:` ordering
2013/// is preserved at the tail, whereas a legacy `manpage` is **appended** to
2014/// `manpages` (the cask renderer does
2015/// `brew.Manpages = append(brew.Manpages, brew.Manpage)`).
2016///
2017/// The fold runs across every config mode — top-level `homebrew_casks`,
2018/// per-crate `publish.homebrew_cask`, `workspaces[].crates[].publish`, and
2019/// `defaults.publish`.
2020pub fn apply_homebrew_cask_legacy_singulars(config: &mut Config) {
2021    /// Fold both deprecated singular fields (`binary:` → `binaries`,
2022    /// `manpage:` → `manpages`) on one cask, returning a warning per folded
2023    /// field. The singular `binary` is prepended to `binaries` so an explicit
2024    /// `binaries[0]` ordering is preserved at the tail; the singular `manpage`
2025    /// is appended to `manpages`.
2026    fn fold_one(location: &str, cask: &mut HomebrewCaskConfig) -> Vec<String> {
2027        let mut warnings = Vec::new();
2028        if let Some(legacy) = cask.legacy_binary.take() {
2029            let entry = HomebrewCaskBinary::Name(legacy.clone());
2030            match cask.binaries {
2031                Some(ref mut list) => list.insert(0, entry),
2032                None => cask.binaries = Some(vec![entry]),
2033            }
2034            warnings.push(format!(
2035                "DEPRECATION: {location}: singular `binary: {legacy}` is deprecated since \
2036                 GoReleaser v2.12.6; use the plural `binaries: [{legacy}]` form. The legacy \
2037                 value has been folded into binaries[0]."
2038            ));
2039        }
2040        if let Some(legacy) = cask.legacy_manpage.take() {
2041            match cask.manpages {
2042                Some(ref mut list) => list.push(legacy.clone()),
2043                None => cask.manpages = Some(vec![legacy.clone()]),
2044            }
2045            warnings.push(format!(
2046                "DEPRECATION: {location}: singular `manpage: {legacy}` is deprecated; \
2047                 use the plural `manpages: [{legacy}]` form. The legacy value has been \
2048                 folded into manpages."
2049            ));
2050        }
2051        warnings
2052    }
2053
2054    let mut warnings = Vec::new();
2055
2056    // Top-level homebrew_casks list (not nested under publish:) — not a
2057    // publish axis, so it is scanned separately from the visitor.
2058    if let Some(ref mut casks) = config.homebrew_casks {
2059        for (i, cask) in casks.iter_mut().enumerate() {
2060            warnings.extend(fold_one(&format!("homebrew_casks[{i}]"), cask));
2061        }
2062    }
2063
2064    for_each_crate_publish_mut(config, |axis, mut publish| {
2065        if let Some(cask) = publish.homebrew_cask_mut() {
2066            warnings.extend(fold_one(&axis.homebrew_cask_location(), cask));
2067        }
2068    });
2069
2070    for msg in warnings {
2071        tracing::warn!("{}", msg);
2072    }
2073}
2074
2075// ---------------------------------------------------------------------------
2076// EnvFilesConfig — accepts list of .env paths OR structured token file paths
2077// ---------------------------------------------------------------------------
2078
2079mod env_files;
2080pub use env_files::*;
2081
2082// ---------------------------------------------------------------------------
2083// Defaults
2084// ---------------------------------------------------------------------------
2085
2086mod defaults;
2087pub use defaults::*;
2088
2089// ---------------------------------------------------------------------------
2090// BuildIgnore — exclude specific os/arch combos from builds
2091// ---------------------------------------------------------------------------
2092
2093mod build;
2094pub use build::*;
2095
2096// ---------------------------------------------------------------------------
2097// ArchivesConfig — untagged enum: false => Disabled, array => Configs
2098// ---------------------------------------------------------------------------
2099
2100mod archives;
2101pub use archives::*;
2102
2103mod completions;
2104pub use completions::*;
2105
2106// ---------------------------------------------------------------------------
2107// ReleaseConfig
2108// ---------------------------------------------------------------------------
2109
2110mod release;
2111pub use release::*;
2112
2113// ---------------------------------------------------------------------------
2114// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
2115// ---------------------------------------------------------------------------
2116
2117mod publishers;
2118pub use publishers::*;
2119
2120// ---------------------------------------------------------------------------
2121// DockerV2Config
2122// ---------------------------------------------------------------------------
2123
2124mod docker;
2125pub use docker::*;
2126
2127// ---------------------------------------------------------------------------
2128// NfpmConfig
2129// ---------------------------------------------------------------------------
2130
2131mod nfpm;
2132pub use nfpm::*;
2133
2134// ---------------------------------------------------------------------------
2135// SnapcraftConfig
2136// ---------------------------------------------------------------------------
2137
2138mod snapcraft;
2139pub use snapcraft::*;
2140// ---------------------------------------------------------------------------
2141// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
2142// ---------------------------------------------------------------------------
2143
2144mod installers;
2145pub use installers::*;
2146
2147// ---------------------------------------------------------------------------
2148// BlobConfig (S3/GCS/Azure cloud storage)
2149// ---------------------------------------------------------------------------
2150
2151mod blob;
2152pub use blob::*;
2153
2154// ---------------------------------------------------------------------------
2155// PartialConfig (split/merge CI fan-out)
2156// ---------------------------------------------------------------------------
2157
2158mod partial;
2159pub use partial::*;
2160
2161// ---------------------------------------------------------------------------
2162// BinstallConfig
2163// ---------------------------------------------------------------------------
2164
2165mod binstall;
2166pub use binstall::*;
2167
2168// ---------------------------------------------------------------------------
2169// NotarizeConfig (macOS code signing and notarization)
2170// ---------------------------------------------------------------------------
2171
2172mod notarize;
2173pub use notarize::*;
2174// ---------------------------------------------------------------------------
2175// SourceConfig
2176// ---------------------------------------------------------------------------
2177
2178mod source;
2179pub use source::*;
2180
2181// ---------------------------------------------------------------------------
2182// SbomConfig
2183// ---------------------------------------------------------------------------
2184
2185mod sbom;
2186pub use sbom::*;
2187
2188// ---------------------------------------------------------------------------
2189// AttestationConfig
2190// ---------------------------------------------------------------------------
2191
2192mod attestation;
2193pub use attestation::*;
2194
2195// ---------------------------------------------------------------------------
2196// VersionSyncConfig
2197// ---------------------------------------------------------------------------
2198
2199mod version_sync;
2200pub use version_sync::*;
2201
2202// ---------------------------------------------------------------------------
2203// ChangelogConfig
2204// ---------------------------------------------------------------------------
2205
2206mod changelog;
2207pub use changelog::*;
2208// ---------------------------------------------------------------------------
2209// SignConfig / DockerSignConfig — lifted to `crate::signing`
2210// ---------------------------------------------------------------------------
2211//
2212// see `crate::signing` for the type definitions. The
2213// re-exports below preserve the historical
2214// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
2215// used by every stage that consumes a sign config.
2216
2217pub use crate::signing::{DockerSignConfig, SignConfig};
2218
2219// ---------------------------------------------------------------------------
2220// UpxConfig
2221// ---------------------------------------------------------------------------
2222
2223mod upx;
2224pub use upx::*;
2225
2226// ---------------------------------------------------------------------------
2227// SnapshotConfig
2228// ---------------------------------------------------------------------------
2229
2230mod snapshot_nightly;
2231pub use snapshot_nightly::*;
2232
2233mod cargo_metadata;
2234pub use cargo_metadata::derive_metadata_from_cargo_toml;
2235
2236// ---------------------------------------------------------------------------
2237// TemplateFileConfig
2238// ---------------------------------------------------------------------------
2239
2240mod templatefiles;
2241pub use templatefiles::*;
2242
2243// ---------------------------------------------------------------------------
2244// AnnounceConfig
2245// ---------------------------------------------------------------------------
2246mod announce;
2247pub use announce::*;
2248// ---------------------------------------------------------------------------
2249// DockerHub description sync
2250// ---------------------------------------------------------------------------
2251
2252mod dockerhub;
2253pub use dockerhub::*;
2254
2255// ---------------------------------------------------------------------------
2256// Artifactory publisher
2257// ---------------------------------------------------------------------------
2258
2259mod artifactory;
2260pub use artifactory::*;
2261
2262// ---------------------------------------------------------------------------
2263// CloudSmith publisher
2264// ---------------------------------------------------------------------------
2265
2266mod cloudsmith;
2267pub use cloudsmith::*;
2268
2269// ---------------------------------------------------------------------------
2270// PublisherConfig
2271// ---------------------------------------------------------------------------
2272
2273mod publisher;
2274pub use publisher::*;
2275
2276// ---------------------------------------------------------------------------
2277// HooksConfig
2278// ---------------------------------------------------------------------------
2279
2280mod hooks;
2281pub use hooks::*;
2282
2283// ---------------------------------------------------------------------------
2284// GitConfig
2285// ---------------------------------------------------------------------------
2286
2287mod git_config;
2288pub use git_config::*;
2289
2290// ---------------------------------------------------------------------------
2291// MonorepoConfig
2292// ---------------------------------------------------------------------------
2293
2294mod monorepo;
2295pub use monorepo::*;
2296
2297// ---------------------------------------------------------------------------
2298// TagConfig
2299// ---------------------------------------------------------------------------
2300
2301mod tag;
2302pub use tag::*;
2303
2304// ---------------------------------------------------------------------------
2305// WorkspaceConfig
2306// ---------------------------------------------------------------------------
2307
2308mod workspace;
2309pub use workspace::*;
2310
2311// ---------------------------------------------------------------------------
2312// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
2313// ---------------------------------------------------------------------------
2314
2315mod retry;
2316pub use retry::*;
2317
2318// ---------------------------------------------------------------------------
2319// PostPublishPollConfig (per-publisher post-publish polling)
2320// ---------------------------------------------------------------------------
2321
2322mod post_publish_poll;
2323pub use post_publish_poll::*;
2324
2325// ---------------------------------------------------------------------------
2326// VerifyReleaseConfig (top-level `verify_release:` post-publish gate)
2327// ---------------------------------------------------------------------------
2328
2329mod verify_release;
2330pub use verify_release::*;
2331
2332// ---------------------------------------------------------------------------
2333// StringOrBool — accepts bool or template string in YAML
2334// ---------------------------------------------------------------------------
2335
2336mod string_or_bool;
2337pub use string_or_bool::*;
2338
2339// ---------------------------------------------------------------------------
2340// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
2341// ---------------------------------------------------------------------------
2342//
2343// All packaging config types live in their own modules under
2344// `crate::packagers`. The re-exports below preserve the historical
2345// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
2346// import paths used by stages and tests.
2347
2348pub use crate::packagers::{
2349    AppImageConfig, AppImageExtra, MakeselfConfig, MakeselfFile, RuntimeHarvest, SrpmConfig,
2350};
2351pub(crate) use crate::packagers::{
2352    appimages_schema, deserialize_appimages, deserialize_makeselfs, makeselfs_schema,
2353};
2354
2355// ---------------------------------------------------------------------------
2356// MilestoneConfig
2357// ---------------------------------------------------------------------------
2358
2359mod milestone;
2360pub use milestone::*;
2361
2362// ---------------------------------------------------------------------------
2363// UploadConfig (generic HTTP upload)
2364// ---------------------------------------------------------------------------
2365
2366mod upload;
2367pub use upload::*;
2368
2369// ---------------------------------------------------------------------------
2370// AurSourceConfig
2371// ---------------------------------------------------------------------------
2372
2373mod aur_source;
2374pub use aur_source::*;
2375
2376// ---------------------------------------------------------------------------
2377// McpConfig (MCP registry publisher)
2378// ---------------------------------------------------------------------------
2379
2380mod mcp;
2381pub use mcp::*;
2382
2383// ---------------------------------------------------------------------------
2384// NpmConfig (NPM package registry publisher)
2385// ---------------------------------------------------------------------------
2386
2387mod npm;
2388pub use npm::*;
2389
2390// ---------------------------------------------------------------------------
2391// GemFuryConfig (Gemfury / fury.io publisher)
2392// ---------------------------------------------------------------------------
2393
2394mod gemfury;
2395pub use gemfury::*;
2396
2397// ---------------------------------------------------------------------------
2398// Tests
2399// ---------------------------------------------------------------------------
2400
2401#[cfg(test)]
2402mod tests;