#!/usr/bin/env python3 # MobileGL - scripts/ci/build_fingerprint.py # Copyright (c) 2025-2026 MobileGL-Dev # Licensed under the GNU Lesser General Public License v3.0: # https://www.gnu.org/licenses/gpl-3.0.txt # https://www.gnu.org/licenses/lgpl-3.0.txt # SPDX-License-Identifier: LGPL-3.0-only # End of Source File Header """Decide whether the two Linux library configurations are identical apart from the benchmarks. build-linux and build-linux-split configure the same sources twice. On the disaggregation branches the only intended difference is MOBILEGL_BUILD_BENCHMARK, which adds MG_Benchmark and the google benchmark dependency and touches nothing else. When the two configurations are identical, the split consumers can take build-linux's artifacts and build-linux-split need not compile again. The fingerprint is read from what configure writes, so it can be taken before the build: cache MOBILEGL_* entries (except MOBILEGL_BUILD_BENCHMARK), the build type, compilers, launchers, flags and the link tools from CMakeCache.txt; compile every compile_commands.json entry: file, output and arguments; link every link statement of build.ninja: outputs, rule, inputs and variables; tests the text of every CTestTestfile.cmake and gtest *_include.cmake. The build directory's path becomes @BUILD@ and a shared FetchContent directory @DEPS@; everything under MobileGL/MG_Benchmark or the benchmark dependency is left out. Both fingerprints come from one runner, so its toolchain and image are recorded beside the verdict and never compared. build_fingerprint.py fingerprint --build-dir D [--deps-dir F] [--out F] build_fingerprint.py compare A.json B.json build_fingerprint.py guard --primary-dir P --split-dir S --deps-dir F [--github-output KEY] [--report R] build_fingerprint.py --self-test guard configures both lines with scripts/ci/configure_build.sh (configure only) into P and S, with F as their one FetchContent directory, so only the first configure fetches anything; a configure that fails, or runs past 10 minutes and is killed, is retried once. It writes KEY=identical|different|unknown, image= and compiler= to $GITHUB_OUTPUT and always exits 0. 'different' is a proven difference. A configure that failed twice, any error, or a tree with fewer than 200 compile entries, 3 link statements (libMobileGL.so among them) or 2 CTest files is 'unknown': only 'identical' lets the workflow reuse a build. Any other verdict is annotated with its first reasons, a warning for 'different' and a notice for 'unknown'. """ import argparse import contextlib import io import json import os from pathlib import Path import re import shlex import shutil import signal import subprocess import sys import tempfile import time ROOT = Path(__file__).resolve().parents[2] CONFIGURE = ROOT / 'scripts' / 'ci' / 'configure_build.sh' # Each configure attempt is killed after CONFIGURE_TIMEOUT seconds. GUARD_STEP_MINUTES must equal the # guard step's timeout-minutes in test.yml, and leave room for four killed attempts. CONFIGURE_TIMEOUT = 600 GUARD_STEP_MINUTES = 45 MIN_COMPILE = 200 MIN_LINK = 3 MIN_TESTS = 2 VERDICTS = ('identical', 'different', 'unknown') CACHE_KEY = re.compile( r'^(MOBILEGL_\w+' r'|CMAKE_BUILD_TYPE' r'|CMAKE_(C|CXX)_COMPILER(_LAUNCHER)?' r'|CMAKE_(C|CXX)_FLAGS(_\w+)?' r'|CMAKE_(EXE|SHARED|MODULE|STATIC)_LINKER_FLAGS(_\w+)?' r'|CMAKE_INTERPROCEDURAL_OPTIMIZATION|CMAKE_POSITION_INDEPENDENT_CODE' r'|CMAKE_LINKER|CMAKE_AR|CMAKE_RANLIB)$') CACHE_SKIP = {'MOBILEGL_BUILD_BENCHMARK'} CACHE_LINE = re.compile(r'^([A-Za-z0-9_.+-]+):([A-Z]+)=(.*)$') LINK_RULE = re.compile(r'^(C|CXX)_(SHARED_LIBRARY|EXECUTABLE|MODULE|STATIC_LIBRARY)_LINKER__') BENCHMARK_PATH = re.compile(r'(^|/)(MobileGL/MG_Benchmark|(_deps|@DEPS@)/benchmark-[a-z]+)(/|$)') BENCHMARK_SUBDIRS = re.compile( r'^\s*subdirs\("[^"]*(MobileGL/MG_Benchmark|(_deps|@DEPS@)/benchmark-build)"\)\s*$') PACKAGES = ('clang-20', 'lld-20', 'libc++-20-dev', 'libc++abi-20-dev', 'libvulkan-dev', 'libegl1-mesa-dev', 'libgles2-mesa-dev') def normalizer(build_dir, deps_dir=None): roots = [(str(Path(build_dir)), '@BUILD@'), (str(Path(build_dir).resolve()), '@BUILD@')] if deps_dir: roots += [(str(Path(deps_dir)), '@DEPS@'), (str(Path(deps_dir).resolve()), '@DEPS@')] roots = sorted(set(roots), key=lambda pair: len(pair[0]), reverse=True) def norm(text): for root, token in roots: text = text.replace(root, token) return text return norm def is_benchmark(path): return bool(BENCHMARK_PATH.search(path.replace('\\', '/'))) def read_cache(build, norm): cache = {} path = build / 'CMakeCache.txt' if not path.is_file(): return cache for line in path.read_text(errors='replace').splitlines(): m = CACHE_LINE.match(line) if m and CACHE_KEY.match(m.group(1)) and m.group(1) not in CACHE_SKIP: cache[m.group(1)] = norm(m.group(3)) return cache def read_compile(build, norm): path = build / 'compile_commands.json' rows = [] if not path.is_file(): return rows for entry in json.loads(path.read_text(errors='replace')): directory = entry.get('directory', '') source = entry.get('file', '') if 'arguments' in entry: argv = list(entry['arguments']) else: argv = shlex.split(entry.get('command', '')) output = entry.get('output', '') rel_out = norm(os.path.join(directory, output)) if output else '' if is_benchmark(norm(source)) or is_benchmark(rel_out) or is_benchmark(norm(directory)): continue rows.append([norm(source), rel_out] + [norm(arg) for arg in argv]) return sorted(rows) def ninja_lines(text): """build.ninja's logical lines: a line ending in an unescaped $ continues on the next one.""" out, pending = [], None for raw in text.splitlines(): line = raw if pending is None else pending + raw.lstrip() trailing = len(line) - len(line.rstrip('$')) if trailing % 2 == 1: pending = line[:-1] continue pending = None out.append(line) if pending is not None: out.append(pending) return out def split_unescaped(text): """Ninja tokens: split on spaces that are not escaped as '$ '.""" tokens, current, i = [], '', 0 while i < len(text): c = text[i] if c == '$' and i + 1 < len(text): current += text[i:i + 2] i += 2 continue if c == ' ': if current: tokens.append(current) current = '' else: current += c i += 1 if current: tokens.append(current) return tokens def read_links(build, norm): links = {} if not (build / 'build.ninja').is_file(): return links lines = ninja_lines((build / 'build.ninja').read_text(errors='replace')) i = 0 while i < len(lines): line = lines[i] i += 1 if not line.startswith('build '): continue head = line[len('build '):] colon = re.search(r'(?')!r} vs {b['cache'].get(k, '')!r}" for k in keys if a['cache'].get(k) != b['cache'].get(k)] if cache: diffs['cache'] = cache ca = {tuple(row) for row in a['compile']} cb = {tuple(row) for row in b['compile']} if ca != cb: only_a = sorted(ca - cb) only_b = sorted(cb - ca) diffs['compile'] = ([f'only in first: {row[0]} -> {row[1]}' for row in only_a[:limit]] + [f'only in second: {row[0]} -> {row[1]}' for row in only_b[:limit]] + [f'{len(only_a)} vs {len(only_b)} differing entries']) link = [] for name in sorted(set(a['link']) | set(b['link'])): la, lb = a['link'].get(name), b['link'].get(name) if la == lb: continue if la is None or lb is None: link.append(f"{name}: only in {'second' if la is None else 'first'}") continue for field in ('rule', 'inputs'): if la[field] != lb[field]: link.append(f'{name}: {field} differs') for var in sorted(set(la['vars']) | set(lb['vars'])): if la['vars'].get(var) != lb['vars'].get(var): link.append(f'{name}: {var} differs') if link: diffs['link'] = link tests = [f"{name}: {'differs' if name in a['tests'] and name in b['tests'] else 'only in one'}" for name in sorted(set(a['tests']) | set(b['tests'])) if a['tests'].get(name) != b['tests'].get(name)] if tests: diffs['tests'] = tests return {field: rows[:limit] + ([f'... {len(rows) - limit} more'] if len(rows) > limit else []) for field, rows in diffs.items()} def verdict(a, b): """(verdict, reasons). Too little evidence on either side is 'unknown', never 'identical'.""" thin = [f'first: {p}' for p in unproven(a)] + [f'second: {p}' for p in unproven(b)] if thin: return 'unknown', thin reasons = [f'{field}: {row}' for field, rows in compare(a, b).items() for row in rows] return ('different' if reasons else 'identical'), reasons def run_attempt(argv, label, timeout=CONFIGURE_TIMEOUT): """One configure attempt; True on success. One that runs past the timeout (a stalled fetch) is killed with its whole process group, cmake and its git clones included, and counts as failed.""" proc = subprocess.Popen(argv, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) try: output, _ = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) try: output, _ = proc.communicate(timeout=60) except subprocess.TimeoutExpired: output = '' print(output[-6000:]) print(f'build_fingerprint: {label} ran past {timeout} s and was killed') return False if proc.returncode != 0: print(output[-6000:]) print(f'build_fingerprint: {label} failed with exit {proc.returncode}') return proc.returncode == 0 def run_configure(variant, build_dir, deps_dir, timeout=None): """configure_build.sh, configure only, with the shared FetchContent directory; True on success.""" return run_attempt(['bash', str(CONFIGURE), variant, str(build_dir), f'-DFETCHCONTENT_BASE_DIR={deps_dir}'], f'configure_build.sh {variant}', CONFIGURE_TIMEOUT if timeout is None else timeout) def configure_twice(configure, variant, build_dir, deps_dir): """One retry: a transient fetch failure must not decide anything. Returns the attempts used.""" for attempt in (1, 2): if build_dir.exists(): shutil.rmtree(build_dir) if configure(variant, build_dir, deps_dir): return attempt return 0 def decide(primary_dir, split_dir, deps_dir, configure=run_configure): """(verdict, reasons, counts). The primary line runs first: it alone fetches the benchmark.""" primary_dir, split_dir, deps_dir = Path(primary_dir), Path(split_dir), Path(deps_dir) counts = {} for variant, build_dir in (('primary', primary_dir), ('split', split_dir)): attempts = configure_twice(configure, variant, build_dir, deps_dir) if not attempts: return 'unknown', [f'the {variant} configure failed twice'], counts counts[f'{variant}_attempts'] = attempts primary = fingerprint(primary_dir, deps_dir) split = fingerprint(split_dir, deps_dir) counts.update(primary=primary['counts'], split=split['counts']) result, reasons = verdict(primary, split) return result, reasons, counts def one_line(text, limit=200): return ' '.join(str(text).split())[:limit] def annotation(result, reasons): """The workflow annotation for a verdict other than 'identical': a 'different' that persists is configuration drift that brings the second build back on every run, so it is a warning.""" if result == 'identical': return None level = 'warning' if result == 'different' else 'notice' text = one_line('; '.join(reasons[:3]) or 'no reason recorded', 600).replace('%', '%25') return f'::{level}::build-guard verdict {result}, so build-linux-split builds: {text}' def write_outputs(key, result, image, compiler): lines = [f'{key}={result}', f'image={one_line(image)}', f'compiler={one_line(compiler)}'] for line in lines: print(line) path = os.environ.get('GITHUB_OUTPUT') if path: with open(path, 'a') as out: out.write(''.join(line + '\n' for line in lines)) def guard(args, configure=run_configure, toolchain_reader=read_toolchain): result, reasons, counts = 'unknown', [], {} image = read_image() toolchain = {} try: toolchain = toolchain_reader() result, reasons, counts = decide(args.primary_dir, args.split_dir, args.deps_dir, configure) except Exception as error: # a guard that cannot decide says so, and nothing is reused result, reasons = 'unknown', [f'the guard could not compare the configurations: {error!r}'] compiler = (toolchain.get('MOBILEGL_CI_CXX') or 'unset').splitlines()[0] if toolchain else 'unread' image_text = f"{image['ImageOS']} {image['ImageVersion']}".strip() or 'unknown image' print(f'build_fingerprint: {counts}') print(f'build_fingerprint: this runner: {image_text}, {compiler}') for reason in reasons: print(f'build_fingerprint: {reason}') note = annotation(result, reasons) if note: print(note) if args.report: Path(args.report).write_text(json.dumps({'verdict': result, 'reasons': reasons, 'counts': counts, 'toolchain': toolchain, 'image': image}, indent=1) + '\n') write_outputs(args.github_output, result, image_text, compiler) return 0 # ------------------------------------------------------------------------------------- self-test def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release', define='-DMG=1', link_flag='-Wl,--as-needed', test_env='VK_ICD_FILENAMES=/icd.json', benchmark=False, empty=False, compile_count=MIN_COMPILE + 5, deps=None, library='libMobileGL.so'): build = root / name build.mkdir(parents=True) if empty: return build abs_build = str(build) deps = str(deps) if deps else abs_build + '/_deps' (build / 'CMakeCache.txt').write_text( f'CMAKE_BUILD_TYPE:STRING={build_type}\n' 'CMAKE_CXX_COMPILER:FILEPATH=/usr/bin/clang++-20\n' 'CMAKE_CXX_COMPILER_LAUNCHER:STRING=ccache\n' 'CMAKE_CXX_FLAGS:STRING=\n' f'MOBILEGL_BUILD_DISAGGREGATED:BOOL={mobilegl_flag}\n' f"MOBILEGL_BUILD_BENCHMARK:BOOL={'ON' if benchmark else 'OFF'}\n" f'FETCHCONTENT_BASE_DIR:PATH={deps}\n' f'CMAKE_CACHEFILE_DIR:INTERNAL={abs_build}\n') entries = [{'directory': abs_build, 'file': f'/src/MobileGL/f{i}.cpp', 'output': f'CMakeFiles/MobileGL.dir/f{i}.cpp.o', 'command': f'ccache clang++ {define if i == 7 else "-DMG=1"} -I{abs_build}/gen ' f'-I{deps}/googletest-src/googletest/include ' f'-o CMakeFiles/MobileGL.dir/f{i}.cpp.o -c /src/MobileGL/f{i}.cpp'} for i in range(compile_count)] entries.append({'directory': deps + '/googletest-build/googletest', 'file': deps + '/googletest-src/googletest/src/gtest-all.cc', 'output': 'CMakeFiles/gtest.dir/src/gtest-all.cc.o', 'command': 'clang++ -c gtest-all.cc'}) if benchmark: entries.append({'directory': abs_build + '/MobileGL/MG_Benchmark', 'file': '/src/MobileGL/MG_Benchmark/b.cpp', 'output': 'CMakeFiles/SanityBench.dir/b.cpp.o', 'command': 'clang++ -c b.cpp'}) entries.append({'directory': deps + '/benchmark-build/src', 'file': deps + '/benchmark-src/src/benchmark.cc', 'output': 'CMakeFiles/benchmark.dir/benchmark.cc.o', 'command': 'clang++ -c benchmark.cc'}) (build / 'compile_commands.json').write_text(json.dumps(entries)) links = [ (library, 'CXX_SHARED_LIBRARY_LINKER__MobileGL_Release', 'CMakeFiles/MobileGL.dir/f1.cpp.o'), ('libMobileGL_s.a', 'CXX_STATIC_LIBRARY_LINKER__MobileGL_s_Release', 'CMakeFiles/MobileGL.dir/f2.cpp.o'), ('MobileGL/MG_Test/SanityTest', 'CXX_EXECUTABLE_LINKER__SanityTest_Release', 'libMobileGL_s.a'), ] if benchmark: links.append(('MobileGL/MG_Benchmark/SanityBench', 'CXX_EXECUTABLE_LINKER__SanityBench_Release', 'libMobileGL_s.a')) links.append((deps + '/benchmark-build/src/libbenchmark.a', 'CXX_STATIC_LIBRARY_LINKER__benchmark_Release', deps + '/benchmark-build/src/CMakeFiles/benchmark.dir/benchmark.cc.o')) ninja = ['ninja_required_version = 1.5', ''] for out, rule, inp in links: ninja += [f'build {out}: {rule} {inp} | /usr/lib/libc.so || cmake_object_order_depends_target_x', f' LINK_FLAGS = {link_flag}', ' LINK_LIBRARIES = -lEGL $', ' -lvulkan', f' POST_BUILD = cd {abs_build}/MobileGL && :', ''] ninja.append('build CMakeFiles/x.o: CXX_COMPILER__MobileGL_Release /src/x.cpp') (build / 'build.ninja').write_text('\n'.join(ninja) + '\n') subdirs = (f'subdirs("MobileGL/MG_Test")\nsubdirs("{deps}/googletest-build")\n' + (f'subdirs("MobileGL/MG_Benchmark")\nsubdirs("{deps}/benchmark-build")\n' if benchmark else '')) (build / 'CTestTestfile.cmake').write_text(f'# CMake generated Testfile for {abs_build}\n{subdirs}') (build / 'MobileGL' / 'MG_Test').mkdir(parents=True) (build / 'MobileGL' / 'MG_Test' / 'CTestTestfile.cmake').write_text( f'add_test([=[T]=] "{abs_build}/MobileGL/MG_Test/SanityTest")\n' f'set_tests_properties([=[T]=] PROPERTIES ENVIRONMENT "{test_env}")\n') if benchmark: (build / 'MobileGL' / 'MG_Benchmark').mkdir(parents=True) (build / 'MobileGL' / 'MG_Benchmark' / 'CTestTestfile.cmake').write_text( 'add_test([=[SanityBench]=] "SanityBench")\n') return build def self_test(): cases = [ ('none', {}, 'identical'), ('a MOBILEGL_* cache line', {'mobilegl_flag': 'OFF'}, 'different'), ("one TU's -D", {'define': '-DMG=2'}, 'different'), ('the build type', {'build_type': 'Debug'}, 'different'), ('a link flag', {'link_flag': '-Wl,--no-as-needed'}, 'different'), ('a test ENVIRONMENT', {'test_env': 'VK_ICD_FILENAMES=/other.json'}, 'different'), ('only the benchmark TUs, link lines, subdirs lines and dependency', {'benchmark': True}, 'identical'), ('only the build directory name', {'name': 'build-other'}, 'identical'), ('only where the shared FetchContent directory is', {'deps_name': 'deps-elsewhere'}, 'identical'), ('an empty directory', {'empty': True}, 'unknown'), ] failures = 0 def report(ok, label, got): nonlocal failures failures += not ok print(f"{'ok' if ok else 'NOT OK':7} {label}: {got}") with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) base_deps = tmp / 'deps' base = fingerprint(canned_tree(tmp / 'base', deps=base_deps), base_deps) assert not unproven(base), unproven(base) for label, change, expected in cases: change = dict(change) deps = tmp / change.pop('deps_name', 'deps') root = tmp / re.sub(r'\W+', '_', label) other = fingerprint(canned_tree(root, deps=deps, **change), deps) got, reasons = verdict(base, other) report(got == expected, label, got + ('' if got == expected or not reasons else f' ({reasons[:2]})')) # Two trees that agree on everything but are too thin to prove anything are never identical. thin_a = fingerprint(canned_tree(tmp / 'thin-a', compile_count=10, deps=base_deps), base_deps) thin_b = fingerprint(canned_tree(tmp / 'thin-b', compile_count=10, deps=base_deps), base_deps) report(verdict(thin_a, thin_b)[0] == 'unknown', 'both sides thin', verdict(thin_a, thin_b)[0]) empty = verdict(fingerprint(canned_tree(tmp / 'empty-a', empty=True)), fingerprint(canned_tree(tmp / 'empty-b', empty=True)))[0] report(empty == 'unknown', 'both sides empty', empty) # Full-sized trees that agree but link no libMobileGL.so prove nothing about the library. no_lib = [fingerprint(canned_tree(tmp / f'no-lib-{side}', deps=base_deps, library='libMobileGLServer.so'), base_deps) for side in 'ab'] report(verdict(*no_lib)[0] == 'unknown', 'both sides without a libMobileGL.so link', verdict(*no_lib)[0]) # A configure attempt that hangs is killed with its children, so it cannot hold the step. started = time.monotonic() with contextlib.redirect_stdout(io.StringIO()): hung = run_attempt(['bash', '-c', 'sleep 60 & wait'], 'a hung configure', timeout=1) elapsed = time.monotonic() - started report(not hung and elapsed < 30, 'a hung configure is killed and fails', f'{hung} after {elapsed:.1f} s') # The production path carries the timeout too, and four killed attempts fit the step. stub = tmp / 'hanging-configure.sh' stub.write_text('sleep 60 & wait\n') real_configure = CONFIGURE globals()['CONFIGURE'] = stub try: started = time.monotonic() with contextlib.redirect_stdout(io.StringIO()): hung = run_configure('primary', tmp / 'hung-build', tmp / 'hung-deps', timeout=1) elapsed = time.monotonic() - started finally: globals()['CONFIGURE'] = real_configure report(not hung and elapsed < 30, 'run_configure passes its timeout on', f'{hung} after {elapsed:.1f} s') report(isinstance(CONFIGURE_TIMEOUT, int) and CONFIGURE_TIMEOUT > 0 and 4 * (CONFIGURE_TIMEOUT + 60) <= GUARD_STEP_MINUTES * 60, 'four killed configure attempts fit the guard step', CONFIGURE_TIMEOUT) escaped = annotation('different', ['100% of the flags']) report(escaped is not None and escaped.endswith('100%25 of the flags'), "an annotation escapes '%'", escaped) # The guard itself, with a stand-in for configure_build.sh. def run_guard(configure, label): output = tmp / f'github_output-{label}' old = os.environ.get('GITHUB_OUTPUT') os.environ['GITHUB_OUTPUT'] = str(output) try: args = argparse.Namespace(primary_dir=str(tmp / f'{label}-primary'), split_dir=str(tmp / f'{label}-split'), deps_dir=str(tmp / f'{label}-deps'), github_output='verdict', report=None) printed = io.StringIO() with contextlib.redirect_stdout(printed): guard(args, configure, lambda: {'MOBILEGL_CI_CXX': 'clang version 20.1.8'}) finally: if old is None: os.environ.pop('GITHUB_OUTPUT', None) else: os.environ['GITHUB_OUTPUT'] = old outputs = dict(line.split('=', 1) for line in output.read_text().splitlines()) outputs['annotations'] = [line for line in printed.getvalue().splitlines() if line.startswith('::')] return outputs calls = [] def flaky(variant, build_dir, deps_dir): calls.append(variant) if variant == 'primary' and calls.count('primary') == 1: return False canned_tree(build_dir.parent, name=build_dir.name, benchmark=variant == 'primary', deps=deps_dir) return True out = run_guard(flaky, 'flaky') report(out.get('verdict') == 'identical' and calls == ['primary', 'primary', 'split'], 'a configure that fails once is retried', f"{out.get('verdict')} after {calls}") report(set(out) == {'verdict', 'image', 'compiler', 'annotations'}, 'the guard writes verdict, image and compiler', sorted(out)) report(out['annotations'] == [], "'identical' is not annotated", out['annotations']) hangs = [] def hangs_once(variant, build_dir, deps_dir): hangs.append(variant) if len(hangs) == 1: return run_attempt(['bash', '-c', 'sleep 60 & wait'], 'a hung configure', timeout=1) canned_tree(build_dir.parent, name=build_dir.name, benchmark=variant == 'primary', deps=deps_dir) return True out = run_guard(hangs_once, 'hangs') report(out.get('verdict') == 'identical' and hangs == ['primary', 'primary', 'split'], 'a configure killed on its timeout is retried', f"{out.get('verdict')} after {hangs}") def broken(variant, build_dir, deps_dir): return variant != 'split' out = run_guard(broken, 'broken') report(out.get('verdict') == 'unknown', 'a configure that fails twice', out.get('verdict')) report(len(out['annotations']) == 1 and out['annotations'][0].startswith('::notice::') and 'failed twice' in out['annotations'][0], "'unknown' is a notice with its reason", out['annotations']) def debug_split(variant, build_dir, deps_dir): canned_tree(build_dir.parent, name=build_dir.name, deps=deps_dir, build_type='Debug' if variant == 'split' else 'Release') return True out = run_guard(debug_split, 'differ') report(out.get('verdict') == 'different', 'a proven difference', out.get('verdict')) report(len(out['annotations']) == 1 and out['annotations'][0].startswith('::warning::') and 'CMAKE_BUILD_TYPE' in out['annotations'][0], "'different' is a warning with its reason", out['annotations']) def raising(variant, build_dir, deps_dir): raise OSError('no bash') out = run_guard(raising, 'raising') report(out.get('verdict') == 'unknown', 'an error inside the guard', out.get('verdict')) print(f'build_fingerprint self-test: {"FAILED" if failures else "ok"}') return 1 if failures else 0 def main(argv=None): if argv is None: argv = sys.argv[1:] if argv == ['--self-test']: return self_test() parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest='command', required=True) p = sub.add_parser('fingerprint') p.add_argument('--build-dir', required=True) p.add_argument('--deps-dir') p.add_argument('--out') p = sub.add_parser('compare') p.add_argument('first') p.add_argument('second') p = sub.add_parser('guard') p.add_argument('--primary-dir', required=True) p.add_argument('--split-dir', required=True) p.add_argument('--deps-dir', required=True) p.add_argument('--github-output', default='verdict') p.add_argument('--report') args = parser.parse_args(argv) if args.command == 'fingerprint': fp = fingerprint(args.build_dir, args.deps_dir) text = json.dumps(fp, indent=1, sort_keys=True) + '\n' if args.out: Path(args.out).write_text(text) print(f"build_fingerprint: {args.build_dir}: {fp['counts']}; unproven: {unproven(fp) or 'no'}") return 0 if args.command == 'compare': a = json.loads(Path(args.first).read_text()) b = json.loads(Path(args.second).read_text()) result, reasons = verdict(a, b) for reason in reasons: print(reason) print(f'verdict={result}') return 0 if result == 'identical' else 1 return guard(args) if __name__ == '__main__': sys.exit(main())