Repository navigation
ci: add copyright header check for changed source files #4087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
32c9a5d
ci: add copyright header check for changed source files
4132eb1
fix(ci): generate copyright year range dynamically
7db659a
fix(ci): limit copyright check workflow token permissions
90b7d77
fix(ci): drop push trigger and add paths filter to copyright check
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| alwaysApply: true | ||
| description: Enforce the copyright header on new and modified source files | ||
| --- | ||
|
|
||
| # Shared project rules | ||
|
|
||
| Read [AGENTS.md](mdc:AGENTS.md) before working in this rule scope. | ||
|
|
||
| Paths are relative to the repository root. If linked context is unavailable, read the file directly. Load only applicable references. Maintain the linked Markdown source instead of duplicating rules here. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| name: Copyright Header Check | ||
|
|
||
| concurrency: | ||
| group: copyright-check-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| check_mode: | ||
| description: 'check scope: all files or only files changed in the latest commit' | ||
| required: false | ||
| default: 'all' | ||
| type: choice | ||
| options: | ||
| - all | ||
| - changed | ||
| pull_request: | ||
| branches: [develop, 'release/**', 'hotfix/**'] | ||
| paths: | ||
| - '**.py' | ||
| - '**.sh' | ||
| - '**.sql' | ||
| - '**.ts' | ||
| - '**.tsx' | ||
| - '**.js' | ||
| - '**.jsx' | ||
| - '**.mjs' | ||
| - '.github/workflows/copyright-check.yml' | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| copyright-check: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@v4 | ||
| with: | ||
| python-version: '3.11' | ||
|
|
||
| - name: Collect changed files | ||
| if: github.event_name != 'workflow_dispatch' || github.event.inputs.check_mode == 'changed' | ||
| run: | | ||
| set -e | ||
| touch changed_files.txt | ||
| if [ "${{ github.event_name }}" = "pull_request" ]; then | ||
| git diff --name-only --diff-filter=ACMR "origin/${{ github.base_ref }}...HEAD" > changed_files.txt | ||
| else | ||
| git diff --name-only --diff-filter=ACMR HEAD~1..HEAD > changed_files.txt | ||
| fi | ||
| echo "---- changed files ----" | ||
| cat changed_files.txt | ||
|
|
||
| - name: Check copyright headers on changed files | ||
| if: github.event_name != 'workflow_dispatch' || github.event.inputs.check_mode == 'changed' | ||
| run: python scripts/check_copyright.py --files-from changed_files.txt | ||
|
|
||
| - name: Check copyright headers on all files | ||
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.check_mode == 'all' | ||
| run: python scripts/check_copyright.py | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,278 @@ | ||
| #!/usr/bin/env python3 | ||
| # coding: utf-8 | ||
| # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. | ||
| """Copyright header checker for the Nexent repository. | ||
|
|
||
| Modes: | ||
| 1. Full scan (default): scan the whole repository for target files. | ||
| 2. Explicit files: pass paths via --files or --files-from (CI incremental mode). | ||
| 3. Fix mode: --fix inserts the standard header into files that lack it. | ||
|
|
||
| Exit code 1 when any checked file is missing the required header in check mode. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import os | ||
| import re | ||
| import sys | ||
| from datetime import datetime | ||
| from pathlib import Path | ||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parent.parent | ||
|
TiTi-iTiT marked this conversation as resolved.
|
||
|
|
||
| # First year of the project's copyright convention; the header inserted by | ||
| # --fix carries a year range ending at the current year (see copyright_text()). | ||
| COPYRIGHT_START_YEAR = 2025 | ||
|
|
||
| # Loose matching pattern so existing year variants keep passing, e.g. | ||
| # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. | ||
| # Copyright (c) Huawei Technologies Co., Ltd. 2025-2025. All rights reserved | ||
| COPYRIGHT_PATTERN = re.compile( | ||
| r"Copyright\s*\(c\)\s*(?:\d{4}(?:\s*[-,\u2013]\s*\d{4})*\s*,?\s*)?" | ||
| r"Huawei\s+Technologies\s+Co\.,?\s*Ltd\.?", | ||
| re.IGNORECASE, | ||
| ) | ||
|
|
||
| # Encoding declaration lines such as "# -*- coding: utf-8 -*-" or "# coding: utf-8". | ||
| CODING_PATTERN = re.compile(r"^#\s*(?:-\*-\s*)?coding[:=]", re.IGNORECASE) | ||
|
|
||
| # Comment prefix per target extension. | ||
| COMMENT_STYLES = { | ||
| ".py": "#", | ||
| ".sh": "#", | ||
| ".sql": "--", | ||
| ".js": "//", | ||
| ".jsx": "//", | ||
| ".mjs": "//", | ||
| ".ts": "//", | ||
| ".tsx": "//", | ||
| } | ||
|
|
||
| # Search the header within the first N lines of the file. | ||
| HEADER_SEARCH_LINES = 10 | ||
| # Give up after N non-trivial content lines without a copyright match. | ||
| CONTENT_LINE_LIMIT = 5 | ||
|
|
||
| # Directories never scanned in full-scan mode. | ||
| EXCLUDED_DIRS = { | ||
| ".git", | ||
| ".cache", | ||
| ".ua", | ||
| ".slidep", | ||
| ".trae", | ||
| ".idea", | ||
| ".vscode", | ||
| "node_modules", | ||
| ".next", | ||
| "dist", | ||
| "build", | ||
| "coverage", | ||
| ".venv", | ||
| "venv", | ||
| "__pycache__", | ||
| ".pytest_cache", | ||
| ".ruff_cache", | ||
| "assets", | ||
| } | ||
|
|
||
| # Generated files exempt from the header requirement (relative path or bare name). | ||
| EXCLUDED_FILES = { | ||
| "next-env.d.ts", | ||
| } | ||
|
|
||
|
|
||
| def copyright_text() -> str: | ||
| """Standard header text; the year range ends at the current year.""" | ||
| current_year = datetime.now().year | ||
| if current_year > COPYRIGHT_START_YEAR: | ||
| years = f"{COPYRIGHT_START_YEAR}-{current_year}" | ||
| else: | ||
| years = str(COPYRIGHT_START_YEAR) | ||
| return f"Copyright (c) {years} Huawei Technologies Co., Ltd. All rights reserved." | ||
|
|
||
|
|
||
| def rel(path: Path) -> str: | ||
| """Return a repo-relative display path.""" | ||
| try: | ||
| return path.resolve().relative_to(REPO_ROOT).as_posix() | ||
| except ValueError: | ||
| return str(path) | ||
|
|
||
|
|
||
| def is_target_file(path: Path) -> bool: | ||
| """True when the file is in scope of the copyright check.""" | ||
| if path.name in EXCLUDED_FILES: | ||
| return False | ||
| if path.suffix.lower() not in COMMENT_STYLES: | ||
| return False | ||
| try: | ||
| parts = path.resolve().relative_to(REPO_ROOT).parts | ||
| except (OSError, ValueError): | ||
| return False | ||
| return not any(part in EXCLUDED_DIRS or part.startswith(".") for part in parts[:-1]) | ||
|
|
||
|
|
||
| def has_header(path: Path) -> bool: | ||
| """True when the copyright line appears in the file header zone.""" | ||
| try: | ||
| lines = path.read_text(encoding="utf-8-sig", errors="replace").splitlines() | ||
| except OSError: | ||
| print(f"warning: cannot read {rel(path)}", file=sys.stderr) | ||
| return True | ||
|
|
||
| content_seen = 0 | ||
| for line in lines[:HEADER_SEARCH_LINES]: | ||
| stripped = line.strip() | ||
| if not stripped: | ||
| continue | ||
| if stripped.startswith("#!"): | ||
| continue | ||
| if CODING_PATTERN.match(stripped): | ||
| continue | ||
| if COPYRIGHT_PATTERN.search(stripped): | ||
| return True | ||
| content_seen += 1 | ||
| if content_seen >= CONTENT_LINE_LIMIT: | ||
| return False | ||
| return False | ||
|
|
||
|
|
||
| def fix_file(path: Path) -> bool: | ||
| """Insert the standard header after shebang/coding lines. True on success.""" | ||
| try: | ||
| raw = path.read_bytes() | ||
| except OSError: | ||
| print(f"warning: cannot read {rel(path)}", file=sys.stderr) | ||
| return False | ||
|
|
||
| has_bom = raw.startswith(b"\xef\xbb\xbf") | ||
| try: | ||
| text = raw.decode("utf-8-sig") | ||
| except UnicodeDecodeError: | ||
| print(f"warning: skip non-utf8 file {rel(path)}", file=sys.stderr) | ||
| return False | ||
|
|
||
| lines = text.splitlines(keepends=True) | ||
| newline = "\r\n" if "\r\n" in text else "\n" | ||
|
|
||
| insert_at = 0 | ||
| for index, line in enumerate(lines[:3]): | ||
| stripped = line.strip() | ||
| if stripped.startswith("#!") or CODING_PATTERN.match(stripped): | ||
| insert_at = index + 1 | ||
| else: | ||
| break | ||
|
|
||
| style = COMMENT_STYLES[path.suffix.lower()] | ||
| lines.insert(insert_at, f"{style} {copyright_text()}{newline}") | ||
| # Keep a blank separator line between the header and the body when needed. | ||
| if insert_at + 1 < len(lines) and lines[insert_at + 1].strip(): | ||
| lines.insert(insert_at + 1, newline) | ||
|
|
||
| payload = "".join(lines).encode("utf-8") | ||
| if has_bom: | ||
| payload = b"\xef\xbb\xbf" + payload | ||
| path.write_bytes(payload) | ||
| print(f" [fixed] {rel(path)}") | ||
| return True | ||
|
|
||
|
|
||
| def iter_repo_files() -> list[Path]: | ||
| """Collect in-scope files across the repository (full-scan mode).""" | ||
| collected = [] | ||
| for dirpath, dirnames, filenames in os.walk(REPO_ROOT): | ||
| dirnames[:] = [ | ||
| d for d in dirnames if d not in EXCLUDED_DIRS and not d.startswith(".") | ||
| ] | ||
| for name in filenames: | ||
| candidate = Path(dirpath) / name | ||
| if name in EXCLUDED_FILES: | ||
| continue | ||
| if candidate.suffix.lower() in COMMENT_STYLES: | ||
| collected.append(candidate) | ||
| return collected | ||
|
|
||
|
|
||
| def read_file_list(list_path: Path) -> list[Path]: | ||
| """Read one path per line; missing files are skipped silently (deleted in PR).""" | ||
| if not list_path.exists(): | ||
| return [] | ||
| files = [] | ||
| for line in list_path.read_text(encoding="utf-8", errors="replace").splitlines(): | ||
| line = line.strip() | ||
| if not line: | ||
| continue | ||
| candidate = Path(line) if Path(line).is_absolute() else REPO_ROOT / line | ||
| if candidate.exists(): | ||
| files.append(candidate) | ||
| return files | ||
|
|
||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument( | ||
| "paths", | ||
| nargs="*", | ||
| help="specific files to check (default: full repository scan)", | ||
| ) | ||
| parser.add_argument( | ||
| "--files", | ||
| nargs="+", | ||
| metavar="FILE", | ||
| help="specific files to check", | ||
| ) | ||
| parser.add_argument( | ||
| "--files-from", | ||
| metavar="FILE", | ||
| help="read the file list line-by-line from a text file (CI mode)", | ||
| ) | ||
| parser.add_argument( | ||
| "--fix", | ||
| action="store_true", | ||
| help="insert the standard header into files that lack it", | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| explicit = [Path(p) for p in (args.paths + (args.files or []))] | ||
| if args.files_from: | ||
| explicit.extend(read_file_list(Path(args.files_from))) | ||
|
|
||
| if explicit: | ||
| targets = [p for p in explicit if p.is_file() and is_target_file(p)] | ||
| scope = "provided file list" | ||
| else: | ||
| targets = iter_repo_files() | ||
| scope = "full repository scan" | ||
|
|
||
| print(f"Copyright header check ({scope}): {len(targets)} target file(s)") | ||
|
|
||
| missing = [path for path in targets if not has_header(path)] | ||
| if not missing: | ||
| print("OK: all target files carry the required copyright header.") | ||
| return 0 | ||
|
|
||
| for path in missing: | ||
| print(f" [missing] {rel(path)}") | ||
|
|
||
| print( | ||
| f"\n{len(missing)} file(s) missing the required header. " | ||
| "Expected comment line within the file header:\n" | ||
| f" {copyright_text()}\n" | ||
| "Comment style: '#' for .py/.sh, '--' for .sql, '//' for .ts/.tsx/.js/.jsx/.mjs.\n" | ||
| "Fix automatically with:\n" | ||
| " python scripts/check_copyright.py --fix <files...>" | ||
| ) | ||
|
|
||
| if args.fix: | ||
| for path in missing: | ||
| fix_file(path) | ||
| print(f"Inserted headers into {len(missing)} file(s). Re-run to verify.") | ||
| return 0 | ||
|
|
||
| return 1 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.