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..f9ca9ea66 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -4,7 +4,25 @@ on: branches: [master] pull_request: jobs: - pmd: + line-endings: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Check LF line endings in index + # .gitattributes declares `* text=auto eol=lf` with .bat/.cmd/.ps1 + # exempted. Git's clean filter normalizes on commit, but verify it + # explicitly in case a file is miscategorized as binary or a filter + # is bypassed. + run: | + violations=$(git ls-files --eol \ + | grep -E "^i/(crlf|mixed)" \ + | grep -vE "\.(bat|cmd|ps1)$" || true) + if [ -n "$violations" ]; then + echo "Files with CRLF/mixed line endings stored in the index:" + echo "$violations" + exit 1 + fi + pmd-checkstyle-analyze: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -14,10 +32,31 @@ 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: + - name: Cache Maven dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: /home/runner/.m2/repository + key: ${{ runner.os }}-maven-0-${{ hashFiles('**/pom.xml') }} + - name: Compile + generate PMD/Checkstyle reports + # Report goals 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. + 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. Runs even if a previous step + # failed so partial reports still surface as annotations. + if: always() + run: python3 .github/scripts/annotate-violations.py --pmd --checkstyle + spotbugs-analyze: runs-on: ubuntu-24.04 + env: + MAVEN_OPTS: -Xmx4g steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 @@ -26,26 +65,47 @@ jobs: 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 - line-endings: + - name: Cache Maven dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: /home/runner/.m2/repository + key: ${{ runner.os }}-maven-0-${{ hashFiles('**/pom.xml') }} + - name: Compile + 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 + maven-gate: + # Authoritative Maven-side validation. Runs every analyzer's :check goal in one + # invocation so they share aux-classpath and JVM state. Defense against the Python + # parser misreading XML schema; if Maven and Python ever disagree on violations, + # this job surfaces the divergence. runs-on: ubuntu-24.04 + env: + MAVEN_OPTS: -Xmx4g steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Check LF line endings in index - # .gitattributes declares `* text=auto eol=lf` with .bat/.cmd/.ps1 - # exempted. Git's clean filter normalizes on commit, but verify it - # explicitly in case a file is miscategorized as binary or a filter - # is bypassed. + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: 'temurin' + java-version: '21' + - name: Set up Workspace Environment Variable + run: echo "WORKSPACE=${{ github.workspace }}" >> $GITHUB_ENV + - name: Cache Maven dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: /home/runner/.m2/repository + key: ${{ runner.os }}-maven-0-${{ hashFiles('**/pom.xml') }} + - name: Compile + run all :check goals run: | - violations=$(git ls-files --eol \ - | grep -E "^i/(crlf|mixed)" \ - | grep -vE "\.(bat|cmd|ps1)$" || true) - if [ -n "$violations" ]; then - echo "Files with CRLF/mixed line endings stored in the index:" - echo "$violations" - exit 1 - fi + mvn -T 2C -f ./ddk-parent/pom.xml --batch-mode --fail-at-end \ + compile \ + pmd:check pmd:cpd-check \ + checkstyle:check \ + spotbugs:check maven-verify: runs-on: ubuntu-24.04 steps: @@ -64,10 +124,11 @@ 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 + # Tests aren't safely parallelizable across modules in this codebase (shared + # workspace state), so no -T flag here. Static-analysis :check goals are + # handled in the maven-gate job, not duplicated here. + 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()