Skip to main content

anodizer_core/config/
mod.rs

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