[CI] (.github/workflows, docs/ci, scripts/ci): decide split build reuse in one guard job

- a new build-guard job configures both Linux lines on one runner (configure only, disaggregation branches) and outputs identical, different or unknown with its runner image and compiler; it is continue-on-error at job level, so it never fails the run, and it takes MOBILEGL_CI_DISAGGREGATED from build-linux's branch expression, so a drifted branch list gives different
- build_fingerprint.py guard: one shared FetchContent directory (normalised as @DEPS@) so only the first configure fetches, a failed configure, or one killed with its process group after 10 minutes, retried once, and unknown for a configure that failed twice, any error or too little evidence; different is a warning annotation and unknown a notice, each with its first reasons; the toolchain is recorded, not compared; self-test cases for both sides thin, no libMobileGL.so link, a hung configure, the retry after a failure and after a timeout, the annotations, the shared directory and every verdict
- build-linux-split builds nothing only on identical, and the consumers take build-linux's artifacts only then; different and unknown keep both builds, the full split matrix, A/B and the split unit run, and no consumer can red on a disagreement
- build-linux drops its probe and runs the link ratchet on every disaggregation build as link-ratchet-primary; integration-split reds on it when it takes that build and prints both jobs' image and compiler
- build-linux exports its build type, and the split consumers refuse to reuse a build-linux that is not Release (split_reuse_build_type.sh, self-test in pipe-gates): a debug-logging re-run of failed jobs keeps the guard's Release verdict
- configure_build.sh passes extra arguments through to cmake
This commit is contained in:
2026-10-17 17:13:49 -04:00
parent 91b889888e
commit 8aa92c357b
5 changed files with 614 additions and 278 deletions
+304 -128
View File
@@ -7,51 +7,63 @@
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Prove that two CMake build trees were configured identically, apart from the benchmarks.
"""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 equal, the
split consumers can take build-linux's artifacts and build-linux-split need not compile again.
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;
toolchain compiler, linker, cmake, ninja and package versions on this runner;
image the runner image, recorded and never compared.
The build directory's own path is replaced by @BUILD@, and everything under MobileGL/MG_Benchmark
or the benchmark dependency is left out. A fingerprint with fewer than 200 compile entries, 3 link
statements (libMobileGL.so among them) or 2 CTest files proves nothing and never counts as equal.
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 [--out F]
build_fingerprint.py fingerprint --build-dir D [--deps-dir F] [--out F]
build_fingerprint.py compare A.json B.json
build_fingerprint.py guard --built D --probe primary|split --probe-dir P [--github-output KEY]
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 the probe with scripts/ci/configure_build.sh (configure only), compares it with
the built tree and writes KEY=true|false to $GITHUB_OUTPUT. Any error makes it false, and it always
exits 0: a difference sends the workflow down the path that builds both, it is not a red.
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+'
@@ -64,18 +76,22 @@ CACHE_KEY = re.compile(
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/benchmark-[a-z]+)(/|$)')
BENCHMARK_SUBDIRS = re.compile(r'^\s*subdirs\("[^"]*(MobileGL/MG_Benchmark|_deps/benchmark-build)"\)\s*$')
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 normaliser(build_dir):
roots = sorted({str(Path(build_dir)), str(Path(build_dir).resolve())}, key=len, reverse=True)
def normaliser(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 in roots:
text = text.replace(root, '@BUILD@')
for root, token in roots:
text = text.replace(root, token)
return text
return norm
@@ -197,9 +213,8 @@ def read_tests(build, norm):
continue
if is_benchmark(rel) or '/CMakeFiles/' in '/' + rel:
continue
text = path.read_text(errors='replace')
kept = [norm(line) for line in text.splitlines()
if not BENCHMARK_SUBDIRS.match(line) and not line.startswith('# ')]
lines = [norm(line) for line in path.read_text(errors='replace').splitlines()]
kept = [line for line in lines if not BENCHMARK_SUBDIRS.match(line) and not line.startswith('# ')]
tests[rel] = '\n'.join(kept)
return tests
@@ -223,22 +238,21 @@ def read_toolchain():
return tools
def fingerprint(build_dir, toolchain=None):
def read_image():
return {key: os.environ.get(key, '') for key in ('ImageOS', 'ImageVersion', 'RUNNER_NAME')}
def fingerprint(build_dir, deps_dir=None):
build = Path(build_dir)
norm = normaliser(build)
print_counts = {}
norm = normaliser(build, deps_dir)
result = {
'cache': read_cache(build, norm),
'compile': read_compile(build, norm),
'link': read_links(build, norm),
'tests': read_tests(build, norm),
'toolchain': read_toolchain() if toolchain is None else toolchain,
'image': {key: os.environ.get(key, '') for key in ('ImageOS', 'ImageVersion', 'RUNNER_NAME')},
}
print_counts['compile'] = len(result['compile'])
print_counts['link'] = len(result['link'])
print_counts['tests'] = len(result['tests'])
result['counts'] = print_counts
result['counts'] = {'compile': len(result['compile']), 'link': len(result['link']),
'tests': len(result['tests'])}
return result
@@ -293,62 +307,119 @@ def compare(a, b, limit=8):
if a['tests'].get(name) != b['tests'].get(name)]
if tests:
diffs['tests'] = tests
toolchain = [k for k in sorted(set(a['toolchain']) | set(b['toolchain']))
if a['toolchain'].get(k) != b['toolchain'].get(k)]
if toolchain:
diffs['toolchain'] = [f'{k} differs' for k in toolchain]
return {field: rows[:limit] + ([f'... {len(rows) - limit} more'] if len(rows) > limit else [])
for field, rows in diffs.items()}
def verdict(a, b):
"""(identical, reasons). Unproven evidence on either side is never 'identical'."""
reasons = [f'first: {p}' for p in unproven(a)] + [f'second: {p}' for p in unproven(b)]
diffs = compare(a, b)
for field, rows in diffs.items():
reasons += [f'{field}: {row}' for row in rows]
return not reasons, reasons
"""(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 write_output(key, identical):
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')
line = f"{key}={'true' if identical else 'false'}"
print(line)
if path:
with open(path, 'a') as out:
out.write(line + '\n')
out.write(''.join(line + '\n' for line in lines))
def guard(args):
identical = False
def guard(args, configure=run_configure, toolchain_reader=read_toolchain):
result, reasons, counts = 'unknown', [], {}
image = read_image()
toolchain = {}
try:
probe = Path(args.probe_dir)
if probe.exists():
shutil.rmtree(probe)
configured = subprocess.run(['bash', str(CONFIGURE), args.probe, str(probe)], cwd=ROOT,
capture_output=True, text=True)
if configured.returncode != 0:
print(configured.stdout[-4000:] + configured.stderr[-4000:])
raise RuntimeError(f'configure_build.sh {args.probe} failed with exit {configured.returncode}')
toolchain = read_toolchain()
built = fingerprint(args.built, toolchain)
probed = fingerprint(probe, toolchain)
print(f"build_fingerprint: built {args.built}: {built['counts']}; probe {args.probe} at {probe}: "
f"{probed['counts']}")
print(f"build_fingerprint: compiler {toolchain.get('MOBILEGL_CI_CXX', '').splitlines()[:1]}, "
f"image {built['image']}")
identical, reasons = verdict(built, probed)
for reason in reasons:
print(f'build_fingerprint: {reason}')
if args.report:
Path(args.report).write_text(json.dumps({'identical': identical, 'reasons': reasons,
'built': built['counts'], 'probe': probed['counts'],
'toolchain': toolchain, 'image': built['image']},
indent=1) + '\n')
except Exception as error: # a guard that cannot decide must not reuse anything
print(f'build_fingerprint: guard could not compare the configurations: {error}')
identical = False
write_output(args.github_output, identical)
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
@@ -356,12 +427,13 @@ def guard(args):
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):
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'
@@ -369,24 +441,33 @@ def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release',
'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(MIN_COMPILE + 5)]
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 = [
('libMobileGL.so', 'CXX_SHARED_LIBRARY_LINKER__MobileGL_Release', 'CMakeFiles/MobileGL.dir/f1.cpp.o'),
(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',
@@ -394,7 +475,8 @@ def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release',
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 = 'subdirs("MobileGL/MG_Test")\n' + ('subdirs("MobileGL/MG_Benchmark")\n' if benchmark else '')
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(
@@ -408,51 +490,144 @@ def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release',
def self_test():
toolchain = {'MOBILEGL_CI_CXX': 'clang version 20.1.8', 'cmake': 'cmake version 3.31.10'}
other_compiler = dict(toolchain, MOBILEGL_CI_CXX='clang version 20.1.2')
cases = [
('none', {}, toolchain, True),
('a MOBILEGL_* cache line', {'mobilegl_flag': 'OFF'}, toolchain, False),
("one TU's -D", {'define': '-DMG=2'}, toolchain, False),
('the build type', {'build_type': 'Debug'}, toolchain, False),
("the compiler's --version text", {}, other_compiler, False),
('a link flag', {'link_flag': '-Wl,--no-as-needed'}, toolchain, False),
('a test ENVIRONMENT', {'test_env': 'VK_ICD_FILENAMES=/other.json'}, toolchain, False),
('only the benchmark TUs, link lines and subdirs line', {'benchmark': True}, toolchain, True),
('only the build directory name', {'name': 'build-other'}, toolchain, True),
('an empty directory', {'empty': True}, toolchain, False),
('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
with tempfile.TemporaryDirectory() as tmp:
base = fingerprint(canned_tree(Path(tmp) / 'base'), toolchain)
assert not unproven(base), unproven(base)
for label, change, chain, expected in cases:
other = fingerprint(canned_tree(Path(tmp) / label.replace(' ', '_').replace("'", ''), **change),
chain)
identical, reasons = verdict(base, other)
ok = identical == expected
failures += not ok
print(f"{'ok' if ok else 'NOT OK':7} {label}: identical={identical}"
+ ('' if ok or identical else f' ({reasons[:2]})'))
# The guard itself: a probe that cannot be configured is never 'identical'.
output = Path(tmp) / 'github_output'
old = os.environ.get('GITHUB_OUTPUT')
os.environ['GITHUB_OUTPUT'] = str(output)
try:
args = argparse.Namespace(built=str(Path(tmp) / 'base' / 'build'), probe='no-such-variant',
probe_dir=str(Path(tmp) / 'probe'), github_output='identical', report=None)
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
guard(args)
finally:
if old is None:
os.environ.pop('GITHUB_OUTPUT', None)
else:
os.environ['GITHUB_OUTPUT'] = old
ok = output.read_text().strip() == 'identical=false'
def report(ok, label, got):
nonlocal failures
failures += not ok
print(f"{'ok' if ok else 'NOT OK':7} a probe that fails to configure: {output.read_text().strip()}")
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
@@ -466,19 +641,20 @@ def main(argv=None):
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('--built', required=True)
p.add_argument('--probe', required=True, choices=('primary', 'split'))
p.add_argument('--probe-dir', required=True)
p.add_argument('--github-output', default='identical')
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)
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)
@@ -487,11 +663,11 @@ def main(argv=None):
if args.command == 'compare':
a = json.loads(Path(args.first).read_text())
b = json.loads(Path(args.second).read_text())
identical, reasons = verdict(a, b)
result, reasons = verdict(a, b)
for reason in reasons:
print(reason)
print(f"identical={'true' if identical else 'false'}")
return 0 if identical else 1
print(f'verdict={result}')
return 0 if result == 'identical' else 1
return guard(args)
+5 -3
View File
@@ -16,12 +16,14 @@
# library logs the resolved transport at INFO, and run_trace_case.cmake and the split lanes read
# that line back as proof the transport resolved in the process under test.
#
# usage: configure_build.sh <primary|split> <build dir>
# with MOBILEGL_CI_CC, MOBILEGL_CI_CXX and VK_ICD_FILENAMES from setup-test-runner.
# usage: configure_build.sh <primary|split> <build dir> [extra cmake arguments...]
# with MOBILEGL_CI_CC, MOBILEGL_CI_CXX and VK_ICD_FILENAMES from setup-test-runner. The builds pass
# no extra arguments; build_fingerprint.py's guard passes its shared FetchContent directory.
set -euo pipefail
variant="${1:?usage: configure_build.sh <primary|split> <build dir>}"
build_dir="${2:?usage: configure_build.sh <primary|split> <build dir>}"
shift 2
common=(
-S . -B "${build_dir}" -G Ninja
@@ -72,4 +74,4 @@ case "${variant}" in
;;
esac
cmake "${common[@]}" "${flags[@]}"
cmake "${common[@]}" "${flags[@]}" "$@"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# MobileGL - scripts/ci/split_reuse_build_type.sh
# 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
# The split consumers' build-type check. A consumer that reuses build-linux's build (SPLIT_REUSE is
# true: build-guard found the two configurations identical) takes it only when build-linux built
# Release, the only type build-guard can find identical to the split line. A re-run of failed jobs
# with debug logging rebuilds build-linux as Debug but keeps build-guard's verdict, while
# build-linux-split built nothing, so the consumer stops here and says so.
#
# usage: split_reuse_build_type.sh (SPLIT_REUSE and PRIMARY_BUILD_TYPE from the job)
# split_reuse_build_type.sh --self-test
set -euo pipefail
if [ "${1:-}" = --self-test ]; then
failures=0
expect() {
local want="$1" reuse="$2" type="$3" got=0
SPLIT_REUSE="${reuse}" PRIMARY_BUILD_TYPE="${type}" bash "$0" > /dev/null || got=$?
if [ "${got}" -ne "${want}" ]; then
echo "NOT OK reuse=${reuse} build type '${type}': exit ${got}, expected ${want}"
failures=$((failures + 1))
fi
}
expect 0 true Release
expect 1 true Debug
expect 1 true ''
expect 0 false Debug
expect 0 '' ''
if [ "${failures}" -ne 0 ]; then
echo "split_reuse_build_type self-test: FAILED"
exit 1
fi
echo "split_reuse_build_type self-test: ok"
exit 0
fi
if [ "${SPLIT_REUSE:-false}" = true ] && [ "${PRIMARY_BUILD_TYPE:-}" != Release ]; then
echo "::error::build-guard found the two configurations identical as Release builds, but build-linux built '${PRIMARY_BUILD_TYPE:-an unknown type}' (a re-run of failed jobs with debug logging keeps the guard's verdict) and build-linux-split built nothing; re-run all jobs"
exit 1
fi