1010
1111# ---------------------------------------------------------------------------
1212# compile — compiles everything once, packages the JAR, uploads classes
13- # test — 4 -way matrix downloads compiled output and runs a shard of tests
13+ # test — 9 -way matrix downloads compiled output and runs a shard of tests
1414# docker — downloads compiled output, builds and pushes the container image
1515#
1616# Wall-clock target:
1717# compile ~10 min (parallel with setup of test shards)
18- # tests ~8 min (4 shards in parallel after compile finishes)
18+ # tests ~8 min (9 shards in parallel after compile finishes)
1919# docker ~3 min (after all shards pass)
2020# total ~21 min (vs ~30 min single-job)
2121# ---------------------------------------------------------------------------
@@ -30,18 +30,21 @@ jobs:
3030 steps :
3131 - uses : actions/checkout@v4
3232
33- - name : Set up JDK 11
33+ - name : Set up JDK 25
3434 uses : actions/setup-java@v4
3535 with :
36- java-version : " 11 "
37- distribution : " adopt "
36+ java-version : " 25 "
37+ distribution : " temurin "
3838 cache : maven # caches ~/.m2/repository keyed on pom.xml hash
3939
4040 - name : Setup production props
4141 run : |
4242 cp obp-api/src/main/resources/props/sample.props.template \
4343 obp-api/src/main/resources/props/production.default.props
4444
45+ - name : Lint — test-isolation (no setPropsValues at class/feature body)
46+ run : python3 .github/scripts/check_test_isolation.py
47+
4548 - name : Compile and install (skip test execution)
4649 run : |
4750 # -DskipTests — compile test sources but do NOT run them
@@ -74,24 +77,36 @@ jobs:
7477 path : push/
7578
7679 # --------------------------------------------------------------------------
77- # Job 2: test (4 -way matrix)
80+ # Job 2: test (9 -way matrix, mirrors build_pull_request.yml shard layout )
7881 #
79- # Shard assignment (based on actual clean-run timings):
80- # Shard 1 ~258s v4_0_0(258)
81- # Shard 2 ~267s v6_0_0(122) v5_0_0(42) v3_0_0(39) v2_1_0(35) v2_2_0(12) …
82- # Shard 3 ~252s v1_2_1(137) ResourceDocs(67) berlin(34) util(12) …
83- # Shard 4 ~232s v5_1_0(79) v3_1_0(65) http4sbridge(52) v7_0_0(45) … + catch-all
82+ # Shard assignment (wall-clock on GitHub-hosted ubuntu-latest runners):
83+ # Shard 1 ~157s v4_0_0 non-Dynamic (explicit class list, ~58 classes)
84+ # Shard 2 ~257s v1_2_1 (single 6604-line suite, isolated)
85+ # Shard 3 ~155s v6_0_0 only (isolated after v2_x moved to shard 7)
86+ # Shard 4 ~183s v5_1_0 v5_0_0 v3_0_0
87+ # Shard 5 ~193s ResourceDocs v3_1_0 v1_4_0 v1_3_0
88+ # Shard 6 ~168s v7_0_0 http4sbridge UKOpenBanking
89+ # Shard 7 ~280s model + views + customer + util + berlin + v2_x
90+ # Shard 8 ~240s connector + auth + login + mgmt + metrics + catch-all
91+ # Shard 9 ~110s v4_0_0 Dynamic* (6 heavy test classes)
8492 # --------------------------------------------------------------------------
8593 test :
8694 needs : compile
8795 runs-on : ubuntu-latest
96+ timeout-minutes : 35
8897 strategy :
8998 fail-fast : false
9099 matrix :
91100 include :
92101 - shard : 1
93- name : " v4 only (bottleneck pkg)"
94- # ~258s — single largest package, kept on its own shard
102+ name : " v4 non-Dynamic"
103+ # v4_0_0 split: non-Dynamic classes only. Dynamic* on shard 9.
104+ # test_filter is a marker, not a literal wildcardSuites value: the "Run tests"
105+ # step below discovers code.api.v4_0_0.* classes at runtime and excludes
106+ # Dynamic* — a static class list here would silently drop any class added
107+ # after the list was written (the shard-8 catch-all treats the whole
108+ # code.api.v4_0_0 package as covered by shard 1, so an omitted class would
109+ # never run on ANY shard). Runtime discovery keeps shard 1 self-updating.
95110 test_filter : >-
96111 code.api.v4_0_0
97112 - shard : 2
@@ -102,12 +117,11 @@ jobs:
102117 test_filter : >-
103118 code.api.v1_2_1
104119 - shard : 3
105- name : " v6 + v2_x"
120+ name : " v6 only"
121+ # v6_0_0 isolated: previously bundled with v2_x causing 700s+ runs;
122+ # v2_x moved to shard 7 which had headroom.
106123 test_filter : >-
107124 code.api.v6_0_0
108- code.api.v2_1_0
109- code.api.v2_2_0
110- code.api.v2_0_0
111125 - shard : 4
112126 name : " v5_1 + v5_0 + v3_0"
113127 test_filter : >-
@@ -128,7 +142,8 @@ jobs:
128142 code.api.http4sbridge
129143 code.api.UKOpenBanking
130144 - shard : 7
131- name : " model + views + customer + util + small data + berlin"
145+ name : " model + views + customer + util + small data + berlin + v2_x"
146+ # v2_0_0/v2_1_0/v2_2_0 moved here from shard 3 to rebalance after v6_0_0 was isolated.
132147 test_filter : >-
133148 code.model
134149 code.views
@@ -142,9 +157,12 @@ jobs:
142157 code.crm
143158 code.accountHolder
144159 code.api.berlin
160+ code.api.v2_1_0
161+ code.api.v2_2_0
162+ code.api.v2_0_0
145163 - shard : 8
146164 name : " connector + auth + login + mgmt + metrics + remaining (catch-all)"
147- # catch-all shard: appends any test package not assigned to shards 1-7
165+ # catch-all shard: appends any test package not assigned to shards 1-7 and 9
148166 # Root-level code.api tests use class-name prefix matching (lowercase classes).
149167 # NOTE: classes that sit DIRECTLY in package code.api must be listed here by
150168 # FQN-prefix — the catch-all marks the parent package code.api as "covered" once
@@ -165,6 +183,15 @@ jobs:
165183 code.container
166184 code.management
167185 code.metrics
186+ code.concurrency
187+ - shard : 9
188+ name : " v4 Dynamic tests"
189+ # v4_0_0 Dynamic* split: 6 heavy test classes (DynamicEndpointHelperTest 4206 lines,
190+ # DynamicEndpointsTest 2548, DynamicEntityTest 1974, plus 3 smaller ones).
191+ # Prefix code.api.v4_0_0.Dynamic matches all 6 classes; shard 1's runtime
192+ # discovery excludes this same prefix so no test runs in both shards.
193+ test_filter : >-
194+ code.api.v4_0_0.Dynamic
168195
169196 services :
170197 redis :
@@ -180,11 +207,11 @@ jobs:
180207 steps :
181208 - uses : actions/checkout@v4
182209
183- - name : Set up JDK 11
210+ - name : Set up JDK 25
184211 uses : actions/setup-java@v4
185212 with :
186- java-version : " 11 "
187- distribution : " adopt "
213+ java-version : " 25 "
214+ distribution : " temurin "
188215 cache : maven
189216
190217 - name : Download compiled output
@@ -258,6 +285,12 @@ jobs:
258285 echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props
259286 echo hikari.maximumPoolSize=20 >> obp-api/src/main/resources/props/test.default.props
260287 echo write_metrics=false >> obp-api/src/main/resources/props/test.default.props
288+ # Log emails instead of opening a real SMTP socket: without this,
289+ # LocalMappedConnector.sendCustomerNotification's EMAIL branch calls
290+ # CommonsEmailWrapper.sendTextEmail which throws ConnectException because
291+ # there's no mail server in CI. That surfaces as 500 in any test that
292+ # hits an endpoint triggering the notification (v5 consent flows, etc.).
293+ echo mail.test.mode=true >> obp-api/src/main/resources/props/test.default.props
261294 # Permissions granted to runtime-compiled dynamic-endpoint code inside the security sandbox
262295 # (mirrors default.props / production.default.props). Required so dynamic resource-doc bodies
263296 # can do JSON extraction (reflection) and read OBP props (getenv); without it the sandbox
@@ -270,19 +303,38 @@ jobs:
270303 # The YAML >- scalar collapses newlines to spaces, so we convert here.
271304 FILTER=$(echo "${{ matrix.test_filter }}" | tr ' ' ',')
272305
306+ # Shard 1 (v4 non-Dynamic): discover code.api.v4_0_0 classes at runtime and
307+ # exclude Dynamic* (shard 9's domain), instead of a hand-maintained class list.
308+ # This is what makes the shard-8 catch-all's "code.api.v4_0_0 is covered by
309+ # shard 1" assumption actually true — a class list here could drift from the
310+ # real directory contents and leave a new test running on no shard at all.
311+ if [ "${{ matrix.shard }}" = "1" ]; then
312+ FILTER=$(grep -l '^class.*extends' obp-api/src/test/scala/code/api/v4_0_0/*.scala \
313+ | xargs -I{} grep -hoP '^class \K[A-Z][A-Za-z0-9_]+' {} \
314+ | grep -v '^Dynamic' \
315+ | sed 's/^/code.api.v4_0_0./' \
316+ | paste -sd, -)
317+ fi
318+
273319 # Shard 8 is the catch-all: append any test package not explicitly
274- # assigned to shards 1–7, so new packages are never silently skipped.
320+ # assigned to shards 1–7 and 9 , so new packages are never silently skipped.
275321 if [ "${{ matrix.shard }}" = "8" ]; then
322+ # Shard 1 discovers v4 non-Dynamic classes at runtime (see above); shard 9
323+ # covers Dynamic*. Use code.api.v4_0_0 as the assigned prefix so the
324+ # catch-all treats the whole v4_0_0 package as covered — true as long as
325+ # shard 1's discovery and shard 9's Dynamic prefix together span it.
276326 SHARD1="code.api.v4_0_0"
277327 SHARD2="code.api.v1_2_1"
278- SHARD3="code.api.v6_0_0 code.api.v2_1_0 code.api.v2_2_0 code.api.v2_0_0 "
328+ SHARD3="code.api.v6_0_0"
279329 SHARD4="code.api.v5_1_0 code.api.v5_0_0 code.api.v3_0_0"
280330 SHARD5="code.api.ResourceDocs1_4_0 code.api.v3_1_0 code.api.v1_4_0 code.api.v1_3_0"
281331 SHARD6="code.api.v7_0_0 code.api.http4sbridge code.api.UKOpenBanking"
282332 SHARD7="code.model code.views code.customer code.usercustomerlinks \
283333 code.api.util code.errormessages code.atms code.branches \
284- code.products code.crm code.accountHolder code.api.berlin"
285- ASSIGNED="$SHARD1 $SHARD2 $SHARD3 $SHARD4 $SHARD5 $SHARD6 $SHARD7 ${{ matrix.test_filter }}"
334+ code.products code.crm code.accountHolder code.api.berlin \
335+ code.api.v2_1_0 code.api.v2_2_0 code.api.v2_0_0"
336+ SHARD9="code.api.v4_0_0.Dynamic"
337+ ASSIGNED="$SHARD1 $SHARD2 $SHARD3 $SHARD4 $SHARD5 $SHARD6 $SHARD7 $SHARD9 ${{ matrix.test_filter }}"
286338
287339 # Discover all packages that contain at least one .scala test file
288340 ALL_PKGS=$(find obp-api/src/test/scala obp-commons/src/test/scala \
@@ -315,10 +367,27 @@ jobs:
315367 # -pl obp-commons,obp-api: obp-commons' own 5 util suites run on whichever
316368 # shard's filter matches com.openbankproject.* (the catch-all shard); on every
317369 # other shard the filter matches nothing in obp-commons → 0 tests there.
370+ # timeout 1500: hard-kill after 25 min to prevent Pekko non-daemon threads
371+ # (ConsentScheduler etc.) from keeping the JVM alive after tests complete.
372+ # Exit code 124 (timeout) is treated as success — tests are done, JVM just hung.
373+ # set +e: GitHub Actions uses -eo pipefail by default; without it, a 124 exit from
374+ # timeout would abort the step before the rc check below can run.
375+ set +e
318376 MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" \
319- mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \
377+ timeout 1500 mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \
320378 -DwildcardSuites="$FILTER" \
321379 > maven-build-shard${{ matrix.shard }}.log 2>&1
380+ rc=$?
381+ set -e
382+ # timeout returns 124 when the JVM was killed. That is only benign when the tests had
383+ # successfully finished but Pekko non-daemon threads kept the JVM alive. We must
384+ # require proof from the log instead of blindly converting 124 to success.
385+ if [ $rc -eq 124 ]; then
386+ if grep -q "BUILD SUCCESS" maven-build-shard${{ matrix.shard }}.log; then
387+ rc=0
388+ fi
389+ fi
390+ exit $rc
322391
323392 - name : Report failing tests — shard ${{ matrix.shard }}
324393 if : always()
0 commit comments