From 956b2859f3bb426806ebb13d9f71a9956ffc8f3e Mon Sep 17 00:00:00 2001 From: "Paul S. Schweigert" Date: Thu, 16 Jul 2026 09:46:47 -0400 Subject: [PATCH 1/2] ci: add check for license/copyright headers on code files Signed-off-by: Paul S. Schweigert --- .github/scripts/bump_version.py | 3 + .github/scripts/check_license_headers.py | 182 +++++++++++++++++++++++ .github/scripts/cut_release_branch.sh | 3 + .github/scripts/release.sh | 3 + .github/scripts/set-last-version.mjs | 3 + .github/workflows/quality.yml | 11 ++ 6 files changed, 205 insertions(+) create mode 100755 .github/scripts/check_license_headers.py diff --git a/.github/scripts/bump_version.py b/.github/scripts/bump_version.py index 63f52a524..92138ffb3 100755 --- a/.github/scripts/bump_version.py +++ b/.github/scripts/bump_version.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + """Compute and commit the next release version. Reads the current version from pyproject.toml, computes the next version per diff --git a/.github/scripts/check_license_headers.py b/.github/scripts/check_license_headers.py new file mode 100755 index 000000000..0b937b5bd --- /dev/null +++ b/.github/scripts/check_license_headers.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Check (or fix) SPDX license headers on code files. + +Every code file must begin with the two-line header (after an optional shebang): + + Copyright IBM Corp. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 + +rendered in the file's native comment syntax. This script is stdlib-only so it can +run in CI without installing dependencies. + +Scope (matches what the headers were originally applied to): +- Included extensions: .py .sh .ts .tsx .js .mjs .css +- Excluded: everything under docs/, all Jinja templates, and non-code file types. + +Usage: + python3 .github/scripts/check_license_headers.py --check # CI: exit 1 if any missing + python3 .github/scripts/check_license_headers.py --fix # insert/upgrade in place +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent.parent + +COPYRIGHT = "Copyright IBM Corp. All Rights Reserved." +SPDX = "SPDX-License-Identifier: Apache-2.0" + +# Comment prefix per extension family. +HASH_EXTS = {".py", ".sh"} +SLASH_EXTS = {".ts", ".tsx", ".js", ".mjs"} +CSS_EXTS = {".css"} +ALL_EXTS = HASH_EXTS | SLASH_EXTS | CSS_EXTS + +# Paths (relative to repo root) whose subtrees are exempt from the header rule. +EXCLUDED_PREFIXES = ("docs/",) + +# How many leading lines to scan for the header (tolerates a shebang + blank line). +SCAN_LINES = 6 + + +def tracked_files() -> list[Path]: + """Return git-tracked files in scope: target extensions, minus excluded subtrees.""" + patterns = [f"*{ext}" for ext in ALL_EXTS] + out = subprocess.run( + ["git", "ls-files", "-z", *patterns], + cwd=REPO, + capture_output=True, + text=True, + check=True, + ).stdout + result: list[Path] = [] + for rel in out.split("\0"): + if not rel: + continue + if rel.startswith(EXCLUDED_PREFIXES): + continue + result.append(REPO / rel) + return result + + +def header_lines(ext: str) -> list[str]: + """Build the header lines (no trailing newlines) for a file extension.""" + if ext in CSS_EXTS: + return [f"/* {COPYRIGHT} */", f"/* {SPDX} */"] + prefix = "//" if ext in SLASH_EXTS else "#" + return [f"{prefix} {COPYRIGHT}", f"{prefix} {SPDX}"] + + +def has_header(lines: list[str]) -> bool: + """True if both the copyright and SPDX lines appear near the top of the file.""" + head = "\n".join(lines[:SCAN_LINES]) + return COPYRIGHT in head and SPDX in head + + +def fix_file(path: Path) -> str: + """Insert or upgrade the header in a single file. Returns an action string.""" + ext = path.suffix + text = path.read_text(encoding="utf-8") + lines = text.split("\n") + had_trailing_newline = text.endswith("\n") + if had_trailing_newline and lines and lines[-1] == "": + lines = lines[:-1] + + hdr = header_lines(ext) + + if has_header(lines): + return "ok" + + # Upgrade: a bare one-line SPDX with no copyright line — replace it with the pair, + # keeping the comment style already present on that line. + for i, line in enumerate(lines[:SCAN_LINES]): + if SPDX in line and COPYRIGHT not in line: + lines[i : i + 1] = hdr + new_text = "\n".join(lines) + ("\n" if had_trailing_newline else "") + path.write_text(new_text, encoding="utf-8") + return "upgraded" + + # Fresh insert, after a shebang if present. + insert_at = 1 if lines and lines[0].startswith("#!") else 0 + block = list(hdr) + following = lines[insert_at] if insert_at < len(lines) else "" + if following.strip() != "": + block.append("") + lines[insert_at:insert_at] = block + new_text = "\n".join(lines) + ("\n" if had_trailing_newline else "") + path.write_text(new_text, encoding="utf-8") + return "inserted" + + +def run_check(files: list[Path]) -> int: + """Report files missing the header. Return process exit code.""" + missing = [ + p for p in files if not has_header(p.read_text(encoding="utf-8").split("\n")) + ] + if not missing: + print(f"All {len(files)} code files have SPDX license headers.") + return 0 + + print( + f"{len(missing)} file(s) are missing the SPDX license header:\n", + file=sys.stderr, + ) + for p in missing: + print(f" {p.relative_to(REPO)}", file=sys.stderr) + print( + "\nEvery code file must begin with (after any shebang):\n\n" + f" {COPYRIGHT}\n" + f" {SPDX}\n\n" + "in the file's comment syntax (# for .py/.sh, // for .ts/.tsx/.js/.mjs, /* */ for .css).\n\n" + "Fix locally with:\n\n" + " python3 .github/scripts/check_license_headers.py --fix\n", + file=sys.stderr, + ) + return 1 + + +def run_fix(files: list[Path]) -> int: + """Insert/upgrade headers in place. Return process exit code (always 0).""" + counts: dict[str, int] = {} + for p in files: + action = fix_file(p) + counts[action] = counts.get(action, 0) + 1 + if action != "ok": + print(f"{action:9} {p.relative_to(REPO)}") + changed = counts.get("inserted", 0) + counts.get("upgraded", 0) + print( + f"\nDone: {changed} file(s) updated " + f"({counts.get('inserted', 0)} inserted, {counts.get('upgraded', 0)} upgraded), " + f"{counts.get('ok', 0)} already compliant." + ) + return 0 + + +def main() -> int: + """Parse args and dispatch to check or fix.""" + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--check", + action="store_true", + help="Report files missing the header and exit non-zero (default).", + ) + group.add_argument( + "--fix", action="store_true", help="Insert or upgrade headers in place." + ) + args = parser.parse_args() + + files = tracked_files() + if args.fix: + return run_fix(files) + return run_check(files) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/cut_release_branch.sh b/.github/scripts/cut_release_branch.sh index 38806d3f4..ddc15ec3e 100755 --- a/.github/scripts/cut_release_branch.sh +++ b/.github/scripts/cut_release_branch.sh @@ -1,4 +1,7 @@ #!/bin/bash +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + # Cut a release branch off main and apply matched version bumps to both branches. # # Expected state before running: diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 47d580bef..926ea822b 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -1,4 +1,7 @@ #!/bin/bash +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + # Publish a release from the currently checked-out branch. Uses the version # already written to pyproject.toml; does not modify it. # diff --git a/.github/scripts/set-last-version.mjs b/.github/scripts/set-last-version.mjs index 473fca43f..872a368e6 100644 --- a/.github/scripts/set-last-version.mjs +++ b/.github/scripts/set-last-version.mjs @@ -1,3 +1,6 @@ +// Copyright IBM Corp. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + import {readFileSync, writeFileSync} from 'node:fs'; const [, , version] = process.argv; diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index decd39362..0661e3f69 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -27,6 +27,17 @@ jobs: - name: Lint GitHub Actions workflows uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2 + license-headers: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Check SPDX license headers + run: python3 .github/scripts/check_license_headers.py --check + quality: runs-on: ubuntu-latest timeout-minutes: 180 From 933222c6833b3983dd833cd720b8ca5ce78ec4e5 Mon Sep 17 00:00:00 2001 From: "Paul S. Schweigert" Date: Fri, 17 Jul 2026 17:30:00 -0400 Subject: [PATCH 2/2] add headers to new tests Signed-off-by: Paul S. Schweigert --- test/backends/test_audio_openai.py | 3 +++ test/backends/test_audio_openai_unit.py | 3 +++ test/backends/test_reasoning_replay.py | 3 +++ test/backends/test_reasoning_replay_e2e.py | 3 +++ test/backends/test_watsonx_multimodal_unit.py | 3 +++ test/stdlib/test_session_audio_unit.py | 3 +++ 6 files changed, 18 insertions(+) diff --git a/test/backends/test_audio_openai.py b/test/backends/test_audio_openai.py index 640e1ef5c..5c21131f8 100644 --- a/test/backends/test_audio_openai.py +++ b/test/backends/test_audio_openai.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + """E2E tests for audio-text-to-text with OpenAI-compatible backends. These tests require a local llama-server with an audio-capable model. diff --git a/test/backends/test_audio_openai_unit.py b/test/backends/test_audio_openai_unit.py index 5786a816e..ea3164f10 100644 --- a/test/backends/test_audio_openai_unit.py +++ b/test/backends/test_audio_openai_unit.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + """Unit tests for audio payload shape on OpenAI-compatible backends (mocked). Verifies that `AudioBlock` is correctly serialised into the ``input_audio`` diff --git a/test/backends/test_reasoning_replay.py b/test/backends/test_reasoning_replay.py index d9ea96d5c..e521555ce 100644 --- a/test/backends/test_reasoning_replay.py +++ b/test/backends/test_reasoning_replay.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + """Per-backend multi-turn reasoning-replay policy tests (no live model). These tests assert the *messages array actually sent to the provider* on a diff --git a/test/backends/test_reasoning_replay_e2e.py b/test/backends/test_reasoning_replay_e2e.py index 2692c9130..d375e854a 100644 --- a/test/backends/test_reasoning_replay_e2e.py +++ b/test/backends/test_reasoning_replay_e2e.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + """Live multi-turn reasoning-replay verification against a real thinking model. This is the "quick verification after the fact" for issue #1201: the mechanical diff --git a/test/backends/test_watsonx_multimodal_unit.py b/test/backends/test_watsonx_multimodal_unit.py index ae8b23d54..a47232a60 100644 --- a/test/backends/test_watsonx_multimodal_unit.py +++ b/test/backends/test_watsonx_multimodal_unit.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + """Unit tests verifying WatsonxAIBackend rejects image and audio inputs. Watsonx does not support image or audio inputs. These tests confirm that diff --git a/test/stdlib/test_session_audio_unit.py b/test/stdlib/test_session_audio_unit.py index 40fc080e1..3aa2c2b18 100644 --- a/test/stdlib/test_session_audio_unit.py +++ b/test/stdlib/test_session_audio_unit.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + """Unit tests verifying MelleaSession forwards audio params to mfuncs — no backend required.""" import base64