Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2fed864
Enhance trusted publishing support for GitLab CI
harsh-ps-2003 Aug 29, 2025
de660d5
test nit
harsh-ps-2003 Aug 29, 2025
7fc20b8
Refactor GitLab trusted publishing tests and update OIDC token handling
harsh-ps-2003 Aug 29, 2025
71c6e3e
Add audience endpoint mocks for PyPI and TestPyPI in GitLab trusted p…
harsh-ps-2003 Aug 29, 2025
50864a3
fmt nit
harsh-ps-2003 Aug 29, 2025
2e990d5
- Updated the `get_token` function to use a normalized environment ke…
harsh-ps-2003 Aug 29, 2025
612bed1
error nit
harsh-ps-2003 Aug 29, 2025
ad50d9e
Prefer HTTPS for OIDC minting; allow HTTP only in test builds
harsh-ps-2003 Aug 30, 2025
9ff70c1
test refactor
harsh-ps-2003 Aug 30, 2025
527108d
nit
harsh-ps-2003 Aug 30, 2025
655c7b7
switch to ambient_id for OIDC token discovery
woodruffw Sep 9, 2025
699b54d
Merge branch 'main' into gitlab
woodruffw Sep 9, 2025
cc12bcf
fix clippy findings
woodruffw Sep 9, 2025
be876cd
fix some tests
woodruffw Sep 9, 2025
170fe8e
fix remaining tests
woodruffw Sep 9, 2025
167062d
bump docs, run full clippy
woodruffw Sep 9, 2025
1c95e95
bump ambient_id
woodruffw Sep 9, 2025
4ee48ae
specialize GitHub permissions error case rendering
woodruffw Sep 10, 2025
ea4d25d
push IdToken further into params
woodruffw Sep 10, 2025
2a33439
improve suggestion on NoToken
woodruffw Sep 10, 2025
d272484
bump snapshot
woodruffw Sep 10, 2025
c800f63
Merge remote-tracking branch 'origin/main' into gitlab
woodruffw Sep 10, 2025
f1dc54d
docs: update trusted publishing docs to include GitLab CI/CD
woodruffw Sep 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ uv-virtualenv = { path = "crates/uv-virtualenv" }
uv-warnings = { path = "crates/uv-warnings" }
uv-workspace = { path = "crates/uv-workspace" }

ambient-id = { version = "0.0.3"}
anstream = { version = "0.6.15" }
anyhow = { version = "1.0.89" }
arcstr = { version = "1.2.0" }
Expand Down
8 changes: 7 additions & 1 deletion crates/uv-publish/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ uv-redacted = { workspace = true }
uv-static = { workspace = true }
uv-warnings = { workspace = true }

ambient-id = { workspace = true }
astral-tokio-tar = { workspace = true }
async-compression = { workspace = true }
base64 = { workspace = true }
Expand All @@ -42,12 +43,17 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true , features = ["io"] }
tokio-util = { workspace = true, features = ["io"] }
tracing = { workspace = true }
url = { workspace = true }

[dev-dependencies]
insta = { workspace = true }

[features]
# Test only feature to enable non-HTTPS URL handling
# in unit tests.
test = []

[lints]
workspace = true
52 changes: 22 additions & 30 deletions crates/uv-publish/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ mod trusted_publishing;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use std::{env, fmt, io};
use std::{fmt, io};

use fs_err::tokio::File;
use futures::TryStreamExt;
Expand All @@ -21,7 +21,6 @@ use tokio::io::{AsyncReadExt, BufReader};
use tokio::sync::Semaphore;
use tokio_util::io::ReaderStream;
use tracing::{Level, debug, enabled, trace, warn};
use trusted_publishing::TrustedPublishingToken;
use url::Url;

use uv_auth::{Credentials, PyxTokenStore};
Expand All @@ -38,10 +37,9 @@ use uv_fs::{ProgressReader, Simplified};
use uv_metadata::read_metadata_async_seek;
use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError};
use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars;
use uv_warnings::{warn_user, warn_user_once};
use uv_warnings::warn_user;

use crate::trusted_publishing::TrustedPublishingError;
use crate::trusted_publishing::{TrustedPublishingError, TrustedPublishingToken};

#[derive(Error, Debug)]
pub enum PublishError {
Expand Down Expand Up @@ -324,26 +322,20 @@ pub async fn check_trusted_publishing(
{
return Ok(TrustedPublishResult::Skipped);
}
// If we aren't in GitHub Actions, we can't use trusted publishing.
if env::var(EnvVars::GITHUB_ACTIONS) != Ok("true".to_string()) {
return Ok(TrustedPublishResult::Skipped);
}
// We could check for credentials from the keyring or netrc the auth middleware first, but
// given that we are in GitHub Actions we check for trusted publishing first.
debug!(
"Running on GitHub Actions without explicit credentials, checking for trusted publishing"
);

debug!("Attempting to get a token for trusted publishing");
// Attempt to get a token for trusted publishing.
match trusted_publishing::get_token(registry, client.for_host(registry).raw_client())
.await
{
Ok(token) => Ok(TrustedPublishResult::Configured(token)),
Err(err) => {
// TODO(konsti): It would be useful if we could differentiate between actual errors
// such as connection errors and warn for them while ignoring errors from trusted
// publishing not being configured.
debug!("Could not obtain trusted publishing credentials, skipping: {err}");
Ok(TrustedPublishResult::Ignored(err))
}
// Success: we have a token for trusted publishing.
Ok(Some(token)) => Ok(TrustedPublishResult::Configured(token)),
// Failed to discover an ambient OIDC token.
Ok(None) => Ok(TrustedPublishResult::Ignored(
TrustedPublishingError::NoToken,
)),
// Hard failure during OIDC discovery or token exchange.
Err(err) => Ok(TrustedPublishResult::Ignored(err)),
}
}
TrustedPublishing::Always => {
Expand All @@ -363,15 +355,15 @@ pub async fn check_trusted_publishing(
return Err(PublishError::MixedCredentials(conflicts.join(" and ")));
}

if env::var(EnvVars::GITHUB_ACTIONS) != Ok("true".to_string()) {
warn_user_once!(
"Trusted publishing was requested, but you're not in GitHub Actions."
);
}

let token =
let Some(token) =
trusted_publishing::get_token(registry, client.for_host(registry).raw_client())
.await?;
.await?
else {
return Err(PublishError::TrustedPublishing(
TrustedPublishingError::NoToken,
));
};

Ok(TrustedPublishResult::Configured(token))
}
TrustedPublishing::Never => Ok(TrustedPublishResult::Skipped),
Expand Down
125 changes: 56 additions & 69 deletions crates/uv-publish/src/trusted_publishing.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
//! Trusted publishing (via OIDC) with GitHub actions.
//! Trusted publishing (via OIDC) with GitHub Actions and GitLab CI.

use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use reqwest::{StatusCode, header};
use reqwest::StatusCode;
use reqwest_middleware::ClientWithMiddleware;
use serde::{Deserialize, Serialize};
use std::env;
use std::env::VarError;
use std::ffi::OsString;
use std::fmt::Display;
use thiserror::Error;
use tracing::{debug, trace};
Expand All @@ -17,12 +15,10 @@ use uv_static::EnvVars;

#[derive(Debug, Error)]
pub enum TrustedPublishingError {
#[error("Environment variable {0} not set, is the `id-token: write` permission missing?")]
MissingEnvVar(&'static str),
#[error("Environment variable {0} is not valid UTF-8: `{1:?}`")]
InvalidEnvVar(&'static str, OsString),
#[error(transparent)]
Url(#[from] url::ParseError),
#[error("Failed to discover OIDC token")]
Discovery(#[from] ambient_id::Error),
#[error("Failed to fetch: `{0}`")]
Reqwest(DisplaySafeUrl, #[source] reqwest::Error),
#[error("Failed to fetch: `{0}`")]
Expand All @@ -36,15 +32,10 @@ pub enum TrustedPublishingError {
/// When trusted publishing is misconfigured, the error above should occur, not this one.
#[error("PyPI returned error code {0}, and the OIDC has an unexpected format.\nResponse: {1}")]
InvalidOidcToken(StatusCode, String),
}

impl TrustedPublishingError {
fn from_var_err(env_var: &'static str, err: VarError) -> Self {
match err {
VarError::NotPresent => Self::MissingEnvVar(env_var),
VarError::NotUnicode(os_string) => Self::InvalidEnvVar(env_var, os_string),
}
}
/// This error can only occur when the user forces the use of trusted publishing
/// in an unsupported environment.
#[error("No OIDC token available from the environment")]
NoToken,
}

#[derive(Deserialize)]
Expand All @@ -63,12 +54,6 @@ struct Audience {
audience: String,
}

/// The response from querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`.
#[derive(Deserialize)]
struct OidcToken {
value: String,
}

/// The body for querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`.
#[derive(Serialize)]
struct MintTokenRequest {
Expand All @@ -94,34 +79,39 @@ pub struct OidcTokenClaims {
}

/// Returns the short-lived token to use for uploading.
///
/// Return states:
/// - `Ok(Some(token))`: Successfully obtained a trusted publishing token.
/// - `Ok(None)`: Not in a supported CI environment for trusted publishing.
/// - `Err(...)`: An error occurred while trying to obtain the token.
pub(crate) async fn get_token(
registry: &DisplaySafeUrl,
client: &ClientWithMiddleware,
) -> Result<TrustedPublishingToken, TrustedPublishingError> {
// If this fails, we can skip the audience request.
let oidc_token_request_token =
env::var(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN).map_err(|err| {
TrustedPublishingError::from_var_err(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN, err)
})?;

// Request 1: Get the audience
) -> Result<Option<TrustedPublishingToken>, TrustedPublishingError> {
// Get the OIDC token's audience from the registry.
let audience = get_audience(registry, client).await?;

// Request 2: Get the OIDC token from GitHub.
let oidc_token = get_oidc_token(&audience, &oidc_token_request_token, client).await?;
// Perform ambient OIDC token discovery.
// Depending on the host (GitHub Actions, GitLab CI, etc.)
// this may perform additional network requests.
let oidc_token = get_oidc_token(&audience, client).await?;

// Request 3: Get the publishing token from PyPI.
let publish_token = get_publish_token(registry, &oidc_token, client).await?;
// Exchange the OIDC token for a short-lived upload token,
// if OIDC token discovery succeeded.
if let Some(oidc_token) = &oidc_token {
let publish_token = get_publish_token(registry, oidc_token.reveal(), client).await?;

debug!("Received token, using trusted publishing");
// If we're on GitHub Actions, mask the exchanged token in logs.
#[allow(clippy::print_stdout)]
if env::var(EnvVars::GITHUB_ACTIONS) == Ok("true".to_string()) {
println!("::add-mask::{publish_token}");
}

// Tell GitHub Actions to mask the token in any console logs.
#[allow(clippy::print_stdout)]
if env::var(EnvVars::GITHUB_ACTIONS) == Ok("true".to_string()) {
println!("::add-mask::{}", &publish_token);
Ok(Some(publish_token))
} else {
// Not in a supported CI environment for trusted publishing.
Ok(None)
}

Ok(publish_token)
}

async fn get_audience(
Expand All @@ -130,8 +120,17 @@ async fn get_audience(
) -> Result<String, TrustedPublishingError> {
// `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority
// (RFC 3986).
let audience_url =
DisplaySafeUrl::parse(&format!("https://{}/_/oidc/audience", registry.authority()))?;
// Prefer HTTPS for OIDC discovery; allow HTTP only in test builds
let scheme: &str = if cfg!(feature = "test") {
registry.scheme()
} else {
"https"
};
let audience_url = DisplaySafeUrl::parse(&format!(
"{}://{}/_/oidc/audience",
scheme,
registry.authority()
))?;
debug!("Querying the trusted publishing audience from {audience_url}");
let response = client
.get(Url::from(audience_url.clone()))
Expand All @@ -148,33 +147,14 @@ async fn get_audience(
Ok(audience.audience)
}

/// Perform ambient OIDC token discovery.
async fn get_oidc_token(
audience: &str,
oidc_token_request_token: &str,
client: &ClientWithMiddleware,
) -> Result<String, TrustedPublishingError> {
let oidc_token_url = env::var(EnvVars::ACTIONS_ID_TOKEN_REQUEST_URL).map_err(|err| {
TrustedPublishingError::from_var_err(EnvVars::ACTIONS_ID_TOKEN_REQUEST_URL, err)
})?;
let mut oidc_token_url = DisplaySafeUrl::parse(&oidc_token_url)?;
oidc_token_url
.query_pairs_mut()
.append_pair("audience", audience);
debug!("Querying the trusted publishing OIDC token from {oidc_token_url}");
let authorization = format!("bearer {oidc_token_request_token}");
let response = client
.get(Url::from(oidc_token_url.clone()))
.header(header::AUTHORIZATION, authorization)
.send()
.await
.map_err(|err| TrustedPublishingError::ReqwestMiddleware(oidc_token_url.clone(), err))?;
let oidc_token: OidcToken = response
.error_for_status()
.map_err(|err| TrustedPublishingError::Reqwest(oidc_token_url.clone(), err))?
.json()
.await
.map_err(|err| TrustedPublishingError::Reqwest(oidc_token_url.clone(), err))?;
Ok(oidc_token.value)
) -> Result<Option<ambient_id::IdToken>, TrustedPublishingError> {
let detector = ambient_id::Detector::new_with_client(client.clone());

Ok(detector.detect(audience).await?)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ambient_id now abstracts all of this 🙂

}

/// Parse the JSON Web Token that the OIDC token is.
Expand All @@ -194,8 +174,15 @@ async fn get_publish_token(
oidc_token: &str,
client: &ClientWithMiddleware,
) -> Result<TrustedPublishingToken, TrustedPublishingError> {
// Prefer HTTPS for OIDC minting; allow HTTP only in test builds
let scheme: &str = if cfg!(feature = "test") {
registry.scheme()
} else {
"https"
};
let mint_token_url = DisplaySafeUrl::parse(&format!(
"https://{}/_/oidc/mint-token",
"{}://{}/_/oidc/mint-token",
scheme,
registry.authority()
))?;
debug!("Querying the trusted publishing upload token from {mint_token_url}");
Expand Down
Loading
Loading