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