mirror of
https://github.com/theupdateframework/python-tuf
synced 2026-05-24 10:08:28 +00:00
249 lines
8.7 KiB
Python
Executable file
249 lines
8.7 KiB
Python
Executable file
#!/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="<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()
|
|
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())
|