Skip to content

Commit aed83aa

Browse files
committed
ci(guards): run every guard the job names, and compile docs-only pull requests
The "Architecture and Documentation Guards" job selected eight test classes while scoping the reactor to graph-compose-core. Two had been deleted months earlier; two live in graph-compose-qa and graph-compose-render-pdf. Surefire aborts only when a selection is entirely empty, so the four names that did match kept the job green while it ran half of what it advertised. The list is now the five guards that live in the engine module, and CiGuardListGuardTest parses the workflow and fails the job when a name in it stops resolving under core/src/test/java — inside the very job it protects. Markdown was also missing from change detection, so a pull request touching only .md skipped the reactor build entirely and merged without DocumentationSnippetCompileTest ever compiling the java fences it publishes. A separate `docs` filter routes those changes into the reactor slice that carries the qa guards, on the baseline JDK; example generation stays gated on `code`, since rendering the catalogue proves nothing about prose. PackageMapGuardTest scanned gitignored docs/private/, where a local note routinely names a retired package in order to record that it is retired. A hit there fails the release runbook's local verify gate for a reason CI can never reproduce, so the directory is excluded — as CanonicalSurfaceGuardTest already excludes it. Three workflow comments claimed things that are not true: that render-docx, render-pptx and graph-compose-testing are absent from Maven Central (they are published; the examples pin them to the reactor's -SNAPSHOT), that the pptx module is a semantic backend (its primary backend is fixed-layout), and that the CodeQL scope matches the canonical verify gate (it is narrower, and the render backends ship unscanned). graph-compose-render-pptx compiles against org.apache.pdfbox types while declaring only fontbox; the dependency is now explicit. Both modules pin pdfbox.version to 3.0.8, so the resolved version is unchanged.
1 parent fe6bbdd commit aed83aa

7 files changed

Lines changed: 232 additions & 25 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,32 @@ jobs:
4545
java-version: '17'
4646
cache: maven
4747

48-
- name: Run guards
48+
- name: Run core architecture and documentation guards
49+
# Every name here must live in graph-compose-core: the run is scoped to
50+
# that module, and Surefire only fails on an empty selection — a name
51+
# that matches nothing while its siblings match is dropped in silence.
52+
# CiGuardListGuardTest asserts each name resolves under core/src/test.
53+
# Guards owned by other modules (PdfRenderInterfaceGuardTest in
54+
# render-pdf, DocumentationExamplesTest and DocumentationSnippetCompileTest
55+
# in qa) run in build-and-test below, which now also covers docs-only PRs.
4956
run: |
5057
./mvnw -B -ntp clean \
51-
"-Dtest=EnginePdfBoundaryTest,CanonicalTemplateComposerPdfBoundaryTest,PdfRenderInterfaceGuardTest,DocumentationCoverageTest,DocumentationExamplesTest,CanonicalSurfaceGuardTest,TemplateComposeApiTest,VersionConsistencyGuardTest" \
58+
"-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest" \
5259
test -pl :graph-compose-core
5360
5461
changes:
5562
# Path-based change detection for selective CI on pull requests. Emits the
5663
# reverse-dependency flags the heavy jobs gate on: `code` (any build input),
57-
# `core` (the root graph-compose-core module — drives japicmp), `perf`
64+
# `docs` (markdown the guard suites read and compile), `core` (the root
65+
# graph-compose-core module — drives japicmp), `perf`
5866
# (modules the smoke benchmark exercises), and `jvm` (published library modules
5967
# + toolchain — drives the JDK matrix width). Pushes/dispatch bypass these gates.
6068
name: Detect changed paths
6169
if: github.event_name != 'schedule'
6270
runs-on: ubuntu-latest
6371
outputs:
6472
code: ${{ steps.filter.outputs.code }}
73+
docs: ${{ steps.filter.outputs.docs }}
6574
core: ${{ steps.filter.outputs.core }}
6675
perf: ${{ steps.filter.outputs.perf }}
6776
jvm: ${{ steps.filter.outputs.jvm }}
@@ -86,6 +95,16 @@ jobs:
8695
- 'mvnw'
8796
- 'mvnw.cmd'
8897
- '.github/workflows/ci.yml'
98+
# Markdown is a build input even though it compiles nothing:
99+
# DocumentationSnippetCompileTest compiles the literal java fences in
100+
# docs/, and the guard suites read README / CONTRIBUTING / docs. Kept
101+
# separate from `code` so a docs-only change runs the reactor (which
102+
# carries those guards) without also running examples-generation,
103+
# which installs eight modules and renders the full example catalogue
104+
# to prove nothing about prose. Deliberately absent from `jvm` too, so
105+
# a docs-only PR uses the baseline JDK alone rather than the matrix.
106+
docs:
107+
- '**/*.md'
89108
core:
90109
- 'core/src/**'
91110
- 'core/pom.xml'
@@ -119,9 +138,12 @@ jobs:
119138
build-and-test:
120139
name: Build and run tests (JDK ${{ matrix.java }})
121140
# Selective CI: on a pull request, skip the heavy reactor build + JDK matrix
122-
# when only docs / non-build files changed (`code` is false). Pushes to the
123-
# integration branches and manual dispatch always run the full gate.
124-
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true')
141+
# when nothing this reactor slice can check has changed. Markdown counts —
142+
# this slice carries the qa guards that compile the published snippets, so a
143+
# docs-only PR runs here (on the baseline JDK alone, since `jvm` stays false)
144+
# rather than merging without any compiler having read the change. Pushes to
145+
# the integration branches and manual dispatch always run the full gate.
146+
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' || needs.changes.outputs.docs == 'true')
125147
needs: [architecture-and-documentation-guards, changes]
126148
runs-on: ubuntu-latest
127149
strategy:
@@ -158,7 +180,9 @@ jobs:
158180
# only via the examples job), the extracted graph-compose-testing module, the
159181
# graph-compose-qa cross-module suites (which need the testing module + the
160182
# engine test-jar on the classpath and so cannot live in the engine's own test
161-
# scope), and the graph-compose-coverage aggregator (JaCoCo over core +
183+
# scope — this is where DocumentationSnippetCompileTest, the guard that
184+
# compiles the literal markdown fences in docs/, and DocumentationExamplesTest
185+
# actually run), and the graph-compose-coverage aggregator (JaCoCo over core +
162186
# render-pdf + templates, counting the qa exec, now with a NON-REGRESSION
163187
# RATCHET — this step fails if aggregate INSTRUCTION or BRANCH coverage drops
164188
# below the floor in coverage/pom.xml). -am pulls the fonts / emoji upstreams;
@@ -189,7 +213,9 @@ jobs:
189213

190214
examples-generation:
191215
name: Examples Generation Smoke Test
192-
# Follows build-and-test: skipped for a docs-only PR, runs otherwise.
216+
# Gated on `code` alone, deliberately narrower than build-and-test above:
217+
# rendering the example catalogue costs eight module installs and proves
218+
# nothing about a markdown change, so a docs-only PR skips it.
193219
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true')
194220
needs: [build-and-test, changes]
195221
runs-on: ubuntu-latest
@@ -214,9 +240,10 @@ jobs:
214240
run: ./mvnw -B -ntp -f fonts/pom.xml -DskipTests install
215241

216242
- name: Install graph-compose-emoji (consumed by the examples module)
217-
# The emoji example renders colour emoji from the bundled Noto SVG set;
218-
# like graph-compose-fonts it is a standalone artifact not on Maven
219-
# Central, so install it into the local repo before building examples.
243+
# The emoji example renders colour emoji from the bundled Noto SVG set.
244+
# Like graph-compose-fonts it carries its own version line, so the reactor
245+
# slice never builds it; installing from source pins the examples to this
246+
# tree's asset set rather than the cached jar at the same coordinate.
220247
run: ./mvnw -B -ntp -f emoji/pom.xml -DskipTests install
221248

222249
- name: Install root artifact (graph-compose-core)
@@ -235,22 +262,23 @@ jobs:
235262
run: ./mvnw -B -ntp -f wrapper/pom.xml -DskipTests install
236263

237264
- name: Install graph-compose-render-docx (consumed by the examples module)
238-
# The DOCX export example depends on the render-docx backend module, which
239-
# is not on Central. Install it after the engine (its graph-compose
240-
# dependency) so the examples build resolves it.
265+
# The DOCX export example depends on the render-docx backend module at
266+
# ${graphcompose.version} — the reactor's -SNAPSHOT, which no repository
267+
# can supply. Install it after the engine (its graph-compose dependency)
268+
# so the examples build resolves it.
241269
run: ./mvnw -B -ntp -f render-docx/pom.xml -DskipTests install
242270

243271
- name: Install graph-compose-testing (consumed by the examples module)
244272
# LayoutSnapshotRegressionExample uses LayoutSnapshotJson and EngineDeckData
245-
# reads benchmark JSON through the jackson it brings; the testing module is
246-
# not on Central, so install it after the engine (its graph-compose
247-
# dependency) before the examples build resolves it.
273+
# reads benchmark JSON through the jackson it brings; the examples pin the
274+
# testing module to the reactor's -SNAPSHOT, so install it after the engine
275+
# (its graph-compose dependency) before the examples build resolves it.
248276
run: ./mvnw -B -ntp -f testing/pom.xml -DskipTests install
249277

250278
- name: Install graph-compose-render-pptx (consumed by the examples module)
251279
# The Engine Deck PPTX example renders the flagship deck through the
252-
# fixed-layout PPTX backend, which is not on Central. Install it after
253-
# the wrapper (its core + render-pdf compile dependencies) AND the
280+
# fixed-layout PPTX backend, pinned to the reactor's -SNAPSHOT version.
281+
# Install it after the wrapper (its core + render-pdf compile dependencies) AND the
254282
# testing module — -DskipTests skips execution but Maven still
255283
# resolves render-pptx's test-scope graph-compose-testing dependency.
256284
run: ./mvnw -B -ntp -f render-pptx/pom.xml -DskipTests install

.github/workflows/codeql.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,14 @@ jobs:
5050
languages: ${{ matrix.language }}
5151
queries: security-and-quality
5252

53-
- name: Build (library module only)
54-
# `-pl :graph-compose-core` scopes the analysis to the published artifact and matches
55-
# the canonical verify gate. Examples/benchmarks are intentionally
56-
# excluded — they ship no production code.
53+
- name: Build (engine module)
54+
# Scoped to the engine module alone: no `-am`, so nothing but core is built
55+
# and nothing but core reaches the extractor. Since the 2.0 split
56+
# graph-compose-core is one of several artifacts published to Maven Central,
57+
# which makes this narrower than both the publish train and the canonical
58+
# verify gate in ci.yml — the render backends and templates ship unscanned.
59+
# Widening it to the code-bearing published modules is tracked for 2.2,
60+
# together with a triage pass on the standing alert set.
5761
run: ./mvnw -B -ntp -DskipTests -pl :graph-compose-core package
5862

5963
- name: Perform CodeQL analysis

.github/workflows/publish.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,10 @@ jobs:
226226

227227
- name: Publish render-pptx to Maven Central
228228
if: steps.plan.outputs.run_render_pptx == 'true'
229-
# The semantic PPTX backend, lockstep-versioned with the engine (ships on
230-
# the same v* tag). graph-compose-core resolves from the local repo installed
229+
# The PPTX render backend — a fixed-layout POI XSLF backend consuming the
230+
# same resolved LayoutGraph as the PDF one, alongside the older semantic
231+
# manifest skeleton. Lockstep-versioned with the engine (ships on the same
232+
# v* tag). graph-compose-core resolves from the local repo installed
231233
# by the engine deploy above.
232234
run: ./mvnw -B -ntp -f render-pptx/pom.xml -P release -DskipTests -Dgpg.skip=false deploy
233235
env:

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ follow semantic versioning; release dates are ISO 8601.
55

66
## v2.1.1 — Planned
77

8+
### Build
9+
10+
- **The CI guard job runs every guard it names.** It selected eight test classes
11+
while scoping the reactor to `graph-compose-core`: two had been deleted months
12+
earlier and two live in `graph-compose-qa` and `graph-compose-render-pdf`, so
13+
four never ran. Surefire aborts only when a selection is entirely empty, so the
14+
four that did match kept the job green. The list is now the five guards that
15+
live in the engine module, and `CiGuardListGuardTest` fails the job if a name in
16+
it stops resolving.
17+
- **A documentation-only pull request is compiled.** Markdown was not a
18+
change-detection input, so a PR touching only `.md` skipped the reactor build
19+
and merged without `DocumentationSnippetCompileTest` ever compiling the java
20+
fences it publishes. Markdown now routes to the reactor slice that carries those
21+
guards, on the baseline JDK, and still skips example generation.
22+
- **`graph-compose-render-pptx` declares PDFBox.** It compiles against
23+
`org.apache.pdfbox` types while declaring only `fontbox`, taking the rest
24+
transitively; the resolved version is unchanged.
25+
826
### Fixed
927

1028
- **A heading no longer strands above a block that was asked to stay whole.**
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.demcha.documentation;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.IOException;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.util.ArrayList;
9+
import java.util.Arrays;
10+
import java.util.LinkedHashSet;
11+
import java.util.List;
12+
import java.util.Set;
13+
import java.util.TreeSet;
14+
import java.util.regex.Matcher;
15+
import java.util.regex.Pattern;
16+
import java.util.stream.Stream;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
/**
21+
* Guards the hand-written {@code -Dtest=} list in the CI guard job against name rot.
22+
*
23+
* <p>The "Architecture and Documentation Guards" job selects a fixed set of test
24+
* classes by name and runs them with {@code -pl :graph-compose-core}. Surefire
25+
* treats that selection as a whole: it aborts only when <em>every</em> pattern
26+
* matches nothing. A single name that matches nothing while its siblings match is
27+
* dropped without a warning, and the job reports success having run fewer tests
28+
* than it names.</p>
29+
*
30+
* <p>Two failure modes therefore pass CI silently. A guard renamed or deleted
31+
* leaves a dead name behind — the job keeps claiming coverage it no longer has.
32+
* A guard that lives in another module ({@code qa}, {@code render-pdf}) can be
33+
* added to the list in good faith and never run, because the module scope
34+
* excludes it. Both had happened here: the list carried two classes deleted
35+
* months earlier plus two that live outside {@code core}, so the job executed
36+
* four of the eight names it advertised.</p>
37+
*
38+
* <p>This test parses the workflow and asserts every selected name resolves to a
39+
* test source under {@code core/src/test/java}. It runs inside the very job it
40+
* protects, so a bad edit to that line fails the step that made it. Guards owned
41+
* by other modules belong in the reactor build, not here.</p>
42+
*/
43+
class CiGuardListGuardTest {
44+
45+
private static final Path PROJECT_ROOT = RepoRoot.get();
46+
private static final Path WORKFLOW = PROJECT_ROOT.resolve(".github/workflows/ci.yml");
47+
private static final Path CORE_TESTS = PROJECT_ROOT.resolve("core/src/test/java");
48+
49+
/**
50+
* The scope that makes a {@code -Dtest=} selection core-only. Anchored at the
51+
* end of the reactor list so {@code -pl :graph-compose-core,:graph-compose-qa}
52+
* — a genuinely multi-module selection — is not held to core residency.
53+
*/
54+
private static final Pattern CORE_SCOPE =
55+
Pattern.compile("-pl :graph-compose-core(?![\\w:,-])");
56+
57+
/** A {@code -Dtest=} value, quoted or bare, up to the end of the shell word. */
58+
private static final Pattern TEST_SELECTION = Pattern.compile("-Dtest=\"?([A-Za-z0-9_$.,*#\\[\\]-]+)\"?");
59+
60+
/** Workflow steps start at a {@code - name:} key at any indentation. */
61+
private static final Pattern STEP_BOUNDARY = Pattern.compile("(?m)^\\s*- name:");
62+
63+
@Test
64+
void everyGuardNamedInTheCoreScopedCiStepLivesInCore() throws IOException {
65+
List<String> selected = coreScopedTestSelections();
66+
67+
assertThat(selected)
68+
.describedAs("no core-scoped '-Dtest=' step found in %s — this guard is reading a "
69+
+ "workflow shape that moved, so it is no longer guarding anything",
70+
relative(WORKFLOW))
71+
.isNotEmpty();
72+
73+
Set<String> missing = new TreeSet<>();
74+
for (String name : selected) {
75+
if (!resolvesUnderCoreTests(name)) {
76+
missing.add(name);
77+
}
78+
}
79+
80+
assertThat(missing)
81+
.describedAs("every class named in the core-scoped '-Dtest=' list of %s must exist "
82+
+ "under core/src/test/java. Surefire drops an unmatched name in silence "
83+
+ "whenever another name in the same selection matches, so a deleted guard "
84+
+ "— or one that lives in qa / render-pdf — leaves the job reporting "
85+
+ "coverage it does not have. Guards owned by another module run in the "
86+
+ "reactor build instead.", relative(WORKFLOW))
87+
.isEmpty();
88+
}
89+
90+
/**
91+
* Class names selected by every workflow step that both filters with
92+
* {@code -Dtest=} and scopes the reactor to {@code graph-compose-core}. A step
93+
* that selects tests for a different module is intentionally not checked here.
94+
*/
95+
private static List<String> coreScopedTestSelections() throws IOException {
96+
String workflow = Files.readString(WORKFLOW);
97+
List<String> names = new ArrayList<>();
98+
99+
for (String step : STEP_BOUNDARY.split(workflow)) {
100+
if (!CORE_SCOPE.matcher(step).find()) {
101+
continue;
102+
}
103+
Matcher selection = TEST_SELECTION.matcher(step);
104+
while (selection.find()) {
105+
Arrays.stream(selection.group(1).split(","))
106+
.map(String::trim)
107+
.filter(name -> !name.isEmpty())
108+
.forEach(names::add);
109+
}
110+
}
111+
return new ArrayList<>(new LinkedHashSet<>(names));
112+
}
113+
114+
/**
115+
* Whether a Surefire selector names a test source in the engine module. The
116+
* {@code #method} suffix is stripped and a package-qualified name is resolved
117+
* as a path, so neither form can smuggle a class name past the check. Only a
118+
* wildcard selector is accepted unchecked — it matches by shape, not by name,
119+
* and has nothing to resolve.
120+
*/
121+
private static boolean resolvesUnderCoreTests(String selector) throws IOException {
122+
String className = selector.split("#", 2)[0];
123+
if (className.contains("*")) {
124+
return true;
125+
}
126+
if (className.contains(".")) {
127+
return Files.isRegularFile(CORE_TESTS.resolve(className.replace('.', '/') + ".java"));
128+
}
129+
try (Stream<Path> sources = Files.walk(CORE_TESTS)) {
130+
return sources.anyMatch(path -> path.getFileName().toString().equals(className + ".java"));
131+
}
132+
}
133+
134+
private static String relative(Path path) {
135+
return PROJECT_ROOT.relativize(path).toString().replace('\\', '/');
136+
}
137+
}

core/src/test/java/com/demcha/documentation/PackageMapGuardTest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ private boolean isIgnored(Path path) {
114114
return relative.startsWith(".git/")
115115
|| relative.startsWith(".claude/")
116116
|| relative.startsWith(".idea/")
117+
// docs/private/ is gitignored — local-only planning notes and
118+
// audits, not part of the public docs surface. They routinely
119+
// name a retired package in order to record that it is retired,
120+
// which reads as a violation here. Because the directory never
121+
// reaches the remote, a hit fails the release runbook's local
122+
// verify gate for a reason CI can never reproduce or clear.
123+
// CanonicalSurfaceGuardTest excludes it for the same reason.
124+
|| relative.startsWith("docs/private/")
117125
|| relative.contains("/target/")
118126
|| relative.startsWith("target/")
119127
|| relative.startsWith("logs/");

render-pptx/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@
9797
<artifactId>fontbox</artifactId>
9898
<version>${pdfbox.version}</version>
9999
</dependency>
100+
<!-- Declared explicitly rather than taken transitively through the PDF
101+
measurement library below: this module compiles against PDFBox types
102+
directly, so a change in dependency mediation would otherwise break
103+
the build with no local cause, and a dependency scan would read the
104+
module's footprint as smaller than it is. -->
105+
<dependency>
106+
<groupId>org.apache.pdfbox</groupId>
107+
<artifactId>pdfbox</artifactId>
108+
<version>${pdfbox.version}</version>
109+
</dependency>
100110

101111
<!-- The fixed PPTX backend deliberately reuses the PDF measurement font
102112
library so glyph sanitization stays byte-for-byte aligned with the

0 commit comments

Comments
 (0)