diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c43a8e937..4e2bcef16 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: @@ -506,21 +515,47 @@ 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 - # 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: Prove every japicmp execution ran + # 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 + 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 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..b8a64a547 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -119,6 +119,44 @@ 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 + # 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 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). 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, + # 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 + 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` — @@ -127,21 +165,10 @@ jobs: # 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. + # 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: Verify binary compatibility against the published baseline - # 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: 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..f55445fde 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 @@ -351,3 +354,98 @@ jobs: if ((Get-Content $roadmap -Raw) -ne $after) { throw "a second promotion changed the file" } Write-Host "roadmap promotion: 3 refusals, 1 rewrite, idempotent on re-run." + + - name: Unit-check the japicmp previous-release baseline move + shell: pwsh + run: | + # The 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 + $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 $want -contains $n.Name + }, $true) + $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 + + # A function nothing calls moves no pin. Assert the post-release path still + # invokes every one of them: the failure this guards is a helper surviving a + # refactor that drops the call, which every content assertion below would still + # pass — the pin it moves simply stops moving. + $calls = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.CommandAst] -and + $want -contains $n.GetCommandName() + }, $true) + $called = $calls | ForEach-Object { $_.GetCommandName() } | Select-Object -Unique + $uncalled = $want | Where-Object { $_ -notin $called } + if ($uncalled) { throw "nothing in cut-release.ps1 calls: $($uncalled -join ', ')" } + + 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", + ' false', + ' ', + '' + ) -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' } + + # 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/CHANGELOG.md b/CHANGELOG.md index ff59335e0..568900e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -560,6 +560,29 @@ 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. 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 + one that does not run — switched off, unbound, or not selected — writes none and fails + 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 - **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/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 true + + + 0.26.2 + 2.0.0 + 2.3.0 + + true @@ -262,5 +280,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 + ${japicmp.break.binary} + false + false + true + true + + false + + @com.demcha.compose.document.api.Internal + + + + + + +