diff --git a/.github/scripts/sarif_summary.py b/.github/scripts/sarif_summary.py
new file mode 100644
index 00000000..a741a429
--- /dev/null
+++ b/.github/scripts/sarif_summary.py
@@ -0,0 +1,150 @@
+"""Summarizes SARIF static analysis results as Markdown and, given the base's results, fails on new findings."""
+import argparse
+import collections
+import json
+import pathlib
+import re
+import sys
+import urllib.parse
+import urllib.request
+
+MAX_LISTED = 50
+GATED_LEVELS = ("error", "warning")
+Finding = collections.namedtuple("Finding", "rule level description path line column message")
+
+
+def sarif_files(arguments):
+ """Returns the SARIF files named by the arguments, expanding directories."""
+ files = []
+ for argument in arguments:
+ path = pathlib.Path(argument)
+ files.extend(sorted(path.rglob("*.sarif")) if path.is_dir() else [path] if path.is_file() else [])
+ return files
+
+
+def relative_path(uri, root):
+ """Returns the path of a SARIF artifact URI, relative to root when it lies inside it."""
+ parsed = urllib.parse.urlparse(uri)
+ path = urllib.request.url2pathname(parsed.path) if parsed.scheme == "file" else urllib.parse.unquote(uri)
+ try:
+ return (root / path).resolve().relative_to(root.resolve()).as_posix()
+ except (ValueError, OSError):
+ return pathlib.PurePath(path).as_posix()
+
+
+def artifact_uri(location, base_ids):
+ """Returns the artifact URI of a SARIF location, resolved against its uriBaseId when the run defines it."""
+ uri = location.get("uri", "")
+ base = base_ids.get(location.get("uriBaseId"), {}).get("uri")
+ return urllib.parse.urljoin(base, uri) if base else uri
+
+
+def findings(files, root):
+ """Yields every distinct finding in the SARIF files, with paths relative to root."""
+ seen = set()
+ for file in files:
+ for run in json.loads(file.read_text(encoding="utf-8-sig")).get("runs", []):
+ rules = {rule.get("id"): rule for rule in run.get("tool", {}).get("driver", {}).get("rules", [])}
+ base_ids = run.get("originalUriBaseIds", {})
+ for result in run.get("results", []):
+ rule_id = result.get("ruleId") or result.get("rule", {}).get("id", "unknown")
+ rule = rules.get(rule_id, {})
+ physical = (result.get("locations") or [{}])[0].get("physicalLocation", {})
+ finding = Finding(
+ rule_id,
+ result.get("level") or rule.get("defaultConfiguration", {}).get("level", "warning"),
+ rule.get("shortDescription", {}).get("text")
+ or rule.get("fullDescription", {}).get("text") or rule.get("helpUri", ""),
+ relative_path(artifact_uri(physical.get("artifactLocation", {}), base_ids), root),
+ physical.get("region", {}).get("startLine"),
+ physical.get("region", {}).get("startColumn"),
+ (result.get("message", {}).get("text") or "").split("\n")[0],
+ )
+ # The same finding in a shared header is reported once per source file that includes it.
+ if finding not in seen:
+ seen.add(finding)
+ yield finding
+
+
+def fingerprint(finding):
+ """Returns what identifies a finding across commits: rule, file and message, with numbers masked."""
+ return finding.rule, finding.path, re.sub(r"\d+", "#", finding.message)
+
+
+def cell(text):
+ """Escapes text for a Markdown table cell."""
+ return str(text).replace("|", "\\|").replace("\n", " ")[:160]
+
+
+def where(finding):
+ """Returns 'path:line', or the path alone when the finding has no line."""
+ return f"{finding.path}:{finding.line}" if finding.line else finding.path
+
+
+def print_summary(title, rows):
+ """Prints the findings per rule and level, then the first findings with their location."""
+ print(f"### {title}: {len(rows)} finding{'s' if len(rows) != 1 else ''}\n")
+ if not rows:
+ return
+ counts = collections.Counter((row.rule, row.level, row.description) for row in rows)
+ print("| Rule | Level | Count | Description |\n|---|---|---|---|")
+ for (rule, level, description), count in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
+ print(f"| {cell(rule)} | {level} | {count} | {cell(description)} |")
+ print(f"\nFirst {min(MAX_LISTED, len(rows))} findings
\n")
+ print("| Location | Rule | Message |\n|---|---|---|")
+ for row in sorted(rows, key=lambda row: (row.path, row.line or 0))[:MAX_LISTED]:
+ print(f"| {cell(where(row))} | {cell(row.rule)} | {cell(row.message)} |")
+ print("\n \n")
+
+
+def print_new(title, rows, base_rows):
+ """Prints the findings that outnumber the base's findings with the same fingerprint; returns the gated count."""
+ base_counts = collections.Counter(fingerprint(row) for row in base_rows)
+ groups = collections.defaultdict(list)
+ for row in rows:
+ groups[fingerprint(row)].append(row)
+ new = {key: group for key, group in groups.items() if len(group) > base_counts[key]}
+ added = sum(len(group) - base_counts[key] for key, group in new.items())
+ gated = sum(len(group) - base_counts[key] for key, group in new.items() if group[0].level in GATED_LEVELS)
+ print(f"### New compared with the base branch: {added}\n")
+ if not new:
+ print("No finding that the base branch does not have.\n")
+ return 0
+ print("| Location | Rule | Level | Message | Base | Head |\n|---|---|---|---|---|---|")
+ for key, group in sorted(new.items()):
+ locations = ", ".join(where(row) for row in group[:5]) + (", ..." if len(group) > 5 else "")
+ print(f"| {cell(locations)} | {cell(key[0])} | {group[0].level} | {cell(group[0].message)} "
+ f"| {base_counts[key]} | {len(group)} |")
+ print()
+ if gated:
+ print(f"::error title={title}::{gated} new finding{'s' if gated != 1 else ''} at "
+ f"{' or '.join(GATED_LEVELS)} level: fix or suppress them, the job summary lists them.", file=sys.stderr)
+ return gated
+
+
+def load(title, paths, root):
+ """Returns the findings of the SARIF files under paths, failing when there is no SARIF at all."""
+ files = sarif_files(paths)
+ if not files:
+ # An analyzer that silently wrote nothing must not read as a clean result.
+ sys.exit(f"::error title={title}::No SARIF file found in {' '.join(paths)}")
+ return list(findings(files, root))
+
+
+def main():
+ """Prints the summary; with --base, also the new findings, and exits with 1 when any is gated."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("title", help="the analyzer's name, used as the heading")
+ parser.add_argument("sarif", nargs="+", help="SARIF files or directories of the working directory's analysis")
+ parser.add_argument("--base", nargs="+", help="SARIF files or directories of the pull request base's analysis")
+ parser.add_argument("--base-root", default=".", help="the base checkout, which the base's paths are relative to")
+ arguments = parser.parse_args()
+ rows = load(arguments.title, arguments.sarif, pathlib.Path.cwd())
+ print_summary(arguments.title, rows)
+ if arguments.base and print_new(arguments.title, rows,
+ load(arguments.title, arguments.base, pathlib.Path(arguments.base_root))):
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/spotbugs.init.gradle b/.github/scripts/spotbugs.init.gradle
new file mode 100644
index 00000000..1b653223
--- /dev/null
+++ b/.github/scripts/spotbugs.init.gradle
@@ -0,0 +1,43 @@
+// Applies SpotBugs to the modules listed in -DspotbugsModules (for example :sdk-java,:sdk-java-ui) and writes each
+// analysis as SARIF to <-DspotbugsDir>/-.sarif. Findings never fail the build.
+// Only .github/workflows/static_analysis.yml applies it, through --init-script, so no build file changes.
+def spotbugsModules = (System.getProperty('spotbugsModules') ?: '').tokenize(',') as Set
+def spotbugsDir = System.getProperty('spotbugsDir')
+
+rootProject {
+ buildscript {
+ repositories {
+ gradlePluginPortal {
+ content {
+ includeGroup 'com.github.spotbugs.snom'
+ }
+ }
+ }
+ dependencies {
+ classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:6.5.13'
+ }
+ }
+}
+
+allprojects { project ->
+ if (!spotbugsModules.contains(project.path)) {
+ return
+ }
+ if (!spotbugsDir) {
+ throw new org.gradle.api.GradleException('Pass -DspotbugsDir= to choose where the reports go.')
+ }
+ project.pluginManager.withPlugin('java') {
+ project.pluginManager.apply('com.github.spotbugs')
+ project.spotbugs {
+ ignoreFailures.set(true)
+ }
+ project.tasks.matching { it.name.startsWith('spotbugs') }.configureEach { task ->
+ task.reports {
+ create('sarif') {
+ required.set(true)
+ outputLocation.set(new File(spotbugsDir, "${project.name}-${task.name}.sarif"))
+ }
+ }
+ }
+ }
+}
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
new file mode 100644
index 00000000..4f33d6aa
--- /dev/null
+++ b/.github/workflows/static_analysis.yml
@@ -0,0 +1,64 @@
+name: Static analysis
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+ - staging
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ spotbugs:
+ name: SpotBugs
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 2
+ persist-credentials: false
+
+ # JDK 17 rather than the SDK's Java 8 floor: settings.gradle only includes :sdk-java-ui on Java 11+.
+ - name: Set up JDK 17
+ uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
+ with:
+ java-version: '17'
+ distribution: 'corretto'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1
+
+ # On pull requests the base branch is analyzed too, so that only findings it lacks can fail the job.
+ - name: Analyze the published modules
+ run: |
+ # Runs SpotBugs on the published modules that the checkout in $1 has, writing SARIF to $2.
+ analyze() {
+ modules=()
+ for module in :sdk-java :sdk-java-ui; do
+ if [ -d "$1/${module#:}" ]; then
+ modules+=("$module")
+ fi
+ done
+ (cd "$1" && ./gradlew --no-configuration-cache --init-script "$GITHUB_WORKSPACE/.github/scripts/spotbugs.init.gradle" \
+ -DspotbugsModules="$(IFS=,; echo "${modules[*]}")" "-DspotbugsDir=$2" "${modules[@]/%/:spotbugsMain}")
+ }
+ analyze "$GITHUB_WORKSPACE" "$RUNNER_TEMP/sarif"
+ if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
+ # The first parent of the pull request's merge commit is the base branch it merges into.
+ git worktree add "$RUNNER_TEMP/base" HEAD^1
+ analyze "$RUNNER_TEMP/base" "$RUNNER_TEMP/sarif-base"
+ fi
+
+ # Every finding goes to the job summary; a new one at warning or error level fails the job.
+ - name: Summarize the findings
+ run: |
+ base=()
+ if [ -d "$RUNNER_TEMP/sarif-base" ]; then
+ base=(--base "$RUNNER_TEMP/sarif-base" --base-root "$RUNNER_TEMP/base")
+ fi
+ python3 .github/scripts/sarif_summary.py SpotBugs "$RUNNER_TEMP/sarif" "${base[@]}" | tee -a "$GITHUB_STEP_SUMMARY"