Skip to content

Commit

Permalink
Add a simple reproducibility test command.
Browse files Browse the repository at this point in the history
  • Loading branch information
jpakkane committed Sep 18, 2024
1 parent 81c5088 commit bdbb853
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 1 deletion.
15 changes: 15 additions & 0 deletions docs/markdown/snippets/reprotester.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Simple tool to test build reproducibility

Meson now ships with a command for testing whether your project can be
[built reprodicibly](https://reproducible-builds.org/). It can be used
by running a command like the following in the source root of your
project:

meson reprotest --intermediaries -- --buildtype=debugoptimized

All command line options after the `--` are passed to the build
invocations directly.

This tool is not meant to be exhaustive, but instead easy and
convenient to run. It will detect some but definitely not all
reproducibility issues.
4 changes: 3 additions & 1 deletion mesonbuild/mesonmain.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class CommandLineParser:
def __init__(self) -> None:
# only import these once we do full argparse processing
from . import mconf, mdist, minit, minstall, mintro, msetup, mtest, rewriter, msubprojects, munstable_coredata, mcompile, mdevenv, mformat
from .scripts import env2mfile
from .scripts import env2mfile, reprotest
from .wrap import wraptool
import shutil

Expand Down Expand Up @@ -103,6 +103,8 @@ def __init__(self) -> None:
help_msg='Run commands in developer environment')
self.add_command('env2mfile', env2mfile.add_arguments, env2mfile.run,
help_msg='Convert current environment to a cross or native file')
self.add_command('reprotest', reprotest.add_arguments, reprotest.run,
help_msg='Test if project builds reproducibly')
self.add_command('format', mformat.add_arguments, mformat.run, aliases=['fmt'],
help_msg='Format meson source file')
# Add new commands above this line to list them in help command
Expand Down
130 changes: 130 additions & 0 deletions mesonbuild/scripts/reprotest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2024 The Meson development team

from __future__ import annotations

import sys, os, subprocess, shutil
import pathlib
import typing as T

if T.TYPE_CHECKING:
import argparse

from ..mesonlib import get_meson_command

# Note: when adding arguments, please also add them to the completion
# scripts in $MESONSRC/data/shell-completions/
def add_arguments(parser: 'argparse.ArgumentParser') -> None:
parser.add_argument('--intermediaries',
default=False,
action='store_true',
help='Check intermediate files.')
parser.add_argument('mesonargs', nargs='*',
help='Arguments to pass to "meson setup".')

IGNORE_PATTERNS = ('.ninja_log',
'.ninja_deps',
'meson-private',
'meson-logs',
'meson-info',
)

INTERMEDIATE_EXTENSIONS = ('.gch',
'.pch',
'.o',
'.obj',
'.class',
)

class ReproTester:
def __init__(self, options: T.Any):
self.args = options.mesonargs
self.meson = get_meson_command()[:]
self.builddir = pathlib.Path('buildrepro')
self.storagedir = pathlib.Path('buildrepro.1st')
self.issues: T.List[str] = []
self.check_intermediaries = options.intermediaries

def run(self) -> int:
if not pathlib.Path('meson.build').is_file():
sys.exit('This command needs to be run at your project source root.')
self.check_ccache()
self.cleanup()
self.build()
self.check_output()
self.print_results()
if not self.issues:
self.cleanup()
return len(self.issues)

def check_ccache(self) -> None:
for evar in ('CC', 'CXX'):
evalue = os.environ.get(evar, '')
if 'ccache' in evalue:
print(f'Environment variable {evar} set to value "{evalue}".')
print('This implies using a compiler cache, which is incompatible with reproducible builds.')
sys.exit(1)

def cleanup(self) -> None:
if self.builddir.exists():
shutil.rmtree(self.builddir)
if self.storagedir.exists():
shutil.rmtree(self.storagedir)

def build(self) -> None:
setup_command: T.Sequence[T.Union[pathlib.Path, str]] = self.meson + ['setup', self.builddir] + self.args
build_command: T.Sequence[T.Union[pathlib.Path, str]] = self.meson + ['compile', '-C', self.builddir]
subprocess.check_call(setup_command)
subprocess.check_call(build_command)
self.builddir.rename(self.storagedir)
subprocess.check_call(setup_command)
subprocess.check_call(build_command)

def ignore_file(self, fstr: str) -> bool:
for p in IGNORE_PATTERNS:
if p in fstr:
return True
if not self.check_intermediaries:
for e in INTERMEDIATE_EXTENSIONS:
if fstr.endswith(e):
return True
return False

def check_contents(self, fromdir: str, todir: str, check_contents: bool) -> None:
frompath = fromdir + '/'
topath = todir + '/'
for fromfile in pathlib.Path(fromdir).glob('**/*'):
if not fromfile.is_file():
continue
fstr = str(fromfile)
if self.ignore_file(fstr):
continue
assert fstr.startswith(frompath)
tofile = pathlib.Path(fstr.replace(frompath, topath, 1))
if not tofile.exists():
self.issues.append(f'Missing file: {tofile}')
elif check_contents:
fromdata = fromfile.read_bytes()
todata = tofile.read_bytes()
if fromdata != todata:
self.issues.append(f'File contents differ: {fromfile}')

def print_results(self) -> None:
if self.issues:
print('Build differences detected')
for i in self.issues:
print(i)
else:
print('No differences detected.')

def check_output(self) -> None:
self.check_contents('buildrepro', 'buildrepro.1st', True)
self.check_contents('buildrepro.1st', 'buildrepro', False)

def run(options: T.Any) -> None:
rt = ReproTester(options)
try:
sys.exit(rt.run())
except FileNotFoundError as e:
print(e)
sys.exit(1)

0 comments on commit bdbb853

Please sign in to comment.