blob: ee52fff9a6e7c2070d7923ce74a5920d60eeedc1 [file] [edit]
#!/usr/bin/env python3
"""Backfill membrowse history for one CI board/example.
Thin wrapper over `membrowse onboard` that derives every convention-bound
argument from the board and example name, so a backfill cannot land under a
target name that differs from what CI uploads (family_support.cmake uses
`<board>/<cmake-target>`, i.e. the example BASENAME - not the role/name path).
Each historical build writes a small linker shim from that commit's ninja graph,
so linker scripts and --defsym values match the ELF being reported rather than
the caller's current checkout.
`membrowse onboard` checks out and `git clean -fdx`s every historical commit in
place. This wrapper confines that to a disposable worktree; each build then runs
that commit's `tools/get_deps.py` so dependency revisions match the ELF.
Composes, from repo-root-relative conventions (using idf.py for espressif):
build dir: examples/cmake-build-<board> (reconfigured for every commit)
build script: tools/get_deps.py -b <board> && cmake -S examples -B <build_dir> ... &&
cmake --build <build_dir> --target <basename>
elf path: <build_dir>/<role>/<basename>/<basename>.elf
target name: <board>/<basename>
ld scripts: regenerated after every build from its ninja graph
change scope: --build-dirs src/ hw/ examples/<role>/<name>/ tools/get_deps.py
(skip rebuilds elsewhere - the example's own dir and dependency
manifest are in scope because both can change the same elf)
Dry-run by default; pass --upload for the real run (requires MEMBROWSE_API_KEY
in the environment - read at run time, passed as argv, never printed).
"""
import argparse
import os
import shlex
import subprocess
import sys
from membrowse_report import extract_ld_scripts, ninja_commands, extract_defsyms, redacted
def compose(board, example, num_commits, upload, api_key, extra, family=None):
"""Return the membrowse onboard argv for one board/example backfill."""
basename = os.path.basename(example.rstrip('/'))
build_dir = f'examples/cmake-build-{board}'
# `membrowse onboard` runs `git clean -fdx` before every historical build,
# which deletes this ignored build_dir - reconfigure it fresh each time.
python = shlex.quote(sys.executable)
quoted_build_dir = shlex.quote(build_dir)
if family == 'espressif':
build = (f'idf.py -C {shlex.quote(f"examples/{example}")} -B {quoted_build_dir} '
f'-GNinja {shlex.quote(f"-DBOARD={board}")} build')
elf_path = f'{build_dir}/{basename}.elf'
link_target = f'{basename}.elf'
shim_scripts = ('esp-idf/esp_system/ld/memory.ld',
'esp-idf/esp_system/ld/sections.ld')
else:
configure = (f'cmake -S examples -B {quoted_build_dir} '
f'{shlex.quote(f"-DBOARD={board}")} -G Ninja '
f'-DCMAKE_BUILD_TYPE=MinSizeRel')
build = f'{configure} && cmake --build {quoted_build_dir} --target {shlex.quote(basename)}'
elf_path = f'{build_dir}/{example}/{basename}.elf'
link_target = basename
shim_scripts = ()
build_script = f'{python} tools/get_deps.py -b {shlex.quote(board)} && {build}'
shim_path = f'{build_dir}/.membrowse-onboard.ld'
write_shim = (f'{python} {shlex.quote(os.path.abspath(__file__))} '
f'--write-linker-shim ninja '
f'{quoted_build_dir} {shlex.quote(link_target)} {shlex.quote(shim_path)}')
if shim_scripts:
write_shim += ' ' + ' '.join(map(shlex.quote, shim_scripts))
build_script = f'{build_script} && {write_shim}'
cmd = ['membrowse', 'onboard']
if '--commits' not in extra:
cmd.append(str(num_commits))
cmd += [
build_script,
elf_path,
f'{board}/{basename}',
api_key,
]
if '--binary-search' not in extra:
role = example.split('/', 1)[0]
cmd += ['--build-dirs', 'src/', 'hw/', 'lib/', 'examples/build_system/',
'examples/CMakeLists.txt', f'examples/{role}/CMakeLists.txt',
f'examples/{example.rstrip("/")}/', 'tools/get_deps.py']
cmd += ['--ld-scripts', shim_path]
if not upload:
cmd.append('--dry-run')
cmd += extra
return cmd
def write_linker_shim(ninja, build_dir, target, output, *scripts):
"""Write current build's linker scripts and defsyms to a stable path."""
commands = ninja_commands(ninja, build_dir, target)
scripts = scripts or extract_ld_scripts(commands)
if not scripts:
sys.exit(f'no linker script found in the ninja build graph for target {target!r}')
lines = []
for sym in extract_defsyms(commands):
name, separator, value = sym.partition('=')
if not separator:
sys.exit(f'invalid --defsym value in ninja build graph: {sym!r}')
lines.append(f'{name} = {value};')
for script in scripts:
path = script if os.path.isabs(script) else os.path.abspath(os.path.join(build_dir, script))
if '"' in path or not os.path.isfile(path):
sys.exit(f'linker script not found or unsupported: {path!r}')
lines.append(f'INCLUDE "{path}"')
with open(output, 'w') as f:
f.write('\n'.join(lines) + '\n')
return 0
def main():
# Internal re-entry point used by the historical build script.
if len(sys.argv) >= 6 and sys.argv[1] == '--write-linker-shim':
try:
return write_linker_shim(*sys.argv[2:])
except RuntimeError as e:
sys.exit(f'error: {e}')
parser = argparse.ArgumentParser(
description='Backfill membrowse history for one CI board/example '
'(dry-run unless --upload). Extra args after -- go to '
'`membrowse onboard` verbatim (see its --help).')
parser.add_argument('board', help='Board name, e.g. stm32f407disco')
parser.add_argument('example', help='Example as role/name, e.g. device/cdc_msc')
parser.add_argument('-n', '--num-commits', type=int, default=30,
help='Most recent N commits to backfill (default: 30)')
parser.add_argument('--upload', action='store_true', default=False,
help='Really upload (default is --dry-run)')
args, extra = parser.parse_known_args()
api_key = os.environ.get('MEMBROWSE_API_KEY')
if args.upload and not api_key:
sys.exit('MEMBROWSE_API_KEY must be set in the environment for --upload')
# Isolate the actual onboard run (checks out + `git clean -fdx`s every
# historical commit in place - see the module docstring) in a disposable
# worktree, never repo_root itself.
repo_root = os.getcwd()
worktree_dir = os.path.join(repo_root, 'cmake-metrics', '_onboard_worktree')
family = 'espressif' if os.path.isdir(
os.path.join(repo_root, 'hw', 'bsp', 'espressif', 'boards', args.board)) else None
cmd = compose(args.board, args.example, args.num_commits, args.upload,
api_key or 'dry-run-placeholder', extra, family)
print('+ ' + ' '.join(redacted(cmd, api_key)), flush=True)
if os.path.isdir(worktree_dir):
subprocess.run(['git', 'worktree', 'remove', '--force', worktree_dir],
capture_output=True)
os.makedirs(os.path.dirname(worktree_dir), exist_ok=True)
ret = subprocess.run(['git', 'worktree', 'add', '--detach', worktree_dir, 'HEAD'],
capture_output=True, text=True)
if ret.returncode != 0:
sys.exit(f'failed to create disposable worktree at {worktree_dir}:\n{ret.stderr}')
try:
return subprocess.run(cmd, cwd=worktree_dir).returncode
finally:
subprocess.run(['git', 'worktree', 'remove', '--force', worktree_dir],
capture_output=True)
if __name__ == '__main__':
sys.exit(main())