#!/usr/bin/env python # 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. """ import argparse import json import os import subprocess import sys from filecmp import cmp from tempfile import TemporaryDirectory from typing import Optional try: import build as _ # type: ignore import requests except ImportError: print("Error: verify_release requires modules 'requests' and 'build':") print(" pip install requests 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) build_cmd = ["python3", "-m", "build", "--outdir", build_dir, src_dir] subprocess.run(build_cmd, stdout=subprocess.DEVNULL, check=True) build_version = None for filename in os.listdir(build_dir): prefix, postfix = f"{PYPI_PROJECT}-", ".tar.gz" if filename.startswith(prefix) and filename.endswith(postfix): build_version = filename[len(prefix) : -len(postfix)] assert build_version return build_version 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) assert process.stdout.startswith("v") and process.stdout.endswith("\n") 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 = json.loads( requests.get(release_json, timeout=HTTP_TIMEOUT).content ) 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)] assert False 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 = requests.get(url, stream=True, timeout=HTTP_TIMEOUT) with open(os.path.join(github_dir, filename), "wb") as f: for data in response.iter_content(): 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: Optional[str] = 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 ) assert os.path.exists(signature_path) def finished(s: str) -> None: # clear line sys.stdout.write("\033[K") print(f"* {s}") def progress(s: str) -> None: # 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: 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() assert git_version.startswith(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())