diff --git a/.circleci/config.yml b/.circleci/config.yml index 65740387..69e18859 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -281,14 +281,15 @@ common-steps: version: 2.1 jobs: tests: - docker: - - image: circleci/python:3.7-buster + machine: + image: ubuntu-2004:202010-01 steps: - checkout - run: name: install test requirements and run tests command: | - virtualenv .venv + make install-deps + virtualenv -p python3 .venv source .venv/bin/activate pip install -r test-requirements.txt make test diff --git a/.gitignore b/.gitignore index 25c48a17..57679247 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +tests/__pycache__/ debhelper-build-stamp *.debhelper.log diff --git a/Makefile b/Makefile index dd1429df..4b71524a 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,10 @@ test: ## Run test suite clean: ## Removes all non-version controlled packaging artifacts rm -rf localwheels/* +.PHONY: reprotest +reprotest: ## Reproducibility test, currently only for wheels + pytest -vvs tests/test_reproducible_wheels.py + .PHONY: help help: ## Prints this message and exits @printf "Makefile for building SecureDrop Workstation packages\n" diff --git a/scripts/build-sync-wheels b/scripts/build-sync-wheels index 3e22528b..6f1f7a44 100755 --- a/scripts/build-sync-wheels +++ b/scripts/build-sync-wheels @@ -2,29 +2,56 @@ import os import sys -import json import subprocess import tempfile import shutil import argparse +# Set SOURCE_DATE_EPOCH to a predictable value. Using the first +# commit to the SecureDrop project: +# +# git show -s 62bbe590afd77a6af2dcaed46c93da6e0cf40951 --date=unix +# +# which yields 1309379017 +os.environ["SOURCE_DATE_EPOCH"] = "1309379017" + +# Force sane umask for reproducibility's sake. +os.umask(0o022) + +# When building wheels, pip defaults to a safe dynamic tmpdir path. +# Some shared objects include the path from build, so we must make +# the path predictable across builds. +WHEEL_BUILD_DIR = "/tmp/pip-wheel-build" + + def main(): parser = argparse.ArgumentParser( description="Builds and caches sources and wheels" ) parser.add_argument( "-p", - help="Points to the project dirctory", + help="Points to the project directory", ) parser.add_argument( "--cache", default="./localwheels", help="Final cache dir" ) + parser.add_argument( + "--clobber", action="store_true", default=False, + help="Whether to overwrite wheels and source tarballs", + ) args = parser.parse_args() - if not os.path.exists(args.p): - print("Project directory missing {0}.".format(args.p)) - sys.exit(1) + if args.p.startswith("https://"): + git_clone_directory = tempfile.mkdtemp(prefix=os.path.basename(args.p)) + cmd = f"git clone {args.p} {git_clone_directory}".split() + subprocess.check_call(cmd) + args.p = git_clone_directory + else: + git_clone_directory = "" + if not os.path.exists(args.p): + print("Project directory missing {0}.".format(args.p)) + sys.exit(1) # Try requirements.txt in the repo root, otherwise try requirements/requirements.txt req_path = os.path.join(args.p, "requirements.txt") @@ -36,6 +63,11 @@ def main(): print("requirements.txt missing at {0}.".format(req_path)) sys.exit(3) + if os.path.exists(WHEEL_BUILD_DIR): + shutil.rmtree(WHEEL_BUILD_DIR) + else: + os.mkdir(WHEEL_BUILD_DIR) + with tempfile.TemporaryDirectory() as tmpdir: # The --require-hashes option will be used by default if there are # hashes in the requirements.txt file. We specify it anyway to guard @@ -46,9 +78,9 @@ def main(): "--no-binary", ":all:", "--require-hashes", - "-d", + "--dest", tmpdir, - "-r", + "--requirement", req_path, ] subprocess.check_call(cmd) @@ -58,11 +90,15 @@ def main(): "wheel", "--no-binary", ":all:", - "-f", + "--find-links", tmpdir, - "-w", + "--progress-bar", + "pretty", + "--wheel-dir", tmpdir, - "-r", + "--build", + WHEEL_BUILD_DIR, + "--requirement", req_path, ] subprocess.check_call(cmd) @@ -76,13 +112,17 @@ def main(): if name == "requirements.txt": # We don't need this in cache continue if name in cachenames: # Means all ready in our cache - continue + if not args.clobber: + continue # Else copy to cache filepath = os.path.join(tmpdir, name) shutil.copy(filepath, args.cache, follow_symlinks=True) print("Copying {0} to cache {1}".format(name, args.cache)) + if git_clone_directory: + shutil.rmtree(git_clone_directory) + if __name__ == "__main__": main() diff --git a/scripts/install-deps b/scripts/install-deps index d78fbc31..044268d5 100755 --- a/scripts/install-deps +++ b/scripts/install-deps @@ -2,6 +2,11 @@ # Installs required dependencies for building SecureDrop Worsktation packages. # Assumes a Debian 10 machine, ideally a Qubes AppVM. +# If running in CI, we need to add the Ubuntu Bionic repo to download dh-virtualenv +if [[ -v CIRCLE_BUILD_URL ]]; then + echo "deb http://archive.ubuntu.com/ubuntu/ bionic universe" | sudo tee -a /etc/apt/sources.list +fi + sudo apt-get update sudo apt-get install \ build-essential \ @@ -15,7 +20,8 @@ sudo apt-get install \ libssl-dev \ python3-all \ python3-pip \ - python3-setuptools + python3-setuptools \ + reprotest # Inspect the wheel files present locally. If repo was cloned diff --git a/tests/test_reproducible_wheels.py b/tests/test_reproducible_wheels.py new file mode 100644 index 00000000..2ee3e77f --- /dev/null +++ b/tests/test_reproducible_wheels.py @@ -0,0 +1,43 @@ +import pytest +import subprocess + + +# These are the SDW repositories that we build wheels for. +REPOS_WITH_WHEELS = [ + "securedrop-client", + "securedrop-log", + "securedrop-proxy", +] + + +def get_repo_root(): + cmd = "git rev-parse --show-toplevel".split() + top_level = subprocess.check_output(cmd).decode("utf-8").rstrip() + return top_level + + +@pytest.mark.parametrize("repo_name", REPOS_WITH_WHEELS) +def test_wheel_builds_are_reproducible(repo_name): + """ + Uses 'reprotest' to confirm that the wheel build process, per repo, + is deterministic, i.e. all .whl files are created with the same checksum + across multiple builds. + + Explanations of the excluded reproducibility checks: + + * time: breaks HTTPS, so pip calls fail + * locales: some locales fail, would be nice to fix, but low priority + * kernel: x86_64 is the supported architecure, we don't ship others + """ + repo_url = f"https://github.com/freedomofpress/{repo_name}" + cmd = [ + "reprotest", + "-c", + f"./scripts/build-sync-wheels -p {repo_url} --clobber", + "--vary", + "+all, -time, -locales, -kernel", + ".", + "localwheels/*.whl", + ] + repo_root = get_repo_root() + subprocess.check_call(cmd, cwd=repo_root)