* feat(build): add locked reproducible wheel gate Build independently in clean source trees and virtualenvs, compare wheel bytes, and stage only verified canonical distributions. Safely clean recognized prior outputs on repeat runs. Part of #701 * ci(release): verify wheel reproducibility before publishing Run the shared gate in PR and tag workflows, preserve failure diagnostics, and keep verification artifacts out of release checksums. Make Snap Store publication wait for the gate and reuse the canonical distributions for PyPI. Part of #701 * docs(release): document reproducible wheel validation Describe the locked toolchain, canonical publication boundary, same-platform scope, diagnostic outputs, and safe automatic cleanup. * chore(scripts): update executable permissions * fix(build): isolate release tooling and preserve cleanup retries Exclude host site-packages from build-only virtualenvs so unlocked setuptools plugins cannot affect verified distributions. Delete staged artifacts before canonical ownership evidence to recover safely from interrupted cleanup. Add regression tests and document the build-only environment exception. * fix(release): make reproducible builds retryable and refresh tools Regenerate release build exports from uv.lock. Hold a checkout lock through cleanup and staging, and atomically rename complete archives into dist. Cover interrupted staging, competing processes, and lock release with regression tests. --------- Co-authored-by: Kanishk Pachauri <itskanishkp.py@gmail.com> Co-authored-by: sesav <1636488+sesav@users.noreply.github.com>
332 lines
13 KiB
Python
Executable File
332 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build independent wheels with locked tooling and stage only verified distributions.
|
|
|
|
The source snapshot includes tracked working-tree edits and non-ignored new files,
|
|
so local review exercises uncommitted changes too. Each build gets a separate copy
|
|
and virtualenv; no build/ or egg-info from the developer's checkout is reused.
|
|
Reproducibility is checked for the same Python/platform/toolchain, not across OSes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fcntl
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def run(argv: list[str], cwd: Path, env: dict[str, str], log: Path) -> None:
|
|
"""Run a build step, retaining its output even when the step fails."""
|
|
with log.open("a", encoding="utf-8") as stream:
|
|
print(f"$ {' '.join(argv)}", file=stream, flush=True)
|
|
result = subprocess.run(argv, cwd=cwd, env=env, stdout=stream, stderr=stream)
|
|
if result.returncode:
|
|
raise RuntimeError(f"command failed ({result.returncode}): {' '.join(argv)}; see {log}")
|
|
|
|
|
|
def snapshot(source: Path, target: Path) -> None:
|
|
"""Copy Git-visible source files, excluding ignored build and venv state."""
|
|
files = subprocess.check_output(
|
|
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=source
|
|
)
|
|
for name in sorted(set(files.decode().split("\0")) - {""}):
|
|
origin = source / name
|
|
if not origin.exists() and not origin.is_symlink():
|
|
continue # A tracked file deleted in the working tree.
|
|
destination = target / name
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(origin, destination, follow_symlinks=False)
|
|
|
|
|
|
def one_file(directory: Path, pattern: str) -> Path:
|
|
"""Require one output, rather than accidentally comparing stale artifacts."""
|
|
files = sorted(directory.glob(pattern))
|
|
if len(files) != 1:
|
|
raise RuntimeError(f"expected exactly one {pattern} in {directory}, found {len(files)}")
|
|
return files[0]
|
|
|
|
|
|
def digest(path: Path) -> str:
|
|
"""Return the SHA-256 of the original archive bytes."""
|
|
with path.open("rb") as stream:
|
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
|
|
|
|
def compare_wheels(first: Path, second: Path, report: Path) -> None:
|
|
"""Reject any byte difference, including ZIP metadata, and explain the mismatch."""
|
|
lines = [f"{digest(path)} {path}" for path in (first, second)]
|
|
# Compare bytes, not re-packed/normalized ZIPs: metadata is part of the wheel.
|
|
equal = first.name == second.name and first.read_bytes() == second.read_bytes()
|
|
if not equal:
|
|
lines.append("FAIL: wheel filenames or bytes differ")
|
|
if first.name != second.name:
|
|
lines.append(f"filenames: {first.name} != {second.name}")
|
|
try:
|
|
with zipfile.ZipFile(first) as left, zipfile.ZipFile(second) as right:
|
|
if left.namelist() != right.namelist():
|
|
lines.append("ZIP member names/order differ")
|
|
for name in sorted(set(left.namelist()) | set(right.namelist())):
|
|
if name not in left.namelist() or name not in right.namelist():
|
|
lines.append(f"member present in only one wheel: {name}")
|
|
elif left.read(name) != right.read(name):
|
|
lines.append(f"member content differs: {name}")
|
|
lines.append("Inspect ZIP timestamps, permissions, compression and headers too.")
|
|
except zipfile.BadZipFile as error:
|
|
lines.append(f"invalid wheel archive: {error}")
|
|
else:
|
|
lines.append("PASS: independently built wheels are byte-for-byte identical")
|
|
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
print(report.read_text(encoding="utf-8"), end="")
|
|
if not equal:
|
|
raise RuntimeError(f"wheel reproducibility failed; see {report}")
|
|
|
|
|
|
def prepare_outputs(output: Path, diagnostics: Path) -> None:
|
|
"""Remove only recognizable gate outputs, validating everything before deletion.
|
|
|
|
The retained canonical archives identify staged files by name AND digest.
|
|
Never recursively wipe build/ or dist/: unrelated/modified files require the
|
|
user to move them aside. Failed builds can be retried even with partial logs
|
|
and archives, provided no unexpected files are present.
|
|
"""
|
|
diagnostic_files: list[Path] = []
|
|
staged_files: list[Path] = []
|
|
directories: list[Path] = []
|
|
archives: dict[str, Path] = {}
|
|
resolved_output = output.resolve()
|
|
resolved_diagnostics = diagnostics.resolve()
|
|
if (
|
|
resolved_output == resolved_diagnostics
|
|
or resolved_output in resolved_diagnostics.parents
|
|
or resolved_diagnostics in resolved_output.parents
|
|
):
|
|
raise RuntimeError("output and diagnostics directories must not overlap")
|
|
for directory in (output, diagnostics):
|
|
for component in (directory, *directory.parents):
|
|
if component.is_symlink():
|
|
raise RuntimeError(f"refusing to clean through symlink: {component}")
|
|
if directory.exists() and not directory.is_dir():
|
|
raise RuntimeError(f"refusing to clean non-directory: {directory}")
|
|
if diagnostics.exists():
|
|
for entry in diagnostics.iterdir():
|
|
if entry.is_symlink():
|
|
raise RuntimeError(f"refusing to clean symlink: {entry}")
|
|
if entry.name in {"canonical", "independent"} and entry.is_dir():
|
|
for artifact in entry.iterdir():
|
|
if (
|
|
artifact.is_symlink()
|
|
or not artifact.is_file()
|
|
or not artifact.name.endswith((".whl", ".tar.gz"))
|
|
):
|
|
raise RuntimeError(
|
|
f"unrecognized diagnostic output; move aside: {artifact}"
|
|
)
|
|
diagnostic_files.append(artifact)
|
|
if entry.name == "canonical":
|
|
archives[artifact.name] = artifact
|
|
directories.append(entry)
|
|
elif entry.is_file() and entry.name in {
|
|
"inputs.log",
|
|
"canonical.log",
|
|
"independent.log",
|
|
"comparison.txt",
|
|
}:
|
|
diagnostic_files.append(entry)
|
|
else:
|
|
raise RuntimeError(f"unrecognized diagnostic output; move aside: {entry}")
|
|
if output.exists():
|
|
for artifact in output.iterdir():
|
|
canonical = archives.get(artifact.name)
|
|
if (
|
|
artifact.is_symlink()
|
|
or not artifact.is_file()
|
|
or canonical is None
|
|
or digest(artifact) != digest(canonical)
|
|
):
|
|
raise RuntimeError(f"unrecognized or modified output; move aside: {artifact}")
|
|
staged_files.append(artifact)
|
|
# Delete staged copies first. Their canonical originals are the ownership
|
|
# evidence needed by the next run if cleanup is interrupted midway through.
|
|
files = staged_files + diagnostic_files
|
|
if files:
|
|
print(f"Cleaning {len(files)} previous reproducible-wheel output files")
|
|
for path in files:
|
|
path.unlink()
|
|
for directory in directories:
|
|
directory.rmdir()
|
|
diagnostics.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
@contextmanager
|
|
def checkout_lock() -> Iterator[None]:
|
|
"""Reject overlapping builds in this checkout before touching their outputs."""
|
|
# Keep the inode after releasing the lock: unlinking it could let another
|
|
# process lock a new inode while a contender still holds the original one.
|
|
with (ROOT / ".reproducible-wheel.lock").open("a") as stream:
|
|
try:
|
|
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError as error:
|
|
raise RuntimeError(
|
|
"another reproducible-wheel build is active in this checkout"
|
|
) from error
|
|
try:
|
|
yield
|
|
finally:
|
|
fcntl.flock(stream, fcntl.LOCK_UN)
|
|
|
|
|
|
def stage_distributions(artifacts: tuple[Path, ...], output: Path) -> None:
|
|
"""Copy outside dist/, then atomically publish each complete archive."""
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
# A sibling stays on the output filesystem for atomic renames. If killed
|
|
# during copying, only an ignored temporary directory can contain partials.
|
|
with tempfile.TemporaryDirectory(
|
|
prefix=".reproducible-wheel-stage-", dir=output.parent
|
|
) as temporary:
|
|
staging = Path(temporary)
|
|
for artifact in artifacts:
|
|
shutil.copy2(artifact, staging / artifact.name)
|
|
for artifact in artifacts:
|
|
(staging / artifact.name).replace(output / artifact.name)
|
|
|
|
|
|
def build(python: str, output: Path, diagnostics: Path) -> None:
|
|
"""Build twice and expose the canonical wheel/sdist only after verification."""
|
|
with checkout_lock():
|
|
_build(python, output, diagnostics)
|
|
|
|
|
|
def _build(python: str, output: Path, diagnostics: Path) -> None:
|
|
"""Run the gate while holding the checkout lock through final staging."""
|
|
prepare_outputs(output, diagnostics)
|
|
# Always derive the timestamp from this commit, not an inherited shell value.
|
|
epoch = subprocess.check_output(
|
|
["git", "log", "-1", "--format=%ct", "HEAD"], cwd=ROOT, text=True
|
|
).strip()
|
|
env = dict(os.environ)
|
|
for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"):
|
|
env.pop(name, None)
|
|
# Override .python-version (the dev interpreter) for the offline lock/export
|
|
# checks too. A clean release runner only needs the selected build Python.
|
|
env.update(
|
|
SOURCE_DATE_EPOCH=epoch,
|
|
PYTHONNOUSERSITE="1",
|
|
UV_PYTHON=python,
|
|
TZ="UTC",
|
|
LC_ALL="C.UTF-8",
|
|
)
|
|
run(["uv", "lock", "--check", "--offline"], ROOT, env, diagnostics / "inputs.log")
|
|
run(
|
|
[sys.executable, str(ROOT / "scripts/check_exports.py")],
|
|
ROOT,
|
|
env,
|
|
diagnostics / "inputs.log",
|
|
)
|
|
with tempfile.TemporaryDirectory(prefix="vocalinux-wheel-") as temporary:
|
|
scratch = Path(temporary)
|
|
source = scratch / "snapshot"
|
|
source.mkdir()
|
|
snapshot(ROOT, source)
|
|
for label, seed in (("canonical", "1"), ("independent", "2")):
|
|
tree = scratch / label / "source"
|
|
shutil.copytree(source, tree, symlinks=True)
|
|
venv = scratch / label / "venv"
|
|
dist = diagnostics / label
|
|
dist.mkdir()
|
|
log = diagnostics / f"{label}.log"
|
|
build_env = {**env, "PYTHONHASHSEED": seed}
|
|
run(
|
|
[
|
|
"uv",
|
|
"venv",
|
|
"--no-config",
|
|
"--python",
|
|
python,
|
|
str(venv),
|
|
],
|
|
scratch,
|
|
build_env,
|
|
log,
|
|
)
|
|
interpreter = str(venv / "bin/python")
|
|
# Force every exported tool into the venv, even if the system already
|
|
# has that version. Only wheels, verified by the committed hashes.
|
|
run(
|
|
[
|
|
"uv",
|
|
"pip",
|
|
"install",
|
|
"--no-config",
|
|
"--python",
|
|
interpreter,
|
|
"--require-hashes",
|
|
"--no-deps",
|
|
"--only-binary=:all:",
|
|
"--reinstall",
|
|
"-r",
|
|
str(source / "requirements/release-build.txt"),
|
|
],
|
|
scratch,
|
|
build_env,
|
|
log,
|
|
)
|
|
run(
|
|
[interpreter, "-I", "-m", "build", "--no-isolation", "--outdir", str(dist)],
|
|
tree,
|
|
build_env,
|
|
log,
|
|
)
|
|
run(
|
|
[
|
|
interpreter,
|
|
"-I",
|
|
"-m",
|
|
"twine",
|
|
"check",
|
|
"--strict",
|
|
str(one_file(dist, "*.whl")),
|
|
str(one_file(dist, "*.tar.gz")),
|
|
],
|
|
tree,
|
|
build_env,
|
|
log,
|
|
)
|
|
canonical = one_file(diagnostics / "canonical", "*.whl")
|
|
compare_wheels(
|
|
canonical,
|
|
one_file(diagnostics / "independent", "*.whl"),
|
|
diagnostics / "comparison.txt",
|
|
)
|
|
stage_distributions((canonical, one_file(diagnostics / "canonical", "*.tar.gz")), output)
|
|
|
|
|
|
def main() -> int:
|
|
"""Run the local and CI release gate."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--python", default=sys.executable, help="build interpreter (CI: Python 3.11)"
|
|
)
|
|
parser.add_argument("--output", type=Path, default=ROOT / "dist")
|
|
parser.add_argument("--diagnostics", type=Path, default=ROOT / "build/reproducible-wheel")
|
|
args = parser.parse_args()
|
|
try:
|
|
build(args.python, args.output.absolute(), args.diagnostics.absolute())
|
|
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
|
print(f"ERROR: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|