Skip to content

Commit d692a4f

Browse files
joaodinissfclaude
andcommitted
ci: build only changed modules + upstream deps in the spotbugs lane
compute-spotbugs-skip.sh now also exports SPOTBUGS_SCOPE_ARGS ("-pl ../ddk-target,<changed modules> -am") and the spotbugs lane passes it to mvn, so a scoped run builds only the PR's changed modules plus their upstream dependencies instead of the full 64-module reactor. The -am-pulled unchanged dependencies keep the injected spotbugs.skip: they compile (complete aux-classpath) but are not analysed. ddk-target is pinned into every scoped reactor: the target-definition artifact is referenced by target-platform-configuration, not by any bundle MANIFEST, so -am alone never pulls it — Tycho then falls back to a local-repository copy of the .target, which fails on a cold cache and can silently resolve a stale target definition on a warm one (verified: a June install of the same sequenceNumber still pointed at the 2026-03 release train while the tree's points at 2026-06). The gate verifies that every scanned source-bearing module produced its SARIF (and that a full scan produced any at all): --fail-never swallows even target-resolution failures, so without a presence check a dead build uploads nothing, counts zero violations, and passes vacuously. Fail-safes are unchanged: a build/config change means a full reactor and full scan, and a PR touching no reactor module builds the full reactor with every analysis skipped. Measured locally (single-module change, clean tree, JDK 21): 34s wall vs 75s for the full-reactor equivalent, resolving the current target platform (jdt.core 3.46.0) with findings identical to the full scan. Verified against a repository with no installed ddk-target: without the pin the run reproduces the swallowed resolution failure with zero SARIFs; with it the scoped run succeeds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 37e4b7d commit d692a4f

3 files changed

Lines changed: 91 additions & 24 deletions

File tree

‎.github/scripts/compute-spotbugs-skip.sh‎

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
# only trimmed ~17% of the goal vs ~88% for this per-module skip (measured on this
1818
# reactor). A small upstream SpotBugs early-exit (skip the run when no application class
1919
# matches the screener) would make onlyAnalyze competitive; if that ever lands, switch
20-
# to onlyAnalyze and delete this script.
20+
# to onlyAnalyze and delete this script (tracked in #1455 / spotbugs/spotbugs#3796).
21+
#
22+
# On top of the skips, the changed reactor modules are exported as SPOTBUGS_SCOPE_ARGS
23+
# ("-pl <changed> -am") so the lane builds only those modules plus their upstream
24+
# dependencies instead of the full reactor. The -am-pulled unchanged dependencies still
25+
# carry the injected skip: they compile (complete aux-classpath) but are not analysed.
2126
#
2227
# Run from the repository root. Usage: compute-spotbugs-skip.sh <base-sha>
2328
set -euo pipefail
@@ -27,6 +32,23 @@ base="${1:?base sha required}"
2732
# count as changed; D keeps deletion-only changes (e.g. a removed ruleset) visible.
2833
changed=$(git diff --name-only --no-renames --diff-filter=ACMD "${base}...HEAD")
2934

35+
# Reactor module dirs from ddk-parent's <modules> (strip the leading ../), and the
36+
# subset that bears sources. ddk-parent is NOT in its own <modules>, so it can never be
37+
# skip-injected — which prevents an accidental inherited (global) skip. Both lists are
38+
# computed up front because the full-scan early-exit below needs the source-bearing set
39+
# for its report-presence gate.
40+
module_dirs=$(grep -oE '<module>\.\./[^<]+</module>' ddk-parent/pom.xml \
41+
| sed -E 's#.*\.\./([^<]+)</module>#\1#')
42+
all_source_modules=""
43+
while IFS= read -r mod; do
44+
[ -n "$mod" ] || continue
45+
if [ -f "${mod}/META-INF/MANIFEST.MF" ] && [ -d "${mod}/src" ]; then
46+
all_source_modules="${all_source_modules:+${all_source_modules} }${mod}"
47+
fi
48+
done <<EOF
49+
${module_dirs}
50+
EOF
51+
3052
# 1) A change to shared build/config can affect any module -> full scan (skip nothing).
3153
# ddk-configuration holds the analyzers' rulesets and filters (e.g. the SpotBugs
3254
# exclusion-filter), so a change there must re-scan everything, not skip silently.
@@ -36,6 +58,16 @@ while IFS= read -r f; do
3658
[ -n "$f" ] || continue
3759
case "$f" in
3860
pom.xml | ddk-parent/* | .mvn/* | *.target | ddk-target/* | .github/* | ddk-configuration/* | *[Ss]pot[Bb]ugs*[Ee]xclude*)
61+
# KEPT must be set on every path: in a workflow `if:` an unset env var is
62+
# null, which coerces to 0 and compares EQUAL to '0' — wrongly skipping
63+
# the build. "all" marks the full-scan case (only "0" relaxes the gate).
64+
# EXPECT_REPORTS lists every source-bearing module so the gate verifies a
65+
# full scan analysed all of them (not just >=1) — a mojo death swallowed by
66+
# --fail-never can't pass as long as one sibling reported.
67+
if [ -n "${GITHUB_ENV:-}" ]; then
68+
echo "SPOTBUGS_KEPT=all" >> "$GITHUB_ENV"
69+
echo "SPOTBUGS_EXPECT_REPORTS=${all_source_modules}" >> "$GITHUB_ENV"
70+
fi
3971
echo "Build/config change ($f) -> full SpotBugs scan (no skips)."
4072
exit 0
4173
;;
@@ -49,13 +81,7 @@ EOF
4981
# grep's no-match exit would otherwise kill the script under pipefail.
5082
changed_mods=$(printf '%s\n' "${changed}" | { grep '/' || true; } | cut -d/ -f1 | sort -u)
5183

52-
# 3) Reactor module dirs from ddk-parent's <modules> (strip the leading ../).
53-
# ddk-parent is NOT in its own <modules>, so it can never be skip-injected — which
54-
# is what prevents an accidental inherited (global) skip.
55-
module_dirs=$(grep -oE '<module>\.\./[^<]+</module>' ddk-parent/pom.xml \
56-
| sed -E 's#.*\.\./([^<]+)</module>#\1#')
57-
58-
# 4) Idempotently inject the skip property; handle poms with and without <properties>.
84+
# 3) Idempotently inject the skip property; handle poms with and without <properties>.
5985
# sed -i.bak + rm is portable across GNU (CI) and BSD (local) sed.
6086
inject_skip() {
6187
local pom="$1/pom.xml"
@@ -69,15 +95,20 @@ inject_skip() {
6995
rm -f "$pom.bak"
7096
}
7197

72-
# 5) Skip every reactor module that was not touched by this PR. Kept bundles with
73-
# sources are expected to produce a SARIF; the gate checks each one.
98+
# 4) Skip every reactor module that was not touched by this PR. Kept modules with a
99+
# bundle MANIFEST are expected to produce an analysis report — the gate checks this
100+
# so a swallowed resolution/compile failure can never pass as "nothing to scan".
74101
kept=0
75102
skipped=0
103+
kept_pl=""
76104
expect_reports=""
77105
while IFS= read -r mod; do
78106
[ -n "$mod" ] || continue
79107
if printf '%s\n' "${changed_mods}" | grep -qx "$mod"; then
80108
kept=$((kept + 1))
109+
kept_pl="${kept_pl:+${kept_pl},}../${mod}"
110+
# Only bundles with sources reliably emit a report (a source-less bundle,
111+
# e.g. pure branding, has nothing for the analyzer to write a SARIF about).
81112
if [ -f "${mod}/META-INF/MANIFEST.MF" ] && [ -d "${mod}/src" ]; then
82113
expect_reports="${expect_reports:+${expect_reports} }${mod}"
83114
fi
@@ -91,15 +122,38 @@ EOF
91122

92123
# The gate's presence check needs to distinguish "all modules skip-injected"
93124
# (zero reports is the expected state) from "the analysis silently died".
94-
# Only source-less modules changed (feature / target / repository) counts as
95-
# nothing to scan: SpotBugs cannot write a report for a bundle without classes.
96-
if [ -z "$expect_reports" ]; then
97-
kept=0
125+
# If the only changed modules are source-less (feature / target / repository — nothing
126+
# any analyzer can report), fold into the docs-only no-op: export KEPT=0 so the lane
127+
# skips the Maven step, gate, and upload instead of failing the merged-report presence
128+
# check on output that could never exist.
129+
if [ "$kept" -eq 0 ] || [ -z "$expect_reports" ]; then
130+
effective_kept=0
131+
else
132+
effective_kept=$kept
98133
fi
99134
if [ -n "${GITHUB_ENV:-}" ]; then
100-
echo "SPOTBUGS_KEPT=${kept}" >> "$GITHUB_ENV"
135+
echo "SPOTBUGS_KEPT=${effective_kept}" >> "$GITHUB_ENV"
136+
fi
137+
138+
# 5) Scope the reactor to the changed modules + their upstream dependencies. ddk-target
139+
# is always kept in the -pl list: the target-definition artifact is referenced by
140+
# target-platform-configuration, not by any MANIFEST, so -am never pulls it — without
141+
# it in the reactor Tycho falls back to a local-repository copy, which fails on a
142+
# cold cache and can silently resolve a stale target definition on a warm one.
143+
# With no analysable changed module (docs-only, or source-less-only) no scope args
144+
# are exported; the workflow skips the Maven step entirely on SPOTBUGS_KEPT=0.
145+
if [ "$effective_kept" -gt 0 ] && [ -n "${GITHUB_ENV:-}" ]; then
146+
echo "SPOTBUGS_SCOPE_ARGS=-pl ../ddk-target,${kept_pl} -am" >> "$GITHUB_ENV"
101147
echo "SPOTBUGS_EXPECT_REPORTS=${expect_reports}" >> "$GITHUB_ENV"
102148
fi
103149

104-
echo "SpotBugs scope: scanning ${kept} changed module(s), skipping ${skipped} unchanged."
150+
echo "SpotBugs scope: scanning ${effective_kept} changed module(s), skipping ${skipped} unchanged."
105151
echo "Changed modules: ${changed_mods:-<none>}"
152+
if [ "$kept" -gt 0 ] && [ "$effective_kept" -eq 0 ]; then
153+
echo "Only source-less modules changed (no analysable sources) -> no-op (KEPT=0)."
154+
fi
155+
if [ "$effective_kept" -gt 0 ]; then
156+
echo "Reactor scope args: -pl ../ddk-target,${kept_pl} -am"
157+
else
158+
echo "Reactor scope args: <full reactor>"
159+
fi

‎.github/workflows/verify.yml‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,11 @@ jobs:
177177
restore-keys: ${{ runner.os }}-maven-publish-
178178

179179
- name: Scope SpotBugs to the PR's changed modules
180-
# Injects <spotbugs.skip>true> into unchanged module poms so the per-module
181-
# SpotBugs fork is skipped for them (the lever that actually scopes the cost).
182-
# Full compile is preserved (correct aux-classpath); a build/config change ->
183-
# full scan. pull_request only; master/snapshot run a full scan.
180+
# Injects <spotbugs.skip>true> into unchanged module poms so their analysis is
181+
# skipped, and exports SPOTBUGS_SCOPE_ARGS (-pl <changed> -am) so only the
182+
# changed modules and their upstream deps build at all (skip-injected deps
183+
# compile for the aux-classpath but are not analysed). A build/config change ->
184+
# full scan, full reactor. pull_request only; master/snapshot run a full scan.
184185
run: bash .github/scripts/compute-spotbugs-skip.sh "${{ github.event.pull_request.base.sha }}"
185186

186187
- name: SpotBugs report (SARIF)
@@ -189,8 +190,12 @@ jobs:
189190
# working tree is dirty here; this job releases nothing, so we tell Tycho's jgit
190191
# build-qualifier to use the last commit's timestamp instead of failing (the
191192
# repo keeps jgit.dirtyWorkingTree=error for maven-verify / releases).
193+
# Skipped entirely when the scope step kept no modules (e.g. a docs-only
194+
# PR): every module would carry spotbugs.skip, so the compile output
195+
# would be unused. The gate below relaxes on the same condition.
196+
if: env.SPOTBUGS_KEPT != '0'
192197
run: |
193-
mvn -T 2C -f ./ddk-parent/pom.xml --batch-mode --fail-never \
198+
mvn -T 2C -f ./ddk-parent/pom.xml ${SPOTBUGS_SCOPE_ARGS:-} --batch-mode --fail-never \
194199
compile \
195200
spotbugs:spotbugs \
196201
-Dspotbugs.sarifOutput=true \
@@ -220,6 +225,8 @@ jobs:
220225
# suppresses even compile/resolution failures) — never a clean pass.
221226
# Exception: the scope step skip-injected every module (no reactor module
222227
# changed, e.g. a docs-only PR), where zero reports is the expected state.
228+
# Each scanned source-bearing module must additionally have produced its
229+
# own SARIF, so a partially-dead scoped build cannot hide either.
223230
run: |
224231
set -eu
225232
if [ "${SPOTBUGS_KEPT:-}" = "0" ]; then

‎docs/ci-static-analysis-design.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,15 @@ the count-gate *stricter* than `:check` (over-fail, never under-fail), each guar
9191

9292
## Two operational rules
9393

94-
- **`compile` must be full-reactor** (`-f ddk-parent/pom.xml`, no `-pl`). PMD's
95-
type-resolving rules need the complete aux-classpath; a `-pl` subset produces
96-
false positives (the trailing-`Throwable` case).
94+
- **A changed module must compile against its complete aux-classpath**, or PMD's
95+
type-resolving rules false-positive (the trailing-`Throwable` case). A bare `-pl <module>`
96+
subset breaks this, but `-pl <changed> -am` does not: `--also-make` restores the module's
97+
dependency closure, which compiles for the classpath even though those deps carry the
98+
analysis skip. Tycho adds each bundle's MANIFEST requirements (`Require-Bundle`,
99+
`Import-Package`) to the Maven project model, so `-am` pulls in reactor siblings required
100+
that way too. `ddk-target` is the one edge with no MANIFEST reference at all, so it is
101+
pinned explicitly into every scoped reactor (a cold cache without it fails loudly rather
102+
than resolving a stale target).
97103
- **Merge SARIFs from SARIF files only.** Code Scanning accepts one run per category,
98104
so per-module SARIFs are merged (jq) before upload. The `ddk-parent` aggregator emits
99105
plain-XML `checkstyle-result.xml`; the merge must filter to JSON-parseable files.

0 commit comments

Comments
 (0)