From dc82a1f7bca760241b98267219abcc0e368dd89e Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 11 Sep 2026 16:02:05 +0100 Subject: [PATCH 1/6] ci(templates): diff the templates API against 2.0.0 and the last release docs/api-stability.md lists every templates.* package as Stable, but japicmp only ever diffed graph-compose-core: a public templates method could be deleted, narrowed or re-typed in a 2.x minor with every check green. templates/pom.xml carries a `japicmp` profile with one execution per published baseline. japicmp-against-major-floor diffs against 2.0.0 and holds the GA surface. japicmp-against-previous-release diffs against the latest release (2.3.0) and holds everything added since, which a floor-only diff cannot: SectionAllocation is absent from 2.0.0, so re-typing SectionAllocation.titleOr(CvSection, String) passes the floor and fails only the previous-release diff. Every templates.* package is Stable, so the only exclusion is the per-element @Internal marker, which nothing carries. ignoreMissingOldVersion is set to false: japicmp defaults it to true, which skips a baseline it cannot resolve with a warning and passes, so a mistyped or unpublished pin now fails instead. The gate runs wherever core's does. The Binary Compatibility PR job diffs core and templates in one reactor invocation, and also runs when only templates/src or templates/pom.xml changed; cut-release.ps1 Step 5b diffs both before the tag; publish.yml diffs templates on the tagged commit. cut-release.ps1 -PostReleaseOnly moves the previous pin onto the release it just published (Update-JapicmpPreviousBaseline, Step 3a), unit-checked in release-script-check.yml. VersionConsistencyGuardTest derives both pins - the floor is X.0.0 of the working version, the previous pin the newest dated final release of that major below it - and holds japicmp.version in lockstep with core. BinaryCompatibilityGateGuardTest reads the wiring back: every execution the gate relies on, break-on-binary-incompatibility, no skip switch, fail-on-unresolvable for templates, no exclusion beyond @Internal, and every gated module named by the PR job, its path filter, the release script and the publish workflow. It runs in the always-on guards job. --- .github/workflows/ci.yml | 44 +- .github/workflows/publish.yml | 10 + .github/workflows/release-script-check.yml | 76 +++ CHANGELOG.md | 16 + CONTRIBUTING.md | 6 +- .../BinaryCompatibilityGateGuardTest.java | 444 ++++++++++++++++++ .../ChangelogVersionParsingTest.java | 96 ++++ .../VersionConsistencyGuardTest.java | 160 ++++++- docs/api-stability.md | 50 +- docs/contributing/release-process.md | 6 +- scripts/cut-release.ps1 | 74 ++- templates/pom.xml | 101 ++++ 12 files changed, 1032 insertions(+), 51 deletions(-) create mode 100644 core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c43a8e937..5ae8d6579 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,16 +66,17 @@ jobs: # in qa) run in build-and-test below, which now also covers docs-only PRs. run: | ./mvnw -B -ntp clean \ - "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" \ + "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,BinaryCompatibilityGateGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" \ test -pl :graph-compose-core 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: @@ -133,6 +135,9 @@ jobs: core: - 'core/src/**' - 'core/pom.xml' + templates: + - 'templates/src/**' + - 'templates/pom.xml' perf: - 'core/src/**' - 'render-pdf/**' @@ -478,9 +483,13 @@ 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 (each module carries a `japicmp` profile), so it only matters when + # one of them changed (`core/src/**`, `core/pom.xml`, `templates/src/**` or + # `templates/pom.xml`). Both are diffed in one reactor invocation: templates + # compiles against the core built in the same run, and a core change can move + # the surface templates exposes. + 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: @@ -507,20 +516,23 @@ 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 + # Each module's `japicmp` profile resolves the published releases its + # pom pins from Maven Central and diffs them against the freshly-built + # artifact: graph-compose-core against `japicmp.baseline` (the 2.x + # floor), graph-compose-templates against `japicmp.baseline.floor` and + # `japicmp.baseline.previous` (the floor and the latest release). Fails + # the job on any binary-incompatible modification to either public + # surface. Source-incompatible changes are reported only (phased policy). + 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 ce418b488..1f4de7a8b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -142,6 +142,16 @@ 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 baselines + # The same defence for graph-compose-templates: its `japicmp` profile diffs + # the freshly built jar against both baselines pinned in templates/pom.xml + # (the 2.x floor and the previous release) and fails on any Stable-surface + # break. Every templates.* package is Stable per docs/api-stability.md, so + # only the per-element @Internal marker is excluded. Standalone pom: it + # resolves graph-compose-core from the local m2 the install step above + # 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/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml index b94c2ff2c..96591d79d 100644 --- a/.github/workflows/release-script-check.yml +++ b/.github/workflows/release-script-check.yml @@ -351,3 +351,79 @@ 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: | + # graph-compose-templates is diffed against two published releases: the major + # floor, which never moves inside a major, and the previous release, which has + # to move onto 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.4.1-SNAPSHOT', + ' ', + ' 2.0.0', + " $previous", + ' ', + '' + ) -join "`n") + return $file + } + + $pom = New-Pom '2.3.0' + if (-not (Update-JapicmpPreviousBaseline $pom '2.4.0')) { throw 'moving the pin reported no change' } + $after = Get-Content $pom -Raw + if ($after -notmatch '2\.4\.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\.4\.1-SNAPSHOT') { throw "the project version was rewritten:`n$after" } + + if (Update-JapicmpPreviousBaseline $pom '2.4.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.4.1-SNAPSHOT`n" + if (Update-JapicmpPreviousBaseline $bare '2.4.0') { throw 'a pom without the pin reported a change' } + if (Update-JapicmpPreviousBaseline (Join-Path ([IO.Path]::GetTempPath()) 'no-such-pom.xml') '2.4.0') { + throw 'a missing pom reported a change' + } + + # And on the real templates pom, so the pattern is proven against the file it + # has to move rather than only against one written to fit it. + $real = Join-Path ([IO.Path]::GetTempPath()) ("templates-pom-" + [guid]::NewGuid().ToString() + ".xml") + Copy-Item templates/pom.xml $real + if (-not (Update-JapicmpPreviousBaseline $real '9.9.9')) { throw 'the real templates pom: the pin did not move' } + $expected = [regex]::Replace((Get-Content templates/pom.xml -Raw), + '[^<]*', + '9.9.9') + if ((Get-Content $real -Raw) -ne $expected) { throw 'the real templates pom: the move changed more than the pin' } + + Write-Host 'japicmp baseline: previous moves, floor stays, idempotent, the real pom moves, and the cut calls it.' diff --git a/CHANGELOG.md b/CHANGELOG.md index ff59335e0..b69d9854a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -560,6 +560,22 @@ follow semantic versioning; release dates are ISO 8601. Per [`docs/api-stability.md`](docs/api-stability.md) § 3 it is Stable-tier, so it is removed no earlier than 3.0 and not before a full minor has shipped with the deprecation in place. +### Build + +- **`graph-compose-templates` is under the binary-compatibility gate.** japicmp used to diff + `graph-compose-core` alone, so a Stable templates method could be deleted in a minor with + every check green. The module now carries its own `japicmp` profile, run by the same CI job, + the publish workflow and the release script, and diffs each build against two published + releases: the 2.x floor (`2.0.0`), which holds the GA surface, and the latest release + (`2.3.0` today), which holds everything added since. `cut-release.ps1 -PostReleaseOnly` + moves the second pin after each release. `VersionConsistencyGuardTest` fails the build when + a pin goes stale; `BinaryCompatibilityGateGuardTest` fails it when an execution goes + missing, when the templates gate is narrowed beyond the per-element `@Internal` marker, or + when the pull-request job, the release script or the publish workflow stops diffing the + module. Every `templates.*` package is Stable, so that marker is the only exclusion, and + nothing carries it. A baseline the gate cannot resolve fails the build; japicmp's default + would skip that diff with a warning and pass. + ### Documentation - **The timeline recipe describes the finished model.** `LEADING | AXIS | CONTENT`, what the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e2dea05a..d5e5fd0ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ When writing new code, avoid Java 21+ APIs and language constructs that don't ex ## Build and test - The blocking validation gate for repository work is `./mvnw -B -ntp clean verify` at the repository root — the root pom is the reactor aggregator, so this builds and verifies **every module**. For a fast inner loop while iterating on the engine, scope it to the core module: `./mvnw -B -ntp verify -pl :graph-compose-core`. -- Run the engine-resident guard suite with `./mvnw -B -ntp "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest" test -pl :graph-compose-core` — the same list CI runs. Every name must live in `graph-compose-core`: Surefire drops a name that matches nothing as long as a sibling matches, so a guard that lives elsewhere would silently not run (`CiGuardListGuardTest` fails the build if one creeps in). +- Run the engine-resident guard suite with `./mvnw -B -ntp "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,BinaryCompatibilityGateGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" test -pl :graph-compose-core` — the same list CI runs. Every name must live in `graph-compose-core`: Surefire drops a name that matches nothing as long as a sibling matches, so a guard that lives elsewhere would silently not run (`CiGuardListGuardTest` fails the build if one creeps in). - The cross-module documentation guards — `DocumentationExamplesTest` and `DocumentationSnippetCompileTest`, which compiles the literal java fences published in `docs/` — live in `graph-compose-qa`: `./mvnw -B -ntp "-Dtest=DocumentationExamplesTest,DocumentationSnippetCompileTest" test -f qa/pom.xml`. A standalone `-f qa/pom.xml` run resolves its `graph-compose-*` dependencies from `~/.m2`, not from the reactor, so run `./mvnw -B -ntp -DskipTests install` once first — otherwise it quietly tests the artifacts you last installed instead of your working tree. - Run the local benchmark wrapper when you change performance-sensitive code or benchmark tooling: `powershell -ExecutionPolicy Bypass -File .\scripts\run-benchmarks.ps1` (Windows). To compare two branches fairly, use `scripts/ab-bench.ps1` (Windows) or the cross-platform `scripts/ab-bench.sh` (Linux/macOS/Git Bash). See [docs/operations/benchmarks.md](./docs/operations/benchmarks.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/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java new file mode 100644 index 000000000..78ce8c5d1 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java @@ -0,0 +1,444 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the wiring that makes japicmp a gate rather than a report. + * + *

The binary-compatibility check is a Maven profile, so what it protects is decided + * outside the code it protects: executions in a pom, a module list in a workflow, a + * path filter, a release-script argument. Each can be dropped by an edit that reads as + * tidying, and none of those edits turns anything red — the job diffs less and still + * reports success. Deleting a Stable method is caught by the diff; deleting the diff is + * caught here.

+ * + *

The gated modules are discovered, as every module pom that declares a + * {@code japicmp} profile, so a module joining the gate is held to the same wiring the + * day it does. {@link #everyJapicmpExecutionTheGateReliesOnStillRuns} names the + * executions that must exist today, so deleting a profile outright cannot shrink the + * discovered set without failing.

+ */ +class BinaryCompatibilityGateGuardTest { + + private static final Path PROJECT_ROOT = RepoRoot.get(); + private static final Path CI = PROJECT_ROOT.resolve(".github/workflows/ci.yml"); + private static final Path PUBLISH = PROJECT_ROOT.resolve(".github/workflows/publish.yml"); + private static final Path RELEASE_SCRIPT = PROJECT_ROOT.resolve("scripts/cut-release.ps1"); + + /** The ci.yml job that runs the gate on pull requests. */ + private static final String PR_JOB = "binary-compat"; + + /** The per-element marker: the one exclusion the templates gate may carry. */ + private static final String INTERNAL_MARKER = "@com.demcha.compose.document.api.Internal"; + + /** + * Each execution the gate relies on, per pom, with the baseline property its + * {@code } must read. + */ + private static final Map> REQUIRED_EXECUTIONS = Map.of( + "core/pom.xml", Map.of("japicmp-against-baseline", "japicmp.baseline"), + "templates/pom.xml", Map.of( + "japicmp-against-major-floor", "japicmp.baseline.floor", + "japicmp-against-previous-release", "japicmp.baseline.previous")); + + /** A {@code run:} line that invokes the gate. */ + private static final Pattern GATE_RUN = Pattern.compile("(?m)^\\s+run: (.*-P japicmp.*)$"); + + /** A job-level {@code if:} — four-space indent, first line only. */ + private static final Pattern JOB_IF = Pattern.compile("(?m)^ if: (.*)$"); + + /** A {@code changes} output that a job condition tests. */ + private static final Pattern CHANGE_OUTPUT = + Pattern.compile("needs\\.changes\\.outputs\\.([A-Za-z_][\\w-]*) == 'true'"); + + /** An output the {@code changes} job exports, and the path filter it exports. */ + private static final Pattern EXPORTED_OUTPUT = Pattern.compile( + "(?m)^ ([A-Za-z_][\\w-]*): \\$\\{\\{ steps\\.filter\\.outputs\\.([A-Za-z_][\\w-]*) }}\\s*$"); + + /** A filter name, and one of its globs, inside the {@code filters: |} literal. */ + private static final Pattern FILTER_KEY = Pattern.compile("^ ([A-Za-z_][\\w-]*):\\s*$"); + private static final Pattern FILTER_GLOB = Pattern.compile("^ - '([^']+)'\\s*$"); + + /** The argument array {@code cut-release.ps1} Step 5b hands to Maven. */ + private static final Pattern RELEASE_GATE_ARGS = + Pattern.compile("(?m)^\\s*\\$japicmpArgs = @\\((.*)\\)\\s*$"); + + /** + * An execution that is gone, unbound from {@code verify}, or pointed at another + * baseline stops a diff from running. A gate that reports a break without failing, or + * carries a skip switch, still runs and protects nothing. Every one of them leaves CI + * green. + */ + @Test + void everyJapicmpExecutionTheGateReliesOnStillRuns() throws Exception { + for (Map.Entry> pom : REQUIRED_EXECUTIONS.entrySet()) { + String where = pom.getKey(); + Element plugin = japicmpPlugin(PROJECT_ROOT.resolve(where)); + Map executions = executionsById(plugin); + + for (Map.Entry required : pom.getValue().entrySet()) { + String id = required.getKey(); + String baseline = "${" + required.getValue() + "}"; + Element execution = executions.get(id); + assertThat(execution) + .describedAs("%s must keep the japicmp execution '%s' — without it the diff " + + "against %s stops running and the job stays green", where, id, baseline) + .isNotNull(); + assertThat(textOf(directChild(execution, "phase"))) + .describedAs("%s execution '%s' must stay bound to verify, the phase every " + + "path that runs the gate invokes", where, id) + .isEqualTo("verify"); + assertThat(childTexts(directChild(execution, "goals"), "goal")) + .describedAs("%s execution '%s' must run japicmp's cmp goal", where, id) + .contains("cmp"); + assertThat(oldVersionOf(plugin, execution)) + .describedAs("%s execution '%s' must diff against %s", where, id, baseline) + .contains(baseline); + } + + assertThat(descendantTexts(plugin, "breakBuildOnBinaryIncompatibleModifications")) + .describedAs("%s: the japicmp gate must fail the build on a binary break, " + + "everywhere it is configured — reporting one protects nothing", where) + .isNotEmpty() + .containsOnly("true"); + assertThat(descendantTexts(plugin, "skip")) + .describedAs("%s: the japicmp gate must carry no skip switch", where) + .isEmpty(); + } + } + + /** + * A baseline the templates gate cannot resolve fails the build. + * + *

japicmp defaults {@code ignoreMissingOldVersion} to {@code true}: a pin naming a + * mistyped or unpublished version, or one the repository cannot serve, is logged as a + * warning, the diff is skipped, and the build passes having compared nothing. So the + * safe setting has to be present — its absence is the unsafe state — and + * {@code ignoreNonResolvableArtifacts}, which skips the same way, must not switch it + * back.

+ */ + @Test + void theTemplatesGateFailsWhenABaselineCannotBeResolved() throws Exception { + Element plugin = japicmpPlugin(PROJECT_ROOT.resolve("templates/pom.xml")); + + assertThat(descendantTexts(plugin, "ignoreMissingOldVersion")) + .describedAs("templates/pom.xml must set ignoreMissingOldVersion to false: japicmp " + + "defaults it to true, which skips a baseline it cannot resolve and passes") + .isNotEmpty() + .containsOnly("false"); + assertThat(descendantTexts(plugin, "ignoreNonResolvableArtifacts")) + .describedAs("templates/pom.xml: ignoreNonResolvableArtifacts skips an unresolvable " + + "baseline just as ignoreMissingOldVersion does") + .noneMatch(value -> value.equalsIgnoreCase("true")); + } + + /** + * Every {@code templates.*} package is Stable ({@code docs/api-stability.md}), so the + * templates gate may narrow itself by nothing but the per-element {@code @Internal} + * marker. An excluded package or class, or an include list naming part of the + * module, would let a Stable break ship with the gate green. + */ + @Test + void theTemplatesGateCoversTheWholeStableSurface() throws Exception { + Element plugin = japicmpPlugin(PROJECT_ROOT.resolve("templates/pom.xml")); + + assertThat(descendantTexts(plugin, "exclude")) + .describedAs("templates/pom.xml: the japicmp gate may exclude only %s — every " + + "templates.* package is Stable per docs/api-stability.md", INTERNAL_MARKER) + .isSubsetOf(List.of(INTERNAL_MARKER)); + assertThat(descendantTexts(plugin, "include")) + .describedAs("templates/pom.xml: an include list narrows the japicmp gate to part " + + "of the Stable surface") + .isEmpty(); + } + + /** + * The pull-request job diffs every gated module, and runs whenever one of them changes. + * + *

Three edits each take a module off that path without failing anything: its + * artifact dropped from the job's {@code -pl} list; its paths dropped from the filter + * the job's condition reads; or that filter not exported from the {@code changes} + * job — an output that is not declared reads as empty, so the condition is never true + * for the module, and a skipped job passes.

+ */ + @Test + void everyGatedModuleIsDiffedOnThePullRequestsThatTouchIt() throws Exception { + Map jobs = CiGateCoverageGuardTest.jobBlocks(Files.readString(CI)); + String job = jobs.get(PR_JOB); + String changes = jobs.get("changes"); + assertThat(job).describedAs("ci.yml has no '%s' job to run the gate on pull requests", PR_JOB).isNotNull(); + assertThat(changes).describedAs("ci.yml has no 'changes' job for the gate's condition to read").isNotNull(); + + String invocation = firstGroup(job, GATE_RUN, "ci.yml job '" + PR_JOB + "'"); + String condition = firstGroup(job, JOB_IF, "ci.yml job '" + PR_JOB + "'"); + Map exported = exportedOutputs(changes); + Map> filters = pathFilters(changes); + + assertThat(invocation) + .describedAs("ci.yml job '%s' must not switch the gate off", PR_JOB) + .doesNotContain("japicmp.skip"); + for (String module : gatedModules()) { + String artifact = artifactIdOf(module); + assertThat(invocation) + .describedAs("ci.yml job '%s' must diff %s: its -pl list decides which modules " + + "the gate sees", PR_JOB, artifact) + .contains(":" + artifact); + + List triggers = new ArrayList<>(); + Matcher read = CHANGE_OUTPUT.matcher(condition); + while (read.find()) { + String filter = exported.get(read.group(1)); + if (filter != null && watches(filters.getOrDefault(filter, List.of()), module)) { + triggers.add(read.group(1)); + } + } + assertThat(triggers) + .describedAs("ci.yml job '%s' must run when %s changes: its condition has to read " + + "a 'changes' output that is exported and whose filter matches both " + + "%s/src/** and %s/pom.xml — otherwise a pull request touching only that " + + "module skips the gate, and a skipped job passes", PR_JOB, module, module, module) + .isNotEmpty(); + } + } + + /** + * The release script diffs every gated module before the tag is cut, and the publish + * workflow diffs each one on the tagged commit before it deploys — the two paths a + * direct push reaches without the pull-request job. + */ + @Test + void everyGatedModuleIsDiffedBeforeTheTagAndBeforeThePublish() throws Exception { + String releaseArgs = firstGroup(read(RELEASE_SCRIPT), RELEASE_GATE_ARGS, "scripts/cut-release.ps1"); + List publishGates = allGroups(read(PUBLISH), GATE_RUN); + assertThat(publishGates) + .describedAs("publish.yml runs no japicmp gate before it deploys") + .isNotEmpty() + .noneMatch(run -> run.contains("japicmp.skip")); + assertThat(releaseArgs) + .describedAs("cut-release.ps1 Step 5b must not switch the gate off") + .doesNotContain("japicmp.skip"); + + for (String module : gatedModules()) { + String artifact = artifactIdOf(module); + assertThat(releaseArgs) + .describedAs("cut-release.ps1 Step 5b must diff %s before the tag is cut", artifact) + .contains(":" + artifact); + assertThat(publishGates) + .describedAs("publish.yml must diff %s on the tagged commit before it deploys", artifact) + .anyMatch(run -> run.contains("-f " + module + "/pom.xml")); + } + } + + /** Every module directory whose pom declares a {@code japicmp} profile, sorted. */ + private static List gatedModules() throws Exception { + List poms; + try (Stream entries = Files.list(PROJECT_ROOT)) { + poms = entries.filter(Files::isDirectory) + .map(module -> module.resolve("pom.xml")) + .filter(Files::isRegularFile) + .sorted() + .toList(); + } + List gated = new ArrayList<>(); + for (Path pom : poms) { + if (japicmpProfile(pom) != null) { + gated.add(pom.getParent().getFileName().toString()); + } + } + assertThat(gated) + .describedAs("no module pom declares a 'japicmp' profile — the discovery broke, or the " + + "gate is gone; either way every check that reads this list passes over nothing") + .isNotEmpty(); + return gated; + } + + private static Element japicmpProfile(Path pom) throws Exception { + for (Element profile : children(directChild(parse(pom), "profiles"), "profile")) { + if ("japicmp".equals(textOf(directChild(profile, "id")))) { + return profile; + } + } + return null; + } + + private static Element japicmpPlugin(Path pom) throws Exception { + Element profile = japicmpProfile(pom); + assertThat(profile) + .describedAs("%s declares no 'japicmp' profile", relative(pom)) + .isNotNull(); + for (Element plugin : children(directChild(directChild(profile, "build"), "plugins"), "plugin")) { + if ("japicmp-maven-plugin".equals(textOf(directChild(plugin, "artifactId")))) { + return plugin; + } + } + throw new AssertionError(relative(pom) + ": the 'japicmp' profile declares no japicmp-maven-plugin"); + } + + private static Map executionsById(Element plugin) { + Map byId = new LinkedHashMap<>(); + for (Element execution : children(directChild(plugin, "executions"), "execution")) { + byId.put(textOf(directChild(execution, "id")), execution); + } + return byId; + } + + /** + * The {@code } an execution diffs against: its own when it declares one, + * which is what Maven does when it merges an execution's configuration over the + * plugin's — otherwise the plugin-level one. + */ + private static String oldVersionOf(Element plugin, Element execution) { + Element own = directChild(directChild(execution, "configuration"), "oldVersion"); + Element effective = own != null ? own : directChild(directChild(plugin, "configuration"), "oldVersion"); + return effective == null ? "" : effective.getTextContent(); + } + + private static String artifactIdOf(String module) throws Exception { + return textOf(directChild(parse(PROJECT_ROOT.resolve(module + "/pom.xml")), "artifactId")); + } + + /** Output name to the path filter it exports, from the {@code changes} job's {@code outputs:}. */ + private static Map exportedOutputs(String changesJob) { + Map exported = new LinkedHashMap<>(); + Matcher output = EXPORTED_OUTPUT.matcher(changesJob); + while (output.find()) { + exported.put(output.group(1), output.group(2)); + } + return exported; + } + + /** Filter name to its globs, from the {@code filters: |} literal of the {@code changes} job. */ + private static Map> pathFilters(String changesJob) { + Map> filters = new LinkedHashMap<>(); + List current = null; + for (String line : changesJob.split("\n")) { + Matcher key = FILTER_KEY.matcher(line); + Matcher glob = FILTER_GLOB.matcher(line); + if (key.matches()) { + current = new ArrayList<>(); + filters.put(key.group(1), current); + } else if (glob.matches() && current != null) { + current.add(glob.group(1)); + } + } + return filters; + } + + /** Whether a change to {@code module}'s main sources, and one to its pom, both match a glob. */ + private static boolean watches(List globs, String module) { + return matchesAny(globs, module + "/src/main/java/Probe.java") + && matchesAny(globs, module + "/pom.xml"); + } + + private static boolean matchesAny(List globs, String path) { + for (String glob : globs) { + if (path.matches(globToRegex(glob))) { + return true; + } + } + return false; + } + + /** The glob shapes the workflow uses: {@code **} crosses directories, {@code *} stays inside one. */ + private static String globToRegex(String glob) { + StringBuilder regex = new StringBuilder(); + for (int i = 0; i < glob.length(); i++) { + char c = glob.charAt(i); + if (c == '*' && i + 1 < glob.length() && glob.charAt(i + 1) == '*') { + regex.append(".*"); + i++; + } else if (c == '*') { + regex.append("[^/]*"); + } else { + regex.append(Pattern.quote(String.valueOf(c))); + } + } + return regex.toString(); + } + + private static String firstGroup(String text, Pattern pattern, String where) { + Matcher matcher = pattern.matcher(text); + assertThat(matcher.find()) + .describedAs("%s no longer matches /%s/ — the gate wiring this guard reads has moved, " + + "so it is no longer guarding anything", where, pattern.pattern()) + .isTrue(); + return matcher.group(1); + } + + private static List allGroups(String text, Pattern pattern) { + List groups = new ArrayList<>(); + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + groups.add(matcher.group(1)); + } + return groups; + } + + private static String read(Path file) throws IOException { + return Files.readString(file).replace("\r\n", "\n"); + } + + private static Element parse(Path pom) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + return factory.newDocumentBuilder().parse(pom.toFile()).getDocumentElement(); + } + + private static List children(Element parent, String name) { + List found = new ArrayList<>(); + if (parent == null) { + return found; + } + NodeList nodes = parent.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE && node.getNodeName().equals(name)) { + found.add((Element) node); + } + } + return found; + } + + private static Element directChild(Element parent, String name) { + List found = children(parent, name); + return found.isEmpty() ? null : found.get(0); + } + + private static List childTexts(Element parent, String name) { + return children(parent, name).stream().map(BinaryCompatibilityGateGuardTest::textOf).toList(); + } + + private static List descendantTexts(Element root, String name) { + List texts = new ArrayList<>(); + NodeList nodes = root.getElementsByTagName(name); + for (int i = 0; i < nodes.getLength(); i++) { + texts.add(nodes.item(i).getTextContent().trim()); + } + return texts; + } + + private static String textOf(Element element) { + return element == null ? null : element.getTextContent().trim(); + } + + private static String relative(Path path) { + return PROJECT_ROOT.relativize(path).toString().replace('\\', '/'); + } +} diff --git a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java index 8338f922a..595f17460 100644 --- a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java +++ b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -195,6 +196,101 @@ void aSnapshotAndAReleaseCandidateShareTheirReleaseLine() { assertThat(VersionConsistencyGuardTest.releaseLineOf("2.2.0")).isEqualTo("2.2.0"); } + // ── Which release the japicmp previous-release pin must name ──── + + private static final String RELEASES = """ + # Changelog + + ## v2.3.0 — Planned + + ## v2.2.0 — 2026-08-15 + + ## v2.2.0-rc.1 — 2026-08-12 + + ## v2.1.1 — 2026-08-05 + + ## v2.0.0 — 2026-07-01 + """; + + @Test + void duringASnapshotCycleThePreviousReleaseIsTheNewestDatedEntry() { + assertThat(previousReleaseFor("2.2.1-SNAPSHOT")).contains("2.2.0"); + assertThat(previousReleaseFor("2.3.0-SNAPSHOT")).contains("2.2.0"); + } + + @Test + void onTheReleaseCommitThePreviousReleaseIsTheOneBeforeIt() { + // The cut dates the entry and sets the poms to the release in the same commit; + // the release itself is not yet on Central, so the gate must still diff it + // against the release before it — and 2.2.0 excludes itself. + assertThat(previousReleaseFor("2.2.0")).contains("2.1.1"); + assertThat(previousReleaseFor("2.2.0-rc.2")).contains("2.1.1"); + } + + @Test + void openAndPreReleaseEntriesNeverBecomeThePreviousRelease() { + // A -rc entry is dated but never published to Central; a Planned entry is + // undated. Neither is an artifact the gate could resolve. + assertThat(previousReleaseFor("2.2.0-rc.2")).contains("2.1.1"); + assertThat(previousReleaseFor("2.4.0-SNAPSHOT")).contains("2.2.0"); + } + + @Test + void aReleaseOfAnEarlierMajorIsNeverThePreviousRelease() { + // Opening 3.0 leaves the major with no release of its own. Reaching back to + // 2.2.0 would diff across the boundary and fail the build on every break the + // major is allowed to make; the caller falls back to the floor instead. + assertThat(previousReleaseFor("3.0.0-SNAPSHOT")).isEmpty(); + assertThat(previousReleaseFor("3.0.0")).isEmpty(); + // Once the major has shipped one, it is used — and only within the major. + assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore( + RELEASES + "\n## v3.0.0 — 2026-10-01\n", "3.0.1-SNAPSHOT")).contains("3.0.0"); + } + + @Test + void aLogWithNothingOlderYieldsNoPreviousRelease() { + assertThat(previousReleaseFor("2.0.0")).isEmpty(); + assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore("## v2.0.0 — Planned\n", "2.0.0-SNAPSHOT")) + .isEmpty(); + } + + @Test + void thePreviousReleaseIsChosenNumericallyNotByFilePosition() { + // A patch entry filed above a newer minor (a hotfix line released after the + // minor opened) must not shadow it: 2.2.0 is newer than 2.1.2 whatever the order. + assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore(""" + ## v2.1.2 — 2026-08-20 + ## v2.2.0 — 2026-08-15 + ## v2.1.1 — 2026-08-05 + """, "2.2.1-SNAPSHOT")).contains("2.2.0"); + assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore( + "## 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); + } + private static String problem(String changelog, String pomVersion) { return VersionConsistencyGuardTest.versionDriftProblem(changelog, pomVersion); } diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index 2bda9b460..c5b4d4e27 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -17,6 +17,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -201,6 +202,148 @@ 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: the + * releases each module is diffed against are pinned per module, and + * {@link #japicmpBaselinesTrackTheWorkingMajorAndTheLatestRelease} holds the + * templates pins to the CHANGELOG. + */ + @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 poms whose {@code japicmp} profile diffs against two published releases: the + * floor of the working major and the release before the working version. The + * engine pom still diffs against its floor alone ({@code japicmp.baseline}). + */ + private static final List TWO_BASELINE_JAPICMP_POMS = List.of("templates/pom.xml"); + + /** + * The two-baseline japicmp pins name the releases they have to. + * + *

The major floor ({@code japicmp.baseline.floor}) is the first release of the + * working major and holds the GA surface. It cannot hold anything added later: a + * method first published in 2.2.0 is absent from 2.0.0 and from a 2.4.1 that + * deletes it, so that diff stays green while a caller compiled against 2.2.0 gets + * {@code NoSuchMethodError}. The previous-release pin + * ({@code japicmp.baseline.previous}) closes that hole — but only while it names + * the latest release actually published, and it is a literal that + * {@code cut-release.ps1 -PostReleaseOnly} moves after each cut. A move that does + * not happen leaves everything added in the release just shipped unprotected, with + * the gate reporting green, so the pin is held to the CHANGELOG here: it must name + * the newest dated final release of the working major strictly older than + * the working version. That reading is right on both sides of a cut — on the + * release commit (pom {@code 2.4.0}, CHANGELOG {@code ## v2.4.0 — }) the + * newest release older than 2.4.0 is still 2.3.0, which is what the release must + * be diffed against; on the next {@code 2.4.1-SNAPSHOT} it is 2.4.0, so a bump + * commit that forgets the pin fails here, and {@code -PostReleaseOnly} runs this + * test before it commits.

+ * + *

Both pins stay inside the working major. On {@code 3.0.0-SNAPSHOT} the floor + * is {@code 3.0.0} and the major has no release yet, so the previous pin falls back + * to the floor: both then name a version nobody has published, and the templates + * gate, set not to skip a baseline it cannot resolve, fails until 3.0.0 is on Central. + * Pinning across the boundary instead would fail it on every break the major is + * allowed to make. Either way, opening a major means deciding how the gate runs for + * that cycle; a gate that went quiet by itself at the boundary would go just as + * quiet if a pin broke for any other reason.

+ */ + @Test + void japicmpBaselinesTrackTheWorkingMajorAndTheLatestRelease() throws Exception { + String changelog = Files.readString(PROJECT_ROOT.resolve("CHANGELOG.md")); + + for (String pom : TWO_BASELINE_JAPICMP_POMS) { + Path path = PROJECT_ROOT.resolve(pom); + String working = effectiveVersion(path); + String floor = releaseLineOf(working).split("\\.")[0] + ".0.0"; + + assertThat(pinnedVersionProperty(path, "japicmp.baseline.floor")) + .describedAs("%s japicmp.baseline.floor must be the first release of the working " + + "major (%s for working version %s)", pom, floor, working) + .isEqualTo(floor); + + String expected = newestFinalReleaseInMajorBefore(changelog, working).orElse(floor); + assertThat(pinnedVersionProperty(path, "japicmp.baseline.previous")) + .describedAs("%s japicmp.baseline.previous must be the newest dated CHANGELOG release " + + "of the working major older than the working version %s (the floor itself " + + "while the major has none) — cut-release.ps1 -PostReleaseOnly moves it to " + + "the version just published; a stale pin leaves everything added in that " + + "release unprotected by the japicmp gate", pom, working) + .isEqualTo(expected); + } + } + + /** + * The newest dated final release ({@code ## vX.Y.Z — YYYY-MM-DD}) in {@code changelog} + * that shares {@code version}'s major and is strictly older than its {@code X.Y.Z}; + * empty when the major has none yet. Open ({@code — Planned}) and pre-release + * ({@code -rc.N}) entries never count: neither is a published Maven Central artifact + * a gate could resolve. Releases of an earlier major never count either — diffing + * across a major boundary would fail the build on breaks the major is allowed to make. + * + *

String-driven so {@code ChangelogVersionParsingTest} can hold the shapes: a + * snapshot cycle, the release commit (working version equal to the newest release), + * the opening of a new major, and a log with nothing older.

+ */ + static Optional newestFinalReleaseInMajorBefore(String changelog, String version) { + String working = releaseLineOf(version); + Matcher released = DATED_FINAL_RELEASE.matcher(changelog); + String newest = null; + while (released.find()) { + String candidate = released.group(1); + if (segment(candidate, 0).equals(segment(working, 0)) + && compareReleases(candidate, working) < 0 + && (newest == null || compareReleases(candidate, newest) > 0)) { + newest = candidate; + } + } + return Optional.ofNullable(newest); + } + + /** + * 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("\\."); + String part = index < parts.length ? parts[index] : "0"; + int firstSignificant = 0; + while (firstSignificant < part.length() - 1 && part.charAt(firstSignificant) == '0') { + firstSignificant++; + } + return part.substring(firstSignificant); + } + /** * 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 @@ -798,18 +941,23 @@ private Set acceptableTargets() throws Exception { */ private String latestPublishedRelease() throws Exception { String changelog = Files.readString(PROJECT_ROOT.resolve("CHANGELOG.md")); - // Only a FINAL semver header (## vX.Y.Z — YYYY-MM-DD) counts as published on - // Maven Central. A dated pre-release header (## vX.Y.Z-rc.N — …) must NOT be - // treated as the published version — pre-releases never ship to Central — so the - // version group is anchored to \d+\.\d+\.\d+ with no suffix. - Matcher released = Pattern.compile("^## v(\\d+\\.\\d+\\.\\d+)\\s*[\\u2014\\-]\\s*\\d{4}-\\d{2}-\\d{2}", Pattern.MULTILINE) - .matcher(changelog); + Matcher released = DATED_FINAL_RELEASE.matcher(changelog); assertThat(released.find()) .describedAs("CHANGELOG.md must contain a dated final release entry (## vX.Y.Z — YYYY-MM-DD) to anchor the install snippets") .isTrue(); return released.group(1); } + /** + * A dated final release entry, {@code ## vX.Y.Z — YYYY-MM-DD}. Only a FINAL semver + * header counts as published on Maven Central. A dated pre-release header + * ({@code ## vX.Y.Z-rc.N — …}) must NOT be treated as a published version — + * pre-releases never ship to Central — so the version group is anchored to + * {@code X.Y.Z} with no suffix. + */ + private static final Pattern DATED_FINAL_RELEASE = Pattern.compile( + "^## v(\\d+\\.\\d+\\.\\d+)\\s*[\\u2014\\-]\\s*\\d{4}-\\d{2}-\\d{2}", Pattern.MULTILINE); + /** * One {@code ## vX.Y.Z — } entry: the release line it names, and whatever * follows the version on that line — a date once shipped, anything else while open. diff --git a/docs/api-stability.md b/docs/api-stability.md index d07b1e8d5..eecd62761 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -209,24 +209,44 @@ 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 engine module (`graph-compose-core`) and on +`graph-compose-templates`. The render backends (`graph-compose-render-pdf`, +`-render-docx`, `-render-pptx`) are not gated yet. + +- **Baselines:** the published artifacts on Maven Central. + - `graph-compose-core` is diffed against the `japicmp.baseline` property in + `core/pom.xml`: the current major's **floor**, `2.0.0` for the whole 2.x line, + advancing 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. + - `graph-compose-templates` is diffed twice, one execution each: against the floor + (`japicmp.baseline.floor`, `2.0.0`) and against the latest published release + (`japicmp.baseline.previous`). The floor holds the GA surface; the previous release + holds everything added since, which a floor-only diff cannot protect — a member + first published in `2.2.0` is absent from `2.0.0`. `cut-release.ps1 + -PostReleaseOnly` moves the previous pin to the release just published. + `VersionConsistencyGuardTest` fails the build when a pin disagrees with the working + version and the CHANGELOG; `BinaryCompatibilityGateGuardTest` fails it when either + execution goes missing, or when the pull-request job, the release script or the + publish workflow stops diffing the module. - **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. + against a baseline — a removed or less-accessible public method/field/type or + constructor, a changed signature, and so on. A deprecated element stays protected like + any other until a major release removes it (§ 3). `@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). Every `templates.*` package is Stable (§ 4), so in `graph-compose-templates` + only an element carrying the per-element `@Internal` marker is excluded — none does + today — and `BinaryCompatibilityGateGuardTest` fails the build if that gate is narrowed + any further. 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. + active for every `-SNAPSHOT` development cycle across the 2.x line that follows. A + baseline `graph-compose-templates` cannot resolve fails the build rather than skipping + its diff (japicmp's default, which the engine gate still runs with, skips it with a + warning), so the first cycle of a new major, whose floor is unpublished until its + release, needs an explicit decision about how that gate runs. 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 e00652fec..3c4755f52 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -94,13 +94,13 @@ 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 with its baselines (each module's own `japicmp` profile: core against the 2.x floor, templates against the floor and the previous release) — a second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. Skipped by `-SkipVerify`. 5c. **Regenerate the knowledge pack** — `node knowledge/tools/api-surface/extract-api.mjs --from-reactor` rewrites `knowledge/api/*.json|md` and `knowledge/manifest.json` from the classes the verify just compiled, so the surfaces embed the just-bumped version (the extractor reads it from the root `pom.xml`). Then `node knowledge/tools/claims/check-claims.mjs --check` and `node knowledge/tools/routing/check-routes.mjs` run as gates, mirroring the tag-time gate in [`release.yml`](../../.github/workflows/release.yml) — a stale claims index or a broken route fails the cut here, with nothing committed, instead of failing the tag after it is pushed. **Not** skipped by `-SkipVerify` (Step 4's installs already compiled every module the extractor reads). If `node` is not on `PATH` the step is skipped with a loud warning — stop before the script's push step (or cut with `-SkipPush`), regenerate, and amend, or the tag fails its own gate. 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 eight 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/`, the regenerated `assets/readme/repository_showcase_render.png`, and `knowledge/` (the surfaces Step 5c just regenerated). `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`). -The script supports `-DryRun` (preview every step), `-SkipPush` (commit + tag locally only), `-SkipVerify` (skip the verify + japicmp gates; the knowledge regen still runs), and `-PostReleaseOnly`. The latter skips release work entirely and instead **opens the next development line**: it bumps every train pom to the next patch `-SNAPSHOT`, flips GH_BASE back to `/blob/develop`, and regenerates the knowledge pack surfaces at the new `-SNAPSHOT` (same commands and gates as Step 5c — the surfaces embed the reactor version, so a bump committed without them turns develop's "Knowledge pack — API surface is current" CI job red until a follow-up regen lands), then commits — staging `knowledge/` beside the poms — and pushes. A pre-bump probe (`extract-api --from-reactor --check`) refuses before any pom moves when the tree cannot regenerate the surfaces — e.g. no compiled classes after a `clean` — so the failure lands on a clean tree, not mid-bump. It deliberately leaves the README/showcase **install snippets on the just-published release** — during a `-SNAPSHOT` cycle they must advertise the version actually on Central, which `VersionConsistencyGuardTest` enforces; `cut-release.ps1` rewrites them to the new version at the next release commit. `-PostReleaseOnly` is idempotent: if the poms already carry a `-SNAPSHOT`, the bump is skipped (only the showcase flip runs, if needed). +The script supports `-DryRun` (preview every step), `-SkipPush` (commit + tag locally only), `-SkipVerify` (skip the verify + japicmp gates; the knowledge regen still runs), and `-PostReleaseOnly`. The latter skips release work entirely and instead **opens the next development line**: it bumps every train pom to the next patch `-SNAPSHOT`, moves the `graph-compose-templates` japicmp previous-release pin (`japicmp.baseline.previous`) onto the release just published, flips GH_BASE back to `/blob/develop`, and regenerates the knowledge pack surfaces at the new `-SNAPSHOT` (same commands and gates as Step 5c — the surfaces embed the reactor version, so a bump committed without them turns develop's "Knowledge pack — API surface is current" CI job red until a follow-up regen lands), then commits — staging `knowledge/` beside the poms — and pushes. A pre-bump probe (`extract-api --from-reactor --check`) refuses before any pom moves when the tree cannot regenerate the surfaces — e.g. no compiled classes after a `clean` — so the failure lands on a clean tree, not mid-bump. It deliberately leaves the README/showcase **install snippets on the just-published release** — during a `-SNAPSHOT` cycle they must advertise the version actually on Central, which `VersionConsistencyGuardTest` enforces; `cut-release.ps1` rewrites them to the new version at the next release commit. `-PostReleaseOnly` is idempotent: if the poms already carry a `-SNAPSHOT`, the bump is skipped (only the showcase flip runs, if needed). **Final vs pre-release.** A **final** release (`X.Y.Z`, no suffix) is the only kind that lands on Maven Central. A **pre-release** (`X.Y.Z-rc.N` / `-alpha` / `-beta`) ships only to the GitHub Release pre-release surface — [`publish.yml`](../../.github/workflows/publish.yml) skips hyphenated tags for Central. So `cut-release.ps1` splits its behaviour on `$isFinalRelease`: it bumps the train poms to the (pre-)release version either way, but for a **pre-release** it does **not** check the README `Latest stable` block and does **not** rewrite the README / module-README / showcase install snippets — those stay on the last stable, on-Central version (rewriting them to an RC would advertise a coordinate that 404s for anyone who copies it). `VersionConsistencyGuardTest` matches this: the snippets must equal the pom only when the pom is a concrete final version; for a `-SNAPSHOT` or a pre-release pom they must equal the latest *published* release. @@ -128,7 +128,7 @@ Run within 1 hour of the tag push. Independent steps can run in parallel. 5. **Smoke-test the install snippet** — minimal POM in `$env:TEMP`, `mvn dependency:resolve` against the snippet copy-pasted from README, expect 0 exit. 6. **Re-run all examples against the published artifact** — `./mvnw -f examples/pom.xml clean package` followed by `exec:java -Dexec.mainClass=com.demcha.examples.GenerateAllExamples`. Expect one `Generated:` line per example. 6b. **Run the external release-smoke suite** — once Central has indexed the train, dispatch the **Release Smoke** workflow ([`.github/workflows/release-smoke.yml`](../../.github/workflows/release-smoke.yml)) with `version=`, or run `bash scripts/release-smoke/run.sh --version `. This resolves every published coordinate from Maven Central in a clean, GraphCompose-evicted repository (no reactor / local install) and exercises the documented consumer scenarios — the wrapper renders PDF, `graph-compose-core` alone throws `MissingBackendException`, core+render-pdf renders, and templates/testing/bundle perform their roles. It is the authoritative "a real user can install and use this" check; the minimal step-5 snippet resolve is a faster subset. (Release smoke tests **published** artifacts, so it necessarily runs post-publish, not pre-tag.) -7. **Open the next development line** — `pwsh ./scripts/cut-release.ps1 -PostReleaseOnly`. This bumps the train poms to the next patch `-SNAPSHOT` (so develop builds are distinguishable from the release and the japicmp gate compares against it) **and** restores linkable "View Code" buttons by flipping ShowcaseMetadata back to `/blob/develop`. The README/showcase install snippets stay on the just-published release. +7. **Open the next development line** — `pwsh ./scripts/cut-release.ps1 -PostReleaseOnly`. This bumps the train poms to the next patch `-SNAPSHOT` (so develop builds are distinguishable from the release and the japicmp gate compares against it), moves the `graph-compose-templates` japicmp previous-release pin (`japicmp.baseline.previous` in `templates/pom.xml`) onto the release just published, **and** restores linkable "View Code" buttons by flipping ShowcaseMetadata back to `/blob/develop`. The README/showcase install snippets stay on the just-published release. 8. **GitHub Release — automated.** Pushing the `v` tag triggers [`.github/workflows/release.yml`](../../.github/workflows/release.yml): it re-runs `./mvnw clean verify` over the whole reactor against the tagged commit, then creates the Release with that version's CHANGELOG section as the body (hyphenated tags like `v1.7.0-rc.1` ship as pre-releases; the step is idempotent — it edits the notes if the Release already exists). The workflow titles it `GraphCompose v`; for a **minor** release, edit the title to add the codename (`v1.4`=cinematic, `v1.5`=intuitive, `v1.6`=expressive; patches drop it). Create the Release by hand (`gh release create v --notes-file `) only if the workflow is unavailable. 9. **Maven Central publish — automated (from v1.6.6).** The same `v` tag push triggers [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml): it re-runs `mvnw verify` at the tagged commit, signs each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) with the repo's GPG key, and uploads to Maven Central via the `central-publishing-maven-plugin`. Hyphenated tags (`-rc`, `-alpha`, `-beta`, `-snapshot`) are skipped — those go only to the GitHub Release pre-release surface. `autoPublish=false` in the plugin config means the artefact lands in the Central validation queue; the maintainer flips the switch on [central.sonatype.com](https://central.sonatype.com) for the first publish, then can opt into auto-release in a follow-up. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the workflow turns green). 10. **Optional**: GitHub Discussions announcement (mirror the prior release's style; close with *"author intent, not coordinates"*), LinkedIn post, r/java post. diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index a65f9c4d8..e25b1c3bc 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -223,6 +223,45 @@ function Update-PomVersion($pomPath, $newVersion) { } } +function Update-JapicmpPreviousBaseline($pomPath, $releasedVersion) { + # Moves the japicmp.baseline.previous property: the release the binary- + # compatibility gate diffs against alongside the major floor. The floor + # (japicmp.baseline.floor) holds the GA surface and only moves at a major; the + # previous pin holds every public element added SINCE the floor, and only while + # it names the release actually published last. Left behind, the gate keeps + # comparing against an older release, and everything added in the release just + # cut becomes freely removable with CI green. + # + # Runs in -PostReleaseOnly, next to the SNAPSHOT bump: on the release commit + # itself the just-cut version is not on Central yet, so the pin stays one release + # back until the publish workflow has uploaded it. Idempotent, and held to the + # CHANGELOG by VersionConsistencyGuardTest. A pom without the property (the + # engine pom, which gates against the floor alone) is left untouched. + if (-not (Test-Path $pomPath)) { + Note "skip (no file): $pomPath" + return $false + } + $content = [System.IO.File]::ReadAllText($pomPath) + $previousRegex = [regex]'[\w\.\-]+' + $match = $previousRegex.Match($content) + if (-not $match.Success) { + Note "no japicmp.baseline.previous property in $pomPath, nothing to move" + return $false + } + $new = '' + $releasedVersion + '' + if ($match.Value -eq $new) { + Note "japicmp previous baseline already $releasedVersion in $pomPath" + return $false + } + if ($DryRun) { + Write-Host " [DRY RUN] japicmp previous baseline: $pomPath to $releasedVersion" -ForegroundColor Yellow + return $true + } + [System.IO.File]::WriteAllText($pomPath, $previousRegex.Replace($content, $new, 1)) + Note "japicmp previous baseline: $pomPath now $releasedVersion" + return $true +} + function Update-AssetVersion($pomPath, $newVersion) { # Moves — the version the committed previews # under assets/readme were rendered at. CommittedAssetDriftTest renders at it to @@ -1227,6 +1266,7 @@ if ($PostReleaseOnly) { } $bumpedPoms = @() + $japicmpMoved = $false if ($nextSnapshot) { Step 3 "Open the next development line: bump train poms to $nextSnapshot" foreach ($pom in @('core/pom.xml', 'pom.xml', 'examples/pom.xml', 'benchmarks/pom.xml', @@ -1237,8 +1277,22 @@ if ($PostReleaseOnly) { $bumpedPoms += $pom } } + + # The release just cut is now the newest published one, so it becomes the + # japicmp previous-release baseline for the cycle that opens here. Only a + # pom that carries the pin is touched (graph-compose-templates today). + Step "3a" "Move the japicmp previous-release baseline to $currentVersion" + foreach ($pom in @('core/pom.xml', 'templates/pom.xml')) { + if (Update-JapicmpPreviousBaseline (Join-Path $repoRoot $pom) $currentVersion) { + $japicmpMoved = $true + if ($bumpedPoms -notcontains $pom) { $bumpedPoms += $pom } + } + } } else { - Step 3 "Skipped SNAPSHOT bump (no core/pom.xml, or the current version is not a final X.Y.Z release)" + # The same condition governs the japicmp previous-release pin: it may only + # move onto a final X.Y.Z, never onto a -SNAPSHOT or an -rc, since neither is + # on Central for the gate to resolve. + Step 3 "Skipped SNAPSHOT bump and japicmp baseline move (no core/pom.xml, or the current version is not a final X.Y.Z release)" } # Validate the bump BEFORE committing or pushing: a reactor `validate` resolves @@ -1298,6 +1352,7 @@ if ($PostReleaseOnly) { if ($filesToCommit.Count -gt 0) { $parts = @() if ($bumpedPoms.Count -gt 0) { $parts += "open $nextSnapshot" } + if ($japicmpMoved) { $parts += "japicmp previous baseline $currentVersion" } if ($showcaseChanged -or $DryRun) { $parts += "restore /blob/$Branch showcase links" } $msg = "chore(release): " + ($parts -join ' + ') Step 4 "Commit" @@ -1577,15 +1632,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/ + # 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. One reactor run diffs both: core against japicmp.baseline in + # core/pom.xml, templates against japicmp.baseline.floor and + # japicmp.baseline.previous in templates/pom.xml. 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. + # every baseline 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. 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 bab37cbe3..bad429c50 100644 --- a/templates/pom.xml +++ b/templates/pom.xml @@ -73,6 +73,22 @@ true + + + 0.26.2 + 2.0.0 + 2.3.0 @@ -262,5 +278,90 @@ + + + + japicmp + + + + com.github.siom79.japicmp + japicmp-maven-plugin + ${japicmp.version} + + + japicmp-against-major-floor + verify + + cmp + + + + + io.github.demchaav + graph-compose-templates + ${japicmp.baseline.floor} + + + + + + japicmp-against-previous-release + verify + + cmp + + + + + io.github.demchaav + graph-compose-templates + ${japicmp.baseline.previous} + + + + + + + + + ${project.build.directory}/${project.build.finalName}.jar + + + + true + true + false + false + true + true + + false + + @com.demcha.compose.document.api.Internal + + + + + + + From df1dfc5b18056a1caa4798c64ddf4133b7dde8b0 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 11 Sep 2026 16:37:12 +0100 Subject: [PATCH 2/6] ci(templates): make every path that runs the gate prove it ran japicmp writes no report for an execution it skips, and skipping fails nothing. A japicmp.skip property in a pom or .mvn/maven.config, an execution unbound from the lifecycle, or a profile left inactive each left the gate reporting success having diffed nothing, and the wiring guard only looked for a few named switches. The Binary Compatibility job, publish.yml and cut-release.ps1 Step 5b now end by checking that each execution the gate relies on left its XML report; Step 5b deletes those reports first, so one left by an earlier run cannot stand in. BinaryCompatibilityGateGuardTest requires the checks on all three paths, and also rejects japicmp.skip anywhere the build reads, overrideCompatibilityChangeParameters, continue-on-error on the job, the job losing `changes` from its needs, and a path that stops running the profile at verify. Its path-filter probe now sits inside a package, where the sources are. release-script-check.yml also runs when templates/pom.xml changes, since the pin the script moves lives there. The docs describe what the guard holds rather than what it would catch, name graph-compose-testing among the ungated modules, and state that the first cut of a new major fails the templates gate until it is decided how the gate runs for it. Comments that restated docs/api-stability.md now point at it. --- .github/workflows/ci.yml | 12 ++ .github/workflows/publish.yml | 18 ++- .github/workflows/release-script-check.yml | 9 +- CHANGELOG.md | 14 +- .../BinaryCompatibilityGateGuardTest.java | 150 +++++++++++++++--- .../ChangelogVersionParsingTest.java | 6 +- .../VersionConsistencyGuardTest.java | 12 +- docs/api-stability.md | 15 +- docs/contributing/release-process.md | 2 +- scripts/cut-release.ps1 | 51 +++--- templates/pom.xml | 18 +-- 11 files changed, 218 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae8d6579..d8de6d24f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -525,6 +525,18 @@ jobs: # surface. Source-incompatible changes are reported only (phased policy). run: ./mvnw -B -ntp -DskipTests -P japicmp verify -pl :graph-compose-core,:graph-compose-templates + - name: Prove every japicmp execution ran + # A japicmp execution that is skipped, unbound from the lifecycle or left + # out of the reactor writes no report and fails nothing, so the step above + # can pass having diffed nothing. Each execution the gate relies on must + # have left its XML report; BinaryCompatibilityGateGuardTest keeps this + # list in step with the poms. + run: | + ls -l core/target/japicmp templates/target/japicmp + test -s core/target/japicmp/japicmp-against-baseline.xml + test -s templates/target/japicmp/japicmp-against-major-floor.xml + test -s templates/target/japicmp/japicmp-against-previous-release.xml + - name: Upload japicmp report if: always() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1f4de7a8b..107592b0f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -143,15 +143,19 @@ jobs: run: ./mvnw -B -ntp -f core/pom.xml -P japicmp -Dmaven.test.skip=true verify - name: Verify templates binary compatibility against the published baselines - # The same defence for graph-compose-templates: its `japicmp` profile diffs - # the freshly built jar against both baselines pinned in templates/pom.xml - # (the 2.x floor and the previous release) and fails on any Stable-surface - # break. Every templates.* package is Stable per docs/api-stability.md, so - # only the per-element @Internal marker is excluded. Standalone pom: it - # resolves graph-compose-core from the local m2 the install step above - # seeded. + # The same defence for graph-compose-templates, against both baselines + # pinned in templates/pom.xml. Standalone pom: it resolves + # graph-compose-core from the local m2 the install step above seeded. run: ./mvnw -B -ntp -f templates/pom.xml -P japicmp -Dmaven.test.skip=true verify + - name: Prove every japicmp execution ran + # A skipped japicmp execution writes no report and fails nothing, so a + # missing report is the only sign the two steps above diffed nothing. + run: | + test -s core/target/japicmp/japicmp-against-baseline.xml + test -s templates/target/japicmp/japicmp-against-major-floor.xml + test -s templates/target/japicmp/japicmp-against-previous-release.xml + - name: Plan the deploy set (start_at resume) id: plan # A partial Central publication CANNOT be blindly re-dispatched: the deploys diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml index 96591d79d..e72053696 100644 --- a/.github/workflows/release-script-check.yml +++ b/.github/workflows/release-script-check.yml @@ -12,10 +12,13 @@ on: paths: - 'scripts/cut-release.ps1' - '.github/workflows/release-script-check.yml' + # Carries the japicmp previous-release pin the script moves. + - 'templates/pom.xml' pull_request: paths: - 'scripts/cut-release.ps1' - '.github/workflows/release-script-check.yml' + - 'templates/pom.xml' permissions: contents: read @@ -355,10 +358,8 @@ jobs: - name: Unit-check the japicmp previous-release baseline move shell: pwsh run: | - # graph-compose-templates is diffed against two published releases: the major - # floor, which never moves inside a major, and the previous release, which has - # to move onto 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 + # The japicmp previous-release pin has to move onto the version just published + # at every cut (docs/api-stability.md, Binary-compatibility enforcement), 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b69d9854a..7160b3d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -568,13 +568,13 @@ follow semantic versioning; release dates are ISO 8601. the publish workflow and the release script, and diffs each build against two published releases: the 2.x floor (`2.0.0`), which holds the GA surface, and the latest release (`2.3.0` today), which holds everything added since. `cut-release.ps1 -PostReleaseOnly` - moves the second pin after each release. `VersionConsistencyGuardTest` fails the build when - a pin goes stale; `BinaryCompatibilityGateGuardTest` fails it when an execution goes - missing, when the templates gate is narrowed beyond the per-element `@Internal` marker, or - when the pull-request job, the release script or the publish workflow stops diffing the - module. Every `templates.*` package is Stable, so that marker is the only exclusion, and - nothing carries it. A baseline the gate cannot resolve fails the build; japicmp's default - would skip that diff with a warning and pass. + moves the second pin after each release. Every `templates.*` package is Stable, so the only + exclusion is the per-element `@Internal` marker, and nothing carries it. A baseline the gate + cannot resolve fails the build; japicmp's default would skip that diff with a warning and + pass. Each path that runs the gate then checks that every execution left its report, since + a skipped execution writes none and fails nothing. `VersionConsistencyGuardTest` holds the + pins to the CHANGELOG, and `BinaryCompatibilityGateGuardTest` holds the executions, their + settings, the trigger and those report checks in place. ### Documentation diff --git a/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java index 78ce8c5d1..9772edc25 100644 --- a/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java @@ -11,8 +11,10 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -29,6 +31,11 @@ * reports success. Deleting a Stable method is caught by the diff; deleting the diff is * caught here.

* + *

A list of forbidden edits is never complete, so the paths that run the gate carry + * their own proof: each ends by checking that every execution left its XML report, which + * a skipped execution never writes, whatever skipped it. This class holds those checks in + * place, together with the switches that would bypass them before they run.

+ * *

The gated modules are discovered, as every module pom that declares a * {@code japicmp} profile, so a module joining the gate is held to the same wiring the * day it does. {@link #everyJapicmpExecutionTheGateReliesOnStillRuns} names the @@ -48,6 +55,9 @@ class BinaryCompatibilityGateGuardTest { /** The per-element marker: the one exclusion the templates gate may carry. */ private static final String INTERNAL_MARKER = "@com.demcha.compose.document.api.Internal"; + /** The property japicmp reads its skip switch from. */ + private static final String SKIP_PROPERTY = "japicmp.skip"; + /** * Each execution the gate relies on, per pom, with the baseline property its * {@code } must read. @@ -64,6 +74,9 @@ class BinaryCompatibilityGateGuardTest { /** A job-level {@code if:} — four-space indent, first line only. */ private static final Pattern JOB_IF = Pattern.compile("(?m)^ if: (.*)$"); + /** A job-level inline {@code needs: [a, b]} flow sequence. */ + private static final Pattern JOB_NEEDS = Pattern.compile("(?m)^ needs: \\[([^]]*)]"); + /** A {@code changes} output that a job condition tests. */ private static final Pattern CHANGE_OUTPUT = Pattern.compile("needs\\.changes\\.outputs\\.([A-Za-z_][\\w-]*) == 'true'"); @@ -82,9 +95,9 @@ class BinaryCompatibilityGateGuardTest { /** * An execution that is gone, unbound from {@code verify}, or pointed at another - * baseline stops a diff from running. A gate that reports a break without failing, or - * carries a skip switch, still runs and protects nothing. Every one of them leaves CI - * green. + * baseline stops a diff from running. A gate that reports a break without failing, + * carries a skip switch, or reclassifies a break as compatible still runs and protects + * nothing. Every one of them leaves CI green. */ @Test void everyJapicmpExecutionTheGateReliesOnStillRuns() throws Exception { @@ -121,6 +134,10 @@ void everyJapicmpExecutionTheGateReliesOnStillRuns() throws Exception { assertThat(descendantTexts(plugin, "skip")) .describedAs("%s: the japicmp gate must carry no skip switch", where) .isEmpty(); + assertThat(descendantTexts(plugin, "overrideCompatibilityChangeParameters")) + .describedAs("%s: overrideCompatibilityChangeParameters can declare a binary break " + + "compatible, which passes it", where) + .isEmpty(); } } @@ -170,13 +187,46 @@ void theTemplatesGateCoversTheWholeStableSurface() throws Exception { } /** - * The pull-request job diffs every gated module, and runs whenever one of them changes. + * Nothing the build reads sets japicmp's skip switch. + * + *

The plugin reads {@code skip} from the {@code japicmp.skip} property, so one line in + * a pom's {@code }, in {@code .mvn/maven.config}, or on a command line turns + * the executions off on every path that runs the gate. Those paths' report checks would + * still fail at run time; this names the cause before anything runs.

+ */ + @Test + void nothingTheBuildReadsSwitchesTheGateOff() throws Exception { + List readByTheBuild = new ArrayList<>(List.of( + PROJECT_ROOT.resolve("pom.xml"), CI, PUBLISH, RELEASE_SCRIPT)); + for (String module : gatedModules()) { + readByTheBuild.add(PROJECT_ROOT.resolve(module + "/pom.xml")); + } + for (String config : List.of(".mvn/maven.config", ".mvn/jvm.config")) { + Path file = PROJECT_ROOT.resolve(config); + if (Files.isRegularFile(file)) { + readByTheBuild.add(file); + } + } + + for (Path file : readByTheBuild) { + assertThat(read(file)) + .describedAs("%s must not mention %s: it switches japicmp's executions off, and a " + + "skipped execution fails nothing", relative(file), SKIP_PROPERTY) + .doesNotContain(SKIP_PROPERTY); + } + } + + /** + * The pull-request job diffs every gated module, runs whenever one of them changes, + * and proves each diff ran. * - *

Three edits each take a module off that path without failing anything: its + *

Each of these takes a module off that path without failing anything: its * artifact dropped from the job's {@code -pl} list; its paths dropped from the filter - * the job's condition reads; or that filter not exported from the {@code changes} - * job — an output that is not declared reads as empty, so the condition is never true - * for the module, and a skipped job passes.

+ * the job's condition reads; that filter not exported from the {@code changes} job, or + * {@code changes} dropped from the job's {@code needs} — an output the job cannot read + * is empty, so the condition is never true, and a skipped job passes; the job told to + * tolerate its own failure; or the report check that closes it removed, so an execution + * skipped for any other reason goes unnoticed.

*/ @Test void everyGatedModuleIsDiffedOnThePullRequestsThatTouchIt() throws Exception { @@ -191,9 +241,25 @@ void everyGatedModuleIsDiffedOnThePullRequestsThatTouchIt() throws Exception { Map exported = exportedOutputs(changes); Map> filters = pathFilters(changes); + assertThat(needsOf(job)) + .describedAs("ci.yml job '%s' must need 'changes': without it every " + + "needs.changes.outputs value reads empty, the condition is never true, and " + + "a job that never runs never fails", PR_JOB) + .contains("changes"); + assertThat(job) + .describedAs("ci.yml job '%s' must not tolerate its own failure", PR_JOB) + .doesNotContain("continue-on-error"); assertThat(invocation) - .describedAs("ci.yml job '%s' must not switch the gate off", PR_JOB) - .doesNotContain("japicmp.skip"); + .describedAs("ci.yml job '%s' must run verify, the phase the japicmp executions are " + + "bound to", PR_JOB) + .contains(" verify"); + for (String report : requiredReports()) { + assertThat(job) + .describedAs("ci.yml job '%s' must prove %s was written — a skipped japicmp " + + "execution writes no report and fails nothing", PR_JOB, report) + .contains("test -s " + report); + } + for (String module : gatedModules()) { String artifact = artifactIdOf(module); assertThat(invocation) @@ -221,19 +287,34 @@ void everyGatedModuleIsDiffedOnThePullRequestsThatTouchIt() throws Exception { /** * The release script diffs every gated module before the tag is cut, and the publish * workflow diffs each one on the tagged commit before it deploys — the two paths a - * direct push reaches without the pull-request job. + * direct push reaches without the pull-request job. Both run the profile at + * {@code verify}, and both end by proving every execution left its report. */ @Test void everyGatedModuleIsDiffedBeforeTheTagAndBeforeThePublish() throws Exception { - String releaseArgs = firstGroup(read(RELEASE_SCRIPT), RELEASE_GATE_ARGS, "scripts/cut-release.ps1"); - List publishGates = allGroups(read(PUBLISH), GATE_RUN); + String script = read(RELEASE_SCRIPT); + String publish = read(PUBLISH); + String releaseArgs = firstGroup(script, RELEASE_GATE_ARGS, "scripts/cut-release.ps1"); + List publishGates = allGroups(publish, GATE_RUN); + assertThat(publishGates) - .describedAs("publish.yml runs no japicmp gate before it deploys") + .describedAs("publish.yml must run the japicmp profile at verify, the phase its " + + "executions are bound to, before it deploys") .isNotEmpty() - .noneMatch(run -> run.contains("japicmp.skip")); + .allMatch(run -> run.contains(" verify")); assertThat(releaseArgs) - .describedAs("cut-release.ps1 Step 5b must not switch the gate off") - .doesNotContain("japicmp.skip"); + .describedAs("cut-release.ps1 Step 5b must activate the japicmp profile and run " + + "verify, the phase its executions are bound to") + .contains("'-P', 'japicmp'") + .contains("'verify'"); + for (String report : requiredReports()) { + assertThat(script) + .describedAs("cut-release.ps1 Step 5b must prove %s was written by its own run", report) + .contains("'" + report + "'"); + assertThat(publish) + .describedAs("publish.yml must prove %s was written before it deploys", report) + .contains("test -s " + report); + } for (String module : gatedModules()) { String artifact = artifactIdOf(module); @@ -246,6 +327,18 @@ void everyGatedModuleIsDiffedBeforeTheTagAndBeforeThePublish() throws Exception } } + /** The XML report each required execution writes: {@code /target/japicmp/.xml}. */ + private static List requiredReports() { + List reports = new ArrayList<>(); + for (Map.Entry> pom : REQUIRED_EXECUTIONS.entrySet()) { + String module = pom.getKey().substring(0, pom.getKey().indexOf('/')); + for (String execution : pom.getValue().keySet()) { + reports.add(module + "/target/japicmp/" + execution + ".xml"); + } + } + return reports; + } + /** Every module directory whose pom declares a {@code japicmp} profile, sorted. */ private static List gatedModules() throws Exception { List poms; @@ -314,6 +407,20 @@ private static String artifactIdOf(String module) throws Exception { return textOf(directChild(parse(PROJECT_ROOT.resolve(module + "/pom.xml")), "artifactId")); } + /** The job ids an inline {@code needs: [...]} names; empty when there is none. */ + private static Set needsOf(String jobBlock) { + Set ids = new LinkedHashSet<>(); + Matcher needs = JOB_NEEDS.matcher(jobBlock); + if (needs.find()) { + for (String id : needs.group(1).split(",")) { + if (!id.isBlank()) { + ids.add(id.trim()); + } + } + } + return ids; + } + /** Output name to the path filter it exports, from the {@code changes} job's {@code outputs:}. */ private static Map exportedOutputs(String changesJob) { Map exported = new LinkedHashMap<>(); @@ -341,9 +448,14 @@ private static Map> pathFilters(String changesJob) { return filters; } - /** Whether a change to {@code module}'s main sources, and one to its pom, both match a glob. */ + /** + * Whether a change to a source file deep inside {@code module}'s main tree, and one to + * its pom, both match a glob. The probe sits in a package, as every real source does: + * a filter that only reaches the top of {@code src/main/java} would pass a shallow probe + * while missing every file that matters. + */ private static boolean watches(List globs, String module) { - return matchesAny(globs, module + "/src/main/java/Probe.java") + return matchesAny(globs, module + "/src/main/java/com/demcha/compose/Probe.java") && matchesAny(globs, module + "/pom.xml"); } diff --git a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java index 595f17460..6f24e22f0 100644 --- a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java +++ b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java @@ -230,8 +230,10 @@ void onTheReleaseCommitThePreviousReleaseIsTheOneBeforeIt() { @Test void openAndPreReleaseEntriesNeverBecomeThePreviousRelease() { // A -rc entry is dated but never published to Central; a Planned entry is - // undated. Neither is an artifact the gate could resolve. - assertThat(previousReleaseFor("2.2.0-rc.2")).contains("2.1.1"); + // undated. Neither is an artifact the gate could resolve, even as the newest + // dated entry of the line. + assertThat(VersionConsistencyGuardTest.newestFinalReleaseInMajorBefore( + "## v2.3.0-rc.1 — 2026-09-01\n## v2.2.0 — 2026-08-15\n", "2.3.0-SNAPSHOT")).contains("2.2.0"); assertThat(previousReleaseFor("2.4.0-SNAPSHOT")).contains("2.2.0"); } diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index c5b4d4e27..6b2ee30e5 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -231,13 +231,11 @@ void japicmpPluginVersionAgreesAcrossGatedModules() throws Exception { /** * The two-baseline japicmp pins name the releases they have to. * - *

The major floor ({@code japicmp.baseline.floor}) is the first release of the - * working major and holds the GA surface. It cannot hold anything added later: a - * method first published in 2.2.0 is absent from 2.0.0 and from a 2.4.1 that - * deletes it, so that diff stays green while a caller compiled against 2.2.0 gets - * {@code NoSuchMethodError}. The previous-release pin - * ({@code japicmp.baseline.previous}) closes that hole — but only while it names - * the latest release actually published, and it is a literal that + *

The floor ({@code japicmp.baseline.floor}) must be the first release of the + * working major; why the gate also diffs against the latest release is set out in + * {@code docs/api-stability.md} (Binary-compatibility enforcement). That + * previous-release pin ({@code japicmp.baseline.previous}) only protects anything + * while it names the latest release actually published, and it is a literal that * {@code cut-release.ps1 -PostReleaseOnly} moves after each cut. A move that does * not happen leaves everything added in the release just shipped unprotected, with * the gate reporting green, so the pin is held to the CHANGELOG here: it must name diff --git a/docs/api-stability.md b/docs/api-stability.md index eecd62761..92b4c4b87 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -211,7 +211,7 @@ The Stable-tier promise (§ 1 — no binary breaks outside a major release) is e mechanically by [japicmp](https://siom79.github.io/japicmp/), run in a `japicmp` Maven profile during `verify` on the engine module (`graph-compose-core`) and on `graph-compose-templates`. The render backends (`graph-compose-render-pdf`, -`-render-docx`, `-render-pptx`) are not gated yet. +`-render-docx`, `-render-pptx`) and `graph-compose-testing` are not gated yet. - **Baselines:** the published artifacts on Maven Central. - `graph-compose-core` is diffed against the `japicmp.baseline` property in @@ -224,11 +224,9 @@ profile during `verify` on the engine module (`graph-compose-core`) and on (`japicmp.baseline.previous`). The floor holds the GA surface; the previous release holds everything added since, which a floor-only diff cannot protect — a member first published in `2.2.0` is absent from `2.0.0`. `cut-release.ps1 - -PostReleaseOnly` moves the previous pin to the release just published. + -PostReleaseOnly` moves the previous pin to the release just published, and `VersionConsistencyGuardTest` fails the build when a pin disagrees with the working - version and the CHANGELOG; `BinaryCompatibilityGateGuardTest` fails it when either - execution goes missing, or when the pull-request job, the release script or the - publish workflow stops diffing the module. + version and the CHANGELOG. - **What fails the build:** any binary-incompatible change to the public surface against a baseline — a removed or less-accessible public method/field/type or constructor, a changed signature, and so on. A deprecated element stays protected like @@ -237,9 +235,12 @@ profile during `verify` on the engine module (`graph-compose-core`) and on render-handoff payload records) are excluded; they carry no compatibility promise (§ 1). Every `templates.*` package is Stable (§ 4), so in `graph-compose-templates` only an element carrying the per-element `@Internal` marker is excluded — none does - today — and `BinaryCompatibilityGateGuardTest` fails the build if that gate is narrowed - any further. Source-only incompatibilities (e.g. adding a default method to an interface) + today. 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. +- **Where it runs:** the pull-request `Binary Compatibility` job, `cut-release.ps1` Step 5b + and the publish workflow. Each ends by checking that every execution left its report — + a skipped execution writes none and fails nothing — and `BinaryCompatibilityGateGuardTest` + holds the executions, their settings, the job's trigger and those checks in place. - **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. A diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index 3c4755f52..e94bd53b0 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,: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 with its baselines (each module's own `japicmp` profile: core against the 2.x floor, templates against the floor and the previous release) — 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 with its baselines (each module's own `japicmp` profile: core against the 2.x floor, templates against the floor and the previous release), or if an execution left no report — a skipped one writes none. A second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. On the first cut of a new major the templates baselines are not on Central yet, so this gate — and the publish workflow's — fails that cut until it is decided how the gate runs for it. Skipped by `-SkipVerify`. 5c. **Regenerate the knowledge pack** — `node knowledge/tools/api-surface/extract-api.mjs --from-reactor` rewrites `knowledge/api/*.json|md` and `knowledge/manifest.json` from the classes the verify just compiled, so the surfaces embed the just-bumped version (the extractor reads it from the root `pom.xml`). Then `node knowledge/tools/claims/check-claims.mjs --check` and `node knowledge/tools/routing/check-routes.mjs` run as gates, mirroring the tag-time gate in [`release.yml`](../../.github/workflows/release.yml) — a stale claims index or a broken route fails the cut here, with nothing committed, instead of failing the tag after it is pushed. **Not** skipped by `-SkipVerify` (Step 4's installs already compiled every module the extractor reads). If `node` is not on `PATH` the step is skipped with a loud warning — stop before the script's push step (or cut with `-SkipPush`), regenerate, and amend, or the tag fails its own gate. 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 eight 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/`, the regenerated `assets/readme/repository_showcase_render.png`, and `knowledge/` (the surfaces Step 5c just regenerated). `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"`). diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index e25b1c3bc..edc27068e 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -224,19 +224,13 @@ function Update-PomVersion($pomPath, $newVersion) { } function Update-JapicmpPreviousBaseline($pomPath, $releasedVersion) { - # Moves the japicmp.baseline.previous property: the release the binary- - # compatibility gate diffs against alongside the major floor. The floor - # (japicmp.baseline.floor) holds the GA surface and only moves at a major; the - # previous pin holds every public element added SINCE the floor, and only while - # it names the release actually published last. Left behind, the gate keeps - # comparing against an older release, and everything added in the release just - # cut becomes freely removable with CI green. - # - # Runs in -PostReleaseOnly, next to the SNAPSHOT bump: on the release commit - # itself the just-cut version is not on Central yet, so the pin stays one release - # back until the publish workflow has uploaded it. Idempotent, and held to the - # CHANGELOG by VersionConsistencyGuardTest. A pom without the property (the - # engine pom, which gates against the floor alone) is left untouched. + # Moves the japicmp.baseline.previous property onto the release just published + # (why the gate needs it: docs/api-stability.md, Binary-compatibility + # enforcement). Runs in -PostReleaseOnly, next to the SNAPSHOT bump: on the + # release commit itself the just-cut version is not on Central yet, so the pin + # stays one release back until the publish workflow has uploaded it. Idempotent, + # and held to the CHANGELOG by VersionConsistencyGuardTest. A pom without the + # property (the engine pom, which gates against the floor alone) is left untouched. if (-not (Test-Path $pomPath)) { Note "skip (no file): $pomPath" return $false @@ -1631,31 +1625,42 @@ try { } if (-not $SkipVerify) { - Step "5b" "Binary-compatibility gate (japicmp vs the published baseline)" + Step "5b" "Binary-compatibility gate (japicmp vs the published baselines)" # 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. One reactor run diffs both: core against japicmp.baseline in - # core/pom.xml, templates against japicmp.baseline.floor and - # japicmp.baseline.previous in templates/pom.xml. 2.0 module layout only (core/ - # present); the legacy 1.x single-artifact tree has no such gate. Precondition: - # every baseline 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. + # bypass. One reactor run diffs both. 2.0 module layout only (core/ present); the + # legacy 1.x single-artifact tree has no such gate. Precondition: every baseline is + # already on Central. On the first cut of a new major the templates baselines + # (X.0.0) are not, so this gate — and publish.yml's — fails that cut until it is + # decided how the gate runs for it. 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,:graph-compose-templates') + # A skipped execution writes no report and fails nothing, so every execution + # the gate relies on must leave its report from THIS run. + $japicmpReports = @('core/target/japicmp/japicmp-against-baseline.xml', + 'templates/target/japicmp/japicmp-against-major-floor.xml', + 'templates/target/japicmp/japicmp-against-previous-release.xml') if ($DryRun) { Write-Host " [DRY RUN] $mvnw $($japicmpArgs -join ' ')" -ForegroundColor Yellow } else { + foreach ($report in $japicmpReports) { + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $repoRoot $report) + } & $mvnw @japicmpArgs 2>&1 | ForEach-Object { if ($_ -match 'BUILD SUCCESS|BUILD FAILURE|ERROR|incompatib') { Write-Host " $_" -ForegroundColor DarkGray } } if ($LASTEXITCODE -ne 0) { - throw "japicmp gate failed: the tagged code breaks binary compatibility with the published baseline." + throw "japicmp gate failed: the tagged code breaks binary compatibility with the published baselines." + } + foreach ($report in $japicmpReports) { + if (-not (Test-Path (Join-Path $repoRoot $report))) { + throw "japicmp gate did not run: $report is missing, and a skipped execution writes no report." + } } - Note "japicmp: binary-compatible with the baseline OK" + Note "japicmp: binary-compatible with the baselines OK" } } else { Note "japicmp gate skipped (1.x single-artifact layout)" diff --git a/templates/pom.xml b/templates/pom.xml index bad429c50..0a469689c 100644 --- a/templates/pom.xml +++ b/templates/pom.xml @@ -74,18 +74,12 @@ true - + 0.26.2 2.0.0 2.3.0 From 4c795e259f2353d4055c0d80f8709c1ae0a2b0b1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 11 Sep 2026 16:52:18 +0100 Subject: [PATCH 3/6] ci(templates): say which skipped executions the report checks catch An execution that does not run - switched off, unbound from the lifecycle, or not selected - writes no japicmp report, and that is what the report checks rely on. A baseline japicmp cannot resolve is different: under the plugin's default it skips the diff with a warning and still writes a report. The templates gate fails on that case (ignoreMissingOldVersion=false); the engine gate does not yet. The comments, the docs and the CHANGELOG said that any skipped execution writes no report, which claimed the checks covered both. BinaryCompatibilityGateGuardTest also stops accepting the checks as mere text: each workflow report check has to be an uncommented `test -s` line, and Step 5b has to clear the reports, run the gate and then check them, in that order, so deleting either loop or commenting a check out now fails it. --- .github/workflows/ci.yml | 12 +-- .github/workflows/publish.yml | 4 +- CHANGELOG.md | 3 +- .../BinaryCompatibilityGateGuardTest.java | 81 ++++++++++++++----- docs/api-stability.md | 9 ++- docs/contributing/release-process.md | 2 +- scripts/cut-release.ps1 | 6 +- 7 files changed, 84 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8de6d24f..12c27a15d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -526,11 +526,13 @@ jobs: run: ./mvnw -B -ntp -DskipTests -P japicmp verify -pl :graph-compose-core,:graph-compose-templates - name: Prove every japicmp execution ran - # A japicmp execution that is skipped, unbound from the lifecycle or left - # out of the reactor writes no report and fails nothing, so the step above - # can pass having diffed nothing. Each execution the gate relies on must - # have left its XML report; BinaryCompatibilityGateGuardTest keeps this - # list in step with the poms. + # A japicmp execution that does not run — switched off, unbound from the + # lifecycle, or left out of the reactor — writes no report and fails + # nothing, so the step above can pass having diffed nothing. Each execution + # the gate relies on must have left its XML report; + # BinaryCompatibilityGateGuardTest keeps this list in step with the poms. + # (A baseline japicmp cannot resolve still leaves a report; the templates + # gate fails on it through ignoreMissingOldVersion=false.) run: | ls -l core/target/japicmp templates/target/japicmp test -s core/target/japicmp/japicmp-against-baseline.xml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 107592b0f..783c2edaf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -149,8 +149,8 @@ jobs: run: ./mvnw -B -ntp -f templates/pom.xml -P japicmp -Dmaven.test.skip=true verify - name: Prove every japicmp execution ran - # A skipped japicmp execution writes no report and fails nothing, so a - # missing report is the only sign the two steps above diffed nothing. + # A japicmp execution that does not run writes no report and fails nothing, + # so each one the gate relies on must have left its report. run: | test -s core/target/japicmp/japicmp-against-baseline.xml test -s templates/target/japicmp/japicmp-against-major-floor.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7160b3d4d..000d1348f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -572,7 +572,8 @@ follow semantic versioning; release dates are ISO 8601. exclusion is the per-element `@Internal` marker, and nothing carries it. A baseline the gate cannot resolve fails the build; japicmp's default would skip that diff with a warning and pass. Each path that runs the gate then checks that every execution left its report, since - a skipped execution writes none and fails nothing. `VersionConsistencyGuardTest` holds the + one that does not run — switched off, unbound, or not selected — writes none and fails + nothing. `VersionConsistencyGuardTest` holds the pins to the CHANGELOG, and `BinaryCompatibilityGateGuardTest` holds the executions, their settings, the trigger and those report checks in place. diff --git a/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java index 9772edc25..ae142843e 100644 --- a/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java @@ -32,9 +32,12 @@ * caught here.

* *

A list of forbidden edits is never complete, so the paths that run the gate carry - * their own proof: each ends by checking that every execution left its XML report, which - * a skipped execution never writes, whatever skipped it. This class holds those checks in - * place, together with the switches that would bypass them before they run.

+ * their own proof: each ends by checking that every execution left its XML report. An + * execution that does not run — switched off, unbound, or not selected — never writes + * one. A baseline japicmp cannot resolve is the exception: the plugin still writes a + * report for it, which is why the templates gate must fail on that case instead. This + * class holds those checks in place, together with the switches that would bypass them + * before they run.

* *

The gated modules are discovered, as every module pom that declares a * {@code japicmp} profile, so a module joining the gate is held to the same wiring the @@ -58,6 +61,9 @@ class BinaryCompatibilityGateGuardTest { /** The property japicmp reads its skip switch from. */ private static final String SKIP_PROPERTY = "japicmp.skip"; + /** How {@code cut-release.ps1} Step 5b runs the gate. */ + private static final String RELEASE_GATE_RUN = "& $mvnw @japicmpArgs"; + /** * Each execution the gate relies on, per pom, with the baseline property its * {@code } must read. @@ -93,6 +99,14 @@ class BinaryCompatibilityGateGuardTest { private static final Pattern RELEASE_GATE_ARGS = Pattern.compile("(?m)^\\s*\\$japicmpArgs = @\\((.*)\\)\\s*$"); + /** Step 5b's loop deleting the reports, so none left by an earlier run can stand in. */ + private static final Pattern REPORTS_CLEARED = Pattern.compile( + "(?m)^\\s*foreach \\(\\$report in \\$japicmpReports\\) \\{\\s*Remove-Item\\b"); + + /** Step 5b's loop that throws when a report is missing after the gate ran. */ + private static final Pattern REPORTS_CHECKED = Pattern.compile( + "(?m)^\\s*foreach \\(\\$report in \\$japicmpReports\\) \\{\\s*if \\(-not \\(Test-Path\\b[^\\n]*\\n\\s*throw\\b"); + /** * An execution that is gone, unbound from {@code verify}, or pointed at another * baseline stops a diff from running. A gate that reports a break without failing, @@ -146,8 +160,9 @@ void everyJapicmpExecutionTheGateReliesOnStillRuns() throws Exception { * *

japicmp defaults {@code ignoreMissingOldVersion} to {@code true}: a pin naming a * mistyped or unpublished version, or one the repository cannot serve, is logged as a - * warning, the diff is skipped, and the build passes having compared nothing. So the - * safe setting has to be present — its absence is the unsafe state — and + * warning, the diff is skipped, and the build passes having compared nothing — with a + * report written all the same, so the report checks cannot see it. So the safe + * setting has to be present — its absence is the unsafe state — and * {@code ignoreNonResolvableArtifacts}, which skips the same way, must not switch it * back.

*/ @@ -210,8 +225,8 @@ void nothingTheBuildReadsSwitchesTheGateOff() throws Exception { for (Path file : readByTheBuild) { assertThat(read(file)) - .describedAs("%s must not mention %s: it switches japicmp's executions off, and a " - + "skipped execution fails nothing", relative(file), SKIP_PROPERTY) + .describedAs("%s must not mention %s: it switches japicmp's executions off, and an " + + "execution that does not run fails nothing", relative(file), SKIP_PROPERTY) .doesNotContain(SKIP_PROPERTY); } } @@ -225,8 +240,8 @@ void nothingTheBuildReadsSwitchesTheGateOff() throws Exception { * the job's condition reads; that filter not exported from the {@code changes} job, or * {@code changes} dropped from the job's {@code needs} — an output the job cannot read * is empty, so the condition is never true, and a skipped job passes; the job told to - * tolerate its own failure; or the report check that closes it removed, so an execution - * skipped for any other reason goes unnoticed.

+ * tolerate its own failure; or a report check removed or commented out, so an + * execution that does not run for any other reason goes unnoticed.

*/ @Test void everyGatedModuleIsDiffedOnThePullRequestsThatTouchIt() throws Exception { @@ -254,10 +269,11 @@ void everyGatedModuleIsDiffedOnThePullRequestsThatTouchIt() throws Exception { + "bound to", PR_JOB) .contains(" verify"); for (String report : requiredReports()) { - assertThat(job) - .describedAs("ci.yml job '%s' must prove %s was written — a skipped japicmp " - + "execution writes no report and fails nothing", PR_JOB, report) - .contains("test -s " + report); + assertThat(reportCheck(report).matcher(job).find()) + .describedAs("ci.yml job '%s' must prove %s was written, as a `test -s` line of its " + + "own — an execution that does not run writes no report and fails nothing", + PR_JOB, report) + .isTrue(); } for (String module : gatedModules()) { @@ -288,7 +304,8 @@ void everyGatedModuleIsDiffedOnThePullRequestsThatTouchIt() throws Exception { * The release script diffs every gated module before the tag is cut, and the publish * workflow diffs each one on the tagged commit before it deploys — the two paths a * direct push reaches without the pull-request job. Both run the profile at - * {@code verify}, and both end by proving every execution left its report. + * {@code verify}, and both end by proving every execution left its report; the script + * deletes the reports first, so one left by an earlier run cannot stand in. */ @Test void everyGatedModuleIsDiffedBeforeTheTagAndBeforeThePublish() throws Exception { @@ -309,13 +326,31 @@ void everyGatedModuleIsDiffedBeforeTheTagAndBeforeThePublish() throws Exception .contains("'verify'"); for (String report : requiredReports()) { assertThat(script) - .describedAs("cut-release.ps1 Step 5b must prove %s was written by its own run", report) + .describedAs("cut-release.ps1 Step 5b must list %s among the reports it checks", report) .contains("'" + report + "'"); - assertThat(publish) - .describedAs("publish.yml must prove %s was written before it deploys", report) - .contains("test -s " + report); + assertThat(reportCheck(report).matcher(publish).find()) + .describedAs("publish.yml must prove %s was written before it deploys, as a " + + "`test -s` line of its own", report) + .isTrue(); } + int cleared = firstIndexOf(script, REPORTS_CLEARED); + int ran = script.indexOf(RELEASE_GATE_RUN); + int checked = firstIndexOf(script, REPORTS_CHECKED); + assertThat(cleared) + .describedAs("cut-release.ps1 Step 5b must delete the reports before the gate runs, " + + "or one left by an earlier run stands in for this one") + .isNotNegative(); + assertThat(checked) + .describedAs("cut-release.ps1 Step 5b must check the reports after the gate runs and " + + "throw when one is missing") + .isNotNegative(); + assertThat(ran) + .describedAs("cut-release.ps1 Step 5b must delete the reports, run the gate " + + "(%s), then check them — in that order", RELEASE_GATE_RUN) + .isGreaterThan(cleared) + .isLessThan(checked); + for (String module : gatedModules()) { String artifact = artifactIdOf(module); assertThat(releaseArgs) @@ -339,6 +374,11 @@ private static List requiredReports() { return reports; } + /** An uncommented shell line that fails unless {@code report} exists and is not empty. */ + private static Pattern reportCheck(String report) { + return Pattern.compile("(?m)^\\s+test -s " + Pattern.quote(report) + "\\s*$"); + } + /** Every module directory whose pom declares a {@code japicmp} profile, sorted. */ private static List gatedModules() throws Exception { List poms; @@ -503,6 +543,11 @@ private static List allGroups(String text, Pattern pattern) { return groups; } + private static int firstIndexOf(String text, Pattern pattern) { + Matcher matcher = pattern.matcher(text); + return matcher.find() ? matcher.start() : -1; + } + private static String read(Path file) throws IOException { return Files.readString(file).replace("\r\n", "\n"); } diff --git a/docs/api-stability.md b/docs/api-stability.md index 92b4c4b87..685f2cec4 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -238,9 +238,12 @@ profile during `verify` on the engine module (`graph-compose-core`) and on today. 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. - **Where it runs:** the pull-request `Binary Compatibility` job, `cut-release.ps1` Step 5b - and the publish workflow. Each ends by checking that every execution left its report — - a skipped execution writes none and fails nothing — and `BinaryCompatibilityGateGuardTest` - holds the executions, their settings, the job's trigger and those checks in place. + and the publish workflow. Each ends by checking that every execution left its report: an + execution that does not run — switched off, unbound, or not selected — writes none and + fails nothing. A baseline japicmp cannot resolve still leaves a report, so that case is + caught only where the gate fails on it — `graph-compose-templates`, as the next point + says. `BinaryCompatibilityGateGuardTest` holds the executions, their settings, the job's + trigger and those checks in place. - **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. A diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index e94bd53b0..331fc2163 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,: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 with its baselines (each module's own `japicmp` profile: core against the 2.x floor, templates against the floor and the previous release), or if an execution left no report — a skipped one writes none. A second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. On the first cut of a new major the templates baselines are not on Central yet, so this gate — and the publish workflow's — fails that cut until it is decided how the gate runs for it. 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 with its baselines (each module's own `japicmp` profile: core against the 2.x floor, templates against the floor and the previous release), or if an execution left no report — one that does not run writes none. A second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. On the first cut of a new major the templates baselines are not on Central yet, so this gate — and the publish workflow's — fails that cut until it is decided how the gate runs for it. Skipped by `-SkipVerify`. 5c. **Regenerate the knowledge pack** — `node knowledge/tools/api-surface/extract-api.mjs --from-reactor` rewrites `knowledge/api/*.json|md` and `knowledge/manifest.json` from the classes the verify just compiled, so the surfaces embed the just-bumped version (the extractor reads it from the root `pom.xml`). Then `node knowledge/tools/claims/check-claims.mjs --check` and `node knowledge/tools/routing/check-routes.mjs` run as gates, mirroring the tag-time gate in [`release.yml`](../../.github/workflows/release.yml) — a stale claims index or a broken route fails the cut here, with nothing committed, instead of failing the tag after it is pushed. **Not** skipped by `-SkipVerify` (Step 4's installs already compiled every module the extractor reads). If `node` is not on `PATH` the step is skipped with a loud warning — stop before the script's push step (or cut with `-SkipPush`), regenerate, and amend, or the tag fails its own gate. 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 eight 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/`, the regenerated `assets/readme/repository_showcase_render.png`, and `knowledge/` (the surfaces Step 5c just regenerated). `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"`). diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index edc27068e..7dcd4d404 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -1636,8 +1636,8 @@ try { # decided how the gate runs for it. 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,:graph-compose-templates') - # A skipped execution writes no report and fails nothing, so every execution - # the gate relies on must leave its report from THIS run. + # An execution that does not run writes no report and fails nothing, so every + # execution the gate relies on must leave its report from THIS run. $japicmpReports = @('core/target/japicmp/japicmp-against-baseline.xml', 'templates/target/japicmp/japicmp-against-major-floor.xml', 'templates/target/japicmp/japicmp-against-previous-release.xml') @@ -1657,7 +1657,7 @@ try { } foreach ($report in $japicmpReports) { if (-not (Test-Path (Join-Path $repoRoot $report))) { - throw "japicmp gate did not run: $report is missing, and a skipped execution writes no report." + throw "japicmp gate incomplete: $report is missing, so that execution did not run." } } Note "japicmp: binary-compatible with the baselines OK" From 99f07f733505ed7e9a04d34a336989d4a9400393 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 12 Sep 2026 01:17:42 +0100 Subject: [PATCH 4/6] ci(templates): keep a release from being its own compatibility baseline japicmp resolves a baseline pin that equals the module's own version to the artifact the build just produced. Measured on a simulated 3.0.0 cut with both pins at 3.0.0: it reported "No incompatible changes found while checking backward compatibility of version 3.0.0 with the previous version 3.0.0" and passed - from the reactor, and from a local repository the release had just been installed into, alike. The fallback that made both pins equal the version being released is the one the first cut of a new major would have taken. The pins now always name a published release strictly older than the working version. While a major has no release of its own - the X.0.0-SNAPSHOT cycle and the X.0.0 release commit - they name the previous major's floor and last release, and japicmp.break.binary is false: both diffs run and are reported, because a major is allowed to break. They become X.0.0, and the switch true, at the first -PostReleaseOnly after X.0.0 is published and dated in the CHANGELOG, so strict same-major enforcement resumes with X.0.1-SNAPSHOT. VersionConsistencyGuardTest derives floor, previous and the switch from the CHANGELOG, and rejects a pin that is not strictly older than the version being built. Two further lines of defence, neither of which replaces that rule: every path drops our cached artifacts from the local repository before the gate resolves, and publish.yml runs the gate - now one reactor run over core and templates - before the clean install that seeds the repository with the release being published. BinaryCompatibilityGateGuardTest holds those drops and that ordering, and docs/api-stability.md states the bootstrap policy the X.0.0 cycle, its publish and its first post-release bump each follow. --- .github/workflows/ci.yml | 9 ++ .github/workflows/publish.yml | 55 +++++---- CHANGELOG.md | 12 +- .../BinaryCompatibilityGateGuardTest.java | 99 ++++++++++++++-- .../ChangelogVersionParsingTest.java | 24 ++++ .../VersionConsistencyGuardTest.java | 111 ++++++++++++------ docs/api-stability.md | 35 ++++-- docs/contributing/release-process.md | 2 +- scripts/cut-release.ps1 | 13 ++ templates/pom.xml | 16 ++- 10 files changed, 290 insertions(+), 86 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12c27a15d..4e2bcef16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -515,6 +515,15 @@ jobs: - name: Install graph-compose-emoji (resolved at test scope by core) run: ./mvnw -B -ntp -f emoji/pom.xml -DskipTests install + - name: Drop cached GraphCompose artifacts before the gate resolves + # The gate must diff against the PUBLISHED baselines. Anything of ours + # already in the runner's cached repository could stand in for one, so + # the pinned coordinates are fetched from Central for this run. The + # modules under test come from the reactor, never from the repository. + run: | + rm -rf ~/.m2/repository/io/github/demchaav/graph-compose-core + rm -rf ~/.m2/repository/io/github/demchaav/graph-compose-templates + - name: Compare public API against baseline # Each module's `japicmp` profile resolves the published releases its # pom pins from Maven Central and diffs them against the freshly-built diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 783c2edaf..d3f03b642 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -119,34 +119,25 @@ jobs: gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} gpg-passphrase: MAVEN_GPG_PASSPHRASE - - name: Build, test and install to local m2 (verify + seed the deploys) - # Re-verify the tagged commit before publishing (defence in depth - # against a tag pushed from a broken branch). `install` — not `verify` — - # seeds the runner's local m2 with EVERY module artifact, including the - # core tests-jar that render-pdf depends on at test scope but the release - # profile never publishes. Each per-module `-P release deploy` below - # builds in isolation and resolves its inter-module deps (Maven resolves - # test-scope deps even with -DskipTests) from this local m2, so the - # unpublished tests-jar no longer fails the deploy. - run: ./mvnw -B -ntp clean install + - name: Drop cached GraphCompose artifacts before the gate resolves + # Nothing of ours may stand in for a published baseline, so the pinned + # coordinates are fetched from Central for this run. This runs before the + # install below for the same reason: the release being published must not + # be in the local repository while its own baselines resolve. + run: | + rm -rf ~/.m2/repository/io/github/demchaav/graph-compose-core + rm -rf ~/.m2/repository/io/github/demchaav/graph-compose-templates - - 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. - run: ./mvnw -B -ntp -f core/pom.xml -P japicmp -Dmaven.test.skip=true verify - - - name: Verify templates binary compatibility against the published baselines - # The same defence for graph-compose-templates, against both baselines - # pinned in templates/pom.xml. Standalone pom: it resolves - # graph-compose-core from the local m2 the install step above seeded. - run: ./mvnw -B -ntp -f templates/pom.xml -P japicmp -Dmaven.test.skip=true verify + # 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). One reactor run + # diffs graph-compose-core and graph-compose-templates against the releases + # their poms pin, exactly as the pull-request job does; templates resolves + # the engine from the reactor rather than from the local repository, which + # is what lets this run before the install below. + run: ./mvnw -B -ntp -DskipTests -P japicmp verify -pl :graph-compose-core,:graph-compose-templates - name: Prove every japicmp execution ran # A japicmp execution that does not run writes no report and fails nothing, @@ -156,6 +147,18 @@ jobs: test -s templates/target/japicmp/japicmp-against-major-floor.xml test -s templates/target/japicmp/japicmp-against-previous-release.xml + - name: Build, test and install to local m2 (verify + seed the deploys) + # Re-verify the tagged commit before publishing (defence in depth + # against a tag pushed from a broken branch). `install` — not `verify` — + # seeds the runner's local m2 with EVERY module artifact, including the + # core tests-jar that render-pdf depends on at test scope but the release + # profile never publishes. Each per-module `-P release deploy` below + # builds in isolation and resolves its inter-module deps (Maven resolves + # test-scope deps even with -DskipTests) from this local m2, so the + # unpublished tests-jar no longer fails the deploy. It runs after the gate + # above, so the artifacts it installs cannot serve as baselines. + run: ./mvnw -B -ntp clean install + - 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 000d1348f..568900e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -573,9 +573,15 @@ follow semantic versioning; release dates are ISO 8601. cannot resolve fails the build; japicmp's default would skip that diff with a warning and pass. Each path that runs the gate then checks that every execution left its report, since one that does not run — switched off, unbound, or not selected — writes none and fails - nothing. `VersionConsistencyGuardTest` holds the - pins to the CHANGELOG, and `BinaryCompatibilityGateGuardTest` holds the executions, their - settings, the trigger and those report checks in place. + nothing. A pin may never name the version being built: japicmp resolves such a pin to the + artifact the build just produced and reports no differences, so both pins stay strictly + older than the working version, every path drops our cached artifacts before it resolves, + and the publish workflow runs the gate before the `install` that seeds the repository. + While a major has no release of its own, the pins name the previous major's floor and last + release and `japicmp.break.binary` is `false` — those diffs are reported, not enforced — + until the first post-release bump after `X.0.0` ships. `VersionConsistencyGuardTest` holds + the pins and that switch to the CHANGELOG, and `BinaryCompatibilityGateGuardTest` holds the + executions, their settings, the trigger, the report checks and the publish ordering in place. ### Documentation diff --git a/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java index ae142843e..4e8d3fa1c 100644 --- a/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/BinaryCompatibilityGateGuardTest.java @@ -35,9 +35,14 @@ * their own proof: each ends by checking that every execution left its XML report. An * execution that does not run — switched off, unbound, or not selected — never writes * one. A baseline japicmp cannot resolve is the exception: the plugin still writes a - * report for it, which is why the templates gate must fail on that case instead. This - * class holds those checks in place, together with the switches that would bypass them - * before they run.

+ * report for it, which is why the templates gate must fail on that case instead.

+ * + *

The other half is where the baseline comes from. japicmp resolves a pin that equals + * the module's own version to the artifact this build just produced — measured, from the + * reactor and from the local repository alike — so the pins are held strictly older than + * the working version by {@code VersionConsistencyGuardTest}, every path drops our + * cached artifacts before it resolves, and the publish workflow runs the gate before it + * installs the release it is about to publish.

* *

The gated modules are discovered, as every module pom that declares a * {@code japicmp} profile, so a module joining the gate is held to the same wiring the @@ -61,9 +66,16 @@ class BinaryCompatibilityGateGuardTest { /** The property japicmp reads its skip switch from. */ private static final String SKIP_PROPERTY = "japicmp.skip"; + /** The property the templates gate reads its break-the-build setting from. */ + private static final String BREAK_PROPERTY = "${japicmp.break.binary}"; + /** How {@code cut-release.ps1} Step 5b runs the gate. */ private static final String RELEASE_GATE_RUN = "& $mvnw @japicmpArgs"; + /** The artifacts a gate path must drop from the local repository before it resolves. */ + private static final List CACHED_ARTIFACTS = + List.of("graph-compose-core", "graph-compose-templates"); + /** * Each execution the gate relies on, per pom, with the baseline property its * {@code } must read. @@ -77,6 +89,10 @@ class BinaryCompatibilityGateGuardTest { /** A {@code run:} line that invokes the gate. */ private static final Pattern GATE_RUN = Pattern.compile("(?m)^\\s+run: (.*-P japicmp.*)$"); + /** The publish workflow's install step, which seeds the repository with this release. */ + private static final Pattern PUBLISH_INSTALL = + Pattern.compile("(?m)^\\s+run: \\./mvnw .*clean install\\s*$"); + /** A job-level {@code if:} — four-space indent, first line only. */ private static final Pattern JOB_IF = Pattern.compile("(?m)^ if: (.*)$"); @@ -107,6 +123,10 @@ class BinaryCompatibilityGateGuardTest { private static final Pattern REPORTS_CHECKED = Pattern.compile( "(?m)^\\s*foreach \\(\\$report in \\$japicmpReports\\) \\{\\s*if \\(-not \\(Test-Path\\b[^\\n]*\\n\\s*throw\\b"); + /** Step 5b's loop dropping the pinned baselines from the local repository. */ + private static final Pattern BASELINES_DROPPED = Pattern.compile( + "(?m)^\\s*foreach \\(\\$baseline in \\$japicmpBaselines\\) \\{[^\\n]*\\n[^\\n]*\\n\\s*Remove-Item\\b"); + /** * An execution that is gone, unbound from {@code verify}, or pointed at another * baseline stops a diff from running. A gate that reports a break without failing, @@ -141,10 +161,11 @@ void everyJapicmpExecutionTheGateReliesOnStillRuns() throws Exception { } assertThat(descendantTexts(plugin, "breakBuildOnBinaryIncompatibleModifications")) - .describedAs("%s: the japicmp gate must fail the build on a binary break, " - + "everywhere it is configured — reporting one protects nothing", where) + .describedAs("%s: the japicmp gate must fail the build on a binary break — either " + + "always (true) or through %s, whose value VersionConsistencyGuardTest " + + "derives from the CHANGELOG. Reporting one protects nothing", where, BREAK_PROPERTY) .isNotEmpty() - .containsOnly("true"); + .allMatch(value -> value.equals("true") || value.equals(BREAK_PROPERTY)); assertThat(descendantTexts(plugin, "skip")) .describedAs("%s: the japicmp gate must carry no skip switch", where) .isEmpty(); @@ -231,6 +252,65 @@ void nothingTheBuildReadsSwitchesTheGateOff() throws Exception { } } + /** + * No path lets an artifact of ours stand in for a published baseline. + * + *

japicmp resolves a pin equal to the module's own version to the artifact the + * build just produced, from the reactor as readily as from the local repository, and + * then reports no differences. {@code VersionConsistencyGuardTest} keeps the pins + * strictly older than the working version, which is what makes that impossible; these + * are the second line. Every path drops our cached artifacts before it resolves, so a + * baseline comes from Central rather than from a copy sitting in the repository, and + * the publish workflow runs the gate before the install that seeds the + * repository with the release it is about to publish.

+ */ + @Test + void noPathLetsThisBuildSupplyItsOwnBaseline() throws Exception { + Map jobs = CiGateCoverageGuardTest.jobBlocks(Files.readString(CI)); + String job = jobs.get(PR_JOB); + assertThat(job).describedAs("ci.yml has no '%s' job", PR_JOB).isNotNull(); + String publish = read(PUBLISH); + String script = read(RELEASE_SCRIPT); + + for (String artifact : CACHED_ARTIFACTS) { + assertThat(dropsCached(artifact).matcher(job).find()) + .describedAs("ci.yml job '%s' must drop the cached %s before the gate resolves its " + + "baselines, so a copy in the repository cannot stand in for the published " + + "release", PR_JOB, artifact) + .isTrue(); + assertThat(dropsCached(artifact).matcher(publish).find()) + .describedAs("publish.yml must drop the cached %s before the gate resolves its " + + "baselines", artifact) + .isTrue(); + } + + int drops = firstIndexOf(job, dropsCached(CACHED_ARTIFACTS.get(0))); + int diffs = firstIndexOf(job, GATE_RUN); + assertThat(drops) + .describedAs("ci.yml job '%s' must drop the cached artifacts before it runs the gate, " + + "not after", PR_JOB) + .isLessThan(diffs); + + int publishDrops = firstIndexOf(publish, dropsCached(CACHED_ARTIFACTS.get(0))); + int publishDiffs = firstIndexOf(publish, GATE_RUN); + int publishInstalls = firstIndexOf(publish, PUBLISH_INSTALL); + assertThat(publishDiffs) + .describedAs("publish.yml must run the japicmp gate BEFORE `clean install`: that install " + + "puts the release being published into the local repository, where a pin equal " + + "to it would resolve, and the gate would compare the release with itself") + .isGreaterThan(publishDrops) + .isLessThan(publishInstalls); + + int scriptDrops = firstIndexOf(script, BASELINES_DROPPED); + int scriptRuns = script.indexOf(RELEASE_GATE_RUN); + assertThat(scriptDrops) + .describedAs("cut-release.ps1 Step 5b must drop the pinned baselines from the local " + + "repository before it runs the gate — Step 4 installed the just-bumped version " + + "there") + .isNotNegative() + .isLessThan(scriptRuns); + } + /** * The pull-request job diffs every gated module, runs whenever one of them changes, * and proves each diff ran. @@ -358,7 +438,7 @@ void everyGatedModuleIsDiffedBeforeTheTagAndBeforeThePublish() throws Exception .contains(":" + artifact); assertThat(publishGates) .describedAs("publish.yml must diff %s on the tagged commit before it deploys", artifact) - .anyMatch(run -> run.contains("-f " + module + "/pom.xml")); + .anyMatch(run -> run.contains(":" + artifact)); } } @@ -379,6 +459,11 @@ private static Pattern reportCheck(String report) { return Pattern.compile("(?m)^\\s+test -s " + Pattern.quote(report) + "\\s*$"); } + /** An uncommented shell line that deletes our cached {@code artifact} from the local repository. */ + private static Pattern dropsCached(String artifact) { + return Pattern.compile("(?m)^\\s+rm -rf [^\\n]*/io/github/demchaav/" + Pattern.quote(artifact) + "\\s*$"); + } + /** Every module directory whose pom declares a {@code japicmp} profile, sorted. */ private static List gatedModules() throws Exception { List poms; diff --git a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java index 6f24e22f0..7ebaf4335 100644 --- a/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java +++ b/core/src/test/java/com/demcha/documentation/ChangelogVersionParsingTest.java @@ -289,6 +289,30 @@ void leadingZerosDoNotChangeTheOrder() { "## v2.02.0 — 2026-08-15\n## v2.1.0 — 2026-07-01\n", "2.3.0-SNAPSHOT")).contains("2.02.0"); } + // ── What a major with no release of its own falls back to ────── + + @Test + void openingAMajorFallsBackToTheLastReleaseOfThePreviousOne() { + // 3.0.0-SNAPSHOT and the 3.0.0 release commit have no in-major release to diff + // against, so the pins name the previous major's last release and its floor. + assertThat(previousReleaseFor("3.0.0-SNAPSHOT")).isEmpty(); + assertThat(VersionConsistencyGuardTest.newestFinalReleaseOlderThan(RELEASES, "3.0.0-SNAPSHOT")) + .contains("2.2.0"); + assertThat(VersionConsistencyGuardTest.newestFinalReleaseOlderThan(RELEASES, "3.0.0")) + .contains("2.2.0"); + } + + @Test + void theFallbackNeverNamesTheVersionBeingBuilt() { + // A pin equal to the working version resolves the module's own artifact, so the + // build compares itself and passes. Even with 3.0.0 dated in the log, the + // fallback for a 3.0.0 build stays on the previous major. + assertThat(VersionConsistencyGuardTest.newestFinalReleaseOlderThan( + RELEASES + "\n## v3.0.0 — 2026-10-01\n", "3.0.0")).contains("2.2.0"); + assertThat(VersionConsistencyGuardTest.newestFinalReleaseOlderThan( + "## v2.0.0 — 2026-07-01\n", "2.0.0")).isEmpty(); + } + 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 6b2ee30e5..6829bf542 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -229,32 +229,34 @@ void japicmpPluginVersionAgreesAcrossGatedModules() throws Exception { private static final List TWO_BASELINE_JAPICMP_POMS = List.of("templates/pom.xml"); /** - * The two-baseline japicmp pins name the releases they have to. + * The two-baseline japicmp pins name published releases, and never the one being built. * - *

The floor ({@code japicmp.baseline.floor}) must be the first release of the - * working major; why the gate also diffs against the latest release is set out in - * {@code docs/api-stability.md} (Binary-compatibility enforcement). That - * previous-release pin ({@code japicmp.baseline.previous}) only protects anything - * while it names the latest release actually published, and it is a literal that - * {@code cut-release.ps1 -PostReleaseOnly} moves after each cut. A move that does - * not happen leaves everything added in the release just shipped unprotected, with - * the gate reporting green, so the pin is held to the CHANGELOG here: it must name - * the newest dated final release of the working major strictly older than - * the working version. That reading is right on both sides of a cut — on the - * release commit (pom {@code 2.4.0}, CHANGELOG {@code ## v2.4.0 — }) the - * newest release older than 2.4.0 is still 2.3.0, which is what the release must - * be diffed against; on the next {@code 2.4.1-SNAPSHOT} it is 2.4.0, so a bump - * commit that forgets the pin fails here, and {@code -PostReleaseOnly} runs this - * test before it commits.

+ *

Inside a live major the floor ({@code japicmp.baseline.floor}) is that major's + * first release and the previous pin ({@code japicmp.baseline.previous}) the newest + * release below the working version; why the gate needs both is set out in + * {@code docs/api-stability.md} (Binary-compatibility enforcement). The previous pin + * only protects anything while it names the release actually published, and it is a + * literal that {@code cut-release.ps1 -PostReleaseOnly} moves after each cut, so it is + * derived from the CHANGELOG here rather than trusted. That reading is right on both + * sides of a cut — on the release commit (pom {@code 2.4.0}, CHANGELOG + * {@code ## v2.4.0 — }) the newest release below 2.4.0 is still 2.3.0, which is + * what the release must be diffed against; on the next {@code 2.4.1-SNAPSHOT} it is + * 2.4.0, so a bump commit that forgets the pin fails here, and + * {@code -PostReleaseOnly} runs this test before it commits.

* - *

Both pins stay inside the working major. On {@code 3.0.0-SNAPSHOT} the floor - * is {@code 3.0.0} and the major has no release yet, so the previous pin falls back - * to the floor: both then name a version nobody has published, and the templates - * gate, set not to skip a baseline it cannot resolve, fails until 3.0.0 is on Central. - * Pinning across the boundary instead would fail it on every break the major is - * allowed to make. Either way, opening a major means deciding how the gate runs for - * that cycle; a gate that went quiet by itself at the boundary would go just as - * quiet if a pin broke for any other reason.

+ *

A major with no release of its own — the cycle that opens it, and its own release + * commit — has nothing in-major to diff against. The pins then name the previous + * major's floor and its last release, and {@code japicmp.break.binary} is + * {@code false}: those diffs are reported rather than enforced, because a major is + * allowed to break. All three flip back on the first post-release bump, once the new + * major's first release is published and dated in the CHANGELOG.

+ * + *

In both states every pin stays strictly older than the working version, + * which is what stops a build from checking itself. Measured on a simulated 3.0.0 cut + * with both pins at 3.0.0: japicmp resolved the module's own artifact and reported "No + * incompatible changes found while checking backward compatibility of version 3.0.0 + * with the previous version 3.0.0" — from the reactor, and from a local repository the + * release had just been installed into, alike.

*/ @Test void japicmpBaselinesTrackTheWorkingMajorAndTheLatestRelease() throws Exception { @@ -263,21 +265,40 @@ void japicmpBaselinesTrackTheWorkingMajorAndTheLatestRelease() throws Exception for (String pom : TWO_BASELINE_JAPICMP_POMS) { Path path = PROJECT_ROOT.resolve(pom); String working = effectiveVersion(path); - String floor = releaseLineOf(working).split("\\.")[0] + ".0.0"; + Optional inMajor = newestFinalReleaseInMajorBefore(changelog, working); + String previous = inMajor.orElseGet(() -> newestFinalReleaseOlderThan(changelog, working) + .orElseThrow(() -> new AssertionError( + "CHANGELOG.md names no dated final release older than " + working + ", so " + + pom + " has no published release to use as a baseline"))); + String floor = segment(previous, 0) + ".0.0"; + String breakBinary = inMajor.isPresent() ? "true" : "false"; assertThat(pinnedVersionProperty(path, "japicmp.baseline.floor")) - .describedAs("%s japicmp.baseline.floor must be the first release of the working " - + "major (%s for working version %s)", pom, floor, working) + .describedAs("%s japicmp.baseline.floor must be %s: the first release of the major " + + "the working version (%s) belongs to, or of the previous major while this " + + "one has no release of its own", pom, floor, working) .isEqualTo(floor); - - String expected = newestFinalReleaseInMajorBefore(changelog, working).orElse(floor); assertThat(pinnedVersionProperty(path, "japicmp.baseline.previous")) - .describedAs("%s japicmp.baseline.previous must be the newest dated CHANGELOG release " - + "of the working major older than the working version %s (the floor itself " - + "while the major has none) — cut-release.ps1 -PostReleaseOnly moves it to " - + "the version just published; a stale pin leaves everything added in that " - + "release unprotected by the japicmp gate", pom, working) - .isEqualTo(expected); + .describedAs("%s japicmp.baseline.previous must be %s: the newest dated CHANGELOG " + + "release below the working version %s — cut-release.ps1 -PostReleaseOnly " + + "moves it to the version just published; a stale pin leaves everything " + + "added in that release unprotected by the japicmp gate", pom, previous, working) + .isEqualTo(previous); + assertThat(pinnedVersionProperty(path, "japicmp.break.binary")) + .describedAs("%s japicmp.break.binary must be %s: the gate breaks the build inside a " + + "major, and only reports while the major that opens has no release of its " + + "own to diff against", pom, breakBinary) + .isEqualTo(breakBinary); + + for (String pin : List.of("japicmp.baseline.floor", "japicmp.baseline.previous")) { + String pinned = pinnedVersionProperty(path, pin); + assertThat(compareReleases(pinned, releaseLineOf(working))) + .describedAs("%s %s is %s, the version being built (%s). japicmp resolves such a " + + "pin to this build's own artifact — from the reactor as readily as from " + + "the local repository — and reports no differences, so the release would " + + "check itself and pass", pom, pin, pinned, working) + .isNegative(); + } } } @@ -308,6 +329,26 @@ && compareReleases(candidate, working) < 0 return Optional.ofNullable(newest); } + /** + * The newest dated final release in {@code changelog} strictly older than + * {@code version}, whatever its major; empty when there is none. This is what a major + * with no release of its own falls back to: the previous major's last release. Never + * the working version itself, which japicmp would resolve to this build's own artifact. + */ + static Optional newestFinalReleaseOlderThan(String changelog, String version) { + String working = releaseLineOf(version); + Matcher released = DATED_FINAL_RELEASE.matcher(changelog); + String newest = null; + while (released.find()) { + String candidate = released.group(1); + if (compareReleases(candidate, working) < 0 + && (newest == null || compareReleases(candidate, newest) > 0)) { + newest = candidate; + } + } + return Optional.ofNullable(newest); + } + /** * Orders two {@code X.Y.Z} release lines, comparing each segment as a number. * diff --git a/docs/api-stability.md b/docs/api-stability.md index 685f2cec4..d187dbd9c 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -244,16 +244,31 @@ profile during `verify` on the engine module (`graph-compose-core`) and on caught only where the gate fails on it — `graph-compose-templates`, as the next point says. `BinaryCompatibilityGateGuardTest` holds the executions, their settings, the job's trigger and those checks in place. -- **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. A - baseline `graph-compose-templates` cannot resolve fails the build rather than skipping - its diff (japicmp's default, which the engine gate still runs with, skips it with a - warning), so the first cycle of a new major, whose floor is unpublished until its - release, needs an explicit decision about how that gate runs. - -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. +- **Where a baseline comes from:** a published release, never this build. japicmp resolves + a pin equal to the module's own version to the artifact the build just produced — + measured on a simulated `3.0.0` cut, where it reported *"No incompatible changes found + while checking backward compatibility of version 3.0.0 with the previous version + 3.0.0"*, from the reactor and from a local repository the release had been installed + into alike. So both pins are held **strictly older** than the working version + (`VersionConsistencyGuardTest`), every path drops our cached artifacts from the local + repository before it resolves, and the publish workflow runs the gate *before* the + `install` that seeds that repository with the release being published. +- **Activity window:** active for every `-SNAPSHOT` cycle and every release commit of a + major that has a published release of its own; a `graph-compose-templates` baseline that + cannot be resolved fails the build rather than skipping its diff (japicmp's default, + which the engine gate still runs with, skips it with a warning). +- **Opening a major:** while major `X` has no release of its own — the whole + `X.0.0-SNAPSHOT` cycle *and* the `X.0.0` release commit — there is nothing in-major to + diff against. `graph-compose-templates` then pins the **previous** major's floor and its + last release, and `japicmp.break.binary` is `false`: both diffs run and are reported, and + a break does not fail the build, because a major is allowed to break. That is the posture + the 2.0 transition ran under. The `X.0.0` publish workflow therefore checks those same two + diffs, report-only, against releases already on Central — it never waits for `X.0.0` + itself and never compares `X.0.0` with itself. The pins become `X.0.0` and + `japicmp.break.binary` returns to `true` at the first `-PostReleaseOnly` after `X.0.0` is + published and dated in the CHANGELOG, so strict same-major enforcement resumes with the + first `X.0.1-SNAPSHOT` build. `VersionConsistencyGuardTest` derives all three values from + the CHANGELOG and fails the build until the poms match them. --- diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index 331fc2163..e8367ab7a 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,: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 with its baselines (each module's own `japicmp` profile: core against the 2.x floor, templates against the floor and the previous release), or if an execution left no report — one that does not run writes none. A second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. On the first cut of a new major the templates baselines are not on Central yet, so this gate — and the publish workflow's — fails that cut until it is decided how the gate runs for it. 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 with its baselines (each module's own `japicmp` profile: core against the 2.x floor, templates against the floor and the previous release), or if an execution left no report — one that does not run writes none. A second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. Step 5b first drops the pinned baselines from the local repository — Step 4 installed the just-bumped version there, and japicmp resolves a baseline from the local repository as readily as from Central. On the cut that opens a new major the templates pins name the previous major's floor and last release with `japicmp.break.binary=false`, so both diffs are reported without blocking the cut (see *Opening a major* in [`docs/api-stability.md`](../api-stability.md)). Skipped by `-SkipVerify`. 5c. **Regenerate the knowledge pack** — `node knowledge/tools/api-surface/extract-api.mjs --from-reactor` rewrites `knowledge/api/*.json|md` and `knowledge/manifest.json` from the classes the verify just compiled, so the surfaces embed the just-bumped version (the extractor reads it from the root `pom.xml`). Then `node knowledge/tools/claims/check-claims.mjs --check` and `node knowledge/tools/routing/check-routes.mjs` run as gates, mirroring the tag-time gate in [`release.yml`](../../.github/workflows/release.yml) — a stale claims index or a broken route fails the cut here, with nothing committed, instead of failing the tag after it is pushed. **Not** skipped by `-SkipVerify` (Step 4's installs already compiled every module the extractor reads). If `node` is not on `PATH` the step is skipped with a loud warning — stop before the script's push step (or cut with `-SkipPush`), regenerate, and amend, or the tag fails its own gate. 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 eight 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/`, the regenerated `assets/readme/repository_showcase_render.png`, and `knowledge/` (the surfaces Step 5c just regenerated). `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"`). diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index 7dcd4d404..f16ca3f1a 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -1641,12 +1641,25 @@ try { $japicmpReports = @('core/target/japicmp/japicmp-against-baseline.xml', 'templates/target/japicmp/japicmp-against-major-floor.xml', 'templates/target/japicmp/japicmp-against-previous-release.xml') + # Step 4 installed the just-bumped version into the local repository, so a + # baseline is dropped from there before the gate resolves it: the diff must + # be against the release on Central, never against what this cut built. + $corePomText = [System.IO.File]::ReadAllText((Join-Path $repoRoot 'core/pom.xml')) + $templatesPomText = [System.IO.File]::ReadAllText((Join-Path $repoRoot 'templates/pom.xml')) + $japicmpBaselines = @( + 'graph-compose-core/' + [regex]::Match($corePomText, '([^<]+)<').Groups[1].Value, + 'graph-compose-templates/' + [regex]::Match($templatesPomText, '([^<]+)<').Groups[1].Value, + 'graph-compose-templates/' + [regex]::Match($templatesPomText, '([^<]+)<').Groups[1].Value) if ($DryRun) { Write-Host " [DRY RUN] $mvnw $($japicmpArgs -join ' ')" -ForegroundColor Yellow } else { foreach ($report in $japicmpReports) { Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $repoRoot $report) } + foreach ($baseline in $japicmpBaselines) { + $cached = Join-Path $env:USERPROFILE ('.m2/repository/io/github/demchaav/' + $baseline) + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cached + } & $mvnw @japicmpArgs 2>&1 | ForEach-Object { if ($_ -match 'BUILD SUCCESS|BUILD FAILURE|ERROR|incompatib') { Write-Host " $_" -ForegroundColor DarkGray diff --git a/templates/pom.xml b/templates/pom.xml index 0a469689c..65714b098 100644 --- a/templates/pom.xml +++ b/templates/pom.xml @@ -76,13 +76,21 @@ + VersionConsistencyGuardTest derives both pins and japicmp.break.binary from + the working version and the CHANGELOG, and holds japicmp.version to the + engine pom's. Both pins name a published release STRICTLY OLDER than the + working version, so an artifact this build produces can never stand in for + a baseline. --> 0.26.2 2.0.0 2.3.0 + + true @@ -338,7 +346,7 @@ true - true + ${japicmp.break.binary} false false true From ffa0a1559ca57db27b2e2a95a026cb7368b478ef Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 12 Sep 2026 01:28:55 +0100 Subject: [PATCH 5/6] ci(templates): hold every japicmp pin to a published older release The strictly-older rule reached graph-compose-templates only, so nothing bounded core's japicmp.baseline - and core/pom.xml still described a baseline equal to the working version as a no-op. It is not: japicmp resolves such a pin to the build's own artifact and passes, which is the hole this branch closed for templates. VersionConsistencyGuardTest now holds every pin in every gated pom strictly older than the version being built, and the comment states what was measured. -PostReleaseOnly can also finish a major transition on its own: Update-JapicmpMajorBaseline moves the floor onto an X.0.0 release and turns japicmp.break.binary back on, beside the previous-release move, so the bump no longer leaves the poms in a state the Step 3b guard rejects with thirteen poms already rewritten. release-script-check.yml lifts both movers by AST and unit-checks that only an X.0.0 release moves the floor. publish.yml installs graph-compose-fonts and graph-compose-emoji before the gate. The gate now runs before the reactor install and without -am, so core's test-scope companions would otherwise have to come from Central - which is the resolution failure ci.yml builds them from source to avoid. Neither is a gated artifact, so seeding them cannot supply a baseline. --- .github/workflows/publish.yml | 10 +++++ .github/workflows/release-script-check.yml | 26 ++++++++++-- core/pom.xml | 18 ++++++--- .../VersionConsistencyGuardTest.java | 37 ++++++++++++++--- scripts/cut-release.ps1 | 40 ++++++++++++++++++- 5 files changed, 114 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d3f03b642..b8a64a547 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -119,6 +119,16 @@ jobs: gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} gpg-passphrase: MAVEN_GPG_PASSPHRASE + - name: Install graph-compose-fonts (resolved at test scope by core) + # The gate below runs before the reactor install and without `-am`, so core's + # test-scope companions resolve from Central unless they are built here first, + # and a tree may pin a companion version that is not published yet. Neither is + # a gated artifact, so seeding them cannot supply a baseline. + run: ./mvnw -B -ntp -f fonts/pom.xml -DskipTests install + + - name: Install graph-compose-emoji (resolved at test scope by core) + run: ./mvnw -B -ntp -f emoji/pom.xml -DskipTests install + - name: Drop cached GraphCompose artifacts before the gate resolves # Nothing of ours may stand in for a published baseline, so the pinned # coordinates are fetched from Central for this run. This runs before the diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml index e72053696..f3fcd0e4a 100644 --- a/.github/workflows/release-script-check.yml +++ b/.github/workflows/release-script-check.yml @@ -364,12 +364,14 @@ jobs: # 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) + $want = @('Update-JapicmpPreviousBaseline', 'Update-JapicmpMajorBaseline') $fn = $ast.FindAll({ param($n) - $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Update-JapicmpPreviousBaseline' + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $want -contains $n.Name }, $true) - if (-not $fn) { throw 'cut-release.ps1 no longer defines Update-JapicmpPreviousBaseline' } - Invoke-Expression $fn[0].Extent.Text + $missing = $want | Where-Object { $_ -notin ($fn | ForEach-Object { $_.Name }) } + if ($missing) { throw "cut-release.ps1 no longer defines: $($missing -join ', ')" } + Invoke-Expression (($fn | ForEach-Object { $_.Extent.Text }) -join "`n") function Note($m) { Write-Host " $m" } $DryRun = $false @@ -391,6 +393,7 @@ jobs: ' ', ' 2.0.0', " $previous", + ' false', ' ', '' ) -join "`n") @@ -427,4 +430,19 @@ jobs: '9.9.9') if ((Get-Content $real -Raw) -ne $expected) { throw 'the real templates pom: the move changed more than the pin' } - Write-Host 'japicmp baseline: previous moves, floor stays, idempotent, the real pom moves, and the cut calls it.' + # Opening a major is the one release that moves the floor and turns the break + # switch back on; every other release leaves both alone (docs/api-stability.md, + # Opening a major). Until that release ships, the pins name the previous major. + $bootstrap = New-Pom '2.4.0' + if (-not (Update-JapicmpMajorBaseline $bootstrap '3.0.0')) { throw 'the X.0.0 release did not move the floor' } + $opened = Get-Content $bootstrap -Raw + if ($opened -notmatch '3\.0\.0') { + throw "the floor did not move onto the release that opened the major:`n$opened" + } + if ($opened -notmatch 'true') { + throw "the break switch did not return to true:`n$opened" + } + if (Update-JapicmpMajorBaseline $bootstrap '3.0.0') { throw 'a second major move reported a change' } + if (Update-JapicmpMajorBaseline (New-Pom '2.4.0') '3.0.1') { throw 'a patch release must not move the floor' } + + Write-Host 'japicmp baseline: previous moves, floor stays except at X.0.0, idempotent, the real pom moves, and the cut calls both.' diff --git a/core/pom.xml b/core/pom.xml index 858f0f218..bd2199685 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -92,12 +92,18 @@ 0.26.2 + Maven Central. The 2.0 major transition is complete, so the gate + enforces binary compatibility against 2.0.0 (see the japicmp profile) + — proven: 2.0.1-SNAPSHOT + a reduced-access public method fails with + METHOD_LESS_ACCESSIBLE. It must stay STRICTLY OLDER than the working + version, which VersionConsistencyGuardTest enforces: a baseline equal + to the version being built is NOT a no-op — japicmp resolves it to this + build's own artifact and reports no differences, so the gate passes + having compared the release with itself (measured on a simulated 3.0.0 + cut). Moving this pin onto a new major therefore waits until that + major's first release is on Central; docs/api-stability.md (Opening a + major) records how the templates gate handles that cycle, and this one + needs the same decision when 3.0 opens. --> 2.0.0