diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c7c6ccc7..1db30ff93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -304,7 +304,7 @@ jobs: repository-url: "https://test.pypi.org/legacy/" packages-dir: "astral-test-pypa-gh-action/dist" - - name: "Request GitLab OIDC token for impersonation" + - name: "Request GitLab OIDC tokens for impersonation" uses: digital-blueprint/gitlab-pipeline-trigger-action@20e77989b24af658ba138a0aa5291bdc657f1505 # v1.3.0 with: host: gitlab.com @@ -316,21 +316,31 @@ jobs: fail_if_no_artifacts: true download_path: ./gitlab-artifacts - - name: "Load GitLab OIDC token from GitLab job artifacts" + - name: "Load GitLab OIDC tokens from GitLab job artifacts" id: load-gitlab-oidc-token run: | - # we expect ./gitlab-artifacts/*/artifacts/id-token to exist - id_token_file=$(find ./gitlab-artifacts -type f -name id-token | head -n 1) - if [ -z "${id_token_file}" ]; then - echo "No id-token file found in GitLab artifacts" + # we expect ./gitlab-artifacts/*/artifacts/pypi-id-token to exist + pypi_id_token_file=$(find ./gitlab-artifacts -type f -name pypi-id-token | head -n 1) + if [ -z "${pypi_id_token_file}" ]; then + echo "No pypi-id-token file found in GitLab artifacts" exit 1 fi - GITLAB_OIDC_TOKEN=$(cat "${id_token_file}") + GITLAB_PYPI_OIDC_TOKEN=$(cat "${pypi_id_token_file}") - # Add a secret mask for the token. - echo "::add-mask::$GITLAB_OIDC_TOKEN" + # we expect ./gitlab-artifacts/*/artifacts/pyx-id-token to exist + pyx_id_token_file=$(find ./gitlab-artifacts -type f -name pyx-id-token | head -n 1) + if [ -z "${pyx_id_token_file}" ]; then + echo "No pyx-id-token file found in GitLab artifacts" + exit 1 + fi + GITLAB_PYX_OIDC_TOKEN=$(cat "${pyx_id_token_file}") - echo "GITLAB_OIDC_TOKEN=${GITLAB_OIDC_TOKEN}" >> "${GITHUB_OUTPUT}" + # Add secret masks for the tokens. + echo "::add-mask::$GITLAB_PYPI_OIDC_TOKEN" + echo "::add-mask::$GITLAB_PYX_OIDC_TOKEN" + + echo "GITLAB_PYPI_OIDC_TOKEN=${GITLAB_PYPI_OIDC_TOKEN}" >> "${GITHUB_OUTPUT}" + echo "GITLAB_PYX_OIDC_TOKEN=${GITLAB_PYX_OIDC_TOKEN}" >> "${GITHUB_OUTPUT}" - name: "Add password to keyring" run: | @@ -358,7 +368,8 @@ jobs: UV_TEST_PUBLISH_CLOUDSMITH_TOKEN: ${{ secrets.UV_TEST_PUBLISH_CLOUDSMITH_TOKEN }} UV_TEST_PUBLISH_PYX_TOKEN: ${{ secrets.UV_TEST_PUBLISH_PYX_TOKEN }} UV_TEST_PUBLISH_PYTHON_VERSION: ${{ env.PYTHON_VERSION }} - UV_TEST_PUBLISH_GITLAB_OIDC_TOKEN: ${{ steps.load-gitlab-oidc-token.outputs.GITLAB_OIDC_TOKEN }} + UV_TEST_PUBLISH_GITLAB_PYPI_OIDC_TOKEN: ${{ steps.load-gitlab-oidc-token.outputs.GITLAB_PYPI_OIDC_TOKEN }} + UV_TEST_PUBLISH_GITLAB_PYX_OIDC_TOKEN: ${{ steps.load-gitlab-oidc-token.outputs.GITLAB_PYX_OIDC_TOKEN }} required-checks-passed: name: "all required jobs passed" diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index 1f1886eb8..c25afcbe8 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -40,7 +40,10 @@ use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_warnings::warn_user; use crate::trusted_publishing::pypi::PyPIPublishingService; -use crate::trusted_publishing::{TrustedPublishingError, TrustedPublishingToken}; +use crate::trusted_publishing::pyx::PyxPublishingService; +use crate::trusted_publishing::{ + TrustedPublishingError, TrustedPublishingService, TrustedPublishingToken, +}; #[derive(Error, Debug)] pub enum PublishError { @@ -402,6 +405,7 @@ pub async fn check_trusted_publishing( username: Option<&str>, password: Option<&str>, keyring_provider: KeyringProviderType, + token_store: &PyxTokenStore, trusted_publishing: TrustedPublishing, registry: &DisplaySafeUrl, client: &BaseClient, @@ -417,9 +421,21 @@ pub async fn check_trusted_publishing( } debug!("Attempting to get a token for trusted publishing"); + // Attempt to get a token for trusted publishing. - let service = PyPIPublishingService::new(registry, client); - match trusted_publishing::get_token(&service).await { + let token = if token_store.is_known_url(registry) { + debug!("Using trusted publishing flow for pyx"); + PyxPublishingService::new(registry, client) + .get_token() + .await + } else { + debug!("Using trusted publishing flow for PyPI"); + PyPIPublishingService::new(registry, client) + .get_token() + .await + }; + + match token { // Success: we have a token for trusted publishing. Ok(Some(token)) => Ok(TrustedPublishResult::Configured(token)), // Failed to discover an ambient OIDC token. @@ -447,11 +463,22 @@ pub async fn check_trusted_publishing( return Err(PublishError::MixedCredentials(conflicts.join(" and "))); } - let service = PyPIPublishingService::new(registry, client); - let Some(token) = trusted_publishing::get_token(&service) - .await - .map_err(Box::new)? - else { + // Attempt to get a token for trusted publishing. + let token = if token_store.is_known_url(registry) { + debug!("Using trusted publishing flow for pyx"); + PyxPublishingService::new(registry, client) + .get_token() + .await + .map_err(Box::new)? + } else { + debug!("Using trusted publishing flow for PyPI"); + PyPIPublishingService::new(registry, client) + .get_token() + .await + .map_err(Box::new)? + }; + + let Some(token) = token else { return Err(PublishError::TrustedPublishing( TrustedPublishingError::NoToken.into(), )); diff --git a/crates/uv-publish/src/trusted_publishing.rs b/crates/uv-publish/src/trusted_publishing.rs index c5741c45e..291454617 100644 --- a/crates/uv-publish/src/trusted_publishing.rs +++ b/crates/uv-publish/src/trusted_publishing.rs @@ -12,6 +12,7 @@ use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_static::EnvVars; pub(crate) mod pypi; +pub(crate) mod pyx; #[derive(Debug, Error)] pub enum TrustedPublishingError { @@ -35,12 +36,17 @@ pub enum TrustedPublishingError { #[error(transparent)] SerdeJson(#[from] serde_json::error::Error), #[error( - "PyPI returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}\nToken claims, which must match the PyPI configuration: {2:#?}" + "Server returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}\nToken claims, which must match the publisher configuration: {2:#?}" )] - Pypi(StatusCode, String, OidcTokenClaims), + TokenRejected(StatusCode, String, OidcTokenClaims), /// 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}")] + #[error( + "Server returned error code {0}, and the OIDC has an unexpected format.\nResponse: {1}" + )] InvalidOidcToken(StatusCode, String), + /// The user gave us a malformed upload URL for trusted publishing with pyx. + #[error("The upload URL `{0}` does not look like a valid pyx upload URL")] + InvalidPyxUploadUrl(DisplaySafeUrl), } #[derive(Deserialize)] @@ -74,62 +80,88 @@ struct PublishToken { /// The payload of the OIDC token. #[derive(Deserialize, Debug)] #[allow(dead_code)] -pub struct OidcTokenClaims { +#[serde(untagged)] +pub enum OidcTokenClaims { + GitHub(GitHubTokenClaims), + GitLab(GitLabTokenClaims), + Buildkite(BuildkiteTokenClaims), +} + +/// The relevant payload of a GitHub OIDC token. +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct GitHubTokenClaims { sub: String, repository: String, repository_owner: String, repository_owner_id: String, job_workflow_ref: String, r#ref: String, + environment: Option, +} + +/// The relevant payload of a GitLab OIDC token. +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct GitLabTokenClaims { + sub: String, + project_path: String, + ci_config_ref_uri: String, + environment: Option, +} + +/// The relevant payload of a Buildkite OIDC token. +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct BuildkiteTokenClaims { + sub: String, + pipeline_slug: String, + organization_slug: String, } /// A service (i.e. uploadable index) that supports trusted publishing. +/// +/// Interactions should go through the default [`get_token`]; implementors +/// should implement the constituent trait methods. pub(crate) trait TrustedPublishingService { - /// Borrow the HTTP client with middleware. + /// Borrow an HTTP client with middleware. fn client(&self) -> &ClientWithMiddleware; /// Retrieve the service's expected OIDC audience. async fn audience(&self) -> Result; /// Exchange an ambient OIDC identity token for a short-lived upload token on the service. - async fn publish_token( + async fn exchange_token( &self, oidc_token: ambient_id::IdToken, ) -> Result; -} -/// 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( - service: &impl TrustedPublishingService, -) -> Result, TrustedPublishingError> { - // Get the OIDC token's audience from the registry. - let audience = service.audience().await?; + /// Perform the full trusted publishing token exchange. + async fn get_token(&self) -> Result, TrustedPublishingError> { + // Get the OIDC token's audience from the registry. + let audience = self.audience().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, service.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, self.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 = service.publish_token(oidc_token).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 = self.exchange_token(oidc_token).await?; - // If we're on GitHub Actions, mask the exchanged token in logs. - #[expect(clippy::print_stdout)] - if env::var(EnvVars::GITHUB_ACTIONS) == Ok("true".to_string()) { - println!("::add-mask::{publish_token}"); + // If we're on GitHub Actions, mask the exchanged token in logs. + #[expect(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(Some(publish_token)) - } else { - // Not in a supported CI environment for trusted publishing. - Ok(None) } } diff --git a/crates/uv-publish/src/trusted_publishing/pypi.rs b/crates/uv-publish/src/trusted_publishing/pypi.rs index 8651b1f95..54d3e6d73 100644 --- a/crates/uv-publish/src/trusted_publishing/pypi.rs +++ b/crates/uv-publish/src/trusted_publishing/pypi.rs @@ -61,7 +61,7 @@ impl TrustedPublishingService for PyPIPublishingService<'_> { Ok(audience.audience) } - async fn publish_token( + async fn exchange_token( &self, oidc_token: ambient_id::IdToken, ) -> Result { @@ -107,7 +107,7 @@ impl TrustedPublishingService for PyPIPublishingService<'_> { // configuration, so we're showing the body and the JWT claims for more context, see // https://docs.pypi.org/trusted-publishers/troubleshooting/#token-minting // for what the body can mean. - Err(TrustedPublishingError::Pypi( + Err(TrustedPublishingError::TokenRejected( status, String::from_utf8_lossy(&body).to_string(), claims, diff --git a/crates/uv-publish/src/trusted_publishing/pyx.rs b/crates/uv-publish/src/trusted_publishing/pyx.rs new file mode 100644 index 000000000..60fdba92c --- /dev/null +++ b/crates/uv-publish/src/trusted_publishing/pyx.rs @@ -0,0 +1,153 @@ +//! Services that implement pyx's Trusted Publishing interfaces. +//! +//! In practice, this is primarily for pyx.dev. + +use tracing::{debug, trace}; +use url::Url; +use uv_redacted::DisplaySafeUrl; + +use crate::trusted_publishing::{ + Audience, MintTokenRequest, PublishToken, TrustedPublishingError, TrustedPublishingService, + TrustedPublishingToken, decode_oidc_token, +}; + +pub(crate) struct PyxPublishingService<'a> { + pub(crate) client: &'a reqwest_middleware::ClientWithMiddleware, + pub(crate) registry: &'a uv_redacted::DisplaySafeUrl, +} + +impl<'a> PyxPublishingService<'a> { + pub(crate) fn new( + registry: &'a uv_redacted::DisplaySafeUrl, + client: &'a uv_client::BaseClient, + ) -> Self { + Self { + client: client.for_host(registry).raw_client(), + registry, + } + } +} + +impl TrustedPublishingService for PyxPublishingService<'_> { + fn client(&self) -> &reqwest_middleware::ClientWithMiddleware { + self.client + } + + async fn audience(&self) -> Result { + // Prefer HTTPS for OIDC discovery; allow HTTP only in test builds + let scheme: &str = if cfg!(feature = "test") { + self.registry.scheme() + } else { + "https" + }; + + let audience_url = DisplaySafeUrl::parse(&format!( + "{}://{}/v1/trusted-publishing/audience", + scheme, + self.registry.authority() + ))?; + + debug!("Querying the trusted publishing audience from {audience_url}"); + + let response = self + .client + .get(Url::from(audience_url.clone())) + .send() + .await + .map_err(|err| TrustedPublishingError::ReqwestMiddleware(audience_url.clone(), err))?; + let audience = response + .error_for_status() + .map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))? + .json::() + .await + .map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))?; + trace!("The audience is `{}`", &audience.audience); + + Ok(audience.audience) + } + + async fn exchange_token( + &self, + oidc_token: ambient_id::IdToken, + ) -> Result { + // Prefer HTTPS for OIDC minting; allow HTTP only in test builds + let scheme: &str = if cfg!(feature = "test") { + self.registry.scheme() + } else { + "https" + }; + + // A pyx upload path looks like `/v1/upload/{workspace_name}/{registry_name}`; a trailing + // slash is also permitted. + // We need to extract the workspace and registry names from the path + // so that we can construct the token minting URL. + let path_segments: Vec<&str> = self + .registry + .path_segments() + .map_or(Vec::new(), std::iter::Iterator::collect); + + let (["v1", "upload", workspace_name, registry_name] + | ["v1", "upload", workspace_name, registry_name, "/"]) = path_segments[..] + else { + return Err(TrustedPublishingError::InvalidPyxUploadUrl( + self.registry.clone(), + )); + }; + + let mint_token_url = DisplaySafeUrl::parse(&format!( + "{}://{}/v1/trusted-publishing/{}/{}/mint-token", + scheme, + self.registry.authority(), + workspace_name, + registry_name + ))?; + + debug!("Querying the trusted publishing upload token from {mint_token_url}"); + let mint_token_payload = MintTokenRequest { + token: oidc_token.reveal().to_string(), + }; + let response = self + .client + .post(Url::from(mint_token_url.clone())) + .body(serde_json::to_vec(&mint_token_payload)?) + .send() + .await + .map_err(|err| { + TrustedPublishingError::ReqwestMiddleware(mint_token_url.clone(), err) + })?; + + // reqwest's implementation of `.json()` also goes through `.bytes()` + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| TrustedPublishingError::Reqwest(mint_token_url.clone(), err))?; + + if status.is_success() { + let publish_token: PublishToken = serde_json::from_slice(&body)?; + Ok(publish_token.token) + } else { + match decode_oidc_token(oidc_token.reveal()) { + Some(claims) => { + // An error here means that something is misconfigured, e.g. a typo in the PyPI + // configuration, so we're showing the body and the JWT claims for more context, see + // https://docs.pypi.org/trusted-publishers/troubleshooting/#token-minting + // for what the body can mean. + Err(TrustedPublishingError::TokenRejected( + status, + String::from_utf8_lossy(&body).to_string(), + claims, + )) + } + None => { + // This is not a user configuration error, the OIDC token should always have a valid + // format. + Err(TrustedPublishingError::InvalidOidcToken( + status, + String::from_utf8_lossy(&body).to_string(), + )) + } + } + } + } +} diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs index 5cf78a625..759d794a7 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -406,6 +406,7 @@ async fn gather_credentials( username.as_deref(), password.as_deref(), keyring_provider, + token_store, trusted_publishing, &publish_url, oidc_client, diff --git a/scripts/publish/test_publish.py b/scripts/publish/test_publish.py index 252817b0d..0e4f21d8e 100644 --- a/scripts/publish/test_publish.py +++ b/scripts/publish/test_publish.py @@ -78,7 +78,6 @@ from time import sleep import httpx from packaging.utils import ( - InvalidSdistFilename, parse_sdist_filename, parse_wheel_filename, ) @@ -125,6 +124,14 @@ class TargetConfiguration: index_url: str index: str | None = None attestations: bool = False + """ + The strategy to use to obtain a fresh version for upload. + + 'latest' means to query the index and select the next unused version. + + 'timestamp' means to synthesize a version based on the current timestamp, + e.g. 0.YYYYMMDDHHMMSS.NNN, where NNN is milliseconds. + """ def index_declaration(self) -> str | None: if not self.index: @@ -167,11 +174,6 @@ class Plan: precedence over it. """ - fresh_version: Version | None = None - """ - A "fresh" version that doesn't exist on the target index yet. - """ - def full_env(self) -> dict[str, str]: """Return the full environment for running uv publish.""" return {**os.environ, **self.env} @@ -242,62 +244,24 @@ all_targets: dict[str, TargetConfiguration] = local_targets | { # OIDC token in addition to the `aud:testpypi` one. attestations=False, ), - # TODO: Not enabled until we have a native Trusted Publishing flow for pyx in uv. - # "pyx-trusted-publishing": TargetConfiguration( - # "astral-test-trusted-publishing", - # "https://api.pyx.dev/v1/upload/astral-test/main", - # "https://api.pyx.dev/simple/astral-test/main", - # ), + "pyx-trusted-publishing-github": TargetConfiguration( + "astral-test-trusted-publishing", + "https://api.pyx.dev/v1/upload/astral-test/test-uv-trusted-publishing", + "https://api.pyx.dev/simple/astral-test/test-uv-trusted-publishing/", + index=None, + ), + "pyx-trusted-publishing-gitlab": TargetConfiguration( + "astral-test-trusted-publishing-gitlab", + publish_url="https://api.pyx.dev/v1/upload/astral-test/test-uv-trusted-publishing", + index_url="https://api.pyx.dev/simple/astral-test/test-uv-trusted-publishing/", + index=None, + ), } # Temporarily disable codeberg on CI due to unreliability. all_targets.pop("codeberg", None) -def get_latest_version(plan: Plan, client: httpx.Client) -> Version | None: - """Return the latest version on all indexes of the package.""" - # To keep the number of packages small we reuse them across targets, so we have to - # pick a version that doesn't exist on any target yet - versions = set() - url = plan.configuration.index_url + plan.configuration.project_name + "/" - - # Get with retries - error = None - for _ in range(5): - try: - versions.update(collect_versions(url, client)) - break - except httpx.HTTPError as err: - error = err - print( - f"Error getting version for {plan.configuration.project_name}, sleeping for 1s: {err}", - file=sys.stderr, - ) - time.sleep(1) - except InvalidSdistFilename as err: - # Sometimes there's a link that says "status page" - error = err - print( - f"Invalid index page for {plan.configuration.project_name}, sleeping for 1s: {err}", - file=sys.stderr, - ) - time.sleep(1) - else: - raise RuntimeError(f"Failed to fetch {url}") from error - - if not versions: - return None - - return max(versions) - - -def get_new_version(latest_version: Version) -> Version: - """Bump the path version to obtain an empty version.""" - release = list(latest_version.release) - release[-1] += 1 - return Version(".".join(str(i) for i in release)) - - def collect_versions(url: str, client: httpx.Client) -> set[Version]: """Return all version from an index page.""" versions = set() @@ -467,74 +431,60 @@ def wait_for_index( sleep(2) +def get_fresh_version(plan: Plan) -> Version: + """Get a fresh version.""" + timestamp = time.strftime("%Y%m%d%H%M%S", time.gmtime()) + milliseconds = int((time.time() % 1) * 1000) + return Version(f"0.{timestamp}.{milliseconds:03d}") + + def test_fresh_upload( plan: Plan, client: httpx.Client ) -> tuple[Version, Path, list[str]]: project_name = plan.configuration.project_name - # If a version was recently uploaded by another run of this script, - # `get_latest_version` may get a cached version and uploading fails. In this case - # we wait and try again. - retries = 3 - while True: - print(f"\nPublish {project_name} for {plan.target}", file=sys.stderr) + print(f"\nPublish {project_name} for {plan.target}", file=sys.stderr) - # The distributions are build to the dist directory of the project. - previous_version = get_latest_version(plan, client) or Version("0.0.0") - version = get_new_version(previous_version) - project_dir = build_project_at_version(plan.target, version, plan.uv) + version = get_fresh_version(plan) + project_dir = build_project_at_version(plan.target, version, plan.uv) - # Upload configuration - publish_url = plan.configuration.publish_url - expected_filenames = [ - path.name - for path in project_dir.joinpath("dist").iterdir() - if path.name.endswith((".tar.gz", ".whl")) - ] + # Upload configuration + publish_url = plan.configuration.publish_url + expected_filenames = [ + path.name + for path in project_dir.joinpath("dist").iterdir() + if path.name.endswith((".tar.gz", ".whl")) + ] - if plan.configuration.attestations: - trust = ClientTrustConfig.production() - identity = oidc.detect_credential() + if plan.configuration.attestations: + trust = ClientTrustConfig.production() + identity = oidc.detect_credential() - if not identity: - raise RuntimeError("Failed to detect OIDC credential for signing") + if not identity: + raise RuntimeError("Failed to detect OIDC credential for signing") - identity_token = oidc.IdentityToken(identity) - context = SigningContext.from_trust_config(trust) + identity_token = oidc.IdentityToken(identity) + context = SigningContext.from_trust_config(trust) - with context.signer(identity_token=identity_token) as signer: - for dist_name in expected_filenames: - dist_path = project_dir / "dist" / dist_name + with context.signer(identity_token=identity_token) as signer: + for dist_name in expected_filenames: + dist_path = project_dir / "dist" / dist_name - dist = Distribution.from_file(dist_path) - attestation = Attestation.sign(signer, dist) + dist = Distribution.from_file(dist_path) + attestation = Attestation.sign(signer, dist) - attestation_path = dist_path.with_suffix( - dist_path.suffix + ".publish.attestation" - ) - attestation_path.write_text(attestation.model_dump_json()) + attestation_path = dist_path.with_suffix( + dist_path.suffix + ".publish.attestation" + ) + attestation_path.write_text(attestation.model_dump_json()) - print( - f"\n=== 1. Publishing a new version: {project_name} {version} {publish_url} ===", - file=sys.stderr, - ) + print( + f"\n=== 1. Publishing a new version: {project_name} {version} {publish_url} ===", + file=sys.stderr, + ) - args = [plan.uv, "publish", "--publish-url", publish_url, *plan.extra_args] - result = run(args, cwd=project_dir, env=plan.full_env(), text=True, stderr=PIPE) - if result.returncode == 0: - # Successful upload - break - - retries -= 1 - if retries > 0: - print( - f"Publish failed, retrying after 10s:\n---\n{result.stderr}\n---", - file=sys.stderr, - ) - sleep(10) - else: - # Raise the error after three failures - result.check_returncode() + args = [plan.uv, "publish", "--publish-url", publish_url, *plan.extra_args] + run(args, cwd=project_dir, env=plan.full_env(), check=True) if plan.configuration.attestations: wait_for_index(plan, version) @@ -549,16 +499,18 @@ def test_reupload_same_files( project_dir: Path, expected_filenames: list[str], ): - """Test that re-uploading the same files works on PyPI. + """Test that re-uploading the same files works on PyPI.""" - NOTE: This skips Trusted Publishing with GitLab, since it uses - a static OIDC token that can't be reused across `uv publish` invocations. - """ - - if plan.configuration.publish_url != TEST_PYPI_PUBLISH_URL: - return - - if plan.target in ("pypi-trusted-publishing-gitlab",): + # NOTE: Skips targets aren't PyPI or pyx, since PyPI and pyx are the only + # ones known to have the "same file" behavior tested below. + # Also skips Trusted Publishing with GitLab, since it uses + # a static OIDC token that can't be reused across `uv publish` invocations. + if ( + plan.configuration.publish_url != TEST_PYPI_PUBLISH_URL + or plan.target.startswith("pyx-") + or plan.target + in ("pypi-trusted-publishing-gitlab", "pyx-trusted-publishing-gitlab") + ): return # Confirm pypi behaviour: Uploading the same file again is fine. @@ -604,12 +556,18 @@ def test_reupload_with_check_url( ): """ Test that re-uploading with check URL or index skips existing files. - - NOTE: This skips Trusted Publishing with GitLab, since it uses - a static OIDC token that can't be reused across `uv publish` invocations. """ - if plan.target in ("pypi-trusted-publishing-gitlab",): + # NOTE: Skips: + # - Trusted Publishing to PyPI with GitLab, since GitLab CI uses a static + # OIDC token that can't be reused across `uv publish` invocations. + # - Trusted Publishing to pyx with GitHub, since `--check-url` requires + # a read credential for pyx, whereas Trusted Publishing is write-only. + if plan.target in ( + "pypi-trusted-publishing-gitlab", + "pyx-trusted-publishing-github", + "pyx-trusted-publishing-gitlab", + ): return mode = "index" if plan.configuration.index else "check URL" @@ -665,12 +623,18 @@ def test_reupload_modified_files( This verifies that the check URL properly detects when local files don't match the files already on the index. - - NOTE: This skips Trusted Publishing with GitLab, since it uses - a static OIDC token that can't be reused across `uv publish` invocations. """ - if plan.target in ("pypi-trusted-publishing-gitlab",): + # NOTE: Skips: + # - Trusted Publishing to pyx/PyPI with GitLab, since GitLab CI uses a static + # OIDC token that can't be reused across `uv publish` invocations. + # - Trusted Publishing to pyx with GitHub, since `--check-url` requires + # a read credential for pyx, whereas Trusted Publishing is write-only. + if plan.target in ( + "pypi-trusted-publishing-gitlab", + "pyx-trusted-publishing-github", + "pyx-trusted-publishing-gitlab", + ): return # Build a different source dist and wheel at the same version, so the upload fails @@ -762,7 +726,21 @@ def target_configuration(target: str) -> tuple[dict[str, str], list[str]]: "GITLAB_CI": "true", # NOTE: We may or may not be running in GitHub Actions, so we explicitly toggle this off. "GITHUB_ACTIONS": "false", - "TESTPYPI_ID_TOKEN": os.environ["UV_TEST_PUBLISH_GITLAB_OIDC_TOKEN"], + "TESTPYPI_ID_TOKEN": os.environ["UV_TEST_PUBLISH_GITLAB_PYPI_OIDC_TOKEN"], + } + elif target == "pyx-trusted-publishing-github": + extra_args = ["--trusted-publishing", "always"] + env = {} + elif target == "pyx-trusted-publishing-gitlab": + extra_args = ["--trusted-publishing", "always"] + # We need to impersonate a Gitlab CI environment here. + # To do that, we set the CI environment variables accordingly. + env = { + "CI": "true", + "GITLAB_CI": "true", + # NOTE: We may or may not be running in GitHub Actions, so we explicitly toggle this off. + "GITHUB_ACTIONS": "false", + "PYX_ID_TOKEN": os.environ["UV_TEST_PUBLISH_GITLAB_PYX_OIDC_TOKEN"], } elif target == "gitlab": env = {"UV_PUBLISH_PASSWORD": os.environ["UV_TEST_PUBLISH_GITLAB_PAT"]} @@ -807,7 +785,7 @@ def main(): logging.basicConfig( format="%(levelname)s [%(asctime)s] %(name)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", - level=logging.DEBUG, + level=logging.INFO, ) parser = ArgumentParser()