run_tests: add file header checker for licensing blocks
Change-Id: Ic0bfa3b03e2ba46d565a5bc2c1b7a7463b7dca2c
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/500103
Commit-Queue: Mike Frysinger <vapier@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Scott Lee <ddoman@google.com>
diff --git a/release/check-metadata.py b/release/check-metadata.py
new file mode 100755
index 0000000..e17932d
--- /dev/null
+++ b/release/check-metadata.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+# Copyright (C) 2025 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Helper tool to check various metadata (e.g. licensing) in source files."""
+
+import argparse
+from pathlib import Path
+import re
+import sys
+
+import util
+
+
+_FILE_HEADER_RE = re.compile(
+ r"""# Copyright \(C\) 20[0-9]{2} The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2\.0 \(the "License"\);
+# you may not use this file except in compliance with the License\.
+# You may obtain a copy of the License at
+#
+# http://www\.apache\.org/licenses/LICENSE-2\.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied\.
+# See the License for the specific language governing permissions and
+# limitations under the License\.
+"""
+)
+
+
+def check_license(path: Path, lines: list[str]) -> bool:
+ """Check license header."""
+ # Enforce licensing on configs & scripts.
+ if not (
+ path.suffix in (".bash", ".cfg", ".ini", ".py", ".toml")
+ or lines[0] in ("#!/bin/bash", "#!/bin/sh", "#!/usr/bin/env python3")
+ ):
+ return True
+
+ # Extract the file header.
+ header_lines = []
+ for line in lines:
+ if line.startswith("#"):
+ header_lines.append(line)
+ else:
+ break
+ if not header_lines:
+ print(
+ f"error: {path.relative_to(util.TOPDIR)}: "
+ "missing file header (copyright+licensing)",
+ file=sys.stderr,
+ )
+ return False
+
+ # Skip the shebang.
+ if header_lines[0].startswith("#!"):
+ header_lines.pop(0)
+
+ # If this file is imported into the tree, then leave it be.
+ if header_lines[0] == "# DO NOT EDIT THIS FILE":
+ return True
+
+ header = "".join(f"{x}\n" for x in header_lines)
+ if not _FILE_HEADER_RE.match(header):
+ print(
+ f"error: {path.relative_to(util.TOPDIR)}: "
+ "file header incorrectly formatted",
+ file=sys.stderr,
+ )
+ print(
+ "".join(f"> {x}\n" for x in header_lines), end="", file=sys.stderr
+ )
+ return False
+
+ return True
+
+
+def check_path(opts: argparse.Namespace, path: Path) -> bool:
+ """Check a single path."""
+ data = path.read_text(encoding="utf-8")
+ lines = data.splitlines()
+ # NB: Use list comprehension and not a generator so we run all the checks.
+ return all(
+ [
+ check_license(path, lines),
+ ]
+ )
+
+
+def check_paths(opts: argparse.Namespace, paths: list[Path]) -> bool:
+ """Check all the paths."""
+ # NB: Use list comprehension and not a generator so we check all paths.
+ return all([check_path(opts, x) for x in paths])
+
+
+def find_files(opts: argparse.Namespace) -> list[Path]:
+ """Find all the files in the source tree."""
+ result = util.run(
+ opts,
+ ["git", "ls-tree", "-r", "-z", "--name-only", "HEAD"],
+ cwd=util.TOPDIR,
+ capture_output=True,
+ encoding="utf-8",
+ )
+ return [util.TOPDIR / x for x in result.stdout.split("\0")[:-1]]
+
+
+def get_parser() -> argparse.ArgumentParser:
+ """Get a CLI parser."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "-n",
+ "--dry-run",
+ dest="dryrun",
+ action="store_true",
+ help="show everything that would be done",
+ )
+ parser.add_argument(
+ "paths",
+ nargs="*",
+ help="the paths to scan",
+ )
+ return parser
+
+
+def main(argv: list[str]) -> int:
+ """The main func!"""
+ parser = get_parser()
+ opts = parser.parse_args(argv)
+
+ paths = opts.paths
+ if not opts.paths:
+ paths = find_files(opts)
+
+ return 0 if check_paths(opts, paths) else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/release/util.py b/release/util.py
index c839b87..8596324 100644
--- a/release/util.py
+++ b/release/util.py
@@ -14,7 +14,7 @@
"""Random utility code for release tools."""
-import os
+from pathlib import Path
import re
import shlex
import subprocess
@@ -24,8 +24,9 @@
assert sys.version_info >= (3, 6), "This module requires Python 3.6+"
-TOPDIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-HOMEDIR = os.path.expanduser("~")
+THIS_FILE = Path(__file__).resolve()
+TOPDIR = THIS_FILE.parent.parent
+HOMEDIR = Path("~").expanduser()
# These are the release keys we sign with.
@@ -54,7 +55,7 @@
def import_release_key(opts):
"""Import the public key of the official release repo signing key."""
# Extract the key from our repo launcher.
- launcher = getattr(opts, "launcher", os.path.join(TOPDIR, "repo"))
+ launcher = getattr(opts, "launcher", TOPDIR / "repo")
print(f'Importing keys from "{launcher}" launcher script')
with open(launcher, encoding="utf-8") as fp:
data = fp.read()
diff --git a/run_tests b/run_tests
index 4720ac2..04f2deb 100755
--- a/run_tests
+++ b/run_tests
@@ -102,6 +102,15 @@
).returncode
+def run_check_metadata():
+ """Returns the exit code from check-metadata."""
+ return subprocess.run(
+ [sys.executable, "release/check-metadata.py"],
+ check=False,
+ cwd=ROOT_DIR,
+ ).returncode
+
+
def run_update_manpages() -> int:
"""Returns the exit code from release/update-manpages."""
# Allow this to fail on CI, but not local devs.
@@ -124,6 +133,7 @@
run_black,
run_flake8,
run_isort,
+ run_check_metadata,
run_update_manpages,
)
# Run all the tests all the time to get full feedback. Don't exit on the