#!/usr/bin/env python3 # Copyright 2022, TUF contributors # SPDX-License-Identifier: MIT OR Apache-2.0 """verify_release - verify that published release matches a locally built one Builds a release from current commit and verifies that the release artifacts on GitHub and PyPI match the built release artifacts. """ from __future__ import annotations import argparse import os import subprocess import sys from filecmp import cmp from tempfile import TemporaryDirectory try: import build as _ # type: ignore[import-not-found] # noqa: F401 from urllib3 import request except ImportError: print("Error: verify_release requires modules 'urllib3' and 'build':") print(" pip install urllib3 build") sys.exit(1) # Project variables # Note that only these project artifacts are supported: # [f"{PYPI_PROJECT}-{VER}-none-any.whl", f"{PYPI_PROJECT}-{VER}.tar.gz"] GITHUB_ORG = "theupdateframework" GITHUB_PROJECT = "python-tuf" PYPI_PROJECT = "tuf" HTTP_TIMEOUT = 5 def build(build_dir: str) -> str: """Build release locally. Return version as string""" orig_dir = os.path.dirname(os.path.abspath(__file__)) with TemporaryDirectory() as src_dir: # fresh git clone: this prevents uncommitted files from affecting build git_cmd = ["git", "clone", "--quiet", orig_dir, src_dir] subprocess.run(git_cmd, stdout=subprocess.DEVNULL, check=True) # patch env to constrain build backend version as we do in cd.yml env = os.environ.copy() env["PIP_CONSTRAINT"] = "requirements/build.txt" build_cmd = ["python3", "-m", "build", "--outdir", build_dir, src_dir] subprocess.run( build_cmd, stdout=subprocess.DEVNULL, check=True, env=env ) for filename in os.listdir(build_dir): prefix, postfix = f"{PYPI_PROJECT}-", ".tar.gz" if filename.startswith(prefix) and filename.endswith(postfix): return filename[len(prefix) : -len(postfix)] raise RuntimeError("Build version not found") def get_git_version() -> str: """Return version string from git describe""" cmd = ["git", "describe"] process = subprocess.run(cmd, text=True, capture_output=True, check=True) if not process.stdout.startswith("v") or not process.stdout.endswith("\n"): raise RuntimeError(f"Unexpected git version {process.stdout}") return process.stdout[1:-1] def get_github_version() -> str: """Return version string of latest GitHub release""" release_json = f"https://api.github.com/repos/{GITHUB_ORG}/{GITHUB_PROJECT}/releases/latest" releases = request("GET", release_json, timeout=HTTP_TIMEOUT).json() return releases["tag_name"][1:] def get_pypi_pip_version() -> str: """Return latest version string available on PyPI according to pip""" # pip can't tell us what the newest available version is... So we download # newest tarball and figure out the version from the filename with TemporaryDirectory() as pypi_dir: cmd = ["pip", "download", "--no-deps", "--dest", pypi_dir] source_download = [*cmd, "--no-binary", PYPI_PROJECT, PYPI_PROJECT] subprocess.run(source_download, stdout=subprocess.DEVNULL, check=True) for filename in os.listdir(pypi_dir): prefix, postfix = f"{PYPI_PROJECT}-", ".tar.gz" if filename.startswith(prefix) and filename.endswith(postfix): return filename[len(prefix) : -len(postfix)] raise RuntimeError("PyPI version not found") def verify_github_release(version: str, compare_dir: str) -> bool: """Verify that given GitHub version artifacts match expected artifacts""" base_url = ( f"https://github.com/{GITHUB_ORG}/{GITHUB_PROJECT}/releases/download" ) tar = f"{PYPI_PROJECT}-{version}.tar.gz" wheel = f"{PYPI_PROJECT}-{version}-py3-none-any.whl" with TemporaryDirectory() as github_dir: for filename in [tar, wheel]: url = f"{base_url}/v{version}/{filename}" response = request( "GET", url, preload_content=False, timeout=HTTP_TIMEOUT ) with open(os.path.join(github_dir, filename), "wb") as f: for data in response.stream(): # noqa: FURB122 f.write(data) return cmp( os.path.join(github_dir, tar), os.path.join(compare_dir, tar), shallow=False, ) and cmp( os.path.join(github_dir, wheel), os.path.join(compare_dir, wheel), shallow=False, ) def verify_pypi_release(version: str, compare_dir: str) -> bool: """Verify that given PyPI version artifacts match expected artifacts""" tar = f"{PYPI_PROJECT}-{version}.tar.gz" wheel = f"{PYPI_PROJECT}-{version}-py3-none-any.whl" with TemporaryDirectory() as pypi_dir: cmd = ["pip", "download", "--no-deps", "--dest", pypi_dir] target = f"{PYPI_PROJECT}=={version}" binary_download = [*cmd, target] source_download = [*cmd, "--no-binary", PYPI_PROJECT, target] subprocess.run(binary_download, stdout=subprocess.DEVNULL, check=True) subprocess.run(source_download, stdout=subprocess.DEVNULL, check=True) return cmp( os.path.join(pypi_dir, wheel), os.path.join(compare_dir, wheel), shallow=False, ) and cmp( os.path.join(pypi_dir, tar), os.path.join(compare_dir, tar), shallow=False, ) def sign_release_artifacts( version: str, build_dir: str, key_id: str | None = None ) -> None: """Sign built release artifacts with gpg and write signature files to cwd""" sdist = f"{PYPI_PROJECT}-{version}.tar.gz" wheel = f"{PYPI_PROJECT}-{version}-py3-none-any.whl" cmd = ["gpg", "--detach-sign", "--armor"] if key_id is not None: cmd += ["--local-user", key_id] for filename in [sdist, wheel]: artifact_path = os.path.join(build_dir, filename) signature_path = f"{filename}.asc" subprocess.run( [*cmd, "--output", signature_path, artifact_path], check=True ) if not os.path.exists(signature_path): raise RuntimeError("Signing failed, signature not found") def finished(s: str) -> None: """Displays a finished message.""" # clear line sys.stdout.write("\033[K") print(f"* {s}") def progress(s: str) -> None: """Displays a progress message.""" # clear line sys.stdout.write("\033[K") # carriage return but no newline: next print will overwrite this one print(f" {s}...", end="\r", flush=True) def main() -> int: # noqa: D103 parser = argparse.ArgumentParser() parser.add_argument( "--skip-pypi", action="store_true", dest="skip_pypi", help="Skip PyPI release check.", ) parser.add_argument( "--sign", nargs="?", const=True, metavar="", dest="sign", help="Sign release artifacts with 'gpg'. If no is passed," " the default signing key is used. Resulting '*.asc' files are written" " to CWD.", ) args = parser.parse_args() success = True with TemporaryDirectory() as build_dir: progress("Building release") build_version = build(build_dir) finished(f"Built release {build_version}") git_version = get_git_version() if not git_version.startswith(build_version): raise RuntimeError( f"Git version is {git_version}, expected {build_version}" ) if git_version != build_version: finished(f"WARNING: Git describes version as {git_version}") progress("Checking GitHub latest version") github_version = get_github_version() if github_version != build_version: finished(f"WARNING: GitHub latest version is {github_version}") if not args.skip_pypi: progress("Checking PyPI latest version") pypi_version = get_pypi_pip_version() if pypi_version != build_version: finished(f"WARNING: PyPI latest version is {pypi_version}") progress("Downloading release from PyPI") if not verify_pypi_release(build_version, build_dir): # This is expected while build is not reproducible finished("ERROR: PyPI artifacts do not match built release") success = False else: finished("PyPI artifacts match the built release") progress("Downloading release from GitHub") if not verify_github_release(build_version, build_dir): # This is expected while build is not reproducible finished("ERROR: GitHub artifacts do not match built release") success = False else: finished("GitHub artifacts match the built release") # NOTE: 'gpg' might prompt for password or ask if it should # override files... if args.sign: progress("Signing built release with gpg") if success: key_id = args.sign if args.sign is not True else None sign_release_artifacts(build_version, build_dir, key_id) finished("Created signatures in cwd (see '*.asc' files)") else: finished("WARNING: Skipped signing of non-matching artifacts") return 0 if success else 1 if __name__ == "__main__": sys.exit(main())