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