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