ferrflow 7.14.0

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

use crate::error_code::{self, ErrorCodeExt};

const AUDIENCE: &str = "pypi";
const DEFAULT_MINT_ENDPOINT: &str = "https://pypi.org/_/oidc/mint-token";
const REQUEST_URL_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
const REQUEST_TOKEN_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";

#[derive(Deserialize)]
struct IdTokenResponse {
    value: String,
}

#[derive(Deserialize)]
struct MintedTokenResponse {
    token: String,
}

pub fn mint(repository_url: Option<&str>) -> Result<String> {
    let request_url = required_env(REQUEST_URL_ENV)?;
    let request_token = required_env(REQUEST_TOKEN_ENV)?;
    let endpoint = mint_endpoint(repository_url)?;
    let agent = crate::http::agent();

    let id_token: IdTokenResponse = agent
        .get(&audience_url(&request_url))
        .header("Authorization", &format!("Bearer {request_token}"))
        .header("Accept", "application/json")
        .call()
        .context("publisher pypi: requesting a GitHub OIDC token failed")
        .error_code(error_code::CONFIG_INVALID_PATH)?
        .body_mut()
        .read_json()
        .context("publisher pypi: the GitHub OIDC token response was not the expected JSON")
        .error_code(error_code::CONFIG_INVALID_PATH)?;

    let minted: MintedTokenResponse = agent
        .post(&endpoint)
        .header("Content-Type", "application/json")
        .header("Accept", "application/json")
        .send_json(serde_json::json!({ "token": id_token.value }))
        .with_context(|| {
            format!(
                "publisher pypi: the token exchange at {endpoint} was refused; \
                 check that this repository and workflow are registered as a trusted publisher \
                 for the project"
            )
        })
        .error_code(error_code::CONFIG_INVALID_PATH)?
        .body_mut()
        .read_json()
        .context("publisher pypi: the mint-token response was not the expected JSON")
        .error_code(error_code::CONFIG_INVALID_PATH)?;

    Ok(minted.token)
}

fn required_env(name: &str) -> Result<String> {
    std::env::var(name)
        .map_err(|_| {
            anyhow!(
                "publisher pypi: trustedPublishing needs `{name}`, which GitHub Actions provides \
                 only when the job declares `permissions: id-token: write`"
            )
        })
        .error_code(error_code::CONFIG_INVALID_PATH)
}

fn audience_url(base: &str) -> String {
    let separator = if base.contains('?') { '&' } else { '?' };
    format!("{base}{separator}audience={AUDIENCE}")
}

fn mint_endpoint(repository_url: Option<&str>) -> Result<String> {
    let Some(raw) = repository_url else {
        return Ok(DEFAULT_MINT_ENDPOINT.to_string());
    };
    let host = raw
        .strip_prefix("https://")
        .and_then(|rest| rest.split('/').next())
        .filter(|host| !host.is_empty())
        .ok_or_else(|| {
            anyhow!(
                "publisher pypi: trustedPublishing needs an https registry url to derive the \
                 token endpoint from, got `{raw}`"
            )
        })
        .error_code(error_code::CONFIG_INVALID_PATH)?;
    Ok(format!("https://{host}/_/oidc/mint-token"))
}

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

    #[test]
    fn the_audience_is_appended_to_a_bare_request_url() {
        assert_eq!(
            audience_url("https://token.actions.githubusercontent.com/"),
            "https://token.actions.githubusercontent.com/?audience=pypi"
        );
    }

    #[test]
    fn the_audience_joins_an_existing_query_string() {
        assert_eq!(
            audience_url("https://token.actions.githubusercontent.com/?api-version=2.0"),
            "https://token.actions.githubusercontent.com/?api-version=2.0&audience=pypi"
        );
    }

    #[test]
    fn the_default_index_mints_on_pypi_org() {
        assert_eq!(mint_endpoint(None).unwrap(), DEFAULT_MINT_ENDPOINT);
    }

    #[test]
    fn a_custom_index_mints_on_its_own_host() {
        assert_eq!(
            mint_endpoint(Some("https://test.pypi.org/legacy/")).unwrap(),
            "https://test.pypi.org/_/oidc/mint-token"
        );
    }

    #[test]
    fn a_plaintext_index_is_refused() {
        let err = mint_endpoint(Some("http://pypi.internal/simple")).expect_err("must error");
        assert!(format!("{err:?}").contains("https registry url"));
    }

    #[test]
    fn a_url_without_a_host_is_refused() {
        assert!(mint_endpoint(Some("https:///legacy/")).is_err());
        assert!(mint_endpoint(Some("pypi.internal")).is_err());
    }
}