python-tuf/verify_release

265 lines
9.3 KiB
Text
Raw Permalink Normal View History

#!/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="<key id>",
dest="sign",
help="Sign release artifacts with 'gpg'. If no <key id> 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())