diff --git a/.github/workflows/rehearse-release-signing.yml b/.github/workflows/rehearse-release-signing.yml
new file mode 100644
index 000000000..0354d14f3
--- /dev/null
+++ b/.github/workflows/rehearse-release-signing.yml
@@ -0,0 +1,230 @@
+name: Rehearse release signing
+
+on:
+ pull_request:
+ paths:
+ - .github/workflows/rehearse-release-signing.yml
+ - scripts/rehearse-release-signing.py*
+ - scripts/smoke-test-signed-python.py
+ - scripts/cpython-entitlements.plist
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Published PBS release to rehearse
+ type: string
+ default: "20260901"
+ python-version:
+ description: CPython version in that release
+ type: string
+ default: "3.14.7"
+ sign:
+ description: Use the protected release signing identity
+ type: boolean
+ default: false
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+defaults:
+ run:
+ shell: bash
+
+env:
+ RELEASE_TAG: ${{ inputs.tag || '20260901' }}
+ PYTHON_VERSION: ${{ inputs.python-version || '3.14.7' }}
+
+jobs:
+ prepare:
+ name: Reassemble unsigned / ${{ matrix.target }}
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix: &targets
+ include:
+ - target: aarch64-apple-darwin
+ system: macos
+ runner: macos-15
+ signer: ubuntu-24.04
+ python: bin/python3
+ uv-checksum: 46740540b63fdee9a6cb2e19baf3f1f475b850c440a33e63455087a6871263f1
+ - target: x86_64-apple-darwin
+ system: macos
+ runner: macos-15-intel
+ signer: ubuntu-24.04
+ python: bin/python3
+ uv-checksum: 0dc8cd6c961582b0d140b5398f96b23502885277fb3464241456a2435e460dfa
+ - target: x86_64-pc-windows-msvc
+ system: windows
+ runner: windows-2025
+ signer: windows-2025
+ python: python.exe
+ uv-checksum: 3d54912924c36e862c14f427d04f2ed70a99e8001d1c30caa101f6d5711626d5
+ env:
+ TARGET: ${{ matrix.target }}
+ SYSTEM: ${{ matrix.system }}
+ PYTHON_BINARY: ${{ matrix.python }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ with:
+ version: "0.12.12"
+ checksum: ${{ matrix.uv-checksum }}
+ enable-cache: false
+ - name: Download published archive
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: uv run --locked scripts/rehearse-release-signing.py download "$RELEASE_TAG" "$PYTHON_VERSION" "$TARGET" original
+ - name: Reassemble without signing
+ run: |
+ archive=(original/*.tar.gz)
+ uv run --locked scripts/rehearse-release-signing.py prepare "$SYSTEM" "${archive[0]}" "unsigned/$TARGET"
+ uv run --locked scripts/rehearse-release-signing.py assemble "$SYSTEM" "${archive[0]}" "unsigned/$TARGET" "assembled/$(basename "${archive[0]}")"
+ uv run --locked scripts/rehearse-release-signing.py check "$SYSTEM" "${archive[0]}" "unsigned/$TARGET" "assembled/$(basename "${archive[0]}")"
+ - name: Exercise assembled Python
+ run: |
+ mkdir installed
+ tar -xzf assembled/*.tar.gz -C installed
+ "installed/python/$PYTHON_BINARY" -I scripts/smoke-test-signed-python.py
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: signing-source-${{ matrix.target }}
+ path: original/*.tar.gz
+ if-no-files-found: error
+ overwrite: true
+
+ sign:
+ name: Sign and reassemble / ${{ matrix.target }}
+ needs: prepare
+ if: >-
+ github.event_name == 'workflow_dispatch' && inputs.sign &&
+ github.repository == 'astral-sh/python-build-standalone' &&
+ github.ref == 'refs/heads/main'
+ runs-on: ${{ matrix.signer }}
+ environment: release
+ permissions:
+ contents: read
+ id-token: write
+ strategy:
+ fail-fast: false
+ matrix: *targets
+ env:
+ TARGET: ${{ matrix.target }}
+ SYSTEM: ${{ matrix.system }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ with:
+ version: "0.12.12"
+ checksum: ${{ runner.os == 'Linux' && 'ab9b309d4586403f024e100abaceb396616e178a553e2500c36087d180f09509' || matrix.uv-checksum }}
+ enable-cache: false
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: signing-source-${{ matrix.target }}
+ path: original
+ - name: Prepare native code
+ id: prepare
+ run: |
+ archive=(original/*.tar.gz)
+ uv run --locked scripts/rehearse-release-signing.py prepare "$SYSTEM" "${archive[0]}" "unsigned/$TARGET"
+ - name: Sign and notarize macOS binaries
+ if: matrix.system == 'macos'
+ uses: astral-sh/github-actions/sign-macos@8d868a52b4aaf77e89d86505026689474cbb8747
+ with:
+ unsigned-directory: unsigned
+ signed-directory: signed
+ entitlements-file: scripts/cpython-entitlements.plist
+ entitlements-binaries: ${{ steps.prepare.outputs.executables }}
+ azure-client-id: ${{ secrets.CODESIGN_AZURE_CLIENT_ID_MACOS }}
+ azure-tenant-id: ${{ secrets.CODESIGN_AZURE_TENANT_ID_MACOS }}
+ azure-subscription-id: ${{ secrets.CODESIGN_AZURE_SUBSCRIPTION_ID_MACOS }}
+ storage-account: ${{ secrets.CODESIGN_STORAGE_ACCOUNT }}
+ storage-container: ${{ secrets.CODESIGN_STORAGE_CONTAINER }}
+ component-rcodesign-blob: ${{ secrets.CODESIGN_COMPONENT_RCODESIGN_BLOB }}
+ component-rcodesign-sha256: ${{ secrets.CODESIGN_COMPONENT_RCODESIGN_SHA256 }}
+ component-pkcs11-blob: ${{ secrets.CODESIGN_COMPONENT_PKCS11_BLOB }}
+ component-pkcs11-sha256: ${{ secrets.CODESIGN_COMPONENT_PKCS11_SHA256 }}
+ azure-keyvault-name: ${{ secrets.CODESIGN_AZURE_KEYVAULT_NAME }}
+ azure-keyvault-key-version: ${{ secrets.CODESIGN_AZURE_KEYVAULT_KEY_VERSION }}
+ key-name: ${{ secrets.CODESIGN_KEY_NAME }}
+ certificate-sha256: ${{ secrets.CODESIGN_CERTIFICATE_SHA256 }}
+ apple-notarization-akv-key-name: ${{ secrets.CODESIGN_APPLE_NOTARIZATION_AKV_KEY_NAME }}
+ - name: Sign Windows binaries
+ if: matrix.system == 'windows'
+ uses: astral-sh/github-actions/sign-windows@8d868a52b4aaf77e89d86505026689474cbb8747
+ with:
+ unsigned-directory: unsigned
+ signed-directory: signed
+ azure-client-id: ${{ secrets.CODESIGN_AZURE_CLIENT_ID_WINDOWS }}
+ azure-tenant-id: ${{ secrets.CODESIGN_AZURE_TENANT_ID_WINDOWS }}
+ azure-subscription-id: ${{ secrets.CODESIGN_AZURE_SUBSCRIPTION_ID_WINDOWS }}
+ endpoint: ${{ secrets.CODESIGN_ENDPOINT }}
+ signing-account-name: ${{ secrets.CODESIGN_SIGNING_ACCOUNT_NAME }}
+ certificate-profile-name: ${{ secrets.CODESIGN_CERTIFICATE_PROFILE_NAME }}
+ certificate-subject: ${{ secrets.CODESIGN_CERTIFICATE_SUBJECT }}
+ - name: Reassemble signed archive
+ run: |
+ archive=(original/*.tar.gz)
+ uv run --locked scripts/rehearse-release-signing.py assemble "$SYSTEM" "${archive[0]}" "signed/$TARGET" "assembled/$(basename "${archive[0]}")"
+ uv run --locked scripts/rehearse-release-signing.py check "$SYSTEM" "${archive[0]}" "signed/$TARGET" "assembled/$(basename "${archive[0]}")"
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: signed-rehearsal-${{ matrix.target }}
+ path: |
+ assembled/
+ signed/
+ if-no-files-found: error
+ overwrite: true
+
+ verify:
+ name: Verify signed archive / ${{ matrix.target }}
+ needs: sign
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix: *targets
+ env:
+ TARGET: ${{ matrix.target }}
+ SYSTEM: ${{ matrix.system }}
+ PYTHON_BINARY: ${{ matrix.python }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ with:
+ version: "0.12.12"
+ checksum: ${{ matrix.uv-checksum }}
+ enable-cache: false
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: signing-source-${{ matrix.target }}
+ path: original
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: signed-rehearsal-${{ matrix.target }}
+ - name: Check complete archive contents
+ run: |
+ archive=(original/*.tar.gz)
+ uv run --locked scripts/rehearse-release-signing.py check "$SYSTEM" "${archive[0]}" "signed/$TARGET" "assembled/$(basename "${archive[0]}")"
+ uv run --locked scripts/rehearse-release-signing.py prepare "$SYSTEM" "assembled/$(basename "${archive[0]}")" packaged-binaries
+ - name: Verify packaged signatures
+ uses: astral-sh/github-actions/verify-binaries@8d868a52b4aaf77e89d86505026689474cbb8747
+ with:
+ signed-directory: signed/${{ matrix.target }}
+ binaries-directory: packaged-binaries
+ - name: Exercise signed Python
+ run: |
+ mkdir installed
+ tar -xzf assembled/*.tar.gz -C installed
+ "installed/python/$PYTHON_BINARY" -I scripts/smoke-test-signed-python.py
diff --git a/scripts/cpython-entitlements.plist b/scripts/cpython-entitlements.plist
new file mode 100644
index 000000000..58598e227
--- /dev/null
+++ b/scripts/cpython-entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+
+
diff --git a/scripts/rehearse-release-signing.py b/scripts/rehearse-release-signing.py
new file mode 100644
index 000000000..04e31a4e9
--- /dev/null
+++ b/scripts/rehearse-release-signing.py
@@ -0,0 +1,265 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = []
+#
+# [tool.uv]
+# no-build = true
+# ///
+"""Prepare and reassemble PBS install-only archives for the signing rehearsal.
+
+Only native code for the selected platform is replaced. All other files, links,
+and archive metadata are retained, and the replacement inventory must match.
+The existing release publisher does not consume these experimental outputs.
+"""
+
+import argparse
+import copy
+import hashlib
+import io
+import json
+import os
+import struct
+import tarfile
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class Member:
+ """One TAR member and its data, if it is a regular file."""
+
+ info: tarfile.TarInfo
+ data: bytes | None
+
+
+def sha256(data: bytes) -> str:
+ """Return the digest used by GitHub release assets and PBS checksums."""
+ return hashlib.sha256(data).hexdigest()
+
+
+def download(tag: str, version: str, target: str, output: Path) -> None:
+ """Download one published stripped archive and verify GitHub's asset digest."""
+ name = f"cpython-{version}+{tag}-{target}-install_only_stripped.tar.gz"
+ url = (
+ "https://api.github.com/repos/astral-sh/python-build-standalone/"
+ f"releases/tags/{urllib.parse.quote(tag, safe='')}"
+ )
+ headers = {"Accept": "application/vnd.github+json"}
+ if token := os.environ.get("GH_TOKEN"):
+ headers["Authorization"] = f"Bearer {token}"
+ with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as source:
+ release = json.load(source)
+ if release["draft"] or release["tag_name"] != tag:
+ raise ValueError("Expected a published release")
+ [asset] = [asset for asset in release["assets"] if asset["name"] == name]
+ with urllib.request.urlopen(asset["browser_download_url"]) as source:
+ data = source.read()
+ if asset["digest"] != f"sha256:{sha256(data)}" or asset["size"] != len(data):
+ raise ValueError(f"Release asset digest or size differs: {name}")
+ output.mkdir(parents=True)
+ (output / name).write_bytes(data)
+ print(f"Downloaded {name} ({sha256(data)})")
+
+
+def read_archive(path: Path) -> list[Member]:
+ """Read a trusted install-only archive, preserving member order and metadata."""
+ members = []
+ names = set()
+ with tarfile.open(path, "r:gz") as archive:
+ for info in archive:
+ if info.name in names:
+ raise ValueError(f"Duplicate archive member: {info.name}")
+ names.add(info.name)
+ source = archive.extractfile(info) if info.isfile() else None
+ data = source.read() if source is not None else None
+ if source is not None:
+ source.close()
+ members.append(Member(info, data))
+ return members
+
+
+def macho_filetypes(data: bytes) -> set[int]:
+ """Read the Mach-O file types from a thin or universal binary's headers."""
+ thin = {
+ b"\xce\xfa\xed\xfe": "<",
+ b"\xcf\xfa\xed\xfe": "<",
+ b"\xfe\xed\xfa\xce": ">",
+ b"\xfe\xed\xfa\xcf": ">",
+ }
+ if data[:4] in thin and len(data) >= 16:
+ return {struct.unpack_from(thin[data[:4]] + "I", data, 12)[0]}
+ fat = {
+ b"\xca\xfe\xba\xbe": (">", "I", 20),
+ b"\xbe\xba\xfe\xca": ("<", "I", 20),
+ b"\xca\xfe\xba\xbf": (">", "Q", 32),
+ b"\xbf\xba\xfe\xca": ("<", "Q", 32),
+ }
+ if data[:4] not in fat or len(data) < 8:
+ return set()
+ endian, width, record_size = fat[data[:4]]
+ count = struct.unpack_from(endian + "I", data, 4)[0]
+ if len(data) < 8 + count * record_size:
+ return set()
+ result = set()
+ for index in range(count):
+ offset, size = struct.unpack_from(
+ endian + width * 2, data, 16 + index * record_size
+ )
+ result.update(macho_filetypes(data[offset : offset + size]))
+ return result
+
+
+def is_native(data: bytes, system: str) -> bool:
+ """Recognize loadable Mach-O images or PE images, independent of filenames."""
+ if system == "macos":
+ # MH_EXECUTE, MH_DYLIB, and MH_BUNDLE. Object files are not signed.
+ return bool(macho_filetypes(data) & {2, 6, 8})
+ if data[:2] != b"MZ" or len(data) < 64:
+ return False
+ offset = struct.unpack_from(" dict[str, bytes]:
+ """Return every regular native-code member that this platform must sign."""
+ binaries = {
+ member.info.name: member.data
+ for member in members
+ if member.data is not None and is_native(member.data, system)
+ }
+ if not binaries:
+ raise ValueError("No native binaries found")
+ if system == "windows" and any(
+ Path(name).suffix.lower() not in {".exe", ".dll", ".pyd"} for name in binaries
+ ):
+ raise ValueError("Found a PE image with an unsupported signing extension")
+ return binaries
+
+
+def prepare(archive: Path, system: str, output: Path) -> None:
+ """Extract only native code, retaining paths used by the shared signing actions."""
+ binaries = native_members(read_archive(archive), system)
+ output.mkdir(parents=True)
+ for name, data in binaries.items():
+ path = output / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data)
+ path.chmod(0o755)
+ executables = sorted(
+ name for name, data in binaries.items() if 2 in macho_filetypes(data)
+ )
+ if github_output := os.environ.get("GITHUB_OUTPUT"):
+ with Path(github_output).open("a", encoding="utf-8") as file:
+ file.write(f"executables={json.dumps(executables)}\n")
+ print(f"Prepared {len(binaries)} {system} binaries from {archive.name}")
+
+
+def replacements(
+ directory: Path, expected: dict[str, bytes], system: str
+) -> dict[str, bytes]:
+ """Require exactly the prepared native-code paths in the signing output."""
+ found = {
+ path.relative_to(directory).as_posix(): path.read_bytes()
+ for path in directory.rglob("*")
+ if path.is_file()
+ and not (system == "macos" and path == directory / "certificate.pem")
+ }
+ if found.keys() != expected.keys():
+ raise ValueError(
+ f"Signing inventory differs: missing={sorted(expected.keys() - found.keys())}, "
+ f"unexpected={sorted(found.keys() - expected.keys())}"
+ )
+ if any(not is_native(data, system) for data in found.values()):
+ raise ValueError("Signing output contains a non-native file")
+ return found
+
+
+def assemble(archive: Path, system: str, signed: Path, output: Path) -> None:
+ """Replace all native code and write a new archive with its checksum sidecar."""
+ members = read_archive(archive)
+ binaries = replacements(signed, native_members(members, system), system)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ if output.exists():
+ raise FileExistsError(output)
+ with tarfile.open(output, "w:gz") as destination:
+ for member in members:
+ info = copy.copy(member.info)
+ data = binaries.get(info.name, member.data)
+ if data is not None:
+ info.size = len(data)
+ if "size" in info.pax_headers:
+ info.pax_headers = {**info.pax_headers, "size": str(len(data))}
+ destination.addfile(info, io.BytesIO(data) if data is not None else None)
+ output.with_name(f"{output.name}.sha256").write_text(
+ f"{sha256(output.read_bytes())} {output.name}\n", encoding="utf-8"
+ )
+
+
+def check(archive: Path, system: str, signed: Path, assembled: Path) -> None:
+ """Check every member's bytes and metadata against its expected replacement."""
+ before = read_archive(archive)
+ after = read_archive(assembled)
+ binaries = replacements(signed, native_members(before, system), system)
+ if len(before) != len(after):
+ raise ValueError("Archive member count changed")
+ for original, actual in zip(before, after, strict=True):
+ expected_data = binaries.get(original.info.name, original.data)
+ expected_info = original.info.get_info()
+ actual_info = actual.info.get_info()
+ # TAR header checksums depend on the header encoding, not its metadata.
+ del expected_info["chksum"], actual_info["chksum"]
+ if expected_data is not None:
+ expected_info["size"] = len(expected_data)
+ if actual.data != expected_data or actual_info != expected_info:
+ raise ValueError(
+ f"Archive member changed unexpectedly: {original.info.name}"
+ )
+ # These PAX fields are already reflected in the metadata checked above.
+ fields = {"path", "linkpath", "size", "mtime", "uid", "gid", "uname", "gname"}
+ expected_pax = {
+ k: v for k, v in original.info.pax_headers.items() if k not in fields
+ }
+ actual_pax = {
+ k: v for k, v in actual.info.pax_headers.items() if k not in fields
+ }
+ if actual_pax != expected_pax:
+ raise ValueError(f"Archive extended metadata changed: {original.info.name}")
+ if assembled.with_name(f"{assembled.name}.sha256").read_text().split() != [
+ sha256(assembled.read_bytes()),
+ assembled.name,
+ ]:
+ raise ValueError("Assembled archive checksum differs")
+ print(f"Checked all {len(after)} members of {assembled.name}")
+
+
+def main() -> None:
+ """Run one phase of the release-signing rehearsal."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+ fetch = commands.add_parser("download")
+ fetch.add_argument("tag")
+ fetch.add_argument("version")
+ fetch.add_argument("target")
+ fetch.add_argument("output", type=Path)
+ for name in ("prepare", "assemble", "check"):
+ command = commands.add_parser(name)
+ command.add_argument("system", choices=("macos", "windows"))
+ command.add_argument("archive", type=Path)
+ if name != "prepare":
+ command.add_argument("signed", type=Path)
+ command.add_argument("output", type=Path)
+ args = parser.parse_args()
+ if args.command == "download":
+ download(args.tag, args.version, args.target, args.output)
+ elif args.command == "prepare":
+ prepare(args.archive, args.system, args.output)
+ elif args.command == "assemble":
+ assemble(args.archive, args.system, args.signed, args.output)
+ else:
+ check(args.archive, args.system, args.signed, args.output)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/rehearse-release-signing.py.lock b/scripts/rehearse-release-signing.py.lock
new file mode 100644
index 000000000..7518fc90b
--- /dev/null
+++ b/scripts/rehearse-release-signing.py.lock
@@ -0,0 +1,3 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
diff --git a/scripts/smoke-test-signed-python.py b/scripts/smoke-test-signed-python.py
new file mode 100644
index 000000000..d586e0932
--- /dev/null
+++ b/scripts/smoke-test-signed-python.py
@@ -0,0 +1,56 @@
+"""Exercise an assembled Python's native modules, callbacks, venv, and pip."""
+
+import ctypes
+import sqlite3
+import ssl
+import subprocess
+import sys
+import tempfile
+import tkinter
+import venv
+from pathlib import Path
+
+
+def main() -> None:
+ """Check native loading, executable memory, and a third-party extension wheel."""
+ print(sys.version)
+ print(ssl.OPENSSL_VERSION)
+ assert sqlite3.connect(":memory:").execute("select 42").fetchone() == (42,)
+ print(tkinter.Tcl().eval("info patchlevel"))
+ callback = ctypes.CFUNCTYPE(ctypes.c_int)(lambda: 42)
+ assert callback() == 42
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ venv.EnvBuilder(with_pip=True).create(directory)
+ python = directory / (
+ "Scripts/python.exe" if sys.platform == "win32" else "bin/python"
+ )
+ subprocess.run(
+ [
+ python,
+ "-m",
+ "pip",
+ "--isolated",
+ "--disable-pip-version-check",
+ "install",
+ "--index-url",
+ "https://pypi.org/simple",
+ "--no-cache-dir",
+ "--only-binary=:all:",
+ "cffi",
+ ],
+ check=True,
+ )
+ subprocess.run(
+ [
+ python,
+ "-I",
+ "-c",
+ "import _cffi_backend; from cffi import FFI; f = FFI().callback('int(void)', lambda: 42); assert f() == 42",
+ ],
+ check=True,
+ )
+
+
+if __name__ == "__main__":
+ main()