apiforge 0.4.0

Production-grade API release automation CLI. From merged code to healthy pods in production — one command.
Documentation
//! Automatic rollback target selection.
//!
//! Picks the version to roll back to from (in priority order):
//! 1. successful release history from the audit store,
//! 2. semver-sorted git tags as a fallback.
//!
//! Selection is anchored on the currently deployed version when it is known.

use semver::Version;

/// Select the version to roll back to.
///
/// * `current` — version currently deployed (parsed from the running image
///   tag), if it could be determined.
/// * `successful_releases` — versions of successful, non-dry-run releases,
///   newest first (from the audit store).
/// * `tags` — release tag versions, newest first (fallback when there is no
///   audit history, e.g. releases ran from another machine).
///
/// Rules:
/// * With a known current version: the newest candidate strictly older than
///   the current version.
/// * Without one: the second-newest candidate (the newest is assumed to be
///   what is deployed).
pub fn select_rollback_target(
    current: Option<&Version>,
    successful_releases: &[Version],
    tags: &[Version],
) -> Option<Version> {
    let candidates: &[Version] = if successful_releases.is_empty() {
        tags
    } else {
        successful_releases
    };

    match current {
        Some(cur) => candidates.iter().find(|v| *v < cur).cloned(),
        None => candidates.get(1).cloned(),
    }
}

/// Parse a version out of an image reference's tag, e.g.
/// `registry.example.com/app:1.2.3` -> `1.2.3`. Returns `None` for images
/// without a tag or with non-semver tags (`latest`, digests, git shas).
pub fn version_from_image(image: &str) -> Option<Version> {
    let tag = image.rsplit_once(':').map(|(_, t)| t)?;
    // Guard against port-only references like `localhost:5000/app`.
    if tag.contains('/') {
        return None;
    }
    Version::parse(tag.trim_start_matches('v')).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn v(s: &str) -> Version {
        Version::parse(s).unwrap()
    }

    #[test]
    fn picks_newest_release_older_than_current() {
        let releases = [v("1.4.0"), v("1.3.2"), v("1.3.1")];
        let target = select_rollback_target(Some(&v("1.4.0")), &releases, &[]);
        assert_eq!(target, Some(v("1.3.2")));
    }

    #[test]
    fn skips_releases_newer_than_current() {
        // A newer release exists in history but was never deployed
        // (e.g. its k8s step failed after the audit entry of a prior run).
        let releases = [v("2.0.0"), v("1.4.0"), v("1.3.2")];
        let target = select_rollback_target(Some(&v("1.4.0")), &releases, &[]);
        assert_eq!(target, Some(v("1.3.2")));
    }

    #[test]
    fn without_current_uses_second_newest() {
        let releases = [v("1.4.0"), v("1.3.2"), v("1.3.1")];
        let target = select_rollback_target(None, &releases, &[]);
        assert_eq!(target, Some(v("1.3.2")));
    }

    #[test]
    fn falls_back_to_tags_when_no_audit_history() {
        let tags = [v("0.9.0"), v("0.8.0")];
        let target = select_rollback_target(Some(&v("0.9.0")), &[], &tags);
        assert_eq!(target, Some(v("0.8.0")));
    }

    #[test]
    fn none_when_no_older_candidate() {
        let releases = [v("1.0.0")];
        assert_eq!(
            select_rollback_target(Some(&v("1.0.0")), &releases, &[]),
            None
        );
        assert_eq!(select_rollback_target(None, &releases, &[]), None);
        assert_eq!(select_rollback_target(None, &[], &[]), None);
    }

    #[test]
    fn parses_version_from_image_tag() {
        assert_eq!(
            version_from_image("123.dkr.ecr.us-east-1.amazonaws.com/app:1.2.3"),
            Some(v("1.2.3"))
        );
        assert_eq!(
            version_from_image("ghcr.io/org/app:v2.0.1"),
            Some(v("2.0.1"))
        );
        assert_eq!(version_from_image("app:latest"), None);
        assert_eq!(version_from_image("app"), None);
        assert_eq!(version_from_image("localhost:5000/app"), None);
    }
}