This is an automated email from the ASF dual-hosted git repository. Pearl1594 pushed a commit to branch ci-param-cs-version in repository https://gitbox.apache.org/repos/asf/cloudstack.git
commit efe59577eb1a9c0515821203da3ab418fddf9dc7 Author: Pearl1594 <[email protected]> AuthorDate: Thu Sep 17 11:54:57 2026 -0400 Add CI check for API since annotation version on branches >= 24 --- .github/scripts/check_since_annotations.py | 224 +++++++++++++++++++++++++++ .github/workflows/check-since-annotation.yml | 50 ++++++ 2 files changed, 274 insertions(+) diff --git a/.github/scripts/check_since_annotations.py b/.github/scripts/check_since_annotations.py new file mode 100644 index 00000000000..0b511420604 --- /dev/null +++ b/.github/scripts/check_since_annotations.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# +# Checks @APICommand/@Parameter/@Param `since` usage on branches that have +# moved past the old 4.x version scheme (i.e. project version >= 24): +# +# 1. A newly added since="4.x" is flagged - contributors often add this out +# of muscle memory even though the project is now versioned e.g. 24.0.0. +# 2. A brand-new @APICommand/@Parameter/@Param (its annotation AND the +# class/field it annotates are both newly added together) that has no +# since attribute at all is flagged - new API surface should record when +# it was introduced. Editing an existing annotation (its declaration +# line is not part of the diff) never requires since, even if the +# existing element never had one. +# +# Only lines actually added by the PR are inspected. A value/field is +# ignored if the same PR also removes the identical since="..." value, or +# the identical field/class name, elsewhere in the same file's diff - this +# covers a field being moved or reformatted rather than a genuinely new +# API/param/response field. + +import argparse +import re +import subprocess +import sys + +ANNOTATION_START_RE = re.compile(r"@(Param|Parameter|APICommand)\s*\(") +SINCE_RE = re.compile(r'since\s*=\s*"(4\.\d[\w.]*)"') +HAS_SINCE_RE = re.compile(r"\bsince\s*=") +VERSION_RE = re.compile(r"<artifactId>cloudstack</artifactId>\s*<version>([^<]+)</version>") +FIELD_DECL_RE = re.compile(r"^\s*(?:private|protected|public)\b[^=;(){}]*?(\w+)\s*;\s*$") +CLASS_DECL_RE = re.compile(r"^\s*(?:public\s+)?(?:final\s+)?class\s+(\w+)") + + +def read_project_version(pom_path: str) -> str: + with open(pom_path, encoding="utf-8") as f: + content = f.read() + match = VERSION_RE.search(content) + if not match: + raise SystemExit(f"Could not find the cloudstack project version in {pom_path}") + return match.group(1) + + +def major_version(version: str) -> int: + match = re.match(r"(\d+)", version) + if not match: + raise SystemExit(f"Could not parse a major version from '{version}'") + return int(match.group(1)) + + +def git_diff(base: str, head: str) -> str: + return subprocess.run( + ["git", "diff", "--no-color", "--unified=0", base, head, "--", "*.java"], + check=True, + capture_output=True, + text=True, + ).stdout + + +def diff_path(line: str) -> str: + path = line[4:] + return path[2:] if path.startswith(("a/", "b/")) else path + + +def parse_hunks(diff_text: str) -> dict: + """file -> list of hunks, each hunk = {"added": [lines], "removed": [lines]}""" + files: dict = {} + current_file = None + current_hunk = None + for line in diff_text.splitlines(): + if line.startswith("+++ "): + current_file = diff_path(line) + files.setdefault(current_file, []) + current_hunk = None + elif line.startswith("--- "): + continue + elif line.startswith("@@"): + current_hunk = {"added": [], "removed": []} + files[current_file].append(current_hunk) + elif current_hunk is not None: + if line.startswith("+") and not line.startswith("+++"): + current_hunk["added"].append(line[1:]) + elif line.startswith("-") and not line.startswith("---"): + current_hunk["removed"].append(line[1:]) + return files + + +def file_removed_text(hunks: list) -> str: + return "\n".join(l for h in hunks for l in h["removed"]) + + +def find_old_scheme_violations(files: dict) -> list: + violations = [] + for path, hunks in files.items(): + removed_values = set(SINCE_RE.findall(file_removed_text(hunks))) + for hunk in hunks: + for line in hunk["added"]: + for value in SINCE_RE.findall(line): + if value in removed_values: + continue + violations.append(("old_scheme", path, line.strip(), value)) + return violations + + +def find_matching_paren(text: str, open_pos: int) -> int: + depth = 1 + i = open_pos + 1 + while i < len(text) and depth: + if text[i] == "(": + depth += 1 + elif text[i] == ")": + depth -= 1 + i += 1 + return i - 1 if depth == 0 else -1 + + +def find_missing_since_violations(files: dict) -> list: + violations = [] + for path, hunks in files.items(): + removed_text = file_removed_text(hunks) + for hunk in hunks: + joined = "\n".join(hunk["added"]) + for match in ANNOTATION_START_RE.finditer(joined): + kind = match.group(1) + open_pos = match.end() - 1 + close_pos = find_matching_paren(joined, open_pos) + if close_pos == -1: + continue # annotation not fully contained in this hunk; can't tell, skip + annotation_text = joined[match.start():close_pos + 1] + if HAS_SINCE_RE.search(annotation_text): + continue # has since (old-scheme check handles wrong values separately) + + remainder = joined[close_pos + 1:] + decl_re = CLASS_DECL_RE if kind == "APICommand" else FIELD_DECL_RE + decl_match = None + for candidate in remainder.splitlines(): + candidate = candidate.strip() + if not candidate: + continue + decl_match = decl_re.match(candidate) + break # only look at the next non-blank added line + + if not decl_match: + continue # declaration wasn't (re)added alongside the annotation -> a modification, not new + + name = decl_match.group(1) + if re.search(rf"\b{re.escape(name)}\b\s*[;{{]", removed_text): + continue # same name also removed elsewhere in this file's diff -> likely a move/reformat + + violations.append(("missing_since", path, annotation_text.strip(), kind)) + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pom", default="pom.xml") + parser.add_argument("--base", required=True, help="Base commit SHA of the PR") + parser.add_argument("--head", required=True, help="Head commit SHA of the PR") + args = parser.parse_args() + + version = read_project_version(args.pom) + major = major_version(version) + print(f"Project version from {args.pom}: {version} (major: {major})") + + if major < 24: + print( + "Project major version is below 24; API annotations still use the " + "4.x 'since' scheme on this branch. Skipping check." + ) + return 0 + + diff_text = git_diff(args.base, args.head) + files = parse_hunks(diff_text) + + old_scheme = find_old_scheme_violations(files) + missing_since = find_missing_since_violations(files) + + if not old_scheme and not missing_since: + print("No newly added/changed API annotations have a 'since' problem.") + return 0 + + for _, path, line, value in old_scheme: + print( + f'::error file={path}::since="{value}" uses the pre-24 CloudStack versioning scheme. ' + f'This project is now versioned {version}; new @APICommand/@Parameter/@Param annotations ' + f'should use since="{major}.x" (e.g. "{major}.0") instead. Offending line: {line}' + ) + + for _, path, annotation_text, kind in missing_since: + snippet = " ".join(annotation_text.split()) + print( + f"::error file={path}::A newly added @{kind} is missing a 'since' attribute. " + f'New API commands/params/response fields should record when they were introduced, ' + f'e.g. since="{major}.0". Annotation: {snippet}' + ) + + total = len(old_scheme) + len(missing_since) + print(f"\n{total} issue(s) found ({len(old_scheme)} outdated 4.x value(s), {len(missing_since)} missing since).") + print( + "Note: this only flags brand-new annotations (added together with the class/field they " + "annotate) and newly added since values; editing an existing annotation never requires " + "adding since, and a moved/reformatted field is detected by its name also appearing on a " + "removed line in the same file and is not flagged." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/check-since-annotation.yml b/.github/workflows/check-since-annotation.yml new file mode 100644 index 00000000000..51e87efa9fa --- /dev/null +++ b/.github/workflows/check-since-annotation.yml @@ -0,0 +1,50 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# CloudStack moved from the 4.x.y.z version scheme to X.y.z (e.g. 24.0.0) on +# main. Out of habit, contributors sometimes still tag newly added +# @APICommand/@Parameter/@Param annotations with since = "4.NN" instead of +# the current major version. This check only runs against branches whose +# pom.xml is already on the new (>= 24) scheme, and only looks at annotations +# newly added by the PR. +name: Check API since annotation version + +on: + pull_request: + paths: + - '**/*.java' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-since-annotation: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + - name: Check newly added since annotations + run: | + python3 .github/scripts/check_since_annotations.py \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}"
