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 ([`WINGET_ARCHITECTURES`]: `x64`, `arm64`, `x86`)
1278/// therefore matches
1279/// no installer, so the dependency would silently disappear from the generated
1280/// manifest. Reject it at config-validate instead of shipping a manifest that
1281/// quietly omits a declared dependency. An empty list (or absent
1282/// `architectures`) means "all installers" and is valid.
1283pub fn validate_winget_dependency_architectures(config: &Config) -> Result<(), String> {
1284    let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1285        let Some(ref deps) = winget.dependencies else {
1286            return Ok(());
1287        };
1288        for (i, dep) in deps.iter().enumerate() {
1289            let Some(ref scopes) = dep.architectures else {
1290                continue;
1291            };
1292            for scope in scopes {
1293                if !WINGET_ARCHITECTURES.contains(&scope.as_str()) {
1294                    return Err(format!(
1295                        "{location}: dependencies[{i}].architectures contains `{scope}`, \
1296                         which is not a valid winget architecture. Use one of: {} \
1297                         (or leave architectures empty/unset to apply the dependency \
1298                         to every installer).",
1299                        WINGET_ARCHITECTURES.join(", ")
1300                    ));
1301                }
1302            }
1303        }
1304        Ok(())
1305    };
1306
1307    try_for_each_crate_publish(config, |axis, publish| {
1308        if let Some(winget) = publish.winget() {
1309            check(&axis.winget_location(), winget)?;
1310        }
1311        Ok(())
1312    })
1313}
1314
1315/// Validate that `archives[].id` and `universal_binaries[].id` are unique
1316/// within their respective lists.
1317///
1318/// The id-uniqueness validation for archives and universal binaries.
1319/// Two archive
1320/// configs with the same `id` silently both set the same `id` metadata key
1321/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
1322/// build/sign stages already enforce id uniqueness; archive and
1323/// universal_binary were missed.
1324///
1325/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
1326/// - `crates[].archives:` / `crates[].universal_binaries:`
1327/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
1328/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
1329///   itself is vacuously true; not walked here.
1330///
1331pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
1332    fn check_unique(
1333        location: &str,
1334        kind: &str,
1335        ids: impl IntoIterator<Item = (usize, Option<String>)>,
1336    ) -> Result<(), String> {
1337        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1338        for (idx, maybe_id) in ids {
1339            // Empty is stored as "default" for archives via Default-time
1340            // assignment. Anodizer applies `default_archive_id` at deserialize
1341            // time, so the option is normally `Some("default")`. A truly empty
1342            // / None id here means the user explicitly cleared it; we still
1343            // dedupe across `None` so two None-id'd entries collide just like
1344            // two "default"-id'd entries would.
1345            let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
1346            if let Some(prev_idx) = seen.insert(key.clone(), idx) {
1347                return Err(format!(
1348                    "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
1349                     ids must be unique within a {kind} list."
1350                ));
1351            }
1352        }
1353        Ok(())
1354    }
1355
1356    let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1357        check_unique(
1358            location,
1359            "archives",
1360            archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
1361        )
1362    };
1363    let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
1364        check_unique(
1365            location,
1366            "universal_binaries",
1367            ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
1368        )
1369    };
1370
1371    for krate in &config.crates {
1372        if let ArchivesConfig::Configs(ref list) = krate.archives {
1373            check_archives(&format!("crates[{}].archives", krate.name), list)?;
1374        }
1375        if let Some(ref ubs) = krate.universal_binaries {
1376            check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
1377        }
1378    }
1379    if let Some(ws_list) = config.workspaces.as_ref() {
1380        for ws in ws_list {
1381            for krate in &ws.crates {
1382                if let ArchivesConfig::Configs(ref list) = krate.archives {
1383                    check_archives(
1384                        &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
1385                        list,
1386                    )?;
1387                }
1388                if let Some(ref ubs) = krate.universal_binaries {
1389                    check_unibins(
1390                        &format!(
1391                            "workspaces[{}].crates[{}].universal_binaries",
1392                            ws.name, krate.name
1393                        ),
1394                        ubs,
1395                    )?;
1396                }
1397            }
1398        }
1399    }
1400    Ok(())
1401}
1402
1403/// Validate `builds[]` entries that opt into `builder: prebuilt`.
1404///
1405/// `builder: prebuilt` skips `cargo build` and imports a binary the
1406/// operator staged elsewhere. The validation rules below follow the
1407/// `prebuilt` builder contract (`/customization/builds/builders/prebuilt.md`):
1408///
1409/// 1. `prebuilt:` block MUST be set and `prebuilt.path` MUST be non-empty.
1410/// 2. `targets:` MUST be explicit on the build entry — no `defaults.targets`
1411///    fallback. Without this rule the build matrix has no rows.
1412/// 3. Cargo-only knobs are rejected as mutually exclusive: `cross_tool`,
1413///    `features`, `no_default_features`, `command`. The crate-level
1414///    `cross:` strategy is also rejected when any build on the crate is
1415///    prebuilt (the strategy has no meaning when nothing is being
1416///    compiled).
1417/// 4. `builder: cargo` (the default) with a `prebuilt:` block set warns —
1418///    the block has no effect and likely indicates a forgotten
1419///    `builder: prebuilt`.
1420pub fn validate_builds(config: &Config) -> Result<(), String> {
1421    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1422        let Some(ref builds) = krate.builds else {
1423            return Ok(());
1424        };
1425        let crate_is_prebuilt = builds
1426            .iter()
1427            .any(|b| matches!(b.builder, Some(BuilderKind::Prebuilt)));
1428        if crate_is_prebuilt && krate.cross.is_some() {
1429            return Err(format!(
1430                "{location}: crate-level `cross:` strategy is set but at least one \
1431                 build uses `builder: prebuilt`; remove `cross:` (prebuilt imports a \
1432                 binary instead of compiling) or change the build's builder to `cargo`."
1433            ));
1434        }
1435        for (idx, build) in builds.iter().enumerate() {
1436            match build.builder {
1437                Some(BuilderKind::Prebuilt) => {
1438                    let path = build.prebuilt.as_ref().map(|p| p.path.trim()).unwrap_or("");
1439                    if path.is_empty() {
1440                        return Err(format!(
1441                            "{location}.builds[{idx}]: `builder: prebuilt` requires a non-empty \
1442                             `prebuilt.path` template. Example: \
1443                             `prebuilt: {{ path: \"output/mybin_{{{{ .Target }}}}\" }}`"
1444                        ));
1445                    }
1446                    let targets_explicit = build.targets.as_ref().is_some_and(|t| !t.is_empty());
1447                    if !targets_explicit {
1448                        return Err(format!(
1449                            "{location}.builds[{idx}] has `builder: prebuilt` but no explicit \
1450                             `targets:` — the prebuilt builder requires per-build target triples \
1451                             (no `defaults.targets:` fallback). Add `targets: [<triple>, ...]`."
1452                        ));
1453                    }
1454                    if build.cross_tool.as_ref().is_some_and(|s| !s.is_empty()) {
1455                        return Err(format!(
1456                            "{location}.builds[{idx}]: `cross_tool` is set with \
1457                             `builder: prebuilt` — the two are mutually exclusive. \
1458                             `cross_tool` controls how cargo cross-compiles; `prebuilt` \
1459                             imports an already-built binary. Drop `cross_tool` or use \
1460                             `builder: cargo`."
1461                        ));
1462                    }
1463                    if build.command.as_ref().is_some_and(|s| !s.is_empty()) {
1464                        return Err(format!(
1465                            "{location}.builds[{idx}]: `command:` override is set with \
1466                             `builder: prebuilt` — the override selects the cargo \
1467                             subcommand, which is not invoked under the prebuilt \
1468                             builder. Drop `command:` or use `builder: cargo`."
1469                        ));
1470                    }
1471                    if build.features.as_ref().is_some_and(|f| !f.is_empty()) {
1472                        return Err(format!(
1473                            "{location}.builds[{idx}]: `features:` is set with \
1474                             `builder: prebuilt` — Cargo features are evaluated at \
1475                             compile time, which the prebuilt builder skips. \
1476                             Drop `features:` or use `builder: cargo`."
1477                        ));
1478                    }
1479                    if build.no_default_features.is_some() {
1480                        return Err(format!(
1481                            "{location}.builds[{idx}]: `no_default_features:` is set with \
1482                             `builder: prebuilt` — Cargo feature flags are evaluated at \
1483                             compile time, which the prebuilt builder skips. \
1484                             Drop the flag or use `builder: cargo`."
1485                        ));
1486                    }
1487                }
1488                Some(BuilderKind::Cargo) | None => {
1489                    if build.prebuilt.is_some() {
1490                        tracing::warn!(
1491                            "{location}: build[{idx}] has a `prebuilt:` block but `builder:` \
1492                             is not `prebuilt`; the block is ignored. Set `builder: prebuilt` \
1493                             or remove the block."
1494                        );
1495                    }
1496                }
1497            }
1498        }
1499        Ok(())
1500    };
1501
1502    for krate in &config.crates {
1503        check_crate(&format!("crates[{}]", krate.name), krate)?;
1504    }
1505    if let Some(ws_list) = config.workspaces.as_ref() {
1506        for ws in ws_list {
1507            for krate in &ws.crates {
1508                check_crate(
1509                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1510                    krate,
1511                )?;
1512            }
1513        }
1514    }
1515    Ok(())
1516}
1517
1518/// Returns `true` if every build entry on every crate has
1519/// `builder: prebuilt`. Used by the determinism harness to short-circuit:
1520/// when no target compiles, there is nothing for the harness to rebuild
1521/// and compare across runs.
1522pub fn all_builds_prebuilt(config: &Config) -> bool {
1523    let crate_all_prebuilt = |krate: &CrateConfig| -> Option<bool> {
1524        let builds = krate.builds.as_ref()?;
1525        if builds.is_empty() {
1526            return None;
1527        }
1528        Some(
1529            builds
1530                .iter()
1531                .all(|b| matches!(b.builder, Some(BuilderKind::Prebuilt))),
1532        )
1533    };
1534
1535    let mut saw_any = false;
1536    for krate in &config.crates {
1537        match crate_all_prebuilt(krate) {
1538            Some(true) => saw_any = true,
1539            Some(false) => return false,
1540            None => {}
1541        }
1542    }
1543    if let Some(ws_list) = config.workspaces.as_ref() {
1544        for ws in ws_list {
1545            for krate in &ws.crates {
1546                match crate_all_prebuilt(krate) {
1547                    Some(true) => saw_any = true,
1548                    Some(false) => return false,
1549                    None => {}
1550                }
1551            }
1552        }
1553    }
1554    saw_any
1555}
1556
1557/// Validate the depth of `changelog.groups[].groups`.
1558///
1559/// Subgroups are capped at ONE level
1560/// (`/customization/publish/changelog.md`: "There can only be one level of
1561/// subgroups"). Anodizer's renderer can technically handle deeper nesting
1562/// (capped at 6 to match Markdown's heading limit), but accepting deeper
1563/// configs silently is a footgun: a config that works in anodizer but is
1564/// rejected here breaks parity for users migrating in.
1565///
1566/// Rejects any `changelog.groups[i].groups[j].groups[..]` configuration
1567/// with a clear error pointing at the offending parent group title.
1568pub fn validate_changelog_groups_depth(config: &Config) -> Result<(), String> {
1569    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1570        let Some(ref groups) = cfg.groups else {
1571            return Ok(());
1572        };
1573        for g in groups {
1574            if let Some(ref subs) = g.groups {
1575                for sub in subs {
1576                    if sub.groups.as_ref().is_some_and(|s| !s.is_empty()) {
1577                        return Err(format!(
1578                            "{location}: changelog group '{}' > '{}' nests further \
1579                             subgroups; GoReleaser permits only one level of subgroups \
1580                             (see https://goreleaser.com/customization/changelog/). \
1581                             Flatten the inner groups into the parent or split into \
1582                             sibling top-level groups.",
1583                            g.title, sub.title
1584                        ));
1585                    }
1586                }
1587            }
1588        }
1589        Ok(())
1590    };
1591    if let Some(ref cfg) = config.changelog {
1592        check("changelog", cfg)?;
1593    }
1594    if let Some(ref ws_list) = config.workspaces {
1595        for ws in ws_list {
1596            if let Some(ref cfg) = ws.changelog {
1597                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1598            }
1599        }
1600    }
1601    Ok(())
1602}
1603
1604/// Validate `changelog.paths[]` syntax.
1605///
1606/// Path patterns are passed straight to `git log -- <path>` (or the
1607/// per-SCM equivalent). Two patterns are always wrong:
1608/// - Leading `/` — git pathspec treats this as anchored-to-CWD which is
1609///   almost never what the user wrote and produces empty changelogs.
1610/// - Empty string — silently matches everything; rejected so a typo
1611///   doesn't disable filtering.
1612///
1613/// Globs containing `**` are accepted (git accepts them) but the docs
1614/// note their semantics differ from gitignore; that's a docs concern,
1615/// not a hard error.
1616pub fn validate_changelog_paths(config: &Config) -> Result<(), String> {
1617    let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1618        let Some(ref paths) = cfg.paths else {
1619            return Ok(());
1620        };
1621        for (idx, p) in paths.iter().enumerate() {
1622            if p.is_empty() {
1623                return Err(format!(
1624                    "{location}: changelog.paths[{idx}] is empty; remove the entry \
1625                     or set a real path (empty string matches everything and \
1626                     disables filtering)"
1627                ));
1628            }
1629            if p.starts_with('/') {
1630                return Err(format!(
1631                    "{location}: changelog.paths[{idx}] = {:?} starts with '/'; \
1632                     git pathspec is repo-root-relative — write {:?} instead",
1633                    p,
1634                    p.trim_start_matches('/')
1635                ));
1636            }
1637        }
1638        Ok(())
1639    };
1640    if let Some(ref cfg) = config.changelog {
1641        check("changelog", cfg)?;
1642    }
1643    if let Some(ref ws_list) = config.workspaces {
1644        for ws in ws_list {
1645            if let Some(ref cfg) = ws.changelog {
1646                check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1647            }
1648        }
1649    }
1650    Ok(())
1651}
1652
1653/// Validate every upload-destination `exclude:` glob across all config axes.
1654///
1655/// `exclude:` drops artifacts whose file name matches a glob (see
1656/// [`crate::artifact::passes_exclude_filter`]). An unparseable glob is treated
1657/// as non-matching at runtime so it never crashes a release — but a typo'd
1658/// glob that silently keeps an asset (or, worse, drops every asset) is a
1659/// foot-gun. Reject malformed globs here, at config-load, with a clear message
1660/// before they can take effect.
1661///
1662/// Covers every config position where `exclude:` is settable: per-crate
1663/// `release:` and `blobs:` (top-level crates AND `workspaces[].crates[]`), the
1664/// top-level `artifactories:`, `cloudsmiths:`, `gemfury:`, and `uploads:`
1665/// lists, and the top-level shared `release:` block.
1666pub fn validate_exclude_globs(config: &Config) -> Result<(), String> {
1667    fn check(location: &str, exclude: Option<&[String]>) -> Result<(), String> {
1668        let Some(globs) = exclude else {
1669            return Ok(());
1670        };
1671        for (idx, g) in globs.iter().enumerate() {
1672            if g.is_empty() {
1673                return Err(format!(
1674                    "{location}: exclude[{idx}] is empty; remove the entry or set a \
1675                     real glob (an empty pattern matches nothing and is a no-op)"
1676                ));
1677            }
1678            if let Err(e) = glob::Pattern::new(g) {
1679                return Err(format!(
1680                    "{location}: exclude[{idx}] = {g:?} is not a valid glob: {e}"
1681                ));
1682            }
1683        }
1684        Ok(())
1685    }
1686
1687    let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1688        if let Some(ref release) = krate.release {
1689            check(&format!("{location}.release"), release.exclude.as_deref())?;
1690        }
1691        if let Some(ref blobs) = krate.blobs {
1692            for (i, b) in blobs.iter().enumerate() {
1693                check(&format!("{location}.blobs[{i}]"), b.exclude.as_deref())?;
1694            }
1695        }
1696        Ok(())
1697    };
1698
1699    for krate in &config.crates {
1700        check_crate(&format!("crates[{}]", krate.name), krate)?;
1701    }
1702    if let Some(ref ws_list) = config.workspaces {
1703        for ws in ws_list {
1704            for krate in &ws.crates {
1705                check_crate(
1706                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1707                    krate,
1708                )?;
1709            }
1710        }
1711    }
1712    if let Some(ref list) = config.artifactories {
1713        for (i, a) in list.iter().enumerate() {
1714            check(&format!("artifactories[{i}]"), a.exclude.as_deref())?;
1715        }
1716    }
1717    if let Some(ref list) = config.cloudsmiths {
1718        for (i, c) in list.iter().enumerate() {
1719            check(&format!("cloudsmiths[{i}]"), c.exclude.as_deref())?;
1720        }
1721    }
1722    if let Some(ref list) = config.gemfury {
1723        for (i, g) in list.iter().enumerate() {
1724            check(&format!("gemfury[{i}]"), g.exclude.as_deref())?;
1725        }
1726    }
1727    if let Some(ref list) = config.uploads {
1728        for (i, u) in list.iter().enumerate() {
1729            check(&format!("uploads[{i}]"), u.exclude.as_deref())?;
1730        }
1731    }
1732    if let Some(ref release) = config.release {
1733        check("release", release.exclude.as_deref())?;
1734    }
1735    Ok(())
1736}
1737
1738// ---------------------------------------------------------------------------
1739// Per-crate publish visitor
1740// ---------------------------------------------------------------------------
1741
1742/// Identifies which of the three publish-config axes a visited block came from.
1743///
1744/// The config-validation walkers each format their own location string from
1745/// this identity, so different walkers can keep their distinct location wording
1746/// (`crate '{name}'` vs `crates[{name}].publish.homebrew_cask`) while sharing a
1747/// single iteration order: crates, then workspaces, then defaults.
1748pub(crate) enum PublishAxis<'a> {
1749    /// A top-level `crates[].publish` block, carrying the crate name.
1750    Crate { name: &'a str },
1751    /// A `workspaces[].crates[].publish` block, carrying the workspace and
1752    /// crate names.
1753    Workspace {
1754        workspace: &'a str,
1755        crate_name: &'a str,
1756    },
1757    /// The `defaults.publish` block.
1758    Defaults,
1759}
1760
1761impl PublishAxis<'_> {
1762    /// Location string in the bare publish-block wording shared by the
1763    /// submitter-required and legacy-Homebrew-Formula warnings:
1764    /// `crate '{name}'`, `workspaces[{ws}].crates[{krate}]`, or
1765    /// `defaults.publish`.
1766    pub(crate) fn location(&self) -> String {
1767        match self {
1768            PublishAxis::Crate { name } => format!("crate '{name}'"),
1769            PublishAxis::Workspace {
1770                workspace,
1771                crate_name,
1772            } => format!("workspaces[{workspace}].crates[{crate_name}]"),
1773            PublishAxis::Defaults => "defaults.publish".to_string(),
1774        }
1775    }
1776
1777    /// Location string in the cask-block wording used by the legacy
1778    /// Homebrew-Cask singular fold: `crates[{name}].publish.homebrew_cask`,
1779    /// `workspaces[{ws}].crates[{krate}].publish.homebrew_cask`, or
1780    /// `defaults.publish.homebrew_cask`.
1781    pub(crate) fn homebrew_cask_location(&self) -> String {
1782        match self {
1783            PublishAxis::Crate { name } => {
1784                format!("crates[{name}].publish.homebrew_cask")
1785            }
1786            PublishAxis::Workspace {
1787                workspace,
1788                crate_name,
1789            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.homebrew_cask"),
1790            PublishAxis::Defaults => "defaults.publish.homebrew_cask".to_string(),
1791        }
1792    }
1793
1794    /// Location string in the winget-block wording:
1795    /// `crates[{name}].publish.winget`,
1796    /// `workspaces[{ws}].crates[{krate}].publish.winget`, or
1797    /// `defaults.publish.winget`.
1798    pub(crate) fn winget_location(&self) -> String {
1799        match self {
1800            PublishAxis::Crate { name } => format!("crates[{name}].publish.winget"),
1801            PublishAxis::Workspace {
1802                workspace,
1803                crate_name,
1804            } => format!("workspaces[{workspace}].crates[{crate_name}].publish.winget"),
1805            PublishAxis::Defaults => "defaults.publish.winget".to_string(),
1806        }
1807    }
1808}
1809
1810/// Shared, immutable view over the publisher sub-configs that appear on both
1811/// [`PublishConfig`] (the `crates[].publish` axis) and [`PublishDefaults`] (the
1812/// `defaults.publish` axis). The two underlying structs are distinct types, so
1813/// this enum erases the difference for read-only walkers.
1814pub(crate) enum PublishRef<'a> {
1815    /// A per-crate `publish:` block.
1816    Crate(&'a PublishConfig),
1817    /// The `defaults.publish:` block.
1818    Defaults(&'a PublishDefaults),
1819}
1820
1821impl PublishRef<'_> {
1822    pub(crate) fn homebrew(&self) -> Option<&HomebrewConfig> {
1823        match self {
1824            PublishRef::Crate(p) => p.homebrew.as_ref(),
1825            PublishRef::Defaults(p) => p.homebrew.as_ref(),
1826        }
1827    }
1828
1829    pub(crate) fn chocolatey(&self) -> Option<&ChocolateyConfig> {
1830        match self {
1831            PublishRef::Crate(p) => p.chocolatey.as_ref(),
1832            PublishRef::Defaults(p) => p.chocolatey.as_ref(),
1833        }
1834    }
1835
1836    pub(crate) fn winget(&self) -> Option<&WingetConfig> {
1837        match self {
1838            PublishRef::Crate(p) => p.winget.as_ref(),
1839            PublishRef::Defaults(p) => p.winget.as_ref(),
1840        }
1841    }
1842
1843    pub(crate) fn aur_source(&self) -> Option<&AurSourceConfig> {
1844        match self {
1845            PublishRef::Crate(p) => p.aur_source.as_ref(),
1846            PublishRef::Defaults(p) => p.aur_source.as_ref(),
1847        }
1848    }
1849
1850    pub(crate) fn homebrew_cask(&self) -> Option<&HomebrewCaskConfig> {
1851        match self {
1852            PublishRef::Crate(p) => p.homebrew_cask.as_ref(),
1853            PublishRef::Defaults(p) => p.homebrew_cask.as_ref(),
1854        }
1855    }
1856}
1857
1858/// Shared, mutable view over the publisher sub-configs that appear on both
1859/// [`PublishConfig`] and [`PublishDefaults`]. The `_mut` companion to
1860/// [`PublishRef`], for walkers that fold or rewrite a publisher block in place.
1861pub(crate) enum PublishMut<'a> {
1862    /// A per-crate `publish:` block.
1863    Crate(&'a mut PublishConfig),
1864    /// The `defaults.publish:` block.
1865    Defaults(&'a mut PublishDefaults),
1866}
1867
1868impl PublishMut<'_> {
1869    pub(crate) fn homebrew_cask_mut(&mut self) -> Option<&mut HomebrewCaskConfig> {
1870        match self {
1871            PublishMut::Crate(p) => p.homebrew_cask.as_mut(),
1872            PublishMut::Defaults(p) => p.homebrew_cask.as_mut(),
1873        }
1874    }
1875}
1876
1877/// Visit every `publish:` block across all three config axes — `crates[]`,
1878/// `workspaces[].crates[]`, then `defaults` — in that fixed order, passing each
1879/// block's [`PublishAxis`] identity and a read-only [`PublishRef`] view to
1880/// `visit`. Axes with no `publish:` block are skipped.
1881pub(crate) fn for_each_crate_publish<F>(config: &Config, mut visit: F)
1882where
1883    F: FnMut(PublishAxis<'_>, PublishRef<'_>),
1884{
1885    for krate in &config.crates {
1886        if let Some(ref publish) = krate.publish {
1887            visit(
1888                PublishAxis::Crate { name: &krate.name },
1889                PublishRef::Crate(publish),
1890            );
1891        }
1892    }
1893
1894    if let Some(ref workspaces) = config.workspaces {
1895        for ws in workspaces {
1896            for krate in &ws.crates {
1897                if let Some(ref publish) = krate.publish {
1898                    visit(
1899                        PublishAxis::Workspace {
1900                            workspace: &ws.name,
1901                            crate_name: &krate.name,
1902                        },
1903                        PublishRef::Crate(publish),
1904                    );
1905                }
1906            }
1907        }
1908    }
1909
1910    if let Some(ref defaults) = config.defaults
1911        && let Some(ref publish) = defaults.publish
1912    {
1913        visit(PublishAxis::Defaults, PublishRef::Defaults(publish));
1914    }
1915}
1916
1917/// Fallible companion to [`for_each_crate_publish`]: visits the same three axes
1918/// in the same fixed order, but short-circuits on the first `Err` the callback
1919/// returns, propagating it to the caller. For validators that early-exit on the
1920/// first offending block.
1921pub(crate) fn try_for_each_crate_publish<F, E>(config: &Config, mut visit: F) -> Result<(), E>
1922where
1923    F: FnMut(PublishAxis<'_>, PublishRef<'_>) -> Result<(), E>,
1924{
1925    for krate in &config.crates {
1926        if let Some(ref publish) = krate.publish {
1927            visit(
1928                PublishAxis::Crate { name: &krate.name },
1929                PublishRef::Crate(publish),
1930            )?;
1931        }
1932    }
1933
1934    if let Some(ref workspaces) = config.workspaces {
1935        for ws in workspaces {
1936            for krate in &ws.crates {
1937                if let Some(ref publish) = krate.publish {
1938                    visit(
1939                        PublishAxis::Workspace {
1940                            workspace: &ws.name,
1941                            crate_name: &krate.name,
1942                        },
1943                        PublishRef::Crate(publish),
1944                    )?;
1945                }
1946            }
1947        }
1948    }
1949
1950    if let Some(ref defaults) = config.defaults
1951        && let Some(ref publish) = defaults.publish
1952    {
1953        visit(PublishAxis::Defaults, PublishRef::Defaults(publish))?;
1954    }
1955
1956    Ok(())
1957}
1958
1959/// Mutable companion to [`for_each_crate_publish`]: visits the same three axes
1960/// in the same fixed order, passing a [`PublishMut`] view so the callback can
1961/// rewrite the publisher block in place.
1962pub(crate) fn for_each_crate_publish_mut<F>(config: &mut Config, mut visit: F)
1963where
1964    F: FnMut(PublishAxis<'_>, PublishMut<'_>),
1965{
1966    for krate in &mut config.crates {
1967        if let Some(ref mut publish) = krate.publish {
1968            visit(
1969                PublishAxis::Crate { name: &krate.name },
1970                PublishMut::Crate(publish),
1971            );
1972        }
1973    }
1974
1975    if let Some(ref mut workspaces) = config.workspaces {
1976        for ws in workspaces {
1977            for krate in &mut ws.crates {
1978                if let Some(ref mut publish) = krate.publish {
1979                    visit(
1980                        PublishAxis::Workspace {
1981                            workspace: &ws.name,
1982                            crate_name: &krate.name,
1983                        },
1984                        PublishMut::Crate(publish),
1985                    );
1986                }
1987            }
1988        }
1989    }
1990
1991    if let Some(ref mut defaults) = config.defaults
1992        && let Some(ref mut publish) = defaults.publish
1993    {
1994        visit(PublishAxis::Defaults, PublishMut::Defaults(publish));
1995    }
1996}
1997
1998/// A submitter moderation-queue advisory paired with the dispatch publisher
1999/// identity that produced it. The CLI filters by [`SubmitterAdvisory::publisher`]
2000/// so an advisory for a publisher deselected by `--skip` / `--publishers`
2001/// (e.g. `chocolatey` under a `--publishers npm` run) is suppressed instead of
2002/// emitted as noise.
2003#[derive(Debug, Clone, PartialEq, Eq)]
2004pub struct SubmitterAdvisory {
2005    /// Dispatch publisher name, matching the string
2006    /// [`crate::context::Context::publisher_deselected`] tests: `chocolatey`,
2007    /// `winget`, or `upstream-aur` (the AUR-source publisher's dispatch name).
2008    /// The CLI keys its deselection predicate on this value.
2009    pub publisher: String,
2010    /// The verbose advisory line surfaced to the operator.
2011    pub message: String,
2012}
2013
2014/// One advisory per publisher configured with `required: true` whose group is
2015/// Submitter (chocolatey, winget, aur_source), each tagged with its dispatch
2016/// publisher identity so the CLI can suppress advisories for deselected
2017/// publishers.
2018///
2019/// `required: true` on a submitter still fails the release when the submission
2020/// itself fails (it feeds `required_failures()`), but the external moderation
2021/// outcome resolves after the release run and cannot be gated on. The advisory
2022/// is non-fatal and clarifies which half of the semantics applies. Cargo is
2023/// excluded: its default is already `required: true` and the message would be
2024/// noise.
2025///
2026/// Covers all three publish axes — `crates[].publish`,
2027/// `workspaces[].crates[].publish`, and `defaults.publish` (via
2028/// [`for_each_crate_publish`]) — plus the top-level `aur_sources:` list.
2029///
2030/// Pure: this returns the advisories without emitting them. The CLI surfaces
2031/// them through `StageLogger::verbose` (the `--verbose`-gated register), so
2032/// they stay hidden at the default log level — see
2033/// `pipeline::load_config_logged`.
2034pub fn submitter_required_warnings(config: &Config) -> Vec<SubmitterAdvisory> {
2035    fn advisory(location: &str, name: &str, publisher: &str) -> SubmitterAdvisory {
2036        SubmitterAdvisory {
2037            publisher: publisher.to_string(),
2038            message: format!(
2039                "{location}: publisher '{name}' submits to an external moderation queue; \
2040                 `required: true` fails the release when the submission itself fails, \
2041                 but the eventual moderation outcome happens outside the release run \
2042                 and cannot be gated."
2043            ),
2044        }
2045    }
2046
2047    let mut warnings = Vec::new();
2048
2049    for_each_crate_publish(config, |axis, publish| {
2050        let loc = axis.location();
2051        if publish.chocolatey().and_then(|c| c.required) == Some(true) {
2052            warnings.push(advisory(&loc, "chocolatey", "chocolatey"));
2053        }
2054        if publish.winget().and_then(|w| w.required) == Some(true) {
2055            warnings.push(advisory(&loc, "winget", "winget"));
2056        }
2057        if publish.aur_source().and_then(|a| a.required) == Some(true) {
2058            // The AUR-source publisher dispatches under the name `upstream-aur`
2059            // (`AurSourcePublisher::PUBLISHER_NAME`); key the advisory on that so
2060            // the CLI's `publisher_deselected("upstream-aur")` filter matches.
2061            warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2062        }
2063    });
2064
2065    // Top-level aur_sources list (not nested under publish:) — no crate axis,
2066    // distinguish via the index in the list so two top-level entries collide cleanly.
2067    if let Some(ref sources) = config.aur_sources {
2068        for (idx, src) in sources.iter().enumerate() {
2069            if src.required == Some(true) {
2070                let loc = format!("top-level aur_sources[{idx}]");
2071                warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2072            }
2073        }
2074    }
2075
2076    warnings
2077}
2078
2079/// No-op preserved for API stability; the legacy `format:` and `builds:`
2080/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
2081/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
2082/// own — every alias hit was already announced at deserialize time.
2083///
2084pub fn apply_archive_legacy_aliases(_config: &mut Config) {
2085    // Intentionally empty — see Deserialize impls.
2086}
2087
2088/// Reject the legacy V1 `dockers:` block at config-load time with a
2089/// clear migration error.
2090///
2091/// anodizer is V2-only by design: it implements `dockers_v2:` and the
2092/// associated multi-arch buildx flow, but does not ship the V1
2093/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
2094/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
2095/// "unknown field `dockers`" message that doesn't tell the user how to
2096/// migrate. This explicit error names the field, points at `dockers_v2:`,
2097/// and references the rationale.
2098///
2099pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2100    if raw_yaml.get("dockers").is_some() {
2101        return Err(
2102            "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
2103             dockers_v2: only (multi-arch buildx flow). Port the config to `dockers_v2:` per \
2104             https://anodize.dev/docs/migration/docker.html."
2105                .to_string(),
2106        );
2107    }
2108    Ok(())
2109}
2110
2111/// Emit a `tracing::warn!` for each `publish.homebrew:` (Homebrew Formula)
2112/// occurrence in the loaded config. The upstream deprecated the
2113/// Formula publisher in favour of `homebrew_casks:`; anodizer mirrors the
2114/// upstream deprecation so users following the change-log see the
2115/// same migration prompt.
2116///
2117/// Covers three placement axes (matching how `publish.homebrew` may appear):
2118///   * `crates[].publish.homebrew`
2119///   * `workspaces[].crates[].publish.homebrew`
2120///   * `defaults.publish.homebrew`
2121///
2122/// There is no top-level `homebrew:` or `brews:` field on anodizer's
2123/// `Config` — only `homebrew_casks:` lives at the top level — so this
2124/// function does not need a top-level scan.
2125pub fn warn_on_legacy_homebrew_formula(config: &Config) {
2126    for msg in legacy_homebrew_formula_warnings(config) {
2127        tracing::warn!("{}", msg);
2128    }
2129}
2130
2131/// Pure helper: returns the warning strings without emitting them.
2132/// Exposed for tests; production callers use
2133/// [`warn_on_legacy_homebrew_formula`].
2134pub(crate) fn legacy_homebrew_formula_warnings(config: &Config) -> Vec<String> {
2135    fn formula_warning(location: &str) -> String {
2136        format!(
2137            "DEPRECATION: {location}: publish.homebrew (Homebrew Formula) is deprecated upstream \
2138             in GoReleaser v2.16; migrate to homebrew_casks. Cask is now the canonical Homebrew \
2139             distribution channel for pre-compiled binaries. See \
2140             https://anodize.dev/docs/publish/homebrew-casks/ for migration."
2141        )
2142    }
2143
2144    let mut warnings = Vec::new();
2145
2146    for_each_crate_publish(config, |axis, publish| {
2147        if publish.homebrew().is_some() {
2148            warnings.push(formula_warning(&axis.location()));
2149        }
2150    });
2151
2152    warnings
2153}
2154
2155/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
2156/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
2157/// so this function only needs to emit the deprecation warning when the
2158/// raw YAML key was the legacy one.
2159///
2160/// Because serde collapses the two spellings to a single field on parse, we
2161/// lose the information about which key the user wrote. This function
2162/// therefore consults the raw YAML pre-parse value (when supplied) to decide.
2163pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
2164    if let Some(snap) = raw_yaml.get("snapshot")
2165        && snap.get("name_template").is_some()
2166    {
2167        tracing::warn!(
2168            "DEPRECATION: snapshot.name_template is deprecated; use \
2169             snapshot.version_template instead. Both spellings are accepted \
2170             but the legacy key will be removed in a future release."
2171        );
2172    }
2173}
2174
2175/// Emit a one-time deprecation warning when a config uses the legacy
2176/// `furies:` top-level key. Serde transparently folds `furies:` into
2177/// `gemfury:` via `#[serde(alias)]`, so this function consults the raw YAML
2178/// pre-parse value to detect the legacy spelling.
2179///
2180/// The `furies → gemfury` rename messaging.
2181pub fn warn_on_legacy_furies_alias(raw_yaml: &serde_yaml_ng::Value) {
2182    if raw_yaml.get("furies").is_some() {
2183        tracing::warn!(
2184            "DEPRECATION: the top-level `furies:` config key is deprecated since GoReleaser \
2185             Pro v2.14; rename it to `gemfury:`. Both spellings are accepted but the legacy \
2186             key will be removed in a future release."
2187        );
2188    }
2189}
2190
2191/// Emit a one-time deprecation warning for each nfpm config object that uses
2192/// the legacy `builds:` key. Serde transparently folds `builds:` into `ids:`
2193/// via `#[serde(alias = "builds")]` on [`NfpmConfig::ids`], so this function
2194/// consults the raw YAML pre-parse value to detect the legacy spelling that the
2195/// typed parse would otherwise erase.
2196///
2197/// The deprecated `NFPM.Builds` field (use `ids` instead).
2198///
2199/// nfpm config objects appear under the key `nfpm` or `nfpms` (a single map or
2200/// a sequence of maps) at multiple nesting depths — top-level, under
2201/// `defaults:`, under each `crates[]` entry, and under each
2202/// `workspaces[].crates[]` entry. Rather than enumerate every path, this walks
2203/// the tree recursively and inspects a node as an nfpm config only when it is
2204/// the value of an `nfpm:`/`nfpms:` key, so an unrelated `builds:` key
2205/// elsewhere (e.g. archives) is not double-counted.
2206pub fn warn_on_legacy_nfpm_builds(raw_yaml: &serde_yaml_ng::Value) {
2207    fn warn_for_nfpm_value(value: &serde_yaml_ng::Value) {
2208        match value {
2209            serde_yaml_ng::Value::Mapping(_) => {
2210                if value.get("builds").is_some() {
2211                    tracing::warn!(
2212                        "DEPRECATION: nfpm `builds:` is deprecated; use `ids:` instead. \
2213                         Both spellings are accepted but the legacy key will be removed in \
2214                         a future release."
2215                    );
2216                }
2217            }
2218            serde_yaml_ng::Value::Sequence(items) => {
2219                for item in items {
2220                    warn_for_nfpm_value(item);
2221                }
2222            }
2223            _ => {}
2224        }
2225    }
2226
2227    fn descend(value: &serde_yaml_ng::Value) {
2228        match value {
2229            serde_yaml_ng::Value::Mapping(map) => {
2230                for (key, child) in map {
2231                    if matches!(key.as_str(), Some("nfpm") | Some("nfpms")) {
2232                        warn_for_nfpm_value(child);
2233                    }
2234                    descend(child);
2235                }
2236            }
2237            serde_yaml_ng::Value::Sequence(items) => {
2238                for item in items {
2239                    descend(item);
2240                }
2241            }
2242            _ => {}
2243        }
2244    }
2245
2246    descend(raw_yaml);
2247}
2248
2249/// Emit a one-time deprecation warning for each block that carries the legacy
2250/// `disable:` spelling of the canonical `skip:` field. Many config blocks
2251/// (`release`, `changelog`, `snapcraft`, the docker / installer / packager
2252/// blocks, …) accept `disable:` via `#[serde(alias = "disable")]` for
2253/// back-compat with imported configs; serde folds the alias into
2254/// `skip` on parse, erasing which spelling the user wrote. This helper
2255/// consults the raw YAML pre-parse value so porting users get a migration
2256/// prompt pointing at the canonical `skip:`.
2257///
2258/// Detection is allow-listed by enclosing block key, NOT a blind tree walk,
2259/// because free-form string-keyed maps would otherwise produce false
2260/// positives:
2261///   * Free-form string-keyed maps (`variables`, `derived_metadata`,
2262///     `build_args`, `labels`, `annotations`, `env`, header maps, …) let a
2263///     user legitimately name a key `disable`. Matching only when the key's
2264///     immediate enclosing block is allow-listed skips those — the nearest
2265///     named ancestor of such a key is the map's own key (e.g. `build_args`),
2266///     never an allow-listed block.
2267///
2268/// Axis-agnostic: the enclosing block key is identical whether the block sits
2269/// at the top level, under `defaults.<block>`, under `crates[].<block>`, or
2270/// under `workspaces[].crates[].<block>`, so a single nearest-named-ancestor
2271/// rule covers every placement.
2272pub fn warn_on_legacy_disable_alias(raw_yaml: &serde_yaml_ng::Value) {
2273    for msg in legacy_disable_alias_warnings(raw_yaml) {
2274        tracing::warn!("{}", msg);
2275    }
2276}
2277
2278/// Pure helper: returns one warning string per offending `disable:` key,
2279/// each naming the YAML path to the key. Exposed for tests; production callers
2280/// use [`warn_on_legacy_disable_alias`].
2281pub(crate) fn legacy_disable_alias_warnings(raw_yaml: &serde_yaml_ng::Value) -> Vec<String> {
2282    // Block key names whose struct exposes `skip` with `#[serde(alias =
2283    // "disable")]`. Resolved from the field's serde key on its parent (see the
2284    // `alias = "disable"` sites in core). `makeselfs` (top-level) and
2285    // `makeselves` (defaults.) both map to MakeselfConfig, so both are listed;
2286    // `gemfury` and its legacy `furies` alias both map to GemFuryConfig.
2287    const ALLOWLIST: &[&str] = &[
2288        "mcp",
2289        "makeselfs",
2290        "makeselves",
2291        "appimages",
2292        "msis",
2293        "pkgs",
2294        "nsis",
2295        "dockerhub",
2296        "release",
2297        "dockers_v2",
2298        "docker_v2",
2299        "changelog",
2300        "snapcrafts",
2301        "npms",
2302        "gemfury",
2303        "furies",
2304        "publishers",
2305        "sboms",
2306        "aur",
2307        "aur_source",
2308        "aur_sources",
2309        "blobs",
2310        "docker_digest",
2311        "checksum",
2312        "flatpaks",
2313    ];
2314
2315    fn disable_warning(path: &str) -> String {
2316        format!(
2317            "DEPRECATION: {path}: legacy `disable:` is deprecated; rename it to `skip:`. \
2318             Both spellings are accepted but the legacy key will be removed in a future release."
2319        )
2320    }
2321
2322    // `enclosing_block`: the nearest named (non-list-index) ancestor key — the
2323    // block the `disable:` key belongs to. Only warn when it is allow-listed.
2324    fn descend(
2325        value: &serde_yaml_ng::Value,
2326        path: &str,
2327        enclosing_block: Option<&str>,
2328        warnings: &mut Vec<String>,
2329    ) {
2330        match value {
2331            serde_yaml_ng::Value::Mapping(map) => {
2332                for (key, child) in map {
2333                    let Some(key) = key.as_str() else { continue };
2334                    let child_path = if path.is_empty() {
2335                        key.to_string()
2336                    } else {
2337                        format!("{path}.{key}")
2338                    };
2339                    if key == "disable"
2340                        && enclosing_block.is_some_and(|block| ALLOWLIST.contains(&block))
2341                    {
2342                        warnings.push(disable_warning(&child_path));
2343                    }
2344                    descend(child, &child_path, Some(key), warnings);
2345                }
2346            }
2347            serde_yaml_ng::Value::Sequence(items) => {
2348                for (idx, item) in items.iter().enumerate() {
2349                    let item_path = format!("{path}[{idx}]");
2350                    // A list index is not a named ancestor: keep the enclosing
2351                    // block (the list's own key) so e.g. `snapcrafts[0].disable`
2352                    // still resolves to the `snapcrafts` block.
2353                    descend(item, &item_path, enclosing_block, warnings);
2354                }
2355            }
2356            _ => {}
2357        }
2358    }
2359
2360    let mut warnings = Vec::new();
2361    descend(raw_yaml, "", None, &mut warnings);
2362    warnings
2363}
2364
2365/// Reject the legacy nested `mcp.github:` block with a
2366/// clear migration error.
2367///
2368/// The registry metadata that used to live under
2369/// `mcp.github:` (repository owner/name/url) to the top-level `mcp:` block
2370/// (canonical surface: `mcp.repository:`, `mcp.name:`, etc.). Anodizer
2371/// never carried the nested shim — its `McpConfig` has `deny_unknown_fields`
2372/// so the key would otherwise produce a generic "unknown field" message.
2373/// This pre-parse check intercepts the legacy spelling so the user sees a
2374/// migration pointer rather than a schema-shape error.
2375pub fn validate_no_mcp_github(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2376    if raw_yaml.get("mcp").and_then(|m| m.get("github")).is_some() {
2377        return Err(
2378            "config: nested `mcp.github:` block is not supported — anodizer mirrors GoReleaser \
2379             v2.13.1+ where registry metadata moved to top-level `mcp:` fields (`mcp.name`, \
2380             `mcp.repository.url`, `mcp.repository.source`). Port the nested keys to the \
2381             canonical surface."
2382                .to_string(),
2383        );
2384    }
2385    Ok(())
2386}
2387
2388/// Emit a one-time deprecation warning for each `dockers_v2[].retry:` or
2389/// `docker_manifests[].retry:` block at config-load time. The per-pipe
2390/// `retry:` field is the legacy shape (retry handling moved to
2391/// the top-level `retry:` block); the per-pipe value is still honored at
2392/// resolve-time (see `stage-docker::resolve_retry_params`) but a top-level
2393/// `retry:` is the canonical surface for retry policy. Warning fires once
2394/// per occurrence so users porting from older configs see a clear
2395/// pointer at load time without waiting for the docker pipe to execute.
2396pub fn warn_on_legacy_docker_retry(config: &Config) {
2397    for msg in legacy_docker_retry_warnings(config) {
2398        tracing::warn!("{}", msg);
2399    }
2400}
2401
2402/// Pure helper: returns the warning strings without emitting them. Exposed
2403/// for tests; production callers use [`warn_on_legacy_docker_retry`].
2404pub(crate) fn legacy_docker_retry_warnings(config: &Config) -> Vec<String> {
2405    fn pipe_warning(location: &str, kind: &str) -> String {
2406        format!(
2407            "DEPRECATION: {location}: nested `{kind}.retry:` is deprecated since GoReleaser \
2408             v2.15.3; move retry settings to the top-level `retry:` block. The per-pipe \
2409             value still wins at resolve time for back-compat, but the legacy spelling will \
2410             be removed in a future release."
2411        )
2412    }
2413
2414    let mut warnings = Vec::new();
2415
2416    let scan_crate = |krate: &CrateConfig, prefix: &str, warnings: &mut Vec<String>| {
2417        if let Some(ref v2) = krate.dockers_v2 {
2418            for (i, cfg) in v2.iter().enumerate() {
2419                if cfg.retry.is_some() {
2420                    warnings.push(pipe_warning(
2421                        &format!("{prefix}.dockers_v2[{i}]"),
2422                        "dockers_v2",
2423                    ));
2424                }
2425            }
2426        }
2427        if let Some(ref manifests) = krate.docker_manifests {
2428            for (i, cfg) in manifests.iter().enumerate() {
2429                if cfg.retry.is_some() {
2430                    warnings.push(pipe_warning(
2431                        &format!("{prefix}.docker_manifests[{i}]"),
2432                        "docker_manifests",
2433                    ));
2434                }
2435            }
2436        }
2437    };
2438
2439    for krate in &config.crates {
2440        scan_crate(krate, &format!("crates[{}]", krate.name), &mut warnings);
2441    }
2442
2443    if let Some(ref workspaces) = config.workspaces {
2444        for ws in workspaces {
2445            for krate in &ws.crates {
2446                scan_crate(
2447                    krate,
2448                    &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
2449                    &mut warnings,
2450                );
2451            }
2452        }
2453    }
2454
2455    if let Some(ref defaults) = config.defaults
2456        && let Some(ref v2) = defaults.dockers_v2
2457        && v2.retry.is_some()
2458    {
2459        warnings.push(pipe_warning("defaults.dockers_v2", "dockers_v2"));
2460    }
2461
2462    warnings
2463}
2464
2465/// Fold the deprecated singular Homebrew Cask fields into their canonical
2466/// plural lists and emit a one-time deprecation warning per folded field:
2467///
2468/// - `binary: <name>` → [`HomebrewCaskConfig::binaries`] (the upstream
2469///   renamed `binary:` to `binaries:`).
2470/// - `manpage: <page>` → [`HomebrewCaskConfig::manpages`].
2471///
2472/// anodizer accepts both spellings so imported configs keep parsing.
2473/// The captured values are moved out of [`HomebrewCaskConfig::legacy_binary`]
2474/// and [`HomebrewCaskConfig::legacy_manpage`] so downstream code only ever
2475/// reads the canonical plural fields.
2476///
2477/// The two folds use different insertion order: a legacy
2478/// `binary` is **prepended** to `binaries` so any explicit `binaries:` ordering
2479/// is preserved at the tail, whereas a legacy `manpage` is **appended** to
2480/// `manpages` (the cask renderer does
2481/// `brew.Manpages = append(brew.Manpages, brew.Manpage)`).
2482///
2483/// The fold runs across every config mode — top-level `homebrew_casks`,
2484/// per-crate `publish.homebrew_cask`, `workspaces[].crates[].publish`, and
2485/// `defaults.publish`.
2486pub fn apply_homebrew_cask_legacy_singulars(config: &mut Config) {
2487    /// Fold both deprecated singular fields (`binary:` → `binaries`,
2488    /// `manpage:` → `manpages`) on one cask, returning a warning per folded
2489    /// field. The singular `binary` is prepended to `binaries` so an explicit
2490    /// `binaries[0]` ordering is preserved at the tail; the singular `manpage`
2491    /// is appended to `manpages`.
2492    fn fold_one(location: &str, cask: &mut HomebrewCaskConfig) -> Vec<String> {
2493        let mut warnings = Vec::new();
2494        if let Some(legacy) = cask.legacy_binary.take() {
2495            let entry = HomebrewCaskBinary::Name(legacy.clone());
2496            match cask.binaries {
2497                Some(ref mut list) => list.insert(0, entry),
2498                None => cask.binaries = Some(vec![entry]),
2499            }
2500            warnings.push(format!(
2501                "DEPRECATION: {location}: singular `binary: {legacy}` is deprecated since \
2502                 GoReleaser v2.12.6; use the plural `binaries: [{legacy}]` form. The legacy \
2503                 value has been folded into binaries[0]."
2504            ));
2505        }
2506        if let Some(legacy) = cask.legacy_manpage.take() {
2507            match cask.manpages {
2508                Some(ref mut list) => list.push(legacy.clone()),
2509                None => cask.manpages = Some(vec![legacy.clone()]),
2510            }
2511            warnings.push(format!(
2512                "DEPRECATION: {location}: singular `manpage: {legacy}` is deprecated; \
2513                 use the plural `manpages: [{legacy}]` form. The legacy value has been \
2514                 folded into manpages."
2515            ));
2516        }
2517        warnings
2518    }
2519
2520    let mut warnings = Vec::new();
2521
2522    // Top-level homebrew_casks list (not nested under publish:) — not a
2523    // publish axis, so it is scanned separately from the visitor.
2524    if let Some(ref mut casks) = config.homebrew_casks {
2525        for (i, cask) in casks.iter_mut().enumerate() {
2526            warnings.extend(fold_one(&format!("homebrew_casks[{i}]"), cask));
2527        }
2528    }
2529
2530    for_each_crate_publish_mut(config, |axis, mut publish| {
2531        if let Some(cask) = publish.homebrew_cask_mut() {
2532            warnings.extend(fold_one(&axis.homebrew_cask_location(), cask));
2533        }
2534    });
2535
2536    for msg in warnings {
2537        tracing::warn!("{}", msg);
2538    }
2539}
2540
2541// ---------------------------------------------------------------------------
2542// EnvFilesConfig — accepts list of .env paths OR structured token file paths
2543// ---------------------------------------------------------------------------
2544
2545mod env_files;
2546pub use env_files::*;
2547
2548// ---------------------------------------------------------------------------
2549// Defaults
2550// ---------------------------------------------------------------------------
2551
2552mod defaults;
2553pub use defaults::*;
2554
2555// ---------------------------------------------------------------------------
2556// BuildIgnore — exclude specific os/arch combos from builds
2557// ---------------------------------------------------------------------------
2558
2559mod build;
2560pub use build::*;
2561
2562// ---------------------------------------------------------------------------
2563// ArchivesConfig — untagged enum: false => Disabled, array => Configs
2564// ---------------------------------------------------------------------------
2565
2566mod archives;
2567pub use archives::*;
2568
2569mod completions;
2570pub use completions::*;
2571
2572// ---------------------------------------------------------------------------
2573// ReleaseConfig
2574// ---------------------------------------------------------------------------
2575
2576mod release;
2577pub use release::*;
2578
2579// ---------------------------------------------------------------------------
2580// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
2581// ---------------------------------------------------------------------------
2582
2583mod publishers;
2584pub use publishers::*;
2585
2586// ---------------------------------------------------------------------------
2587// DockerV2Config
2588// ---------------------------------------------------------------------------
2589
2590mod docker;
2591pub use docker::*;
2592
2593// ---------------------------------------------------------------------------
2594// NfpmConfig
2595// ---------------------------------------------------------------------------
2596
2597mod nfpm;
2598pub use nfpm::*;
2599
2600// ---------------------------------------------------------------------------
2601// SnapcraftConfig
2602// ---------------------------------------------------------------------------
2603
2604mod snapcraft;
2605pub use snapcraft::*;
2606// ---------------------------------------------------------------------------
2607// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
2608// ---------------------------------------------------------------------------
2609
2610mod installers;
2611pub use installers::*;
2612
2613// ---------------------------------------------------------------------------
2614// BlobConfig (S3/GCS/Azure cloud storage)
2615// ---------------------------------------------------------------------------
2616
2617mod blob;
2618pub use blob::*;
2619
2620// ---------------------------------------------------------------------------
2621// PartialConfig (split/merge CI fan-out)
2622// ---------------------------------------------------------------------------
2623
2624mod partial;
2625pub use partial::*;
2626
2627// ---------------------------------------------------------------------------
2628// BinstallConfig
2629// ---------------------------------------------------------------------------
2630
2631mod binstall;
2632pub use binstall::*;
2633
2634// ---------------------------------------------------------------------------
2635// NotarizeConfig (macOS code signing and notarization)
2636// ---------------------------------------------------------------------------
2637
2638mod notarize;
2639pub use notarize::*;
2640// ---------------------------------------------------------------------------
2641// SourceConfig
2642// ---------------------------------------------------------------------------
2643
2644mod source;
2645pub use source::*;
2646
2647// ---------------------------------------------------------------------------
2648// SbomConfig
2649// ---------------------------------------------------------------------------
2650
2651mod sbom;
2652pub use sbom::*;
2653
2654// ---------------------------------------------------------------------------
2655// AttestationConfig
2656// ---------------------------------------------------------------------------
2657
2658mod attestation;
2659pub use attestation::*;
2660
2661// ---------------------------------------------------------------------------
2662// VersionSyncConfig
2663// ---------------------------------------------------------------------------
2664
2665mod version_sync;
2666pub use version_sync::*;
2667
2668// ---------------------------------------------------------------------------
2669// ChangelogConfig
2670// ---------------------------------------------------------------------------
2671
2672mod changelog;
2673pub use changelog::*;
2674// ---------------------------------------------------------------------------
2675// SignConfig / DockerSignConfig — lifted to `crate::signing`
2676// ---------------------------------------------------------------------------
2677//
2678// see `crate::signing` for the type definitions. The
2679// re-exports below preserve the historical
2680// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
2681// used by every stage that consumes a sign config.
2682
2683pub use crate::signing::{AuthenticodeConfig, DockerSignConfig, SignConfig};
2684
2685// ---------------------------------------------------------------------------
2686// UpxConfig
2687// ---------------------------------------------------------------------------
2688
2689mod upx;
2690pub use upx::*;
2691
2692// ---------------------------------------------------------------------------
2693// SnapshotConfig
2694// ---------------------------------------------------------------------------
2695
2696mod snapshot_nightly;
2697pub use snapshot_nightly::*;
2698
2699mod cargo_metadata;
2700pub use cargo_metadata::derive_metadata_from_cargo_toml;
2701
2702/// Extract the name portion of a `"Name <email>"` maintainer/author string,
2703/// dropping any `<…>` email suffix. Returns `None` when the result is empty
2704/// (e.g. a bare-email `<ada@example.com>`), so a derived Vendor / OCI `vendor`
2705/// value is never emitted blank.
2706pub fn maintainer_name_only(maintainer: &str) -> Option<String> {
2707    let name = maintainer.split('<').next().unwrap_or(maintainer).trim();
2708    (!name.is_empty()).then(|| name.to_string())
2709}
2710
2711// ---------------------------------------------------------------------------
2712// TemplateFileConfig
2713// ---------------------------------------------------------------------------
2714
2715mod templatefiles;
2716pub use templatefiles::*;
2717
2718// ---------------------------------------------------------------------------
2719// AnnounceConfig
2720// ---------------------------------------------------------------------------
2721mod announce;
2722pub use announce::*;
2723// ---------------------------------------------------------------------------
2724// DockerHub description sync
2725// ---------------------------------------------------------------------------
2726
2727mod dockerhub;
2728pub use dockerhub::*;
2729
2730// ---------------------------------------------------------------------------
2731// Artifactory publisher
2732// ---------------------------------------------------------------------------
2733
2734mod artifactory;
2735pub use artifactory::*;
2736
2737// ---------------------------------------------------------------------------
2738// CloudSmith publisher
2739// ---------------------------------------------------------------------------
2740
2741mod cloudsmith;
2742pub use cloudsmith::*;
2743
2744// ---------------------------------------------------------------------------
2745// PublisherConfig
2746// ---------------------------------------------------------------------------
2747
2748mod publisher;
2749pub use publisher::*;
2750
2751// ---------------------------------------------------------------------------
2752// HooksConfig
2753// ---------------------------------------------------------------------------
2754
2755mod hooks;
2756pub use hooks::*;
2757
2758// ---------------------------------------------------------------------------
2759// GitConfig
2760// ---------------------------------------------------------------------------
2761
2762mod git_config;
2763pub use git_config::*;
2764
2765// ---------------------------------------------------------------------------
2766// MonorepoConfig
2767// ---------------------------------------------------------------------------
2768
2769mod monorepo;
2770pub use monorepo::*;
2771
2772// ---------------------------------------------------------------------------
2773// TagConfig
2774// ---------------------------------------------------------------------------
2775
2776mod tag;
2777pub use tag::*;
2778
2779// ---------------------------------------------------------------------------
2780// WorkspaceConfig
2781// ---------------------------------------------------------------------------
2782
2783mod workspace;
2784pub use workspace::*;
2785
2786// ---------------------------------------------------------------------------
2787// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
2788// ---------------------------------------------------------------------------
2789
2790mod retry;
2791pub use retry::*;
2792
2793// ---------------------------------------------------------------------------
2794// PostPublishPollConfig (per-publisher post-publish polling)
2795// ---------------------------------------------------------------------------
2796
2797mod post_publish_poll;
2798pub use post_publish_poll::*;
2799
2800// ---------------------------------------------------------------------------
2801// VerifyReleaseConfig (top-level `verify_release:` post-publish gate)
2802// ---------------------------------------------------------------------------
2803
2804mod verify_release;
2805pub use verify_release::*;
2806
2807// ---------------------------------------------------------------------------
2808// StringOrBool — accepts bool or template string in YAML
2809// ---------------------------------------------------------------------------
2810
2811mod string_or_bool;
2812pub use string_or_bool::*;
2813
2814// ---------------------------------------------------------------------------
2815// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
2816// ---------------------------------------------------------------------------
2817//
2818// All packaging config types live in their own modules under
2819// `crate::packagers`. The re-exports below preserve the historical
2820// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
2821// import paths used by stages and tests.
2822
2823pub use crate::packagers::{
2824    AppImageConfig, AppImageExtra, MakeselfConfig, MakeselfFile, RuntimeHarvest, SrpmConfig,
2825};
2826pub(crate) use crate::packagers::{
2827    appimages_schema, deserialize_appimages, deserialize_makeselfs, makeselfs_schema,
2828};
2829
2830// ---------------------------------------------------------------------------
2831// MilestoneConfig
2832// ---------------------------------------------------------------------------
2833
2834mod milestone;
2835pub use milestone::*;
2836
2837// ---------------------------------------------------------------------------
2838// UploadConfig (generic HTTP upload)
2839// ---------------------------------------------------------------------------
2840
2841mod upload;
2842pub use upload::*;
2843
2844// ---------------------------------------------------------------------------
2845// AurSourceConfig
2846// ---------------------------------------------------------------------------
2847
2848mod aur_source;
2849pub use aur_source::*;
2850
2851// ---------------------------------------------------------------------------
2852// McpConfig (MCP registry publisher)
2853// ---------------------------------------------------------------------------
2854
2855mod mcp;
2856pub use mcp::*;
2857
2858// ---------------------------------------------------------------------------
2859// NpmConfig (NPM package registry publisher)
2860// ---------------------------------------------------------------------------
2861
2862mod npm;
2863pub use npm::*;
2864
2865// ---------------------------------------------------------------------------
2866// GemFuryConfig (Gemfury / fury.io publisher)
2867// ---------------------------------------------------------------------------
2868
2869mod gemfury;
2870pub use gemfury::*;
2871
2872// ---------------------------------------------------------------------------
2873// Tests
2874// ---------------------------------------------------------------------------
2875
2876#[cfg(test)]
2877mod tests;