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/// Marker prefix for the axis-mismatch validation error class. Existing
758/// validators in this module return `Result<(), String>` rather than a
759/// typed enum, so we expose this constant (instead of a `ConfigError`
760/// variant) for callers that want to recognise the error class
761/// programmatically.
762///
763/// The prefix is emitted at the start of every error returned by
764/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
765/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
766/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
767/// exact human-readable wording.
768///
769/// ```ignore
770/// match validate_defaults_axis(&config) {
771///     Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
772///         // handle the axis-mismatch error class
773///     }
774///     other => other?,
775/// }
776/// ```
777///
778/// Future error-type unification can rename to
779/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
780/// match on this prefix.
781pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
782
783/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
784/// top-level axis.
785///
786/// Rules:
787/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
788/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
789/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
790///   (mutually exclusive).
791/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
792pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
793    let Some(ref defaults) = config.defaults else {
794        return Ok(());
795    };
796    let has_crate_block = defaults.crates.is_some();
797    let has_workspace_block = defaults.workspaces.is_some();
798
799    if has_crate_block && has_workspace_block {
800        return Err(format!(
801            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
802             mutually exclusive — pick the axis that matches the top-level config \
803             (`crates:` or `workspaces:`)",
804        ));
805    }
806
807    let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
808    let top_uses_crates = !config.crates.is_empty();
809
810    if has_crate_block && !top_uses_crates {
811        return Err(format!(
812            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
813             is {}; move defaults under `defaults.workspaces:` or remove the block",
814            if top_uses_workspaces {
815                "absent (top-level uses `workspaces:`)"
816            } else {
817                "absent"
818            },
819        ));
820    }
821    if has_workspace_block && !top_uses_workspaces {
822        return Err(format!(
823            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
824             `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
825            if top_uses_crates {
826                "absent (top-level uses `crates:`)"
827            } else {
828                "absent"
829            },
830        ));
831    }
832
833    Ok(())
834}
835
836/// Validate `archives[].format_overrides[].os` values reject unknown OSes.
837/// Silently no-op-ing unknown overrides has burned users typing
838/// Rust triples like `apple` or `pc-windows-msvc`.
839///
840/// Walks every `archives[]` location in the config:
841/// - `crates[].archives:`
842/// - `workspaces[].crates[].archives:`
843/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
844///   and propagate to every inheriting crate at merge time).
845pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
846    let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
847        for (idx, archive) in archives.iter().enumerate() {
848            let Some(ref overrides) = archive.format_overrides else {
849                continue;
850            };
851            for over in overrides {
852                if !KNOWN_OS.contains(&over.os.as_str()) {
853                    let archive_id = archive.id.as_deref().unwrap_or("default");
854                    return Err(format!(
855                        "{}: archives[{}] (id={}): format_overrides.os=\"{}\" is not a recognised OS. \
856                         Accepted values: {}.",
857                        location,
858                        idx,
859                        archive_id,
860                        over.os,
861                        KNOWN_OS.join(", ")
862                    ));
863                }
864            }
865        }
866        Ok(())
867    };
868    for krate in &config.crates {
869        if let ArchivesConfig::Configs(ref list) = krate.archives {
870            check(&format!("crate {}", krate.name), list)?;
871        }
872    }
873    if let Some(ws_list) = config.workspaces.as_ref() {
874        for ws in ws_list {
875            for krate in &ws.crates {
876                if let ArchivesConfig::Configs(ref list) = krate.archives {
877                    check(&format!("crate {}", krate.name), list)?;
878                }
879            }
880        }
881    }
882    if let Some(ref defaults) = config.defaults
883        && let Some(ref archive) = defaults.archives
884    {
885        // defaults.archives is a single ArchiveConfig (not a list); wrap it
886        // into a one-element slice so the same checker walks it.
887        check("defaults.archives", std::slice::from_ref(archive))?;
888    }
889    Ok(())
890}
891
892/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
893/// `url.template` simultaneously — they are mutually exclusive shorthands
894/// for the same URL field and combining them is ambiguous.
895///
896/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
897/// - `homebrew_casks:` (top-level array)
898/// - `crates[].publish.homebrew_cask:`
899/// - `workspaces[].crates[].publish.homebrew_cask:`
900/// - `defaults.publish.homebrew_cask:`
901pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
902    let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
903        let has_url_template = cask.url_template.is_some();
904        let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
905        if has_url_template && has_url_dot_template {
906            return Err(format!(
907                "{location}: homebrew_cask sets both `url_template` and `url.template`. \
908                 These are mutually exclusive — use one or the other."
909            ));
910        }
911        Ok(())
912    };
913
914    // Top-level homebrew_casks list (not nested under publish:) — not a
915    // publish axis, so it is scanned separately from the visitor.
916    if let Some(ref casks) = config.homebrew_casks {
917        for (i, cask) in casks.iter().enumerate() {
918            check(&format!("homebrew_casks[{i}]"), cask)?;
919        }
920    }
921
922    try_for_each_crate_publish(config, |axis, publish| {
923        if let Some(cask) = publish.homebrew_cask() {
924            check(&axis.homebrew_cask_location(), cask)?;
925        }
926        Ok(())
927    })
928}
929
930/// Validate that `archives[].id` and `universal_binaries[].id` are unique
931/// within their respective lists.
932///
933/// The id-uniqueness validation for archives and universal binaries.
934/// Two archive
935/// configs with the same `id` silently both set the same `id` metadata key
936/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
937/// build/sign stages already enforce id uniqueness; archive and
938/// universal_binary were missed.
939///
940/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
941/// - `crates[].archives:` / `crates[].universal_binaries:`
942/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
943/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
944///   itself is vacuously true; not walked here.
945///
946pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
947    fn check_unique<F>(
948        location: &str,
949        kind: &str,
950        ids: impl IntoIterator<Item = (usize, Option<String>)>,
951        empty_ok: F,
952    ) -> Result<(), String>
953    where
954        F: Fn() -> bool,
955    {
956        let _ = empty_ok;
957        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
958        for (idx, maybe_id) in ids {
959            // Empty is stored as "default" for archives via Default-time
960            // assignment. Anodizer applies `default_archive_id` at deserialize
961            // time, so the option is normally `Some("default")`. A truly empty
962            // / None id here means the user explicitly cleared it; we still
963            // dedupe across `None` so two None-id'd entries collide just like
964            // two "default"-id'd entries would.
965            let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
966            if let Some(prev_idx) = seen.insert(key.clone(), idx) {
967                return Err(format!(
968                    "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
969                     ids must be unique within a {kind} list."
970                ));
971            }
972        }
973        Ok(())
974    }
975
976    let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
977        check_unique(
978            location,
979            "archives",
980            archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
981            || true,
982        )
983    };
984    let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
985        check_unique(
986            location,
987            "universal_binaries",
988            ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
989            || true,
990        )
991    };
992
993    for krate in &config.crates {
994        if let ArchivesConfig::Configs(ref list) = krate.archives {
995            check_archives(&format!("crates[{}].archives", krate.name), list)?;
996        }
997        if let Some(ref ubs) = krate.universal_binaries {
998            check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
999        }
1000    }
1001    if let Some(ws_list) = config.workspaces.as_ref() {
1002        for ws in ws_list {
1003            for krate in &ws.crates {
1004                if let ArchivesConfig::Configs(ref list) = krate.archives {
1005                    check_archives(
1006                        &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
1007                        list,
1008                    )?;
1009                }
1010                if let Some(ref ubs) = krate.universal_binaries {
1011                    check_unibins(
1012                        &format!(
1013                            "workspaces[{}].crates[{}].universal_binaries",
1014                            ws.name, krate.name
1015                        ),
1016                        ubs,
1017                    )?;
1018                }
1019            }
1020        }
1021    }
1022    Ok(())
1023}
1024
1025/// Validate `builds[]` entries that opt into `builder: prebuilt`.
1026///
1027/// `builder: prebuilt` skips `cargo build` and imports a binary the
1028/// operator staged elsewhere. The validation rules below follow the
1029/// `prebuilt` builder contract (`/customization/builds/builders/prebuilt.md`):
1030///
1031/// 1. `prebuilt:` block MUST be set and `prebuilt.path` MUST be non-empty.
1032/// 2. `targets:` MUST be explicit on the build entry — no `defaults.targets`
1033///    fallback. Without this rule the build matrix has no rows.
1034/// 3. Cargo-only knobs are rejected as mutually exclusive: `cross_tool`,
1035///    `features`, `no_default_features`, `command`. The crate-level
1036///    `cross:` strategy is also rejected when any build on the crate is
1037///    prebuilt (the strategy has no meaning when nothing is being
1038///    compiled).
1039/// 4. `builder: cargo` (the default) with a `prebuilt:` block set warns —
1040///    the block has no effect and likely indicates a forgotten
1041///    `builder: prebuilt`.
1042pub fn validate_builds(config: &Config) -> Result<(), String> {
1043    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1044        let Some(ref builds) = krate.builds else {
1045            return Ok(());
1046        };
1047        let crate_is_prebuilt = builds
1048            .iter()
1049            .any(|b| matches!(b.builder, Some(BuilderKind::Prebuilt)));
1050        if crate_is_prebuilt && krate.cross.is_some() {
1051            return Err(format!(
1052                "{location}: crate-level `cross:` strategy is set but at least one \
1053                 build uses `builder: prebuilt`; remove `cross:` (prebuilt imports a \
1054                 binary instead of compiling) or change the build's builder to `cargo`."
1055            ));
1056        }
1057        for (idx, build) in builds.iter().enumerate() {
1058            match build.builder {
1059                Some(BuilderKind::Prebuilt) => {
1060                    let path = build.prebuilt.as_ref().map(|p| p.path.trim()).unwrap_or("");
1061                    if path.is_empty() {
1062                        return Err(format!(
1063                            "{location}.builds[{idx}]: `builder: prebuilt` requires a non-empty \
1064                             `prebuilt.path` template. Example: \
1065                             `prebuilt: {{ path: \"output/mybin_{{{{ .Target }}}}\" }}`"
1066                        ));
1067                    }
1068                    let targets_explicit = build.targets.as_ref().is_some_and(|t| !t.is_empty());
1069                    if !targets_explicit {
1070                        return Err(format!(
1071                            "{location}.builds[{idx}] has `builder: prebuilt` but no explicit \
1072                             `targets:` — the prebuilt builder requires per-build target triples \
1073                             (no `defaults.targets:` fallback). Add `targets: [<triple>, ...]`."
1074                        ));
1075                    }
1076                    if build.cross_tool.as_ref().is_some_and(|s| !s.is_empty()) {
1077                        return Err(format!(
1078                            "{location}.builds[{idx}]: `cross_tool` is set with \
1079                             `builder: prebuilt` — the two are mutually exclusive. \
1080                             `cross_tool` controls how cargo cross-compiles; `prebuilt` \
1081                             imports an already-built binary. Drop `cross_tool` or use \
1082                             `builder: cargo`."
1083                        ));
1084                    }
1085                    if build.command.as_ref().is_some_and(|s| !s.is_empty()) {
1086                        return Err(format!(
1087                            "{location}.builds[{idx}]: `command:` override is set with \
1088                             `builder: prebuilt` — the override selects the cargo \
1089                             subcommand, which is not invoked under the prebuilt \
1090                             builder. Drop `command:` or use `builder: cargo`."
1091                        ));
1092                    }
1093                    if build.features.as_ref().is_some_and(|f| !f.is_empty()) {
1094                        return Err(format!(
1095                            "{location}.builds[{idx}]: `features:` is set with \
1096                             `builder: prebuilt` — Cargo features are evaluated at \
1097                             compile time, which the prebuilt builder skips. \
1098                             Drop `features:` or use `builder: cargo`."
1099                        ));
1100                    }
1101                    if build.no_default_features.is_some() {
1102                        return Err(format!(
1103                            "{location}.builds[{idx}]: `no_default_features:` is set with \
1104                             `builder: prebuilt` — Cargo feature flags are evaluated at \
1105                             compile time, which the prebuilt builder skips. \
1106                             Drop the flag or use `builder: cargo`."
1107                        ));
1108                    }
1109                }
1110                Some(BuilderKind::Cargo) | None => {
1111                    if build.prebuilt.is_some() {
1112                        tracing::warn!(
1113                            "{location}: build[{idx}] has a `prebuilt:` block but `builder:` \
1114                             is not `prebuilt`; the block is ignored. Set `builder: prebuilt` \
1115                             or remove the block."
1116                        );
1117                    }
1118                }
1119            }
1120        }
1121        Ok(())
1122    };
1123
1124    for krate in &config.crates {
1125        check_crate(&format!("crates[{}]", krate.name), krate)?;
1126    }
1127    if let Some(ws_list) = config.workspaces.as_ref() {
1128        for ws in ws_list {
1129            for krate in &ws.crates {
1130                check_crate(
1131                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1132                    krate,
1133                )?;
1134            }
1135        }
1136    }
1137    Ok(())
1138}
1139
1140/// Returns `true` if every build entry on every crate has
1141/// `builder: prebuilt`. Used by the determinism harness to short-circuit:
1142/// when no target compiles, there is nothing for the harness to rebuild
1143/// and compare across runs.
1144pub fn all_builds_prebuilt(config: &Config) -> bool {
1145    let crate_all_prebuilt = |krate: &CrateConfig| -> Option<bool> {
1146        let builds = krate.builds.as_ref()?;
1147        if builds.is_empty() {
1148            return None;
1149        }
1150        Some(
1151            builds
1152                .iter()
1153                .all(|b| matches!(b.builder, Some(BuilderKind::Prebuilt))),
1154        )
1155    };
1156
1157    let mut saw_any = false;
1158    for krate in &config.crates {
1159        match crate_all_prebuilt(krate) {
1160            Some(true) => saw_any = true,
1161            Some(false) => return false,
1162            None => {}
1163        }
1164    }
1165    if let Some(ws_list) = config.workspaces.as_ref() {
1166        for ws in ws_list {
1167            for krate in &ws.crates {
1168                match crate_all_prebuilt(krate) {
1169                    Some(true) => saw_any = true,
1170                    Some(false) => return false,
1171                    None => {}
1172                }
1173            }
1174        }
1175    }
1176    saw_any
1177}
1178
1179/// Validate the depth of `changelog.groups[].groups`.
1180///
1181/// Subgroups are capped at ONE level
1182/// (`/customization/publish/changelog.md`: "There can only be one level of
1183/// subgroups"). Anodizer's renderer can technically handle deeper nesting
1184/// (capped at 6 to match Markdown's heading limit), but accepting deeper
1185/// configs silently is a footgun: a config that works in anodizer but is
1186/// rejected here breaks parity for users migrating in.
1187///
1188/// Rejects any `changelog.groups[i].groups[j].groups[..]` configuration
1189/// with a clear error pointing at the offending parent group title.
1190pub fn validate_changelog_groups_depth(config: &Config) -> Result<(), String> {
1191    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1192        let Some(ref groups) = cfg.groups else {
1193            return Ok(());
1194        };
1195        for g in groups {
1196            if let Some(ref subs) = g.groups {
1197                for sub in subs {
1198                    if sub.groups.as_ref().is_some_and(|s| !s.is_empty()) {
1199                        return Err(format!(
1200                            "{location}: changelog group '{}' > '{}' nests further \
1201                             subgroups; GoReleaser permits only one level of subgroups \
1202                             (see https://goreleaser.com/customization/changelog/). \
1203                             Flatten the inner groups into the parent or split into \
1204                             sibling top-level groups.",
1205                            g.title, sub.title
1206                        ));
1207                    }
1208                }
1209            }
1210        }
1211        Ok(())
1212    };
1213    if let Some(ref cfg) = config.changelog {
1214        check("changelog", cfg)?;
1215    }
1216    if let Some(ref ws_list) = config.workspaces {
1217        for ws in ws_list {
1218            if let Some(ref cfg) = ws.changelog {
1219                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1220            }
1221        }
1222    }
1223    Ok(())
1224}
1225
1226/// Validate `changelog.paths[]` syntax.
1227///
1228/// Path patterns are passed straight to `git log -- <path>` (or the
1229/// per-SCM equivalent). Two patterns are always wrong:
1230/// - Leading `/` — git pathspec treats this as anchored-to-CWD which is
1231///   almost never what the user wrote and produces empty changelogs.
1232/// - Empty string — silently matches everything; rejected so a typo
1233///   doesn't disable filtering.
1234///
1235/// Globs containing `**` are accepted (git accepts them) but the docs
1236/// note their semantics differ from gitignore; that's a docs concern,
1237/// not a hard error.
1238pub fn validate_changelog_paths(config: &Config) -> Result<(), String> {
1239    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1240        let Some(ref paths) = cfg.paths else {
1241            return Ok(());
1242        };
1243        for (idx, p) in paths.iter().enumerate() {
1244            if p.is_empty() {
1245                return Err(format!(
1246                    "{location}: changelog.paths[{idx}] is empty; remove the entry \
1247                     or set a real path (empty string matches everything and \
1248                     disables filtering)"
1249                ));
1250            }
1251            if p.starts_with('/') {
1252                return Err(format!(
1253                    "{location}: changelog.paths[{idx}] = {:?} starts with '/'; \
1254                     git pathspec is repo-root-relative — write {:?} instead",
1255                    p,
1256                    p.trim_start_matches('/')
1257                ));
1258            }
1259        }
1260        Ok(())
1261    };
1262    if let Some(ref cfg) = config.changelog {
1263        check("changelog", cfg)?;
1264    }
1265    if let Some(ref ws_list) = config.workspaces {
1266        for ws in ws_list {
1267            if let Some(ref cfg) = ws.changelog {
1268                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1269            }
1270        }
1271    }
1272    Ok(())
1273}
1274
1275// ---------------------------------------------------------------------------
1276// Per-crate publish visitor
1277// ---------------------------------------------------------------------------
1278
1279/// Identifies which of the three publish-config axes a visited block came from.
1280///
1281/// The config-validation walkers each format their own location string from
1282/// this identity, so different walkers can keep their distinct location wording
1283/// (`crate '{name}'` vs `crates[{name}].publish.homebrew_cask`) while sharing a
1284/// single iteration order: crates, then workspaces, then defaults.
1285pub(crate) enum PublishAxis<'a> {
1286    /// A top-level `crates[].publish` block, carrying the crate name.
1287    Crate { name: &'a str },
1288    /// A `workspaces[].crates[].publish` block, carrying the workspace and
1289    /// crate names.
1290    Workspace {
1291        workspace: &'a str,
1292        crate_name: &'a str,
1293    },
1294    /// The `defaults.publish` block.
1295    Defaults,
1296}
1297
1298impl PublishAxis<'_> {
1299    /// Location string in the bare publish-block wording shared by the
1300    /// submitter-required and legacy-Homebrew-Formula warnings:
1301    /// `crate '{name}'`, `workspaces[{ws}].crates[{krate}]`, or
1302    /// `defaults.publish`.
1303    pub(crate) fn location(&self) -> String {
1304        match self {
1305            PublishAxis::Crate { name } => format!("crate '{name}'"),
1306            PublishAxis::Workspace {
1307                workspace,
1308                crate_name,
1309            } => format!("workspaces[{workspace}].crates[{crate_name}]"),
1310            PublishAxis::Defaults => "defaults.publish".to_string(),
1311        }
1312    }
1313
1314    /// Location string in the cask-block wording used by the legacy
1315    /// Homebrew-Cask singular fold: `crates[{name}].publish.homebrew_cask`,
1316    /// `workspaces[{ws}].crates[{krate}].publish.homebrew_cask`, or
1317    /// `defaults.publish.homebrew_cask`.
1318    pub(crate) fn homebrew_cask_location(&self) -> String {
1319        match self {
1320            PublishAxis::Crate { name } => {
1321                format!("crates[{name}].publish.homebrew_cask")
1322            }
1323            PublishAxis::Workspace {
1324                workspace,
1325                crate_name,
1326            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.homebrew_cask"),
1327            PublishAxis::Defaults => "defaults.publish.homebrew_cask".to_string(),
1328        }
1329    }
1330}
1331
1332/// Shared, immutable view over the publisher sub-configs that appear on both
1333/// [`PublishConfig`] (the `crates[].publish` axis) and [`PublishDefaults`] (the
1334/// `defaults.publish` axis). The two underlying structs are distinct types, so
1335/// this enum erases the difference for read-only walkers.
1336pub(crate) enum PublishRef<'a> {
1337    /// A per-crate `publish:` block.
1338    Crate(&'a PublishConfig),
1339    /// The `defaults.publish:` block.
1340    Defaults(&'a PublishDefaults),
1341}
1342
1343impl PublishRef<'_> {
1344    pub(crate) fn homebrew(&self) -> Option<&HomebrewConfig> {
1345        match self {
1346            PublishRef::Crate(p) => p.homebrew.as_ref(),
1347            PublishRef::Defaults(p) => p.homebrew.as_ref(),
1348        }
1349    }
1350
1351    pub(crate) fn chocolatey(&self) -> Option<&ChocolateyConfig> {
1352        match self {
1353            PublishRef::Crate(p) => p.chocolatey.as_ref(),
1354            PublishRef::Defaults(p) => p.chocolatey.as_ref(),
1355        }
1356    }
1357
1358    pub(crate) fn winget(&self) -> Option<&WingetConfig> {
1359        match self {
1360            PublishRef::Crate(p) => p.winget.as_ref(),
1361            PublishRef::Defaults(p) => p.winget.as_ref(),
1362        }
1363    }
1364
1365    pub(crate) fn aur_source(&self) -> Option<&AurSourceConfig> {
1366        match self {
1367            PublishRef::Crate(p) => p.aur_source.as_ref(),
1368            PublishRef::Defaults(p) => p.aur_source.as_ref(),
1369        }
1370    }
1371
1372    pub(crate) fn homebrew_cask(&self) -> Option<&HomebrewCaskConfig> {
1373        match self {
1374            PublishRef::Crate(p) => p.homebrew_cask.as_ref(),
1375            PublishRef::Defaults(p) => p.homebrew_cask.as_ref(),
1376        }
1377    }
1378}
1379
1380/// Shared, mutable view over the publisher sub-configs that appear on both
1381/// [`PublishConfig`] and [`PublishDefaults`]. The `_mut` companion to
1382/// [`PublishRef`], for walkers that fold or rewrite a publisher block in place.
1383pub(crate) enum PublishMut<'a> {
1384    /// A per-crate `publish:` block.
1385    Crate(&'a mut PublishConfig),
1386    /// The `defaults.publish:` block.
1387    Defaults(&'a mut PublishDefaults),
1388}
1389
1390impl PublishMut<'_> {
1391    pub(crate) fn homebrew_cask_mut(&mut self) -> Option<&mut HomebrewCaskConfig> {
1392        match self {
1393            PublishMut::Crate(p) => p.homebrew_cask.as_mut(),
1394            PublishMut::Defaults(p) => p.homebrew_cask.as_mut(),
1395        }
1396    }
1397}
1398
1399/// Visit every `publish:` block across all three config axes — `crates[]`,
1400/// `workspaces[].crates[]`, then `defaults` — in that fixed order, passing each
1401/// block's [`PublishAxis`] identity and a read-only [`PublishRef`] view to
1402/// `visit`. Axes with no `publish:` block are skipped.
1403pub(crate) fn for_each_crate_publish<F>(config: &Config, mut visit: F)
1404where
1405    F: FnMut(PublishAxis<'_>, PublishRef<'_>),
1406{
1407    for krate in &config.crates {
1408        if let Some(ref publish) = krate.publish {
1409            visit(
1410                PublishAxis::Crate { name: &krate.name },
1411                PublishRef::Crate(publish),
1412            );
1413        }
1414    }
1415
1416    if let Some(ref workspaces) = config.workspaces {
1417        for ws in workspaces {
1418            for krate in &ws.crates {
1419                if let Some(ref publish) = krate.publish {
1420                    visit(
1421                        PublishAxis::Workspace {
1422                            workspace: &ws.name,
1423                            crate_name: &krate.name,
1424                        },
1425                        PublishRef::Crate(publish),
1426                    );
1427                }
1428            }
1429        }
1430    }
1431
1432    if let Some(ref defaults) = config.defaults
1433        && let Some(ref publish) = defaults.publish
1434    {
1435        visit(PublishAxis::Defaults, PublishRef::Defaults(publish));
1436    }
1437}
1438
1439/// Fallible companion to [`for_each_crate_publish`]: visits the same three axes
1440/// in the same fixed order, but short-circuits on the first `Err` the callback
1441/// returns, propagating it to the caller. For validators that early-exit on the
1442/// first offending block.
1443pub(crate) fn try_for_each_crate_publish<F, E>(config: &Config, mut visit: F) -> Result<(), E>
1444where
1445    F: FnMut(PublishAxis<'_>, PublishRef<'_>) -> Result<(), E>,
1446{
1447    for krate in &config.crates {
1448        if let Some(ref publish) = krate.publish {
1449            visit(
1450                PublishAxis::Crate { name: &krate.name },
1451                PublishRef::Crate(publish),
1452            )?;
1453        }
1454    }
1455
1456    if let Some(ref workspaces) = config.workspaces {
1457        for ws in workspaces {
1458            for krate in &ws.crates {
1459                if let Some(ref publish) = krate.publish {
1460                    visit(
1461                        PublishAxis::Workspace {
1462                            workspace: &ws.name,
1463                            crate_name: &krate.name,
1464                        },
1465                        PublishRef::Crate(publish),
1466                    )?;
1467                }
1468            }
1469        }
1470    }
1471
1472    if let Some(ref defaults) = config.defaults
1473        && let Some(ref publish) = defaults.publish
1474    {
1475        visit(PublishAxis::Defaults, PublishRef::Defaults(publish))?;
1476    }
1477
1478    Ok(())
1479}
1480
1481/// Mutable companion to [`for_each_crate_publish`]: visits the same three axes
1482/// in the same fixed order, passing a [`PublishMut`] view so the callback can
1483/// rewrite the publisher block in place.
1484pub(crate) fn for_each_crate_publish_mut<F>(config: &mut Config, mut visit: F)
1485where
1486    F: FnMut(PublishAxis<'_>, PublishMut<'_>),
1487{
1488    for krate in &mut config.crates {
1489        if let Some(ref mut publish) = krate.publish {
1490            visit(
1491                PublishAxis::Crate { name: &krate.name },
1492                PublishMut::Crate(publish),
1493            );
1494        }
1495    }
1496
1497    if let Some(ref mut workspaces) = config.workspaces {
1498        for ws in workspaces {
1499            for krate in &mut ws.crates {
1500                if let Some(ref mut publish) = krate.publish {
1501                    visit(
1502                        PublishAxis::Workspace {
1503                            workspace: &ws.name,
1504                            crate_name: &krate.name,
1505                        },
1506                        PublishMut::Crate(publish),
1507                    );
1508                }
1509            }
1510        }
1511    }
1512
1513    if let Some(ref mut defaults) = config.defaults
1514        && let Some(ref mut publish) = defaults.publish
1515    {
1516        visit(PublishAxis::Defaults, PublishMut::Defaults(publish));
1517    }
1518}
1519
1520/// Emit a `tracing::warn!` for each publisher configured with `required: true`
1521/// whose group is Submitter (chocolatey, winget, aur_source).
1522///
1523/// `required: true` on a submitter still fails the release when the
1524/// submission itself fails (it feeds `required_failures()`), but the
1525/// external moderation outcome resolves after the release run and cannot
1526/// be gated on. The warning is non-fatal and clarifies which half of the
1527/// semantics applies. Cargo is excluded: its default is already
1528/// `required: true` and the warning would be noise.
1529///
1530/// Covers all three publish axes — `crates[].publish`,
1531/// `workspaces[].crates[].publish`, and `defaults.publish` (via
1532/// [`for_each_crate_publish`]) — plus the top-level `aur_sources:` list.
1533pub fn warn_on_submitter_required(config: &Config) {
1534    for msg in submitter_required_warnings(config) {
1535        tracing::warn!("{}", msg);
1536    }
1537}
1538
1539/// Pure helper: returns the warning strings without emitting them. Exposed
1540/// for tests; production callers use [`warn_on_submitter_required`].
1541pub(crate) fn submitter_required_warnings(config: &Config) -> Vec<String> {
1542    fn submitter_warning(location: &str, name: &str) -> String {
1543        format!(
1544            "{location}: publisher '{name}' submits to an external moderation queue; \
1545             `required: true` fails the release when the submission itself fails, \
1546             but the eventual moderation outcome happens outside the release run \
1547             and cannot be gated."
1548        )
1549    }
1550
1551    let mut warnings = Vec::new();
1552
1553    for_each_crate_publish(config, |axis, publish| {
1554        let loc = axis.location();
1555        if publish.chocolatey().and_then(|c| c.required) == Some(true) {
1556            warnings.push(submitter_warning(&loc, "chocolatey"));
1557        }
1558        if publish.winget().and_then(|w| w.required) == Some(true) {
1559            warnings.push(submitter_warning(&loc, "winget"));
1560        }
1561        if publish.aur_source().and_then(|a| a.required) == Some(true) {
1562            warnings.push(submitter_warning(&loc, "aur_source"));
1563        }
1564    });
1565
1566    // Top-level aur_sources list (not nested under publish:) — no crate axis,
1567    // distinguish via the index in the list so two top-level entries collide cleanly.
1568    if let Some(ref sources) = config.aur_sources {
1569        for (idx, src) in sources.iter().enumerate() {
1570            if src.required == Some(true) {
1571                let loc = format!("top-level aur_sources[{idx}]");
1572                warnings.push(submitter_warning(&loc, "aur_source"));
1573            }
1574        }
1575    }
1576
1577    warnings
1578}
1579
1580/// No-op preserved for API stability; the legacy `format:` and `builds:`
1581/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
1582/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
1583/// own — every alias hit was already announced at deserialize time.
1584///
1585pub fn apply_archive_legacy_aliases(_config: &mut Config) {
1586    // Intentionally empty — see Deserialize impls.
1587}
1588
1589/// Reject the legacy V1 `dockers:` block at config-load time with a
1590/// clear migration error.
1591///
1592/// anodizer is V2-only by design: it implements `dockers_v2:` and the
1593/// associated multi-arch buildx flow, but does not ship the V1
1594/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
1595/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
1596/// "unknown field `dockers`" message that doesn't tell the user how to
1597/// migrate. This explicit error names the field, points at `dockers_v2:`,
1598/// and references the rationale.
1599///
1600pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
1601    if raw_yaml.get("dockers").is_some() {
1602        return Err(
1603            "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
1604             dockers_v2: only (multi-arch buildx flow). Port the config to `dockers_v2:` per \
1605             https://anodize.dev/docs/migration/docker.html."
1606                .to_string(),
1607        );
1608    }
1609    Ok(())
1610}
1611
1612/// Emit a `tracing::warn!` for each `publish.homebrew:` (Homebrew Formula)
1613/// occurrence in the loaded config. The upstream deprecated the
1614/// Formula publisher in favour of `homebrew_casks:`; anodizer mirrors the
1615/// upstream deprecation so users following the change-log see the
1616/// same migration prompt.
1617///
1618/// Covers three placement axes (matching how `publish.homebrew` may appear):
1619///   * `crates[].publish.homebrew`
1620///   * `workspaces[].crates[].publish.homebrew`
1621///   * `defaults.publish.homebrew`
1622///
1623/// There is no top-level `homebrew:` or `brews:` field on anodizer's
1624/// `Config` — only `homebrew_casks:` lives at the top level — so this
1625/// function does not need a top-level scan.
1626pub fn warn_on_legacy_homebrew_formula(config: &Config) {
1627    for msg in legacy_homebrew_formula_warnings(config) {
1628        tracing::warn!("{}", msg);
1629    }
1630}
1631
1632/// Pure helper: returns the warning strings without emitting them.
1633/// Exposed for tests; production callers use
1634/// [`warn_on_legacy_homebrew_formula`].
1635pub(crate) fn legacy_homebrew_formula_warnings(config: &Config) -> Vec<String> {
1636    fn formula_warning(location: &str) -> String {
1637        format!(
1638            "DEPRECATION: {location}: publish.homebrew (Homebrew Formula) is deprecated upstream \
1639             in GoReleaser v2.16; migrate to homebrew_casks. Cask is now the canonical Homebrew \
1640             distribution channel for pre-compiled binaries. See \
1641             https://anodize.dev/docs/publish/homebrew-casks/ for migration."
1642        )
1643    }
1644
1645    let mut warnings = Vec::new();
1646
1647    for_each_crate_publish(config, |axis, publish| {
1648        if publish.homebrew().is_some() {
1649            warnings.push(formula_warning(&axis.location()));
1650        }
1651    });
1652
1653    warnings
1654}
1655
1656/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
1657/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
1658/// so this function only needs to emit the deprecation warning when the
1659/// raw YAML key was the legacy one.
1660///
1661/// Because serde collapses the two spellings to a single field on parse, we
1662/// lose the information about which key the user wrote. This function
1663/// therefore consults the raw YAML pre-parse value (when supplied) to decide.
1664pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
1665    if let Some(snap) = raw_yaml.get("snapshot")
1666        && snap.get("name_template").is_some()
1667    {
1668        tracing::warn!(
1669            "DEPRECATION: snapshot.name_template is deprecated; use \
1670             snapshot.version_template instead. Both spellings are accepted \
1671             but the legacy key will be removed in a future release."
1672        );
1673    }
1674}
1675
1676/// Emit a one-time deprecation warning when a config uses the legacy
1677/// `furies:` top-level key. Serde transparently folds `furies:` into
1678/// `gemfury:` via `#[serde(alias)]`, so this function consults the raw YAML
1679/// pre-parse value to detect the legacy spelling.
1680///
1681/// The `furies → gemfury` rename messaging.
1682pub fn warn_on_legacy_furies_alias(raw_yaml: &serde_yaml_ng::Value) {
1683    if raw_yaml.get("furies").is_some() {
1684        tracing::warn!(
1685            "DEPRECATION: the top-level `furies:` config key is deprecated since GoReleaser \
1686             Pro v2.14; rename it to `gemfury:`. Both spellings are accepted but the legacy \
1687             key will be removed in a future release."
1688        );
1689    }
1690}
1691
1692/// Emit a one-time deprecation warning for each nfpm config object that uses
1693/// the legacy `builds:` key. Serde transparently folds `builds:` into `ids:`
1694/// via `#[serde(alias = "builds")]` on [`NfpmConfig::ids`], so this function
1695/// consults the raw YAML pre-parse value to detect the legacy spelling that the
1696/// typed parse would otherwise erase.
1697///
1698/// The deprecated `NFPM.Builds` field (use `ids` instead).
1699///
1700/// nfpm config objects appear under the key `nfpm` or `nfpms` (a single map or
1701/// a sequence of maps) at multiple nesting depths — top-level, under
1702/// `defaults:`, under each `crates[]` entry, and under each
1703/// `workspaces[].crates[]` entry. Rather than enumerate every path, this walks
1704/// the tree recursively and inspects a node as an nfpm config only when it is
1705/// the value of an `nfpm:`/`nfpms:` key, so an unrelated `builds:` key
1706/// elsewhere (e.g. archives) is not double-counted.
1707pub fn warn_on_legacy_nfpm_builds(raw_yaml: &serde_yaml_ng::Value) {
1708    fn warn_for_nfpm_value(value: &serde_yaml_ng::Value) {
1709        match value {
1710            serde_yaml_ng::Value::Mapping(_) => {
1711                if value.get("builds").is_some() {
1712                    tracing::warn!(
1713                        "DEPRECATION: nfpm `builds:` is deprecated; use `ids:` instead. \
1714                         Both spellings are accepted but the legacy key will be removed in \
1715                         a future release."
1716                    );
1717                }
1718            }
1719            serde_yaml_ng::Value::Sequence(items) => {
1720                for item in items {
1721                    warn_for_nfpm_value(item);
1722                }
1723            }
1724            _ => {}
1725        }
1726    }
1727
1728    fn descend(value: &serde_yaml_ng::Value) {
1729        match value {
1730            serde_yaml_ng::Value::Mapping(map) => {
1731                for (key, child) in map {
1732                    if matches!(key.as_str(), Some("nfpm") | Some("nfpms")) {
1733                        warn_for_nfpm_value(child);
1734                    }
1735                    descend(child);
1736                }
1737            }
1738            serde_yaml_ng::Value::Sequence(items) => {
1739                for item in items {
1740                    descend(item);
1741                }
1742            }
1743            _ => {}
1744        }
1745    }
1746
1747    descend(raw_yaml);
1748}
1749
1750/// Emit a one-time deprecation warning for each block that carries the legacy
1751/// `disable:` spelling of the canonical `skip:` field. Many config blocks
1752/// (`release`, `changelog`, `snapcraft`, the docker / installer / packager
1753/// blocks, …) accept `disable:` via `#[serde(alias = "disable")]` for
1754/// back-compat with imported configs; serde folds the alias into
1755/// `skip` on parse, erasing which spelling the user wrote. This helper
1756/// consults the raw YAML pre-parse value so porting users get a migration
1757/// prompt pointing at the canonical `skip:`.
1758///
1759/// Detection is allow-listed by enclosing block key, NOT a blind tree walk,
1760/// because free-form string-keyed maps would otherwise produce false
1761/// positives:
1762///   * Free-form string-keyed maps (`variables`, `derived_metadata`,
1763///     `build_args`, `labels`, `annotations`, `env`, header maps, …) let a
1764///     user legitimately name a key `disable`. Matching only when the key's
1765///     immediate enclosing block is allow-listed skips those — the nearest
1766///     named ancestor of such a key is the map's own key (e.g. `build_args`),
1767///     never an allow-listed block.
1768///
1769/// Axis-agnostic: the enclosing block key is identical whether the block sits
1770/// at the top level, under `defaults.<block>`, under `crates[].<block>`, or
1771/// under `workspaces[].crates[].<block>`, so a single nearest-named-ancestor
1772/// rule covers every placement.
1773pub fn warn_on_legacy_disable_alias(raw_yaml: &serde_yaml_ng::Value) {
1774    for msg in legacy_disable_alias_warnings(raw_yaml) {
1775        tracing::warn!("{}", msg);
1776    }
1777}
1778
1779/// Pure helper: returns one warning string per offending `disable:` key,
1780/// each naming the YAML path to the key. Exposed for tests; production callers
1781/// use [`warn_on_legacy_disable_alias`].
1782pub(crate) fn legacy_disable_alias_warnings(raw_yaml: &serde_yaml_ng::Value) -> Vec<String> {
1783    // Block key names whose struct exposes `skip` with `#[serde(alias =
1784    // "disable")]`. Resolved from the field's serde key on its parent (see the
1785    // `alias = "disable"` sites in core). `makeselfs` (top-level) and
1786    // `makeselves` (defaults.) both map to MakeselfConfig, so both are listed;
1787    // `gemfury` and its legacy `furies` alias both map to GemFuryConfig.
1788    const ALLOWLIST: &[&str] = &[
1789        "mcp",
1790        "makeselfs",
1791        "makeselves",
1792        "appimages",
1793        "msis",
1794        "pkgs",
1795        "nsis",
1796        "dockerhub",
1797        "release",
1798        "dockers_v2",
1799        "docker_v2",
1800        "changelog",
1801        "snapcrafts",
1802        "npms",
1803        "gemfury",
1804        "furies",
1805        "publishers",
1806        "sboms",
1807        "aur",
1808        "aur_source",
1809        "aur_sources",
1810        "blobs",
1811        "docker_digest",
1812        "checksum",
1813        "flatpaks",
1814    ];
1815
1816    fn disable_warning(path: &str) -> String {
1817        format!(
1818            "DEPRECATION: {path}: legacy `disable:` is deprecated; rename it to `skip:`. \
1819             Both spellings are accepted but the legacy key will be removed in a future release."
1820        )
1821    }
1822
1823    // `enclosing_block`: the nearest named (non-list-index) ancestor key — the
1824    // block the `disable:` key belongs to. Only warn when it is allow-listed.
1825    fn descend(
1826        value: &serde_yaml_ng::Value,
1827        path: &str,
1828        enclosing_block: Option<&str>,
1829        warnings: &mut Vec<String>,
1830    ) {
1831        match value {
1832            serde_yaml_ng::Value::Mapping(map) => {
1833                for (key, child) in map {
1834                    let Some(key) = key.as_str() else { continue };
1835                    let child_path = if path.is_empty() {
1836                        key.to_string()
1837                    } else {
1838                        format!("{path}.{key}")
1839                    };
1840                    if key == "disable"
1841                        && enclosing_block.is_some_and(|block| ALLOWLIST.contains(&block))
1842                    {
1843                        warnings.push(disable_warning(&child_path));
1844                    }
1845                    descend(child, &child_path, Some(key), warnings);
1846                }
1847            }
1848            serde_yaml_ng::Value::Sequence(items) => {
1849                for (idx, item) in items.iter().enumerate() {
1850                    let item_path = format!("{path}[{idx}]");
1851                    // A list index is not a named ancestor: keep the enclosing
1852                    // block (the list's own key) so e.g. `snapcrafts[0].disable`
1853                    // still resolves to the `snapcrafts` block.
1854                    descend(item, &item_path, enclosing_block, warnings);
1855                }
1856            }
1857            _ => {}
1858        }
1859    }
1860
1861    let mut warnings = Vec::new();
1862    descend(raw_yaml, "", None, &mut warnings);
1863    warnings
1864}
1865
1866/// Reject the legacy nested `mcp.github:` block with a
1867/// clear migration error.
1868///
1869/// The registry metadata that used to live under
1870/// `mcp.github:` (repository owner/name/url) to the top-level `mcp:` block
1871/// (canonical surface: `mcp.repository:`, `mcp.name:`, etc.). Anodizer
1872/// never carried the nested shim — its `McpConfig` has `deny_unknown_fields`
1873/// so the key would otherwise produce a generic "unknown field" message.
1874/// This pre-parse check intercepts the legacy spelling so the user sees a
1875/// migration pointer rather than a schema-shape error.
1876pub fn validate_no_mcp_github(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
1877    if raw_yaml.get("mcp").and_then(|m| m.get("github")).is_some() {
1878        return Err(
1879            "config: nested `mcp.github:` block is not supported — anodizer mirrors GoReleaser \
1880             v2.13.1+ where registry metadata moved to top-level `mcp:` fields (`mcp.name`, \
1881             `mcp.repository.url`, `mcp.repository.source`). Port the nested keys to the \
1882             canonical surface."
1883                .to_string(),
1884        );
1885    }
1886    Ok(())
1887}
1888
1889/// Emit a one-time deprecation warning for each `dockers_v2[].retry:` or
1890/// `docker_manifests[].retry:` block at config-load time. The per-pipe
1891/// `retry:` field is the legacy shape (retry handling moved to
1892/// the top-level `retry:` block); the per-pipe value is still honored at
1893/// resolve-time (see `stage-docker::resolve_retry_params`) but a top-level
1894/// `retry:` is the canonical surface for retry policy. Warning fires once
1895/// per occurrence so users porting from older configs see a clear
1896/// pointer at load time without waiting for the docker pipe to execute.
1897pub fn warn_on_legacy_docker_retry(config: &Config) {
1898    for msg in legacy_docker_retry_warnings(config) {
1899        tracing::warn!("{}", msg);
1900    }
1901}
1902
1903/// Pure helper: returns the warning strings without emitting them. Exposed
1904/// for tests; production callers use [`warn_on_legacy_docker_retry`].
1905pub(crate) fn legacy_docker_retry_warnings(config: &Config) -> Vec<String> {
1906    fn pipe_warning(location: &str, kind: &str) -> String {
1907        format!(
1908            "DEPRECATION: {location}: nested `{kind}.retry:` is deprecated since GoReleaser \
1909             v2.15.3; move retry settings to the top-level `retry:` block. The per-pipe \
1910             value still wins at resolve time for back-compat, but the legacy spelling will \
1911             be removed in a future release."
1912        )
1913    }
1914
1915    let mut warnings = Vec::new();
1916
1917    let scan_crate = |krate: &CrateConfig, prefix: &str, warnings: &mut Vec<String>| {
1918        if let Some(ref v2) = krate.dockers_v2 {
1919            for (i, cfg) in v2.iter().enumerate() {
1920                if cfg.retry.is_some() {
1921                    warnings.push(pipe_warning(
1922                        &format!("{prefix}.dockers_v2[{i}]"),
1923                        "dockers_v2",
1924                    ));
1925                }
1926            }
1927        }
1928        if let Some(ref manifests) = krate.docker_manifests {
1929            for (i, cfg) in manifests.iter().enumerate() {
1930                if cfg.retry.is_some() {
1931                    warnings.push(pipe_warning(
1932                        &format!("{prefix}.docker_manifests[{i}]"),
1933                        "docker_manifests",
1934                    ));
1935                }
1936            }
1937        }
1938    };
1939
1940    for krate in &config.crates {
1941        scan_crate(krate, &format!("crates[{}]", krate.name), &mut warnings);
1942    }
1943
1944    if let Some(ref workspaces) = config.workspaces {
1945        for ws in workspaces {
1946            for krate in &ws.crates {
1947                scan_crate(
1948                    krate,
1949                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1950                    &mut warnings,
1951                );
1952            }
1953        }
1954    }
1955
1956    if let Some(ref defaults) = config.defaults
1957        && let Some(ref v2) = defaults.dockers_v2
1958        && v2.retry.is_some()
1959    {
1960        warnings.push(pipe_warning("defaults.dockers_v2", "dockers_v2"));
1961    }
1962
1963    warnings
1964}
1965
1966/// Fold the deprecated singular Homebrew Cask fields into their canonical
1967/// plural lists and emit a one-time deprecation warning per folded field:
1968///
1969/// - `binary: <name>` → [`HomebrewCaskConfig::binaries`] (the upstream
1970///   renamed `binary:` to `binaries:`).
1971/// - `manpage: <page>` → [`HomebrewCaskConfig::manpages`].
1972///
1973/// anodizer accepts both spellings so imported configs keep parsing.
1974/// The captured values are moved out of [`HomebrewCaskConfig::legacy_binary`]
1975/// and [`HomebrewCaskConfig::legacy_manpage`] so downstream code only ever
1976/// reads the canonical plural fields.
1977///
1978/// The two folds use different insertion order: a legacy
1979/// `binary` is **prepended** to `binaries` so any explicit `binaries:` ordering
1980/// is preserved at the tail, whereas a legacy `manpage` is **appended** to
1981/// `manpages` (the cask renderer does
1982/// `brew.Manpages = append(brew.Manpages, brew.Manpage)`).
1983///
1984/// The fold runs across every config mode — top-level `homebrew_casks`,
1985/// per-crate `publish.homebrew_cask`, `workspaces[].crates[].publish`, and
1986/// `defaults.publish`.
1987pub fn apply_homebrew_cask_legacy_singulars(config: &mut Config) {
1988    /// Fold both deprecated singular fields (`binary:` → `binaries`,
1989    /// `manpage:` → `manpages`) on one cask, returning a warning per folded
1990    /// field. The singular `binary` is prepended to `binaries` so an explicit
1991    /// `binaries[0]` ordering is preserved at the tail; the singular `manpage`
1992    /// is appended to `manpages`.
1993    fn fold_one(location: &str, cask: &mut HomebrewCaskConfig) -> Vec<String> {
1994        let mut warnings = Vec::new();
1995        if let Some(legacy) = cask.legacy_binary.take() {
1996            let entry = HomebrewCaskBinary::Name(legacy.clone());
1997            match cask.binaries {
1998                Some(ref mut list) => list.insert(0, entry),
1999                None => cask.binaries = Some(vec![entry]),
2000            }
2001            warnings.push(format!(
2002                "DEPRECATION: {location}: singular `binary: {legacy}` is deprecated since \
2003                 GoReleaser v2.12.6; use the plural `binaries: [{legacy}]` form. The legacy \
2004                 value has been folded into binaries[0]."
2005            ));
2006        }
2007        if let Some(legacy) = cask.legacy_manpage.take() {
2008            match cask.manpages {
2009                Some(ref mut list) => list.push(legacy.clone()),
2010                None => cask.manpages = Some(vec![legacy.clone()]),
2011            }
2012            warnings.push(format!(
2013                "DEPRECATION: {location}: singular `manpage: {legacy}` is deprecated; \
2014                 use the plural `manpages: [{legacy}]` form. The legacy value has been \
2015                 folded into manpages."
2016            ));
2017        }
2018        warnings
2019    }
2020
2021    let mut warnings = Vec::new();
2022
2023    // Top-level homebrew_casks list (not nested under publish:) — not a
2024    // publish axis, so it is scanned separately from the visitor.
2025    if let Some(ref mut casks) = config.homebrew_casks {
2026        for (i, cask) in casks.iter_mut().enumerate() {
2027            warnings.extend(fold_one(&format!("homebrew_casks[{i}]"), cask));
2028        }
2029    }
2030
2031    for_each_crate_publish_mut(config, |axis, mut publish| {
2032        if let Some(cask) = publish.homebrew_cask_mut() {
2033            warnings.extend(fold_one(&axis.homebrew_cask_location(), cask));
2034        }
2035    });
2036
2037    for msg in warnings {
2038        tracing::warn!("{}", msg);
2039    }
2040}
2041
2042// ---------------------------------------------------------------------------
2043// EnvFilesConfig — accepts list of .env paths OR structured token file paths
2044// ---------------------------------------------------------------------------
2045
2046mod env_files;
2047pub use env_files::*;
2048
2049// ---------------------------------------------------------------------------
2050// Defaults
2051// ---------------------------------------------------------------------------
2052
2053mod defaults;
2054pub use defaults::*;
2055
2056// ---------------------------------------------------------------------------
2057// BuildIgnore — exclude specific os/arch combos from builds
2058// ---------------------------------------------------------------------------
2059
2060mod build;
2061pub use build::*;
2062
2063// ---------------------------------------------------------------------------
2064// ArchivesConfig — untagged enum: false => Disabled, array => Configs
2065// ---------------------------------------------------------------------------
2066
2067mod archives;
2068pub use archives::*;
2069
2070mod completions;
2071pub use completions::*;
2072
2073// ---------------------------------------------------------------------------
2074// ReleaseConfig
2075// ---------------------------------------------------------------------------
2076
2077mod release;
2078pub use release::*;
2079
2080// ---------------------------------------------------------------------------
2081// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
2082// ---------------------------------------------------------------------------
2083
2084mod publishers;
2085pub use publishers::*;
2086
2087// ---------------------------------------------------------------------------
2088// DockerV2Config
2089// ---------------------------------------------------------------------------
2090
2091mod docker;
2092pub use docker::*;
2093
2094// ---------------------------------------------------------------------------
2095// NfpmConfig
2096// ---------------------------------------------------------------------------
2097
2098mod nfpm;
2099pub use nfpm::*;
2100
2101// ---------------------------------------------------------------------------
2102// SnapcraftConfig
2103// ---------------------------------------------------------------------------
2104
2105mod snapcraft;
2106pub use snapcraft::*;
2107// ---------------------------------------------------------------------------
2108// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
2109// ---------------------------------------------------------------------------
2110
2111mod installers;
2112pub use installers::*;
2113
2114// ---------------------------------------------------------------------------
2115// BlobConfig (S3/GCS/Azure cloud storage)
2116// ---------------------------------------------------------------------------
2117
2118mod blob;
2119pub use blob::*;
2120
2121// ---------------------------------------------------------------------------
2122// PartialConfig (split/merge CI fan-out)
2123// ---------------------------------------------------------------------------
2124
2125mod partial;
2126pub use partial::*;
2127
2128// ---------------------------------------------------------------------------
2129// BinstallConfig
2130// ---------------------------------------------------------------------------
2131
2132mod binstall;
2133pub use binstall::*;
2134
2135// ---------------------------------------------------------------------------
2136// NotarizeConfig (macOS code signing and notarization)
2137// ---------------------------------------------------------------------------
2138
2139mod notarize;
2140pub use notarize::*;
2141// ---------------------------------------------------------------------------
2142// SourceConfig
2143// ---------------------------------------------------------------------------
2144
2145mod source;
2146pub use source::*;
2147
2148// ---------------------------------------------------------------------------
2149// SbomConfig
2150// ---------------------------------------------------------------------------
2151
2152mod sbom;
2153pub use sbom::*;
2154
2155// ---------------------------------------------------------------------------
2156// AttestationConfig
2157// ---------------------------------------------------------------------------
2158
2159mod attestation;
2160pub use attestation::*;
2161
2162// ---------------------------------------------------------------------------
2163// VersionSyncConfig
2164// ---------------------------------------------------------------------------
2165
2166mod version_sync;
2167pub use version_sync::*;
2168
2169// ---------------------------------------------------------------------------
2170// ChangelogConfig
2171// ---------------------------------------------------------------------------
2172
2173mod changelog;
2174pub use changelog::*;
2175// ---------------------------------------------------------------------------
2176// SignConfig / DockerSignConfig — lifted to `crate::signing`
2177// ---------------------------------------------------------------------------
2178//
2179// see `crate::signing` for the type definitions. The
2180// re-exports below preserve the historical
2181// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
2182// used by every stage that consumes a sign config.
2183
2184pub use crate::signing::{DockerSignConfig, SignConfig};
2185
2186// ---------------------------------------------------------------------------
2187// UpxConfig
2188// ---------------------------------------------------------------------------
2189
2190mod upx;
2191pub use upx::*;
2192
2193// ---------------------------------------------------------------------------
2194// SnapshotConfig
2195// ---------------------------------------------------------------------------
2196
2197mod snapshot_nightly;
2198pub use snapshot_nightly::*;
2199
2200mod cargo_metadata;
2201pub use cargo_metadata::derive_metadata_from_cargo_toml;
2202
2203// ---------------------------------------------------------------------------
2204// TemplateFileConfig
2205// ---------------------------------------------------------------------------
2206
2207mod templatefiles;
2208pub use templatefiles::*;
2209
2210// ---------------------------------------------------------------------------
2211// AnnounceConfig
2212// ---------------------------------------------------------------------------
2213mod announce;
2214pub use announce::*;
2215// ---------------------------------------------------------------------------
2216// DockerHub description sync
2217// ---------------------------------------------------------------------------
2218
2219mod dockerhub;
2220pub use dockerhub::*;
2221
2222// ---------------------------------------------------------------------------
2223// Artifactory publisher
2224// ---------------------------------------------------------------------------
2225
2226mod artifactory;
2227pub use artifactory::*;
2228
2229// ---------------------------------------------------------------------------
2230// CloudSmith publisher
2231// ---------------------------------------------------------------------------
2232
2233mod cloudsmith;
2234pub use cloudsmith::*;
2235
2236// ---------------------------------------------------------------------------
2237// PublisherConfig
2238// ---------------------------------------------------------------------------
2239
2240mod publisher;
2241pub use publisher::*;
2242
2243// ---------------------------------------------------------------------------
2244// HooksConfig
2245// ---------------------------------------------------------------------------
2246
2247mod hooks;
2248pub use hooks::*;
2249
2250// ---------------------------------------------------------------------------
2251// GitConfig
2252// ---------------------------------------------------------------------------
2253
2254mod git_config;
2255pub use git_config::*;
2256
2257// ---------------------------------------------------------------------------
2258// MonorepoConfig
2259// ---------------------------------------------------------------------------
2260
2261mod monorepo;
2262pub use monorepo::*;
2263
2264// ---------------------------------------------------------------------------
2265// TagConfig
2266// ---------------------------------------------------------------------------
2267
2268mod tag;
2269pub use tag::*;
2270
2271// ---------------------------------------------------------------------------
2272// WorkspaceConfig
2273// ---------------------------------------------------------------------------
2274
2275mod workspace;
2276pub use workspace::*;
2277
2278// ---------------------------------------------------------------------------
2279// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
2280// ---------------------------------------------------------------------------
2281
2282mod retry;
2283pub use retry::*;
2284
2285// ---------------------------------------------------------------------------
2286// PostPublishPollConfig (per-publisher post-publish polling)
2287// ---------------------------------------------------------------------------
2288
2289mod post_publish_poll;
2290pub use post_publish_poll::*;
2291
2292// ---------------------------------------------------------------------------
2293// VerifyReleaseConfig (top-level `verify_release:` post-publish gate)
2294// ---------------------------------------------------------------------------
2295
2296mod verify_release;
2297pub use verify_release::*;
2298
2299// ---------------------------------------------------------------------------
2300// StringOrBool — accepts bool or template string in YAML
2301// ---------------------------------------------------------------------------
2302
2303mod string_or_bool;
2304pub use string_or_bool::*;
2305
2306// ---------------------------------------------------------------------------
2307// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
2308// ---------------------------------------------------------------------------
2309//
2310// All packaging config types live in their own modules under
2311// `crate::packagers`. The re-exports below preserve the historical
2312// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
2313// import paths used by stages and tests.
2314
2315pub use crate::packagers::{
2316    AppImageConfig, AppImageExtra, MakeselfConfig, MakeselfFile, RuntimeHarvest, SrpmConfig,
2317};
2318pub(crate) use crate::packagers::{
2319    appimages_schema, deserialize_appimages, deserialize_makeselfs, makeselfs_schema,
2320};
2321
2322// ---------------------------------------------------------------------------
2323// MilestoneConfig
2324// ---------------------------------------------------------------------------
2325
2326mod milestone;
2327pub use milestone::*;
2328
2329// ---------------------------------------------------------------------------
2330// UploadConfig (generic HTTP upload)
2331// ---------------------------------------------------------------------------
2332
2333mod upload;
2334pub use upload::*;
2335
2336// ---------------------------------------------------------------------------
2337// AurSourceConfig
2338// ---------------------------------------------------------------------------
2339
2340mod aur_source;
2341pub use aur_source::*;
2342
2343// ---------------------------------------------------------------------------
2344// McpConfig (MCP registry publisher)
2345// ---------------------------------------------------------------------------
2346
2347mod mcp;
2348pub use mcp::*;
2349
2350// ---------------------------------------------------------------------------
2351// NpmConfig (NPM package registry publisher)
2352// ---------------------------------------------------------------------------
2353
2354mod npm;
2355pub use npm::*;
2356
2357// ---------------------------------------------------------------------------
2358// GemFuryConfig (Gemfury / fury.io publisher)
2359// ---------------------------------------------------------------------------
2360
2361mod gemfury;
2362pub use gemfury::*;
2363
2364// ---------------------------------------------------------------------------
2365// Tests
2366// ---------------------------------------------------------------------------
2367
2368#[cfg(test)]
2369mod tests;