Skip to main content

anodizer_core/config/publishers/
mod.rs

1use std::path::PathBuf;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::aur_source::AurSourceConfig;
7use super::hooks::HookEntry;
8use super::string_or_bool::HumanDuration;
9use super::{StringOrBool, deserialize_string_or_bool_opt};
10
11mod homebrew;
12pub use homebrew::*;
13
14mod chocolatey;
15pub use chocolatey::*;
16
17mod winget;
18pub use winget::*;
19
20mod aur;
21pub use aur::*;
22
23mod krew;
24pub use krew::*;
25
26mod nix;
27pub use nix::*;
28
29mod schemastore;
30pub use schemastore::{SchemaEntry, SchemaMode, SchemastoreConfig};
31
32// ---------------------------------------------------------------------------
33// Shared publisher config types: RepositoryConfig, CommitAuthorConfig
34// ---------------------------------------------------------------------------
35
36/// Shared repository configuration used by all git-based publishers
37/// (Homebrew, Scoop, Winget, Krew, Nix). A repository reference.
38#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
39#[serde(default, deny_unknown_fields)]
40pub struct RepositoryConfig {
41    /// Repository owner (GitHub user or organization).
42    pub owner: Option<String>,
43    /// Repository name.
44    pub name: Option<String>,
45    /// Auth token for the repository. Falls back to env-based resolution.
46    pub token: Option<String>,
47    /// Token type: "github" (default), "gitlab", "gitea".
48    pub token_type: Option<String>,
49    /// Branch to push to (default: repo default branch).
50    pub branch: Option<String>,
51    /// Git-specific settings for SSH-based publishing.
52    pub git: Option<GitRepoConfig>,
53    /// Pull request settings for fork-based workflows.
54    pub pull_request: Option<PullRequestConfig>,
55}
56
57/// Git-specific repository settings for SSH-based publishing.
58#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
59#[serde(default, deny_unknown_fields)]
60pub struct GitRepoConfig {
61    /// Git URL (e.g. `ssh://git@github.com/owner/repo.git`).
62    pub url: Option<String>,
63    /// Custom SSH command (e.g. `ssh -i /path/to/key`).
64    pub ssh_command: Option<String>,
65    /// Path to SSH private key file.
66    pub private_key: Option<String>,
67}
68
69/// Pull request configuration for fork-based publisher workflows.
70#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
71#[serde(default, deny_unknown_fields)]
72pub struct PullRequestConfig {
73    /// Enable PR creation instead of direct push.
74    pub enabled: Option<bool>,
75    /// Create PR as draft.
76    pub draft: Option<bool>,
77    /// Body text for the pull request.
78    pub body: Option<String>,
79    /// Target base repository/branch for the PR.
80    pub base: Option<PullRequestBaseConfig>,
81}
82
83/// Target base for pull requests (upstream repo to PR against).
84#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
85#[serde(default, deny_unknown_fields)]
86pub struct PullRequestBaseConfig {
87    /// Owner of the upstream repository to PR against.
88    pub owner: Option<String>,
89    /// Name of the upstream repository to PR against.
90    pub name: Option<String>,
91    /// Base branch of the upstream repository to target with the PR.
92    pub branch: Option<String>,
93}
94
95/// Shared commit author configuration with optional GPG/SSH signing.
96/// Commit-author identity for publisher commits.
97#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
98#[serde(default, deny_unknown_fields)]
99pub struct CommitAuthorConfig {
100    /// Git commit author display name.
101    pub name: Option<String>,
102    /// Git commit author email address.
103    pub email: Option<String>,
104    /// Commit signing configuration.
105    pub signing: Option<CommitSigningConfig>,
106    /// When true, omit the explicit `-c user.name=` / `-c user.email=`
107    /// overrides at commit time and let the running git client use the
108    /// invoking GitHub App's identity (i.e. the `<app-slug>[bot]@users.noreply.github.com`
109    /// account that the GitHub Actions checkout step has already configured
110    /// in the repo's local git config).
111    ///
112    /// The use-github-app-token toggle
113    /// uses the local git identity; the canonical use-case is
114    /// PRs against `homebrew/homebrew-core` / `kubernetes-sigs/krew-index`
115    /// / `microsoft/winget-pkgs` opened from a GitHub App workflow, where
116    /// EasyCLA / DCO / signed-commit policies require the App's identity
117    /// (rather than a per-user bot identity) to land the merge.
118    #[serde(default)]
119    pub use_github_app_token: bool,
120}
121
122impl CommitAuthorConfig {
123    /// Fill in the anodizer default name/email when either field is empty.
124    /// The commit-author defaulting, which
125    /// runs during the Default pass — so validation messages that reference
126    /// commit-author identity see non-empty strings rather than blanks.
127    pub fn normalize_defaults(&mut self) {
128        if self.name.as_deref().is_none_or(str::is_empty) {
129            self.name = Some("anodizer".to_string());
130        }
131        if self.email.as_deref().is_none_or(str::is_empty) {
132            self.email = Some("bot@anodizer.dev".to_string());
133        }
134    }
135}
136
137/// Commit signing configuration (GPG, x509, or SSH).
138#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
139#[serde(default, deny_unknown_fields)]
140pub struct CommitSigningConfig {
141    /// Enable commit signing.
142    pub enabled: Option<bool>,
143    /// Signing key identifier.
144    pub key: Option<String>,
145    /// Signing program (e.g. `gpg`, `gpg2`).
146    pub program: Option<String>,
147    /// Signing format: "openpgp" (default), "x509", or "ssh".
148    pub format: Option<String>,
149}
150
151// ---------------------------------------------------------------------------
152// PublishConfig
153// ---------------------------------------------------------------------------
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
156#[serde(default, deny_unknown_fields)]
157pub struct PublishConfig {
158    /// Publish to crates.io. Presence opts in; use `cargo: { skip: true }` to opt out.
159    pub cargo: Option<CargoPublishConfig>,
160    /// Homebrew formula publishing configuration.
161    pub homebrew: Option<HomebrewConfig>,
162    /// Homebrew Cask publishing configuration (macOS .app bundles).
163    ///
164    /// Uses the unified `HomebrewCaskConfig` which carries all fields from both
165    /// the per-crate cask config and the top-level `homebrew_casks:` config.
166    pub homebrew_cask: Option<HomebrewCaskConfig>,
167    /// Scoop manifest publishing configuration.
168    pub scoop: Option<ScoopConfig>,
169    /// Chocolatey package publishing configuration.
170    pub chocolatey: Option<ChocolateyConfig>,
171    /// WinGet manifest publishing configuration.
172    pub winget: Option<WingetConfig>,
173    /// AUR (Arch User Repository) binary package publishing configuration.
174    pub aur: Option<AurConfig>,
175    /// AUR source package publishing configuration (source-only PKGBUILD, not -bin).
176    pub aur_source: Option<AurSourceConfig>,
177    /// Krew (kubectl plugin manager) manifest publishing configuration.
178    pub krew: Option<KrewConfig>,
179    /// Nix derivation publishing configuration.
180    pub nix: Option<NixConfig>,
181
182    /// Hooks that fire once per FAILED publisher, after rollback has been
183    /// attempted. Each entry is a standard hook (`cmd` / `dir` / `env` /
184    /// `output`); the template surface adds `{{ .Publisher }}`,
185    /// `{{ .Error }}`, `{{ .Version }}`, `{{ .Tag }}`, `{{ .Group }}`
186    /// (Assets/Manager/Submitter), `{{ .Required }}`, and
187    /// `{{ .RolledBack }}` — true if any publisher was rolled back (or
188    /// rollback was attempted and failed) during this run. The same values
189    /// are also exported to the hook process as environment variables:
190    /// `ANODIZER_PUBLISHER`, `ANODIZER_ERROR`, `ANODIZER_VERSION`,
191    /// `ANODIZER_TAG`, `ANODIZER_GROUP`, `ANODIZER_REQUIRED`,
192    /// `ANODIZER_ROLLED_BACK`. A hook's own failure is logged as a warning
193    /// and never changes the release outcome.
194    ///
195    /// Security: the rendered `cmd` string is parsed by `sh -c`, and
196    /// `{{ .Error }}` carries untrusted remote text (HTTP error bodies, git
197    /// stderr) — interpolating it into `cmd` lets crafted error content
198    /// break quoting and execute. Read untrusted values from the env vars
199    /// instead:
200    ///
201    /// ```yaml
202    /// publish:
203    ///   on_error:
204    ///     - cmd: 'notify "anodizer: $ANODIZER_PUBLISHER failed @ $ANODIZER_VERSION: $ANODIZER_ERROR"'
205    /// ```
206    pub on_error: Option<Vec<HookEntry>>,
207}
208
209/// `cargo publish` flag surface.
210///
211/// Presence under `publish:` opts the crate in; use `skip: true` (or a
212/// truthy template) to opt out. There is no `enabled` field — presence is
213/// the on-switch.
214///
215/// Fields intentionally omitted because anodizer owns them:
216/// - `--package` / `--workspace` / `--exclude`: the top-level `crates[]`
217///   axis owns crate selection.
218/// - `--dry-run`: pipeline-level CLI ergonomics (`anodizer release --dry-run`).
219/// - `-v` / `-q` / `--color`: CLI ergonomics, not config.
220/// - `--config` / `-Z`: cargo CLI escape hatches; out of scope.
221#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
222#[serde(default, deny_unknown_fields)]
223pub struct CargoPublishConfig {
224    // ----- Registry selection -----
225    /// Alternate registry name from `~/.cargo/config.toml` (`--registry`).
226    pub registry: Option<String>,
227    /// Registry index URL (`--index`).
228    pub index: Option<String>,
229    /// Seconds to wait for the crates.io sparse index to publish a crate
230    /// before its dependents are pushed (anodizer-original — no `cargo
231    /// publish` equivalent).
232    pub index_timeout: Option<u64>,
233    /// Pre-publish gate that polls crates.io for every workspace-internal
234    /// dep of the crate being published, blocking until each is queryable
235    /// at its expected version. Required for multi-tag-multi-crate
236    /// workspaces (e.g. cfgd) where per-crate tags fire independent
237    /// `Release.yml` runs that would otherwise race the sparse-index
238    /// propagation.
239    ///
240    /// Single-crate workspaces and lockstep-bumped monorepos (anodizer
241    /// itself) leave this off — there is no inter-tag race to gate on.
242    pub wait_for_workspace_deps: Option<WaitForWorkspaceDepsConfig>,
243
244    // ----- Verify / dirty -----
245    /// Skip the local `cargo build --release` verification step (`--no-verify`).
246    pub no_verify: Option<bool>,
247    /// Allow publishing with an uncommitted working tree (`--allow-dirty`).
248    pub allow_dirty: Option<bool>,
249
250    // ----- Feature selection -----
251    /// Crate features to activate (`--features`).
252    pub features: Option<Vec<String>>,
253    /// Activate every feature, including `default` (`--all-features`).
254    pub all_features: Option<bool>,
255    /// Disable the `default` feature set (`--no-default-features`).
256    pub no_default_features: Option<bool>,
257
258    // ----- Compilation -----
259    /// Build target triple for the verification step (`--target`).
260    pub target: Option<String>,
261    /// Override the cargo target directory (`--target-dir`).
262    pub target_dir: Option<PathBuf>,
263    /// Number of parallel compile jobs for verification (`--jobs`).
264    pub jobs: Option<u32>,
265    /// Continue on errors when verifying multiple crates (`--keep-going`).
266    pub keep_going: Option<bool>,
267
268    // ----- Manifest -----
269    /// Path to the crate's `Cargo.toml` (`--manifest-path`).
270    pub manifest_path: Option<PathBuf>,
271    /// Require an up-to-date `Cargo.lock` matching the resolver (`--locked`).
272    pub locked: Option<bool>,
273    /// Require offline resolution; never hit the network (`--offline`).
274    pub offline: Option<bool>,
275    /// Both `--locked` and `--offline` (`--frozen`).
276    pub frozen: Option<bool>,
277
278    // ----- Peer-publisher pattern -----
279    /// Skip this publisher; supports template strings or bool.
280    /// Truthy renders disable the publisher without removing the block.
281    #[serde(default, deserialize_with = "deserialize_string_or_bool_opt")]
282    pub skip: Option<StringOrBool>,
283    /// Override whether this publisher failing should fail the overall release.
284    ///
285    /// Default: `true` — a failure here aborts the release.
286    /// Set to `false` to log failures but continue.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub required: Option<bool>,
289    /// Template-conditional gate: when the rendered result is falsy
290    /// (`"false"` / `"0"` / `"no"` / empty), the cargo publisher is
291    /// skipped. Render failure hard-errors. Config key: the publisher's `if:`.
292    #[serde(rename = "if")]
293    pub if_condition: Option<String>,
294    /// When `true`, a triggered rollback leaves this publisher's work in
295    /// place rather than attempting to undo it. Default `false`.
296    pub retain_on_rollback: Option<bool>,
297}
298
299/// Pre-publish polling gate for `cargo publish`. When `enabled`, the cargo
300/// publisher reads its crate's manifest, identifies every dep that points
301/// at another crate in the same anodize workspace, and polls
302/// `https://index.crates.io/<prefix>/<name>` until each `(name, version)`
303/// pair is queryable. Only then does `cargo publish` run.
304///
305/// Default: disabled. Anodize's own workspaces publish lockstep with one
306/// tag; this feature only kicks in for multi-tag-multi-crate workspaces
307/// like cfgd where downstream crates can otherwise race the sparse-index
308/// propagation of their upstream deps.
309///
310/// Complementary to `cargo.index_timeout`: this gate runs BEFORE publish
311/// (waits for *upstream* deps to land), while `index_timeout` runs AFTER
312/// publish (waits for the *just-published* crate to land before the next
313/// dependent in the same run starts).
314///
315/// ```yaml
316/// publish:
317///   cargo:
318///     wait_for_workspace_deps:
319///       enabled: true
320///       poll_interval: 5s
321///       max_wait: 5m
322/// ```
323#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
324#[serde(default, deny_unknown_fields)]
325pub struct WaitForWorkspaceDepsConfig {
326    /// Master switch. Default `false` preserves today's behavior for
327    /// single-crate workspaces and lockstep monorepos.
328    pub enabled: Option<bool>,
329    /// Time between successive index probes. Humantime-style string
330    /// (e.g. `"5s"`, `"500ms"`, `"1m"`). Default: `"5s"`.
331    pub poll_interval: Option<HumanDuration>,
332    /// Hard ceiling on the total wait. The publisher bails with a clear
333    /// error once `max_wait` elapses without every dep appearing.
334    /// Humantime-style string (e.g. `"5m"`, `"30s"`). Default: `"5m"`.
335    pub max_wait: Option<HumanDuration>,
336}
337
338impl WaitForWorkspaceDepsConfig {
339    /// Default poll interval — short enough to feel snappy when the
340    /// upstream's publish lands quickly, long enough that a 5-minute
341    /// wait window costs at most 60 HTTP probes.
342    pub const DEFAULT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
343
344    /// Default ceiling — five minutes matches the historical
345    /// `index_timeout` default and covers the worst-case sparse-index
346    /// CDN propagation window observed in practice.
347    pub const DEFAULT_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
348
349    /// Resolve `enabled`, defaulting to `false` (master switch off).
350    pub fn resolved_enabled(&self) -> bool {
351        self.enabled.unwrap_or(false)
352    }
353
354    /// Resolve `poll_interval`, falling back to
355    /// [`Self::DEFAULT_POLL_INTERVAL`].
356    pub fn resolved_poll_interval(&self) -> std::time::Duration {
357        self.poll_interval
358            .map(|d| d.duration())
359            .unwrap_or(Self::DEFAULT_POLL_INTERVAL)
360    }
361
362    /// Resolve `max_wait`, falling back to [`Self::DEFAULT_MAX_WAIT`].
363    pub fn resolved_max_wait(&self) -> std::time::Duration {
364        self.max_wait
365            .map(|d| d.duration())
366            .unwrap_or(Self::DEFAULT_MAX_WAIT)
367    }
368}