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