anodizer 0.28.1

A Rust-native release automation tool inspired by GoReleaser
Documentation
use anodizer_core::config::Config;
use anodizer_core::context::Context;
use anodizer_core::git;
use anodizer_core::log::StageLogger;

/// The crate whose tag family the run derives its tag from: the first crate in
/// DECLARATION order that this run covers.
///
/// Declaration order is what makes the answer one answer: `--crate b --crate a`
/// and `--crate a --crate b` name the same set, so they must seed the same
/// family whichever way they were typed.
/// An empty selection covers every crate, which makes the first declared crate
/// the answer there too.
pub(crate) fn first_covered_crate<'a>(
    ctx: &Context,
    config: &'a Config,
) -> Option<&'a anodizer_core::config::CrateConfig> {
    let selected = &ctx.options.selected_crates;
    config
        .crate_universe()
        .into_iter()
        .find(|c| anodizer_core::config::crate_is_selected(selected, &c.name))
}

/// Resolve the current-tag override from the env-var precedence chain.
///
/// Precedence (first non-empty wins):
///   1. `ANODIZER_CURRENT_TAG`
///   2. `GORELEASER_CURRENT_TAG` (compat alias)
///   3. `GITHUB_REF_NAME`, but only when `GITHUB_REF_TYPE == "tag"` — GitHub
///      Actions exposes the triggering tag here on a tag push, while a branch
///      push puts the branch name in the same var (which is not a tag).
pub(super) fn resolve_tag_override(
    anodizer_current_tag: Option<String>,
    goreleaser_current_tag: Option<String>,
    github_ref_type: Option<String>,
    github_ref_name: Option<String>,
) -> Option<String> {
    anodizer_current_tag
        .filter(|s| !s.is_empty())
        .or_else(|| goreleaser_current_tag.filter(|s| !s.is_empty()))
        .or_else(|| {
            let is_tag = github_ref_type.as_deref().filter(|s| *s == "tag").is_some();
            if is_tag {
                github_ref_name.filter(|s| !s.is_empty())
            } else {
                None
            }
        })
}

/// The newest tag, by semver, across the tag families of every crate this run
/// covers (the explicit selection when there is one, else the whole crate
/// universe), together with the template of the family it came from.
///
/// Families are deduped by template, so a lockstep workspace performs exactly
/// one probe and a single-crate config is unchanged. Returns `None` when no
/// covered family has a tag.
///
/// The template travels with the tag because the previous-tag look-back must
/// search the SAME family: a base taken from `v0.10.0` with a look-back
/// scoped to `crd-v` would pair the version with a previous tag from another
/// track, and the compare link and changelog window are both cut from that
/// pair.
fn newest_tag_across_crates(
    ctx: &Context,
    config: &Config,
    monorepo_prefix: Option<&str>,
    log: &StageLogger,
) -> Option<(String, String)> {
    let selected = &ctx.options.selected_crates;
    let covered = config.selected_crates(selected);

    let mut seen_templates: Vec<String> = Vec::new();
    let mut best: Option<(git::SemVer, String, String)> = None;
    for crate_cfg in covered {
        let template = crate_cfg.tag_family_template();
        if seen_templates.contains(&template) {
            continue;
        }
        seen_templates.push(template.clone());
        let found = match git::find_latest_tag_matching_with_prefix(
            &template,
            config.git.as_ref(),
            Some(ctx.template_vars()),
            monorepo_prefix,
        ) {
            Ok(found) => found,
            Err(e) => {
                log.warn(&format!("error finding tags matching template: {e}"));
                continue;
            }
        };
        let Some(tag) = found else { continue };
        let stripped = match monorepo_prefix {
            Some(prefix) => git::strip_monorepo_prefix(&tag, prefix),
            None => tag.as_str(),
        };
        let Ok(semver) = git::parse_semver_tag(stripped) else {
            continue;
        };
        if best.as_ref().is_none_or(|(bv, _, _)| semver > *bv) {
            best = Some((semver, tag, template));
        }
    }
    if let Some((_, ref tag, _)) = best
        && seen_templates.len() > 1
    {
        log.verbose(&format!(
            "synthesized version base '{tag}' — newest across {} tag families",
            seen_templates.len()
        ));
    }
    best.map(|(_, tag, template)| (tag, template))
}

/// Resolve tag and populate git variables on the context.
///
/// Finds the first selected crate (or the first crate in config), looks up
/// the latest tag matching its `tag_template`, detects git info, and
/// populates the context's template variables.
pub fn resolve_git_context(
    ctx: &mut Context,
    config: &Config,
    log: &StageLogger,
) -> anyhow::Result<()> {
    // Warn on shallow clones where tag discovery may be incomplete.
    if git::is_shallow_clone() {
        log.warn(
            "shallow clone detected; tag discovery may be incomplete. \
             Use `git fetch --unshallow` in CI.",
        );
    }

    // Allow env var overrides for tag discovery. Anodizer-native var wins;
    // a compat alias is checked as a fallback so CI jobs migrating
    // pick up their existing env vars without rewiring. As a
    // last resort, GitHub Actions exposes the triggering tag as GITHUB_REF_NAME
    // when GITHUB_REF_TYPE=tag — use that so workflows that didn't explicitly
    // export ANODIZER_CURRENT_TAG (e.g. `Release.yml` jobs dispatched by a tag
    // push) still resolve the correct tag instead of falling through to
    // per-crate-template latest-tag scanning (which can mis-resolve when the
    // triggering tag's prefix doesn't match the first crate's tag_template).
    let anodizer_current_tag = ctx.env_var("ANODIZER_CURRENT_TAG");
    let goreleaser_current_tag = ctx.env_var("GORELEASER_CURRENT_TAG");
    let github_ref_type = ctx.env_var("GITHUB_REF_TYPE");
    let github_ref_name = ctx.env_var("GITHUB_REF_NAME");
    tracing::debug!(
        anodizer_current_tag = ?anodizer_current_tag,
        goreleaser_current_tag = ?goreleaser_current_tag,
        github_ref_type = ?github_ref_type,
        github_ref_name = ?github_ref_name,
        "tag_override resolution: env var snapshot"
    );
    let tag_override = resolve_tag_override(
        anodizer_current_tag,
        goreleaser_current_tag,
        github_ref_type,
        github_ref_name,
    );

    // The universe fallback catches a selection that names no declared crate;
    // without it `Version` is never populated in the template context for a
    // snapshot / dry-run in a workspace-only config, breaking every template
    // that references it.
    let first_crate =
        first_covered_crate(ctx, config).or_else(|| config.crate_universe().into_iter().next());

    if let Some(crate_cfg) = first_crate {
        // The crate's own tag family, resolved once. A nightly / snapshot base
        // may come from a SIBLING family instead (see below), in which case
        // that family — not this one — bounds the previous-tag look-back.
        let crate_tag_template = crate_cfg.tag_family_template();
        let mut base_tag_template = crate_tag_template.clone();
        // An override is the operator NAMING the version this run targets;
        // everything else is an inference from what the repository happens to
        // hold. Gates that ask "is the resolved version the one being
        // published?" answer differently for the two, so the distinction
        // travels on the resolved `GitInfo` instead of being re-derived from
        // the environment at each such gate.
        let tag_source = if tag_override.is_some() {
            git::TagSource::Declared
        } else {
            git::TagSource::Inferred
        };
        let tag = if let Some(ref override_tag) = tag_override {
            log.verbose(&format!(
                "using ANODIZER_CURRENT_TAG override '{}'",
                override_tag
            ));
            override_tag.clone()
        } else {
            let monorepo_prefix = config.monorepo_tag_prefix();
            // A synthesized version (`--nightly` / `--snapshot`) is issued FROM
            // a base rather than read off a tag at HEAD, so the base must not
            // depend on which crate is declared first: a multi-track workspace
            // whose first crate lags its siblings would stamp every track with
            // the laggard's version. Take the newest tag across the covered
            // crates' families instead — order-independent, and never older
            // than any track's own last release. A single-crate or lockstep
            // workspace has one family, so the answer is unchanged. The
            // standalone preflight plans its version from the same base.
            let latest_tag = if ctx.is_nightly() || ctx.is_snapshot() || ctx.options.observe {
                newest_tag_across_crates(ctx, config, monorepo_prefix, log).map(|(tag, tmpl)| {
                    base_tag_template = tmpl;
                    tag
                })
            } else {
                match git::find_latest_tag_matching_with_prefix(
                    &crate_tag_template,
                    config.git.as_ref(),
                    Some(ctx.template_vars()),
                    monorepo_prefix,
                ) {
                    Ok(found) => found,
                    Err(e) => {
                        log.warn(&format!("error finding tags matching template: {e}"));
                        None
                    }
                }
            };
            match latest_tag {
                Some(t) => t,
                None => {
                    if ctx.options.snapshot || ctx.options.nightly {
                        let mode = if ctx.options.nightly {
                            "nightly"
                        } else {
                            "snapshot"
                        };
                        log.warn(&format!(
                            "no git tags found, defaulting to v0.0.0 ({mode} mode)."
                        ));
                        "v0.0.0".to_string()
                    } else if ctx.options.dry_run {
                        log.warn("no git tags found, defaulting to v0.0.0 (dry-run mode).");
                        "v0.0.0".to_string()
                    } else if ctx.options.observe {
                        log.verbose("no git tags found; the report targets the first version");
                        "v0.0.0".to_string()
                    } else if ctx.options.notify {
                        // A notification must not be blocked by the absence of a
                        // tag; the synthetic v0.0.0 lets any `{{ Tag }}` ref render
                        // (raw on_error messages skip rendering entirely).
                        "v0.0.0".to_string()
                    } else {
                        anyhow::bail!("no git tag found; create a tag or use --snapshot");
                    }
                }
            }
        };

        // Validate HEAD points at the tag.
        // Skip this check for the synthetic v0.0.0 tag since it doesn't exist in git.
        // The standalone `changelog` preview also skips it: an inspection tool
        // must render a tag's window without requiring the operator to check
        // that tag out (the release pipeline never sets `changelog_preview`).
        let is_synthetic_tag = tag == "v0.0.0" && tag_override.is_none();
        if !is_synthetic_tag
            && let Ok(false) = git::tag_points_at_head(&tag)
            && !ctx.options.snapshot
            && !ctx.options.nightly
            && !ctx.options.changelog_preview
            && !ctx.options.observe
            && !ctx.options.notify
        {
            let head = git::get_short_commit().unwrap_or_else(|_| "unknown".to_string());
            anyhow::bail!(
                "tag {} does not point at HEAD ({}). Check out the tag or use --snapshot to skip this check.",
                tag,
                head
            );
        }

        match git::detect_git_info(&tag, ctx.skip_validate()) {
            Ok(mut git_info) => {
                git_info.tag_source = tag_source;
                // Validate dirty working tree: error in non-snapshot/non-dry-run mode,
                // a dirty-tree check. The standalone `changelog` preview skips
                // it too — a local inspection must not require a clean tree.
                if git_info.dirty
                    && !ctx.options.snapshot
                    && !ctx.options.nightly
                    && !ctx.options.changelog_preview
                    && !ctx.options.observe
                    && !ctx.options.notify
                {
                    if ctx.options.dry_run {
                        log.warn("git is in a dirty state; run `git status` to see what changed.");
                    } else {
                        anyhow::bail!(
                            "git is in a dirty state; run `git status` to see what changed. \
                             Use --snapshot to force."
                        );
                    }
                }

                // Allow ANODIZER_PREVIOUS_TAG (or the compat
                // GORELEASER_PREVIOUS_TAG) env override for the previous tag.
                let prev_override = ctx
                    .env_var("ANODIZER_PREVIOUS_TAG")
                    .filter(|s| !s.is_empty())
                    .or_else(|| {
                        ctx.env_var("GORELEASER_PREVIOUS_TAG")
                            .filter(|s| !s.is_empty())
                    });
                if let Some(prev_override) = prev_override {
                    log.verbose(&format!(
                        "using ANODIZER_PREVIOUS_TAG override '{}'",
                        prev_override
                    ));
                    git_info.previous_tag = Some(prev_override);
                } else {
                    // Scope the look-back to the family the resolved tag
                    // actually belongs to (e.g. `v` for cfgd, `csi-v` for
                    // cfgd-csi) so monorepo-style workspaces don't bleed prior
                    // tags across crates. Without this, `git describe --tags`
                    // returns the most recent tag of ANY crate — e.g. `cfgd:
                    // csi-v0.3.4 -> 0.3.5` ends up in the nix/homebrew commit
                    // message because csi was the most recently tagged sibling.
                    git_info.previous_tag = git::find_previous_tag_in_family(
                        &tag,
                        &base_tag_template,
                        config.git.as_ref(),
                        Some(ctx.template_vars()),
                        config.monorepo_tag_prefix(),
                        &config.sibling_tag_families_of(&base_tag_template),
                    )
                    .ok()
                    .flatten();
                }
                ctx.git_info = Some(git_info);
                ctx.populate_git_vars();
            }
            Err(e) => {
                // snapshot/nightly tolerate a tagless or HEADless repo (defaults
                // stand in); notify joins them — a notification side-channel must
                // not fail because git info can't be detected (e.g. a release that
                // never reached a commit, or an on_error hook in a fresh repo).
                let lenient_mode = if ctx.options.nightly {
                    Some("nightly")
                } else if ctx.options.snapshot {
                    Some("snapshot")
                } else if ctx.options.notify {
                    Some("notify")
                } else if ctx.options.observe {
                    Some("preflight")
                } else {
                    None
                };
                if let Some(mode) = lenient_mode {
                    log.warn(&format!(
                        "could not detect git info in {mode} mode, using defaults: {e}"
                    ));
                    ctx.git_info = Some(git::GitInfo {
                        tag: tag.clone(),
                        tag_source,
                        commit: "none".to_string(),
                        short_commit: "none".to_string(),
                        branch: "none".to_string(),
                        dirty: true,
                        semver: git::SemVer {
                            major: 0,
                            minor: 0,
                            patch: 0,
                            prerelease: None,
                            build_metadata: None,
                        },
                        commit_date: String::new(),
                        commit_timestamp: String::new(),
                        previous_tag: None,
                        remote_url: String::new(),
                        summary: mode.to_string(),
                        tag_subject: String::new(),
                        tag_contents: String::new(),
                        tag_body: String::new(),
                        first_commit: None,
                    });
                    ctx.populate_git_vars();
                } else {
                    return Err(anyhow::anyhow!("could not detect git info: {e}"));
                }
            }
        }
    } else {
        ctx.populate_git_vars();
    }
    Ok(())
}