From c3742274f3e7b794ad2e6334cba6b7bbc5dc14ea Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 17 Aug 2026 12:00:41 +0100
Subject: [PATCH 1/3] ci(templates): put the templates public surface under the
japicmp gate
docs/api-stability.md lists every templates.* package as Stable, but japicmp
only ever diffed graph-compose-core: a removed or narrowed public method in a
preset, a data record, or a shared widget could ship in a 2.x minor unnoticed.
templates/pom.xml carries the same `japicmp` profile as the engine pom, pinned
to its own published floor of the major (graph-compose-templates:2.0.0). The
PR-time Binary Compatibility job diffs core and templates in one reactor run
whenever either module's sources or pom changed; cut-release.ps1 step 5b runs
the same pair before the tag is cut, and the publish workflow diffs templates
on the tagged commit before deploying. VersionConsistencyGuardTest holds the
two japicmp.version pins in lockstep.
The current surface is binary- and source-compatible with 2.0.0 (additions
only); narrowing a public method to package-private fails the build with
METHOD_LESS_ACCESSIBLE.
---
.github/workflows/ci.yml | 42 +++++++-----
.github/workflows/publish.yml | 9 +++
CHANGELOG.md | 15 +++++
CONTRIBUTING.md | 4 +-
ROADMAP.md | 2 +-
.../VersionConsistencyGuardTest.java | 18 +++++
docs/api-stability.md | 29 +++++---
docs/contributing/release-process.md | 2 +-
docs/roadmaps/post-2.0-engineering.md | 15 +++--
scripts/cut-release.ps1 | 19 +++---
templates/pom.xml | 67 +++++++++++++++++++
11 files changed, 179 insertions(+), 43 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a79df8a2..6fbcf5e2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,10 +72,11 @@ jobs:
changes:
# Path-based change detection for selective CI on pull requests. Emits the
# reverse-dependency flags the heavy jobs gate on: `code` (any build input),
- # `docs` (markdown the guard suites read and compile), `core` (the root
- # graph-compose-core module — drives japicmp), `perf`
- # (modules the smoke benchmark exercises), and `jvm` (published library modules
- # + toolchain — drives the JDK matrix width). Pushes/dispatch bypass these gates.
+ # `docs` (markdown the guard suites read and compile), `core` and `templates`
+ # (the graph-compose-core / graph-compose-templates sources + pom — the two
+ # modules under the japicmp gate), `perf` (modules the smoke benchmark
+ # exercises), and `jvm` (published library modules + toolchain — drives the
+ # JDK matrix width). Pushes/dispatch bypass these gates.
name: Detect changed paths
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
@@ -83,6 +84,7 @@ jobs:
code: ${{ steps.filter.outputs.code }}
docs: ${{ steps.filter.outputs.docs }}
core: ${{ steps.filter.outputs.core }}
+ templates: ${{ steps.filter.outputs.templates }}
perf: ${{ steps.filter.outputs.perf }}
jvm: ${{ steps.filter.outputs.jvm }}
steps:
@@ -124,6 +126,9 @@ jobs:
core:
- 'core/src/**'
- 'core/pom.xml'
+ templates:
+ - 'templates/src/**'
+ - 'templates/pom.xml'
perf:
- 'core/src/**'
- 'render-pdf/**'
@@ -394,9 +399,12 @@ jobs:
binary-compat:
name: Binary Compatibility (japicmp vs pom baseline)
- # japicmp diffs the graph-compose-core public surface, so it only matters
- # when the core module (`core/src/**` or `core/pom.xml`) changed.
- if: github.event_name == 'pull_request' && needs.changes.outputs.core == 'true'
+ # japicmp diffs the graph-compose-core and graph-compose-templates public
+ # surfaces (both carry a `japicmp` profile), so it only matters when one of
+ # those modules (`core/**` or `templates/**` sources / pom) changed. Both are
+ # always diffed together: templates resolves core from the same reactor build,
+ # and a core change can move the templates surface it re-exports.
+ if: github.event_name == 'pull_request' && (needs.changes.outputs.core == 'true' || needs.changes.outputs.templates == 'true')
needs: [architecture-and-documentation-guards, changes]
runs-on: ubuntu-latest
env:
@@ -423,20 +431,24 @@ jobs:
run: ./mvnw -B -ntp -f emoji/pom.xml -DskipTests install
- name: Compare public API against baseline
- # The `japicmp` profile resolves the baseline release pinned
- # by the `japicmp.baseline` property in core/pom.xml (the
- # published graph-compose-core on Maven Central) and diffs it
- # against the freshly-built artifact. Fails the job on any binary-
- # incompatible modification to the public surface. Source-
- # incompatible changes are reported only (phased policy).
- run: ./mvnw -B -ntp -DskipTests -P japicmp verify -pl :graph-compose-core
+ # The `japicmp` profile in each module resolves the baseline release
+ # pinned by that module's `japicmp.baseline` property (the published
+ # graph-compose-core / graph-compose-templates on Maven Central) and
+ # diffs it against the freshly-built artifact. Fails the job on any
+ # binary-incompatible modification to the public surface. Source-
+ # incompatible changes are reported only (phased policy). Both modules
+ # sit in one reactor invocation: the reactor orders core before
+ # templates, so templates compiles against the freshly-built core.
+ run: ./mvnw -B -ntp -DskipTests -P japicmp verify -pl :graph-compose-core,:graph-compose-templates
- name: Upload japicmp report
if: always()
uses: actions/upload-artifact@v7
with:
name: japicmp-report-${{ github.run_id }}
- path: core/target/japicmp/**
+ path: |
+ core/target/japicmp/**
+ templates/target/japicmp/**
if-no-files-found: ignore
perf-smoke:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 8c2f1af0..092a98f0 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -142,6 +142,15 @@ jobs:
# already ran the full suite on this commit.
run: ./mvnw -B -ntp -f core/pom.xml -P japicmp -Dmaven.test.skip=true verify
+ - name: Verify templates binary compatibility against the published baseline
+ # Same defence for graph-compose-templates: its own `japicmp` profile
+ # diffs the freshly built jar against the japicmp.baseline pinned in
+ # templates/pom.xml. Every templates.* package is Stable per
+ # docs/api-stability.md, so only elements carrying the per-element
+ # @Internal marker are excluded (none today). Standalone pom — it
+ # resolves graph-compose-core from the local m2 the install step seeded.
+ run: ./mvnw -B -ntp -f templates/pom.xml -P japicmp -Dmaven.test.skip=true verify
+
- name: Plan the deploy set (start_at resume)
id: plan
# A partial Central publication CANNOT be blindly re-dispatched: the deploys
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6680edb..2eed8a73 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ follow semantic versioning; release dates are ISO 8601.
### Build
+- **The templates module is under the binary-compatibility gate.** `docs/api-stability.md`
+ has listed every `templates.*` package as Stable — no binary break outside a major —
+ since the 2.0 module split, but japicmp only ever diffed `graph-compose-core`; a
+ removed or narrowed public method in a preset, a data record, or a shared widget
+ would have shipped in a 2.x minor with nothing looking at it. `templates/pom.xml` now
+ carries the same `japicmp` profile as the engine pom, pinned to its own published
+ floor of the major (`graph-compose-templates:2.0.0`); the PR-time `Binary
+ Compatibility` job runs it whenever the core or templates sources or pom changed,
+ `cut-release.ps1` step 5b runs it before the tag is cut, and the publish workflow
+ runs it again on the tagged commit before anything is deployed. The current surface
+ is binary- and source-compatible with
+ 2.0.0 (the diff is additions only), and narrowing a public method to package-private
+ fails the build with `METHOD_LESS_ACCESSIBLE`, so the gate is proven live, not
+ merely configured.
+
- **The weekly benchmark run builds the modules it measures.** The JMH workflow
installed the engine from source and then let Maven resolve the rest from Central,
and one of them is not there to resolve: the benchmarks read their document fixtures
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2e2dea05..d7d7a3b7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -70,11 +70,11 @@ Almost all work targets **`develop`**, the ongoing 2.x line. The `1.x` branch ta
- `Architecture and Documentation Guards` — fast canonical / engine-boundary guard tests, fail-first gate (always runs)
- `Build and run tests (JDK 17)`, `(JDK 21)`, `(JDK 25)` — full `mvnw verify` in parallel matrix across the supported JVMs
- `Examples Generation Smoke Test` — regenerates every runnable example and uploads the PDFs as a CI artifact
- - `Binary Compatibility` — PR-only japicmp diff of the `graph-compose-core` surface
+ - `Binary Compatibility` — PR-only japicmp diff of the `graph-compose-core` and `graph-compose-templates` surfaces
- `Performance Smoke Check` — PR-only coarse benchmark to catch performance regressions
- `CI Gate` — single aggregate status check that is green when every job that ran passed
- **Selective on pull requests:** a `dorny/paths-filter` step skips the heavy jobs when a PR touches nothing that affects the build. Markdown counts as a build input, so a **docs-only PR still runs the reactor** — on the baseline JDK alone, and without example generation — because that is where the guards compiling the published snippets live. `Binary Compatibility` runs only when the core module changed, and the `Performance Smoke Check` only when core / render-pdf / templates changed. Pushes to `develop` / `main` (and manual dispatch) always run the full gate. Point branch protection at **`CI Gate`** + **`Architecture and Documentation Guards`** rather than the individual matrix legs, so a docs-only PR is not left waiting on a skipped check.
+ **Selective on pull requests:** a `dorny/paths-filter` step skips the heavy jobs when a PR touches nothing that affects the build. Markdown counts as a build input, so a **docs-only PR still runs the reactor** — on the baseline JDK alone, and without example generation — because that is where the guards compiling the published snippets live. `Binary Compatibility` runs only when the core or templates module changed, and the `Performance Smoke Check` only when core / render-pdf / templates changed. Pushes to `develop` / `main` (and manual dispatch) always run the full gate. Point branch protection at **`CI Gate`** + **`Architecture and Documentation Guards`** rather than the individual matrix legs, so a docs-only PR is not left waiting on a skipped check.
The PR cannot merge into a protected branch until all required checks are green.
7. **Address review comments**, then squash any fixup commits before merge. The maintainer merges through GitHub once review is complete.
diff --git a/ROADMAP.md b/ROADMAP.md
index 14d6ed2e..9b8f1bf9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -49,7 +49,7 @@ The **v1.9.x** line receives critical fixes only.
Committed internal direction for the post-2.0 line: refactors, scale work, and tooling that do **not** change the public authoring API. Tracked in [docs/roadmaps/post-2.0-engineering.md](docs/roadmaps/post-2.0-engineering.md).
- **Decompose the layout hot files** — split `LayoutCompiler` and `TextFlowSupport` along their natural seams into individually-tested collaborators, with layout output unchanged.
-- **Per-module binary-compatibility baselines** — now that the 2.0 GA artifacts are published, switch `japicmp` from the single-artifact baseline to per-module baselines in break-on-incompatible mode.
+- **Per-module binary-compatibility baselines** — `japicmp` gates `graph-compose-core` and `graph-compose-templates` against their 2.0.0 floors in break-on-incompatible mode; the remaining published modules join once each has a documented Stable / Internal split.
## Later (directional)
diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
index 2bda9b46..ec7237ab 100644
--- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
@@ -201,6 +201,24 @@ void jacocoPluginVersionAgreesAcrossModules() throws Exception {
.isEqualTo(core);
}
+ /**
+ * The binary-compatibility gate runs the same japicmp plugin in every module
+ * that carries a {@code japicmp} profile — a version pinned as a literal in each
+ * standalone pom. Two literals of one version drift apart silently, and a
+ * plugin skew here means the two modules are judged by different
+ * compatibility rules. Only the plugin version is held in lockstep: each
+ * module's {@code japicmp.baseline} is its own published floor and legitimately
+ * differs from the engine's the day one of them crosses a major.
+ */
+ @Test
+ void japicmpPluginVersionAgreesAcrossGatedModules() throws Exception {
+ String core = pinnedVersionProperty(PROJECT_ROOT.resolve("core/pom.xml"), "japicmp.version");
+
+ assertThat(pinnedVersionProperty(PROJECT_ROOT.resolve("templates/pom.xml"), "japicmp.version"))
+ .describedAs("templates japicmp.version must match the engine pom's (%s)", core)
+ .isEqualTo(core);
+ }
+
/**
* The wrapper builds its javadoc jar from the engine's sources, which are
* Lombok-annotated, so it feeds Lombok to the javadoc plugin as an additional
diff --git a/docs/api-stability.md b/docs/api-stability.md
index 605846e6..b0457c66 100644
--- a/docs/api-stability.md
+++ b/docs/api-stability.md
@@ -197,24 +197,31 @@ window starts, and its `Status` flips to `deprecated 1.x`.
The Stable-tier promise (§ 1 — no binary breaks outside a major release) is enforced
mechanically by [japicmp](https://siom79.github.io/japicmp/), run in a `japicmp` Maven
-profile on the engine module during `verify`.
-
-- **Baseline:** the published `graph-compose-core` on Maven Central, pinned by the
- `japicmp.baseline` property in `core/pom.xml`. It is the current major's **floor** —
- `2.0.0` for the whole 2.x line — and advances only at the next major. Holding it at
- the floor (rather than the previous release) is what enforces the Stable promise:
- every 2.x build must stay binary-compatible with the `2.0.0` public surface, not
- merely with the last minor.
+profile during `verify` on the two modules whose packages the § 4 map lists as Stable
+authoring surface: the engine (`graph-compose-core`) and the templates
+(`graph-compose-templates`).
+
+- **Baseline:** the published artifact of the same module on Maven Central, pinned by
+ the `japicmp.baseline` property in that module's pom (`core/pom.xml`,
+ `templates/pom.xml`). It is the current major's **floor** — `2.0.0` for the whole
+ 2.x line — and advances only at the next major. Holding it at the floor (rather than
+ the previous release) is what enforces the Stable promise: every 2.x build must stay
+ binary-compatible with the `2.0.0` public surface, not merely with the last minor.
- **What fails the build:** any binary-incompatible change to the public surface
against the baseline — a removed or less-accessible public method/field/type, a
changed signature, and so on. `@Internal` packages (`com.demcha.compose.engine.*`,
`com.demcha.compose.document.layout.*` and its render-handoff payload records) are
- excluded; they carry no compatibility promise (§ 1). Source-only incompatibilities
- (e.g. adding a default method to an interface) are reported but do not fail, pending
- a finalized 2.x source-compatibility policy.
+ excluded; they carry no compatibility promise (§ 1). The templates module has no
+ Internal package tree — every `templates.*` package is Stable — so its gate excludes
+ only elements carrying the per-element `@Internal` marker. Source-only
+ incompatibilities (e.g. adding a default method to an interface) are reported but do
+ not fail, pending a finalized 2.x source-compatibility policy.
- **Activity window:** the gate compares the working version against the baseline, so
it is a no-op only when the two are equal — the `2.0.0` release commit itself — and
active for every `-SNAPSHOT` development cycle across the 2.x line that follows.
+- **Where it runs:** the PR-time `Binary Compatibility` CI job (when the core or
+ templates sources or pom changed), `scripts/cut-release.ps1` step 5b before the tag
+ is cut, and the publish workflow on the tagged commit.
During the 2.0 major transition the gate ran report-only (the major intentionally
broke 1.x binary compatibility); it enforces from the `2.0.0` baseline forward.
diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md
index d67ac1d5..cd82ec41 100644
--- a/docs/contributing/release-process.md
+++ b/docs/contributing/release-process.md
@@ -94,7 +94,7 @@ Running `pwsh ./scripts/cut-release.ps1 -Version ` performs:
The compatibility check itself runs in **Step 0**, before any file is written, so a cut that cannot describe itself refuses with a clean tree rather than after every pom has moved.
4. **Switch ShowcaseMetadata GH_BASE** from `/blob/develop` to `/blob/v` and regenerate `web/examples.json`.
5. **`mvnw verify`** — full reactor sanity build (the script auto-detects the layout by the presence of `core/pom.xml`, scoping to `-pl .` on the 1.x line). Skip with `-SkipVerify` only if you just ran it.
-5b. **Binary-compatibility gate** — `mvnw -P japicmp verify -pl :graph-compose-core` against the published baseline (2.0 module layout only). Fails the cut if the tagged code breaks binary compatibility of the `graph-compose-core` public API (the japicmp profile lives only in `core/pom.xml`) with the baseline — a second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. Skipped by `-SkipVerify`.
+5b. **Binary-compatibility gate** — `mvnw -P japicmp verify -pl :graph-compose-core,:graph-compose-templates` against the published baselines (2.0 module layout only). Fails the cut if the tagged code breaks binary compatibility of the `graph-compose-core` or `graph-compose-templates` public API (each module's `japicmp` profile and `japicmp.baseline` live in its own pom) with the baseline — a second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. Skipped by `-SkipVerify`.
6. **Commit** as `Release v`. Staging is an explicit allow-list, not `git add -A`. It covers the 13 train poms, `README.md`, `ROADMAP.md`, and the seven per-module READMEs (`core`, `render-pdf`, `render-docx`, `render-pptx`, `templates`, `testing`, `wrapper`, `bundle`), `CHANGELOG.md`, `ShowcaseMetadata.java`, `web/examples.json`, `web/index.html`, `web/showcase/`, and the regenerated `assets/readme/repository_showcase_render.png`. `examples/README.md` and every other doc are NOT touched by the script — fix those pre-release.
7. **Annotated tag** `v` (`git tag -a -m "Release v"`).
8. **Push** `develop` and the tag to `origin` (skip with `-SkipPush`).
diff --git a/docs/roadmaps/post-2.0-engineering.md b/docs/roadmaps/post-2.0-engineering.md
index 24808c1e..e04ba099 100644
--- a/docs/roadmaps/post-2.0-engineering.md
+++ b/docs/roadmaps/post-2.0-engineering.md
@@ -108,8 +108,13 @@ exercise it. Report-only; thresholds can follow after a baseline read.
### Per-module binary-compatibility baselines
-`japicmp` runs report-only on the 2.0 line — the major intentionally breaks
-binary compatibility. Once the 2.0 GA artifacts are published, the gate should
-switch to per-module baselines pinned at the GA release and break-on-incompatible
-mode, so each published module's public surface is protected from that point on.
-**Status: Deferred (post-GA).**
+`japicmp` ran report-only through the 2.0 major — the major intentionally broke
+binary compatibility. With the 2.0.0 GA artifacts on Central the gate switched to
+per-module baselines pinned at the major's floor (2.0.0) in break-on-incompatible
+mode: `graph-compose-core` first, `graph-compose-templates` since 2.2.1 — each
+module's `japicmp` profile lives in its own pom and runs in the PR-time
+`Binary Compatibility` job, in `cut-release.ps1` step 5b, and in the publish
+workflow. The remaining published modules (`render-pdf` / `render-docx` /
+`render-pptx` / `testing` / `wrapper`) join once each has a documented
+Stable / Internal split to gate against.
+**Status: Done for core + templates; other modules open.**
diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1
index 6b5e55cc..2feac434 100644
--- a/scripts/cut-release.ps1
+++ b/scripts/cut-release.ps1
@@ -1489,15 +1489,18 @@ try {
if (-not $SkipVerify) {
Step "5b" "Binary-compatibility gate (japicmp vs the published baseline)"
- # Confirm the graph-compose-core public API stays binary-compatible with the
- # japicmp baseline BEFORE the tag is cut — independent of the PR-time CI japicmp
- # job, which a direct-to-branch push could bypass. 2.0 module layout only (core/
- # present); the legacy 1.x single-artifact tree has no such gate. Precondition:
- # the baseline (japicmp.baseline in core/pom.xml) must already be on Central — so
- # this gate is meaningful from 2.0.1 onward (vs the published 2.0.0), not on the
- # first-of-a-major cut that publishes the baseline itself.
+ # Confirm the graph-compose-core AND graph-compose-templates public APIs stay
+ # binary-compatible with their japicmp baselines BEFORE the tag is cut —
+ # independent of the PR-time CI japicmp job, which a direct-to-branch push could
+ # bypass. 2.0 module layout only (core/ present); the legacy 1.x single-artifact
+ # tree has no such gate. Precondition: each baseline (japicmp.baseline in
+ # core/pom.xml and templates/pom.xml) must already be on Central — so this gate
+ # is meaningful from 2.0.1 onward (vs the published 2.0.0), not on the
+ # first-of-a-major cut that publishes the baseline itself. One reactor
+ # invocation covers both: the reactor orders core before templates, so
+ # templates compiles against the freshly-built core.
if (Test-Path (Join-Path $repoRoot 'core/pom.xml')) {
- $japicmpArgs = @('-B', '-ntp', '-P', 'japicmp', '-Dmaven.test.skip=true', '-Djacoco.skip=true', 'verify', '-pl', ':graph-compose-core')
+ $japicmpArgs = @('-B', '-ntp', '-P', 'japicmp', '-Dmaven.test.skip=true', '-Djacoco.skip=true', 'verify', '-pl', ':graph-compose-core,:graph-compose-templates')
if ($DryRun) {
Write-Host " [DRY RUN] $mvnw $($japicmpArgs -join ' ')" -ForegroundColor Yellow
} else {
diff --git a/templates/pom.xml b/templates/pom.xml
index 9fb51273..f8efeca4 100644
--- a/templates/pom.xml
+++ b/templates/pom.xml
@@ -73,6 +73,15 @@
true
+
+
+ 0.26.1
+ 2.0.0
@@ -262,5 +271,63 @@
+
+
+
+ japicmp
+
+
+
+ com.github.siom79.japicmp
+ japicmp-maven-plugin
+ ${japicmp.version}
+
+
+ japicmp-against-baseline
+ verify
+
+ cmp
+
+
+
+
+
+
+ io.github.demchaav
+ graph-compose-templates
+ ${japicmp.baseline}
+
+
+
+
+ ${project.build.directory}/${project.build.finalName}.jar
+
+
+
+ true
+ true
+ false
+ false
+ true
+ true
+
+ @com.demcha.compose.document.api.Internal
+
+
+
+
+
+
+
From c62721c3f4fb322bf83e1c886e5ad4eb261cef86 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 17 Aug 2026 13:09:51 +0100
Subject: [PATCH 2/3] ci(release): diff each gated module against the major
floor AND the last release
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A single floor baseline protects only the surface the major shipped with. A
method first published in 2.2.0 is absent from both 2.0.0 and a 2.2.1 that
deletes it, so the floor diff stays green while an application compiled
against 2.2.0 gets NoSuchMethodError — and MarkdownInline.appendTransformed
and SectionAllocation, both added after 2.0.0, sat in exactly that gap.
core/pom.xml and templates/pom.xml pin japicmp.baseline.floor and
japicmp.baseline.previous, diffed in their own executions of the japicmp
profile. cut-release.ps1 -PostReleaseOnly moves the previous pin onto the
version just published (Update-JapicmpPreviousBaseline, step 3a), which is
also the only point at which that release is on Central.
The pins are derived rather than trusted:
japicmpBaselinesTrackTheWorkingMajorAndTheLatestRelease reads the floor from
the module's own version and the previous pin from the newest dated CHANGELOG
release of that major, and bothJapicmpBaselineExecutionsSurviveInEachGatedPom
asserts neither execution has been dropped — properties alone would let the
second diff stop running with CI green. release-script-check.yml drives the
mover through the same AST lift the other release-script checks use.
Both pins stay inside the working major, so opening the next one leaves them
naming a version nobody has published; ignoreMissingOldVersion turns that into
a skipped execution rather than an unresolvable-artifact failure, and the
cycle is free to make the breaks a major is for. Enforcement resumes at its
first -PostReleaseOnly.
---
.github/workflows/ci.yml | 14 +-
.github/workflows/publish.yml | 17 +--
.github/workflows/release-script-check.yml | 66 +++++++++
CHANGELOG.md | 44 ++++--
core/pom.xml | 93 +++++++++----
.../ChangelogVersionParsingTest.java | 77 +++++++++++
.../VersionConsistencyGuardTest.java | 130 +++++++++++++++++-
docs/api-stability.md | 44 ++++--
docs/contributing/release-process.md | 4 +-
scripts/cut-release.ps1 | 66 ++++++++-
templates/pom.xml | 69 +++++++---
11 files changed, 531 insertions(+), 93 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6fbcf5e2..23f9feaf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -398,7 +398,7 @@ jobs:
if-no-files-found: error
binary-compat:
- name: Binary Compatibility (japicmp vs pom baseline)
+ name: Binary Compatibility (japicmp vs pom baselines)
# japicmp diffs the graph-compose-core and graph-compose-templates public
# surfaces (both carry a `japicmp` profile), so it only matters when one of
# those modules (`core/**` or `templates/**` sources / pom) changed. Both are
@@ -430,11 +430,13 @@ jobs:
- name: Install graph-compose-emoji (resolved at test scope by core)
run: ./mvnw -B -ntp -f emoji/pom.xml -DskipTests install
- - name: Compare public API against baseline
- # The `japicmp` profile in each module resolves the baseline release
- # pinned by that module's `japicmp.baseline` property (the published
- # graph-compose-core / graph-compose-templates on Maven Central) and
- # diffs it against the freshly-built artifact. Fails the job on any
+ - name: Compare public API against baselines
+ # The `japicmp` profile in each module resolves TWO published baselines of
+ # that module (japicmp.baseline.floor — the major's first release, holding
+ # the GA surface; japicmp.baseline.previous — the latest release, holding
+ # everything added since) and diffs each against the freshly-built
+ # artifact. The floor alone would let a method introduced after it be
+ # deleted with the diff green. Fails the job on any
# binary-incompatible modification to the public surface. Source-
# incompatible changes are reported only (phased policy). Both modules
# sit in one reactor invocation: the reactor orders core before
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 092a98f0..29463417 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -130,21 +130,22 @@ jobs:
# unpublished tests-jar no longer fails the deploy.
run: ./mvnw -B -ntp clean install
- - name: Verify binary compatibility against the published baseline
+ - name: Verify binary compatibility against the published baselines
# Defence in depth: run the japicmp gate on the tagged commit before any
# deploy, so an accidental binary-incompatible change to the public
# surface aborts the publish even when the tag reached here by bypassing
# branch protection (the CI japicmp job only gates pull requests). Compares
- # the freshly built graph-compose-core against the japicmp.baseline release
- # on Maven Central and fails the job on any Stable-surface break; the
- # Internal packages (engine.**, document.layout.**) are excluded per
- # docs/api-stability.md. Test build is skipped — the install step above
- # already ran the full suite on this commit.
+ # the freshly built graph-compose-core against both baselines pinned in
+ # core/pom.xml — the major floor and the latest published release — and
+ # fails the job on any Stable-surface break; the Internal packages
+ # (engine.**, document.layout.**) are excluded per docs/api-stability.md.
+ # Test build is skipped — the install step above already ran the full
+ # suite on this commit.
run: ./mvnw -B -ntp -f core/pom.xml -P japicmp -Dmaven.test.skip=true verify
- - name: Verify templates binary compatibility against the published baseline
+ - name: Verify templates binary compatibility against the published baselines
# Same defence for graph-compose-templates: its own `japicmp` profile
- # diffs the freshly built jar against the japicmp.baseline pinned in
+ # diffs the freshly built jar against the two baselines pinned in
# templates/pom.xml. Every templates.* package is Stable per
# docs/api-stability.md, so only elements carrying the per-element
# @Internal marker are excluded (none today). Standalone pom — it
diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml
index b94c2ff2..b72dd866 100644
--- a/.github/workflows/release-script-check.yml
+++ b/.github/workflows/release-script-check.yml
@@ -351,3 +351,69 @@ jobs:
if ((Get-Content $roadmap -Raw) -ne $after) { throw "a second promotion changed the file" }
Write-Host "roadmap promotion: 3 refusals, 1 rewrite, idempotent on re-run."
+
+ - name: Unit-check the japicmp previous-release baseline move
+ shell: pwsh
+ run: |
+ # The binary-compatibility gate diffs against two published releases: the major
+ # floor, which never moves inside a major, and the previous release, which must
+ # move to the version just published at every cut. A pin left behind leaves
+ # everything added in that release freely removable with the gate green, so the
+ # move is code that has to be exercised, not a step someone remembers. Lifted by
+ # AST like the checks above, so the code under test is the code that ships.
+ $path = (Resolve-Path scripts/cut-release.ps1).Path
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
+ $fn = $ast.FindAll({
+ param($n)
+ $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Update-JapicmpPreviousBaseline'
+ }, $true)
+ if (-not $fn) { throw 'cut-release.ps1 no longer defines Update-JapicmpPreviousBaseline' }
+ Invoke-Expression $fn[0].Extent.Text
+ function Note($m) { Write-Host " $m" }
+ $DryRun = $false
+
+ # A function nothing calls moves no pin. Assert the post-release path still
+ # invokes it — the failure this guards is the helper surviving a refactor that
+ # drops the call, which every content assertion below would still pass.
+ $calls = $ast.FindAll({
+ param($n)
+ $n -is [System.Management.Automation.Language.CommandAst] -and
+ $n.GetCommandName() -eq 'Update-JapicmpPreviousBaseline'
+ }, $true)
+ if (-not $calls) { throw 'nothing in cut-release.ps1 calls Update-JapicmpPreviousBaseline' }
+
+ function New-Pom($previous) {
+ $file = Join-Path ([IO.Path]::GetTempPath()) ("pom-" + [guid]::NewGuid().ToString() + ".xml")
+ Set-Content -Path $file -NoNewline -Value (@(
+ '',
+ ' 2.3.1-SNAPSHOT',
+ ' ',
+ ' 2.0.0',
+ " $previous",
+ ' ',
+ ''
+ ) -join "`n")
+ return $file
+ }
+
+ $pom = New-Pom '2.2.0'
+ if (-not (Update-JapicmpPreviousBaseline $pom '2.3.0')) { throw 'moving the pin reported no change' }
+ $after = Get-Content $pom -Raw
+ if ($after -notmatch '2\.3\.0') {
+ throw "the previous-release pin did not move:`n$after"
+ }
+ if ($after -notmatch '2\.0\.0') {
+ throw "the major floor must not move at a cut:`n$after"
+ }
+ if ($after -notmatch '2\.3\.1-SNAPSHOT') { throw "the project version was rewritten:`n$after" }
+
+ if (Update-JapicmpPreviousBaseline $pom '2.3.0') { throw 'a second move reported a change' }
+
+ $bare = Join-Path ([IO.Path]::GetTempPath()) ("pom-" + [guid]::NewGuid().ToString() + ".xml")
+ Set-Content -Path $bare -NoNewline -Value "`n 2.3.1-SNAPSHOT`n"
+ if (Update-JapicmpPreviousBaseline $bare '2.3.0') { throw 'a pom without the pin reported a change' }
+ if (Update-JapicmpPreviousBaseline (Join-Path ([IO.Path]::GetTempPath()) 'no-such-pom.xml') '2.3.0') {
+ throw 'a missing pom reported a change'
+ }
+
+ Write-Host 'japicmp baseline: previous moves, floor stays, idempotent, and the cut calls it.'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2eed8a73..eaeda76d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,20 +7,36 @@ follow semantic versioning; release dates are ISO 8601.
### Build
-- **The templates module is under the binary-compatibility gate.** `docs/api-stability.md`
- has listed every `templates.*` package as Stable — no binary break outside a major —
- since the 2.0 module split, but japicmp only ever diffed `graph-compose-core`; a
- removed or narrowed public method in a preset, a data record, or a shared widget
- would have shipped in a 2.x minor with nothing looking at it. `templates/pom.xml` now
- carries the same `japicmp` profile as the engine pom, pinned to its own published
- floor of the major (`graph-compose-templates:2.0.0`); the PR-time `Binary
- Compatibility` job runs it whenever the core or templates sources or pom changed,
- `cut-release.ps1` step 5b runs it before the tag is cut, and the publish workflow
- runs it again on the tagged commit before anything is deployed. The current surface
- is binary- and source-compatible with
- 2.0.0 (the diff is additions only), and narrowing a public method to package-private
- fails the build with `METHOD_LESS_ACCESSIBLE`, so the gate is proven live, not
- merely configured.
+- **The templates module is under the binary-compatibility gate, and the gate now
+ protects what a release added.** `docs/api-stability.md` has listed every
+ `templates.*` package as Stable — no binary break outside a major — since the 2.0
+ module split, but japicmp only ever diffed `graph-compose-core`; a removed or
+ narrowed public method in a preset, a data record, or a shared widget would have
+ shipped in a 2.x minor with nothing looking at it. `templates/pom.xml` now carries
+ the same `japicmp` profile as the engine pom.
+
+ Both modules pin **two** baselines instead of one, each diffed in its own execution.
+ A floor pin alone protects only the surface the major shipped with: a method that
+ first ships in 2.2.0 is absent from both 2.0.0 and a 2.2.1 that deletes it, so the
+ floor diff stays green while an application compiled against 2.2.0 gets
+ `NoSuchMethodError` — and `MarkdownInline.appendTransformed` and `SectionAllocation`,
+ both added after 2.0.0, sat in exactly that gap. `japicmp.baseline.floor` keeps
+ holding the GA surface; `japicmp.baseline.previous` holds everything added since and
+ moves to the version just published in `cut-release.ps1 -PostReleaseOnly`. A pin left
+ behind is not a silent hole: `VersionConsistencyGuardTest` derives both rather than
+ trusting them — the floor from the module's own version, the previous pin from the
+ CHANGELOG — and asserts both executions are still in the pom, while the release-script
+ workflow drives the move itself. Both pins stay inside the working major, so opening
+ the next one leaves them naming a version nobody has published and the gate stands
+ down for that cycle rather than failing every break the major is for.
+
+ The PR-time `Binary Compatibility` job runs the gate whenever the core or templates
+ sources or pom changed, `cut-release.ps1` step 5b runs it before the tag is cut, and
+ the publish workflow runs it again on the tagged commit before anything is deployed.
+ The current surface is binary- and source-compatible with both baselines; removing
+ `MarkdownInline.appendTransformed` fails the build with `METHOD_REMOVED` against the
+ 2.2.0 pin while the 2.0.0 diff stays green, which is the hole the second baseline
+ closes.
- **The weekly benchmark run builds the modules it measures.** The JMH workflow
installed the engine from source and then let Maven resolve the rest from Central,
diff --git a/core/pom.xml b/core/pom.xml
index 5e1d1bdd..2d53ee4c 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -89,16 +89,23 @@
3.8.0
17
-
+
0.26.1
-
- 2.0.0
+
+ 2.0.0
+ 2.2.0
japicmp
@@ -683,27 +692,59 @@
${japicmp.version}
- japicmp-against-baseline
+ japicmp-against-major-floor
verify
cmp
+
+
+
+ io.github.demchaav
+ graph-compose-core
+ ${japicmp.baseline.floor}
+
+
+
+
+
+ japicmp-against-previous-release
+ verify
+
+ cmp
+
+
+
+
+ io.github.demchaav
+ graph-compose-core
+ ${japicmp.baseline.previous}
+
+
+
-
-
- io.github.demchaav
- graph-compose-core
- ${japicmp.baseline}
-
-
${project.build.directory}/${project.build.finalName}.jar
+
+ true
true
true
-
+
0.26.1
- 2.0.0
+ 2.0.0
+ 2.2.0
@@ -274,10 +280,12 @@
+ true
true
true
false
From d165fff92de9cdf72cf1c9d86d4538e97352349f Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 17 Aug 2026 13:40:32 +0100
Subject: [PATCH 3/3] test(guards): order release lines without parsing them to
int
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The baseline guard compared versions by parsing each segment with
Integer.parseInt, which throws NumberFormatException on a segment wider than
an int. In a guard that lands as a stack trace where the assertion message
naming the wrong pin should be, and it is an uncaught path CodeQL is right to
flag.
Nothing there needs the numeric value, only the order, so segments are
compared as digit strings: longer wins once leading zeros are stripped, equal
lengths compare lexicographically. Two cases pin it — a segment no int could
hold still orders correctly, and leading zeros do not change the order.
---
.../ChangelogVersionParsingTest.java | 20 ++++++++
.../VersionConsistencyGuardTest.java | 47 ++++++++++++++-----
2 files changed, 54 insertions(+), 13 deletions(-)
diff --git a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
index 53531914..e2580bd3 100644
--- a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
+++ b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java
@@ -268,6 +268,26 @@ void thePreviousReleaseIsChosenNumericallyNotByFilePosition() {
"## v2.10.0 — 2026-12-01\n## v2.9.0 — 2026-11-01\n", "2.10.1-SNAPSHOT")).contains("2.10.0");
}
+ @Test
+ void aVersionSegmentTooWideForAnIntIsOrderedNotRejected() {
+ // Segments are compared as digit strings, so a number no int could hold orders
+ // correctly instead of throwing NumberFormatException out of a guard — which
+ // would replace the assertion message naming the wrong pin with a stack trace.
+ assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore("""
+ ## v2.99999999999.0 — 2026-12-01
+ ## v2.2.0 — 2026-08-15
+ """, "2.99999999999.1-SNAPSHOT")).contains("2.99999999999.0");
+ assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore(
+ "## v2.99999999999.0 — 2026-12-01\n## v2.2.0 — 2026-08-15\n", "2.3.0-SNAPSHOT"))
+ .contains("2.2.0");
+ }
+
+ @Test
+ void leadingZerosDoNotChangeTheOrder() {
+ assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore(
+ "## v2.02.0 — 2026-08-15\n## v2.1.0 — 2026-07-01\n", "2.3.0-SNAPSHOT")).contains("2.02.0");
+ }
+
private static Optional previousReleaseFor(String pomVersion) {
return VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore(RELEASES, pomVersion);
}
diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
index a00d39b5..26cb2c7c 100644
--- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
@@ -13,7 +13,6 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -317,32 +316,54 @@ void bothJapicmpBaselineExecutionsSurviveInEachGatedPom() throws Exception {
* the opening of a new major, and a log with nothing older.
*/
static Optional newestFinalReleaseInMajorBefore(String changelog, String version) {
- int[] working = releaseNumbers(releaseLineOf(version));
+ String working = releaseLineOf(version);
Matcher released = Pattern.compile(
"^## v(\\d+\\.\\d+\\.\\d+)\\s*[\\u2014\\-]\\s*\\d{4}-\\d{2}-\\d{2}", Pattern.MULTILINE)
.matcher(changelog);
String newest = null;
- int[] newestNumbers = null;
while (released.find()) {
String candidate = released.group(1);
- int[] numbers = releaseNumbers(candidate);
- if (numbers[0] == working[0]
- && Arrays.compare(numbers, working) < 0
- && (newestNumbers == null || Arrays.compare(numbers, newestNumbers) > 0)) {
+ if (segment(candidate, 0).equals(segment(working, 0))
+ && compareReleases(candidate, working) < 0
+ && (newest == null || compareReleases(candidate, newest) > 0)) {
newest = candidate;
- newestNumbers = numbers;
}
}
return Optional.ofNullable(newest);
}
- private static int[] releaseNumbers(String version) {
+ /**
+ * Orders two {@code X.Y.Z} release lines, comparing each segment as a number.
+ *
+ * Compared as digit strings rather than parsed: {@code Integer.parseInt} throws
+ * {@link NumberFormatException} on a segment wider than an {@code int}, which in a
+ * guard would surface as a stack trace instead of the assertion message that says
+ * which pin is wrong. Nothing here needs the numeric value — only the order — and
+ * a longer digit string is the larger number once leading zeros are gone.
+ */
+ private static int compareReleases(String left, String right) {
+ for (int i = 0; i < 3; i++) {
+ String a = segment(left, i);
+ String b = segment(right, i);
+ int order = a.length() != b.length()
+ ? Integer.compare(a.length(), b.length())
+ : a.compareTo(b);
+ if (order != 0) {
+ return order;
+ }
+ }
+ return 0;
+ }
+
+ /** Segment {@code index} of an {@code X.Y.Z} version, leading zeros stripped. */
+ private static String segment(String version, int index) {
String[] parts = version.split("\\.");
- int[] numbers = new int[3];
- for (int i = 0; i < 3 && i < parts.length; i++) {
- numbers[i] = Integer.parseInt(parts[i]);
+ String part = index < parts.length ? parts[index] : "0";
+ int firstSignificant = 0;
+ while (firstSignificant < part.length() - 1 && part.charAt(firstSignificant) == '0') {
+ firstSignificant++;
}
- return numbers;
+ return part.substring(firstSignificant);
}
/**