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 /// `true` when any top-level / workspace `signs:` or `binary_signs:`
727 /// entry will invoke gpg (via `SignConfig::is_gpg()`).
728 ///
729 /// Used by preflight to decide whether to probe
730 /// `gpg --faked-system-time` support. `docker_signs:` is excluded
731 /// because that driver only ever invokes cosign.
732 pub fn has_gpg_sign_configured(&self) -> bool {
733 let top_level = self
734 .signs
735 .iter()
736 .chain(self.binary_signs.iter())
737 .any(|s| s.is_gpg());
738 if top_level {
739 return true;
740 }
741 // Workspaces inherit their own signs:/binary_signs: lists.
742 self.workspaces.iter().flatten().any(|w| {
743 w.signs
744 .iter()
745 .chain(w.binary_signs.iter())
746 .any(|s| s.is_gpg())
747 })
748 }
749}
750
751/// JSON Schema for the [`Config`] document as a canonical `serde_json::Value`,
752/// in the JSON Schema draft-07 dialect.
753///
754/// The published `schema.json`, the `anodizer jsonschema` command, and the
755/// config-reference doc generator all read the schema from this one function so
756/// the dialect (`definitions` + `#/definitions/` refs) and the byte-form are
757/// fixed in a single place. draft-07 is the dialect editors (VS Code, the JSON
758/// Schema Store) resolve for `.anodizer.yaml`, so the published schema and the
759/// editor integration agree.
760///
761/// Returns a plain `Value` rather than [`schemars::Schema`] deliberately:
762/// serializing a `Schema` re-imposes schemars 1.x's keyword ordering (via its
763/// internal `OrderedKeywordWrapper`), which would undo [`canonicalize_schema`].
764/// Serializing the `Value` directly preserves the canonical order.
765#[must_use]
766pub fn config_schema() -> serde_json::Value {
767 let schema = schemars::generate::SchemaSettings::draft07()
768 .into_generator()
769 .into_root_schema_for::<Config>();
770 let mut value = schema.to_value();
771 canonicalize_schema(&mut value);
772 value
773}
774
775/// JSON Schema keyword serialization order matching schemars 0.8's `SchemaObject`
776/// field declaration order (its flattened `Metadata` / `SubschemaValidation` /
777/// number / string / array / object validation structs concatenated in struct
778/// order). The published `schema.json` is byte-pinned to this order so it stays
779/// stable across schemars upgrades (1.x emits a different keyword order, and the
780/// workspace builds `serde_json` with `preserve_order` — via `stage-publish` —
781/// so insertion order leaks into the file unless re-imposed here). An unlisted
782/// keyword sorts after all listed ones, then lexicographically.
783const SCHEMA_KEYWORD_ORDER: &[&str] = &[
784 "$id",
785 "$schema",
786 "title",
787 "description",
788 "default",
789 "deprecated",
790 "readOnly",
791 "writeOnly",
792 "type",
793 "format",
794 "enum",
795 "const",
796 "allOf",
797 "anyOf",
798 "oneOf",
799 "not",
800 "if",
801 "then",
802 "else",
803 "multipleOf",
804 "maximum",
805 "exclusiveMaximum",
806 "minimum",
807 "exclusiveMinimum",
808 "maxLength",
809 "minLength",
810 "pattern",
811 "items",
812 "additionalItems",
813 "maxItems",
814 "minItems",
815 "uniqueItems",
816 "contains",
817 "maxProperties",
818 "minProperties",
819 "required",
820 "properties",
821 "patternProperties",
822 "additionalProperties",
823 "propertyNames",
824 "$ref",
825 "definitions",
826];
827
828/// Schema object keys whose VALUE is a map of name → subschema (not a subschema
829/// itself). Their entries are sorted by NAME (schemars 0.8 backed these with a
830/// `BTreeMap`); every other keyword's value is a schema whose own keys are
831/// ordered by [`SCHEMA_KEYWORD_ORDER`].
832const SCHEMA_DEFINITION_MAPS: &[&str] = &["properties", "patternProperties", "definitions"];
833
834/// Re-impose schemars 0.8's deterministic serialization on a draft-07 schema
835/// `Value` so the published artifact is byte-stable across schemars versions:
836/// recursively (1) order each schema object's keys by [`SCHEMA_KEYWORD_ORDER`],
837/// (2) sort definition-map entries (`properties`/`definitions`/…) by name,
838/// (3) sort `required` (a set), and (4) normalize every `description` to single
839/// spaces within a paragraph while preserving blank-line paragraph breaks.
840fn canonicalize_schema(value: &mut serde_json::Value) {
841 use serde_json::Value;
842 match value {
843 Value::Object(map) => {
844 if let Some(Value::String(d)) = map.get_mut("description") {
845 *d = collapse_description(d);
846 }
847 if let Some(Value::Array(required)) = map.get_mut("required") {
848 required.sort_by(|a, b| match (a.as_str(), b.as_str()) {
849 (Some(x), Some(y)) => x.cmp(y),
850 _ => std::cmp::Ordering::Equal,
851 });
852 }
853 // Recurse, treating each value by its role:
854 // - a definition-map value (`properties`/`definitions`/…) is a
855 // name→schema map: sort its entries by name, recurse each schema;
856 // - `default`/`enum`/`const`/`examples` hold literal instance DATA,
857 // not schemas — never reorder their keys (they preserve the config
858 // struct's serialization order);
859 // - every other value is itself a schema (or array of schemas).
860 for (key, child) in map.iter_mut() {
861 match key.as_str() {
862 k if SCHEMA_DEFINITION_MAPS.contains(&k) => {
863 if let Value::Object(entries) = child {
864 sort_object_by_key(entries);
865 for sub in entries.values_mut() {
866 canonicalize_schema(sub);
867 }
868 }
869 }
870 "default" | "enum" | "const" | "examples" => {}
871 _ => canonicalize_schema(child),
872 }
873 }
874 reorder_object(map, SCHEMA_KEYWORD_ORDER);
875 }
876 Value::Array(items) => {
877 for item in items {
878 canonicalize_schema(item);
879 }
880 }
881 _ => {}
882 }
883}
884
885/// Reorder `map`'s entries so listed keys come first in `order`, then any
886/// remaining keys lexicographically. `serde_json`'s `preserve_order` feature is
887/// active workspace-wide, so a `Map` serializes in insertion order — rebuilding
888/// it in the target order fixes the serialized key order.
889fn reorder_object(map: &mut serde_json::Map<String, serde_json::Value>, order: &[&str]) {
890 let mut keys: Vec<String> = map.keys().cloned().collect();
891 keys.sort_by(|a, b| {
892 let rank = |k: &str| order.iter().position(|o| *o == k).unwrap_or(order.len());
893 rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
894 });
895 let mut rebuilt = serde_json::Map::with_capacity(map.len());
896 for k in keys {
897 if let Some(v) = map.remove(&k) {
898 rebuilt.insert(k, v);
899 }
900 }
901 *map = rebuilt;
902}
903
904/// Sort an object map's entries by key (rebuilt because `preserve_order` keeps
905/// insertion order). Used for definition maps where 0.8 emitted `BTreeMap`-sorted
906/// names.
907fn sort_object_by_key(map: &mut serde_json::Map<String, serde_json::Value>) {
908 let mut keys: Vec<String> = map.keys().cloned().collect();
909 keys.sort();
910 let mut rebuilt = serde_json::Map::with_capacity(map.len());
911 for k in keys {
912 if let Some(v) = map.remove(&k) {
913 rebuilt.insert(k, v);
914 }
915 }
916 *map = rebuilt;
917}
918
919/// Normalize a schema `description`: collapse each paragraph's internal
920/// whitespace (including the rustdoc doc-comment's hard line wraps, which
921/// schemars 1.x preserves verbatim) to single spaces, while preserving
922/// blank-line paragraph breaks (`\n\n`). Reproduces the single-spaced,
923/// paragraph-separated form earlier schemars releases emitted, so the published
924/// schema's tooltips render as clean prose in editors.
925fn collapse_description(s: &str) -> String {
926 s.split("\n\n")
927 .map(|para| {
928 para.split('\n')
929 .map(str::trim)
930 .collect::<Vec<_>>()
931 .join(" ")
932 })
933 .collect::<Vec<_>>()
934 .join("\n\n")
935}
936
937/// Run a deserialization closure on a worker thread sized large enough that
938/// the `Config` derive (60+ `Option<NestedStruct>` fields) cannot exhaust
939/// the host's main-thread stack.
940///
941/// Background: debug builds of `serde_yaml_ng::from_value::<Config>` and
942/// `toml::from_str::<Config>` consume several MiB of stack because each
943/// generated visitor branch for the giant struct lives in a single
944/// monomorphised frame and debug builds neither inline nor tail-call. The
945/// Windows main-thread default reservation is 1 MiB, so any debug-built
946/// integration test that triggers full-config deserialization overflows
947/// before reaching the visitor's body.
948///
949/// Routing every full-`Config` deserialization through this helper keeps
950/// every entry-point platform-agnostic without resorting to per-platform
951/// linker flags or `RUST_MIN_STACK`.
952pub fn deserialize_on_worker<F, T>(f: F) -> anyhow::Result<T>
953where
954 F: FnOnce() -> anyhow::Result<T> + Send + 'static,
955 T: Send + 'static,
956{
957 use anyhow::Context as _;
958
959 // 8 MiB matches the Linux/macOS process default and comfortably exceeds
960 // the ~2 MiB peak observed for debug `Config` deserialization.
961 const WORKER_STACK_SIZE: usize = 8 * 1024 * 1024;
962
963 let handle = std::thread::Builder::new()
964 .stack_size(WORKER_STACK_SIZE)
965 .name("anodizer-config-deserialize".to_string())
966 .spawn(f)
967 .context("failed to spawn config deserialization worker thread")?;
968 match handle.join() {
969 Ok(result) => result,
970 Err(payload) => std::panic::resume_unwind(payload),
971 }
972}
973
974/// Validate the config schema version. Accepts version 1 (default) and 2.
975/// Returns an error for unknown versions.
976pub fn validate_version(config: &Config) -> Result<(), String> {
977 match config.version {
978 None | Some(1) | Some(2) => Ok(()),
979 Some(v) => Err(format!(
980 "unsupported config version: {}. Supported versions are 1 and 2.",
981 v
982 )),
983 }
984}
985
986/// Validate `git.tag_sort` if present. Accepted values:
987/// - `"-version:refname"` (default, lexicographic version sort)
988/// - `"-version:creatordate"` (sort by tag creation date, newest first)
989/// - `"semver"` (Rust-side strict SemVer 2.0.0 ordering, prereleases sort
990/// below their release per spec section 11)
991/// - `"smartsemver"` (same ordering as `semver`, but when the current version
992/// is non-prerelease, prerelease tags are skipped when picking the previous
993/// tag — avoids selecting `v0.2.0-beta.3` as the predecessor of `v0.2.0`)
994///
995/// Returns an error for unrecognized values.
996pub fn validate_tag_sort(config: &Config) -> Result<(), String> {
997 if let Some(ref git) = config.git
998 && let Some(ref sort) = git.tag_sort
999 {
1000 match sort.as_str() {
1001 "-version:refname" | "-version:creatordate" | "semver" | "smartsemver" => {}
1002 other => {
1003 return Err(format!(
1004 "unsupported git.tag_sort value: \"{}\". \
1005 Accepted values: \"-version:refname\", \"-version:creatordate\", \
1006 \"semver\", \"smartsemver\".",
1007 other
1008 ));
1009 }
1010 }
1011 }
1012 Ok(())
1013}
1014
1015/// Validate `partial.by` up front so a stale value is rejected at config-load
1016/// time regardless of which target-resolution path runs.
1017///
1018/// `partial.by` is read in two unrelated places: the host-detection branch of
1019/// [`crate::partial::resolve_partial_target`] (which already rejects unknown
1020/// values) and the split-matrix generator (which treats anything that is not
1021/// `"os"` as `"target"`). Those two readers disagree on an out-of-set value
1022/// like the pre-rename `"goos"`: one errors, the other silently mis-groups the
1023/// matrix. Centralising the check means a typo fails loudly once, before
1024/// either reader can diverge.
1025pub fn validate_partial(config: &Config) -> Result<(), String> {
1026 if let Some(ref partial) = config.partial
1027 && let Some(ref by) = partial.by
1028 {
1029 match by.as_str() {
1030 "os" | "target" => {}
1031 other => {
1032 return Err(format!(
1033 "unsupported partial.by value: \"{}\". \
1034 Accepted values: \"os\", \"target\".",
1035 other
1036 ));
1037 }
1038 }
1039 }
1040 Ok(())
1041}
1042
1043/// Known OS values accepted by `archives[].format_overrides[].os`.
1044/// The Go runtime's `runtime.GOOS` values the archive pipe
1045/// recognises; anything outside this set is almost always a typo
1046/// (e.g. a Rust target triple slice like `pc-windows-msvc`).
1047const KNOWN_OS: &[&str] = &[
1048 "aix",
1049 "android",
1050 "darwin",
1051 "dragonfly",
1052 "freebsd",
1053 "illumos",
1054 "ios",
1055 "js",
1056 "linux",
1057 "netbsd",
1058 "openbsd",
1059 "plan9",
1060 "solaris",
1061 "wasip1",
1062 "windows",
1063];
1064
1065/// Validate that each crate's `release:` block configures at most one SCM
1066/// backend. A multiple-releases error, which
1067/// errors at `Default()` time. Anodizer dispatches on `ctx.token_type` at
1068/// runtime so a silently-ignored extra backend is easy to miss.
1069pub fn validate_release_backends(config: &Config) -> Result<(), String> {
1070 let check = |crate_name: &str, release: &ReleaseConfig| -> Result<(), String> {
1071 let mut set = Vec::new();
1072 if release.github.is_some() {
1073 set.push("github");
1074 }
1075 if release.gitlab.is_some() {
1076 set.push("gitlab");
1077 }
1078 if release.gitea.is_some() {
1079 set.push("gitea");
1080 }
1081 if set.len() > 1 {
1082 return Err(format!(
1083 "crate {}: release config sets multiple mutually-exclusive SCM \
1084 backends ({}). Pick one.",
1085 crate_name,
1086 set.join(" + ")
1087 ));
1088 }
1089 Ok(())
1090 };
1091 for krate in &config.crates {
1092 if let Some(ref release) = krate.release {
1093 check(&krate.name, release)?;
1094 }
1095 }
1096 if let Some(ws_list) = config.workspaces.as_ref() {
1097 for ws in ws_list {
1098 for krate in &ws.crates {
1099 if let Some(ref release) = krate.release {
1100 check(&krate.name, release)?;
1101 }
1102 }
1103 }
1104 }
1105 Ok(())
1106}
1107
1108/// Validate that `release.on_failure` is set only at the root.
1109///
1110/// The failure policy is one process-wide decision per run, resolved
1111/// from the top-level `release:` block alone. Crate-level `release:`
1112/// blocks share the `ReleaseConfig` struct, so the field parses there
1113/// — but it would never be read; rejecting the misplacement at config
1114/// load keeps a policy choice from being silently ignored.
1115pub fn validate_on_failure_root_only(config: &Config) -> Result<(), String> {
1116 // Deliberately raw (not `crate_universe()`): validation must flag every
1117 // entry as written, including a workspace entry the dedup would shadow —
1118 // a policy violation on a shadowed crate is still a config mistake.
1119 let mut offenders: Vec<&str> = config
1120 .crates
1121 .iter()
1122 .chain(
1123 config
1124 .workspaces
1125 .iter()
1126 .flatten()
1127 .flat_map(|ws| ws.crates.iter()),
1128 )
1129 .filter(|c| c.release.as_ref().is_some_and(|r| r.on_failure.is_some()))
1130 .map(|c| c.name.as_str())
1131 .collect();
1132 offenders.sort_unstable();
1133 offenders.dedup();
1134 if offenders.is_empty() {
1135 return Ok(());
1136 }
1137 Err(format!(
1138 "release.on_failure is a root-level policy and cannot be set per crate \
1139 (set on: {}). Move it to the top-level `release:` block.",
1140 offenders.join(", ")
1141 ))
1142}
1143
1144/// Marker prefix for the axis-mismatch validation error class. Existing
1145/// validators in this module return `Result<(), String>` rather than a
1146/// typed enum, so we expose this constant (instead of a `ConfigError`
1147/// variant) for callers that want to recognise the error class
1148/// programmatically.
1149///
1150/// The prefix is emitted at the start of every error returned by
1151/// [`validate_defaults_axis`] (formatted as `"DefaultsAxisMismatch: …"`),
1152/// so callers can match with `err.starts_with(ERR_DEFAULTS_AXIS_MISMATCH)`
1153/// or `err.contains(ERR_DEFAULTS_AXIS_MISMATCH)` without depending on the
1154/// exact human-readable wording.
1155///
1156/// ```ignore
1157/// match validate_defaults_axis(&config) {
1158/// Err(e) if e.starts_with(ERR_DEFAULTS_AXIS_MISMATCH) => {
1159/// // handle the axis-mismatch error class
1160/// }
1161/// other => other?,
1162/// }
1163/// ```
1164///
1165/// Future error-type unification can rename to
1166/// `ConfigError::DefaultsAxisMismatch` without changing call-sites that
1167/// match on this prefix.
1168pub const ERR_DEFAULTS_AXIS_MISMATCH: &str = "DefaultsAxisMismatch";
1169
1170/// Validate that `defaults.crates:` and `defaults.workspaces:` match the
1171/// top-level axis.
1172///
1173/// Rules:
1174/// - `defaults.crates:` is set → top-level `crates:` MUST be present.
1175/// - `defaults.workspaces:` is set → top-level `workspaces:` MUST be present.
1176/// - Both `defaults.crates` and `defaults.workspaces` set simultaneously → error
1177/// (mutually exclusive).
1178/// - Wrong-axis (e.g. `defaults.crates:` while top-level uses `workspaces:`) → error.
1179pub fn validate_defaults_axis(config: &Config) -> Result<(), String> {
1180 let Some(ref defaults) = config.defaults else {
1181 return Ok(());
1182 };
1183 let has_crate_block = defaults.crates.is_some();
1184 let has_workspace_block = defaults.workspaces.is_some();
1185
1186 if has_crate_block && has_workspace_block {
1187 return Err(format!(
1188 "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates and defaults.workspaces are \
1189 mutually exclusive — pick the axis that matches the top-level config \
1190 (`crates:` or `workspaces:`)",
1191 ));
1192 }
1193
1194 let top_uses_workspaces = config.workspaces.as_ref().is_some_and(|w| !w.is_empty());
1195 let top_uses_crates = !config.crates.is_empty();
1196
1197 if has_crate_block && !top_uses_crates {
1198 return Err(format!(
1199 "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.crates is set but top-level `crates:` \
1200 is {}; move defaults under `defaults.workspaces:` or remove the block",
1201 if top_uses_workspaces {
1202 "absent (top-level uses `workspaces:`)"
1203 } else {
1204 "absent"
1205 },
1206 ));
1207 }
1208 if has_workspace_block && !top_uses_workspaces {
1209 return Err(format!(
1210 "{ERR_DEFAULTS_AXIS_MISMATCH}: defaults.workspaces is set but top-level \
1211 `workspaces:` is {}; move defaults under `defaults.crates:` or remove the block",
1212 if top_uses_crates {
1213 "absent (top-level uses `crates:`)"
1214 } else {
1215 "absent"
1216 },
1217 ));
1218 }
1219
1220 Ok(())
1221}
1222
1223/// Validate `archives[].format_overrides[].os` values reject unknown OSes.
1224/// Silently no-op-ing unknown overrides has burned users typing
1225/// Rust triples like `apple` or `pc-windows-msvc`.
1226///
1227/// Walks every `archives[]` location in the config:
1228/// - `crates[].archives:`
1229/// - `workspaces[].crates[].archives:`
1230/// - `defaults.archives:` (an unknown `os` here would otherwise pass silently
1231/// and propagate to every inheriting crate at merge time).
1232pub fn validate_format_overrides(config: &Config) -> Result<(), String> {
1233 let check = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1234 for (idx, archive) in archives.iter().enumerate() {
1235 let Some(ref overrides) = archive.format_overrides else {
1236 continue;
1237 };
1238 for over in overrides {
1239 if !KNOWN_OS.contains(&over.os.as_str()) {
1240 let archive_id = archive.id.as_deref().unwrap_or("default");
1241 return Err(format!(
1242 "{}: archives[{}] (id={}): format_overrides.os=\"{}\" is not a recognised OS. \
1243 Accepted values: {}.",
1244 location,
1245 idx,
1246 archive_id,
1247 over.os,
1248 KNOWN_OS.join(", ")
1249 ));
1250 }
1251 }
1252 }
1253 Ok(())
1254 };
1255 for krate in &config.crates {
1256 if let ArchivesConfig::Configs(ref list) = krate.archives {
1257 check(&format!("crate {}", krate.name), list)?;
1258 }
1259 }
1260 if let Some(ws_list) = config.workspaces.as_ref() {
1261 for ws in ws_list {
1262 for krate in &ws.crates {
1263 if let ArchivesConfig::Configs(ref list) = krate.archives {
1264 check(&format!("crate {}", krate.name), list)?;
1265 }
1266 }
1267 }
1268 }
1269 if let Some(ref defaults) = config.defaults
1270 && let Some(ref archive) = defaults.archives
1271 {
1272 // defaults.archives is a single ArchiveConfig (not a list); wrap it
1273 // into a one-element slice so the same checker walks it.
1274 check("defaults.archives", std::slice::from_ref(archive))?;
1275 }
1276 Ok(())
1277}
1278
1279/// Validate that no [`HomebrewCaskConfig`] sets both `url_template` AND
1280/// `url.template` simultaneously — they are mutually exclusive shorthands
1281/// for the same URL field and combining them is ambiguous.
1282///
1283/// Inspects every occurrence of `HomebrewCaskConfig` in the config:
1284/// - `homebrew_casks:` (top-level array)
1285/// - `crates[].publish.homebrew_cask:`
1286/// - `workspaces[].crates[].publish.homebrew_cask:`
1287/// - `defaults.publish.homebrew_cask:`
1288pub fn validate_homebrew_cask_url_template(config: &Config) -> Result<(), String> {
1289 let check = |location: &str, cask: &HomebrewCaskConfig| -> Result<(), String> {
1290 let has_url_template = cask.url_template.is_some();
1291 let has_url_dot_template = cask.url.as_ref().is_some_and(|u| u.template.is_some());
1292 if has_url_template && has_url_dot_template {
1293 return Err(format!(
1294 "{location}: homebrew_cask sets both `url_template` and `url.template`. \
1295 These are mutually exclusive — use one or the other."
1296 ));
1297 }
1298 Ok(())
1299 };
1300
1301 // Top-level homebrew_casks list (not nested under publish:) — not a
1302 // publish axis, so it is scanned separately from the visitor.
1303 if let Some(ref casks) = config.homebrew_casks {
1304 for (i, cask) in casks.iter().enumerate() {
1305 check(&format!("homebrew_casks[{i}]"), cask)?;
1306 }
1307 }
1308
1309 try_for_each_crate_publish(config, |axis, publish| {
1310 if let Some(cask) = publish.homebrew_cask() {
1311 check(&axis.homebrew_cask_location(), cask)?;
1312 }
1313 Ok(())
1314 })
1315}
1316
1317/// Allowed `winget.upgrade_behavior` values, mirroring the winget installer
1318/// manifest schema (1.12.0) `UpgradeBehavior` enum. A value outside this set
1319/// renders an installer manifest the winget validator rejects at PR time —
1320/// catch it at config-validate instead.
1321pub const WINGET_UPGRADE_BEHAVIORS: [&str; 3] = ["install", "uninstallPrevious", "deny"];
1322
1323/// Validate that every configured `winget.upgrade_behavior` is one of the
1324/// winget-recognized values ([`WINGET_UPGRADE_BEHAVIORS`]). Walks the per-crate,
1325/// per-workspace, and `defaults.publish` axes.
1326pub fn validate_winget_upgrade_behavior(config: &Config) -> Result<(), String> {
1327 let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1328 if let Some(ref behavior) = winget.upgrade_behavior
1329 && !WINGET_UPGRADE_BEHAVIORS.contains(&behavior.as_str())
1330 {
1331 return Err(format!(
1332 "{location}: upgrade_behavior `{behavior}` is not a valid winget value. \
1333 Use one of: {}.",
1334 WINGET_UPGRADE_BEHAVIORS.join(", ")
1335 ));
1336 }
1337 Ok(())
1338 };
1339
1340 try_for_each_crate_publish(config, |axis, publish| {
1341 if let Some(winget) = publish.winget() {
1342 check(&axis.winget_location(), winget)?;
1343 }
1344 Ok(())
1345 })
1346}
1347
1348/// Validate that every `winget.dependencies[].architectures` entry names a
1349/// recognized WinGet architecture ([`WINGET_ARCHITECTURES`]). Walks the
1350/// per-crate, per-workspace, and `defaults.publish` axes.
1351///
1352/// The per-installer dependency emitter matches a scope value against each
1353/// installer's WinGet architecture by exact, case-sensitive equality. A value
1354/// outside the canonical set ([`WINGET_ARCHITECTURES`]: `x64`, `arm64`, `x86`)
1355/// therefore matches
1356/// no installer, so the dependency would silently disappear from the generated
1357/// manifest. Reject it at config-validate instead of shipping a manifest that
1358/// quietly omits a declared dependency. An empty list (or absent
1359/// `architectures`) means "all installers" and is valid.
1360pub fn validate_winget_dependency_architectures(config: &Config) -> Result<(), String> {
1361 let check = |location: &str, winget: &WingetConfig| -> Result<(), String> {
1362 let Some(ref deps) = winget.dependencies else {
1363 return Ok(());
1364 };
1365 for (i, dep) in deps.iter().enumerate() {
1366 let Some(ref scopes) = dep.architectures else {
1367 continue;
1368 };
1369 for scope in scopes {
1370 if !WINGET_ARCHITECTURES.contains(&scope.as_str()) {
1371 return Err(format!(
1372 "{location}: dependencies[{i}].architectures contains `{scope}`, \
1373 which is not a valid winget architecture. Use one of: {} \
1374 (or leave architectures empty/unset to apply the dependency \
1375 to every installer).",
1376 WINGET_ARCHITECTURES.join(", ")
1377 ));
1378 }
1379 }
1380 }
1381 Ok(())
1382 };
1383
1384 try_for_each_crate_publish(config, |axis, publish| {
1385 if let Some(winget) = publish.winget() {
1386 check(&axis.winget_location(), winget)?;
1387 }
1388 Ok(())
1389 })
1390}
1391
1392/// Validate that `archives[].id` and `universal_binaries[].id` are unique
1393/// within their respective lists.
1394///
1395/// The id-uniqueness validation for archives and universal binaries.
1396/// Two archive
1397/// configs with the same `id` silently both set the same `id` metadata key
1398/// on artifacts, breaking publishers that filter `ids: [<id>]`. Anodizer's
1399/// build/sign stages already enforce id uniqueness; archive and
1400/// universal_binary were missed.
1401///
1402/// Walks every occurrence of `archives[]` and `universal_binaries[]`:
1403/// - `crates[].archives:` / `crates[].universal_binaries:`
1404/// - `workspaces[].crates[].archives:` / `.universal_binaries:`
1405/// - `defaults.archives:` is a single `ArchiveConfig`, so uniqueness within
1406/// itself is vacuously true; not walked here.
1407///
1408pub fn validate_id_uniqueness(config: &Config) -> Result<(), String> {
1409 fn check_unique(
1410 location: &str,
1411 kind: &str,
1412 ids: impl IntoIterator<Item = (usize, Option<String>)>,
1413 ) -> Result<(), String> {
1414 let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1415 for (idx, maybe_id) in ids {
1416 // Empty is stored as "default" for archives via Default-time
1417 // assignment. Anodizer applies `default_archive_id` at deserialize
1418 // time, so the option is normally `Some("default")`. A truly empty
1419 // / None id here means the user explicitly cleared it; we still
1420 // dedupe across `None` so two None-id'd entries collide just like
1421 // two "default"-id'd entries would.
1422 let key = maybe_id.unwrap_or_else(|| "<unset>".to_string());
1423 if let Some(prev_idx) = seen.insert(key.clone(), idx) {
1424 return Err(format!(
1425 "{location}: {kind} id \"{key}\" is used by both entry {prev_idx} and entry {idx} — \
1426 ids must be unique within a {kind} list."
1427 ));
1428 }
1429 }
1430 Ok(())
1431 }
1432
1433 let check_archives = |location: &str, archives: &[ArchiveConfig]| -> Result<(), String> {
1434 check_unique(
1435 location,
1436 "archives",
1437 archives.iter().enumerate().map(|(i, a)| (i, a.id.clone())),
1438 )
1439 };
1440 let check_unibins = |location: &str, ubs: &[UniversalBinaryConfig]| -> Result<(), String> {
1441 check_unique(
1442 location,
1443 "universal_binaries",
1444 ubs.iter().enumerate().map(|(i, u)| (i, u.id.clone())),
1445 )
1446 };
1447
1448 for krate in &config.crates {
1449 if let ArchivesConfig::Configs(ref list) = krate.archives {
1450 check_archives(&format!("crates[{}].archives", krate.name), list)?;
1451 }
1452 if let Some(ref ubs) = krate.universal_binaries {
1453 check_unibins(&format!("crates[{}].universal_binaries", krate.name), ubs)?;
1454 }
1455 }
1456 if let Some(ws_list) = config.workspaces.as_ref() {
1457 for ws in ws_list {
1458 for krate in &ws.crates {
1459 if let ArchivesConfig::Configs(ref list) = krate.archives {
1460 check_archives(
1461 &format!("workspaces[{}].crates[{}].archives", ws.name, krate.name),
1462 list,
1463 )?;
1464 }
1465 if let Some(ref ubs) = krate.universal_binaries {
1466 check_unibins(
1467 &format!(
1468 "workspaces[{}].crates[{}].universal_binaries",
1469 ws.name, krate.name
1470 ),
1471 ubs,
1472 )?;
1473 }
1474 }
1475 }
1476 }
1477 Ok(())
1478}
1479
1480/// Validate `builds[]` entries that opt into `builder: prebuilt`.
1481///
1482/// `builder: prebuilt` skips `cargo build` and imports a binary the
1483/// operator staged elsewhere. The validation rules below follow the
1484/// `prebuilt` builder contract (`/customization/builds/builders/prebuilt.md`):
1485///
1486/// 1. `prebuilt:` block MUST be set and `prebuilt.path` MUST be non-empty.
1487/// 2. `targets:` MUST be explicit on the build entry — no `defaults.targets`
1488/// fallback. Without this rule the build matrix has no rows.
1489/// 3. Cargo-only knobs are rejected as mutually exclusive: `cross_tool`,
1490/// `features`, `no_default_features`, `command`. The crate-level
1491/// `cross:` strategy is also rejected when any build on the crate is
1492/// prebuilt (the strategy has no meaning when nothing is being
1493/// compiled).
1494/// 4. `builder: cargo` (the default) with a `prebuilt:` block set warns —
1495/// the block has no effect and likely indicates a forgotten
1496/// `builder: prebuilt`.
1497pub fn validate_builds(config: &Config) -> Result<(), String> {
1498 let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1499 let Some(ref builds) = krate.builds else {
1500 return Ok(());
1501 };
1502 let crate_is_prebuilt = builds
1503 .iter()
1504 .any(|b| matches!(b.builder, Some(BuilderKind::Prebuilt)));
1505 if crate_is_prebuilt && krate.cross.is_some() {
1506 return Err(format!(
1507 "{location}: crate-level `cross:` strategy is set but at least one \
1508 build uses `builder: prebuilt`; remove `cross:` (prebuilt imports a \
1509 binary instead of compiling) or change the build's builder to `cargo`."
1510 ));
1511 }
1512 for (idx, build) in builds.iter().enumerate() {
1513 match build.builder {
1514 Some(BuilderKind::Prebuilt) => {
1515 let path = build.prebuilt.as_ref().map(|p| p.path.trim()).unwrap_or("");
1516 if path.is_empty() {
1517 return Err(format!(
1518 "{location}.builds[{idx}]: `builder: prebuilt` requires a non-empty \
1519 `prebuilt.path` template. Example: \
1520 `prebuilt: {{ path: \"output/mybin_{{{{ .Target }}}}\" }}`"
1521 ));
1522 }
1523 let targets_explicit = build.targets.as_ref().is_some_and(|t| !t.is_empty());
1524 if !targets_explicit {
1525 return Err(format!(
1526 "{location}.builds[{idx}] has `builder: prebuilt` but no explicit \
1527 `targets:` — the prebuilt builder requires per-build target triples \
1528 (no `defaults.targets:` fallback). Add `targets: [<triple>, ...]`."
1529 ));
1530 }
1531 if build.cross_tool.as_ref().is_some_and(|s| !s.is_empty()) {
1532 return Err(format!(
1533 "{location}.builds[{idx}]: `cross_tool` is set with \
1534 `builder: prebuilt` — the two are mutually exclusive. \
1535 `cross_tool` controls how cargo cross-compiles; `prebuilt` \
1536 imports an already-built binary. Drop `cross_tool` or use \
1537 `builder: cargo`."
1538 ));
1539 }
1540 if build.command.as_ref().is_some_and(|s| !s.is_empty()) {
1541 return Err(format!(
1542 "{location}.builds[{idx}]: `command:` override is set with \
1543 `builder: prebuilt` — the override selects the cargo \
1544 subcommand, which is not invoked under the prebuilt \
1545 builder. Drop `command:` or use `builder: cargo`."
1546 ));
1547 }
1548 if build.features.as_ref().is_some_and(|f| !f.is_empty()) {
1549 return Err(format!(
1550 "{location}.builds[{idx}]: `features:` is set with \
1551 `builder: prebuilt` — Cargo features are evaluated at \
1552 compile time, which the prebuilt builder skips. \
1553 Drop `features:` or use `builder: cargo`."
1554 ));
1555 }
1556 if build.no_default_features.is_some() {
1557 return Err(format!(
1558 "{location}.builds[{idx}]: `no_default_features:` is set with \
1559 `builder: prebuilt` — Cargo feature flags are evaluated at \
1560 compile time, which the prebuilt builder skips. \
1561 Drop the flag or use `builder: cargo`."
1562 ));
1563 }
1564 }
1565 Some(BuilderKind::Cargo) | None => {
1566 if build.prebuilt.is_some() {
1567 tracing::warn!(
1568 "{location}: build[{idx}] has a `prebuilt:` block but `builder:` \
1569 is not `prebuilt`; the block is ignored. Set `builder: prebuilt` \
1570 or remove the block."
1571 );
1572 }
1573 }
1574 }
1575 }
1576 Ok(())
1577 };
1578
1579 for krate in &config.crates {
1580 check_crate(&format!("crates[{}]", krate.name), krate)?;
1581 }
1582 if let Some(ws_list) = config.workspaces.as_ref() {
1583 for ws in ws_list {
1584 for krate in &ws.crates {
1585 check_crate(
1586 &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1587 krate,
1588 )?;
1589 }
1590 }
1591 }
1592 Ok(())
1593}
1594
1595/// Returns `true` if every build entry on every crate has
1596/// `builder: prebuilt`. Used by the determinism harness to short-circuit:
1597/// when no target compiles, there is nothing for the harness to rebuild
1598/// and compare across runs.
1599pub fn all_builds_prebuilt(config: &Config) -> bool {
1600 let crate_all_prebuilt = |krate: &CrateConfig| -> Option<bool> {
1601 let builds = krate.builds.as_ref()?;
1602 if builds.is_empty() {
1603 return None;
1604 }
1605 Some(
1606 builds
1607 .iter()
1608 .all(|b| matches!(b.builder, Some(BuilderKind::Prebuilt))),
1609 )
1610 };
1611
1612 let mut saw_any = false;
1613 for krate in config.crate_universe() {
1614 match crate_all_prebuilt(krate) {
1615 Some(true) => saw_any = true,
1616 Some(false) => return false,
1617 None => {}
1618 }
1619 }
1620 saw_any
1621}
1622
1623/// Validate the depth of `changelog.groups[].groups`.
1624///
1625/// Subgroups are capped at ONE level
1626/// (`/customization/publish/changelog.md`: "There can only be one level of
1627/// subgroups"). Anodizer's renderer can technically handle deeper nesting
1628/// (capped at 6 to match Markdown's heading limit), but accepting deeper
1629/// configs silently is a footgun: a config that works in anodizer but is
1630/// rejected here breaks parity for users migrating in.
1631///
1632/// Rejects any `changelog.groups[i].groups[j].groups[..]` configuration
1633/// with a clear error pointing at the offending parent group title.
1634pub fn validate_changelog_groups_depth(config: &Config) -> Result<(), String> {
1635 let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1636 let Some(ref groups) = cfg.groups else {
1637 return Ok(());
1638 };
1639 for g in groups {
1640 if let Some(ref subs) = g.groups {
1641 for sub in subs {
1642 if sub.groups.as_ref().is_some_and(|s| !s.is_empty()) {
1643 return Err(format!(
1644 "{location}: changelog group '{}' > '{}' nests further \
1645 subgroups; GoReleaser permits only one level of subgroups \
1646 (see https://goreleaser.com/customization/changelog/). \
1647 Flatten the inner groups into the parent or split into \
1648 sibling top-level groups.",
1649 g.title, sub.title
1650 ));
1651 }
1652 }
1653 }
1654 }
1655 Ok(())
1656 };
1657 if let Some(ref cfg) = config.changelog {
1658 check("changelog", cfg)?;
1659 }
1660 if let Some(ref ws_list) = config.workspaces {
1661 for ws in ws_list {
1662 if let Some(ref cfg) = ws.changelog {
1663 check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1664 }
1665 }
1666 }
1667 Ok(())
1668}
1669
1670/// Validate `changelog.paths[]` syntax.
1671///
1672/// Path patterns are passed straight to `git log -- <path>` (or the
1673/// per-SCM equivalent). Two patterns are always wrong:
1674/// - Leading `/` — git pathspec treats this as anchored-to-CWD which is
1675/// almost never what the user wrote and produces empty changelogs.
1676/// - Empty string — silently matches everything; rejected so a typo
1677/// doesn't disable filtering.
1678///
1679/// Globs containing `**` are accepted (git accepts them) but the docs
1680/// note their semantics differ from gitignore; that's a docs concern,
1681/// not a hard error.
1682pub fn validate_changelog_paths(config: &Config) -> Result<(), String> {
1683 let check = |location: &str, cfg: &ChangelogConfig| -> Result<(), String> {
1684 let Some(ref paths) = cfg.paths else {
1685 return Ok(());
1686 };
1687 for (idx, p) in paths.iter().enumerate() {
1688 if p.is_empty() {
1689 return Err(format!(
1690 "{location}: changelog.paths[{idx}] is empty; remove the entry \
1691 or set a real path (empty string matches everything and \
1692 disables filtering)"
1693 ));
1694 }
1695 if p.starts_with('/') {
1696 return Err(format!(
1697 "{location}: changelog.paths[{idx}] = {:?} starts with '/'; \
1698 git pathspec is repo-root-relative — write {:?} instead",
1699 p,
1700 p.trim_start_matches('/')
1701 ));
1702 }
1703 }
1704 Ok(())
1705 };
1706 if let Some(ref cfg) = config.changelog {
1707 check("changelog", cfg)?;
1708 }
1709 if let Some(ref ws_list) = config.workspaces {
1710 for ws in ws_list {
1711 if let Some(ref cfg) = ws.changelog {
1712 check(&format!("workspaces[{}].changelog", ws.name), cfg)?;
1713 }
1714 }
1715 }
1716 Ok(())
1717}
1718
1719/// Validate every upload-destination `exclude:` glob across all config axes.
1720///
1721/// `exclude:` drops artifacts whose file name matches a glob (see
1722/// [`crate::artifact::passes_exclude_filter`]). An unparseable glob is treated
1723/// as non-matching at runtime so it never crashes a release — but a typo'd
1724/// glob that silently keeps an asset (or, worse, drops every asset) is a
1725/// foot-gun. Reject malformed globs here, at config-load, with a clear message
1726/// before they can take effect.
1727///
1728/// Covers every config position where `exclude:` is settable: per-crate
1729/// `release:` and `blobs:` (top-level crates AND `workspaces[].crates[]`), the
1730/// top-level `artifactories:`, `cloudsmiths:`, `gemfury:`, and `uploads:`
1731/// lists, and the top-level shared `release:` block.
1732pub fn validate_exclude_globs(config: &Config) -> Result<(), String> {
1733 fn check(location: &str, exclude: Option<&[String]>) -> Result<(), String> {
1734 let Some(globs) = exclude else {
1735 return Ok(());
1736 };
1737 for (idx, g) in globs.iter().enumerate() {
1738 if g.is_empty() {
1739 return Err(format!(
1740 "{location}: exclude[{idx}] is empty; remove the entry or set a \
1741 real glob (an empty pattern matches nothing and is a no-op)"
1742 ));
1743 }
1744 if let Err(e) = glob::Pattern::new(g) {
1745 return Err(format!(
1746 "{location}: exclude[{idx}] = {g:?} is not a valid glob: {e}"
1747 ));
1748 }
1749 }
1750 Ok(())
1751 }
1752
1753 let check_crate = |location: &str, krate: &CrateConfig| -> Result<(), String> {
1754 if let Some(ref release) = krate.release {
1755 check(&format!("{location}.release"), release.exclude.as_deref())?;
1756 }
1757 if let Some(ref blobs) = krate.blobs {
1758 for (i, b) in blobs.iter().enumerate() {
1759 check(&format!("{location}.blobs[{i}]"), b.exclude.as_deref())?;
1760 }
1761 }
1762 Ok(())
1763 };
1764
1765 for krate in &config.crates {
1766 check_crate(&format!("crates[{}]", krate.name), krate)?;
1767 }
1768 if let Some(ref ws_list) = config.workspaces {
1769 for ws in ws_list {
1770 for krate in &ws.crates {
1771 check_crate(
1772 &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
1773 krate,
1774 )?;
1775 }
1776 }
1777 }
1778 if let Some(ref list) = config.artifactories {
1779 for (i, a) in list.iter().enumerate() {
1780 check(&format!("artifactories[{i}]"), a.exclude.as_deref())?;
1781 }
1782 }
1783 if let Some(ref list) = config.cloudsmiths {
1784 for (i, c) in list.iter().enumerate() {
1785 check(&format!("cloudsmiths[{i}]"), c.exclude.as_deref())?;
1786 }
1787 }
1788 if let Some(ref list) = config.gemfury {
1789 for (i, g) in list.iter().enumerate() {
1790 check(&format!("gemfury[{i}]"), g.exclude.as_deref())?;
1791 }
1792 }
1793 if let Some(ref list) = config.uploads {
1794 for (i, u) in list.iter().enumerate() {
1795 check(&format!("uploads[{i}]"), u.exclude.as_deref())?;
1796 }
1797 }
1798 if let Some(ref release) = config.release {
1799 check("release", release.exclude.as_deref())?;
1800 }
1801 Ok(())
1802}
1803
1804// ---------------------------------------------------------------------------
1805// Per-crate publish visitor
1806// ---------------------------------------------------------------------------
1807
1808/// Identifies which of the three publish-config axes a visited block came from.
1809///
1810/// The config-validation walkers each format their own location string from
1811/// this identity, so different walkers can keep their distinct location wording
1812/// (`crate '{name}'` vs `crates[{name}].publish.homebrew_cask`) while sharing a
1813/// single iteration order: crates, then workspaces, then defaults.
1814pub(crate) enum PublishAxis<'a> {
1815 /// A top-level `crates[].publish` block, carrying the crate name.
1816 Crate { name: &'a str },
1817 /// A `workspaces[].crates[].publish` block, carrying the workspace and
1818 /// crate names.
1819 Workspace {
1820 workspace: &'a str,
1821 crate_name: &'a str,
1822 },
1823 /// The `defaults.publish` block.
1824 Defaults,
1825}
1826
1827impl PublishAxis<'_> {
1828 /// Location string in the bare publish-block wording shared by the
1829 /// submitter-required and legacy-Homebrew-Formula warnings:
1830 /// `crate '{name}'`, `workspaces[{ws}].crates[{krate}]`, or
1831 /// `defaults.publish`.
1832 pub(crate) fn location(&self) -> String {
1833 match self {
1834 PublishAxis::Crate { name } => format!("crate '{name}'"),
1835 PublishAxis::Workspace {
1836 workspace,
1837 crate_name,
1838 } => format!("workspaces[{workspace}].crates[{crate_name}]"),
1839 PublishAxis::Defaults => "defaults.publish".to_string(),
1840 }
1841 }
1842
1843 /// Location string in the cask-block wording used by the legacy
1844 /// Homebrew-Cask singular fold: `crates[{name}].publish.homebrew_cask`,
1845 /// `workspaces[{ws}].crates[{krate}].publish.homebrew_cask`, or
1846 /// `defaults.publish.homebrew_cask`.
1847 pub(crate) fn homebrew_cask_location(&self) -> String {
1848 match self {
1849 PublishAxis::Crate { name } => {
1850 format!("crates[{name}].publish.homebrew_cask")
1851 }
1852 PublishAxis::Workspace {
1853 workspace,
1854 crate_name,
1855 } => format!("workspaces[{workspace}].crates[{crate_name}].publish.homebrew_cask"),
1856 PublishAxis::Defaults => "defaults.publish.homebrew_cask".to_string(),
1857 }
1858 }
1859
1860 /// Location string in the winget-block wording:
1861 /// `crates[{name}].publish.winget`,
1862 /// `workspaces[{ws}].crates[{krate}].publish.winget`, or
1863 /// `defaults.publish.winget`.
1864 pub(crate) fn winget_location(&self) -> String {
1865 match self {
1866 PublishAxis::Crate { name } => format!("crates[{name}].publish.winget"),
1867 PublishAxis::Workspace {
1868 workspace,
1869 crate_name,
1870 } => format!("workspaces[{workspace}].crates[{crate_name}].publish.winget"),
1871 PublishAxis::Defaults => "defaults.publish.winget".to_string(),
1872 }
1873 }
1874}
1875
1876/// Shared, immutable view over the publisher sub-configs that appear on both
1877/// [`PublishConfig`] (the `crates[].publish` axis) and [`PublishDefaults`] (the
1878/// `defaults.publish` axis). The two underlying structs are distinct types, so
1879/// this enum erases the difference for read-only walkers.
1880pub(crate) enum PublishRef<'a> {
1881 /// A per-crate `publish:` block.
1882 Crate(&'a PublishConfig),
1883 /// The `defaults.publish:` block.
1884 Defaults(&'a PublishDefaults),
1885}
1886
1887impl PublishRef<'_> {
1888 pub(crate) fn homebrew(&self) -> Option<&HomebrewConfig> {
1889 match self {
1890 PublishRef::Crate(p) => p.homebrew.as_ref(),
1891 PublishRef::Defaults(p) => p.homebrew.as_ref(),
1892 }
1893 }
1894
1895 pub(crate) fn chocolatey(&self) -> Option<&ChocolateyConfig> {
1896 match self {
1897 PublishRef::Crate(p) => p.chocolatey.as_ref(),
1898 PublishRef::Defaults(p) => p.chocolatey.as_ref(),
1899 }
1900 }
1901
1902 pub(crate) fn winget(&self) -> Option<&WingetConfig> {
1903 match self {
1904 PublishRef::Crate(p) => p.winget.as_ref(),
1905 PublishRef::Defaults(p) => p.winget.as_ref(),
1906 }
1907 }
1908
1909 pub(crate) fn aur_source(&self) -> Option<&AurSourceConfig> {
1910 match self {
1911 PublishRef::Crate(p) => p.aur_source.as_ref(),
1912 PublishRef::Defaults(p) => p.aur_source.as_ref(),
1913 }
1914 }
1915
1916 pub(crate) fn homebrew_cask(&self) -> Option<&HomebrewCaskConfig> {
1917 match self {
1918 PublishRef::Crate(p) => p.homebrew_cask.as_ref(),
1919 PublishRef::Defaults(p) => p.homebrew_cask.as_ref(),
1920 }
1921 }
1922}
1923
1924/// Shared, mutable view over the publisher sub-configs that appear on both
1925/// [`PublishConfig`] and [`PublishDefaults`]. The `_mut` companion to
1926/// [`PublishRef`], for walkers that fold or rewrite a publisher block in place.
1927pub(crate) enum PublishMut<'a> {
1928 /// A per-crate `publish:` block.
1929 Crate(&'a mut PublishConfig),
1930 /// The `defaults.publish:` block.
1931 Defaults(&'a mut PublishDefaults),
1932}
1933
1934impl PublishMut<'_> {
1935 pub(crate) fn homebrew_cask_mut(&mut self) -> Option<&mut HomebrewCaskConfig> {
1936 match self {
1937 PublishMut::Crate(p) => p.homebrew_cask.as_mut(),
1938 PublishMut::Defaults(p) => p.homebrew_cask.as_mut(),
1939 }
1940 }
1941}
1942
1943/// Visit every `publish:` block across all three config axes — `crates[]`,
1944/// `workspaces[].crates[]`, then `defaults` — in that fixed order, passing each
1945/// block's [`PublishAxis`] identity and a read-only [`PublishRef`] view to
1946/// `visit`. Axes with no `publish:` block are skipped.
1947pub(crate) fn for_each_crate_publish<F>(config: &Config, mut visit: F)
1948where
1949 F: FnMut(PublishAxis<'_>, PublishRef<'_>),
1950{
1951 for krate in &config.crates {
1952 if let Some(ref publish) = krate.publish {
1953 visit(
1954 PublishAxis::Crate { name: &krate.name },
1955 PublishRef::Crate(publish),
1956 );
1957 }
1958 }
1959
1960 if let Some(ref workspaces) = config.workspaces {
1961 for ws in workspaces {
1962 for krate in &ws.crates {
1963 if let Some(ref publish) = krate.publish {
1964 visit(
1965 PublishAxis::Workspace {
1966 workspace: &ws.name,
1967 crate_name: &krate.name,
1968 },
1969 PublishRef::Crate(publish),
1970 );
1971 }
1972 }
1973 }
1974 }
1975
1976 if let Some(ref defaults) = config.defaults
1977 && let Some(ref publish) = defaults.publish
1978 {
1979 visit(PublishAxis::Defaults, PublishRef::Defaults(publish));
1980 }
1981}
1982
1983/// Fallible companion to [`for_each_crate_publish`]: visits the same three axes
1984/// in the same fixed order, but short-circuits on the first `Err` the callback
1985/// returns, propagating it to the caller. For validators that early-exit on the
1986/// first offending block.
1987pub(crate) fn try_for_each_crate_publish<F, E>(config: &Config, mut visit: F) -> Result<(), E>
1988where
1989 F: FnMut(PublishAxis<'_>, PublishRef<'_>) -> Result<(), E>,
1990{
1991 for krate in &config.crates {
1992 if let Some(ref publish) = krate.publish {
1993 visit(
1994 PublishAxis::Crate { name: &krate.name },
1995 PublishRef::Crate(publish),
1996 )?;
1997 }
1998 }
1999
2000 if let Some(ref workspaces) = config.workspaces {
2001 for ws in workspaces {
2002 for krate in &ws.crates {
2003 if let Some(ref publish) = krate.publish {
2004 visit(
2005 PublishAxis::Workspace {
2006 workspace: &ws.name,
2007 crate_name: &krate.name,
2008 },
2009 PublishRef::Crate(publish),
2010 )?;
2011 }
2012 }
2013 }
2014 }
2015
2016 if let Some(ref defaults) = config.defaults
2017 && let Some(ref publish) = defaults.publish
2018 {
2019 visit(PublishAxis::Defaults, PublishRef::Defaults(publish))?;
2020 }
2021
2022 Ok(())
2023}
2024
2025/// Mutable companion to [`for_each_crate_publish`]: visits the same three axes
2026/// in the same fixed order, passing a [`PublishMut`] view so the callback can
2027/// rewrite the publisher block in place.
2028pub(crate) fn for_each_crate_publish_mut<F>(config: &mut Config, mut visit: F)
2029where
2030 F: FnMut(PublishAxis<'_>, PublishMut<'_>),
2031{
2032 for krate in &mut config.crates {
2033 if let Some(ref mut publish) = krate.publish {
2034 visit(
2035 PublishAxis::Crate { name: &krate.name },
2036 PublishMut::Crate(publish),
2037 );
2038 }
2039 }
2040
2041 if let Some(ref mut workspaces) = config.workspaces {
2042 for ws in workspaces {
2043 for krate in &mut ws.crates {
2044 if let Some(ref mut publish) = krate.publish {
2045 visit(
2046 PublishAxis::Workspace {
2047 workspace: &ws.name,
2048 crate_name: &krate.name,
2049 },
2050 PublishMut::Crate(publish),
2051 );
2052 }
2053 }
2054 }
2055 }
2056
2057 if let Some(ref mut defaults) = config.defaults
2058 && let Some(ref mut publish) = defaults.publish
2059 {
2060 visit(PublishAxis::Defaults, PublishMut::Defaults(publish));
2061 }
2062}
2063
2064/// A submitter moderation-queue advisory paired with the dispatch publisher
2065/// identity that produced it. The CLI filters by [`SubmitterAdvisory::publisher`]
2066/// so an advisory for a publisher deselected by `--skip` / `--publishers`
2067/// (e.g. `chocolatey` under a `--publishers npm` run) is suppressed instead of
2068/// emitted as noise.
2069#[derive(Debug, Clone, PartialEq, Eq)]
2070pub struct SubmitterAdvisory {
2071 /// Dispatch publisher name, matching the string
2072 /// [`crate::context::Context::publisher_deselected`] tests: `chocolatey`,
2073 /// `winget`, or `upstream-aur` (the AUR-source publisher's dispatch name).
2074 /// The CLI keys its deselection predicate on this value.
2075 pub publisher: String,
2076 /// The verbose advisory line surfaced to the operator.
2077 pub message: String,
2078}
2079
2080/// One advisory per publisher configured with `required: true` whose group is
2081/// Submitter (chocolatey, winget, aur_source), each tagged with its dispatch
2082/// publisher identity so the CLI can suppress advisories for deselected
2083/// publishers.
2084///
2085/// `required: true` on a submitter still fails the release when the submission
2086/// itself fails (it feeds `required_failures()`), but the external moderation
2087/// outcome resolves after the release run and cannot be gated on. The advisory
2088/// is non-fatal and clarifies which half of the semantics applies. Cargo is
2089/// excluded: its default is already `required: true` and the message would be
2090/// noise.
2091///
2092/// Covers all three publish axes — `crates[].publish`,
2093/// `workspaces[].crates[].publish`, and `defaults.publish` (via
2094/// [`for_each_crate_publish`]) — plus the top-level `aur_sources:` list.
2095///
2096/// Pure: this returns the advisories without emitting them. The CLI surfaces
2097/// them through `StageLogger::verbose` (the `--verbose`-gated register), so
2098/// they stay hidden at the default log level — see
2099/// `pipeline::load_config_logged`.
2100pub fn submitter_required_warnings(config: &Config) -> Vec<SubmitterAdvisory> {
2101 fn advisory(location: &str, name: &str, publisher: &str) -> SubmitterAdvisory {
2102 SubmitterAdvisory {
2103 publisher: publisher.to_string(),
2104 message: format!(
2105 "{location}: publisher '{name}' submits to an external moderation queue; \
2106 `required: true` fails the release when the submission itself fails, \
2107 but the eventual moderation outcome happens outside the release run \
2108 and cannot be gated."
2109 ),
2110 }
2111 }
2112
2113 let mut warnings = Vec::new();
2114
2115 for_each_crate_publish(config, |axis, publish| {
2116 let loc = axis.location();
2117 if publish.chocolatey().and_then(|c| c.required) == Some(true) {
2118 warnings.push(advisory(&loc, "chocolatey", "chocolatey"));
2119 }
2120 if publish.winget().and_then(|w| w.required) == Some(true) {
2121 warnings.push(advisory(&loc, "winget", "winget"));
2122 }
2123 if publish.aur_source().and_then(|a| a.required) == Some(true) {
2124 // The AUR-source publisher dispatches under the name `upstream-aur`
2125 // (`AurSourcePublisher::PUBLISHER_NAME`); key the advisory on that so
2126 // the CLI's `publisher_deselected("upstream-aur")` filter matches.
2127 warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2128 }
2129 });
2130
2131 // Top-level aur_sources list (not nested under publish:) — no crate axis,
2132 // distinguish via the index in the list so two top-level entries collide cleanly.
2133 if let Some(ref sources) = config.aur_sources {
2134 for (idx, src) in sources.iter().enumerate() {
2135 if src.required == Some(true) {
2136 let loc = format!("top-level aur_sources[{idx}]");
2137 warnings.push(advisory(&loc, "aur_source", "upstream-aur"));
2138 }
2139 }
2140 }
2141
2142 warnings
2143}
2144
2145/// No-op preserved for API stability; the legacy `format:` and `builds:`
2146/// folds happen inline in `<ArchiveConfig as Deserialize>::deserialize` and
2147/// `<FormatOverride as Deserialize>::deserialize`. Emits no warning of its
2148/// own — every alias hit was already announced at deserialize time.
2149///
2150pub fn apply_archive_legacy_aliases(_config: &mut Config) {
2151 // Intentionally empty — see Deserialize impls.
2152}
2153
2154/// Reject the legacy V1 `dockers:` block at config-load time with a
2155/// clear migration error.
2156///
2157/// anodizer is V2-only by design: it implements `dockers_v2:` and the
2158/// associated multi-arch buildx flow, but does not ship the V1
2159/// `dockers: -> dockerfile + image_templates` pipe. Without this check the
2160/// top-level `Config` struct's `deny_unknown_fields` would emit a generic
2161/// "unknown field `dockers`" message that doesn't tell the user how to
2162/// migrate. This explicit error names the field, points at `dockers_v2:`,
2163/// and references the rationale.
2164///
2165pub fn validate_no_docker_v1(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2166 if raw_yaml.get("dockers").is_some() {
2167 return Err(
2168 "config: legacy GoReleaser `dockers:` block is not supported — anodizer ships \
2169 dockers_v2: only (multi-arch buildx flow). Port the config to `dockers_v2:` per \
2170 https://anodize.dev/docs/migration/docker.html."
2171 .to_string(),
2172 );
2173 }
2174 Ok(())
2175}
2176
2177/// Emit a `tracing::warn!` for each `publish.homebrew:` (Homebrew Formula)
2178/// occurrence in the loaded config. The upstream deprecated the
2179/// Formula publisher in favour of `homebrew_casks:`; anodizer mirrors the
2180/// upstream deprecation so users following the change-log see the
2181/// same migration prompt.
2182///
2183/// Covers three placement axes (matching how `publish.homebrew` may appear):
2184/// * `crates[].publish.homebrew`
2185/// * `workspaces[].crates[].publish.homebrew`
2186/// * `defaults.publish.homebrew`
2187///
2188/// There is no top-level `homebrew:` or `brews:` field on anodizer's
2189/// `Config` — only `homebrew_casks:` lives at the top level — so this
2190/// function does not need a top-level scan.
2191pub fn warn_on_legacy_homebrew_formula(config: &Config) {
2192 for msg in legacy_homebrew_formula_warnings(config) {
2193 tracing::warn!("{}", msg);
2194 }
2195}
2196
2197/// Pure helper: returns the warning strings without emitting them.
2198/// Exposed for tests; production callers use
2199/// [`warn_on_legacy_homebrew_formula`].
2200pub(crate) fn legacy_homebrew_formula_warnings(config: &Config) -> Vec<String> {
2201 fn formula_warning(location: &str) -> String {
2202 format!(
2203 "DEPRECATION: {location}: publish.homebrew (Homebrew Formula) is deprecated upstream \
2204 in GoReleaser v2.16; migrate to homebrew_casks. Cask is now the canonical Homebrew \
2205 distribution channel for pre-compiled binaries. See \
2206 https://anodize.dev/docs/publish/homebrew-casks/ for migration."
2207 )
2208 }
2209
2210 let mut warnings = Vec::new();
2211
2212 for_each_crate_publish(config, |axis, publish| {
2213 if publish.homebrew().is_some() {
2214 warnings.push(formula_warning(&axis.location()));
2215 }
2216 });
2217
2218 warnings
2219}
2220
2221/// Fold the deprecated `snapshot.name_template` alias into `version_template`.
2222/// Serde already accepts both spellings via `#[serde(alias = "name_template")]`,
2223/// so this function only needs to emit the deprecation warning when the
2224/// raw YAML key was the legacy one.
2225///
2226/// Because serde collapses the two spellings to a single field on parse, we
2227/// lose the information about which key the user wrote. This function
2228/// therefore consults the raw YAML pre-parse value (when supplied) to decide.
2229pub fn warn_on_legacy_snapshot_name_template(raw_yaml: &serde_yaml_ng::Value) {
2230 if let Some(snap) = raw_yaml.get("snapshot")
2231 && snap.get("name_template").is_some()
2232 {
2233 tracing::warn!(
2234 "DEPRECATION: snapshot.name_template is deprecated; use \
2235 snapshot.version_template instead. Both spellings are accepted \
2236 but the legacy key will be removed in a future release."
2237 );
2238 }
2239}
2240
2241/// Emit a one-time deprecation warning when a config uses the legacy
2242/// `furies:` top-level key. Serde transparently folds `furies:` into
2243/// `gemfury:` via `#[serde(alias)]`, so this function consults the raw YAML
2244/// pre-parse value to detect the legacy spelling.
2245///
2246/// The `furies → gemfury` rename messaging.
2247pub fn warn_on_legacy_furies_alias(raw_yaml: &serde_yaml_ng::Value) {
2248 if raw_yaml.get("furies").is_some() {
2249 tracing::warn!(
2250 "DEPRECATION: the top-level `furies:` config key is deprecated since GoReleaser \
2251 Pro v2.14; rename it to `gemfury:`. Both spellings are accepted but the legacy \
2252 key will be removed in a future release."
2253 );
2254 }
2255}
2256
2257/// Emit a one-time deprecation warning for each nfpm config object that uses
2258/// the legacy `builds:` key. Serde transparently folds `builds:` into `ids:`
2259/// via `#[serde(alias = "builds")]` on [`NfpmConfig::ids`], so this function
2260/// consults the raw YAML pre-parse value to detect the legacy spelling that the
2261/// typed parse would otherwise erase.
2262///
2263/// The deprecated `NFPM.Builds` field (use `ids` instead).
2264///
2265/// nfpm config objects appear under the key `nfpm` or `nfpms` (a single map or
2266/// a sequence of maps) at multiple nesting depths — top-level, under
2267/// `defaults:`, under each `crates[]` entry, and under each
2268/// `workspaces[].crates[]` entry. Rather than enumerate every path, this walks
2269/// the tree recursively and inspects a node as an nfpm config only when it is
2270/// the value of an `nfpm:`/`nfpms:` key, so an unrelated `builds:` key
2271/// elsewhere (e.g. archives) is not double-counted.
2272pub fn warn_on_legacy_nfpm_builds(raw_yaml: &serde_yaml_ng::Value) {
2273 fn warn_for_nfpm_value(value: &serde_yaml_ng::Value) {
2274 match value {
2275 serde_yaml_ng::Value::Mapping(_) => {
2276 if value.get("builds").is_some() {
2277 tracing::warn!(
2278 "DEPRECATION: nfpm `builds:` is deprecated; use `ids:` instead. \
2279 Both spellings are accepted but the legacy key will be removed in \
2280 a future release."
2281 );
2282 }
2283 }
2284 serde_yaml_ng::Value::Sequence(items) => {
2285 for item in items {
2286 warn_for_nfpm_value(item);
2287 }
2288 }
2289 _ => {}
2290 }
2291 }
2292
2293 fn descend(value: &serde_yaml_ng::Value) {
2294 match value {
2295 serde_yaml_ng::Value::Mapping(map) => {
2296 for (key, child) in map {
2297 if matches!(key.as_str(), Some("nfpm") | Some("nfpms")) {
2298 warn_for_nfpm_value(child);
2299 }
2300 descend(child);
2301 }
2302 }
2303 serde_yaml_ng::Value::Sequence(items) => {
2304 for item in items {
2305 descend(item);
2306 }
2307 }
2308 _ => {}
2309 }
2310 }
2311
2312 descend(raw_yaml);
2313}
2314
2315/// Emit a one-time deprecation warning for each block that carries the legacy
2316/// `disable:` spelling of the canonical `skip:` field. Many config blocks
2317/// (`release`, `changelog`, `snapcraft`, the docker / installer / packager
2318/// blocks, …) accept `disable:` via `#[serde(alias = "disable")]` for
2319/// back-compat with imported configs; serde folds the alias into
2320/// `skip` on parse, erasing which spelling the user wrote. This helper
2321/// consults the raw YAML pre-parse value so porting users get a migration
2322/// prompt pointing at the canonical `skip:`.
2323///
2324/// Detection is allow-listed by enclosing block key, NOT a blind tree walk,
2325/// because free-form string-keyed maps would otherwise produce false
2326/// positives:
2327/// * Free-form string-keyed maps (`variables`, `derived_metadata`,
2328/// `build_args`, `labels`, `annotations`, `env`, header maps, …) let a
2329/// user legitimately name a key `disable`. Matching only when the key's
2330/// immediate enclosing block is allow-listed skips those — the nearest
2331/// named ancestor of such a key is the map's own key (e.g. `build_args`),
2332/// never an allow-listed block.
2333///
2334/// Axis-agnostic: the enclosing block key is identical whether the block sits
2335/// at the top level, under `defaults.<block>`, under `crates[].<block>`, or
2336/// under `workspaces[].crates[].<block>`, so a single nearest-named-ancestor
2337/// rule covers every placement.
2338pub fn warn_on_legacy_disable_alias(raw_yaml: &serde_yaml_ng::Value) {
2339 for msg in legacy_disable_alias_warnings(raw_yaml) {
2340 tracing::warn!("{}", msg);
2341 }
2342}
2343
2344/// Pure helper: returns one warning string per offending `disable:` key,
2345/// each naming the YAML path to the key. Exposed for tests; production callers
2346/// use [`warn_on_legacy_disable_alias`].
2347pub(crate) fn legacy_disable_alias_warnings(raw_yaml: &serde_yaml_ng::Value) -> Vec<String> {
2348 // Block key names whose struct exposes `skip` with `#[serde(alias =
2349 // "disable")]`. Resolved from the field's serde key on its parent (see the
2350 // `alias = "disable"` sites in core). `makeselfs` (top-level) and
2351 // `makeselves` (defaults.) both map to MakeselfConfig, so both are listed;
2352 // `gemfury` and its legacy `furies` alias both map to GemFuryConfig.
2353 const ALLOWLIST: &[&str] = &[
2354 "mcp",
2355 "makeselfs",
2356 "makeselves",
2357 "appimages",
2358 "msis",
2359 "pkgs",
2360 "nsis",
2361 "dockerhub",
2362 "release",
2363 "dockers_v2",
2364 "docker_v2",
2365 "changelog",
2366 "snapcrafts",
2367 "npms",
2368 "gemfury",
2369 "furies",
2370 "publishers",
2371 "sboms",
2372 "aur",
2373 "aur_source",
2374 "aur_sources",
2375 "blobs",
2376 "docker_digest",
2377 "checksum",
2378 "flatpaks",
2379 ];
2380
2381 fn disable_warning(path: &str) -> String {
2382 format!(
2383 "DEPRECATION: {path}: legacy `disable:` is deprecated; rename it to `skip:`. \
2384 Both spellings are accepted but the legacy key will be removed in a future release."
2385 )
2386 }
2387
2388 // `enclosing_block`: the nearest named (non-list-index) ancestor key — the
2389 // block the `disable:` key belongs to. Only warn when it is allow-listed.
2390 fn descend(
2391 value: &serde_yaml_ng::Value,
2392 path: &str,
2393 enclosing_block: Option<&str>,
2394 warnings: &mut Vec<String>,
2395 ) {
2396 match value {
2397 serde_yaml_ng::Value::Mapping(map) => {
2398 for (key, child) in map {
2399 let Some(key) = key.as_str() else { continue };
2400 let child_path = if path.is_empty() {
2401 key.to_string()
2402 } else {
2403 format!("{path}.{key}")
2404 };
2405 if key == "disable"
2406 && enclosing_block.is_some_and(|block| ALLOWLIST.contains(&block))
2407 {
2408 warnings.push(disable_warning(&child_path));
2409 }
2410 descend(child, &child_path, Some(key), warnings);
2411 }
2412 }
2413 serde_yaml_ng::Value::Sequence(items) => {
2414 for (idx, item) in items.iter().enumerate() {
2415 let item_path = format!("{path}[{idx}]");
2416 // A list index is not a named ancestor: keep the enclosing
2417 // block (the list's own key) so e.g. `snapcrafts[0].disable`
2418 // still resolves to the `snapcrafts` block.
2419 descend(item, &item_path, enclosing_block, warnings);
2420 }
2421 }
2422 _ => {}
2423 }
2424 }
2425
2426 let mut warnings = Vec::new();
2427 descend(raw_yaml, "", None, &mut warnings);
2428 warnings
2429}
2430
2431/// Reject the legacy nested `mcp.github:` block with a
2432/// clear migration error.
2433///
2434/// The registry metadata that used to live under
2435/// `mcp.github:` (repository owner/name/url) to the top-level `mcp:` block
2436/// (canonical surface: `mcp.repository:`, `mcp.name:`, etc.). Anodizer
2437/// never carried the nested shim — its `McpConfig` has `deny_unknown_fields`
2438/// so the key would otherwise produce a generic "unknown field" message.
2439/// This pre-parse check intercepts the legacy spelling so the user sees a
2440/// migration pointer rather than a schema-shape error.
2441pub fn validate_no_mcp_github(raw_yaml: &serde_yaml_ng::Value) -> Result<(), String> {
2442 if raw_yaml.get("mcp").and_then(|m| m.get("github")).is_some() {
2443 return Err(
2444 "config: nested `mcp.github:` block is not supported — anodizer mirrors GoReleaser \
2445 v2.13.1+ where registry metadata moved to top-level `mcp:` fields (`mcp.name`, \
2446 `mcp.repository.url`, `mcp.repository.source`). Port the nested keys to the \
2447 canonical surface."
2448 .to_string(),
2449 );
2450 }
2451 Ok(())
2452}
2453
2454/// Emit a one-time deprecation warning for each `dockers_v2[].retry:` or
2455/// `docker_manifests[].retry:` block at config-load time. The per-pipe
2456/// `retry:` field is the legacy shape (retry handling moved to
2457/// the top-level `retry:` block); the per-pipe value is still honored at
2458/// resolve-time (see `stage-docker::resolve_retry_params`) but a top-level
2459/// `retry:` is the canonical surface for retry policy. Warning fires once
2460/// per occurrence so users porting from older configs see a clear
2461/// pointer at load time without waiting for the docker pipe to execute.
2462pub fn warn_on_legacy_docker_retry(config: &Config) {
2463 for msg in legacy_docker_retry_warnings(config) {
2464 tracing::warn!("{}", msg);
2465 }
2466}
2467
2468/// Pure helper: returns the warning strings without emitting them. Exposed
2469/// for tests; production callers use [`warn_on_legacy_docker_retry`].
2470pub(crate) fn legacy_docker_retry_warnings(config: &Config) -> Vec<String> {
2471 fn pipe_warning(location: &str, kind: &str) -> String {
2472 format!(
2473 "DEPRECATION: {location}: nested `{kind}.retry:` is deprecated since GoReleaser \
2474 v2.15.3; move retry settings to the top-level `retry:` block. The per-pipe \
2475 value still wins at resolve time for back-compat, but the legacy spelling will \
2476 be removed in a future release."
2477 )
2478 }
2479
2480 let mut warnings = Vec::new();
2481
2482 let scan_crate = |krate: &CrateConfig, prefix: &str, warnings: &mut Vec<String>| {
2483 if let Some(ref v2) = krate.dockers_v2 {
2484 for (i, cfg) in v2.iter().enumerate() {
2485 if cfg.retry.is_some() {
2486 warnings.push(pipe_warning(
2487 &format!("{prefix}.dockers_v2[{i}]"),
2488 "dockers_v2",
2489 ));
2490 }
2491 }
2492 }
2493 if let Some(ref manifests) = krate.docker_manifests {
2494 for (i, cfg) in manifests.iter().enumerate() {
2495 if cfg.retry.is_some() {
2496 warnings.push(pipe_warning(
2497 &format!("{prefix}.docker_manifests[{i}]"),
2498 "docker_manifests",
2499 ));
2500 }
2501 }
2502 }
2503 };
2504
2505 for krate in &config.crates {
2506 scan_crate(krate, &format!("crates[{}]", krate.name), &mut warnings);
2507 }
2508
2509 if let Some(ref workspaces) = config.workspaces {
2510 for ws in workspaces {
2511 for krate in &ws.crates {
2512 scan_crate(
2513 krate,
2514 &format!("workspaces[{}].crates[{}]", ws.name, krate.name),
2515 &mut warnings,
2516 );
2517 }
2518 }
2519 }
2520
2521 if let Some(ref defaults) = config.defaults
2522 && let Some(ref v2) = defaults.dockers_v2
2523 && v2.retry.is_some()
2524 {
2525 warnings.push(pipe_warning("defaults.dockers_v2", "dockers_v2"));
2526 }
2527
2528 warnings
2529}
2530
2531/// Fold the deprecated singular Homebrew Cask fields into their canonical
2532/// plural lists and emit a one-time deprecation warning per folded field:
2533///
2534/// - `binary: <name>` → [`HomebrewCaskConfig::binaries`] (the upstream
2535/// renamed `binary:` to `binaries:`).
2536/// - `manpage: <page>` → [`HomebrewCaskConfig::manpages`].
2537///
2538/// anodizer accepts both spellings so imported configs keep parsing.
2539/// The captured values are moved out of [`HomebrewCaskConfig::legacy_binary`]
2540/// and [`HomebrewCaskConfig::legacy_manpage`] so downstream code only ever
2541/// reads the canonical plural fields.
2542///
2543/// The two folds use different insertion order: a legacy
2544/// `binary` is **prepended** to `binaries` so any explicit `binaries:` ordering
2545/// is preserved at the tail, whereas a legacy `manpage` is **appended** to
2546/// `manpages` (the cask renderer does
2547/// `brew.Manpages = append(brew.Manpages, brew.Manpage)`).
2548///
2549/// The fold runs across every config mode — top-level `homebrew_casks`,
2550/// per-crate `publish.homebrew_cask`, `workspaces[].crates[].publish`, and
2551/// `defaults.publish`.
2552pub fn apply_homebrew_cask_legacy_singulars(config: &mut Config) {
2553 /// Fold both deprecated singular fields (`binary:` → `binaries`,
2554 /// `manpage:` → `manpages`) on one cask, returning a warning per folded
2555 /// field. The singular `binary` is prepended to `binaries` so an explicit
2556 /// `binaries[0]` ordering is preserved at the tail; the singular `manpage`
2557 /// is appended to `manpages`.
2558 fn fold_one(location: &str, cask: &mut HomebrewCaskConfig) -> Vec<String> {
2559 let mut warnings = Vec::new();
2560 if let Some(legacy) = cask.legacy_binary.take() {
2561 let entry = HomebrewCaskBinary::Name(legacy.clone());
2562 match cask.binaries {
2563 Some(ref mut list) => list.insert(0, entry),
2564 None => cask.binaries = Some(vec![entry]),
2565 }
2566 warnings.push(format!(
2567 "DEPRECATION: {location}: singular `binary: {legacy}` is deprecated since \
2568 GoReleaser v2.12.6; use the plural `binaries: [{legacy}]` form. The legacy \
2569 value has been folded into binaries[0]."
2570 ));
2571 }
2572 if let Some(legacy) = cask.legacy_manpage.take() {
2573 match cask.manpages {
2574 Some(ref mut list) => list.push(legacy.clone()),
2575 None => cask.manpages = Some(vec![legacy.clone()]),
2576 }
2577 warnings.push(format!(
2578 "DEPRECATION: {location}: singular `manpage: {legacy}` is deprecated; \
2579 use the plural `manpages: [{legacy}]` form. The legacy value has been \
2580 folded into manpages."
2581 ));
2582 }
2583 warnings
2584 }
2585
2586 let mut warnings = Vec::new();
2587
2588 // Top-level homebrew_casks list (not nested under publish:) — not a
2589 // publish axis, so it is scanned separately from the visitor.
2590 if let Some(ref mut casks) = config.homebrew_casks {
2591 for (i, cask) in casks.iter_mut().enumerate() {
2592 warnings.extend(fold_one(&format!("homebrew_casks[{i}]"), cask));
2593 }
2594 }
2595
2596 for_each_crate_publish_mut(config, |axis, mut publish| {
2597 if let Some(cask) = publish.homebrew_cask_mut() {
2598 warnings.extend(fold_one(&axis.homebrew_cask_location(), cask));
2599 }
2600 });
2601
2602 for msg in warnings {
2603 tracing::warn!("{}", msg);
2604 }
2605}
2606
2607// ---------------------------------------------------------------------------
2608// EnvFilesConfig — accepts list of .env paths OR structured token file paths
2609// ---------------------------------------------------------------------------
2610
2611mod env_files;
2612pub use env_files::*;
2613
2614// ---------------------------------------------------------------------------
2615// Defaults
2616// ---------------------------------------------------------------------------
2617
2618mod defaults;
2619pub use defaults::*;
2620
2621// ---------------------------------------------------------------------------
2622// BuildIgnore — exclude specific os/arch combos from builds
2623// ---------------------------------------------------------------------------
2624
2625mod build;
2626pub use build::*;
2627
2628// ---------------------------------------------------------------------------
2629// ArchivesConfig — untagged enum: false => Disabled, array => Configs
2630// ---------------------------------------------------------------------------
2631
2632mod archives;
2633pub use archives::*;
2634
2635mod completions;
2636pub use completions::*;
2637
2638// ---------------------------------------------------------------------------
2639// ReleaseConfig
2640// ---------------------------------------------------------------------------
2641
2642mod release;
2643pub use release::*;
2644
2645// ---------------------------------------------------------------------------
2646// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
2647// ---------------------------------------------------------------------------
2648
2649mod publishers;
2650pub use publishers::*;
2651
2652// ---------------------------------------------------------------------------
2653// DockerV2Config
2654// ---------------------------------------------------------------------------
2655
2656mod docker;
2657pub use docker::*;
2658
2659// ---------------------------------------------------------------------------
2660// NfpmConfig
2661// ---------------------------------------------------------------------------
2662
2663mod nfpm;
2664pub use nfpm::*;
2665
2666// ---------------------------------------------------------------------------
2667// SnapcraftConfig
2668// ---------------------------------------------------------------------------
2669
2670mod snapcraft;
2671pub use snapcraft::*;
2672// ---------------------------------------------------------------------------
2673// DmgConfig / MsiConfig / PkgConfig / NsisConfig / AppBundleConfig / FlatpakConfig
2674// ---------------------------------------------------------------------------
2675
2676mod installers;
2677pub use installers::*;
2678
2679// ---------------------------------------------------------------------------
2680// BlobConfig (S3/GCS/Azure cloud storage)
2681// ---------------------------------------------------------------------------
2682
2683mod blob;
2684pub use blob::*;
2685
2686// ---------------------------------------------------------------------------
2687// PartialConfig (split/merge CI fan-out)
2688// ---------------------------------------------------------------------------
2689
2690mod partial;
2691pub use partial::*;
2692
2693// ---------------------------------------------------------------------------
2694// BinstallConfig
2695// ---------------------------------------------------------------------------
2696
2697mod binstall;
2698pub use binstall::*;
2699
2700// ---------------------------------------------------------------------------
2701// NotarizeConfig (macOS code signing and notarization)
2702// ---------------------------------------------------------------------------
2703
2704mod notarize;
2705pub use notarize::*;
2706// ---------------------------------------------------------------------------
2707// SourceConfig
2708// ---------------------------------------------------------------------------
2709
2710mod source;
2711pub use source::*;
2712
2713// ---------------------------------------------------------------------------
2714// SbomConfig
2715// ---------------------------------------------------------------------------
2716
2717mod sbom;
2718pub use sbom::*;
2719
2720// ---------------------------------------------------------------------------
2721// AttestationConfig
2722// ---------------------------------------------------------------------------
2723
2724mod attestation;
2725pub use attestation::*;
2726
2727// ---------------------------------------------------------------------------
2728// VersionSyncConfig
2729// ---------------------------------------------------------------------------
2730
2731mod version_sync;
2732pub use version_sync::*;
2733
2734// ---------------------------------------------------------------------------
2735// ChangelogConfig
2736// ---------------------------------------------------------------------------
2737
2738mod changelog;
2739pub use changelog::*;
2740// ---------------------------------------------------------------------------
2741// SignConfig / DockerSignConfig — lifted to `crate::signing`
2742// ---------------------------------------------------------------------------
2743//
2744// see `crate::signing` for the type definitions. The
2745// re-exports below preserve the historical
2746// `anodizer_core::config::{SignConfig, DockerSignConfig}` import paths
2747// used by every stage that consumes a sign config.
2748
2749pub use crate::signing::{AuthenticodeConfig, DockerSignConfig, SignConfig, SignVerifyConfig};
2750
2751// ---------------------------------------------------------------------------
2752// UpxConfig
2753// ---------------------------------------------------------------------------
2754
2755mod upx;
2756pub use upx::*;
2757
2758// ---------------------------------------------------------------------------
2759// SnapshotConfig
2760// ---------------------------------------------------------------------------
2761
2762mod snapshot_nightly;
2763pub use snapshot_nightly::*;
2764
2765mod cargo_metadata;
2766pub use cargo_metadata::derive_metadata_from_cargo_toml;
2767
2768/// Extract the name portion of a `"Name <email>"` maintainer/author string,
2769/// dropping any `<…>` email suffix. Returns `None` when the result is empty
2770/// (e.g. a bare-email `<ada@example.com>`), so a derived Vendor / OCI `vendor`
2771/// value is never emitted blank.
2772pub fn maintainer_name_only(maintainer: &str) -> Option<String> {
2773 let name = maintainer.split('<').next().unwrap_or(maintainer).trim();
2774 (!name.is_empty()).then(|| name.to_string())
2775}
2776
2777// ---------------------------------------------------------------------------
2778// TemplateFileConfig
2779// ---------------------------------------------------------------------------
2780
2781mod templatefiles;
2782pub use templatefiles::*;
2783
2784// ---------------------------------------------------------------------------
2785// AnnounceConfig
2786// ---------------------------------------------------------------------------
2787mod announce;
2788pub use announce::*;
2789// ---------------------------------------------------------------------------
2790// DockerHub description sync
2791// ---------------------------------------------------------------------------
2792
2793mod dockerhub;
2794pub use dockerhub::*;
2795
2796// ---------------------------------------------------------------------------
2797// Artifactory publisher
2798// ---------------------------------------------------------------------------
2799
2800mod artifactory;
2801pub use artifactory::*;
2802
2803// ---------------------------------------------------------------------------
2804// CloudSmith publisher
2805// ---------------------------------------------------------------------------
2806
2807mod cloudsmith;
2808pub use cloudsmith::*;
2809
2810// ---------------------------------------------------------------------------
2811// PublisherConfig
2812// ---------------------------------------------------------------------------
2813
2814mod publisher;
2815pub use publisher::*;
2816
2817// ---------------------------------------------------------------------------
2818// HooksConfig
2819// ---------------------------------------------------------------------------
2820
2821mod hooks;
2822pub use hooks::*;
2823
2824// ---------------------------------------------------------------------------
2825// GitConfig
2826// ---------------------------------------------------------------------------
2827
2828mod git_config;
2829pub use git_config::*;
2830
2831// ---------------------------------------------------------------------------
2832// MonorepoConfig
2833// ---------------------------------------------------------------------------
2834
2835mod monorepo;
2836pub use monorepo::*;
2837
2838// ---------------------------------------------------------------------------
2839// TagConfig
2840// ---------------------------------------------------------------------------
2841
2842mod tag;
2843pub use tag::*;
2844
2845// ---------------------------------------------------------------------------
2846// WorkspaceConfig
2847// ---------------------------------------------------------------------------
2848
2849mod workspace;
2850pub use workspace::*;
2851
2852// ---------------------------------------------------------------------------
2853// RetryConfig (top-level `retry:` block — bridges to crate::retry::RetryPolicy)
2854// ---------------------------------------------------------------------------
2855
2856mod retry;
2857pub use retry::*;
2858
2859// ---------------------------------------------------------------------------
2860// PostPublishPollConfig (per-publisher post-publish polling)
2861// ---------------------------------------------------------------------------
2862
2863mod post_publish_poll;
2864pub use post_publish_poll::*;
2865
2866// ---------------------------------------------------------------------------
2867// VerifyReleaseConfig (top-level `verify_release:` post-publish gate)
2868// ---------------------------------------------------------------------------
2869
2870mod verify_release;
2871pub use verify_release::*;
2872
2873// ---------------------------------------------------------------------------
2874// PreflightConfig (top-level `preflight:` pre-publish probe tuning)
2875// ---------------------------------------------------------------------------
2876
2877mod preflight;
2878pub use preflight::*;
2879
2880// ---------------------------------------------------------------------------
2881// StringOrBool — accepts bool or template string in YAML
2882// ---------------------------------------------------------------------------
2883
2884mod string_or_bool;
2885pub use string_or_bool::*;
2886
2887// ---------------------------------------------------------------------------
2888// MakeselfConfig + SrpmConfig — lifted to `crate::packagers`
2889// ---------------------------------------------------------------------------
2890//
2891// All packaging config types live in their own modules under
2892// `crate::packagers`. The re-exports below preserve the historical
2893// `anodizer_core::config::{MakeselfConfig, MakeselfFile, SrpmConfig}`
2894// import paths used by stages and tests.
2895
2896pub use crate::packagers::{
2897 AppImageConfig, AppImageExtra, MakeselfConfig, MakeselfFile, RuntimeHarvest, SrpmConfig,
2898};
2899pub(crate) use crate::packagers::{
2900 appimages_schema, deserialize_appimages, deserialize_makeselfs, makeselfs_schema,
2901};
2902
2903// ---------------------------------------------------------------------------
2904// MilestoneConfig
2905// ---------------------------------------------------------------------------
2906
2907mod milestone;
2908pub use milestone::*;
2909
2910// ---------------------------------------------------------------------------
2911// UploadConfig (generic HTTP upload)
2912// ---------------------------------------------------------------------------
2913
2914mod upload;
2915pub use upload::*;
2916
2917// ---------------------------------------------------------------------------
2918// AurSourceConfig
2919// ---------------------------------------------------------------------------
2920
2921mod aur_source;
2922pub use aur_source::*;
2923
2924// ---------------------------------------------------------------------------
2925// McpConfig (MCP registry publisher)
2926// ---------------------------------------------------------------------------
2927
2928mod mcp;
2929pub use mcp::*;
2930
2931// ---------------------------------------------------------------------------
2932// NpmConfig (NPM package registry publisher)
2933// ---------------------------------------------------------------------------
2934
2935mod npm;
2936pub use npm::*;
2937
2938// ---------------------------------------------------------------------------
2939// GemFuryConfig (Gemfury / fury.io publisher)
2940// ---------------------------------------------------------------------------
2941
2942mod gemfury;
2943pub use gemfury::*;
2944
2945// ---------------------------------------------------------------------------
2946// Well-known config file discovery
2947// ---------------------------------------------------------------------------
2948
2949mod discovery;
2950pub use discovery::*;
2951
2952// ---------------------------------------------------------------------------
2953// Tests
2954// ---------------------------------------------------------------------------
2955
2956#[cfg(test)]
2957mod tests;