anodizer 0.26.0

A Rust-native release automation tool inspired by GoReleaser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
pub mod rollback;

mod bump_detect;
mod crate_info;
mod per_crate;
mod repo_shape;
mod run;
mod single_crate;
mod version_plan;
mod workspace_bump;

pub(crate) use bump_detect::*;
pub(crate) use crate_info::*;
pub(crate) use per_crate::*;
pub(crate) use repo_shape::*;
pub(crate) use run::run;
pub(crate) use single_crate::*;
pub(crate) use version_plan::*;
pub(crate) use workspace_bump::*;

#[cfg(test)]
mod tests;

use anodizer_core::config::{Config, CrateConfig, GitConfig, TagConfig};
use anodizer_core::git;
use anodizer_core::hooks::{HookRunContext, run_hooks};
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::template::TemplateVars;
use anodizer_core::version_files::FileRewrite;
use anyhow::{Result, bail};
use regex::Regex;
use std::path::{Path, PathBuf};

use crate::commands::bump::cargo_edit::{
    Propagated, WorkspaceInfo, apply_plan, heal_dep_floors, load_workspace, member_version,
};
use crate::commands::bump::plan::{BumpLevel, PlanRow};
use crate::commands::changelog_sync::{
    ChangelogRouting, ChangelogTarget, render_and_stage_changelogs, resolve_changelog_enabled,
};
use crate::commands::version_files_resolve::{enrolled_units, resolve_version_files};

/// Options of `anodizer tag`: the version override, the crate scope, the
/// signing and push behaviour, and the write/preview mode.
pub(crate) struct TagOpts {
    pub dry_run: bool,
    pub custom_tag: Option<String>,
    /// Explicit `--version`: tag exactly this version, bypassing autotag
    /// derivation and the Cargo.toml-ahead guard. Accepts `1.2.3` or `v1.2.3`;
    /// normalized + validated in [`run()`].
    pub version_override: Option<String>,
    pub default_bump: Option<String>,
    /// When set, select a specific crate's tag_template for tagging.
    pub crate_name: Option<String>,
    /// Push the version-sync bump commit to the release branch atomically with the tag.
    pub push: bool,
    /// Do not push anything; the tag and the version-sync bump commit both stay local.
    pub no_push: bool,
    /// Push the tag(s) but NOT the version-sync bump commit. The explicit
    /// opt-in for the deferred-branch CI pattern: the release pipeline pushes
    /// the tag to trigger publishing and fast-forwards the branch onto the
    /// bump commit only after publish succeeds, so a failed release advances
    /// nothing. The branch MUST be advanced separately or the remote tag
    /// permanently references a commit missing from every branch.
    pub push_tags_only: bool,
    /// Create the version tag as a signed annotated tag (`git tag -s`). The
    /// signing key/method come from the user's git config (`user.signingkey`,
    /// `gpg.format`). Overrides `tag.sign = false`.
    pub sign: bool,
    /// Create the version tag unsigned (`git tag -a`), overriding
    /// `tag.sign = true`. Wins over `--sign` and config, mirroring `--no-push`.
    pub no_sign: bool,
    /// Remote to push to; defaults to `origin` when unset.
    pub push_remote: Option<String>,
    /// Preview the `git push` commands `--push` would run, without executing.
    pub push_dry_run: bool,
    /// Refresh `CHANGELOG.md` as part of this tag. Opt-in: the refresh runs only
    /// when set AND a `changelog:` config block is present and not skipped.
    pub changelog: bool,
    pub config_override: Option<std::path::PathBuf>,
    pub verbose: bool,
    pub debug: bool,
    pub quiet: bool,
    pub strict: bool,
}

/// Resolve whether this run pushes at all (the bump commit + tag(s),
/// atomically). A run either pushes branch and tags together or pushes
/// nothing — a remote tag referencing an unpushed bump commit (an orphan
/// tag) is not a representable outcome.
///
/// Every dispatch shape — single, lockstep, `--crate`, and per-crate
/// auto-dispatch — shares one default: **fully local**. Cutting a tag never
/// touches the remote unless a push is explicitly requested, mirroring
/// `git tag`. `--no-push` always wins (redundant with the default); then an
/// explicit `--push` or `tag.push = true` selects the atomic push; otherwise
/// nothing is pushed.
fn resolve_effective_push(opts: &TagOpts, config_push: Option<bool>) -> bool {
    if opts.no_push {
        false
    } else {
        opts.push || config_push == Some(true)
    }
}

/// Resolve whether the version tag is created signed (`git tag -s`) or unsigned
/// (`git tag -a`). Workspace-global: the resolved value applies to every tag
/// this run cuts in single-crate, lockstep, and per-crate modes. Precedence
/// mirrors `resolve_effective_push`: `--no-sign` always wins, then `--sign` or
/// `tag.sign = true` selects a signed tag; otherwise the tag is unsigned.
fn resolve_effective_sign(opts: &TagOpts, config_sign: Option<bool>) -> bool {
    if opts.no_sign {
        false
    } else {
        opts.sign || config_sign == Some(true)
    }
}

/// Resolved tag configuration with defaults applied.
#[derive(Clone)]
pub(crate) struct ResolvedConfig {
    default_bump: String,
    bump_minor_pre_major: bool,
    bump_patch_for_minor_pre_major: bool,
    tag_prefix: String,
    release_branches: Vec<String>,
    custom_tag: Option<String>,
    tag_context: String,
    branch_history: String,
    initial_version: String,
    prerelease: bool,
    prerelease_suffix: String,
    force_without_changes: bool,
    force_without_changes_pre: bool,
    major_string_token: String,
    minor_string_token: String,
    patch_string_token: String,
    none_string_token: String,
    git_api_tagging: bool,
    skip_ci_on_bump: bool,
}

impl ResolvedConfig {
    fn from_config(config: &Config, opts: &TagOpts) -> Self {
        let default_tag = TagConfig::default();
        let cfg = config.tag.as_ref().unwrap_or(&default_tag);
        ResolvedConfig {
            default_bump: opts
                .default_bump
                .clone()
                .or_else(|| cfg.default_bump.clone())
                .unwrap_or_else(|| "none".to_string()),
            bump_minor_pre_major: cfg.bump_minor_pre_major.unwrap_or(false),
            bump_patch_for_minor_pre_major: cfg.bump_patch_for_minor_pre_major.unwrap_or(false),
            tag_prefix: config.repo_tag_prefix().to_string(),
            release_branches: cfg.release_branches.clone().unwrap_or_default(),
            custom_tag: opts.custom_tag.clone().or_else(|| cfg.custom_tag.clone()),
            tag_context: cfg
                .tag_context
                .clone()
                .unwrap_or_else(|| "repo".to_string()),
            branch_history: cfg
                .branch_history
                .clone()
                .unwrap_or_else(|| "compare".to_string()),
            initial_version: cfg
                .initial_version
                .clone()
                .unwrap_or_else(|| "0.0.0".to_string()),
            prerelease: cfg.prerelease.unwrap_or(false),
            prerelease_suffix: cfg
                .prerelease_suffix
                .clone()
                .unwrap_or_else(|| "beta".to_string()),
            force_without_changes: cfg.force_without_changes.unwrap_or(false),
            force_without_changes_pre: cfg.force_without_changes_pre.unwrap_or(false),
            major_string_token: cfg
                .major_string_token
                .clone()
                .unwrap_or_else(|| "#major".to_string()),
            minor_string_token: cfg
                .minor_string_token
                .clone()
                .unwrap_or_else(|| "#minor".to_string()),
            patch_string_token: cfg
                .patch_string_token
                .clone()
                .unwrap_or_else(|| "#patch".to_string()),
            none_string_token: cfg
                .none_string_token
                .clone()
                .unwrap_or_else(|| "#none".to_string()),
            git_api_tagging: cfg.git_api_tagging.unwrap_or(false),
            skip_ci_on_bump: cfg.skip_ci_on_bump.unwrap_or(false),
        }
    }
}

/// Warn about a stale `Cargo.lock` after a version writeback.
///
/// Staleness is warn-and-continue by design (a missing/broken `cargo` on PATH
/// must not block tagging), but the stale lockfile WILL break the release
/// later — publish/determinism reject a lockfile that disagrees with the
/// bumped manifests — so the warn names that consequence and the remedy.
fn warn_cargo_lock_stale(log: &StageLogger, cause: &str) {
    log.warn(&format!(
        "{cause}; Cargo.lock is now stale relative to the bumped Cargo.toml, and \
         `release` (publish / determinism) will fail on it later — run \
         `cargo update --workspace` and fold Cargo.lock into the bump commit \
         before releasing"
    ));
}

/// Refresh `Cargo.lock` after a version writeback and report whether the repo
/// has one to stage.
///
/// The gate is `<root>/Cargo.lock` exists: a repo that commits no lockfile has
/// nothing to go stale, and `cargo update --workspace` there would CREATE a
/// lockfile the release never asked for and the bump commit would then carry.
/// Every bump path uses that one gate — three of them used to run the refresh
/// unconditionally and warn about a lockfile that does not exist.
///
/// Failure is warn-and-continue by design: a missing or broken `cargo` on PATH
/// must not block tagging, and [`warn_cargo_lock_stale`] names the consequence.
pub(crate) fn refresh_cargo_lock(root: &Path, log: &StageLogger) -> bool {
    if !root.join("Cargo.lock").is_file() {
        return false;
    }
    match anodizer_core::cargo_lock::cargo_update_workspace(Some(root)) {
        Ok(true) => {}
        Ok(false) => warn_cargo_lock_stale(
            log,
            "`cargo update --workspace` exited non-zero after version sync",
        ),
        Err(e) => warn_cargo_lock_stale(
            log,
            &format!("could not spawn `cargo update --workspace` ({e})"),
        ),
    }
    true
}

/// `[skip ci]` suffix appended to a bump-commit subject, or empty when
/// `skip_ci_on_bump` is off (the default). Returned with a leading space so
/// callers can append it directly after the subject body.
fn skip_ci_suffix(skip_ci_on_bump: bool) -> &'static str {
    if skip_ci_on_bump { " [skip ci]" } else { "" }
}

/// Workspace-wide version bump for the "single tag, many crates" layout.
///
/// Rewrites `[workspace.package].version`, every member manifest that doesn't
/// inherit, and every `[workspace.dependencies].*.version` / sibling
/// `[dependencies].*.version` pin for bumped crates. Then regenerates
/// Cargo.lock and creates a single `chore(release): bump workspace → X`
/// commit covering the edits.
///
/// Returns `true` when a bump commit was actually created, `false` when the
/// workspace was already at the target (or in `dry_run`).
/// The shared old→new bump and every enrolled unit it rewrites, passed through
/// to the workspace bump so `version_files` rewriting rides in the same commit.
/// `old` is `None` when there is no previous tag to rewrite from.
///
/// A lockstep workspace moves every declared crate at once, so the units are
/// whatever [`enrolled_units`] resolves for the config — the same set
/// `check version-files` validates — each rewritten at the shared old→new.
pub(crate) struct VersionFilesBump<'a> {
    old: Option<&'a str>,
    units: &'a [crate::commands::version_files_resolve::EnrolledUnit],
}

/// Lockstep changelog-refresh inputs for [`apply_workspace_bump`]. The shared
/// workspace tag bounds every member's rendered commit range, so a single
/// `from_tag` applies to all members, and the single shared `full_tag` keys the
/// aggregate root section.
pub(crate) struct ChangelogBump<'a> {
    enabled: bool,
    from_tag: Option<&'a str>,
    /// The shared workspace tag for this release (e.g. `v1.2.0`).
    full_tag: &'a str,
    /// The resolved routing for the lockstep changelog destinations.
    routing: &'a ChangelogRouting<'a>,
}

/// The repo-committed edits [`apply_workspace_bump`] folds into the bump commit
/// beyond the manifests: `version_files` rewrites and the `CHANGELOG.md`
/// refresh, grouped so the workspace bump takes one edits carrier.
pub(crate) struct WorkspaceBumpEdits<'a> {
    vf: VersionFilesBump<'a>,
    cl: ChangelogBump<'a>,
}

/// Resolve the config file path from CLI overrides or cwd-relative
/// auto-detection.
///
/// Used only for the bootstrap workspace-root discovery, where the root is not
/// yet known. Once the root is known, prefer `resolve_config_path_at` so a
/// subdirectory invocation still finds the repo-root config.
fn resolve_config_path(opts: &TagOpts) -> Option<std::path::PathBuf> {
    opts.config_override
        .as_deref()
        .filter(|p| p.exists())
        .map(|p| p.to_path_buf())
        .or_else(|| crate::pipeline::find_config(None).ok())
}

/// Load the anodizer config against a known workspace `root`.
///
/// A `--config` override always wins. Otherwise the config is searched at the
/// discovered workspace root (via [`crate::pipeline::load_repo_config`]) rather
/// than the process cwd, so `tag` invoked from a subdirectory still loads the
/// repo-root `.anodizer.yaml` and its `version_files` enrollment. Returns `None`
/// when no config is found or it fails to parse.
fn load_config_at(opts: &TagOpts, root: &Path) -> Result<anodizer_core::config::Config> {
    match opts.config_override.as_deref() {
        Some(p) => {
            // An explicitly-named `--config` path that doesn't exist is an
            // operator error, not a silent fall-through to the repo config
            // (which would read a DIFFERENT config than the one named).
            if !p.exists() {
                anyhow::bail!("--config path does not exist: {}", p.display());
            }
            crate::pipeline::load_config(p)
        }
        // `load_repo_config` returns `Ok(Config::default())` for a repo with a
        // Cargo.toml but no `.anodizer.yaml` (the Cargo.toml fallback), and
        // only `Err`s when the config file exists but fails to read/parse (or
        // neither a config nor a Cargo.toml is present). Propagating that
        // `Err` — instead of the old `.ok()` that flattened it to a silent
        // default — is what stops a malformed `.anodizer.yaml` from cutting
        // the wrong tag (lost `default_bump` / changelog / version_files).
        None => crate::pipeline::load_repo_config(root),
    }
}

/// Result of per-crate tag computation for one group.
pub(crate) struct GroupTagResult {
    /// Crate names in this group.
    crate_names: Vec<String>,
    /// New tags to create (one per crate in the group).
    new_tags: Vec<(String, String)>,
    /// Bump commit paths that need version updates.
    version_updates: Vec<(String, String)>,
    /// Bare previous version this group bumps FROM, or `None` when the group
    /// has no previous tag (nothing to rewrite version_files from).
    old_version: Option<String>,
    /// The group's previous tag ref (e.g. `core-v0.1.0`), or `None` on a first
    /// tag — bounds the rendered changelog commit range per crate.
    prev_tag: Option<String>,
    /// Effective `version_files` enrollment per crate, parallel to
    /// `version_updates` (same crate order within the group).
    crate_version_files: Vec<Vec<anodizer_core::config::VersionFileEntry>>,
}

/// Execute per-crate / hybrid-workspace tagging when no `--crate` is given
/// and the repository has per-crate versions (not lockstep).
///
/// Runs change detection → computes new tags for changed groups → writes one
/// bump commit for all changed crates → creates all tags → pushes commit and
/// tags atomically → emits `anodizer-output crates=[...]` line.
/// Per-run controls threaded into the per-crate tagging path: the push-target
/// remote + resolved `tag.push` config value (CLI flags on [`TagOpts`] override
/// it), and whether the `CHANGELOG.md` refresh is enabled for this run.
#[derive(Debug, Clone, Copy)]
pub(crate) struct PushControls<'a> {
    remote: &'a str,
    config_push: Option<bool>,
    /// Resolved signed-tag selection (`--sign`/`--no-sign`/`tag.sign`), threaded
    /// in so per-crate tags are signed identically to the single/lockstep path.
    sign: bool,
    changelog_enabled: bool,
    /// `tag_pre_hooks` / `tag_post_hooks`, threaded in so the per-crate path
    /// honors the same hook config as the single/lockstep `create_tag` closure.
    pre_hooks: &'a [anodizer_core::config::HookEntry],
    post_hooks: &'a [anodizer_core::config::HookEntry],
    /// Tag names present on `remote` (one ls-remote per invocation, fetched by
    /// [`run()`]); `None` when there is no remote or the fetch failed (local
    /// fallback). Threaded into previous-tag resolution so a remotely-deleted
    /// tag that survives in this clone never counts as "previous".
    remote_tags: Option<&'a std::collections::HashSet<String>>,
}

/// The per-crate engine's dispatched unit: the lockstep groups to tag plus
/// whether they are a single `FlatAggregate` (shared-prefix flat `crates:`
/// list). The flag drives the one-flat-section changelog collapse; it is
/// resolved once by [`detect_repo_shape`] so the collapse decision is never
/// re-derived from prefixes here.
pub(crate) struct PerCrateDispatch {
    groups: Vec<Vec<CrateConfig>>,
    is_flat_aggregate: bool,
    /// The config-derived workspace root threaded from `run` so the per-crate
    /// engine's git ops and cross-crate scans resolve the same root from a
    /// subdirectory as from the repo root.
    workspace_root: PathBuf,
}

/// Info extracted from a crate's config for path-scoped tagging.
pub(crate) struct CrateTagInfo {
    tag_prefix: String,
    path: String,
    version_sync: bool,
    /// Effective `version_files` enrollment for this crate (per-crate /
    /// defaults list, else the top-level `Config.version_files`).
    version_files: Vec<anodizer_core::config::VersionFileEntry>,
}