ferrflow 7.13.2

Universal semantic versioning for monorepos and classic repos
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ForgeKind {
    #[default]
    Auto,
    #[serde(alias = "GitHub")]
    Github,
    #[serde(alias = "GitLab")]
    Gitlab,
    #[serde(alias = "Gitea", alias = "forgejo", alias = "Forgejo")]
    Gitea,
    #[serde(alias = "Bitbucket")]
    Bitbucket,
}

pub const GENERIC_TOKEN_ENV_VAR: &str = "FERRFLOW_TOKEN";

impl ForgeKind {
    pub const ALL: &'static [Self] = &[Self::Github, Self::Gitlab, Self::Gitea, Self::Bitbucket];

    pub fn token_env_vars(self) -> &'static [&'static str] {
        match self {
            Self::Github => &["GITHUB_TOKEN"],
            Self::Gitlab => &["GITLAB_TOKEN"],
            Self::Gitea => &["GITEA_TOKEN", "FORGEJO_TOKEN"],
            Self::Bitbucket => &["BITBUCKET_TOKEN"],
            Self::Auto => &[],
        }
    }
}

pub fn all_token_env_vars() -> impl Iterator<Item = &'static str> {
    std::iter::once(GENERIC_TOKEN_ENV_VAR).chain(
        ForgeKind::ALL
            .iter()
            .flat_map(|kind| kind.token_env_vars().iter().copied()),
    )
}

#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct HooksConfig {
    #[serde(alias = "preBump")]
    pub pre_bump: Option<String>,
    #[serde(alias = "postBump")]
    pub post_bump: Option<String>,
    #[serde(alias = "preCommit")]
    pub pre_commit: Option<String>,
    #[serde(alias = "postCommit")]
    pub post_commit: Option<String>,
    #[serde(alias = "preTag")]
    pub pre_tag: Option<String>,
    #[serde(alias = "postTag")]
    pub post_tag: Option<String>,
    #[serde(alias = "prePublish")]
    pub pre_publish: Option<String>,
    #[serde(alias = "postPublish")]
    pub post_publish: Option<String>,
    #[serde(alias = "preRelease")]
    pub pre_release: Option<String>,
    #[serde(alias = "onSuccess")]
    pub on_success: Option<String>,
    #[serde(alias = "onError")]
    pub on_error: Option<String>,
    #[serde(default, alias = "onFailure")]
    pub on_failure: Option<OnFailure>,
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OnFailure {
    #[default]
    Abort,
    Continue,
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum VersionSourcePolicy {
    #[default]
    Highest,
    Tag,
    File,
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub enum OrphanedTagStrategy {
    #[default]
    Warn,
    TreeHash,
    Message,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BranchChannelConfig {
    pub name: String,
    #[serde(default)]
    pub channel: ChannelValue,
    #[serde(default, alias = "prereleaseIdentifier")]
    pub prerelease_identifier: PrereleaseIdentifier,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChannelValue {
    Stable(bool),
    Named(String),
}

impl Default for ChannelValue {
    fn default() -> Self {
        ChannelValue::Stable(false)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PrereleaseIdentifier {
    #[default]
    Increment,
    Timestamp,
    ShortHash,
    TimestampHash,
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ReleaseCommitMode {
    #[default]
    Commit,
    Pr,
    None,
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ReleaseCommitScope {
    #[default]
    Grouped,
    PerPackage,
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ReleaseCommitBody {
    #[default]
    None,
    Summary,
    Full,
}

/// Declarative registry-credential map shared across every publisher
/// kind. Lets users say "the Kellnr registry's token is in
/// CARGO_REGISTRIES_KELLNR_TOKEN" once at the workspace level instead
/// of restating it on every package's publisher entry.
///
/// `tokenEnv` is the env-var name (not the value) — the actual token
/// stays in the runner's secret store. `url` is informational and used
/// for the dry-run preview message + future idempotency probes.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
pub struct RegistryConfig {
    #[serde(default)]
    pub url: Option<String>,
    #[serde(alias = "tokenEnv")]
    pub token_env: Option<String>,
}

/// Declarative replacement for the shell-soup that currently lives in
/// `postPublish` hooks across every FerrLabs cloud repo. Each
/// publisher is a self-contained intent ("push this package to that
/// registry"), evaluated in declaration order after the release
/// commit + tag + GitHub Release have been created. v1 ships parsing
/// and dry-run preview only — actual publish execution lands in
/// follow-up PRs (one publisher kind per PR) so the schema can be
/// reviewed before any side-effecting code does.
///
/// The kind is the externally-tagged discriminator (`{"kind":
/// "cargo", ...}`) — keeps the JSON Schema readable and lets us add
/// kinds without overloading existing fields.
fn default_true() -> bool {
    true
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum PublisherConfig {
    Cargo {
        #[serde(default)]
        registry: Option<String>,
        #[serde(default, rename = "allowDirty")]
        allow_dirty: bool,
        #[serde(default, rename = "noVerify")]
        no_verify: bool,
        #[serde(default)]
        args: Vec<String>,
    },
    Npm {
        #[serde(default)]
        registry: Option<String>,
        #[serde(default)]
        tag: Option<String>,
        #[serde(default)]
        access: Option<String>,
        #[serde(default)]
        args: Vec<String>,
    },
    Docker {
        image: String,
        #[serde(default = "default_docker_tags")]
        tags: Vec<String>,
        #[serde(default)]
        platforms: Vec<String>,
        #[serde(default = "default_docker_context")]
        context: String,
        #[serde(default = "default_dockerfile")]
        dockerfile: String,
        #[serde(default)]
        sign: DockerSign,
        #[serde(default)]
        args: Vec<String>,
    },
    Helm {
        #[serde(default = "default_helm_chart_path")]
        chart: String,
        registry: String,
        #[serde(default)]
        args: Vec<String>,
    },
    GithubReleaseAsset {
        path: String,
        #[serde(default, rename = "displayName")]
        display_name: Option<String>,
        #[serde(default)]
        args: Vec<String>,
    },
    Pypi {
        #[serde(default)]
        registry: Option<String>,
        #[serde(default = "default_true")]
        build: bool,
        #[serde(default)]
        args: Vec<String>,
    },
    Webhook {
        url: String,
        #[serde(default)]
        body: Option<serde_json::Value>,
        #[serde(default)]
        headers: std::collections::BTreeMap<String, String>,
    },
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum DockerSign {
    #[default]
    None,
    Sigstore,
}

fn default_docker_tags() -> Vec<String> {
    vec!["{version}".to_string()]
}

fn default_docker_context() -> String {
    ".".to_string()
}

fn default_dockerfile() -> String {
    "Dockerfile".to_string()
}

fn default_helm_chart_path() -> String {
    ".".to_string()
}

impl PublisherConfig {
    /// Whether a successful publish through this publisher cannot be undone.
    ///
    /// crates.io never removes a version (yanking hides it, the artefact stays),
    /// PyPI is the same, and npm refuses to republish an unpublished version.
    /// Rolling a release back past one of these would delete the tag for a
    /// version anyone can still install, so `ferrflow rollback` stops instead.
    /// Docker tags, Helm charts, release assets and webhooks are all replaceable.
    pub fn is_immutable_publish(&self) -> bool {
        matches!(
            self,
            PublisherConfig::Cargo { .. }
                | PublisherConfig::Npm { .. }
                | PublisherConfig::Pypi { .. }
        )
    }

    /// Short human-friendly name (`"cargo"`, `"docker"`, …) for log
    /// lines, dry-run output, and step summaries. Stable enough to be
    /// pattern-matched in CI scripts.
    pub fn kind_name(&self) -> &'static str {
        match self {
            PublisherConfig::Cargo { .. } => "cargo",
            PublisherConfig::Npm { .. } => "npm",
            PublisherConfig::Docker { .. } => "docker",
            PublisherConfig::Helm { .. } => "helm",
            PublisherConfig::GithubReleaseAsset { .. } => "github-release-asset",
            PublisherConfig::Pypi { .. } => "pypi",
            PublisherConfig::Webhook { .. } => "webhook",
        }
    }

    /// One-line description for the dry-run preview ("would publish
    /// X to Y"). Resolves placeholders that only depend on per-call
    /// inputs (name + new version); registry-level fields stay
    /// unresolved at this stage.
    pub fn describe(&self, package_name: &str, new_version: &str) -> String {
        match self {
            PublisherConfig::Cargo { registry, .. } => {
                let reg = registry.as_deref().unwrap_or("crates-io");
                format!("cargo publish {package_name}@{new_version}{reg}")
            }
            PublisherConfig::Pypi { registry, .. } => {
                let reg = registry.as_deref().unwrap_or("pypi.org");
                format!("twine upload {package_name}@{new_version}{reg}")
            }
            PublisherConfig::Npm { registry, tag, .. } => {
                let reg = registry.as_deref().unwrap_or("npmjs.org");
                let tag = tag.as_deref().unwrap_or("latest");
                format!("npm publish {package_name}@{new_version}{reg} (tag={tag})")
            }
            PublisherConfig::Docker {
                image,
                tags,
                platforms,
                sign,
                ..
            } => {
                let resolved: Vec<String> = tags
                    .iter()
                    .map(|t| t.replace("{version}", new_version))
                    .collect();
                let platforms_s = if platforms.is_empty() {
                    "linux/amd64".to_string()
                } else {
                    platforms.join(",")
                };
                let sign_s = match sign {
                    DockerSign::None => "",
                    DockerSign::Sigstore => " +sigstore",
                };
                format!(
                    "docker push {image}:[{}] platforms={platforms_s}{sign_s}",
                    resolved.join(", ")
                )
            }
            PublisherConfig::Helm {
                chart, registry, ..
            } => {
                format!("helm push {chart} {new_version}{registry}")
            }
            PublisherConfig::GithubReleaseAsset {
                path, display_name, ..
            } => {
                let shown = display_name.as_deref().unwrap_or(path);
                format!("upload {shown} → GitHub Release {new_version}")
            }
            PublisherConfig::Webhook { url, .. } => {
                format!("POST {url} ({package_name}@{new_version})")
            }
        }
    }
}