From d13673b59de54f63c84eb7a4849bc5cb565e8d94 Mon Sep 17 00:00:00 2001 From: Jussi Pakkanen Date: Wed, 18 Sep 2024 18:05:30 +0300 Subject: [PATCH] Add a simple reproducibility test command. --- docs/markdown/Commands.md | 28 ++++++ docs/markdown/snippets/reprotester.md | 15 +++ mesonbuild/mesonmain.py | 4 +- mesonbuild/scripts/reprotest.py | 128 ++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 docs/markdown/snippets/reprotester.md create mode 100755 mesonbuild/scripts/reprotest.py diff --git a/docs/markdown/Commands.md b/docs/markdown/Commands.md index 542f1b269d40..3853e5777064 100644 --- a/docs/markdown/Commands.md +++ b/docs/markdown/Commands.md @@ -225,6 +225,34 @@ DESTDIR=/path/to/staging/area meson install -C builddir Since *0.60.0* `DESTDIR` and `--destdir` can be a path relative to build directory. An absolute path will be set into environment when executing scripts. +### reprotest + +*(since 1.6.0)* + +{{ reprotest_usage.inc }} + +Simple reproducible build tester that compiles the project twice and +checks whether the end results are identical. + +This command must be run in the source root of the project you want to +test. + +{{ reprotest_arguments.inc }} + +#### Examples + + meson reprotest + +Builds the current project with its default settings. + + meson reprotest --intermediates -- --buildtype=debugoptimized + +Builds the target and also checks that all intermediate files like +object files are also identical. All command line arguments after the +`--` are passed directly to the underlying `meson` invocation. Only +use option arguments, i.e. those that start with a dash, Meson sets +directory arguments automatically. + ### rewrite *(since 0.50.0)* diff --git a/docs/markdown/snippets/reprotester.md b/docs/markdown/snippets/reprotester.md new file mode 100644 index 000000000000..dc86acdb988d --- /dev/null +++ b/docs/markdown/snippets/reprotester.md @@ -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. diff --git a/mesonbuild/mesonmain.py b/mesonbuild/mesonmain.py index faa0f426d82a..2c1ca97a386f 100644 --- a/mesonbuild/mesonmain.py +++ b/mesonbuild/mesonmain.py @@ -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 @@ -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 diff --git a/mesonbuild/scripts/reprotest.py b/mesonbuild/scripts/reprotest.py new file mode 100755 index 000000000000..573381658f09 --- /dev/null +++ b/mesonbuild/scripts/reprotest.py @@ -0,0 +1,128 @@ +# 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: + if fstr.endswith(INTERMEDIATE_EXTENSIONS): + return True + return False + + def check_contents(self, fromdir: str, todir: str, check_contents: bool) -> None: + import filecmp + 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: + if not filecmp.cmp(fromfile, tofile, shallow=False): + 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)