Skip to content

Commit cee267e

Browse files
authored
Merge pull request #211 from freedomofpress/196-reproducible-wheels
Makes wheel builds reproducible, with tests
2 parents dd3ecad + fbe4010 commit cee267e

File tree

6 files changed

+110
-15
lines changed

6 files changed

+110
-15
lines changed

.circleci/config.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,14 +281,15 @@ common-steps:
281281
version: 2.1
282282
jobs:
283283
tests:
284-
docker:
285-
- image: circleci/python:3.7-buster
284+
machine:
285+
image: ubuntu-2004:202010-01
286286
steps:
287287
- checkout
288288
- run:
289289
name: install test requirements and run tests
290290
command: |
291-
virtualenv .venv
291+
make install-deps
292+
virtualenv -p python3 .venv
292293
source .venv/bin/activate
293294
pip install -r test-requirements.txt
294295
make test

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
tests/__pycache__/
12
debhelper-build-stamp
23
*.debhelper.log

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ test: ## Run test suite
6161
clean: ## Removes all non-version controlled packaging artifacts
6262
rm -rf localwheels/*
6363

64+
.PHONY: reprotest
65+
reprotest: ## Reproducibility test, currently only for wheels
66+
pytest -vvs tests/test_reproducible_wheels.py
67+
6468
.PHONY: help
6569
help: ## Prints this message and exits
6670
@printf "Makefile for building SecureDrop Workstation packages\n"

scripts/build-sync-wheels

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,56 @@
22

33
import os
44
import sys
5-
import json
65
import subprocess
76
import tempfile
87
import shutil
98
import argparse
109

1110

11+
# Set SOURCE_DATE_EPOCH to a predictable value. Using the first
12+
# commit to the SecureDrop project:
13+
#
14+
# git show -s 62bbe590afd77a6af2dcaed46c93da6e0cf40951 --date=unix
15+
#
16+
# which yields 1309379017
17+
os.environ["SOURCE_DATE_EPOCH"] = "1309379017"
18+
19+
# Force sane umask for reproducibility's sake.
20+
os.umask(0o022)
21+
22+
# When building wheels, pip defaults to a safe dynamic tmpdir path.
23+
# Some shared objects include the path from build, so we must make
24+
# the path predictable across builds.
25+
WHEEL_BUILD_DIR = "/tmp/pip-wheel-build"
26+
27+
1228
def main():
1329
parser = argparse.ArgumentParser(
1430
description="Builds and caches sources and wheels"
1531
)
1632
parser.add_argument(
1733
"-p",
18-
help="Points to the project dirctory",
34+
help="Points to the project directory",
1935
)
2036
parser.add_argument(
2137
"--cache", default="./localwheels", help="Final cache dir"
2238
)
39+
parser.add_argument(
40+
"--clobber", action="store_true", default=False,
41+
help="Whether to overwrite wheels and source tarballs",
42+
)
2343
args = parser.parse_args()
2444

25-
if not os.path.exists(args.p):
26-
print("Project directory missing {0}.".format(args.p))
27-
sys.exit(1)
45+
if args.p.startswith("https://"):
46+
git_clone_directory = tempfile.mkdtemp(prefix=os.path.basename(args.p))
47+
cmd = f"git clone {args.p} {git_clone_directory}".split()
48+
subprocess.check_call(cmd)
49+
args.p = git_clone_directory
50+
else:
51+
git_clone_directory = ""
52+
if not os.path.exists(args.p):
53+
print("Project directory missing {0}.".format(args.p))
54+
sys.exit(1)
2855

2956
# Try requirements.txt in the repo root, otherwise try requirements/requirements.txt
3057
req_path = os.path.join(args.p, "requirements.txt")
@@ -36,6 +63,11 @@ def main():
3663
print("requirements.txt missing at {0}.".format(req_path))
3764
sys.exit(3)
3865

66+
if os.path.exists(WHEEL_BUILD_DIR):
67+
shutil.rmtree(WHEEL_BUILD_DIR)
68+
else:
69+
os.mkdir(WHEEL_BUILD_DIR)
70+
3971
with tempfile.TemporaryDirectory() as tmpdir:
4072
# The --require-hashes option will be used by default if there are
4173
# hashes in the requirements.txt file. We specify it anyway to guard
@@ -46,9 +78,9 @@ def main():
4678
"--no-binary",
4779
":all:",
4880
"--require-hashes",
49-
"-d",
81+
"--dest",
5082
tmpdir,
51-
"-r",
83+
"--requirement",
5284
req_path,
5385
]
5486
subprocess.check_call(cmd)
@@ -58,11 +90,15 @@ def main():
5890
"wheel",
5991
"--no-binary",
6092
":all:",
61-
"-f",
93+
"--find-links",
6294
tmpdir,
63-
"-w",
95+
"--progress-bar",
96+
"pretty",
97+
"--wheel-dir",
6498
tmpdir,
65-
"-r",
99+
"--build",
100+
WHEEL_BUILD_DIR,
101+
"--requirement",
66102
req_path,
67103
]
68104
subprocess.check_call(cmd)
@@ -76,13 +112,17 @@ def main():
76112
if name == "requirements.txt": # We don't need this in cache
77113
continue
78114
if name in cachenames: # Means all ready in our cache
79-
continue
115+
if not args.clobber:
116+
continue
80117

81118
# Else copy to cache
82119
filepath = os.path.join(tmpdir, name)
83120
shutil.copy(filepath, args.cache, follow_symlinks=True)
84121
print("Copying {0} to cache {1}".format(name, args.cache))
85122

123+
if git_clone_directory:
124+
shutil.rmtree(git_clone_directory)
125+
86126

87127
if __name__ == "__main__":
88128
main()

scripts/install-deps

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
# Installs required dependencies for building SecureDrop Worsktation packages.
33
# Assumes a Debian 10 machine, ideally a Qubes AppVM.
44

5+
# If running in CI, we need to add the Ubuntu Bionic repo to download dh-virtualenv
6+
if [[ -v CIRCLE_BUILD_URL ]]; then
7+
echo "deb http://archive.ubuntu.com/ubuntu/ bionic universe" | sudo tee -a /etc/apt/sources.list
8+
fi
9+
510
sudo apt-get update
611
sudo apt-get install \
712
build-essential \
@@ -15,7 +20,8 @@ sudo apt-get install \
1520
libssl-dev \
1621
python3-all \
1722
python3-pip \
18-
python3-setuptools
23+
python3-setuptools \
24+
reprotest
1925

2026

2127
# Inspect the wheel files present locally. If repo was cloned

tests/test_reproducible_wheels.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pytest
2+
import subprocess
3+
4+
5+
# These are the SDW repositories that we build wheels for.
6+
REPOS_WITH_WHEELS = [
7+
"securedrop-client",
8+
"securedrop-log",
9+
"securedrop-proxy",
10+
]
11+
12+
13+
def get_repo_root():
14+
cmd = "git rev-parse --show-toplevel".split()
15+
top_level = subprocess.check_output(cmd).decode("utf-8").rstrip()
16+
return top_level
17+
18+
19+
@pytest.mark.parametrize("repo_name", REPOS_WITH_WHEELS)
20+
def test_wheel_builds_are_reproducible(repo_name):
21+
"""
22+
Uses 'reprotest' to confirm that the wheel build process, per repo,
23+
is deterministic, i.e. all .whl files are created with the same checksum
24+
across multiple builds.
25+
26+
Explanations of the excluded reproducibility checks:
27+
28+
* time: breaks HTTPS, so pip calls fail
29+
* locales: some locales fail, would be nice to fix, but low priority
30+
* kernel: x86_64 is the supported architecure, we don't ship others
31+
"""
32+
repo_url = f"https://github.com/freedomofpress/{repo_name}"
33+
cmd = [
34+
"reprotest",
35+
"-c",
36+
f"./scripts/build-sync-wheels -p {repo_url} --clobber",
37+
"--vary",
38+
"+all, -time, -locales, -kernel",
39+
".",
40+
"localwheels/*.whl",
41+
]
42+
repo_root = get_repo_root()
43+
subprocess.check_call(cmd, cwd=repo_root)

0 commit comments

Comments
 (0)