Skip to content

Commit

Permalink
Implement trusted publishing
Browse files Browse the repository at this point in the history
  • Loading branch information
konstin committed Sep 19, 2024
1 parent 49382c1 commit d7b23e9
Show file tree
Hide file tree
Showing 13 changed files with 434 additions and 44 deletions.
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,10 @@ jobs:
build-binary-linux:
timeout-minutes: 10
needs: determine_changes
if: ${{ github.repository == 'astral-sh/uv' && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
runs-on:
labels: ubuntu-latest-large
#if: ${{ github.repository == 'astral-sh/uv' && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
#runs-on:
# labels: ubuntu-latest-large
name: "build binary | linux"
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -901,6 +902,9 @@ jobs:
env:
# No dbus in GitHub Actions
PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring
permissions:
# For trusted publishing
id-token: write
steps:
- uses: actions/checkout@v4
with:
Expand Down
21 changes: 21 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4324,6 +4324,27 @@ pub struct PublishArgs {
)]
pub token: Option<String>,

/// Always use trusted publishing through GitHub Actions.
///
/// By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it
/// if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request
/// from a fork). With this option, uv forces using a trusted publishing and errors if no
/// trusted publishing credentials can be found.
#[arg(
long,
conflicts_with_all = ["no_trusted_publishing", "username", "password", "token"]
)]
pub trusted_publishing: bool,

/// Always use trusted publishing through GitHub Actions.
///
/// By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it
/// if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request
/// from a fork). With this option, uv skips this check and only tries other authentication
/// methods.
#[arg(long, overrides_with("trusted_publishing"))]
pub no_trusted_publishing: bool,

/// Attempt to use `keyring` for authentication for remote requirements files.
///
/// At present, only `--keyring-provider subprocess` is supported, which configures uv to
Expand Down
122 changes: 84 additions & 38 deletions crates/uv-publish/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
mod trusted_publishing;

use crate::trusted_publishing::TrustedPublishingError;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
Expand All @@ -15,7 +18,7 @@ use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::{fmt, io};
use std::{env, fmt, io};
use thiserror::Error;
use tokio::io::AsyncReadExt;
use tracing::{debug, enabled, trace, Level};
Expand All @@ -27,7 +30,7 @@ use uv_warnings::warn_user_once;

#[derive(Error, Debug)]
pub enum PublishError {
#[error("Invalid publish paths")]
#[error("The publish paths are not valid glob patterns")]
Pattern(#[from] PatternError),
/// [`GlobError`] is a wrapped io error.
#[error(transparent)]
Expand All @@ -42,6 +45,8 @@ pub enum PublishError {
PublishPrepare(PathBuf, #[source] PublishPrepareError),
#[error("Failed to publish `{}` to `{}`", _0.user_display(), _1)]
PublishSend(PathBuf, Url, #[source] PublishSendError),
#[error("Failed to obtain token for trusted publishing")]
TrustedPublishing(#[from] TrustedPublishingError),
}

/// Failure to get the metadata for a specific file.
Expand Down Expand Up @@ -200,6 +205,83 @@ pub fn files_for_publishing(
Ok(files)
}

/// If applicable, attempt obtaining a token for trusted publishing.
pub async fn check_trusted_publishing(
username: Option<&str>,
password: Option<&str>,
trusted_publishing: bool,
no_trusted_publishing: bool,
registry: &Url,
client: &BaseClient,
) -> Result<Option<String>, PublishError> {
if trusted_publishing {
debug!("Using trusted publishing for GitHub Actions");
if env::var("GITHUB_ACTIONS") != Ok("true".to_string()) {
warn_user_once!("Trusted publishing was requested, but you're not in GitHub Actions.");
}

let token = trusted_publishing::get_token(registry, client).await?;
Ok(Some(token))
} else if !no_trusted_publishing
&& username.is_none()
&& password.is_none()
&& env::var("GITHUB_ACTIONS") == Ok("true".to_string())
{
// 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");
match trusted_publishing::get_token(registry, client).await {
Ok(token) => Ok(Some(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(None)
}
}
} else {
Ok(None)
}
}

/// Upload a file to a registry.
///
/// Returns `true` if the file was newly uploaded and `false` if it already existed.
pub async fn upload(
file: &Path,
filename: &DistFilename,
registry: &Url,
client: &BaseClient,
username: Option<&str>,
password: Option<&str>,
) -> Result<bool, PublishError> {
let form_metadata = form_metadata(file, filename)
.await
.map_err(|err| PublishError::PublishPrepare(file.to_path_buf(), err))?;

let request = build_request(
file,
filename,
registry,
client,
username,
password,
form_metadata,
)
.await
.map_err(|err| PublishError::PublishPrepare(file.to_path_buf(), err))?;

let response = request.send().await.map_err(|err| {
let send_err = PublishSendError::ReqwestMiddleware(registry.clone(), err);
PublishError::PublishSend(file.to_path_buf(), registry.clone(), send_err)
})?;

handle_response(registry, response)
.await
.map_err(|err| PublishError::PublishSend(file.to_path_buf(), registry.clone(), err))
}

/// Calculate the SHA256 of a file.
fn hash_file(path: impl AsRef<Path>) -> Result<String, io::Error> {
// Ideally, this would be async, but in case we actually want to make parallel uploads we should
Expand Down Expand Up @@ -273,42 +355,6 @@ async fn metadata(file: &Path, filename: &DistFilename) -> Result<Metadata, Publ
Ok(Metadata::parse(&contents)?)
}

/// Upload a file to a registry.
///
/// Returns `true` if the file was newly uploaded and `false` if it already existed.
pub async fn upload(
file: &Path,
filename: &DistFilename,
registry: &Url,
client: &BaseClient,
username: Option<&str>,
password: Option<&str>,
) -> Result<bool, PublishError> {
let form_metadata = form_metadata(file, filename)
.await
.map_err(|err| PublishError::PublishPrepare(file.to_path_buf(), err))?;
let request = build_request(
file,
filename,
registry,
client,
username,
password,
form_metadata,
)
.await
.map_err(|err| PublishError::PublishPrepare(file.to_path_buf(), err))?;

let response = request.send().await.map_err(|err| {
let send_err = PublishSendError::ReqwestMiddleware(registry.clone(), err);
PublishError::PublishSend(file.to_path_buf(), registry.clone(), send_err)
})?;

handle_response(registry, response)
.await
.map_err(|err| PublishError::PublishSend(file.to_path_buf(), registry.clone(), err))
}

/// Collect the non-file field for the multipart request from the package METADATA.
async fn form_metadata(
file: &Path,
Expand Down
153 changes: 153 additions & 0 deletions crates/uv-publish/src/trusted_publishing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//! Trusted publishing (via OIDC) with GitHub actions.
use reqwest::{header, Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::env;
use std::env::VarError;
use thiserror::Error;
use tracing::{debug, trace};
use url::Url;
use uv_client::BaseClient;

#[derive(Debug, Error)]
pub enum TrustedPublishingError {
#[error(transparent)]
Var(#[from] VarError),
#[error(transparent)]
Url(#[from] url::ParseError),
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
#[error(transparent)]
SerdeJson(#[from] serde_json::error::Error),
#[error(
"PyPI returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}"
)]
Pypi(StatusCode, String),
}

/// The response from querying `https://pypi.org/_/oidc/audience`.
#[derive(Deserialize)]
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 {
token: String,
}

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

/// Returns the short-lived token to use for uploading.
pub(crate) async fn get_token(
registry: &Url,
client: &BaseClient,
) -> Result<String, TrustedPublishingError> {
// If this fails, we can skip the audience request.
let oidc_token_request_token = env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN")?;

// When communicating with GitHub and PyPI, we don't want any custom ssl settings or retries, so
// we use a default client.
let client = client.raw_client();

// Request 1: Get the audience
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?;

// Request 3: Get the publishing token from PyPI.
let publish_token = get_publish_token(registry, &oidc_token, client).await?;

debug!("Received token, using trusted publishing");

// Tell GitHub Actions to mask the token in any console logs.
#[allow(clippy::print_stdout)]
{
// If we successfully obtained a token, we know we must be in GitHub Actions, so it's safe
// to use GitHub Actions commands.
println!("::add-mask::{}", &publish_token);
}

Ok(publish_token)
}

async fn get_audience(registry: &Url, client: &Client) -> Result<String, TrustedPublishingError> {
// `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority
// (RFC 3986).
let audience_url = Url::parse(&format!("https://{}/_/oidc/audience", registry.authority()))?;
debug!("Querying the trusted publishing audience from {audience_url}");
let response = client.get(audience_url).send().await?;
let audience = response.error_for_status()?.json::<Audience>().await?;
trace!("The audience is `{}`", &audience.audience);
Ok(audience.audience)
}

async fn get_oidc_token(
audience: &str,
oidc_token_request_token: &str,
client: &Client,
) -> Result<String, TrustedPublishingError> {
let mut oidc_token_url = Url::parse(&env::var("ACTIONS_ID_TOKEN_REQUEST_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(oidc_token_url)
.header(header::AUTHORIZATION, authorization)
.send()
.await?;
let oidc_token: OidcToken = response.error_for_status()?.json().await?;
Ok(oidc_token.value)
}

async fn get_publish_token(
registry: &Url,
oidc_token: &str,
client: Client,
) -> Result<String, TrustedPublishingError> {
let mint_token_url = Url::parse(&format!(
"https://{}/_/oidc/mint-token",
registry.authority()
))?;
debug!("Querying the trusted publishing upload token from {mint_token_url}");
let mint_token_payload = MintTokenRequest {
token: oidc_token.to_string(),
};
let response = client
.post(mint_token_url)
.body(serde_json::to_vec(&mint_token_payload)?)
.send()
.await?;

// reqwest's implementation of `.json()` also goes through `.bytes()`
let status = response.status();
let body = response.bytes().await?;

if status.is_success() {
let publish_token: PublishToken = serde_json::from_slice(&body)?;
Ok(publish_token.token)
} else {
// An error here means that something is misconfigured, e.g. a typo in the PyPI
// configuration, so we're showing the body for more context, see
// https://docs.pypi.org/trusted-publishers/troubleshooting/#token-minting
// for what the body can mean.
Err(TrustedPublishingError::Pypi(
status,
String::from_utf8_lossy(&body).to_string(),
))
}
}
Loading

0 comments on commit d7b23e9

Please sign in to comment.