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