fission-command-release 0.8.0

Release content, store, auth, beta, and review workflows for the fission command
Documentation
use anyhow::Result;
use fission_command_core::DistributionProvider;
use std::env;

use super::{
    base_report, finalize_status, print_report, AuthCommand, LifecycleCheck, LifecycleReport,
};

pub(super) fn auth(command: AuthCommand) -> Result<()> {
    match command {
        AuthCommand::Login { provider, json } => {
            print_report(auth_setup_report("auth.login", provider), json)
        }
        AuthCommand::Status { provider, json } => {
            print_report(auth_report("auth.status", provider), json)
        }
        AuthCommand::Setup { provider, json } => {
            print_report(auth_setup_report("auth.setup", provider), json)
        }
        AuthCommand::Logout { provider, json } => print_report(auth_logout_report(provider), json),
        AuthCommand::Import {
            provider,
            from,
            json,
        } => print_report(auth_import_report(provider, from.as_deref()), json),
        AuthCommand::Rotate { provider, json } => print_report(auth_rotate_report(provider), json),
        AuthCommand::Audit { json } => print_report(auth_report("auth.audit", None), json),
    }
}

pub(super) fn auth_report(area: &str, provider: Option<DistributionProvider>) -> LifecycleReport {
    let mut report = base_report(area, provider, None);
    let providers = provider
        .map(|provider| vec![provider])
        .unwrap_or_else(auth_providers);
    for provider in providers {
        report.checks.push(provider_env_check(provider));
    }
    finalize_status(&mut report);
    report
}

pub(super) fn auth_setup_report(
    area: &str,
    provider: Option<DistributionProvider>,
) -> LifecycleReport {
    let mut report = base_report(area, provider, None);
    let providers = provider
        .map(|provider| vec![provider])
        .unwrap_or_else(auth_providers);
    for provider in providers {
        let spec = provider_auth_spec(provider);
        report.checks.push(LifecycleCheck {
            id: format!(
                "auth.{}.credential_kind",
                provider.as_str().replace('-', "_")
            ),
            status: "passed".to_string(),
            summary: format!("{} credential kind is documented", provider.as_str()),
            details: Some(spec.kind.to_string()),
            remediation: Vec::new(),
        });
        report.checks.push(LifecycleCheck {
            id: format!("auth.{}.env", provider.as_str().replace('-', "_")),
            status: "passed".to_string(),
            summary: format!("{} accepted environment variables", provider.as_str()),
            details: Some(spec.env.join(", ")),
            remediation: Vec::new(),
        });
        report.checks.push(LifecycleCheck {
            id: format!("auth.{}.setup", provider.as_str().replace('-', "_")),
            status: "passed".to_string(),
            summary: format!("{} setup command", provider.as_str()),
            details: Some(spec.command.to_string()),
            remediation: Vec::new(),
        });
        report.checks.push(LifecycleCheck {
            id: format!("auth.{}.scopes", provider.as_str().replace('-', "_")),
            status: "passed".to_string(),
            summary: format!("{} required provider permissions", provider.as_str()),
            details: Some(spec.permissions.to_string()),
            remediation: Vec::new(),
        });
    }
    finalize_status(&mut report);
    report
}

pub(super) fn auth_logout_report(provider: Option<DistributionProvider>) -> LifecycleReport {
    let mut report = base_report("auth.logout", provider, None);
    let providers = provider
        .map(|provider| vec![provider])
        .unwrap_or_else(auth_providers);
    for provider in providers {
        let spec = provider_auth_spec(provider);
        report.checks.push(LifecycleCheck {
            id: format!("auth.{}.logout", provider.as_str().replace('-', "_")),
            status: "warning".to_string(),
            summary: format!("{} credential revocation is provider-owned", provider.as_str()),
            details: Some(spec.logout.to_string()),
            remediation: vec![
                "Remove related environment variables from the current shell/CI secret store."
                    .to_string(),
                "Revoke or rotate the provider credential in the provider console when the key is no longer trusted."
                    .to_string(),
            ],
        });
    }
    finalize_status(&mut report);
    report
}

pub(super) fn auth_import_report(
    provider: DistributionProvider,
    from: Option<&str>,
) -> LifecycleReport {
    let mut report = base_report("auth.import", Some(provider), None);
    let spec = provider_auth_spec(provider);
    report.checks.push(LifecycleCheck {
        id: format!("auth.{}.import_policy", provider.as_str().replace('-', "_")),
        status: "warning".to_string(),
        summary: "Fission does not import provider secrets into project state".to_string(),
        details: from.map(|value| format!("requested source: {value}")),
        remediation: vec![
            format!(
                "Expose the credential through one of these environment variables instead: {}.",
                spec.env.join(", ")
            ),
            "For CI, store secret material in the CI secret store; for local release, use an owner-only ~/.fission/<app>/release.env sourced by your shell."
                .to_string(),
            "Do not write secret file paths, tokens, service-account JSON, .p8 keys, JKS files, or PFX/P12 files to fission.toml."
                .to_string(),
        ],
    });
    finalize_status(&mut report);
    report
}

pub(super) fn auth_rotate_report(provider: Option<DistributionProvider>) -> LifecycleReport {
    let mut report = base_report("auth.rotate", provider, None);
    let providers = provider
        .map(|provider| vec![provider])
        .unwrap_or_else(auth_providers);
    for provider in providers {
        let spec = provider_auth_spec(provider);
        report.checks.push(LifecycleCheck {
            id: format!("auth.{}.rotate", provider.as_str().replace('-', "_")),
            status: "warning".to_string(),
            summary: format!("{} credential rotation is provider-owned", provider.as_str()),
            details: Some(spec.rotate.to_string()),
            remediation: vec![
                "Create the replacement credential in the provider console or provider CLI."
                    .to_string(),
                format!(
                    "Update the CI/local environment variables used by Fission: {}.",
                    spec.env.join(", ")
                ),
                "Run `fission auth status` and the relevant `fission readiness release` check before publishing."
                    .to_string(),
            ],
        });
    }
    finalize_status(&mut report);
    report
}

fn auth_providers() -> Vec<DistributionProvider> {
    vec![
        DistributionProvider::GithubPages,
        DistributionProvider::GithubReleases,
        DistributionProvider::CloudflarePages,
        DistributionProvider::DockerRegistry,
        DistributionProvider::Netlify,
        DistributionProvider::S3,
        DistributionProvider::GoogleDrive,
        DistributionProvider::OneDrive,
        DistributionProvider::Dropbox,
        DistributionProvider::PlayStore,
        DistributionProvider::AppStore,
        DistributionProvider::MicrosoftStore,
    ]
}

struct ProviderAuthSpec {
    kind: &'static str,
    env: &'static [&'static str],
    command: &'static str,
    logout: &'static str,
    rotate: &'static str,
    permissions: &'static str,
}

fn provider_auth_spec(provider: DistributionProvider) -> ProviderAuthSpec {
    match provider {
        DistributionProvider::GithubPages => ProviderAuthSpec {
            kind: "GitHub token or GitHub App installation token",
            env: &["GH_TOKEN", "GITHUB_TOKEN"],
            command: "export GH_TOKEN=<github-token>",
            logout: "unset GH_TOKEN GITHUB_TOKEN; revoke the GitHub token in GitHub settings when needed",
            rotate: "create a replacement GitHub token/App installation token, update GH_TOKEN or GITHUB_TOKEN, then revoke the old token",
            permissions: "repository contents/workflows/pages permissions for local API operations; Actions deployment uses repository workflow permissions",
        },
        DistributionProvider::GithubReleases => ProviderAuthSpec {
            kind: "Authenticated GitHub CLI session, GitHub token, or GitHub App installation token",
            env: &["GH_TOKEN", "GITHUB_TOKEN"],
            command: "gh auth login",
            logout: "gh auth logout --hostname github.com and unset GH_TOKEN GITHUB_TOKEN",
            rotate: "run gh auth refresh or create a replacement GitHub token, update GH_TOKEN/GITHUB_TOKEN, then revoke the old credential",
            permissions: "repository Contents write permission to create/update releases and upload/delete release assets",
        },
        DistributionProvider::CloudflarePages => ProviderAuthSpec {
            kind: "Cloudflare API token plus Wrangler login/config for uploads",
            env: &["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"],
            command: "export CLOUDFLARE_API_TOKEN=<cloudflare-token>",
            logout: "unset CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID; revoke the API token in Cloudflare",
            rotate: "create a replacement Cloudflare API token with the same Pages scope, update CLOUDFLARE_API_TOKEN, then revoke the old token",
            permissions: "Pages edit/deploy permission for the target account/project",
        },
        DistributionProvider::DockerRegistry => ProviderAuthSpec {
            kind: "Authenticated Docker CLI session for the target registry",
            env: &["DOCKER_CONFIG"],
            command: "docker login <registry>",
            logout: "docker logout <registry>",
            rotate: "create a replacement registry token/password, run docker login with it, then revoke the old credential",
            permissions: "push permission for every image repository configured in [distribution.docker_registry.<profile>].tags",
        },
        DistributionProvider::Netlify => ProviderAuthSpec {
            kind: "Netlify personal access token",
            env: &["NETLIFY_AUTH_TOKEN"],
            command: "export NETLIFY_AUTH_TOKEN=<netlify-token>",
            logout: "unset NETLIFY_AUTH_TOKEN; revoke the personal access token in Netlify",
            rotate: "create a replacement Netlify personal access token, update NETLIFY_AUTH_TOKEN, then revoke the old token",
            permissions: "site read/deploy permissions for the configured site",
        },
        DistributionProvider::S3 => ProviderAuthSpec {
            kind: "AWS/S3 profile or access key credentials",
            env: &["AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
            command: "export AWS_PROFILE=<profile> # or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY",
            logout: "unset AWS_PROFILE AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN",
            rotate: "rotate the IAM/S3-compatible access key in the provider, update AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, then disable the old key",
            permissions: "s3:PutObject, s3:ListBucket, and optional s3:PutObjectAcl for public artifacts",
        },
        DistributionProvider::GoogleDrive => ProviderAuthSpec {
            kind: "Google OAuth access token or service-account flow managed outside fission.toml",
            env: &["GOOGLE_DRIVE_ACCESS_TOKEN"],
            command: "export GOOGLE_DRIVE_ACCESS_TOKEN=<access-token>",
            logout: "unset GOOGLE_DRIVE_ACCESS_TOKEN; revoke the OAuth token or service account key in Google Cloud",
            rotate: "create a replacement Google OAuth/service-account credential, update GOOGLE_DRIVE_ACCESS_TOKEN, then revoke the old credential",
            permissions: "Drive file create/update permission for the selected folder",
        },
        DistributionProvider::OneDrive => ProviderAuthSpec {
            kind: "Microsoft Graph OAuth access token",
            env: &["ONEDRIVE_ACCESS_TOKEN"],
            command: "export ONEDRIVE_ACCESS_TOKEN=<access-token>",
            logout: "unset ONEDRIVE_ACCESS_TOKEN; revoke the app grant/token in Microsoft Entra",
            rotate: "create a replacement Microsoft Graph credential, update ONEDRIVE_ACCESS_TOKEN, then revoke the old credential",
            permissions: "Files.ReadWrite or equivalent delegated/application permission for the target drive",
        },
        DistributionProvider::Dropbox => ProviderAuthSpec {
            kind: "Dropbox OAuth access token",
            env: &["DROPBOX_ACCESS_TOKEN"],
            command: "export DROPBOX_ACCESS_TOKEN=<access-token>",
            logout: "unset DROPBOX_ACCESS_TOKEN; revoke the Dropbox OAuth token/app grant",
            rotate: "create a replacement Dropbox OAuth token, update DROPBOX_ACCESS_TOKEN, then revoke the old token",
            permissions: "files.content.write and files.metadata.read for the destination path",
        },
        DistributionProvider::PlayStore => ProviderAuthSpec {
            kind: "Google Play Android Publisher service-account JSON or access token",
            env: &[
                "PLAY_STORE_ACCESS_TOKEN",
                "PLAY_STORE_SERVICE_ACCOUNT_JSON",
                "PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64",
                "GOOGLE_APPLICATION_CREDENTIALS",
            ],
            command: "export PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64=<base64-service-account-json>",
            logout: "unset PLAY_STORE_ACCESS_TOKEN PLAY_STORE_SERVICE_ACCOUNT_JSON PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64 GOOGLE_APPLICATION_CREDENTIALS",
            rotate: "create a replacement Google service-account key, update PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64 or CI secret files, then delete the old key",
            permissions: "Android Publisher API access to the configured package and release tracks",
        },
        DistributionProvider::AppStore => ProviderAuthSpec {
            kind: "App Store Connect API private key plus issuer/key ids",
            env: &[
                "APP_STORE_CONNECT_API_KEY",
                "APP_STORE_CONNECT_API_KEY_BASE64",
                "APP_STORE_CONNECT_API_KEY_PATH",
                "APP_STORE_CONNECT_ISSUER_ID",
                "APP_STORE_CONNECT_KEY_ID",
            ],
            command: "export APP_STORE_CONNECT_API_KEY_BASE64=<base64-p8-key>",
            logout: "unset APP_STORE_CONNECT_ACCESS_TOKEN APP_STORE_CONNECT_API_KEY APP_STORE_CONNECT_API_KEY_BASE64 APP_STORE_CONNECT_API_KEY_PATH APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_KEY_ID",
            rotate: "create a replacement App Store Connect API key, update APP_STORE_CONNECT_API_KEY_BASE64 plus issuer/key ids, then revoke the old key",
            permissions: "App Manager or equivalent App Store Connect API role for metadata, uploads, TestFlight, and submissions",
        },
        DistributionProvider::MicrosoftStore => ProviderAuthSpec {
            kind: "Partner Center/Entra application secret or access token",
            env: &[
                "MICROSOFT_STORE_TOKEN",
                "MICROSOFT_STORE_CLIENT_SECRET",
                "PARTNER_CENTER_CLIENT_SECRET",
            ],
            command: "export MICROSOFT_STORE_CLIENT_SECRET=<partner-center-secret>",
            logout: "unset MICROSOFT_STORE_TOKEN MICROSOFT_STORE_CLIENT_SECRET PARTNER_CENTER_CLIENT_SECRET",
            rotate: "create a replacement Entra/Partner Center client secret, update MICROSOFT_STORE_CLIENT_SECRET or PARTNER_CENTER_CLIENT_SECRET, then revoke the old secret",
            permissions: "Partner Center app submission permissions for the configured product",
        },
    }
}

fn provider_env_check(provider: DistributionProvider) -> LifecycleCheck {
    let vars: &[&str] = match provider {
        DistributionProvider::GithubPages => &["GH_TOKEN", "GITHUB_TOKEN"],
        DistributionProvider::GithubReleases => &["GH_TOKEN", "GITHUB_TOKEN"],
        DistributionProvider::CloudflarePages => &["CLOUDFLARE_API_TOKEN"],
        DistributionProvider::DockerRegistry => &["DOCKER_CONFIG"],
        DistributionProvider::Netlify => &["NETLIFY_AUTH_TOKEN"],
        DistributionProvider::S3 => &["AWS_PROFILE", "AWS_ACCESS_KEY_ID"],
        DistributionProvider::GoogleDrive => &["GOOGLE_DRIVE_ACCESS_TOKEN"],
        DistributionProvider::OneDrive => &["ONEDRIVE_ACCESS_TOKEN"],
        DistributionProvider::Dropbox => &["DROPBOX_ACCESS_TOKEN"],
        DistributionProvider::PlayStore => &[
            "PLAY_STORE_ACCESS_TOKEN",
            "PLAY_STORE_SERVICE_ACCOUNT_JSON",
            "PLAY_STORE_SERVICE_ACCOUNT_JSON_BASE64",
            "GOOGLE_APPLICATION_CREDENTIALS",
        ],
        DistributionProvider::AppStore => &[
            "APP_STORE_CONNECT_ACCESS_TOKEN",
            "APP_STORE_CONNECT_API_KEY",
            "APP_STORE_CONNECT_API_KEY_BASE64",
            "APP_STORE_CONNECT_API_KEY_PATH",
        ],
        DistributionProvider::MicrosoftStore => &[
            "MICROSOFT_STORE_TOKEN",
            "MICROSOFT_STORE_CLIENT_SECRET",
            "PARTNER_CENTER_CLIENT_SECRET",
        ],
    };
    let found = vars.iter().find(|name| env::var_os(name).is_some());
    LifecycleCheck {
        id: format!("auth.{}.credentials", provider.as_str().replace('-', "_")),
        status: if found.is_some() { "passed" } else { "missing" }.to_string(),
        summary: format!("{} credentials are available", provider.as_str()),
        details: found.map(|name| format!("using {name}")),
        remediation: vec![format!(
            "Set one of {} from your shell, CI secret store, or platform-specific credential tool.",
            vars.join(", ")
        )],
    }
}