diff --git a/.github/scripts/annotate-violations.py b/.github/scripts/annotate-violations.py new file mode 100755 index 000000000..82a39b643 --- /dev/null +++ b/.github/scripts/annotate-violations.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Annotate PMD, Checkstyle, and SpotBugs violations as GitHub workflow commands. + +Walks `target/` reports under GITHUB_WORKSPACE, emits one `::warning` or `::error` +per violation (rendered inline on the PR's Files-changed view), and exits 1 if any +violation was found so the workflow step fails fast. +""" +import argparse +import os +import sys +from pathlib import Path +from xml.etree import ElementTree as ET + + +def make_emit(root: Path): + count = 0 + + def emit(level: str, file, line, title: str, msg: str | None) -> None: + nonlocal count + count += 1 + text = (msg or '').strip().replace('\n', ' ')[:1000] + try: + rel = Path(file).relative_to(root) + except ValueError: + rel = file + print(f"::{level} file={rel},line={line},title={title}::{text}") + + return emit, lambda: count + + +def parse_pmd(root: Path, emit) -> None: + for xml in root.rglob('target/pmd.xml'): + for f in ET.parse(xml).getroot().findall('file'): + for v in f.findall('violation'): + level = 'error' if v.attrib.get('priority') == '1' else 'warning' + emit(level, f.attrib['name'], v.attrib.get('beginline', '1'), + f"PMD/{v.attrib.get('rule', '')}", v.text) + + +def parse_checkstyle(root: Path, emit) -> None: + for xml in root.rglob('target/checkstyle-result.xml'): + for f in ET.parse(xml).getroot().findall('file'): + for e in f.findall('error'): + level = 'error' if e.attrib.get('severity') == 'error' else 'warning' + emit(level, f.attrib['name'], e.attrib.get('line', '1'), + f"Checkstyle/{e.attrib.get('source', '').split('.')[-1]}", + e.attrib.get('message', '')) + + +def parse_spotbugs(root: Path, emit) -> None: + for xml in root.rglob('target/spotbugsXml.xml'): + # SpotBugs sourcepath is package-relative (e.g. "com/avaloq/tools/ddk/Foo.java"), + # not repo-relative — combine with the module's source root so GitHub renders the + # annotation inline on the file in the PR's Files-changed view. + module_dir = xml.parent.parent + for b in ET.parse(xml).getroot().findall('BugInstance'): + sl = b.find('SourceLine') + lm = b.find('LongMessage') + if sl is None or lm is None: + continue + sourcepath = sl.attrib.get('sourcepath', '?') + file_path = None + for src_root in ('src', 'src/main/java', 'src-gen'): + candidate = module_dir / src_root / sourcepath + if candidate.exists(): + file_path = candidate + break + if file_path is None: + file_path = module_dir / sourcepath + emit('warning', file_path, sl.attrib.get('start', '1'), + f"SpotBugs/{b.attrib.get('type', '')}", lm.text) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--pmd', action='store_true', help='annotate PMD violations') + parser.add_argument('--checkstyle', action='store_true', help='annotate Checkstyle violations') + parser.add_argument('--spotbugs', action='store_true', help='annotate SpotBugs violations') + args = parser.parse_args() + + if not (args.pmd or args.checkstyle or args.spotbugs): + parser.error('pick at least one of --pmd, --checkstyle, --spotbugs') + + root = Path(os.environ.get('GITHUB_WORKSPACE', '.')) + emit, total = make_emit(root) + + if args.pmd: + parse_pmd(root, emit) + if args.checkstyle: + parse_checkstyle(root, emit) + if args.spotbugs: + parse_spotbugs(root, emit) + + kinds = ' + '.join(k for k, on in (('PMD', args.pmd), ('Checkstyle', args.checkstyle), ('SpotBugs', args.spotbugs)) if on) + print(f"{kinds} violations: {total()}") + return 1 if total() > 0 else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index c88269363..c6d904ba0 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -4,7 +4,7 @@ on: branches: [master] pull_request: jobs: - pmd: + static-analysis: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -14,20 +14,52 @@ jobs: java-version: '21' - name: Set up Workspace Environment Variable run: echo "WORKSPACE=${{ github.workspace }}" >> $GITHUB_ENV - - name: PMD Check - run: mvn pmd:pmd pmd:cpd pmd:check pmd:cpd-check -f ./ddk-parent/pom.xml --batch-mode --fail-at-end - checkstyle: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + - name: Cache Maven dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - distribution: 'temurin' - java-version: '21' - - name: Set up Workspace Environment Variable - run: echo "WORKSPACE=${{ github.workspace }}" >> $GITHUB_ENV - - name: Checkstyle Check - run: mvn checkstyle:checkstyle checkstyle:check -f ./ddk-parent/pom.xml --batch-mode --fail-at-end + path: /home/runner/.m2/repository + key: ${{ runner.os }}-maven-0-${{ hashFiles('**/pom.xml') }} + - name: Compile + generate PMD/Checkstyle reports + # Report goals (pmd:pmd, pmd:cpd, checkstyle:checkstyle) never fail the build, so + # every module's XML is produced — no Maven cascade-skip. `compile` runs in the + # same invocation so PMD's type-resolving rules (e.g. InvalidLogMessageFormat) get + # the aux-classpath they need; without it, those rules misfire on idioms like + # SLF4J's trailing-Throwable pattern. + run: | + mvn -T 2C -f ./ddk-parent/pom.xml --batch-mode --fail-at-end \ + compile \ + pmd:pmd pmd:cpd \ + checkstyle:checkstyle + - name: Annotate + count PMD/Checkstyle violations + # Emits one GitHub workflow-command annotation per violation (visible inline in + # PR Files-changed) and exits 1 if any are found — fail-fast that bypasses + # SpotBugs (slow) when PMD/Checkstyle already has issues. Runs even if a previous + # step failed. + if: always() + run: python3 .github/scripts/annotate-violations.py --pmd --checkstyle + - name: Enforce PMD/Checkstyle thresholds (Maven safety-net) + # Redundant in the success case (Python check above already passed). Provides + # official-tool validation in case the Python parser misreads schema. `compile` + # is required in this invocation too — pmd:check re-runs analysis from scratch + # rather than reading the prior pmd.xml, so it needs its own aux-classpath. + run: | + mvn -T 2C -f ./ddk-parent/pom.xml --batch-mode --fail-at-end \ + compile \ + pmd:check pmd:cpd-check \ + checkstyle:check + - name: Generate SpotBugs report + run: | + mvn -T 2C -f ./ddk-parent/pom.xml --batch-mode --fail-at-end \ + compile \ + spotbugs:spotbugs + - name: Annotate + count SpotBugs violations + if: always() + run: python3 .github/scripts/annotate-violations.py --spotbugs + - name: Enforce SpotBugs threshold (Maven safety-net) + run: | + mvn -T 2C -f ./ddk-parent/pom.xml --batch-mode --fail-at-end \ + compile \ + spotbugs:check line-endings: runs-on: ubuntu-24.04 steps: @@ -64,10 +96,8 @@ jobs: with: path: /home/runner/.m2/repository key: ${{ runner.os }}-maven-0-${{ hashFiles('**/pom.xml') }} - - name: Build with Maven within a virtual X Server Environment - # Run pmd:pmd and pmd:cpd first to generate reports for all modules, then run pmd:check and pmd:cpd-check - # This ensures all violations are collected and reported before the build fails - run: xvfb-run mvn clean verify checkstyle:check pmd:pmd pmd:cpd pmd:check pmd:cpd-check spotbugs:check -f ./ddk-parent/pom.xml --batch-mode --fail-at-end + - name: Build with Maven within a virtual X Server Environment + run: xvfb-run mvn clean verify -f ./ddk-parent/pom.xml --batch-mode --fail-at-end - name: Fail on missing surefire reports # Tycho-Surefire writes no TEST-*.xml when discovery is empty — fail the job in that case. if: always()