diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8ed9ce6a40..69f5ef11ad 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -33,5 +33,7 @@ * Upload to PyPI `twine upload dist/*` * Verify the package at https://pypi.org/project/tuf/ and by installing with pip * Attach both signed dists and their detached signatures to the release on GitHub +* `verify_release` should be used to make sure the release artifacts match the + git sources, preferably by another developer on a different machine. * Announce the release on [#tuf on CNCF Slack](https://cloud-native.slack.com/archives/C8NMD3QJ3) * Ensure [POUF 1](https://github.com/theupdateframework/taps/blob/master/POUFs/reference-POUF/pouf1.md), for the reference implementation, is up-to-date diff --git a/verify_release b/verify_release new file mode 100755 index 0000000000..1d4176f511 --- /dev/null +++ b/verify_release @@ -0,0 +1,164 @@ +#!/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 json +import os +import subprocess +import sys +from filecmp import dircmp +from tempfile import TemporaryDirectory + +import requests + +# 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" + + +def build(build_dir: str) -> str: + """Build release locally. Return version as string""" + cmd = ["python3", "-m", "build", "--outdir", build_dir] + subprocess.run(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).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", ":all:", 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) + with open(os.path.join(github_dir, filename), "wb") as f: + for data in response.iter_content(): + f.write(data) + + same = dircmp(github_dir, compare_dir).same_files + return sorted(same) == [wheel, tar] + + +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", ":all:", target] + + subprocess.run(binary_download, stdout=subprocess.DEVNULL, check=True) + subprocess.run(source_download, stdout=subprocess.DEVNULL, check=True) + + same = dircmp(pypi_dir, compare_dir).same_files + return sorted(same) == [wheel, tar] + + +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: + 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}") + + 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 + + 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 + + if success: + finished("Github and PyPI artifacts match the built release") + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main())