diff --git a/.github/workflows/build-release-binaries.yml b/.github/workflows/build-release-binaries.yml index 59273ba0482f7..5457eeb267a2e 100644 --- a/.github/workflows/build-release-binaries.yml +++ b/.github/workflows/build-release-binaries.yml @@ -13,6 +13,10 @@ on: plan: required: false type: string + environment: + description: "GitHub environment for secrets (e.g., code signing certificates)" + required: false + type: string concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -86,6 +90,7 @@ jobs: macos-x86_64: name: x86_64-apple-darwin runs-on: depot-macos-14 + environment: ${{ inputs.environment }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -110,6 +115,10 @@ jobs: args: --release --locked --out dist --features self-update --compatibility pypi env: CARGO: ${{ github.workspace }}/scripts/cargo.sh + CODE_SIGN_IDENTITY: ${{ secrets.CODE_SIGN_IDENTITY }} + CODE_SIGN_CERTIFICATE: ${{ secrets.CODE_SIGN_CERTIFICATE }} + CODE_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODE_SIGN_CERTIFICATE_PASSWORD }} + CODE_SIGN_ALLOW_UNTRUSTED: ${{ vars.CODE_SIGN_ALLOW_UNTRUSTED }} - name: "Upload wheels" uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -143,6 +152,10 @@ jobs: args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml --compatibility pypi env: CARGO: ${{ github.workspace }}/scripts/cargo.sh + CODE_SIGN_IDENTITY: ${{ secrets.CODE_SIGN_IDENTITY }} + CODE_SIGN_CERTIFICATE: ${{ secrets.CODE_SIGN_CERTIFICATE }} + CODE_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODE_SIGN_CERTIFICATE_PASSWORD }} + CODE_SIGN_ALLOW_UNTRUSTED: ${{ vars.CODE_SIGN_ALLOW_UNTRUSTED }} - name: "Upload wheels uv-build" uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -152,6 +165,7 @@ jobs: macos-aarch64: name: aarch64-apple-darwin runs-on: depot-macos-14 + environment: ${{ inputs.environment }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -177,6 +191,10 @@ jobs: args: --release --locked --out dist --features self-update --compatibility pypi env: CARGO: ${{ github.workspace }}/scripts/cargo.sh + CODE_SIGN_IDENTITY: ${{ secrets.CODE_SIGN_IDENTITY }} + CODE_SIGN_CERTIFICATE: ${{ secrets.CODE_SIGN_CERTIFICATE }} + CODE_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODE_SIGN_CERTIFICATE_PASSWORD }} + CODE_SIGN_ALLOW_UNTRUSTED: ${{ vars.CODE_SIGN_ALLOW_UNTRUSTED }} - name: "Test wheel - aarch64" run: | pip install ${PACKAGE_NAME} --no-index --find-links dist/ --force-reinstall @@ -216,6 +234,10 @@ jobs: args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml --compatibility pypi env: CARGO: ${{ github.workspace }}/scripts/cargo.sh + CODE_SIGN_IDENTITY: ${{ secrets.CODE_SIGN_IDENTITY }} + CODE_SIGN_CERTIFICATE: ${{ secrets.CODE_SIGN_CERTIFICATE }} + CODE_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODE_SIGN_CERTIFICATE_PASSWORD }} + CODE_SIGN_ALLOW_UNTRUSTED: ${{ vars.CODE_SIGN_ALLOW_UNTRUSTED }} - name: "Test wheel - aarch64" run: | pip install ${PACKAGE_NAME}_build --no-index --find-links crates/uv-build/dist --force-reinstall @@ -230,6 +252,7 @@ jobs: windows: name: ${{ matrix.platform.target }} runs-on: ${{ matrix.platform.runner }} + environment: ${{ inputs.environment }} strategy: matrix: platform: @@ -256,6 +279,33 @@ jobs: - name: "Install cargo extensions" shell: bash run: scripts/install-cargo-extensions.sh + - name: "Prepare Windows signing certificate" + shell: pwsh + run: | + if (-not $env:CODE_SIGN_CERTIFICATE_BASE64) { + Write-Host "No signing certificate configured, skipping." + return + } + + $certBytes = [Convert]::FromBase64String($env:CODE_SIGN_CERTIFICATE_BASE64) + $pfxPath = Join-Path $env:RUNNER_TEMP "codesign.pfx" + [IO.File]::WriteAllBytes($pfxPath, $certBytes) + + # Find signtool.exe — it's not on PATH on GitHub Actions runners. + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + + if (-not $signtool) { + throw "signtool.exe not found in Windows SDK" + } + + "CODE_SIGN_TOOL_PATH=$signtool" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CODE_SIGN_CERTIFICATE_PATH=$pfxPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CODE_SIGN_CERTIFICATE_PASSWORD=$($env:CODE_SIGN_CERTIFICATE_PASSWORD)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + env: + CODE_SIGN_CERTIFICATE_BASE64: ${{ secrets.CODE_SIGN_CERTIFICATE_BASE64 }} + CODE_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODE_SIGN_CERTIFICATE_PASSWORD }} # uv - name: "Build wheels" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac7b5010545f8..b67ae65c2c8b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,8 @@ jobs: needs: plan if: ${{ needs.plan.outputs.build-release-binaries == 'true' }} uses: ./.github/workflows/build-release-binaries.yml + with: + environment: release-test secrets: inherit build-docker: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 334840df1af56..0621f8cb4d41d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,7 @@ jobs: uses: ./.github/workflows/build-release-binaries.yml with: plan: ${{ needs.plan.outputs.val }} + environment: release secrets: inherit custom-build-docker: diff --git a/scripts/cargo-auditable.cmd b/scripts/cargo-auditable.cmd new file mode 100644 index 0000000000000..8f50f8f2d32cd --- /dev/null +++ b/scripts/cargo-auditable.cmd @@ -0,0 +1,5 @@ +@echo off +REM Cargo wrapper that runs `cargo auditable` to embed SBOM metadata. +REM See cargo-auditable.sh for the full explanation. + +cargo.exe auditable %* diff --git a/scripts/cargo-auditable.sh b/scripts/cargo-auditable.sh new file mode 100755 index 0000000000000..6480332c20821 --- /dev/null +++ b/scripts/cargo-auditable.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +## Cargo wrapper that runs `cargo auditable` to embed SBOM metadata. +## +## Used as the inner build command for `cargo-code-sign`. +## +## Usage: +## +## CARGO_CODE_SIGN_CARGO="$PWD/scripts/cargo-auditable.sh" cargo-code-sign code-sign ... + +set -eu + +exec cargo auditable "$@" diff --git a/scripts/cargo-code-sign.cmd b/scripts/cargo-code-sign.cmd new file mode 100644 index 0000000000000..f64219a994ee3 --- /dev/null +++ b/scripts/cargo-code-sign.cmd @@ -0,0 +1,5 @@ +@echo off +REM Cargo wrapper that signs binaries after building via `cargo-code-sign`. +REM See cargo-code-sign.sh for the full explanation. + +cargo-code-sign.exe code-sign %* diff --git a/scripts/cargo-code-sign.sh b/scripts/cargo-code-sign.sh new file mode 100755 index 0000000000000..dce08dae6c901 --- /dev/null +++ b/scripts/cargo-code-sign.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +## Cargo wrapper that signs binaries after building via `cargo-code-sign`. +## +## Uses `CARGO_CODE_SIGN_CARGO` to determine the inner cargo command. +## If unset, falls back to plain `cargo`. +## +## Usage: +## +## CARGO_CODE_SIGN_CARGO="$PWD/scripts/cargo-auditable.sh" \ +## scripts/cargo-code-sign.sh build --release + +set -eu + +exec cargo-code-sign code-sign "$@" diff --git a/scripts/cargo.cmd b/scripts/cargo.cmd index b2b09522a12d8..f1109f2cc27d2 100644 --- a/scripts/cargo.cmd +++ b/scripts/cargo.cmd @@ -1,15 +1,8 @@ @echo off -REM Wrapper script that invokes `cargo auditable` instead of plain `cargo`. +REM Top-level cargo wrapper for release builds. REM -REM Use `scripts/install-cargo-extensions.sh` to install the dependencies. -REM -REM Usage: -REM -REM set CARGO=%CD%\scripts\cargo.cmd -REM cargo build --release +REM Chains `cargo-code-sign` (post-build binary signing) with `cargo-auditable` +REM (SBOM embedding). See cargo.sh for the full explanation. -if defined REAL_CARGO ( - "%REAL_CARGO%" auditable %* -) else ( - cargo.exe auditable %* -) +set CARGO_CODE_SIGN_CARGO=%~dp0cargo-auditable.cmd +%~dp0cargo-code-sign.cmd %* diff --git a/scripts/cargo.sh b/scripts/cargo.sh index a34b6c17d755a..230a2b2b79a68 100755 --- a/scripts/cargo.sh +++ b/scripts/cargo.sh @@ -1,16 +1,22 @@ #!/usr/bin/env sh -## Wrapper script that invokes `cargo auditable` instead of plain `cargo`. +## Top-level cargo wrapper for release builds. +## +## Chains `cargo-code-sign` (post-build binary signing) with `cargo-auditable` +## (SBOM embedding): +## +## maturin -> cargo.sh -> cargo-code-sign -> cargo-auditable -> cargo ## ## Use `scripts/install-cargo-extensions.sh` to install the dependencies. ## ## Usage: ## -## CARGO="$PWD/scripts/cargo.sh" cargo build --release +## CARGO="$PWD/scripts/cargo.sh" maturin build --release set -eu -if [ -n "${REAL_CARGO:-}" ]; then - exec "$REAL_CARGO" auditable "$@" -else - exec cargo auditable "$@" -fi +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Tell cargo-code-sign to use cargo-auditable as the inner build command. +export CARGO_CODE_SIGN_CARGO="${SCRIPT_DIR}/cargo-auditable.sh" + +exec "${SCRIPT_DIR}/cargo-code-sign.sh" "$@" diff --git a/scripts/check-release-artifacts-signed.sh b/scripts/check-release-artifacts-signed.sh new file mode 100755 index 0000000000000..66f928bddd45e --- /dev/null +++ b/scripts/check-release-artifacts-signed.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +## Check that release artifacts from a CI run are code-signed. +## +## Downloads macOS and Windows artifacts and wheels from the given GitHub +## Actions run, extracts binaries, and verifies: +## - macOS: codesign identity signature (not ad-hoc) +## - Windows: Authenticode signature present +## +## Usage: +## scripts/check-release-artifacts-signed.sh + +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +missing=() +command -v gh >/dev/null 2>&1 || missing+=(gh) +command -v codesign >/dev/null 2>&1 || missing+=(codesign) +command -v osslsigncode >/dev/null 2>&1 || missing+=(osslsigncode) + +if [ ${#missing[@]} -gt 0 ]; then + echo "error: missing required tools: ${missing[*]}" >&2 + echo "" >&2 + echo "Install with:" >&2 + for tool in "${missing[@]}"; do + case "$tool" in + gh) echo " brew install gh" >&2 ;; + codesign) echo " (requires macOS)" >&2 ;; + osslsigncode) echo " brew install osslsigncode" >&2 ;; + esac + done + exit 1 +fi + +RUN_ID="$1" +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +PASS=0 +FAIL=0 + +pass() { echo "PASS $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL $1"; FAIL=$((FAIL + 1)); } + +check_macos() { + local binary="$1" + local label="$2" + local info + info=$(codesign -dv "$binary" 2>&1) || true + if echo "$info" | grep -q "Signature=adhoc"; then + fail "$label (ad-hoc, not identity-signed)" + elif sig_size=$(echo "$info" | grep "Signature size=" | sed 's/.*Signature size=//'); then + pass "$label (identity-signed, size=$sig_size)" + else + fail "$label (not signed)" + fi +} + +check_windows() { + local binary="$1" + local label="$2" + local output + output=$(osslsigncode verify -in "$binary" 2>&1) || true + if echo "$output" | grep -q "Signer's certificate:"; then + local subject + subject=$(echo "$output" | grep "Subject:" | head -1 | sed 's/.*Subject: //') + pass "$label (Authenticode, $subject)" + else + fail "$label (not Authenticode signed)" + fi +} + +echo "Fetching artifacts for run $RUN_ID..." +ALL_ARTIFACTS=$(gh api "repos/{owner}/{repo}/actions/runs/$RUN_ID/artifacts" \ + --paginate --jq '.artifacts[].name') + +echo "" + +for artifact in $ALL_ARTIFACTS; do + # Only check macOS and Windows archives and wheels. + case "$artifact" in + artifacts-*apple-darwin*|artifacts-macos-*) check=check_macos ;; + artifacts-*windows*|artifacts-*win*) check=check_windows ;; + wheels_uv-*apple-darwin*|wheels_uv-macos-*) check=check_macos ;; + wheels_uv-*windows*|wheels_uv-*win*) check=check_windows ;; + *) continue ;; + esac + + dest="$WORK_DIR/$artifact" + mkdir -p "$dest" + if ! gh run download "$RUN_ID" -n "$artifact" -D "$dest"; then + fail "$artifact (download failed)" + continue + fi + + # Extract everything: tar.gz archives, zip archives, and wheels. + for tarball in "$dest"/*.tar.gz; do + [ -f "$tarball" ] || continue + tar xzf "$tarball" -C "$dest" + done + for zip in "$dest"/*.zip "$dest"/*.whl; do + [ -f "$zip" ] || continue + unzip -qo "$zip" -d "$dest" + done + + # Check each binary. The label shows the archive/wheel filename and binary name, + # e.g. "uv-x86_64-apple-darwin.tar.gz uv" or "uv-0.10.8-py3-none-win_amd64.whl uv.exe". + while IFS= read -r binary; do + bin_name=$(basename "$binary") + # Walk up to find the archive or wheel this binary came from. + archive="" + for f in "$dest"/*.tar.gz "$dest"/*.zip "$dest"/*.whl; do + [ -f "$f" ] && archive=$(basename "$f") && break + done + $check "$binary" "${archive:-$artifact} / $bin_name" + done < <(find "$dest" \( -name "uv" -o -name "uvx" -o -name "uv.exe" -o -name "uvx.exe" -o -name "uvw.exe" \) -type f ! -name "*.sha256") +done + +echo "" +echo "PASS $PASS / FAIL $FAIL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/scripts/generate-codesign-test-secrets.sh b/scripts/generate-codesign-test-secrets.sh new file mode 100755 index 0000000000000..3006990832354 --- /dev/null +++ b/scripts/generate-codesign-test-secrets.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +## Generate a self-signed code signing certificate and populate a GitHub +## environment with the resulting secrets and variables via the `gh` CLI. +## +## Secrets: CODE_SIGN_CERTIFICATE_PASSWORD, CODE_SIGN_IDENTITY, +## CODE_SIGN_CERTIFICATE, CODE_SIGN_CERTIFICATE_BASE64 +## Variables: CODE_SIGN_ALLOW_UNTRUSTED +## +## Usage: +## +## scripts/generate-codesign-test-secrets.sh + +set -euo pipefail + +if ! command -v gh &>/dev/null; then + echo "error: gh CLI is required but not found. Install from https://cli.github.com" >&2 + exit 1 +fi + +REPO="astral-sh/uv" +ENV_NAME="release-test" + +echo "Generating self-signed code signing certificate..." + +CERT_DIR="$(mktemp -d)" +trap 'rm -rf "$CERT_DIR"' EXIT + +CERT_NAME="uv-codesign-test" +P12_PASSWORD="$(uuidgen | tr -d '-')" + +# --------------------------------------------------------------------------- +# Generate a self-signed code-signing certificate as a PKCS#12 / PFX. +# The same file is used for both macOS (.p12) and Windows (.pfx) — they are +# the same format. +# --------------------------------------------------------------------------- + +openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ + -keyout "$CERT_DIR/key.pem" \ + -out "$CERT_DIR/cert.pem" \ + -subj "/CN=$CERT_NAME" \ + -addext "extendedKeyUsage=codeSigning" \ + -addext "keyUsage=digitalSignature" \ + 2>/dev/null + +# Detect whether we need -legacy (OpenSSL 3.x requires it for macOS keychain +# compatibility; LibreSSL shipped with macOS does not support it). +LEGACY_FLAG="" +if openssl version 2>&1 | grep -q "^OpenSSL 3"; then + LEGACY_FLAG="-legacy" +fi + +# shellcheck disable=SC2086 +openssl pkcs12 -export $LEGACY_FLAG \ + -inkey "$CERT_DIR/key.pem" \ + -in "$CERT_DIR/cert.pem" \ + -name "$CERT_NAME" \ + -out "$CERT_DIR/cert.p12" \ + -passout pass:"$P12_PASSWORD" \ + 2>/dev/null + +CERT_B64="$(base64 < "$CERT_DIR/cert.p12" | tr -d '\n')" +CERT_SHA1="$(openssl x509 -in "$CERT_DIR/cert.pem" -noout -fingerprint -sha1 \ + | cut -d= -f2 | tr -d ':')" + +# --------------------------------------------------------------------------- +# Populate the GitHub environment. +# --------------------------------------------------------------------------- + +echo "Setting secrets and variables in '${ENV_NAME}' environment for ${REPO}..." + +# Shared password for the .p12/.pfx certificate. +gh secret set CODE_SIGN_CERTIFICATE_PASSWORD \ + --repo "$REPO" --env "$ENV_NAME" --body "$P12_PASSWORD" + +# macOS: identity (SHA1 fingerprint) and base64-encoded .p12 certificate. +# These are passed directly to cargo-code-sign as CODE_SIGN_IDENTITY and +# CODE_SIGN_CERTIFICATE. +gh secret set CODE_SIGN_IDENTITY \ + --repo "$REPO" --env "$ENV_NAME" --body "$CERT_SHA1" + +gh secret set CODE_SIGN_CERTIFICATE \ + --repo "$REPO" --env "$ENV_NAME" --body "$CERT_B64" + +# Windows: base64-encoded .pfx certificate. The workflow decodes this to a +# file and sets CODE_SIGN_CERTIFICATE_PATH for cargo-code-sign. +gh secret set CODE_SIGN_CERTIFICATE_BASE64 \ + --repo "$REPO" --env "$ENV_NAME" --body "$CERT_B64" + +# Self-signed certs aren't trusted by the macOS keychain, so cargo-code-sign +# needs this to find the identity. +gh variable set CODE_SIGN_ALLOW_UNTRUSTED \ + --repo "$REPO" --env "$ENV_NAME" --body "1" + +echo "" +echo "Done. Set in '${ENV_NAME}' environment for ${REPO}:" +echo " CODE_SIGN_CERTIFICATE_PASSWORD" +echo " CODE_SIGN_IDENTITY" +echo " CODE_SIGN_CERTIFICATE" +echo " CODE_SIGN_CERTIFICATE_BASE64" +echo " CODE_SIGN_ALLOW_UNTRUSTED" diff --git a/scripts/install-cargo-extensions.sh b/scripts/install-cargo-extensions.sh index 9e14345e7b064..a6f6434e61b36 100755 --- a/scripts/install-cargo-extensions.sh +++ b/scripts/install-cargo-extensions.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh ## Install cargo extensions for release builds. ## -## Installs cargo-auditable for SBOM embedding. +## Installs cargo-auditable for SBOM embedding and cargo-code-sign for binary signing. ## ## Includes handling for cross-build containers in our release workflow. ## @@ -17,6 +17,10 @@ CARGO_AUDITABLE_INSTALL="cargo install cargo-auditable \ --locked \ --version 0.7.4" +CARGO_CODE_SIGN_INSTALL="cargo install cargo-code-sign \ + --locked \ + --version 0.2.0" + # In Linux containers running on x86_64, build a static musl binary so the installed tool works in # musl-based environments (Alpine, etc.). # @@ -26,6 +30,8 @@ if [ "$(uname -m 2>/dev/null)" = "x86_64" ] && [ "$(uname -s 2>/dev/null)" = "Li MUSL_TARGET="x86_64-unknown-linux-musl" rustup target add "$MUSL_TARGET" CC=gcc $CARGO_AUDITABLE_INSTALL --target "$MUSL_TARGET" + CC=gcc $CARGO_CODE_SIGN_INSTALL --target "$MUSL_TARGET" else $CARGO_AUDITABLE_INSTALL + $CARGO_CODE_SIGN_INSTALL fi