use anyhow::{bail, Context, Result};
const REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
const REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
pub fn available() -> bool {
non_empty(REQUEST_URL).is_some() && non_empty(REQUEST_TOKEN).is_some()
}
pub fn inside_github_actions() -> bool {
non_empty("GITHUB_ACTIONS").is_some()
}
fn non_empty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn audience(base: &str) -> String {
non_empty("PORTAKI_REGISTRY_OIDC_AUDIENCE")
.unwrap_or_else(|| format!("{}/registry", base.trim_end_matches('/')))
}
pub async fn request_token(audience: &str) -> Result<String> {
let url = non_empty(REQUEST_URL).context("ACTIONS_ID_TOKEN_REQUEST_URL absent")?;
let bearer = non_empty(REQUEST_TOKEN).context("ACTIONS_ID_TOKEN_REQUEST_TOKEN absent")?;
let response = reqwest::Client::new()
.get(url)
.query(&[("audience", audience)])
.header("Authorization", format!("bearer {bearer}"))
.send()
.await
.context("demander un jeton OIDC à GitHub Actions")?;
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
if !(200..300).contains(&status) {
bail!("GitHub a refusé d'émettre un jeton OIDC ({status}) : {body}");
}
let parsed: serde_json::Value =
serde_json::from_str(&body).context("réponse OIDC illisible")?;
match parsed.get("value").and_then(serde_json::Value::as_str) {
Some(token) if !token.is_empty() => Ok(token.to_string()),
_ => bail!("la réponse OIDC de GitHub ne porte pas de jeton"),
}
}
pub async fn exchange(
base: &str,
module_id: &str,
channel: &str,
oidc_token: &str,
) -> Result<String> {
let response = reqwest::Client::new()
.post(format!(
"{}/registry/v1/publications/token",
base.trim_end_matches('/')
))
.bearer_auth(oidc_token)
.json(&serde_json::json!({ "moduleId": module_id, "channel": channel }))
.send()
.await
.context("échanger le jeton OIDC contre un credential de publication")?;
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
if !(200..300).contains(&status) {
bail!("{}", refusal(status, &body));
}
let parsed: serde_json::Value =
serde_json::from_str(&body).context("réponse d'échange illisible")?;
match parsed.get("token").and_then(serde_json::Value::as_str) {
Some(token) if !token.is_empty() => Ok(token.to_string()),
_ => bail!("le registre n'a pas rendu de credential"),
}
}
fn refusal(status: u16, body: &str) -> String {
let parsed: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::Value::Null);
let code = parsed
.get("code")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let message = parsed
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or(body);
let hint = match code {
"module_not_linked" => {
"lie ce module à ce dépôt depuis le dashboard avant de publier depuis la CI"
}
"repository_not_linked" => "ce module est lié à un autre dépôt",
"workflow_not_allowed" => {
"la liaison attend un autre fichier de workflow — corrige-la ou publie depuis celui déclaré"
}
"environment_required" => {
"le canal stable exige l'environment déclaré dans la liaison : ajoute `environment:` au job"
}
"event_not_allowed" => "cet événement déclencheur n'est pas autorisé par la liaison",
"runner_not_allowed" => "la liaison exige un runner hébergé par GitHub",
"token_replayed" => "ce jeton OIDC a déjà été échangé — demandes-en un nouveau",
"github_oidc_required" => {
"le registre n'a pas reconnu le jeton : vérifie l'audience (PORTAKI_REGISTRY_OIDC_AUDIENCE)"
}
_ => "",
};
if hint.is_empty() {
format!("le registre a refusé l'échange ({status} {code}) : {message}")
} else {
format!("le registre a refusé l'échange ({status} {code}) : {message} — {hint}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_audience_defaults_to_the_registry_of_the_target_platform() {
assert_eq!(
audience("https://api.portaki.app/"),
"https://api.portaki.app/registry"
);
}
#[test]
fn each_refusal_says_what_to_fix() {
let refused = refusal(403, r#"{"code":"environment_required","message":"aucun"}"#);
assert!(refused.contains("environment_required"));
assert!(refused.contains("environment:"));
}
#[test]
fn an_unknown_refusal_still_carries_the_body() {
let refused = refusal(500, "<html>oops</html>");
assert!(refused.contains("oops"));
}
}