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