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