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