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::SchemaGenerator) -> schemars::Schema {
289    let mut schema = generator.subschema_for::<Vec<SignConfig>>();
290    schema.ensure_object().insert(
291        "description".to_owned(),
292        "Artifact signing configurations (cosign, GPG, etc.). Accepts a single object or array."
293            .into(),
294    );
295    schema
296}
297
298/// Helper schema function for the upx field (accepts object or array).
299fn upx_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
300    let mut schema = generator.subschema_for::<Vec<UpxConfig>>();
301    schema.ensure_object().insert(
302        "description".to_owned(),
303        "UPX binary compression configurations. Accepts a single object or array.".into(),
304    );
305    schema
306}
307
308/// Helper schema function for the sboms field (accepts object or array).
309fn sboms_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
310    let mut schema = generator.subschema_for::<Vec<SbomConfig>>();
311    schema.ensure_object().insert(
312        "description".to_owned(),
313        "SBOM generation configurations. Accepts a single object or array.".into(),
314    );
315    schema
316}
317
318fn default_dist() -> PathBuf {
319    PathBuf::from("./dist")
320}
321
322impl Default for Config {
323    fn default() -> Self {
324        Config {
325            version: None,
326            project_name: String::new(),
327            dist: default_dist(),
328            includes: None,
329            env_files: None,
330            defaults: None,
331            before: None,
332            after: None,
333            before_publish: None,
334            crates: Vec::new(),
335            changelog: None,
336            signs: Vec::new(),
337            binary_signs: Vec::new(),
338            docker_signs: None,
339            upx: Vec::new(),
340            snapshot: None,
341            nightly: None,
342            announce: None,
343            report_sizes: None,
344            env: None,
345            variables: None,
346            publishers: None,
347            dockerhub: None,
348            artifactories: None,
349            cloudsmiths: None,
350            homebrew_casks: None,
351            version_files: None,
352            tag: None,
353            git: None,
354            partial: None,
355            workspaces: None,
356            source: None,
357            sboms: Vec::new(),
358            attestations: None,
359            release: None,
360            github_urls: None,
361            gitlab_urls: None,
362            gitea_urls: None,
363            force_token: None,
364            notarize: None,
365            metadata: None,
366            template_files: None,
367            monorepo: None,
368            makeselfs: Vec::new(),
369            appimages: Vec::new(),
370            verify_release: VerifyReleaseConfig::default(),
371            srpms: None,
372            milestones: None,
373            uploads: None,
374            aur_sources: None,
375            retry: None,
376            mcp: McpConfig::default(),
377            schemastore: crate::config::publishers::SchemastoreConfig::default(),
378            npms: None,
379            gemfury: None,
380            derived_metadata: BTreeMap::new(),
381        }
382    }
383}
384
385impl Config {
386    /// Return the monorepo tag prefix, if configured.
387    ///
388    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())`.
389    pub fn monorepo_tag_prefix(&self) -> Option<&str> {
390        self.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())
391    }
392
393    /// Return the monorepo working directory, if configured.
394    ///
395    /// Shorthand for `config.monorepo.as_ref().and_then(|m| m.dir.as_deref())`.
396    pub fn monorepo_dir(&self) -> Option<&str> {
397        self.monorepo.as_ref().and_then(|m| m.dir.as_deref())
398    }
399
400    /// The build targets compiled when neither a per-build `targets` nor
401    /// `defaults.targets` is set: `defaults.targets` (when non-empty), else the
402    /// canonical `DEFAULT_TARGETS`. Single source of truth for the target-set
403    /// fallback — every target enumeration MUST resolve through this rather than
404    /// re-deriving the fallback, so they never diverge.
405    pub fn effective_default_targets(&self) -> Vec<String> {
406        self.defaults
407            .as_ref()
408            .and_then(|d| d.targets.clone())
409            .filter(|t| !t.is_empty())
410            .unwrap_or_else(|| {
411                crate::target::DEFAULT_TARGETS
412                    .iter()
413                    .map(|s| (*s).to_string())
414                    .collect()
415            })
416    }
417
418    /// The cross-compilation strategy applied to a crate that does not set its
419    /// own `cross:` — `defaults.cross`, else `Auto`. SSOT for the per-crate
420    /// strategy fallback.
421    pub fn default_cross_strategy(&self) -> CrossStrategy {
422        self.defaults
423            .as_ref()
424            .and_then(|d| d.cross.clone())
425            .unwrap_or(CrossStrategy::Auto)
426    }
427
428    // --- Project metadata defaulting helpers ---
429    //
430    // Publishers that expose homepage/license/description/maintainer fields
431    // fall back to these when their own field is unset, so a project only
432    // needs to declare metadata once. Resolution precedence (highest first):
433    //
434    //   1. the per-publisher override (the publisher's own config field)
435    //   2. a hand-written top-level `metadata:` YAML field
436    //   3. the value derived from the crate's `Cargo.toml [package]` table
437    //      (populated by `populate_derived_metadata`)
438    //
439    // Steps 1 is enforced by the publisher's `or_else(|| cfg.meta_*_for(..))`
440    // chain; steps 2-3 are enforced inside the `meta_*_for` accessors. A
441    // publisher that knows which crate it is publishing for should call the
442    // crate-aware `meta_*_for(crate_name)` variant so workspace/per-crate
443    // configs resolve each crate's OWN Cargo.toml metadata. The crate-agnostic
444    // `meta_*` variants resolve the top-level `metadata:` block only (no
445    // Cargo.toml fallback) and exist for truly project-level callers.
446
447    /// Per-crate derived metadata for `crate_name`, if `Cargo.toml` supplied any.
448    fn derived_for(&self, crate_name: &str) -> Option<&MetadataConfig> {
449        self.derived_metadata.get(crate_name)
450    }
451
452    /// Name of the primary crate (first declared `crates:` entry, else the
453    /// first workspace crate). Used as the metadata-derivation source for
454    /// project-level publishers (e.g. top-level `homebrew_casks:`) that are
455    /// not bound to a single crate.
456    fn primary_crate_name(&self) -> Option<&str> {
457        self.crates
458            .first()
459            .or_else(|| {
460                self.workspaces
461                    .iter()
462                    .flatten()
463                    .flat_map(|w| w.crates.iter())
464                    .next()
465            })
466            .map(|c| c.name.as_str())
467    }
468
469    /// Project homepage: top-level `metadata.homepage` wins, else the primary
470    /// crate's `Cargo.toml`-derived homepage. For project-level publishers
471    /// (top-level casks) with no owning crate.
472    pub fn meta_homepage_project(&self) -> Option<&str> {
473        self.meta_homepage()
474            .or_else(|| self.meta_homepage_for(self.primary_crate_name()?))
475    }
476
477    /// Project description: top-level `metadata.description` wins, else the
478    /// primary crate's `Cargo.toml`-derived description.
479    pub fn meta_description_project(&self) -> Option<&str> {
480        self.meta_description()
481            .or_else(|| self.meta_description_for(self.primary_crate_name()?))
482    }
483
484    /// Project source-repository URL: top-level `metadata.repository` wins, else
485    /// the primary crate's `Cargo.toml`-derived repository. Backs the
486    /// `{{ Metadata.Repository }}` template var.
487    pub fn meta_repository_project(&self) -> Option<&str> {
488        self.meta_repository()
489            .or_else(|| self.meta_repository_for(self.primary_crate_name()?))
490    }
491
492    /// Project license: top-level `metadata.license` wins, else the primary
493    /// crate's `Cargo.toml`-derived license. For the `{{ Metadata.License }}`
494    /// template var and project-level publishers with no owning crate.
495    pub fn meta_license_project(&self) -> Option<&str> {
496        self.meta_license()
497            .or_else(|| self.meta_license_for(self.primary_crate_name()?))
498    }
499
500    /// Project documentation URL: top-level `metadata.documentation` wins, else
501    /// the primary crate's `Cargo.toml`-derived documentation URL.
502    pub fn meta_documentation_project(&self) -> Option<&str> {
503        self.meta_documentation()
504            .or_else(|| self.meta_documentation_for(self.primary_crate_name()?))
505    }
506
507    /// Project homepage from `metadata.homepage` (top-level YAML only).
508    pub fn meta_homepage(&self) -> Option<&str> {
509        self.metadata.as_ref().and_then(|m| m.homepage.as_deref())
510    }
511
512    /// Project license from `metadata.license` (top-level YAML only).
513    pub fn meta_license(&self) -> Option<&str> {
514        self.metadata.as_ref().and_then(|m| m.license.as_deref())
515    }
516
517    /// Project source-repository URL from `metadata.repository` (top-level YAML only).
518    pub fn meta_repository(&self) -> Option<&str> {
519        self.metadata.as_ref().and_then(|m| m.repository.as_deref())
520    }
521
522    /// Project description from `metadata.description` (top-level YAML only).
523    pub fn meta_description(&self) -> Option<&str> {
524        self.metadata
525            .as_ref()
526            .and_then(|m| m.description.as_deref())
527    }
528
529    /// Project documentation URL from `metadata.documentation` (top-level YAML only).
530    pub fn meta_documentation(&self) -> Option<&str> {
531        self.metadata
532            .as_ref()
533            .and_then(|m| m.documentation.as_deref())
534    }
535
536    /// Project maintainers from `metadata.maintainers` (top-level YAML only).
537    pub fn meta_maintainers(&self) -> &[String] {
538        self.metadata
539            .as_ref()
540            .and_then(|m| m.maintainers.as_deref())
541            .unwrap_or(&[])
542    }
543
544    /// First maintainer as "Name <email>" or just "Name" (publisher convention).
545    /// Returns None when no maintainers are configured.
546    pub fn meta_first_maintainer(&self) -> Option<&str> {
547        self.meta_maintainers().first().map(|s| s.as_str())
548    }
549
550    /// Homepage for `crate_name`: top-level `metadata.homepage` wins, else the
551    /// value derived from the crate's `Cargo.toml [package]`.
552    pub fn meta_homepage_for(&self, crate_name: &str) -> Option<&str> {
553        self.meta_homepage()
554            .or_else(|| self.derived_for(crate_name)?.homepage.as_deref())
555    }
556
557    /// License for `crate_name`: top-level `metadata.license` wins, else the
558    /// crate's `Cargo.toml [package].license` (never synthesised from
559    /// `license-file`).
560    pub fn meta_license_for(&self, crate_name: &str) -> Option<&str> {
561        self.meta_license()
562            .or_else(|| self.derived_for(crate_name)?.license.as_deref())
563    }
564
565    /// Source-repository URL for `crate_name`: top-level `metadata.repository`
566    /// wins, else the crate's `Cargo.toml [package].repository`. Feeds the npm
567    /// `package.json` `repository` field so npm provenance validation (which
568    /// matches it against the OIDC-claimed repository) passes without requiring
569    /// the operator to restate the URL in the publisher config.
570    pub fn meta_repository_for(&self, crate_name: &str) -> Option<&str> {
571        self.meta_repository()
572            .or_else(|| self.derived_for(crate_name)?.repository.as_deref())
573    }
574
575    /// Description for `crate_name`: top-level `metadata.description` wins, else
576    /// the crate's `Cargo.toml [package].description`.
577    pub fn meta_description_for(&self, crate_name: &str) -> Option<&str> {
578        self.meta_description()
579            .or_else(|| self.derived_for(crate_name)?.description.as_deref())
580    }
581
582    /// Documentation URL for `crate_name`: top-level `metadata.documentation`
583    /// wins, else the crate's `Cargo.toml [package].documentation`.
584    pub fn meta_documentation_for(&self, crate_name: &str) -> Option<&str> {
585        self.meta_documentation()
586            .or_else(|| self.derived_for(crate_name)?.documentation.as_deref())
587    }
588
589    /// Maintainers for `crate_name`: top-level `metadata.maintainers` wins
590    /// (when non-empty), else the crate's `Cargo.toml [package].authors`.
591    pub fn meta_maintainers_for(&self, crate_name: &str) -> &[String] {
592        let top = self.meta_maintainers();
593        if !top.is_empty() {
594            return top;
595        }
596        self.derived_for(crate_name)
597            .and_then(|m| m.maintainers.as_deref())
598            .unwrap_or(&[])
599    }
600
601    /// First maintainer for `crate_name` as "Name <email>" or just "Name".
602    pub fn meta_first_maintainer_for(&self, crate_name: &str) -> Option<&str> {
603        self.meta_maintainers_for(crate_name)
604            .first()
605            .map(|s| s.as_str())
606    }
607
608    /// Vendor / distributing-entity name for `crate_name`: the first
609    /// maintainer with any `<email>` suffix stripped (e.g.
610    /// `"Ada Lovelace <ada@x>"` → `"Ada Lovelace"`). `None` when no maintainer
611    /// is derivable or the result is empty, so a Vendor field is never emitted
612    /// blank. Reused by the rpm/deb Vendor and the OCI image `vendor` label.
613    pub fn meta_vendor_for(&self, crate_name: &str) -> Option<String> {
614        self.meta_first_maintainer_for(crate_name)
615            .and_then(maintainer_name_only)
616    }
617
618    /// Populate [`Config::derived_metadata`] by reading each crate's
619    /// `Cargo.toml [package]` table (description / license / homepage /
620    /// authors), so publishers resolve a plain Rust project's metadata without
621    /// requiring a top-level `metadata:` YAML block.
622    ///
623    /// Covers every crate the config knows about: top-level `crates:` plus
624    /// every `workspaces[].crates[]`, so single-crate, workspace-lockstep, and
625    /// per-crate configs all populate. Each crate is read from
626    /// `<crate.path>/Cargo.toml` relative to `base_dir` (the directory the
627    /// config was loaded from / the monorepo working directory).
628    ///
629    /// Idempotent and non-destructive: only fills entries; existing
630    /// `derived_metadata` keys are overwritten with a fresh read. Crates whose
631    /// `Cargo.toml` is missing or supplies nothing contribute an all-`None`
632    /// entry (harmless — the accessors treat it as "no value").
633    pub fn populate_derived_metadata(&mut self, base_dir: &std::path::Path) {
634        let crate_paths: Vec<(String, String)> = self
635            .crates
636            .iter()
637            .chain(
638                self.workspaces
639                    .iter()
640                    .flatten()
641                    .flat_map(|w| w.crates.iter()),
642            )
643            .map(|c| (c.name.clone(), c.path.clone()))
644            .collect();
645        for (name, path) in crate_paths {
646            let crate_dir = base_dir.join(&path);
647            let derived = derive_metadata_from_cargo_toml(&crate_dir);
648            self.derived_metadata.insert(name, derived);
649        }
650    }
651
652    /// `true` when any top-level / workspace `signs:` or `binary_signs:`
653    /// entry will invoke gpg (via `SignConfig::is_gpg()`).
654    ///
655    /// Used by preflight to decide whether to probe
656    /// `gpg --faked-system-time` support. `docker_signs:` is excluded
657    /// because that driver only ever invokes cosign.
658    pub fn has_gpg_sign_configured(&self) -> bool {
659        let top_level = self
660            .signs
661            .iter()
662            .chain(self.binary_signs.iter())
663            .any(|s| s.is_gpg());
664        if top_level {
665            return true;
666        }
667        // Workspaces inherit their own signs:/binary_signs: lists.
668        self.workspaces.iter().flatten().any(|w| {
669            w.signs
670                .iter()
671                .chain(w.binary_signs.iter())
672                .any(|s| s.is_gpg())
673        })
674    }
675}
676
677/// JSON Schema for the [`Config`] document as a canonical `serde_json::Value`,
678/// in the JSON Schema draft-07 dialect.
679///
680/// The published `schema.json`, the `anodizer jsonschema` command, and the
681/// config-reference doc generator all read the schema from this one function so
682/// the dialect (`definitions` + `#/definitions/` refs) and the byte-form are
683/// fixed in a single place. draft-07 is the dialect editors (VS Code, the JSON
684/// Schema Store) resolve for `.anodizer.yaml`, so the published schema and the
685/// editor integration agree.
686///
687/// Returns a plain `Value` rather than [`schemars::Schema`] deliberately:
688/// serializing a `Schema` re-imposes schemars 1.x's keyword ordering (via its
689/// internal `OrderedKeywordWrapper`), which would undo [`canonicalize_schema`].
690/// Serializing the `Value` directly preserves the canonical order.
691#[must_use]
692pub fn config_schema() -> serde_json::Value {
693    let schema = schemars::generate::SchemaSettings::draft07()
694        .into_generator()
695        .into_root_schema_for::<Config>();
696    let mut value = schema.to_value();
697    canonicalize_schema(&mut value);
698    value
699}
700
701/// JSON Schema keyword serialization order matching schemars 0.8's `SchemaObject`
702/// field declaration order (its flattened `Metadata` / `SubschemaValidation` /
703/// number / string / array / object validation structs concatenated in struct
704/// order). The published `schema.json` is byte-pinned to this order so it stays
705/// stable across schemars upgrades (1.x emits a different keyword order, and the
706/// workspace builds `serde_json` with `preserve_order` — via `stage-publish` —
707/// so insertion order leaks into the file unless re-imposed here). An unlisted
708/// keyword sorts after all listed ones, then lexicographically.
709const SCHEMA_KEYWORD_ORDER: &[&str] = &[
710    "$id",
711    "$schema",
712    "title",
713    "description",
714    "default",
715    "deprecated",
716    "readOnly",
717    "writeOnly",
718    "type",
719    "format",
720    "enum",
721    "const",
722    "allOf",
723    "anyOf",
724    "oneOf",
725    "not",
726    "if",
727    "then",
728    "else",
729    "multipleOf",
730    "maximum",
731    "exclusiveMaximum",
732    "minimum",
733    "exclusiveMinimum",
734    "maxLength",
735    "minLength",
736    "pattern",
737    "items",
738    "additionalItems",
739    "maxItems",
740    "minItems",
741    "uniqueItems",
742    "contains",
743    "maxProperties",
744    "minProperties",
745    "required",
746    "properties",
747    "patternProperties",
748    "additionalProperties",
749    "propertyNames",
750    "$ref",
751    "definitions",
752];
753
754/// Schema object keys whose VALUE is a map of name → subschema (not a subschema
755/// itself). Their entries are sorted by NAME (schemars 0.8 backed these with a
756/// `BTreeMap`); every other keyword's value is a schema whose own keys are
757/// ordered by [`SCHEMA_KEYWORD_ORDER`].
758const SCHEMA_DEFINITION_MAPS: &[&str] = &["properties", "patternProperties", "definitions"];
759
760/// Re-impose schemars 0.8's deterministic serialization on a draft-07 schema
761/// `Value` so the published artifact is byte-stable across schemars versions:
762/// recursively (1) order each schema object's keys by [`SCHEMA_KEYWORD_ORDER`],
763/// (2) sort definition-map entries (`properties`/`definitions`/…) by name,
764/// (3) sort `required` (a set), and (4) normalize every `description` to single
765/// spaces within a paragraph while preserving blank-line paragraph breaks.
766fn canonicalize_schema(value: &mut serde_json::Value) {
767    use serde_json::Value;
768    match value {
769        Value::Object(map) => {
770            if let Some(Value::String(d)) = map.get_mut("description") {
771                *d = collapse_description(d);
772            }
773            if let Some(Value::Array(required)) = map.get_mut("required") {
774                required.sort_by(|a, b| match (a.as_str(), b.as_str()) {
775                    (Some(x), Some(y)) => x.cmp(y),
776                    _ => std::cmp::Ordering::Equal,
777                });
778            }
779            // Recurse, treating each value by its role:
780            // - a definition-map value (`properties`/`definitions`/…) is a
781            //   name→schema map: sort its entries by name, recurse each schema;
782            // - `default`/`enum`/`const`/`examples` hold literal instance DATA,
783            //   not schemas — never reorder their keys (they preserve the config
784            //   struct's serialization order);
785            // - every other value is itself a schema (or array of schemas).
786            for (key, child) in map.iter_mut() {
787                match key.as_str() {
788                    k if SCHEMA_DEFINITION_MAPS.contains(&k) => {
789                        if let Value::Object(entries) = child {
790                            sort_object_by_key(entries);
791                            for sub in entries.values_mut() {
792                                canonicalize_schema(sub);
793                            }
794                        }
795                    }
796                    "default" | "enum" | "const" | "examples" => {}
797                    _ => canonicalize_schema(child),
798                }
799            }
800            reorder_object(map, SCHEMA_KEYWORD_ORDER);
801        }
802        Value::Array(items) => {
803            for item in items {
804                canonicalize_schema(item);
805            }
806        }
807        _ => {}
808    }
809}
810
811/// Reorder `map`'s entries so listed keys come first in `order`, then any
812/// remaining keys lexicographically. `serde_json`'s `preserve_order` feature is
813/// active workspace-wide, so a `Map` serializes in insertion order — rebuilding
814/// it in the target order fixes the serialized key order.
815fn reorder_object(map: &mut serde_json::Map<String, serde_json::Value>, order: &[&str]) {
816    let mut keys: Vec<String> = map.keys().cloned().collect();
817    keys.sort_by(|a, b| {
818        let rank = |k: &str| order.iter().position(|o| *o == k).unwrap_or(order.len());
819        rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
820    });
821    let mut rebuilt = serde_json::Map::with_capacity(map.len());
822    for k in keys {
823        if let Some(v) = map.remove(&k) {
824            rebuilt.insert(k, v);
825        }
826    }
827    *map = rebuilt;
828}
829
830/// Sort an object map's entries by key (rebuilt because `preserve_order` keeps
831/// insertion order). Used for definition maps where 0.8 emitted `BTreeMap`-sorted
832/// names.
833fn sort_object_by_key(map: &mut serde_json::Map<String, serde_json::Value>) {
834    let mut keys: Vec<String> = map.keys().cloned().collect();
835    keys.sort();
836    let mut rebuilt = serde_json::Map::with_capacity(map.len());
837    for k in keys {
838        if let Some(v) = map.remove(&k) {
839            rebuilt.insert(k, v);
840        }
841    }
842    *map = rebuilt;
843}
844
845/// Normalize a schema `description`: collapse each paragraph's internal
846/// whitespace (including the rustdoc doc-comment's hard line wraps, which
847/// schemars 1.x preserves verbatim) to single spaces, while preserving
848/// blank-line paragraph breaks (`\n\n`). Reproduces the single-spaced,
849/// paragraph-separated form earlier schemars releases emitted, so the published
850/// schema's tooltips render as clean prose in editors.
851fn collapse_description(s: &str) -> String {
852    s.split("\n\n")
853        .map(|para| {
854            para.split('\n')
855                .map(str::trim)
856                .collect::<Vec<_>>()
857                .join(" ")
858        })
859        .collect::<Vec<_>>()
860        .join("\n\n")
861}
862
863/// Run a deserialization closure on a worker thread sized large enough that
864/// the `Config` derive (60+ `Option<NestedStruct>` fields) cannot exhaust
865/// the host's main-thread stack.
866///
867/// Background: debug builds of `serde_yaml_ng::from_value::<Config>` and
868/// `toml::from_str::<Config>` consume several MiB of stack because each
869/// generated visitor branch for the giant struct lives in a single
870/// monomorphised frame and debug builds neither inline nor tail-call. The
871/// Windows main-thread default reservation is 1 MiB, so any debug-built
872/// integration test that triggers full-config deserialization overflows
873/// before reaching the visitor's body.
874///
875/// Routing every full-`Config` deserialization through this helper keeps
876/// every entry-point platform-agnostic without resorting to per-platform
877/// linker flags or `RUST_MIN_STACK`.
878pub fn deserialize_on_worker<F, T>(f: F) -> anyhow::Result<T>
879where
880    F: FnOnce() -> anyhow::Result<T> + Send + 'static,
881    T: Send + 'static,
882{
883    use anyhow::Context as _;
884
885    // 8 MiB matches the Linux/macOS process default and comfortably exceeds
886    // the ~2 MiB peak observed for debug `Config` deserialization.
887    const WORKER_STACK_SIZE: usize = 8 * 1024 * 1024;
888
889    let handle = std::thread::Builder::new()
890        .stack_size(WORKER_STACK_SIZE)
891        .name("anodizer-config-deserialize".to_string())
892        .spawn(f)
893        .context("failed to spawn config deserialization worker thread")?;
894    match handle.join() {
895        Ok(result) => result,
896        Err(payload) => std::panic::resume_unwind(payload),
897    }
898}
899
900/// Validate the config schema version. Accepts version 1 (default) and 2.
901/// Returns an error for unknown versions.
902pub fn validate_version(config: &Config) -> Result<(), String> {
903    match config.version {
904        None | Some(1) | Some(2) => Ok(()),
905        Some(v) => Err(format!(
906            "unsupported config version: {}. Supported versions are 1 and 2.",
907            v
908        )),
909    }
910}
911
912/// Validate `git.tag_sort` if present. Accepted values:
913/// - `"-version:refname"` (default, lexicographic version sort)
914/// - `"-version:creatordate"` (sort by tag creation date, newest first)
915/// - `"semver"` (Rust-side strict SemVer 2.0.0 ordering, prereleases sort
916///   below their release per spec section 11)
917/// - `"smartsemver"` (same ordering as `semver`, but when the current version
918///   is non-prerelease, prerelease tags are skipped when picking the previous
919///   tag — avoids selecting `v0.2.0-beta.3` as the predecessor of `v0.2.0`)
920///
921/// Returns an error for unrecognized values.
922pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
923    if let Some(ref git) = config.git
924        && let Some(ref sort) = git.tag_sort
925    {
926        match sort.as_str() {
927            "-version:refname" | "-version:creatordate" | "semver" | "smartsemver" => {}
928            other => {
929                return Err(format!(
930                    "unsupported git.tag_sort value: \"{}\". \
931                     Accepted values: \"-version:refname\", \"-version:creatordate\", \
932                     \"semver\", \"smartsemver\".",
933                    other
934                ));
935            }
936        }
937    }
938    Ok(())
939}
940
941/// Validate `partial.by` up front so a stale value is rejected at config-load
942/// time regardless of which target-resolution path runs.
943///
944/// `partial.by` is read in two unrelated places: the host-detection branch of
945/// [`crate::partial::resolve_partial_target`] (which already rejects unknown
946/// values) and the split-matrix generator (which treats anything that is not
947/// `"os"` as `"target"`). Those two readers disagree on an out-of-set value
948/// like the pre-rename `"goos"`: one errors, the other silently mis-groups the
949/// matrix. Centralising the check means a typo fails loudly once, before
950/// either reader can diverge.
951pub fn validate_partial(config: &Config) -> Result<(), String> {
952    if let Some(ref partial) = config.partial
953        && let Some(ref by) = partial.by
954    {
955        match by.as_str() {
956            "os" | "target" => {}
957            other => {
958                return Err(format!(
959                    "unsupported partial.by value: \"{}\". \
960                     Accepted values: \"os\", \"target\".",
961                    other
962                ));
963            }
964        }
965    }
966    Ok(())
967}
968
969/// Known OS values accepted by `archives[].format_overrides[].os`.
970/// The Go runtime's `runtime.GOOS` values the archive pipe
971/// recognises; anything outside this set is almost always a typo
972/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
973const KNOWN_OS: &[&str] = &[
974    "aix",
975    "android",
976    "darwin",
977    "dragonfly",
978    "freebsd",
979    "illumos",
980    "ios",
981    "js",
982    "linux",
983    "netbsd",
984    "openbsd",
985    "plan9",
986    "solaris",
987    "wasip1",
988    "windows",
989];
990
991/// Validate that each crate's `release:` block configures at most one SCM
992/// backend. A multiple-releases error, which
993/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
994/// runtime so a silently-ignored extra backend is easy to miss.
995pub fn validate_release_backends(config: &Config) -> Result<(), String> {
996    let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
997        let mut set = Vec::new();
998        if release.github.is_some() {
999            set.push("github");
1000        }
1001        if release.gitlab.is_some() {
1002            set.push("gitlab");
1003        }
1004        if release.gitea.is_some() {
1005            set.push("gitea");
1006        }
1007        if set.len() > 1 {
1008            return Err(format!(
1009                "crate {}: release config sets multiple mutually-exclusive SCM \
1010                 backends ({}). Pick one.",
1011                crate_name,
1012                set.join(" + ")
1013            ));
1014        }
1015        Ok(())
1016    };
1017    for krate in &config.crates {
1018        if let Some(ref release) = krate.release {
1019            check(&krate.name, release)?;
1020        }
1021    }
1022    if let Some(ws_list) = config.workspaces.as_ref() {
1023        for ws in ws_list {
1024            for krate in &ws.crates {
1025                if let Some(ref release) = krate.release {
1026                    check(&krate.name, release)?;
1027                }
1028            }
1029        }
1030    }
1031    Ok(())
1032}
1033
1034/// Validate that `release.on_failure` is set only at the root.
1035///
1036/// The failure policy is one process-wide decision per run, resolved
1037/// from the top-level `release:` block alone. Crate-level `release:`
1038/// blocks share the `ReleaseConfig` struct, so the field parses there
1039/// — but it would never be read; rejecting the misplacement at config
1040/// load keeps a policy choice from being silently ignored.
1041pub fn validate_on_failure_root_only(config: &Config) -> Result<(), String> {
1042    let mut offenders: Vec<&str> = config
1043        .crates
1044        .iter()
1045        .chain(
1046            config
1047                .workspaces
1048                .iter()
1049                .flatten()
1050                .flat_map(|ws| ws.crates.iter()),
1051        )
1052        .filter(|c| c.release.as_ref().is_some_and(|r| r.on_failure.is_some()))
1053        .map(|c| c.name.as_str())
1054        .collect();
1055    offenders.sort_unstable();
1056    offenders.dedup();
1057    if offenders.is_empty() {
1058        return Ok(());
1059    }
1060    Err(format!(
1061        "release.on_failure is a root-level policy and cannot be set per crate \
1062         (set on: {}). Move it to the top-level `release:` block.",
1063        offenders.join(", ")
1064    ))
1065}
1066
1067/// Marker prefix for the axis-mismatch validation error class. Existing
1068/// validators in this module return `Result<(), String>` rather than a
1069/// typed enum, so we expose this constant (instead of a `ConfigError`
1070/// variant) for callers that want to recognise the error class
1071/// programmatically.
1072///
1073/// The prefix is emitted at the start of every error returned by
1074/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
1075/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
1076/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
1077/// exact human-readable wording.
1078///
1079/// ```ignore
1080/// match validate_defaults_axis(&config) {
1081///     Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
1082///         // handle the axis-mismatch error class
1083///     }
1084///     other => other?,
1085/// }
1086/// ```
1087///
1088/// Future error-type unification can rename to
1089/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
1090/// match on this prefix.
1091pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
1092
1093/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
1094/// top-level axis.
1095///
1096/// Rules:
1097/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
1098/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
1099/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
1100///   (mutually exclusive).
1101/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
1102pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
1103    let Some(ref defaults) = config.defaults else {
1104        return Ok(());
1105    };
1106    let has_crate_block = defaults.crates.is_some();
1107    let has_workspace_block = defaults.workspaces.is_some();
1108
1109    if has_crate_block && has_workspace_block {
1110        return Err(format!(
1111            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
1112             mutually exclusive — pick the axis that matches the top-level config \
1113             (`crates:` or `workspaces:`)",
1114        ));
1115    }
1116
1117    let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
1118    let top_uses_crates = !config.crates.is_empty();
1119
1120    if has_crate_block && !top_uses_crates {
1121        return Err(format!(
1122            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
1123             is {}; move defaults under `defaults.workspaces:` or remove the block",
1124            if top_uses_workspaces {
1125                "absent (top-level uses `workspaces:`)"
1126            } else {
1127                "absent"
1128            },
1129        ));
1130    }
1131    if has_workspace_block && !top_uses_workspaces {
1132        return Err(format!(
1133            "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
1134             `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
1135            if top_uses_crates {
1136                "absent (top-level uses `crates:`)"
1137            } else {
1138                "absent"
1139            },
1140        ));
1141    }
1142
1143    Ok(())
1144}
1145
1146/// Validate `archives[].format_overrides[].os` values reject unknown OSes.
1147/// Silently no-op-ing unknown overrides has burned users typing
1148/// Rust triples like `apple` or `pc-windows-msvc`.
1149///
1150/// Walks every `archives[]` location in the config:
1151/// - `crates[].archives:`
1152/// - `workspaces[].crates[].archives:`
1153/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
1154///   and propagate to every inheriting crate at merge time).
1155pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
1156    let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1157        for (idx, archive) in archives.iter().enumerate() {
1158            let Some(ref overrides) = archive.format_overrides else {
1159                continue;
1160            };
1161            for over in overrides {
1162                if !KNOWN_OS.contains(&over.os.as_str()) {
1163                    let archive_id = archive.id.as_deref().unwrap_or("default");
1164                    return Err(format!(
1165                        "{}: archives[{}] (id={}): format_overrides.os=\"{}\" is not a recognised OS. \
1166                         Accepted values: {}.",
1167                        location,
1168                        idx,
1169                        archive_id,
1170                        over.os,
1171                        KNOWN_OS.join(", ")
1172                    ));
1173                }
1174            }
1175        }
1176        Ok(())
1177    };
1178    for krate in &config.crates {
1179        if let ArchivesConfig::Configs(ref list) = krate.archives {
1180            check(&format!("crate {}", krate.name), list)?;
1181        }
1182    }
1183    if let Some(ws_list) = config.workspaces.as_ref() {
1184        for ws in ws_list {
1185            for krate in &ws.crates {
1186                if let ArchivesConfig::Configs(ref list) = krate.archives {
1187                    check(&format!("crate {}", krate.name), list)?;
1188                }
1189            }
1190        }
1191    }
1192    if let Some(ref defaults) = config.defaults
1193        && let Some(ref archive) = defaults.archives
1194    {
1195        // defaults.archives is a single ArchiveConfig (not a list); wrap it
1196        // into a one-element slice so the same checker walks it.
1197        check("defaults.archives", std::slice::from_ref(archive))?;
1198    }
1199    Ok(())
1200}
1201
1202/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
1203/// `url.template` simultaneously — they are mutually exclusive shorthands
1204/// for the same URL field and combining them is ambiguous.
1205///
1206/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
1207/// - `homebrew_casks:` (top-level array)
1208/// - `crates[].publish.homebrew_cask:`
1209/// - `workspaces[].crates[].publish.homebrew_cask:`
1210/// - `defaults.publish.homebrew_cask:`
1211pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
1212    let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
1213        let has_url_template = cask.url_template.is_some();
1214        let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
1215        if has_url_template && has_url_dot_template {
1216            return Err(format!(
1217                "{location}: homebrew_cask sets both `url_template` and `url.template`. \
1218                 These are mutually exclusive — use one or the other."
1219            ));
1220        }
1221        Ok(())
1222    };
1223
1224    // Top-level homebrew_casks list (not nested under publish:) — not a
1225    // publish axis, so it is scanned separately from the visitor.
1226    if let Some(ref casks) = config.homebrew_casks {
1227        for (i, cask) in casks.iter().enumerate() {
1228            check(&format!("homebrew_casks[{i}]"), cask)?;
1229        }
1230    }
1231
1232    try_for_each_crate_publish(config, |axis, publish| {
1233        if let Some(cask) = publish.homebrew_cask() {
1234            check(&axis.homebrew_cask_location(), cask)?;
1235        }
1236        Ok(())
1237    })
1238}
1239
1240/// Allowed `winget.upgrade_behavior` values, mirroring the winget installer
1241/// manifest schema (1.12.0) `UpgradeBehavior` enum. A value outside this set
1242/// renders an installer manifest the winget validator rejects at PR time —
1243/// catch it at config-validate instead.
1244pub const WINGET_UPGRADE_BEHAVIORS: [&str; 3] = ["install", "uninstallPrevious", "deny"];
1245
1246/// Validate that every configured `winget.upgrade_behavior` is one of the
1247/// winget-recognized values ([`WINGET_UPGRADE_BEHAVIORS`]). Walks the per-crate,
1248/// per-workspace, and `defaults.publish` axes.
1249pub fn validate_winget_upgrade_behavior(config: &Config) -> Result<(), String> {
1250    let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1251        if let Some(ref behavior) = winget.upgrade_behavior
1252            && !WINGET_UPGRADE_BEHAVIORS.contains(&behavior.as_str())
1253        {
1254            return Err(format!(
1255                "{location}: upgrade_behavior `{behavior}` is not a valid winget value. \
1256                 Use one of: {}.",
1257                WINGET_UPGRADE_BEHAVIORS.join(", ")
1258            ));
1259        }
1260        Ok(())
1261    };
1262
1263    try_for_each_crate_publish(config, |axis, publish| {
1264        if let Some(winget) = publish.winget() {
1265            check(&axis.winget_location(), winget)?;
1266        }
1267        Ok(())
1268    })
1269}
1270
1271/// Validate that every `winget.dependencies[].architectures` entry names a
1272/// recognized WinGet architecture ([`WINGET_ARCHITECTURES`]). Walks the
1273/// per-crate, per-workspace, and `defaults.publish` axes.
1274///
1275/// The per-installer dependency emitter matches a scope value against each
1276/// installer's WinGet architecture by exact, case-sensitive equality. A value
1277/// outside the canonical set (`amd64`, `X64`, `aarch64`, …) therefore matches
1278/// no installer, so the dependency would silently disappear from the generated
1279/// manifest. Reject it at config-validate instead of shipping a manifest that
1280/// quietly omits a declared dependency. An empty list (or absent
1281/// `architectures`) means "all installers" and is valid.
1282pub fn validate_winget_dependency_architectures(config: &Config) -> Result<(), String> {
1283    let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1284        let Some(ref deps) = winget.dependencies else {
1285            return Ok(());
1286        };
1287        for (i, dep) in deps.iter().enumerate() {
1288            let Some(ref scopes) = dep.architectures else {
1289                continue;
1290            };
1291            for scope in scopes {
1292                if !WINGET_ARCHITECTURES.contains(&scope.as_str()) {
1293                    return Err(format!(
1294                        "{location}: dependencies[{i}].architectures contains `{scope}`, \
1295                         which is not a valid winget architecture. Use one of: {} \
1296                         (or leave architectures empty/unset to apply the dependency \
1297                         to every installer).",
1298                        WINGET_ARCHITECTURES.join(", ")
1299                    ));
1300                }
1301            }
1302        }
1303        Ok(())
1304    };
1305
1306    try_for_each_crate_publish(config, |axis, publish| {
1307        if let Some(winget) = publish.winget() {
1308            check(&axis.winget_location(), winget)?;
1309        }
1310        Ok(())
1311    })
1312}
1313
1314/// Validate that `archives[].id` and `universal_binaries[].id` are unique
1315/// within their respective lists.
1316///
1317/// The id-uniqueness validation for archives and universal binaries.
1318/// Two archive
1319/// configs with the same `id` silently both set the same `id` metadata key
1320/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
1321/// build/sign stages already enforce id uniqueness; archive and
1322/// universal_binary were missed.
1323///
1324/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
1325/// - `crates[].archives:` / `crates[].universal_binaries:`
1326/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
1327/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
1328///   itself is vacuously true; not walked here.
1329///
1330pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
1331    fn check_unique<F>(
1332        location: &str,
1333        kind: &str,
1334        ids: impl IntoIterator<Item = (usize, Option<String>)>,
1335        empty_ok: F,
1336    ) -> Result<(), String>
1337    where
1338        F: Fn() -> bool,
1339    {
1340        let _ = empty_ok;
1341        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1342        for (idx, maybe_id) in ids {
1343            // Empty is stored as "default" for archives via Default-time
1344            // assignment. Anodizer applies `default_archive_id` at deserialize
1345            // time, so the option is normally `Some("default")`. A truly empty
1346            // / None id here means the user explicitly cleared it; we still
1347            // dedupe across `None` so two None-id'd entries collide just like
1348            // two "default"-id'd entries would.
1349            let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
1350            if let Some(prev_idx) = seen.insert(key.clone(), idx) {
1351                return Err(format!(
1352                    "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
1353                     ids must be unique within a {kind} list."
1354                ));
1355            }
1356        }
1357        Ok(())
1358    }
1359
1360    let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1361        check_unique(
1362            location,
1363            "archives",
1364            archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
1365            || true,
1366        )
1367    };
1368    let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
1369        check_unique(
1370            location,
1371            "universal_binaries",
1372            ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
1373            || true,
1374        )
1375    };
1376
1377    for krate in &config.crates {
1378        if let ArchivesConfig::Configs(ref list) = krate.archives {
1379            check_archives(&format!("crates[{}].archives", krate.name), list)?;
1380        }
1381        if let Some(ref ubs) = krate.universal_binaries {
1382            check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
1383        }
1384    }
1385    if let Some(ws_list) = config.workspaces.as_ref() {
1386        for ws in ws_list {
1387            for krate in &ws.crates {
1388                if let ArchivesConfig::Configs(ref list) = krate.archives {
1389                    check_archives(
1390                        &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
1391                        list,
1392                    )?;
1393                }
1394                if let Some(ref ubs) = krate.universal_binaries {
1395                    check_unibins(
1396                        &format!(
1397                            "workspaces[{}].crates[{}].universal_binaries",
1398                            ws.name, krate.name
1399                        ),
1400                        ubs,
1401                    )?;
1402                }
1403            }
1404        }
1405    }
1406    Ok(())
1407}
1408
1409/// Validate `builds[]` entries that opt into `builder: prebuilt`.
1410///
1411/// `builder: prebuilt` skips `cargo build` and imports a binary the
1412/// operator staged elsewhere. The validation rules below follow the
1413/// `prebuilt` builder contract (`/customization/builds/builders/prebuilt.md`):
1414///
1415/// 1. `prebuilt:` block MUST be set and `prebuilt.path` MUST be non-empty.
1416/// 2. `targets:` MUST be explicit on the build entry — no `defaults.targets`
1417///    fallback. Without this rule the build matrix has no rows.
1418/// 3. Cargo-only knobs are rejected as mutually exclusive: `cross_tool`,
1419///    `features`, `no_default_features`, `command`. The crate-level
1420///    `cross:` strategy is also rejected when any build on the crate is
1421///    prebuilt (the strategy has no meaning when nothing is being
1422///    compiled).
1423/// 4. `builder: cargo` (the default) with a `prebuilt:` block set warns —
1424///    the block has no effect and likely indicates a forgotten
1425///    `builder: prebuilt`.
1426pub fn validate_builds(config: &Config) -> Result<(), String> {
1427    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1428        let Some(ref builds) = krate.builds else {
1429            return Ok(());
1430        };
1431        let crate_is_prebuilt = builds
1432            .iter()
1433            .any(|b| matches!(b.builder, Some(BuilderKind::Prebuilt)));
1434        if crate_is_prebuilt && krate.cross.is_some() {
1435            return Err(format!(
1436                "{location}: crate-level `cross:` strategy is set but at least one \
1437                 build uses `builder: prebuilt`; remove `cross:` (prebuilt imports a \
1438                 binary instead of compiling) or change the build's builder to `cargo`."
1439            ));
1440        }
1441        for (idx, build) in builds.iter().enumerate() {
1442            match build.builder {
1443                Some(BuilderKind::Prebuilt) => {
1444                    let path = build.prebuilt.as_ref().map(|p| p.path.trim()).unwrap_or("");
1445                    if path.is_empty() {
1446                        return Err(format!(
1447                            "{location}.builds[{idx}]: `builder: prebuilt` requires a non-empty \
1448                             `prebuilt.path` template. Example: \
1449                             `prebuilt: {{ path: \"output/mybin_{{{{ .Target }}}}\" }}`"
1450                        ));
1451                    }
1452                    let targets_explicit = build.targets.as_ref().is_some_and(|t| !t.is_empty());
1453                    if !targets_explicit {
1454                        return Err(format!(
1455                            "{location}.builds[{idx}] has `builder: prebuilt` but no explicit \
1456                             `targets:` — the prebuilt builder requires per-build target triples \
1457                             (no `defaults.targets:` fallback). Add `targets: [<triple>, ...]`."
1458                        ));
1459                    }
1460                    if build.cross_tool.as_ref().is_some_and(|s| !s.is_empty()) {
1461                        return Err(format!(
1462                            "{location}.builds[{idx}]: `cross_tool` is set with \
1463                             `builder: prebuilt` — the two are mutually exclusive. \
1464                             `cross_tool` controls how cargo cross-compiles; `prebuilt` \
1465                             imports an already-built binary. Drop `cross_tool` or use \
1466                             `builder: cargo`."
1467                        ));
1468                    }
1469                    if build.command.as_ref().is_some_and(|s| !s.is_empty()) {
1470                        return Err(format!(
1471                            "{location}.builds[{idx}]: `command:` override is set with \
1472                             `builder: prebuilt` — the override selects the cargo \
1473                             subcommand, which is not invoked under the prebuilt \
1474                             builder. Drop `command:` or use `builder: cargo`."
1475                        ));
1476                    }
1477                    if build.features.as_ref().is_some_and(|f| !f.is_empty()) {
1478                        return Err(format!(
1479                            "{location}.builds[{idx}]: `features:` is set with \
1480                             `builder: prebuilt` — Cargo features are evaluated at \
1481                             compile time, which the prebuilt builder skips. \
1482                             Drop `features:` or use `builder: cargo`."
1483                        ));
1484                    }
1485                    if build.no_default_features.is_some() {
1486                        return Err(format!(
1487                            "{location}.builds[{idx}]: `no_default_features:` is set with \
1488                             `builder: prebuilt` — Cargo feature flags are evaluated at \
1489                             compile time, which the prebuilt builder skips. \
1490                             Drop the flag or use `builder: cargo`."
1491                        ));
1492                    }
1493                }
1494                Some(BuilderKind::Cargo) | None => {
1495                    if build.prebuilt.is_some() {
1496                        tracing::warn!(
1497                            "{location}: build[{idx}] has a `prebuilt:` block but `builder:` \
1498                             is not `prebuilt`; the block is ignored. Set `builder: prebuilt` \
1499                             or remove the block."
1500                        );
1501                    }
1502                }
1503            }
1504        }
1505        Ok(())
1506    };
1507
1508    for krate in &config.crates {
1509        check_crate(&format!("crates[{}]", krate.name), krate)?;
1510    }
1511    if let Some(ws_list) = config.workspaces.as_ref() {
1512        for ws in ws_list {
1513            for krate in &ws.crates {
1514                check_crate(
1515                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1516                    krate,
1517                )?;
1518            }
1519        }
1520    }
1521    Ok(())
1522}
1523
1524/// Returns `true` if every build entry on every crate has
1525/// `builder: prebuilt`. Used by the determinism harness to short-circuit:
1526/// when no target compiles, there is nothing for the harness to rebuild
1527/// and compare across runs.
1528pub fn all_builds_prebuilt(config: &Config) -> bool {
1529    let crate_all_prebuilt = |krate: &CrateConfig| -> Option<bool> {
1530        let builds = krate.builds.as_ref()?;
1531        if builds.is_empty() {
1532            return None;
1533        }
1534        Some(
1535            builds
1536                .iter()
1537                .all(|b| matches!(b.builder, Some(BuilderKind::Prebuilt))),
1538        )
1539    };
1540
1541    let mut saw_any = false;
1542    for krate in &config.crates {
1543        match crate_all_prebuilt(krate) {
1544            Some(true) => saw_any = true,
1545            Some(false) => return false,
1546            None => {}
1547        }
1548    }
1549    if let Some(ws_list) = config.workspaces.as_ref() {
1550        for ws in ws_list {
1551            for krate in &ws.crates {
1552                match crate_all_prebuilt(krate) {
1553                    Some(true) => saw_any = true,
1554                    Some(false) => return false,
1555                    None => {}
1556                }
1557            }
1558        }
1559    }
1560    saw_any
1561}
1562
1563/// Validate the depth of `changelog.groups[].groups`.
1564///
1565/// Subgroups are capped at ONE level
1566/// (`/customization/publish/changelog.md`: "There can only be one level of
1567/// subgroups"). Anodizer's renderer can technically handle deeper nesting
1568/// (capped at 6 to match Markdown's heading limit), but accepting deeper
1569/// configs silently is a footgun: a config that works in anodizer but is
1570/// rejected here breaks parity for users migrating in.
1571///
1572/// Rejects any `changelog.groups[i].groups[j].groups[..]` configuration
1573/// with a clear error pointing at the offending parent group title.
1574pub fn validate_changelog_groups_depth(config: &Config) -> Result<(), String> {
1575    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1576        let Some(ref groups) = cfg.groups else {
1577            return Ok(());
1578        };
1579        for g in groups {
1580            if let Some(ref subs) = g.groups {
1581                for sub in subs {
1582                    if sub.groups.as_ref().is_some_and(|s| !s.is_empty()) {
1583                        return Err(format!(
1584                            "{location}: changelog group '{}' > '{}' nests further \
1585                             subgroups; GoReleaser permits only one level of subgroups \
1586                             (see https://goreleaser.com/customization/changelog/). \
1587                             Flatten the inner groups into the parent or split into \
1588                             sibling top-level groups.",
1589                            g.title, sub.title
1590                        ));
1591                    }
1592                }
1593            }
1594        }
1595        Ok(())
1596    };
1597    if let Some(ref cfg) = config.changelog {
1598        check("changelog", cfg)?;
1599    }
1600    if let Some(ref ws_list) = config.workspaces {
1601        for ws in ws_list {
1602            if let Some(ref cfg) = ws.changelog {
1603                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1604            }
1605        }
1606    }
1607    Ok(())
1608}
1609
1610/// Validate `changelog.paths[]` syntax.
1611///
1612/// Path patterns are passed straight to `git log -- <path>` (or the
1613/// per-SCM equivalent). Two patterns are always wrong:
1614/// - Leading `/` — git pathspec treats this as anchored-to-CWD which is
1615///   almost never what the user wrote and produces empty changelogs.
1616/// - Empty string — silently matches everything; rejected so a typo
1617///   doesn't disable filtering.
1618///
1619/// Globs containing `**` are accepted (git accepts them) but the docs
1620/// note their semantics differ from gitignore; that's a docs concern,
1621/// not a hard error.
1622pub fn validate_changelog_paths(config: &Config) -> Result<(), String> {
1623    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1624        let Some(ref paths) = cfg.paths else {
1625            return Ok(());
1626        };
1627        for (idx, p) in paths.iter().enumerate() {
1628            if p.is_empty() {
1629                return Err(format!(
1630                    "{location}: changelog.paths[{idx}] is empty; remove the entry \
1631                     or set a real path (empty string matches everything and \
1632                     disables filtering)"
1633                ));
1634            }
1635            if p.starts_with('/') {
1636                return Err(format!(
1637                    "{location}: changelog.paths[{idx}] = {:?} starts with '/'; \
1638                     git pathspec is repo-root-relative — write {:?} instead",
1639                    p,
1640                    p.trim_start_matches('/')
1641                ));
1642            }
1643        }
1644        Ok(())
1645    };
1646    if let Some(ref cfg) = config.changelog {
1647        check("changelog", cfg)?;
1648    }
1649    if let Some(ref ws_list) = config.workspaces {
1650        for ws in ws_list {
1651            if let Some(ref cfg) = ws.changelog {
1652                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1653            }
1654        }
1655    }
1656    Ok(())
1657}
1658
1659/// Validate every upload-destination `exclude:` glob across all config axes.
1660///
1661/// `exclude:` drops artifacts whose file name matches a glob (see
1662/// [`crate::artifact::passes_exclude_filter`]). An unparseable glob is treated
1663/// as non-matching at runtime so it never crashes a release — but a typo'd
1664/// glob that silently keeps an asset (or, worse, drops every asset) is a
1665/// foot-gun. Reject malformed globs here, at config-load, with a clear message
1666/// before they can take effect.
1667///
1668/// Covers every config position where `exclude:` is settable: per-crate
1669/// `release:` and `blobs:` (top-level crates AND `workspaces[].crates[]`), the
1670/// top-level `artifactories:`, `cloudsmiths:`, `gemfury:`, and `uploads:`
1671/// lists, and the top-level shared `release:` block.
1672pub fn validate_exclude_globs(config: &Config) -> Result<(), String> {
1673    fn check(location: &str, exclude: Option<&[String]>) -> Result<(), String> {
1674        let Some(globs) = exclude else {
1675            return Ok(());
1676        };
1677        for (idx, g) in globs.iter().enumerate() {
1678            if g.is_empty() {
1679                return Err(format!(
1680                    "{location}: exclude[{idx}] is empty; remove the entry or set a \
1681                     real glob (an empty pattern matches nothing and is a no-op)"
1682                ));
1683            }
1684            if let Err(e) = glob::Pattern::new(g) {
1685                return Err(format!(
1686                    "{location}: exclude[{idx}] = {g:?} is not a valid glob: {e}"
1687                ));
1688            }
1689        }
1690        Ok(())
1691    }
1692
1693    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1694        if let Some(ref release) = krate.release {
1695            check(&format!("{location}.release"), release.exclude.as_deref())?;
1696        }
1697        if let Some(ref blobs) = krate.blobs {
1698            for (i, b) in blobs.iter().enumerate() {
1699                check(&format!("{location}.blobs[{i}]"), b.exclude.as_deref())?;
1700            }
1701        }
1702        Ok(())
1703    };
1704
1705    for krate in &config.crates {
1706        check_crate(&format!("crates[{}]", krate.name), krate)?;
1707    }
1708    if let Some(ref ws_list) = config.workspaces {
1709        for ws in ws_list {
1710            for krate in &ws.crates {
1711                check_crate(
1712                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1713                    krate,
1714                )?;
1715            }
1716        }
1717    }
1718    if let Some(ref list) = config.artifactories {
1719        for (i, a) in list.iter().enumerate() {
1720            check(&format!("artifactories[{i}]"), a.exclude.as_deref())?;
1721        }
1722    }
1723    if let Some(ref list) = config.cloudsmiths {
1724        for (i, c) in list.iter().enumerate() {
1725            check(&format!("cloudsmiths[{i}]"), c.exclude.as_deref())?;
1726        }
1727    }
1728    if let Some(ref list) = config.gemfury {
1729        for (i, g) in list.iter().enumerate() {
1730            check(&format!("gemfury[{i}]"), g.exclude.as_deref())?;
1731        }
1732    }
1733    if let Some(ref list) = config.uploads {
1734        for (i, u) in list.iter().enumerate() {
1735            check(&format!("uploads[{i}]"), u.exclude.as_deref())?;
1736        }
1737    }
1738    if let Some(ref release) = config.release {
1739        check("release", release.exclude.as_deref())?;
1740    }
1741    Ok(())
1742}
1743
1744// ---------------------------------------------------------------------------
1745// Per-crate publish visitor
1746// ---------------------------------------------------------------------------
1747
1748/// Identifies which of the three publish-config axes a visited block came from.
1749///
1750/// The config-validation walkers each format their own location string from
1751/// this identity, so different walkers can keep their distinct location wording
1752/// (`crate '{name}'` vs `crates[{name}].publish.homebrew_cask`) while sharing a
1753/// single iteration order: crates, then workspaces, then defaults.
1754pub(crate) enum PublishAxis<'a> {
1755    /// A top-level `crates[].publish` block, carrying the crate name.
1756    Crate { name: &'a str },
1757    /// A `workspaces[].crates[].publish` block, carrying the workspace and
1758    /// crate names.
1759    Workspace {
1760        workspace: &'a str,
1761        crate_name: &'a str,
1762    },
1763    /// The `defaults.publish` block.
1764    Defaults,
1765}
1766
1767impl PublishAxis<'_> {
1768    /// Location string in the bare publish-block wording shared by the
1769    /// submitter-required and legacy-Homebrew-Formula warnings:
1770    /// `crate '{name}'`, `workspaces[{ws}].crates[{krate}]`, or
1771    /// `defaults.publish`.
1772    pub(crate) fn location(&self) -> String {
1773        match self {
1774            PublishAxis::Crate { name } => format!("crate '{name}'"),
1775            PublishAxis::Workspace {
1776                workspace,
1777                crate_name,
1778            } => format!("workspaces[{workspace}].crates[{crate_name}]"),
1779            PublishAxis::Defaults => "defaults.publish".to_string(),
1780        }
1781    }
1782
1783    /// Location string in the cask-block wording used by the legacy
1784    /// Homebrew-Cask singular fold: `crates[{name}].publish.homebrew_cask`,
1785    /// `workspaces[{ws}].crates[{krate}].publish.homebrew_cask`, or
1786    /// `defaults.publish.homebrew_cask`.
1787    pub(crate) fn homebrew_cask_location(&self) -> String {
1788        match self {
1789            PublishAxis::Crate { name } => {
1790                format!("crates[{name}].publish.homebrew_cask")
1791            }
1792            PublishAxis::Workspace {
1793                workspace,
1794                crate_name,
1795            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.homebrew_cask"),
1796            PublishAxis::Defaults => "defaults.publish.homebrew_cask".to_string(),
1797        }
1798    }
1799
1800    /// Location string in the winget-block wording:
1801    /// `crates[{name}].publish.winget`,
1802    /// `workspaces[{ws}].crates[{krate}].publish.winget`, or
1803    /// `defaults.publish.winget`.
1804    pub(crate) fn winget_location(&self) -> String {
1805        match self {
1806            PublishAxis::Crate { name } => format!("crates[{name}].publish.winget"),
1807            PublishAxis::Workspace {
1808                workspace,
1809                crate_name,
1810            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.winget"),
1811            PublishAxis::Defaults => "defaults.publish.winget".to_string(),
1812        }
1813    }
1814}
1815
1816/// Shared, immutable view over the publisher sub-configs that appear on both
1817/// [`PublishConfig`] (the `crates[].publish` axis) and [`PublishDefaults`] (the
1818/// `defaults.publish` axis). The two underlying structs are distinct types, so
1819/// this enum erases the difference for read-only walkers.
1820pub(crate) enum PublishRef<'a> {
1821    /// A per-crate `publish:` block.
1822    Crate(&'a PublishConfig),
1823    /// The `defaults.publish:` block.
1824    Defaults(&'a PublishDefaults),
1825}
1826
1827impl PublishRef<'_> {
1828    pub(crate) fn homebrew(&self) -> Option<&HomebrewConfig> {
1829        match self {
1830            PublishRef::Crate(p) => p.homebrew.as_ref(),
1831            PublishRef::Defaults(p) => p.homebrew.as_ref(),
1832        }
1833    }
1834
1835    pub(crate) fn chocolatey(&self) -> Option<&ChocolateyConfig> {
1836        match self {
1837            PublishRef::Crate(p) => p.chocolatey.as_ref(),
1838            PublishRef::Defaults(p) => p.chocolatey.as_ref(),
1839        }
1840    }
1841
1842    pub(crate) fn winget(&self) -> Option<&WingetConfig> {
1843        match self {
1844            PublishRef::Crate(p) => p.winget.as_ref(),
1845            PublishRef::Defaults(p) => p.winget.as_ref(),
1846        }
1847    }
1848
1849    pub(crate) fn aur_source(&self) -> Option<&AurSourceConfig> {
1850        match self {
1851            PublishRef::Crate(p) => p.aur_source.as_ref(),
1852            PublishRef::Defaults(p) => p.aur_source.as_ref(),
1853        }
1854    }
1855
1856    pub(crate) fn homebrew_cask(&self) -> Option<&HomebrewCaskConfig> {
1857        match self {
1858            PublishRef::Crate(p) => p.homebrew_cask.as_ref(),
1859            PublishRef::Defaults(p) => p.homebrew_cask.as_ref(),
1860        }
1861    }
1862}
1863
1864/// Shared, mutable view over the publisher sub-configs that appear on both
1865/// [`PublishConfig`] and [`PublishDefaults`]. The `_mut` companion to
1866/// [`PublishRef`], for walkers that fold or rewrite a publisher block in place.
1867pub(crate) enum PublishMut<'a> {
1868    /// A per-crate `publish:` block.
1869    Crate(&'a mut PublishConfig),
1870    /// The `defaults.publish:` block.
1871    Defaults(&'a mut PublishDefaults),
1872}
1873
1874impl PublishMut<'_> {
1875    pub(crate) fn homebrew_cask_mut(&mut self) -> Option<&mut HomebrewCaskConfig> {
1876        match self {
1877            PublishMut::Crate(p) => p.homebrew_cask.as_mut(),
1878            PublishMut::Defaults(p) => p.homebrew_cask.as_mut(),
1879        }
1880    }
1881}
1882
1883/// Visit every `publish:` block across all three config axes — `crates[]`,
1884/// `workspaces[].crates[]`, then `defaults` — in that fixed order, passing each
1885/// block's [`PublishAxis`] identity and a read-only [`PublishRef`] view to
1886/// `visit`. Axes with no `publish:` block are skipped.
1887pub(crate) fn for_each_crate_publish<F>(config: &Config, mut visit: F)
1888where
1889    F: FnMut(PublishAxis<'_>, PublishRef<'_>),
1890{
1891    for krate in &config.crates {
1892        if let Some(ref publish) = krate.publish {
1893            visit(
1894                PublishAxis::Crate { name: &krate.name },
1895                PublishRef::Crate(publish),
1896            );
1897        }
1898    }
1899
1900    if let Some(ref workspaces) = config.workspaces {
1901        for ws in workspaces {
1902            for krate in &ws.crates {
1903                if let Some(ref publish) = krate.publish {
1904                    visit(
1905                        PublishAxis::Workspace {
1906                            workspace: &ws.name,
1907                            crate_name: &krate.name,
1908                        },
1909                        PublishRef::Crate(publish),
1910                    );
1911                }
1912            }
1913        }
1914    }
1915
1916    if let Some(ref defaults) = config.defaults
1917        && let Some(ref publish) = defaults.publish
1918    {
1919        visit(PublishAxis::Defaults, PublishRef::Defaults(publish));
1920    }
1921}
1922
1923/// Fallible companion to [`for_each_crate_publish`]: visits the same three axes
1924/// in the same fixed order, but short-circuits on the first `Err` the callback
1925/// returns, propagating it to the caller. For validators that early-exit on the
1926/// first offending block.
1927pub(crate) fn try_for_each_crate_publish<F, E>(config: &Config, mut visit: F) -> Result<(), E>
1928where
1929    F: FnMut(PublishAxis<'_>, PublishRef<'_>) -> Result<(), E>,
1930{
1931    for krate in &config.crates {
1932        if let Some(ref publish) = krate.publish {
1933            visit(
1934                PublishAxis::Crate { name: &krate.name },
1935                PublishRef::Crate(publish),
1936            )?;
1937        }
1938    }
1939
1940    if let Some(ref workspaces) = config.workspaces {
1941        for ws in workspaces {
1942            for krate in &ws.crates {
1943                if let Some(ref publish) = krate.publish {
1944                    visit(
1945                        PublishAxis::Workspace {
1946                            workspace: &ws.name,
1947                            crate_name: &krate.name,
1948                        },
1949                        PublishRef::Crate(publish),
1950                    )?;
1951                }
1952            }
1953        }
1954    }
1955
1956    if let Some(ref defaults) = config.defaults
1957        && let Some(ref publish) = defaults.publish
1958    {
1959        visit(PublishAxis::Defaults, PublishRef::Defaults(publish))?;
1960    }
1961
1962    Ok(())
1963}
1964
1965/// Mutable companion to [`for_each_crate_publish`]: visits the same three axes
1966/// in the same fixed order, passing a [`PublishMut`] view so the callback can
1967/// rewrite the publisher block in place.
1968pub(crate) fn for_each_crate_publish_mut<F>(config: &mut Config, mut visit: F)
1969where
1970    F: FnMut(PublishAxis<'_>, PublishMut<'_>),
1971{
1972    for krate in &mut config.crates {
1973        if let Some(ref mut publish) = krate.publish {
1974            visit(
1975                PublishAxis::Crate { name: &krate.name },
1976                PublishMut::Crate(publish),
1977            );
1978        }
1979    }
1980
1981    if let Some(ref mut workspaces) = config.workspaces {
1982        for ws in workspaces {
1983            for krate in &mut ws.crates {
1984                if let Some(ref mut publish) = krate.publish {
1985                    visit(
1986                        PublishAxis::Workspace {
1987                            workspace: &ws.name,
1988                            crate_name: &krate.name,
1989                        },
1990                        PublishMut::Crate(publish),
1991                    );
1992                }
1993            }
1994        }
1995    }
1996
1997    if let Some(ref mut defaults) = config.defaults
1998        && let Some(ref mut publish) = defaults.publish
1999    {
2000        visit(PublishAxis::Defaults, PublishMut::Defaults(publish));
2001    }
2002}
2003
2004/// A submitter moderation-queue advisory paired with the dispatch publisher
2005/// identity that produced it. The CLI filters by [`SubmitterAdvisory::publisher`]
2006/// so an advisory for a publisher deselected by `--skip` / `--publishers`
2007/// (e.g. `chocolatey` under a `--publishers npm` run) is suppressed instead of
2008/// emitted as noise.
2009#[derive(Debug, Clone, PartialEq, Eq)]
2010pub struct SubmitterAdvisory {
2011    /// Dispatch publisher name, matching the string
2012    /// [`crate::context::Context::publisher_deselected`] tests: `chocolatey`,
2013    /// `winget`, or `upstream-aur` (the AUR-source publisher's dispatch name).
2014    /// The CLI keys its deselection predicate on this value.
2015    pub publisher: String,
2016    /// The verbose advisory line surfaced to the operator.
2017    pub message: String,
2018}
2019
2020/// One advisory per publisher configured with `required: true` whose group is
2021/// Submitter (chocolatey, winget, aur_source), each tagged with its dispatch
2022/// publisher identity so the CLI can suppress advisories for deselected
2023/// publishers.
2024///
2025/// `required: true` on a submitter still fails the release when the submission
2026/// itself fails (it feeds `required_failures()`), but the external moderation
2027/// outcome resolves after the release run and cannot be gated on. The advisory
2028/// is non-fatal and clarifies which half of the semantics applies. Cargo is
2029/// excluded: its default is already `required: true` and the message would be
2030/// noise.
2031///
2032/// Covers all three publish axes — `crates[].publish`,
2033/// `workspaces[].crates[].publish`, and `defaults.publish` (via
2034/// [`for_each_crate_publish`]) — plus the top-level `aur_sources:` list.
2035///
2036/// Pure: this returns the advisories without emitting them. The CLI surfaces
2037/// them through `StageLogger::verbose` (the `--verbose`-gated register), so
2038/// they stay hidden at the default log level — see
2039/// `pipeline::load_config_logged`.
2040pub fn submitter_required_warnings(config: &Config) -> Vec<SubmitterAdvisory> {
2041    fn advisory(location: &str, name: &str, publisher: &str) -> SubmitterAdvisory {
2042        SubmitterAdvisory {
2043            publisher: publisher.to_string(),
2044            message: format!(
2045                "{location}: publisher '{name}' submits to an external moderation queue; \
2046                 `required: true` fails the release when the submission itself fails, \
2047                 but the eventual moderation outcome happens outside the release run \
2048                 and cannot be gated."
2049            ),
2050        }
2051    }
2052
2053    let mut warnings = Vec::new();
2054
2055    for_each_crate_publish(config, |axis, publish| {
2056        let loc = axis.location();
2057        if publish.chocolatey().and_then(|c| c.required) == Some(true) {
2058            warnings.push(advisory(&loc, "chocolatey", "chocolatey"));
2059        }
2060        if publish.winget().and_then(|w| w.required) == Some(true) {
2061            warnings.push(advisory(&loc, "winget", "winget"));
2062        }
2063        if publish.aur_source().and_then(|a| a.required) == Some(true) {
2064            // The AUR-source publisher dispatches under the name `upstream-aur`
2065            // (`AurSourcePublisher::PUBLISHER_NAME`); key the advisory on that so
2066            // the CLI's `publisher_deselected("upstream-aur")` filter matches.
2067            warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2068        }
2069    });
2070
2071    // Top-level aur_sources list (not nested under publish:) — no crate axis,
2072    // distinguish via the index in the list so two top-level entries collide cleanly.
2073    if let Some(ref sources) = config.aur_sources {
2074        for (idx, src) in sources.iter().enumerate() {
2075            if src.required == Some(true) {
2076                let loc = format!("top-level aur_sources[{idx}]");
2077                warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2078            }
2079        }
2080    }
2081
2082    warnings
2083}
2084
2085/// No-op preserved for API stability; the legacy `format:` and `builds:`
2086/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
2087/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
2088/// own — every alias hit was already announced at deserialize time.
2089///
2090pub fn apply_archive_legacy_aliases(_config: &mut Config) {
2091    // Intentionally empty — see Deserialize impls.
2092}
2093
2094/// Reject the legacy V1 `dockers:` block at config-load time with a
2095/// clear migration error.
2096///
2097/// anodizer is V2-only by design: it implements `dockers_v2:` and the
2098/// associated multi-arch buildx flow, but does not ship the V1
2099/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
2100/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
2101/// "unknown field `dockers`" message that doesn't tell the user how to
2102/// migrate. This explicit error names the field, points at `dockers_v2:`,
2103/// and references the rationale.
2104///
2105pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2106    if raw_yaml.get("dockers").is_some() {
2107        return Err(
2108            "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
2109             dockers_v2: only (multi-arch buildx flow). Port the config to `dockers_v2:` per \
2110             https://anodize.dev/docs/migration/docker.html."
2111                .to_string(),
2112        );
2113    }
2114    Ok(())
2115}
2116
2117/// Emit a `tracing::warn!` for each `publish.homebrew:` (Homebrew Formula)
2118/// occurrence in the loaded config. The upstream deprecated the
2119/// Formula publisher in favour of `homebrew_casks:`; anodizer mirrors the
2120/// upstream deprecation so users following the change-log see the
2121/// same migration prompt.
2122///
2123/// Covers three placement axes (matching how `publish.homebrew` may appear):
2124///   * `crates[].publish.homebrew`
2125///   * `workspaces[].crates[].publish.homebrew`
2126///   * `defaults.publish.homebrew`
2127///
2128/// There is no top-level `homebrew:` or `brews:` field on anodizer's
2129/// `Config` — only `homebrew_casks:` lives at the top level — so this
2130/// function does not need a top-level scan.
2131pub fn warn_on_legacy_homebrew_formula(config: &Config) {
2132    for msg in legacy_homebrew_formula_warnings(config) {
2133        tracing::warn!("{}", msg);
2134    }
2135}
2136
2137/// Pure helper: returns the warning strings without emitting them.
2138/// Exposed for tests; production callers use
2139/// [`warn_on_legacy_homebrew_formula`].
2140pub(crate) fn legacy_homebrew_formula_warnings(config: &Config) -> Vec<String> {
2141    fn formula_warning(location: &str) -> String {
2142        format!(
2143            "DEPRECATION: {location}: publish.homebrew (Homebrew Formula) is deprecated upstream \
2144             in GoReleaser v2.16; migrate to homebrew_casks. Cask is now the canonical Homebrew \
2145             distribution channel for pre-compiled binaries. See \
2146             https://anodize.dev/docs/publish/homebrew-casks/ for migration."
2147        )
2148    }
2149
2150    let mut warnings = Vec::new();
2151
2152    for_each_crate_publish(config, |axis, publish| {
2153        if publish.homebrew().is_some() {
2154            warnings.push(formula_warning(&axis.location()));
2155        }
2156    });
2157
2158    warnings
2159}
2160
2161/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
2162/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
2163/// so this function only needs to emit the deprecation warning when the
2164/// raw YAML key was the legacy one.
2165///
2166/// Because serde collapses the two spellings to a single field on parse, we
2167/// lose the information about which key the user wrote. This function
2168/// therefore consults the raw YAML pre-parse value (when supplied) to decide.
2169pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
2170    if let Some(snap) = raw_yaml.get("snapshot")
2171        && snap.get("name_template").is_some()
2172    {
2173        tracing::warn!(
2174            "DEPRECATION: snapshot.name_template is deprecated; use \
2175             snapshot.version_template instead. Both spellings are accepted \
2176             but the legacy key will be removed in a future release."
2177        );
2178    }
2179}
2180
2181/// Emit a one-time deprecation warning when a config uses the legacy
2182/// `furies:` top-level key. Serde transparently folds `furies:` into
2183/// `gemfury:` via `#[serde(alias)]`, so this function consults the raw YAML
2184/// pre-parse value to detect the legacy spelling.
2185///
2186/// The `furies → gemfury` rename messaging.
2187pub fn warn_on_legacy_furies_alias(raw_yaml: &serde_yaml_ng::Value) {
2188    if raw_yaml.get("furies").is_some() {
2189        tracing::warn!(
2190            "DEPRECATION: the top-level `furies:` config key is deprecated since GoReleaser \
2191             Pro v2.14; rename it to `gemfury:`. Both spellings are accepted but the legacy \
2192             key will be removed in a future release."
2193        );
2194    }
2195}
2196
2197/// Emit a one-time deprecation warning for each nfpm config object that uses
2198/// the legacy `builds:` key. Serde transparently folds `builds:` into `ids:`
2199/// via `#[serde(alias = "builds")]` on [`NfpmConfig::ids`], so this function
2200/// consults the raw YAML pre-parse value to detect the legacy spelling that the
2201/// typed parse would otherwise erase.
2202///
2203/// The deprecated `NFPM.Builds` field (use `ids` instead).
2204///
2205/// nfpm config objects appear under the key `nfpm` or `nfpms` (a single map or
2206/// a sequence of maps) at multiple nesting depths — top-level, under
2207/// `defaults:`, under each `crates[]` entry, and under each
2208/// `workspaces[].crates[]` entry. Rather than enumerate every path, this walks
2209/// the tree recursively and inspects a node as an nfpm config only when it is
2210/// the value of an `nfpm:`/`nfpms:` key, so an unrelated `builds:` key
2211/// elsewhere (e.g. archives) is not double-counted.
2212pub fn warn_on_legacy_nfpm_builds(raw_yaml: &serde_yaml_ng::Value) {
2213    fn warn_for_nfpm_value(value: &serde_yaml_ng::Value) {
2214        match value {
2215            serde_yaml_ng::Value::Mapping(_) => {
2216                if value.get("builds").is_some() {
2217                    tracing::warn!(
2218                        "DEPRECATION: nfpm `builds:` is deprecated; use `ids:` instead. \
2219                         Both spellings are accepted but the legacy key will be removed in \
2220                         a future release."
2221                    );
2222                }
2223            }
2224            serde_yaml_ng::Value::Sequence(items) => {
2225                for item in items {
2226                    warn_for_nfpm_value(item);
2227                }
2228            }
2229            _ => {}
2230        }
2231    }
2232
2233    fn descend(value: &serde_yaml_ng::Value) {
2234        match value {
2235            serde_yaml_ng::Value::Mapping(map) => {
2236                for (key, child) in map {
2237                    if matches!(key.as_str(), Some("nfpm") | Some("nfpms")) {
2238                        warn_for_nfpm_value(child);
2239                    }
2240                    descend(child);
2241                }
2242            }
2243            serde_yaml_ng::Value::Sequence(items) => {
2244                for item in items {
2245                    descend(item);
2246                }
2247            }
2248            _ => {}
2249        }
2250    }
2251
2252    descend(raw_yaml);
2253}
2254
2255/// Emit a one-time deprecation warning for each block that carries the legacy
2256/// `disable:` spelling of the canonical `skip:` field. Many config blocks
2257/// (`release`, `changelog`, `snapcraft`, the docker / installer / packager
2258/// blocks, …) accept `disable:` via `#[serde(alias = "disable")]` for
2259/// back-compat with imported configs; serde folds the alias into
2260/// `skip` on parse, erasing which spelling the user wrote. This helper
2261/// consults the raw YAML pre-parse value so porting users get a migration
2262/// prompt pointing at the canonical `skip:`.
2263///
2264/// Detection is allow-listed by enclosing block key, NOT a blind tree walk,
2265/// because free-form string-keyed maps would otherwise produce false
2266/// positives:
2267///   * Free-form string-keyed maps (`variables`, `derived_metadata`,
2268///     `build_args`, `labels`, `annotations`, `env`, header maps, …) let a
2269///     user legitimately name a key `disable`. Matching only when the key's
2270///     immediate enclosing block is allow-listed skips those — the nearest
2271///     named ancestor of such a key is the map's own key (e.g. `build_args`),
2272///     never an allow-listed block.
2273///
2274/// Axis-agnostic: the enclosing block key is identical whether the block sits
2275/// at the top level, under `defaults.<block>`, under `crates[].<block>`, or
2276/// under `workspaces[].crates[].<block>`, so a single nearest-named-ancestor
2277/// rule covers every placement.
2278pub fn warn_on_legacy_disable_alias(raw_yaml: &serde_yaml_ng::Value) {
2279    for msg in legacy_disable_alias_warnings(raw_yaml) {
2280        tracing::warn!("{}", msg);
2281    }
2282}
2283
2284/// Pure helper: returns one warning string per offending `disable:` key,
2285/// each naming the YAML path to the key. Exposed for tests; production callers
2286/// use [`warn_on_legacy_disable_alias`].
2287pub(crate) fn legacy_disable_alias_warnings(raw_yaml: &serde_yaml_ng::Value) -> Vec<String> {
2288    // Block key names whose struct exposes `skip` with `#[serde(alias =
2289    // "disable")]`. Resolved from the field's serde key on its parent (see the
2290    // `alias = "disable"` sites in core). `makeselfs` (top-level) and
2291    // `makeselves` (defaults.) both map to MakeselfConfig, so both are listed;
2292    // `gemfury` and its legacy `furies` alias both map to GemFuryConfig.
2293    const ALLOWLIST: &[&str] = &[
2294        "mcp",
2295        "makeselfs",
2296        "makeselves",
2297        "appimages",
2298        "msis",
2299        "pkgs",
2300        "nsis",
2301        "dockerhub",
2302        "release",
2303        "dockers_v2",
2304        "docker_v2",
2305        "changelog",
2306        "snapcrafts",
2307        "npms",
2308        "gemfury",
2309        "furies",
2310        "publishers",
2311        "sboms",
2312        "aur",
2313        "aur_source",
2314        "aur_sources",
2315        "blobs",
2316        "docker_digest",
2317        "checksum",
2318        "flatpaks",
2319    ];
2320
2321    fn disable_warning(path: &str) -> String {
2322        format!(
2323            "DEPRECATION: {path}: legacy `disable:` is deprecated; rename it to `skip:`. \
2324             Both spellings are accepted but the legacy key will be removed in a future release."
2325        )
2326    }
2327
2328    // `enclosing_block`: the nearest named (non-list-index) ancestor key — the
2329    // block the `disable:` key belongs to. Only warn when it is allow-listed.
2330    fn descend(
2331        value: &serde_yaml_ng::Value,
2332        path: &str,
2333        enclosing_block: Option<&str>,
2334        warnings: &mut Vec<String>,
2335    ) {
2336        match value {
2337            serde_yaml_ng::Value::Mapping(map) => {
2338                for (key, child) in map {
2339                    let Some(key) = key.as_str() else { continue };
2340                    let child_path = if path.is_empty() {
2341                        key.to_string()
2342                    } else {
2343                        format!("{path}.{key}")
2344                    };
2345                    if key == "disable"
2346                        && enclosing_block.is_some_and(|block| ALLOWLIST.contains(&block))
2347                    {
2348                        warnings.push(disable_warning(&child_path));
2349                    }
2350                    descend(child, &child_path, Some(key), warnings);
2351                }
2352            }
2353            serde_yaml_ng::Value::Sequence(items) => {
2354                for (idx, item) in items.iter().enumerate() {
2355                    let item_path = format!("{path}[{idx}]");
2356                    // A list index is not a named ancestor: keep the enclosing
2357                    // block (the list's own key) so e.g. `snapcrafts[0].disable`
2358                    // still resolves to the `snapcrafts` block.
2359                    descend(item, &item_path, enclosing_block, warnings);
2360                }
2361            }
2362            _ => {}
2363        }
2364    }
2365
2366    let mut warnings = Vec::new();
2367    descend(raw_yaml, "", None, &mut warnings);
2368    warnings
2369}
2370
2371/// Reject the legacy nested `mcp.github:` block with a
2372/// clear migration error.
2373///
2374/// The registry metadata that used to live under
2375/// `mcp.github:` (repository owner/name/url) to the top-level `mcp:` block
2376/// (canonical surface: `mcp.repository:`, `mcp.name:`, etc.). Anodizer
2377/// never carried the nested shim — its `McpConfig` has `deny_unknown_fields`
2378/// so the key would otherwise produce a generic "unknown field" message.
2379/// This pre-parse check intercepts the legacy spelling so the user sees a
2380/// migration pointer rather than a schema-shape error.
2381pub fn validate_no_mcp_github(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2382    if raw_yaml.get("mcp").and_then(|m| m.get("github")).is_some() {
2383        return Err(
2384            "config: nested `mcp.github:` block is not supported — anodizer mirrors GoReleaser \
2385             v2.13.1+ where registry metadata moved to top-level `mcp:` fields (`mcp.name`, \
2386             `mcp.repository.url`, `mcp.repository.source`). Port the nested keys to the \
2387             canonical surface."
2388                .to_string(),
2389        );
2390    }
2391    Ok(())
2392}
2393
2394/// Emit a one-time deprecation warning for each `dockers_v2[].retry:` or
2395/// `docker_manifests[].retry:` block at config-load time. The per-pipe
2396/// `retry:` field is the legacy shape (retry handling moved to
2397/// the top-level `retry:` block); the per-pipe value is still honored at
2398/// resolve-time (see `stage-docker::resolve_retry_params`) but a top-level
2399/// `retry:` is the canonical surface for retry policy. Warning fires once
2400/// per occurrence so users porting from older configs see a clear
2401/// pointer at load time without waiting for the docker pipe to execute.
2402pub fn warn_on_legacy_docker_retry(config: &Config) {
2403    for msg in legacy_docker_retry_warnings(config) {
2404        tracing::warn!("{}", msg);
2405    }
2406}
2407
2408/// Pure helper: returns the warning strings without emitting them. Exposed
2409/// for tests; production callers use [`warn_on_legacy_docker_retry`].
2410pub(crate) fn legacy_docker_retry_warnings(config: &Config) -> Vec<String> {
2411    fn pipe_warning(location: &str, kind: &str) -> String {
2412        format!(
2413            "DEPRECATION: {location}: nested `{kind}.retry:` is deprecated since GoReleaser \
2414             v2.15.3; move retry settings to the top-level `retry:` block. The per-pipe \
2415             value still wins at resolve time for back-compat, but the legacy spelling will \
2416             be removed in a future release."
2417        )
2418    }
2419
2420    let mut warnings = Vec::new();
2421
2422    let scan_crate = |krate: &CrateConfig, prefix: &str, warnings: &mut Vec<String>| {
2423        if let Some(ref v2) = krate.dockers_v2 {
2424            for (i, cfg) in v2.iter().enumerate() {
2425                if cfg.retry.is_some() {
2426                    warnings.push(pipe_warning(
2427                        &format!("{prefix}.dockers_v2[{i}]"),
2428                        "dockers_v2",
2429                    ));
2430                }
2431            }
2432        }
2433        if let Some(ref manifests) = krate.docker_manifests {
2434            for (i, cfg) in manifests.iter().enumerate() {
2435                if cfg.retry.is_some() {
2436                    warnings.push(pipe_warning(
2437                        &format!("{prefix}.docker_manifests[{i}]"),
2438                        "docker_manifests",
2439                    ));
2440                }
2441            }
2442        }
2443    };
2444
2445    for krate in &config.crates {
2446        scan_crate(krate, &format!("crates[{}]", krate.name), &mut warnings);
2447    }
2448
2449    if let Some(ref workspaces) = config.workspaces {
2450        for ws in workspaces {
2451            for krate in &ws.crates {
2452                scan_crate(
2453                    krate,
2454                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
2455                    &mut warnings,
2456                );
2457            }
2458        }
2459    }
2460
2461    if let Some(ref defaults) = config.defaults
2462        && let Some(ref v2) = defaults.dockers_v2
2463        && v2.retry.is_some()
2464    {
2465        warnings.push(pipe_warning("defaults.dockers_v2", "dockers_v2"));
2466    }
2467
2468    warnings
2469}
2470
2471/// Fold the deprecated singular Homebrew Cask fields into their canonical
2472/// plural lists and emit a one-time deprecation warning per folded field:
2473///
2474/// - `binary: <name>` → [`HomebrewCaskConfig::binaries`] (the upstream
2475///   renamed `binary:` to `binaries:`).
2476/// - `manpage: <page>` → [`HomebrewCaskConfig::manpages`].
2477///
2478/// anodizer accepts both spellings so imported configs keep parsing.
2479/// The captured values are moved out of [`HomebrewCaskConfig::legacy_binary`]
2480/// and [`HomebrewCaskConfig::legacy_manpage`] so downstream code only ever
2481/// reads the canonical plural fields.
2482///
2483/// The two folds use different insertion order: a legacy
2484/// `binary` is **prepended** to `binaries` so any explicit `binaries:` ordering
2485/// is preserved at the tail, whereas a legacy `manpage` is **appended** to
2486/// `manpages` (the cask renderer does
2487/// `brew.Manpages = append(brew.Manpages, brew.Manpage)`).
2488///
2489/// The fold runs across every config mode — top-level `homebrew_casks`,
2490/// per-crate `publish.homebrew_cask`, `workspaces[].crates[].publish`, and
2491/// `defaults.publish`.
2492pub fn apply_homebrew_cask_legacy_singulars(config: &mut Config) {
2493    /// Fold both deprecated singular fields (`binary:` → `binaries`,
2494    /// `manpage:` → `manpages`) on one cask, returning a warning per folded
2495    /// field. The singular `binary` is prepended to `binaries` so an explicit
2496    /// `binaries[0]` ordering is preserved at the tail; the singular `manpage`
2497    /// is appended to `manpages`.
2498    fn fold_one(location: &str, cask: &mut HomebrewCaskConfig) -> Vec<String> {
2499        let mut warnings = Vec::new();
2500        if let Some(legacy) = cask.legacy_binary.take() {
2501            let entry = HomebrewCaskBinary::Name(legacy.clone());
2502            match cask.binaries {
2503                Some(ref mut list) => list.insert(0, entry),
2504                None => cask.binaries = Some(vec![entry]),
2505            }
2506            warnings.push(format!(
2507                "DEPRECATION: {location}: singular `binary: {legacy}` is deprecated since \
2508                 GoReleaser v2.12.6; use the plural `binaries: [{legacy}]` form. The legacy \
2509                 value has been folded into binaries[0]."
2510            ));
2511        }
2512        if let Some(legacy) = cask.legacy_manpage.take() {
2513            match cask.manpages {
2514                Some(ref mut list) => list.push(legacy.clone()),
2515                None => cask.manpages = Some(vec![legacy.clone()]),
2516            }
2517            warnings.push(format!(
2518                "DEPRECATION: {location}: singular `manpage: {legacy}` is deprecated; \
2519                 use the plural `manpages: [{legacy}]` form. The legacy value has been \
2520                 folded into manpages."
2521            ));
2522        }
2523        warnings
2524    }
2525
2526    let mut warnings = Vec::new();
2527
2528    // Top-level homebrew_casks list (not nested under publish:) — not a
2529    // publish axis, so it is scanned separately from the visitor.
2530    if let Some(ref mut casks) = config.homebrew_casks {
2531        for (i, cask) in casks.iter_mut().enumerate() {
2532            warnings.extend(fold_one(&format!("homebrew_casks[{i}]"), cask));
2533        }
2534    }
2535
2536    for_each_crate_publish_mut(config, |axis, mut publish| {
2537        if let Some(cask) = publish.homebrew_cask_mut() {
2538            warnings.extend(fold_one(&axis.homebrew_cask_location(), cask));
2539        }
2540    });
2541
2542    for msg in warnings {
2543        tracing::warn!("{}", msg);
2544    }
2545}
2546
2547// ---------------------------------------------------------------------------
2548// EnvFilesConfig — accepts list of .env paths OR structured token file paths
2549// ---------------------------------------------------------------------------
2550
2551mod env_files;
2552pub use env_files::*;
2553
2554// ---------------------------------------------------------------------------
2555// Defaults
2556// ---------------------------------------------------------------------------
2557
2558mod defaults;
2559pub use defaults::*;
2560
2561// ---------------------------------------------------------------------------
2562// BuildIgnore — exclude specific os/arch combos from builds
2563// ---------------------------------------------------------------------------
2564
2565mod build;
2566pub use build::*;
2567
2568// ---------------------------------------------------------------------------
2569// ArchivesConfig — untagged enum: false => Disabled, array => Configs
2570// ---------------------------------------------------------------------------
2571
2572mod archives;
2573pub use archives::*;
2574
2575mod completions;
2576pub use completions::*;
2577
2578// ---------------------------------------------------------------------------
2579// ReleaseConfig
2580// ---------------------------------------------------------------------------
2581
2582mod release;
2583pub use release::*;
2584
2585// ---------------------------------------------------------------------------
2586// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
2587// ---------------------------------------------------------------------------
2588
2589mod publishers;
2590pub use publishers::*;
2591
2592// ---------------------------------------------------------------------------
2593// DockerV2Config
2594// ---------------------------------------------------------------------------
2595
2596mod docker;
2597pub use docker::*;
2598
2599// ---------------------------------------------------------------------------
2600// NfpmConfig
2601// ---------------------------------------------------------------------------
2602
2603mod nfpm;
2604pub use nfpm::*;
2605
2606// ---------------------------------------------------------------------------
2607// SnapcraftConfig
2608// ---------------------------------------------------------------------------
2609
2610mod snapcraft;
2611pub use snapcraft::*;
2612// ---------------------------------------------------------------------------
2613// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
2614// ---------------------------------------------------------------------------
2615
2616mod installers;
2617pub use installers::*;
2618
2619// ---------------------------------------------------------------------------
2620// BlobConfig (S3/GCS/Azure cloud storage)
2621// ---------------------------------------------------------------------------
2622
2623mod blob;
2624pub use blob::*;
2625
2626// ---------------------------------------------------------------------------
2627// PartialConfig (split/merge CI fan-out)
2628// ---------------------------------------------------------------------------
2629
2630mod partial;
2631pub use partial::*;
2632
2633// ---------------------------------------------------------------------------
2634// BinstallConfig
2635// ---------------------------------------------------------------------------
2636
2637mod binstall;
2638pub use binstall::*;
2639
2640// ---------------------------------------------------------------------------
2641// NotarizeConfig (macOS code signing and notarization)
2642// ---------------------------------------------------------------------------
2643
2644mod notarize;
2645pub use notarize::*;
2646// ---------------------------------------------------------------------------
2647// SourceConfig
2648// ---------------------------------------------------------------------------
2649
2650mod source;
2651pub use source::*;
2652
2653// ---------------------------------------------------------------------------
2654// SbomConfig
2655// ---------------------------------------------------------------------------
2656
2657mod sbom;
2658pub use sbom::*;
2659
2660// ---------------------------------------------------------------------------
2661// AttestationConfig
2662// ---------------------------------------------------------------------------
2663
2664mod attestation;
2665pub use attestation::*;
2666
2667// ---------------------------------------------------------------------------
2668// VersionSyncConfig
2669// ---------------------------------------------------------------------------
2670
2671mod version_sync;
2672pub use version_sync::*;
2673
2674// ---------------------------------------------------------------------------
2675// ChangelogConfig
2676// ---------------------------------------------------------------------------
2677
2678mod changelog;
2679pub use changelog::*;
2680// ---------------------------------------------------------------------------
2681// SignConfig / DockerSignConfig — lifted to `crate::signing`
2682// ---------------------------------------------------------------------------
2683//
2684// see `crate::signing` for the type definitions. The
2685// re-exports below preserve the historical
2686// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
2687// used by every stage that consumes a sign config.
2688
2689pub use crate::signing::{AuthenticodeConfig, DockerSignConfig, SignConfig};
2690
2691// ---------------------------------------------------------------------------
2692// UpxConfig
2693// ---------------------------------------------------------------------------
2694
2695mod upx;
2696pub use upx::*;
2697
2698// ---------------------------------------------------------------------------
2699// SnapshotConfig
2700// ---------------------------------------------------------------------------
2701
2702mod snapshot_nightly;
2703pub use snapshot_nightly::*;
2704
2705mod cargo_metadata;
2706pub use cargo_metadata::derive_metadata_from_cargo_toml;
2707
2708/// Extract the name portion of a `"Name <email>"` maintainer/author string,
2709/// dropping any `<…>` email suffix. Returns `None` when the result is empty
2710/// (e.g. a bare-email `<ada@example.com>`), so a derived Vendor / OCI `vendor`
2711/// value is never emitted blank.
2712pub fn maintainer_name_only(maintainer: &str) -> Option<String> {
2713    let name = maintainer.split('<').next().unwrap_or(maintainer).trim();
2714    (!name.is_empty()).then(|| name.to_string())
2715}
2716
2717// ---------------------------------------------------------------------------
2718// TemplateFileConfig
2719// ---------------------------------------------------------------------------
2720
2721mod templatefiles;
2722pub use templatefiles::*;
2723
2724// ---------------------------------------------------------------------------
2725// AnnounceConfig
2726// ---------------------------------------------------------------------------
2727mod announce;
2728pub use announce::*;
2729// ---------------------------------------------------------------------------
2730// DockerHub description sync
2731// ---------------------------------------------------------------------------
2732
2733mod dockerhub;
2734pub use dockerhub::*;
2735
2736// ---------------------------------------------------------------------------
2737// Artifactory publisher
2738// ---------------------------------------------------------------------------
2739
2740mod artifactory;
2741pub use artifactory::*;
2742
2743// ---------------------------------------------------------------------------
2744// CloudSmith publisher
2745// ---------------------------------------------------------------------------
2746
2747mod cloudsmith;
2748pub use cloudsmith::*;
2749
2750// ---------------------------------------------------------------------------
2751// PublisherConfig
2752// ---------------------------------------------------------------------------
2753
2754mod publisher;
2755pub use publisher::*;
2756
2757// ---------------------------------------------------------------------------
2758// HooksConfig
2759// ---------------------------------------------------------------------------
2760
2761mod hooks;
2762pub use hooks::*;
2763
2764// ---------------------------------------------------------------------------
2765// GitConfig
2766// ---------------------------------------------------------------------------
2767
2768mod git_config;
2769pub use git_config::*;
2770
2771// ---------------------------------------------------------------------------
2772// MonorepoConfig
2773// ---------------------------------------------------------------------------
2774
2775mod monorepo;
2776pub use monorepo::*;
2777
2778// ---------------------------------------------------------------------------
2779// TagConfig
2780// ---------------------------------------------------------------------------
2781
2782mod tag;
2783pub use tag::*;
2784
2785// ---------------------------------------------------------------------------
2786// WorkspaceConfig
2787// ---------------------------------------------------------------------------
2788
2789mod workspace;
2790pub use workspace::*;
2791
2792// ---------------------------------------------------------------------------
2793// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
2794// ---------------------------------------------------------------------------
2795
2796mod retry;
2797pub use retry::*;
2798
2799// ---------------------------------------------------------------------------
2800// PostPublishPollConfig (per-publisher post-publish polling)
2801// ---------------------------------------------------------------------------
2802
2803mod post_publish_poll;
2804pub use post_publish_poll::*;
2805
2806// ---------------------------------------------------------------------------
2807// VerifyReleaseConfig (top-level `verify_release:` post-publish gate)
2808// ---------------------------------------------------------------------------
2809
2810mod verify_release;
2811pub use verify_release::*;
2812
2813// ---------------------------------------------------------------------------
2814// StringOrBool — accepts bool or template string in YAML
2815// ---------------------------------------------------------------------------
2816
2817mod string_or_bool;
2818pub use string_or_bool::*;
2819
2820// ---------------------------------------------------------------------------
2821// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
2822// ---------------------------------------------------------------------------
2823//
2824// All packaging config types live in their own modules under
2825// `crate::packagers`. The re-exports below preserve the historical
2826// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
2827// import paths used by stages and tests.
2828
2829pub use crate::packagers::{
2830    AppImageConfig, AppImageExtra, MakeselfConfig, MakeselfFile, RuntimeHarvest, SrpmConfig,
2831};
2832pub(crate) use crate::packagers::{
2833    appimages_schema, deserialize_appimages, deserialize_makeselfs, makeselfs_schema,
2834};
2835
2836// ---------------------------------------------------------------------------
2837// MilestoneConfig
2838// ---------------------------------------------------------------------------
2839
2840mod milestone;
2841pub use milestone::*;
2842
2843// ---------------------------------------------------------------------------
2844// UploadConfig (generic HTTP upload)
2845// ---------------------------------------------------------------------------
2846
2847mod upload;
2848pub use upload::*;
2849
2850// ---------------------------------------------------------------------------
2851// AurSourceConfig
2852// ---------------------------------------------------------------------------
2853
2854mod aur_source;
2855pub use aur_source::*;
2856
2857// ---------------------------------------------------------------------------
2858// McpConfig (MCP registry publisher)
2859// ---------------------------------------------------------------------------
2860
2861mod mcp;
2862pub use mcp::*;
2863
2864// ---------------------------------------------------------------------------
2865// NpmConfig (NPM package registry publisher)
2866// ---------------------------------------------------------------------------
2867
2868mod npm;
2869pub use npm::*;
2870
2871// ---------------------------------------------------------------------------
2872// GemFuryConfig (Gemfury / fury.io publisher)
2873// ---------------------------------------------------------------------------
2874
2875mod gemfury;
2876pub use gemfury::*;
2877
2878// ---------------------------------------------------------------------------
2879// Tests
2880// ---------------------------------------------------------------------------
2881
2882#[cfg(test)]
2883mod tests;