Add integration test support to GN. This adds a simple integration test to verify that the GN works correctly. It is a modified verison of examples/simple_build, with the following changes: * Remove any platform-dependent logic, allowing us to use the same goldens for all platforms * Add flags to the toolchain to make it very obvious what flags are where, given that the tools are not runnable anyway. And while I'm at it, attempt to reduce the tech debt of build/gen.py by creating a simple API for writing the GN ninja file. We can migrate to it slowly. Bug: None Change-Id: Ica26f158daf704e3bba8d9ae46e716e46a6a6964 Reviewed-on: https://gn-review.googlesource.com/c/gn/+/23280 Reviewed-by: Takuto Ikuta <tikuta@google.com> Commit-Queue: Matt Stark <msta@google.com>
diff --git a/build/gen.py b/build/gen.py index 7f7745f..9a9027b 100755 --- a/build/gen.py +++ b/build/gen.py
@@ -13,6 +13,8 @@ import subprocess import sys +from ninja_file import NinjaFile, escape_path_ninja + assert sys.version_info >= (3, 9, 0) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -97,6 +99,11 @@ def is_serenity(self): return self._platform == 'serenity' + @property + def exe_suffix(self): + return '.exe' if self.is_windows() else '' + + class ArgumentsList: """Helper class to accumulate ArgumentParser argument definitions and be able to regenerate a corresponding command-line to be @@ -280,7 +287,11 @@ cxx, ar, ld, platform, host, options, args_list, cflags=[], ldflags=[], libflags=[], include_dirs=[], solibs=[]): + # Generate integration tests using NinjaFile + build_dir = os.path.dirname(path) + ninja = NinjaFile(platform, REPO_ROOT, build_dir) args = args_list.gen_command_line_args(options) + if args: args = " " + args @@ -297,13 +308,8 @@ ' generator = 1', ' depfile = build.ninja.d', '', - # TODO: Remove this. A dummy target to ensure that CI is actually running - # this test before the integration tests are submitted. - 'build run_integration_tests: phony build.ninja', - '' ] - template_filename = os.path.join(SCRIPT_DIR, { 'msvc': 'build_win.ninja.template', 'mingw': 'build_linux.ninja.template', @@ -337,9 +343,6 @@ library_ext = '.a' object_ext = '.o' - def escape_path_ninja(path): - return path.replace('$ ', '$$ ').replace(' ', '$ ').replace(':', '$:') - def src_to_obj(path): return escape_path_ninja('%s' % os.path.splitext(path)[0] + object_ext) @@ -368,7 +371,6 @@ ' '.join([src_to_obj(src_file) for src_file in settings['sources']]))) ninja_lines.append(' libflags = %s' % ' '.join(libflags)) - for executable, settings in executables.items(): for src_file in settings['sources']: build_source(src_file, settings) @@ -386,17 +388,33 @@ ninja_lines.append('') # Make sure the file ends with a newline. + ninja.Phony( + 'run_tests', + inputs=[ + ninja.RunBinary( + 'run_gn_unittests', + inputs=['gn_unittests' + platform.exe_suffix], + args='--quiet', + ), + ninja.Phony( + 'run_integration_tests', inputs=[ninja.IntegrationTest('simple')] + ), + ], + ) + with open(path, 'w') as f: f.write('\n'.join(ninja_header_lines)) f.write(ninja_template) f.write('\n'.join(ninja_lines)) + f.write(ninja.write_ninja()) - build_dir = os.path.dirname(path) + depfile_deps = [ + os.path.relpath(os.path.join(SCRIPT_DIR, 'gen.py'), build_dir), + os.path.relpath(template_filename, build_dir), + os.path.relpath(os.path.join(SCRIPT_DIR, 'ninja_file.py'), build_dir), + ] + [str(path) for path in ninja.regen_triggers] with open(path + '.d', 'w') as f: - f.write('build.ninja: ' + - os.path.relpath(os.path.join(SCRIPT_DIR, 'gen.py'), - build_dir) + ' ' + - os.path.relpath(template_filename, build_dir) + '\n') + f.write('build.ninja: ' + ' '.join(depfile_deps) + '\n') if options.generate_compilation_database: with open(os.path.join(REPO_ROOT, 'compile_commands.json'), 'w') as f:
diff --git a/build/ninja_file.py b/build/ninja_file.py new file mode 100644 index 0000000..ff854b7 --- /dev/null +++ b/build/ninja_file.py
@@ -0,0 +1,189 @@ +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""A simple data structure for generating simple Ninja build files.""" + +import dataclasses +import os +import pathlib +import shlex +import sys + + +def escape_path_ninja(path): + return str(path).replace('$ ', '$$ ').replace(' ', '$ ').replace(':', '$:') + + +def escape_path_command(path): + # shlex.quote is designed for posix shells and thus won't work on windows. + # Assume there are no double quotes in the path - seems pretty reasonable. + return f'"{path}"' + + + +@dataclasses.dataclass +class Action: + rule: 'DummyRule' + output: pathlib.Path + inputs: list[pathlib.Path] = dataclasses.field(default_factory=list) + implicit_inputs: list[pathlib.Path] = dataclasses.field(default_factory=list) + variables: dict[str, str] = dataclasses.field(default_factory=dict) + + def __str__(self): + return str(self.output) + + +class DummyRule: + + def __init__(self, name, ninja_file, inputs=None): + self.name = name + self.ninja_file = ninja_file + self.inputs = inputs or [] + + def __call__(self, output, *, inputs=None, implicit_inputs=None, **kwargs): + action = Action( + rule=self, + output=pathlib.Path(output), + inputs=inputs or [], + implicit_inputs=self.inputs + (implicit_inputs or []), + variables=kwargs, + ) + self.ninja_file.actions.append(action) + return action.output + + +class Rule(DummyRule): + + def __init__(self, name, ninja_file, command, description=None, inputs=None): + super().__init__(name, ninja_file, inputs) + self.command = command + self.description = description + self.ninja_file.rules.append(self) + + +class NinjaFile: + + def __init__(self, platform, source_root, out_dir): + self.platform = platform + self.out_dir = pathlib.Path(out_dir).resolve() + # source_root is relative to out_dir + self.source_root = pathlib.Path(os.path.relpath(source_root, self.out_dir)) + + self.regen_triggers = [] + self.rules = [] + self.actions = [] + + self._gn_exe = pathlib.Path('gn' + self.platform.exe_suffix) + build_prefix = '' if self.platform.is_windows() else './' + + def python(path, args): + return ( + f'{escape_path_command(sys.executable)}' + f' {escape_path_command(self.source_file(path))} {args}' + ) + + # Define standard/dummy rules (no rule block generated in build.ninja) + self.Phony = DummyRule('phony', self) + self.Cxx = DummyRule('cxx', self) + self.Link = DummyRule('link', self) + + self.RunBinary = Rule( + name='run_binary', + ninja_file=self, + command=self.chain( + f'{build_prefix}$in $args', python('tools/touch.py', '$out') + ), + description='RUN BINARY $in', + ) + + # Define custom rules (rule block generated in build.ninja) + self._run_gn = Rule( + name='run_gn', + ninja_file=self, + command=self.chain( + # For golden tests it's very important that if a ninja file is no + # longer generated, it is actually deleted. + python('tools/clean.py', '$out.actual'), + f'{build_prefix}{self._gn_exe} gen $out.actual --quiet' + ' --root=$path', + python('tools/touch.py', '$out'), + ), + description='RUN GN $out', + inputs=[self._gn_exe], + ) + + compare_script = self.source_file('tools/compare_goldens.py') + self._compare_goldens = Rule( + name='compare_goldens', + ninja_file=self, + command=self.chain( + python('tools/compare_goldens.py', '$path $goldens'), + python('tools/touch.py', '$out'), + ), + description='COMPARE $out', + inputs=[compare_script], + ) + + def chain(self, *commands): + joined = ' && '.join(commands) + if self.platform.is_windows(): + return f'cmd.exe /s /c "{joined}"' + return joined + + def source_file(self, path): + return self.source_root / path + + def directory(self, dir_path, exclude_dirs): + # Join out_dir with dir_path (which is relative to out_dir) to get absolute path for filesystem walk + full_dir_path = (self.out_dir / dir_path).resolve() + inputs = [] + for root, dirs, files in os.walk(full_dir_path): + for exc in exclude_dirs: + if exc in dirs: + dirs.remove(exc) + root = pathlib.Path(os.path.relpath(root, self.out_dir)) + self.regen_triggers.append(root) + inputs.append(root) + for file in files: + inputs.append(root / file) + return sorted(inputs) + + # IntegrationTest is a macro more so than a rule. + def IntegrationTest(self, name): + path = self.source_file(f'integration_tests/{name}') + inputs = self.directory(path, ['out', 'goldens']) + golden_path = path / 'goldens' + + stamp = self._run_gn(name, path=path, inputs=inputs) + + return self._compare_goldens( + name + '_integration_test', + inputs=[stamp] + self.directory(golden_path, []), + path=f'{name}.actual', + goldens=golden_path, + ) + + def write_ninja(self): + out = [] + for rule in self.rules: + out.append(f'rule {rule.name}') + out.append(f' command = {getattr(rule, "command", "")}') + out.append(f' description = {getattr(rule, "description", "")}') + out.append('') + + for action in self.actions: + inputs = ' '.join([escape_path_ninja(p) for p in action.inputs]) + inputs = f' {inputs}' if inputs else '' + + implicit = ' '.join( + [escape_path_ninja(p) for p in action.implicit_inputs] + ) + implicit = f' | {implicit}' if implicit else '' + + out.append(f'build {action.output}: {action.rule.name}{inputs}{implicit}') + for k, v in action.variables.items(): + out.append(f' {k} = {v}') + out.append('') + + return '\n'.join(out)
diff --git a/integration_tests/simple/.gn b/integration_tests/simple/.gn new file mode 100644 index 0000000..e5b6d4a --- /dev/null +++ b/integration_tests/simple/.gn
@@ -0,0 +1,2 @@ +# The location of the build configuration file. +buildconfig = "//build/BUILDCONFIG.gn"
diff --git a/integration_tests/simple/BUILD.gn b/integration_tests/simple/BUILD.gn new file mode 100644 index 0000000..a77fadc --- /dev/null +++ b/integration_tests/simple/BUILD.gn
@@ -0,0 +1,28 @@ +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +executable("hello") { + sources = [ "hello.cc" ] + + deps = [ + ":hello_shared", + ":hello_static", + ] +} + +shared_library("hello_shared") { + sources = [ + "hello_shared.cc", + "hello_shared.h", + ] + + defines = [ "HELLO_SHARED_IMPLEMENTATION" ] +} + +static_library("hello_static") { + sources = [ + "hello_static.cc", + "hello_static.h", + ] +}
diff --git a/integration_tests/simple/build/BUILD.gn b/integration_tests/simple/build/BUILD.gn new file mode 100644 index 0000000..65baa2b --- /dev/null +++ b/integration_tests/simple/build/BUILD.gn
@@ -0,0 +1,16 @@ +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +config("compiler_defaults") { + cflags = [ + "-fPIC", + "-pthread", + ] +} + +config("executable_ldconfig") { + ldflags = [ + "-ldexecutable_ldconfig", + ] +}
diff --git a/integration_tests/simple/build/BUILDCONFIG.gn b/integration_tests/simple/build/BUILDCONFIG.gn new file mode 100644 index 0000000..d39d66d --- /dev/null +++ b/integration_tests/simple/build/BUILDCONFIG.gn
@@ -0,0 +1,25 @@ +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# All binary targets will get this list of configs by default. +_shared_binary_target_configs = [ "//build:compiler_defaults" ] + +# Apply that default list to the binary target types. +set_defaults("executable") { + configs = _shared_binary_target_configs + + # Executables get this additional configuration. + configs += [ "//build:executable_ldconfig" ] +} +set_defaults("static_library") { + configs = _shared_binary_target_configs +} +set_defaults("shared_library") { + configs = _shared_binary_target_configs +} +set_defaults("source_set") { + configs = _shared_binary_target_configs +} + +set_default_toolchain("//build/toolchain:gcc") \ No newline at end of file
diff --git a/integration_tests/simple/build/toolchain/BUILD.gn b/integration_tests/simple/build/toolchain/BUILD.gn new file mode 100644 index 0000000..b4d9202 --- /dev/null +++ b/integration_tests/simple/build/toolchain/BUILD.gn
@@ -0,0 +1,82 @@ +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +# We don't actually care about being able to run any of these tools, but we +# strongly care about generating the exact same file for all platforms. +toolchain("gcc") { + tool("cc") { + depfile = "{{output}}.d" + command = "cc --depfile=$depfile DEFINES {{defines}} INCLUDE_DIRS {{include_dirs}} CFLAGS {{cflags}} CFLAGS_CC {{cflags_c}} SOURCE {{source}} OUTPUT {{output}}" + depsformat = "gcc" + description = "CC {{output}}" + outputs = + [ "{{source_out_dir}}/{{target_output_name}}.{{source_name_part}}.o" ] + } + + tool("cxx") { + depfile = "{{output}}.d" + command = "cxx --depfile=$depfile DEFINES {{defines}} INCLUDE_DIRS {{include_dirs}} CFLAGS {{cflags}} CFLAGS_CC {{cflags_c}} SOURCE {{source}} OUTPUT {{output}}" + depsformat = "gcc" + description = "CXX {{output}}" + outputs = + [ "{{source_out_dir}}/{{target_output_name}}.{{source_name_part}}.o" ] + } + + tool("alink") { + command = "alink {{output}} INPUTS {{inputs}}" + description = "AR {{target_output_name}}{{output_extension}}" + + outputs = + [ "{{target_out_dir}}/{{target_output_name}}{{output_extension}}" ] + default_output_extension = ".a" + output_prefix = "lib" + } + + tool("solink") { + soname = "{{target_output_name}}{{output_extension}}" # e.g. "libfoo.so". + sofile = "{{output_dir}}/$soname" + rspfile = soname + ".rsp" + rspfile_content = "{{inputs}} {{solibs}} {{libs}}" + + command = "solink $sofile @$rspfile" + + description = "SOLINK $soname" + + # Use this for {{output_extension}} expansions unless a target manually + # overrides it (in which case {{output_extension}} will be what the target + # specifies). + default_output_extension = ".so" + + # Use this for {{output_dir}} expansions unless a target manually overrides + # it (in which case {{output_dir}} will be what the target specifies). + default_output_dir = "{{root_out_dir}}" + + outputs = [ sofile ] + link_output = sofile + depend_output = sofile + output_prefix = "lib" + } + + tool("link") { + outfile = "{{target_output_name}}{{output_extension}}" + rspfile = "$outfile.rsp" + rspfile_content = "{{inputs}}" + + command = "link $outfile @$rspfile {{solibs}} {{libs}}" + + description = "LINK $outfile" + default_output_dir = "{{root_out_dir}}" + outputs = [ outfile ] + } + + tool("stamp") { + command = "stamp {{output}}" + description = "STAMP {{output}}" + } + + tool("copy") { + command = "copy {{source}} {{output}}" + description = "COPY {{source}} {{output}}" + } +}
diff --git a/integration_tests/simple/goldens/build.ninja b/integration_tests/simple/goldens/build.ninja new file mode 100644 index 0000000..e1ec3c3 --- /dev/null +++ b/integration_tests/simple/goldens/build.ninja
@@ -0,0 +1,25 @@ +ninja_required_version = 1.7.2 + + +# The 'gn' rule also writes build.ninja, unbeknownst to ninja. The +# build.ninja edge is separate to prevent ninja from deleting it +# (due to depfile usage) if interrupted. gn uses atomic writes to +# ensure that build.ninja is always valid even if interrupted. + +build build.ninja: phony build.ninja.stamp + generator = 1 + +subninja toolchain.ninja + +build hello_shared: phony ./libhello_shared.so +build hello_static: phony obj/libhello_static.a +build $:hello: phony hello +build $:hello_shared: phony ./libhello_shared.so +build $:hello_static: phony obj/libhello_static.a + +build all: phony $ + hello $ + ./libhello_shared.so $ + obj/libhello_static.a + +default all
diff --git a/integration_tests/simple/goldens/obj/hello.ninja b/integration_tests/simple/goldens/obj/hello.ninja new file mode 100644 index 0000000..1b37420 --- /dev/null +++ b/integration_tests/simple/goldens/obj/hello.ninja
@@ -0,0 +1,17 @@ +defines = +include_dirs = +cflags = -fPIC -pthread +target_out_dir = obj +target_output_name = hello + +build obj/hello.hello.o: cxx ../../integration_tests/simple/hello.cc + source_file_part = hello.cc + source_name_part = hello + +build hello: link obj/hello.hello.o ./libhello_shared.so obj/libhello_static.a + ldflags = -ldexecutable_ldconfig + libs = + frameworks = + swiftmodules = + output_extension = + output_dir = .
diff --git a/integration_tests/simple/goldens/obj/hello_shared.ninja b/integration_tests/simple/goldens/obj/hello_shared.ninja new file mode 100644 index 0000000..60e4105 --- /dev/null +++ b/integration_tests/simple/goldens/obj/hello_shared.ninja
@@ -0,0 +1,17 @@ +defines = -DHELLO_SHARED_IMPLEMENTATION +include_dirs = +cflags = -fPIC -pthread +target_out_dir = obj +target_output_name = libhello_shared + +build obj/libhello_shared.hello_shared.o: cxx ../../integration_tests/simple/hello_shared.cc + source_file_part = hello_shared.cc + source_name_part = hello_shared + +build ./libhello_shared.so: solink obj/libhello_shared.hello_shared.o + ldflags = + libs = + frameworks = + swiftmodules = + output_extension = .so + output_dir = .
diff --git a/integration_tests/simple/goldens/obj/hello_static.ninja b/integration_tests/simple/goldens/obj/hello_static.ninja new file mode 100644 index 0000000..165a67f --- /dev/null +++ b/integration_tests/simple/goldens/obj/hello_static.ninja
@@ -0,0 +1,14 @@ +defines = +include_dirs = +cflags = -fPIC -pthread +target_out_dir = obj +target_output_name = libhello_static + +build obj/libhello_static.hello_static.o: cxx ../../integration_tests/simple/hello_static.cc + source_file_part = hello_static.cc + source_name_part = hello_static + +build obj/libhello_static.a: alink obj/libhello_static.hello_static.o + arflags = + output_extension = .a + output_dir =
diff --git a/integration_tests/simple/goldens/toolchain.ninja b/integration_tests/simple/goldens/toolchain.ninja new file mode 100644 index 0000000..113cbd3 --- /dev/null +++ b/integration_tests/simple/goldens/toolchain.ninja
@@ -0,0 +1,33 @@ +rule alink + command = alink ${out} INPUTS ${in} + description = AR ${target_output_name}${output_extension} +rule cc + command = cc --depfile=${out}.d DEFINES ${defines} INCLUDE_DIRS ${include_dirs} CFLAGS ${cflags} CFLAGS_CC ${cflags_c} SOURCE ${in} OUTPUT ${out} + description = CC ${out} + depfile = ${out}.d + deps = gcc +rule copy + command = copy ${in} ${out} + description = COPY ${in} ${out} +rule cxx + command = cxx --depfile=${out}.d DEFINES ${defines} INCLUDE_DIRS ${include_dirs} CFLAGS ${cflags} CFLAGS_CC ${cflags_c} SOURCE ${in} OUTPUT ${out} + description = CXX ${out} + depfile = ${out}.d + deps = gcc +rule link + command = link ${target_output_name}${output_extension} @${target_output_name}${output_extension}.rsp ${solibs} ${libs} + description = LINK ${target_output_name}${output_extension} + rspfile = ${target_output_name}${output_extension}.rsp + rspfile_content = ${in} +rule solink + command = solink ${output_dir}/${target_output_name}${output_extension} @${target_output_name}${output_extension}.rsp + description = SOLINK ${target_output_name}${output_extension} + rspfile = ${target_output_name}${output_extension}.rsp + rspfile_content = ${in} ${solibs} ${libs} +rule stamp + command = stamp ${out} + description = STAMP ${out} + +subninja obj/hello.ninja +subninja obj/hello_shared.ninja +subninja obj/hello_static.ninja
diff --git a/tools/clean.py b/tools/clean.py new file mode 100644 index 0000000..48be64d --- /dev/null +++ b/tools/clean.py
@@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""A cross-platform rm -rf""" + +import os +import shutil +import sys + +if os.path.exists(sys.argv[1]): + shutil.rmtree(sys.argv[1])
diff --git a/tools/compare_goldens.py b/tools/compare_goldens.py new file mode 100755 index 0000000..070cbd1 --- /dev/null +++ b/tools/compare_goldens.py
@@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import argparse +import difflib +import os +from pathlib import Path +import re +import shlex +import shutil +import sys + +COLOR_GREEN = '\033[32m' +COLOR_RED = '\033[31m' +COLOR_CYAN = '\033[36m' +COLOR_RESET = '\033[0m' + +# These two lines are platform-dependent, since they contain the GN binary itself. +STRIP_REGEN_COMMAND = re.compile( + r'^(rule gn|build build.ninja.stamp.*)\n(?:[ \t]+.+\n)*', flags=re.MULTILINE +) + + +def get_ninja_files(directory: Path) -> set[Path]: + ninja_files = set() + for root, _, files in os.walk(directory): + for f in files: + if f.endswith('.ninja'): + rel = os.path.relpath(os.path.join(root, f), directory) + ninja_files.add(Path(rel)) + return ninja_files + + +def compare_files(want: Path, got: Path, update) -> bool: + got_text = STRIP_REGEN_COMMAND.sub( + '', got.read_text(encoding='utf-8', errors='replace') + ) + want_text = want.read_text(encoding='utf-8', errors='replace') + + diff = list( + difflib.unified_diff( + want_text.splitlines(keepends=True), + got_text.splitlines(keepends=True), + fromfile=str(want), + tofile=str(got), + lineterm='\n', + ) + ) + + if not diff: + return False + + if update: + want.write_text(got_text) + return False + + for line in diff: + if line.startswith('+') and not line.startswith('+++'): + sys.stdout.write(COLOR_GREEN + line + COLOR_RESET) + elif line.startswith('-') and not line.startswith('---'): + sys.stdout.write(COLOR_RED + line + COLOR_RESET) + elif line.startswith('@@'): + sys.stdout.write(COLOR_CYAN + line + COLOR_RESET) + else: + sys.stdout.write(line) + return True + + +def main(): + parser = argparse.ArgumentParser( + description='Compare or update ninja output files.' + ) + parser.add_argument( + '--update', action='store_true', help='Update the golden files' + ) + parser.add_argument('generated_dir', help='Path to generated files directory') + parser.add_argument('golden_dir', help='Path to golden files directory') + args = parser.parse_args() + + want_dir = Path(args.golden_dir).resolve() + got_dir = Path(args.generated_dir).resolve() + + want_files = get_ninja_files(want_dir) + got_files = get_ninja_files(got_dir) + + return_code = 0 + all_files = want_files | got_files + + for f in sorted(all_files): + want = want_dir / f + got = got_dir / f + + if f not in got_files: + if args.update: + want.unlink() + else: + print(f'Error: Missing generated file: {f}', file=sys.stderr) + return_code = 1 + elif f not in want_files: + if args.update: + shutil.copy2(got, want) + else: + print(f'Error: Unexpected generated file: {f}', file=sys.stderr) + return_code = 1 + else: + if compare_files(want, got, args.update): + return_code = 1 + + if return_code: + cmd = ' '.join([ + shlex.quote(sys.executable), + shlex.quote(str(Path(__file__).resolve())), + shlex.quote(str(got_dir)), + shlex.quote(str(want_dir)), + '--update', + ]) + print( + f'Run `{cmd}` to update the golden files if the changes are' + ' intentional.' + ) + sys.exit(return_code) + + +if __name__ == '__main__': + main()
diff --git a/tools/touch.py b/tools/touch.py new file mode 100644 index 0000000..44fabec --- /dev/null +++ b/tools/touch.py
@@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""A cross-platform `touch` command""" + +import pathlib +import sys + +for arg in sys.argv[1:]: + pathlib.Path(arg).touch()