From 4e148a990743ae8ba1c69f6b3bac67b9ab9f4c96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:53:36 +0100 Subject: [PATCH 1/7] deps(deps): bump the maven-minor-patch group across 3 directories with 8 updates (#391) Bumps the maven-minor-patch group with 8 updates in the / directory: | Package | From | To | | --- | --- | --- | | [org.junit:junit-bom](https://github.com/junit-team/junit-framework) | `6.1.1` | `6.1.2` | | [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) | `1.5.37` | `1.5.38` | | [org.junit.jupiter:junit-jupiter](https://github.com/junit-team/junit-framework) | `6.1.1` | `6.1.2` | | [net.jqwik:jqwik](https://github.com/jqwik-team/jqwik) | `1.9.3` | `1.10.1` | | [org.jacoco:jacoco-maven-plugin](https://github.com/jacoco/jacoco) | `0.8.12` | `0.8.15` | | org.apache.pdfbox:pdfbox | `3.0.7` | `3.0.8` | | [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) | `2.21.4` | `2.22.1` | | [com.itextpdf:itext-core](https://github.com/itext/itext7) | `9.6.0` | `9.7.0` | Bumps the maven-minor-patch group with 3 updates in the /benchmarks directory: [org.junit:junit-bom](https://github.com/junit-team/junit-framework), [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) and [com.itextpdf:itext-core](https://github.com/itext/itext7). Bumps the maven-minor-patch group with 2 updates in the /examples directory: [org.junit:junit-bom](https://github.com/junit-team/junit-framework) and [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback). Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `org.junit.jupiter:junit-jupiter` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `net.jqwik:jqwik` from 1.9.3 to 1.10.1 - [Release notes](https://github.com/jqwik-team/jqwik/releases) - [Commits](https://github.com/jqwik-team/jqwik/compare/1.9.3...1.10.1) Updates `org.jacoco:jacoco-maven-plugin` from 0.8.12 to 0.8.15 - [Release notes](https://github.com/jacoco/jacoco/releases) - [Commits](https://github.com/jacoco/jacoco/compare/v0.8.12...v0.8.15) Updates `org.apache.pdfbox:pdfbox` from 3.0.7 to 3.0.8 Updates `com.fasterxml.jackson.core:jackson-databind` from 2.21.4 to 2.22.1 - [Commits](https://github.com/FasterXML/jackson/commits) Updates `com.itextpdf:itext-core` from 9.6.0 to 9.7.0 - [Release notes](https://github.com/itext/itext7/releases) - [Commits](https://github.com/itext/itext7/compare/9.6.0...9.7.0) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `com.itextpdf:itext-core` from 9.6.0 to 9.7.0 - [Release notes](https://github.com/itext/itext7/releases) - [Commits](https://github.com/itext/itext7/compare/9.6.0...9.7.0) Updates `org.junit.jupiter:junit-jupiter` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `org.junit.jupiter:junit-jupiter` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `com.itextpdf:itext-core` from 9.6.0 to 9.7.0 - [Release notes](https://github.com/itext/itext7/releases) - [Commits](https://github.com/itext/itext7/compare/9.6.0...9.7.0) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `com.itextpdf:itext-core` from 9.6.0 to 9.7.0 - [Release notes](https://github.com/itext/itext7/releases) - [Commits](https://github.com/itext/itext7/compare/9.6.0...9.7.0) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) Updates `org.junit:junit-bom` from 6.1.1 to 6.1.2 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.1...r6.1.2) Updates `ch.qos.logback:logback-classic` from 1.5.37 to 1.5.38 - [Release notes](https://github.com/qos-ch/logback/releases) - [Commits](https://github.com/qos-ch/logback/compare/v_1.5.37...v_1.5.38) --- updated-dependencies: - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.junit.jupiter:junit-jupiter dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: net.jqwik:jqwik dependency-version: 1.10.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: maven-minor-patch - dependency-name: org.jacoco:jacoco-maven-plugin dependency-version: 0.8.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.apache.pdfbox:pdfbox dependency-version: 3.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: com.fasterxml.jackson.core:jackson-databind dependency-version: 2.22.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: maven-minor-patch - dependency-name: com.itextpdf:itext-core dependency-version: 9.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: com.itextpdf:itext-core dependency-version: 9.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: maven-minor-patch - dependency-name: org.junit.jupiter:junit-jupiter dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.junit.jupiter:junit-jupiter dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: com.itextpdf:itext-core dependency-version: 9.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: com.itextpdf:itext-core dependency-version: 9.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: org.junit:junit-bom dependency-version: 6.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch - dependency-name: ch.qos.logback:logback-classic dependency-version: 1.5.38 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: maven-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Artem Demchyshyn <132658418+DemchaAV@users.noreply.github.com> --- benchmarks/pom.xml | 6 +++--- core/pom.xml | 8 ++++---- examples/pom.xml | 4 ++-- pom.xml | 2 +- qa/pom.xml | 8 ++++---- render-docx/pom.xml | 4 ++-- render-pdf/pom.xml | 8 ++++---- render-pptx/pom.xml | 4 ++-- templates/pom.xml | 6 +++--- testing/pom.xml | 8 ++++---- 10 files changed, 29 insertions(+), 29 deletions(-) diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml index 620837ce..199409a5 100644 --- a/benchmarks/pom.xml +++ b/benchmarks/pom.xml @@ -25,12 +25,12 @@ 17 1.37 - 6.1.1 + 6.1.2 3.27.7 - 1.5.37 + 1.5.38 1.0.10 - 9.6.0 + 9.7.0 7.0.7 diff --git a/core/pom.xml b/core/pom.xml index f8a6739a..73643181 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -47,17 +47,17 @@ 0.64.8 - 1.5.37 + 1.5.38 1.18.46 2.0.18 3.27.7 - 6.1.1 + 6.1.2 5.23.0 1.18.11 1.4.2 - 1.9.3 + 1.10.1 - 0.8.12 + 0.8.15 - 0.8.12 + 0.8.15 3.4.0 3.12.0 3.2.8 diff --git a/render-pptx/pom.xml b/render-pptx/pom.xml index 59422ce6..89eb144c 100644 --- a/render-pptx/pom.xml +++ b/render-pptx/pom.xml @@ -57,9 +57,9 @@ UTF-8 17 - 6.1.1 + 6.1.2 3.27.7 - 1.5.37 + 1.5.38 1.0.0 3.15.0 diff --git a/templates/pom.xml b/templates/pom.xml index aabb19ce..e5341e90 100644 --- a/templates/pom.xml +++ b/templates/pom.xml @@ -56,16 +56,16 @@ 17 1.18.46 - 6.1.1 + 6.1.2 3.27.7 - 1.5.37 + 1.5.38 3.15.0 3.5.0 3.5.6 - 0.8.12 + 0.8.15 3.4.0 3.12.0 3.2.8 diff --git a/testing/pom.xml b/testing/pom.xml index 2b941a23..6c43ab49 100644 --- a/testing/pom.xml +++ b/testing/pom.xml @@ -57,11 +57,11 @@ UTF-8 17 - 3.0.7 - 2.21.4 - 6.1.1 + 3.0.8 + 2.22.1 + 6.1.2 3.27.7 - 1.5.37 + 1.5.38 3.15.0 3.5.0 From 17d8a6d8881b1719852846bc3d0a9b9c67eae43f Mon Sep 17 00:00:00 2001 From: Artem Demchyshyn <132658418+DemchaAV@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:36:38 +0100 Subject: [PATCH 2/7] build(japicmp): enforce binary compatibility against the 2.0.0 baseline (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(japicmp): enforce binary compatibility against the 2.0.0 baseline The japicmp gate ran report-only through the 2.0 major transition and compared against a JitPack-hosted v1.7.0. With 2.0.0 published to Maven Central, point the baseline at graph-compose-core:2.0.0 (resolved from Central, no extra repository) and fail the build on any binary-incompatible change to the public surface, so a regression can no longer ship in a 2.x update. Source-incompatible changes stay report-only. The baseline is the major's floor and holds for the whole 2.x line, advancing only at the next major. The gate short-circuits when the working version equals the baseline, so it only bites once development moves off the release version: bump every module to 2.0.1-SNAPSHOT. The install snippets stay pinned to the published 2.0.0, so VersionConsistencyGuard now also accepts the latest published release (the topmost dated CHANGELOG entry) alongside the pom and Planned versions. Drop the two stale 1.x removal excludes (TextMeasurementSystem, ConfigLoader) — the 2.0.0 baseline predates them. Tests: VersionConsistencyGuardTest 12/12 (poms 2.0.1-SNAPSHOT, snippets 2.0.0); reactor-wide `mvn validate` green; `-P japicmp verify` on core is BUILD SUCCESS against 2.0.0, and a controlled public->package-private change fails with METHOD_LESS_ACCESSIBLE. * build(japicmp): exclude the full Internal surface, add a publish preflight Align the binary-compatibility gate with docs/api-stability.md. The Internal tier — com.demcha.compose.engine.** and com.demcha.compose.document.layout.**, plus any element marked @Internal — carries no compatibility promise, so exclude all of it; the gate previously excluded only document.layout.payloads. Only the Stable public surface is now enforced. Run the gate in the publish workflow before any deploy, so a binary-incompatible change to the public surface aborts the release even if the tag reached the publish job by bypassing branch protection (the CI japicmp job only gates pull requests). Tighten VersionConsistencyGuardTest: during a -SNAPSHOT development cycle the install snippets must advertise the latest published release (the version resolvable on Maven Central), not the in-development SNAPSHOT or a Planned version. The release commit bumps the pom off SNAPSHOT and rewrites the snippets to the release version, at which point they must equal it. Verified on a 2.0.1-SNAPSHOT working tree: the gate fails a Stable break (DocumentColor.rgba -> package-private, METHOD_LESS_ACCESSIBLE) and passes an Internal break (LayoutCompiler.compile -> package-private, excluded); the guard rejects a snippet pinned to an unpublished 2.0.1. * test(japicmp): add a controlled Stable/Internal exclude verification A git-revert-safe script that proves the gate enforces the Stable surface and excludes the Internal surface: it reduces a Stable public method to package-private and asserts the gate FAILS (METHOD_LESS_ACCESSIBLE), then does the same to an Internal method and asserts the gate PASSES. A trap always reverts the source. Run it after changing the japicmp or moving a type between tiers. --- .github/workflows/ci.yml | 7 +- .github/workflows/publish.yml | 12 ++ CHANGELOG.md | 11 ++ benchmarks/pom.xml | 2 +- bundle/pom.xml | 2 +- core/pom.xml | 103 ++++++++---------- .../VersionConsistencyGuardTest.java | 75 +++++++------ coverage/pom.xml | 2 +- docs/api-stability.md | 26 +++++ examples/pom.xml | 2 +- pom.xml | 2 +- qa/pom.xml | 2 +- render-docx/pom.xml | 2 +- render-pdf/pom.xml | 2 +- render-pptx/pom.xml | 2 +- scripts/japicmp-verify-excludes.sh | 64 +++++++++++ templates/pom.xml | 2 +- testing/pom.xml | 2 +- wrapper/pom.xml | 2 +- 19 files changed, 220 insertions(+), 102 deletions(-) create mode 100755 scripts/japicmp-verify-excludes.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 448f01be..1673a1d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,12 +291,11 @@ jobs: - name: Compare public API against baseline # The `japicmp` profile resolves the baseline release pinned - # by the `japicmp.baseline` property in core/pom.xml (via the - # profile-local JitPack repository) and diffs it against the - # freshly-built artifact. Fails the job on any binary- + # 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). - # See CHANGELOG v1.6.6 "Build" notes for the policy rationale. run: ./mvnw -B -ntp -DskipTests -P japicmp verify -pl :graph-compose-core - name: Upload japicmp report diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ba076dd8..d7ce9320 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -99,6 +99,18 @@ jobs: # unpublished tests-jar no longer fails the deploy. 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: Publish engine to Maven Central # Activates the release profile (sources + javadoc + gpg sign + # central-publishing) and flips gpg.skip=false. The deploy diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bcf89e..c5403c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. +## v2.0.1 — Planned + +### Build + +- The binary-compatibility gate (japicmp) now **enforces** backward compatibility + against the published `2.0.0` baseline: an accidental binary-incompatible change to + the public API fails the build instead of only being reported. Development proceeds + on the `2.0.1-SNAPSHOT` line, so the gate compares the working tree against that + baseline and catches a regression before it can ship in a 2.x update. Source-level + incompatibilities remain report-only for now. + ## v2.0.0 — 2026-07-13 The 2.0 development line. Binary-breaking by design — japicmp runs report-only diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml index 199409a5..6e5eb31a 100644 --- a/benchmarks/pom.xml +++ b/benchmarks/pom.xml @@ -7,7 +7,7 @@ io.github.demchaav graph-compose-build - 2.0.0 + 2.0.1-SNAPSHOT ../pom.xml diff --git a/bundle/pom.xml b/bundle/pom.xml index cfa92991..5ad64177 100644 --- a/bundle/pom.xml +++ b/bundle/pom.xml @@ -32,7 +32,7 @@ graph-compose and graph-compose-templates dependencies below use ${project.version}, so they follow automatically. --> - 2.0.0 + 2.0.1-SNAPSHOT jar GraphCompose Bundle diff --git a/core/pom.xml b/core/pom.xml index 73643181..1cd3357d 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ io.github.demchaav graph-compose-core - 2.0.0 + 2.0.1-SNAPSHOT GraphCompose Core A declarative layout engine for programmatic document generation, implemented primarily in Java. This is the lean engine coordinate; depend on the `graph-compose` artifact for the drop-in, PDF-capable install. @@ -91,7 +91,14 @@ 0.26.1 - v1.7.0 + + 2.0.0 japicmp - - - jitpack.io - https://jitpack.io - - @@ -673,8 +675,8 @@ - com.github.DemchaAV - GraphCompose + io.github.demchaav + graph-compose-core ${japicmp.baseline} @@ -686,51 +688,42 @@ true - false + true false false true true - com.demcha.compose.document.layout.payloads - - com.demcha.compose.engine.measurement.TextMeasurementSystem - - com.demcha.compose.ConfigLoader + com.demcha.compose.engine.** + com.demcha.compose.document.layout.** + @com.demcha.compose.document.api.Internal diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index 9db9e0de..870ed0d8 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -12,7 +12,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; @@ -34,15 +33,14 @@ * which is the drift class that previously let the benchmarks module run * against the previous release. * - *

The snippet checks accept the version as matching either - * the current {@code pom.xml} version or the version named in - * the top {@code CHANGELOG.md} {@code Planned} entry. This makes the - * forward-looking Maven Central snippet (which has to advertise the - * about-to-ship version so users copy a coord that will resolve once the tag - * lands) compatible with the pre-cut window where {@code pom.xml} still carries - * the previous release version. {@code cut-release.ps1} bumps both pom and - * snippet to the same target in the release commit, after which the two paths - * converge and the test continues to pass. + *

The install-snippet checks require the snippets to advertise a version that + * a user can actually resolve. During a normal {@code -SNAPSHOT} development cycle + * that is the latest published release (the topmost dated + * {@code CHANGELOG.md} entry) — not the in-development {@code -SNAPSHOT} and not a + * forward-looking {@code Planned} version. In the release commit itself + * {@code cut-release.ps1} bumps the pom off {@code -SNAPSHOT} and rewrites the + * snippets to the new release version, so once the pom carries a concrete release + * version the snippets must equal it. See {@link #acceptableTargets()}. */ class VersionConsistencyGuardTest { @@ -227,10 +225,10 @@ void readmeInstallSnippetsMatchTheProjectVersion() throws Exception { String gradleSnippetVersion = firstMatchingGroup(readme, INSTALL_SNIPPET_PATTERNS_README_GRADLE); assertThat(mavenSnippetVersion) - .describedAs("README Maven install snippet must reference the current pom or CHANGELOG Planned version (one of %s)", targets) + .describedAs("README Maven install snippet must reference the latest published release, or the release version in a release commit (one of %s)", targets) .isIn(targets); assertThat(gradleSnippetVersion) - .describedAs("README Gradle install snippet must reference the current pom or CHANGELOG Planned version (one of %s)", targets) + .describedAs("README Gradle install snippet must reference the latest published release, or the release version in a release commit (one of %s)", targets) .isIn(targets); } @@ -247,19 +245,19 @@ void showcaseSiteVersionMatchesTheProjectVersion() throws Exception { String site = Files.readString(PROJECT_ROOT.resolve("web/index.html")); assertThat(firstGroup(site, "\"softwareVersion\":\\s*\"v?([0-9][^\"]*)\"")) - .describedAs("web/index.html JSON-LD softwareVersion must equal the current pom or planned version (one of %s)", targets) + .describedAs("web/index.html JSON-LD softwareVersion must equal the latest published release, or the release version in a release commit (one of %s)", targets) .isIn(targets); // Match up to the delimiter (space before `·`) rather than // digits-and-dots only, so a pre-release version like 2.0.0-rc.1 is // captured too — consistent with the JSON-LD and snippet patterns. assertThat(firstGroup(site, "v([0-9][^\\s&]*)\\s*·\\s*MIT")) - .describedAs("web/index.html hero version badge must equal the current pom or planned version (one of %s)", targets) + .describedAs("web/index.html hero version badge must equal the latest published release, or the release version in a release commit (one of %s)", targets) .isIn(targets); assertThat(firstMatchingGroup(site, INSTALL_SNIPPET_PATTERNS_SHOWCASE_MAVEN)) - .describedAs("web/index.html Maven install snippet must equal the current pom or planned version (one of %s)", targets) + .describedAs("web/index.html Maven install snippet must equal the latest published release, or the release version in a release commit (one of %s)", targets) .isIn(targets); assertThat(firstMatchingGroup(site, INSTALL_SNIPPET_PATTERNS_SHOWCASE_GRADLE)) - .describedAs("web/index.html Gradle install snippet must equal the current pom or planned version (one of %s)", targets) + .describedAs("web/index.html Gradle install snippet must equal the latest published release, or the release version in a release commit (one of %s)", targets) .isIn(targets); } @@ -287,10 +285,10 @@ void moduleReadmeInstallSnippetsMatchTheProjectVersion() throws Exception { String readme = Files.readString(PROJECT_ROOT.resolve(module.getKey())); String artifact = Pattern.quote(module.getValue()); assertThat(firstGroup(readme, "" + artifact + "\\s*v?([0-9][^<]*)")) - .describedAs("%s Maven install snippet must equal the current pom or planned version (one of %s)", module.getKey(), targets) + .describedAs("%s Maven install snippet must equal the latest published release, or the release version in a release commit (one of %s)", module.getKey(), targets) .isIn(targets); assertThat(firstGroup(readme, "io\\.github\\.demchaav:" + artifact + ":v?([0-9][\\w.\\-]*)")) - .describedAs("%s Gradle install snippet must equal the current pom or planned version (one of %s)", module.getKey(), targets) + .describedAs("%s Gradle install snippet must equal the latest published release, or the release version in a release commit (one of %s)", module.getKey(), targets) .isIn(targets); } } @@ -343,23 +341,38 @@ void companionReadmeInstallSnippetsMatchTheirPomVersions() throws Exception { }; /** - * Returns the set of versions that any install snippet may legitimately - * advertise: the current {@code pom.xml} version always, plus the version - * named in the top {@code CHANGELOG.md} {@code Planned} entry if one - * exists. The Planned entry covers the pre-cut window where {@code pom.xml} - * still carries the previous release version while the README + showcase - * already advertise the about-to-ship version. + * Returns the set of versions an install snippet may legitimately advertise. + * + *

During a normal {@code -SNAPSHOT} development cycle this is only + * the latest published release — the version actually resolvable on + * Maven Central — so a user who copies a snippet always gets a coordinate that + * resolves today, never the in-development {@code -SNAPSHOT} nor a + * forward-looking {@code Planned} version. In the release commit itself + * {@code cut-release.ps1} bumps the pom off {@code -SNAPSHOT} to the new + * release version and rewrites the snippets to match, so once the pom is a + * concrete release version the snippets must equal it.

*/ private Set acceptableTargets() throws Exception { - Set targets = new LinkedHashSet<>(); - targets.add(effectiveVersion(PROJECT_ROOT.resolve("core/pom.xml"))); + String pomVersion = effectiveVersion(PROJECT_ROOT.resolve("core/pom.xml")); + if (pomVersion.endsWith("-SNAPSHOT")) { + return Set.of(latestPublishedRelease()); + } + return Set.of(pomVersion); + } + + /** + * The latest published release: the topmost dated {@code CHANGELOG.md} entry + * ({@code ## vX.Y.Z — YYYY-MM-DD}). {@code find()} returns the newest such + * entry; a {@code — Planned} entry is skipped because it carries no date. + */ + private String latestPublishedRelease() throws Exception { String changelog = Files.readString(PROJECT_ROOT.resolve("CHANGELOG.md")); - Matcher planned = Pattern.compile("^## v([0-9][^ \\n]*)\\s*[\\u2014\\-]\\s*Planned\\b", Pattern.MULTILINE) + Matcher released = Pattern.compile("^## v([0-9][^ \\n]*)\\s*[\\u2014\\-]\\s*\\d{4}-\\d{2}-\\d{2}", Pattern.MULTILINE) .matcher(changelog); - if (planned.find()) { - targets.add(planned.group(1)); - } - return targets; + assertThat(released.find()) + .describedAs("CHANGELOG.md must contain a dated release entry (## vX.Y.Z — YYYY-MM-DD) to anchor the install snippets") + .isTrue(); + return released.group(1); } /** diff --git a/coverage/pom.xml b/coverage/pom.xml index eebd7c9d..fb9b6ae2 100644 --- a/coverage/pom.xml +++ b/coverage/pom.xml @@ -26,7 +26,7 @@ io.github.demchaav graph-compose-build - 2.0.0 + 2.0.1-SNAPSHOT ../pom.xml diff --git a/docs/api-stability.md b/docs/api-stability.md index 65d207f4..be0bf5cc 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -183,6 +183,32 @@ window starts, and its `Status` flips to `deprecated 1.x`. | `DocumentSession.pageMargins(List)` / `PageMarginRule` | Stable | planned | Per-page margins resolve a block's content width by the page it *begins* on (the engine measures each block once, before pagination). A margin that changes the content width therefore does not re-wrap a block mid-flow across a page boundary. | Revisit a page-aware per-line/per-fragment width model so a block can re-wrap when it crosses a margin boundary, if demand warrants. | — | — | | `io.github.demchaav:graph-compose` single-jar packaging | Stable | **landed 2.0** | The one published jar bundled the engine, the PDFBox render backend, the POI semantic backend, zxing, and the template families, so an engine-only or bring-your-own-backend consumer still pulled all of them. | **Done in 2.0.** Split into per-concern lockstep modules, render backends discovered via a `ServiceLoader` SPI. The root coordinate is renamed `graph-compose-core` (the lean engine); `graph-compose` is kept as a back-compat wrapper over `graph-compose-core` + `graph-compose-render-pdf`, so it still renders PDF out of the box. Templates are opt-in (`graph-compose-templates`); DOCX / PPTX ship in `graph-compose-render-docx` / `-render-pptx`. Migration: [modules guide](migration/v2.0.0-modules.md). | [ADR 0016](adr/0016-multi-module-packaging.md) | — | +### Binary-compatibility enforcement + +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. +- **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. +- **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. + +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. + --- ## 4. Tier mapping per package diff --git a/examples/pom.xml b/examples/pom.xml index 3e65182a..3dea9e92 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -7,7 +7,7 @@ io.github.demchaav graph-compose-build - 2.0.0 + 2.0.1-SNAPSHOT ../pom.xml diff --git a/pom.xml b/pom.xml index b70a9141..9b29c825 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.demchaav graph-compose-build - 2.0.0 + 2.0.1-SNAPSHOT pom GraphCompose Build Aggregator diff --git a/qa/pom.xml b/qa/pom.xml index 3b09b0cb..48c54c24 100644 --- a/qa/pom.xml +++ b/qa/pom.xml @@ -22,7 +22,7 @@ io.github.demchaav graph-compose-build - 2.0.0 + 2.0.1-SNAPSHOT ../pom.xml diff --git a/render-docx/pom.xml b/render-docx/pom.xml index 8440791b..5f01e93e 100644 --- a/render-docx/pom.xml +++ b/render-docx/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-render-docx - 2.0.0 + 2.0.1-SNAPSHOT GraphCompose Render — DOCX Semantic DOCX export backend for GraphCompose, backed by Apache POI. diff --git a/render-pdf/pom.xml b/render-pdf/pom.xml index 9fd99459..4947d6b7 100644 --- a/render-pdf/pom.xml +++ b/render-pdf/pom.xml @@ -25,7 +25,7 @@ --> io.github.demchaav graph-compose-render-pdf - 2.0.0 + 2.0.1-SNAPSHOT GraphCompose Render — PDF The PDFBox-backed PDF render backend for GraphCompose. diff --git a/render-pptx/pom.xml b/render-pptx/pom.xml index 89eb144c..1e27a770 100644 --- a/render-pptx/pom.xml +++ b/render-pptx/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-render-pptx - 2.0.0 + 2.0.1-SNAPSHOT GraphCompose Render — PPTX Semantic PPTX export backend for GraphCompose (slide-safe semantic node validation). diff --git a/scripts/japicmp-verify-excludes.sh b/scripts/japicmp-verify-excludes.sh new file mode 100755 index 00000000..aa1f2f05 --- /dev/null +++ b/scripts/japicmp-verify-excludes.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Controlled verification that the japicmp binary-compatibility gate enforces the +# Stable public surface and excludes the Internal surface exactly as +# docs/api-stability.md defines it (§ 1 tiers, § 4 package map, "Binary- +# compatibility enforcement"). Run it after changing the japicmp in +# core/pom.xml, or after moving a type between the Stable and Internal tiers. +# +# It applies one controlled break at a time, runs the gate, checks the outcome, +# and ALWAYS reverts the source (even on interrupt) via a trap. Requires a clean +# working tree for the two touched files. The engine version must be off the +# japicmp baseline (a -SNAPSHOT dev version) or the gate short-circuits. +# +# Expected: a Stable break FAILS the gate; an Internal break does NOT. +# +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/.." && pwd)" +cd "$REPO_ROOT" +MVNW="$REPO_ROOT/mvnw" + +# A Stable public method (document.style is a Stable package) and an Internal +# public method (document.layout is @Internal). Both are compile-safe to reduce to +# package-private: rgba has no main callers, compile() has no cross-package caller. +STABLE_FILE="core/src/main/java/com/demcha/compose/document/style/DocumentColor.java" +INTERNAL_FILE="core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java" + +LOGDIR="$REPO_ROOT/target/japicmp-verify" +mkdir -p "$LOGDIR" +MVN_ARGS=(-B -ntp clean -P japicmp -Dmaven.test.skip=true -Djacoco.skip=true verify -pl :graph-compose-core) + +revert() { git checkout -- "$STABLE_FILE" "$INTERNAL_FILE" 2>/dev/null || true; } +trap revert EXIT + +fail=0 + +echo "== [1/2] Stable break must FAIL the gate ==" +perl -0pi -e 's/ public static DocumentColor rgba/ static DocumentColor rgba/' "$STABLE_FILE" +if "$MVNW" "${MVN_ARGS[@]}" > "$LOGDIR/stable.log" 2>&1; then + echo " FAIL: the gate PASSED a Stable-surface break (DocumentColor.rgba)"; fail=1 +elif grep -q "DocumentColor.rgba.*METHOD_LESS_ACCESSIBLE" "$LOGDIR/stable.log"; then + echo " OK: the gate failed the build (DocumentColor.rgba METHOD_LESS_ACCESSIBLE)" +else + echo " FAIL: the gate failed, but not on the rgba break — see $LOGDIR/stable.log"; fail=1 +fi +git checkout -- "$STABLE_FILE" + +echo "== [2/2] Internal break must NOT fail the gate ==" +perl -0pi -e 's/ public LayoutGraph compile\(/ LayoutGraph compile(/' "$INTERNAL_FILE" +if "$MVNW" "${MVN_ARGS[@]}" > "$LOGDIR/internal.log" 2>&1; then + echo " OK: the gate passed (LayoutCompiler.compile is excluded via document.layout.**)" +else + echo " FAIL: the gate FAILED an Internal-surface break — see $LOGDIR/internal.log"; fail=1 +fi +git checkout -- "$INTERNAL_FILE" + +echo "" +if [ "$fail" = "0" ]; then + echo "VERIFY PASS — Stable enforced, Internal excluded." +else + echo "VERIFY FAIL — see the logs above." +fi +exit "$fail" diff --git a/templates/pom.xml b/templates/pom.xml index e5341e90..34028cd3 100644 --- a/templates/pom.xml +++ b/templates/pom.xml @@ -17,7 +17,7 @@ --> io.github.demchaav graph-compose-templates - 2.0.0 + 2.0.1-SNAPSHOT GraphCompose Templates Built-in CV, cover-letter, invoice, and proposal document templates for GraphCompose. diff --git a/testing/pom.xml b/testing/pom.xml index 6c43ab49..3fa18625 100644 --- a/testing/pom.xml +++ b/testing/pom.xml @@ -19,7 +19,7 @@ --> io.github.demchaav graph-compose-testing - 2.0.0 + 2.0.1-SNAPSHOT GraphCompose Testing Consumer testing support for GraphCompose: layout-snapshot assertions and PDF visual regression. diff --git a/wrapper/pom.xml b/wrapper/pom.xml index 24b75897..2e9978d9 100644 --- a/wrapper/pom.xml +++ b/wrapper/pom.xml @@ -22,7 +22,7 @@ --> io.github.demchaav graph-compose - 2.0.0 + 2.0.1-SNAPSHOT GraphCompose The graph-compose coordinate: a drop-in aggregator over graph-compose-core for a PDF-capable install. From 6d0f1807746e7aba90834a86056248154275461d Mon Sep 17 00:00:00 2001 From: Artem Demchyshyn <132658418+DemchaAV@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:38:39 +0100 Subject: [PATCH 3/7] docs(adr): correct ADR 0016 to the shipped 2.0 module layout (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two statements in the multi-module packaging ADR described a superseded draft rather than what shipped: - Engine sources are in the `core/` module (coordinate `graph-compose-core`), not "at the repository root" — the root is the `graph-compose-build` aggregator pom that binds the reactor. - The dormant Entity-Component-System internals (EntityManager / SystemECS runtime, the Entity model, the ECS render pipeline) were removed in 2.0.0, not moved into a module with removal deferred. They were unreachable from the live render path, so layout, PDF output, and guideLines(...) are unchanged. Documentation guards (CanonicalSurface, DocumentationCoverage, PackageMap, VersionConsistency) stay green. --- docs/adr/0016-multi-module-packaging.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/adr/0016-multi-module-packaging.md b/docs/adr/0016-multi-module-packaging.md index 61d735aa..c49408bf 100644 --- a/docs/adr/0016-multi-module-packaging.md +++ b/docs/adr/0016-multi-module-packaging.md @@ -60,9 +60,10 @@ render backends discovered at runtime through a `ServiceLoader` SPI. (via the wrapper) + templates + the independently-versioned `graph-compose-fonts` and `graph-compose-emoji` companions. Office backends are not bundled. -The engine sources stay at the repository root and only the **root coordinate** is -renamed `graph-compose` → `graph-compose-core`; the new wrapper is a small module. The -default coordinate `graph-compose` therefore continues to mean "PDF out of the box". +The engine sources live in the `core/` module under the coordinate `graph-compose-core`; +the repository root is the `graph-compose-build` aggregator pom that binds the modules +into one lockstep reactor, and `graph-compose` is the small jar-wrapper. The default +coordinate `graph-compose` therefore continues to mean "PDF out of the box". No JPMS: modules carry an `Automatic-Module-Name` only, so a split package across test-scope jars stays legal on the classpath. @@ -82,6 +83,8 @@ test-scope jars stays legal on the classpath. `graph-compose-emoji` keep their own lines (they change rarely). - **A `MissingBackendException` is now the "why won't it render" signal** for a lean `graph-compose-core`, replacing the pre-split assumption that the backend is always present. -- The legacy `Entity`-Component-System render pipeline moves into `graph-compose-render-pdf` - rather than being deleted; it is dead on the canonical path but still exercised by - test scaffolding, so its removal is deferred beyond 2.0. +- The dormant `Entity`-Component-System engine internals — the `EntityManager` / + `SystemECS` runtime, the `Entity` component model, and the ECS render pipeline — + were removed in 2.0.0 rather than carried into a module. They were unreachable from + the live `DocumentSession` → layout compiler → fixed-layout backend path, so document + layout, PDF output, and the public `guideLines(...)` overlay are unchanged. From e42a4aa717bd161e5e642f0cd774fad527dc293e Mon Sep 17 00:00:00 2001 From: Artem Demchyshyn <132658418+DemchaAV@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:45:03 +0100 Subject: [PATCH 4/7] test(release-smoke): external consumer smoke projects for the 2.0 modules (#396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(release-smoke): add external consumer smoke projects for the 2.0 modules Prove the modular 2.0 release works for a real consumer resolving from Maven Central, outside the reactor and with no local install behind it. Six standalone Maven projects (no , so the reactor never builds them), each verifying one published-artifact scenario: - graph-compose renders a PDF out of the box (the drop-in wrapper); - graph-compose-core alone throws MissingBackendException naming graph-compose-render-pdf, and its dependency tree pulls no PDFBox / POI / ZXing / templates / fonts / emoji (maven-enforcer bannedDependencies); - graph-compose-core + graph-compose-render-pdf render via the ServiceLoader SPI; - graph-compose-templates composes a built-in template through the PDF stack; - graph-compose-testing round-trips a layout snapshot (LayoutSnapshotAssertions); - graph-compose-bundle renders a template, exposes the bundled fonts, and makes the colour-emoji set resolvable. A dual-shell harness (run.sh / run.ps1) runs each project against a dedicated local repository that never receives an install of GraphCompose, evicting the graph-compose-* artifacts before each scenario so they must re-resolve from Central; it prints per-scenario RESULT lines and a machine-readable SUMMARY. Maven's own plugins stay cached (re-downloading them tests Maven, not the release). Not wired into CI — run on demand around a release. Tests: all six scenarios pass against the published 2.0.0 on Maven Central, both warm and with GraphCompose evicted per scenario (graph-compose-core-2.0.0.jar fetched from repo.maven.apache.org confirms Central resolution). * test(release-smoke): pin Central-only resolution, add a dispatch workflow - Isolated settings.xml (mirrorOf=*) forces every artifact and plugin request through Maven Central, passed to each scenario with -s, so a scenario can only pass if the coordinate is genuinely resolvable on Central. - Hard-fail if the GraphCompose cache directory cannot be evicted, and confirm io/github/demchaav is gone before each scenario, so a stale cache can never mask a broken publish. - Accept a version override (--version / -Version, default 2.0.0) passed as -Dgc.version; release smoke tests published artifacts only, never a SNAPSHOT. - Add a workflow_dispatch workflow (.github/workflows/release-smoke.yml) that runs the harness manually with a version input. Tests: all six scenarios pass against 2.0.0 on Maven Central through the Central-only mirror with GraphCompose evicted per scenario (SUMMARY {"version":"2.0.0","passed":6,"failed":0,"total":6}). --- .github/workflows/release-smoke.yml | 41 +++++++++ scripts/release-smoke/README.md | 71 ++++++++++++++ scripts/release-smoke/run.ps1 | 68 ++++++++++++++ scripts/release-smoke/run.sh | 83 +++++++++++++++++ .../release-smoke/s1-graph-compose/pom.xml | 52 +++++++++++ .../demcha/smoke/WrapperRendersPdfTest.java | 39 ++++++++ scripts/release-smoke/s2-core-only/pom.xml | 92 +++++++++++++++++++ .../smoke/CoreOnlyMissingBackendTest.java | 41 +++++++++ .../release-smoke/s3-core-render-pdf/pom.xml | 58 ++++++++++++ .../com/demcha/smoke/CoreRenderPdfTest.java | 40 ++++++++ scripts/release-smoke/s4-templates/pom.xml | 58 ++++++++++++ .../com/demcha/smoke/TemplatesRenderTest.java | 53 +++++++++++ scripts/release-smoke/s5-testing/pom.xml | 59 ++++++++++++ .../com/demcha/smoke/TestingHelperTest.java | 50 ++++++++++ scripts/release-smoke/s6-bundle/pom.xml | 53 +++++++++++ .../com/demcha/smoke/BundleRendersTest.java | 63 +++++++++++++ scripts/release-smoke/settings.xml | 21 +++++ 17 files changed, 942 insertions(+) create mode 100644 .github/workflows/release-smoke.yml create mode 100644 scripts/release-smoke/README.md create mode 100644 scripts/release-smoke/run.ps1 create mode 100755 scripts/release-smoke/run.sh create mode 100644 scripts/release-smoke/s1-graph-compose/pom.xml create mode 100644 scripts/release-smoke/s1-graph-compose/src/test/java/com/demcha/smoke/WrapperRendersPdfTest.java create mode 100644 scripts/release-smoke/s2-core-only/pom.xml create mode 100644 scripts/release-smoke/s2-core-only/src/test/java/com/demcha/smoke/CoreOnlyMissingBackendTest.java create mode 100644 scripts/release-smoke/s3-core-render-pdf/pom.xml create mode 100644 scripts/release-smoke/s3-core-render-pdf/src/test/java/com/demcha/smoke/CoreRenderPdfTest.java create mode 100644 scripts/release-smoke/s4-templates/pom.xml create mode 100644 scripts/release-smoke/s4-templates/src/test/java/com/demcha/smoke/TemplatesRenderTest.java create mode 100644 scripts/release-smoke/s5-testing/pom.xml create mode 100644 scripts/release-smoke/s5-testing/src/test/java/com/demcha/smoke/TestingHelperTest.java create mode 100644 scripts/release-smoke/s6-bundle/pom.xml create mode 100644 scripts/release-smoke/s6-bundle/src/test/java/com/demcha/smoke/BundleRendersTest.java create mode 100644 scripts/release-smoke/settings.xml diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 00000000..9f18aa62 --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,41 @@ +name: Release Smoke (consumer verification) + +# Manually verifies that a PUBLISHED GraphCompose release resolves and works for +# real external consumers straight from Maven Central (scripts/release-smoke/). +# Not tied to a tag or push — dispatch it after a publish (allowing for Central +# indexing delay), or any time, to re-check a shipped version. It never touches a +# -SNAPSHOT: release smoke tests published artifacts only. + +on: + workflow_dispatch: + inputs: + version: + description: 'Published GraphCompose version to smoke-test from Maven Central' + required: true + type: string + default: '2.0.0' + +permissions: + contents: read + +jobs: + smoke: + name: Smoke ${{ inputs.version }} from Maven Central + runs-on: ubuntu-latest + env: + JAVA_TOOL_OPTIONS: -Djava.awt.headless=true + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Temurin JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Run release smoke against Maven Central + # The harness forces Central-only resolution (its settings.xml) and evicts + # the GraphCompose artifacts before each scenario, so this genuinely tests + # the published ${{ inputs.version }} coordinates end to end. + run: bash scripts/release-smoke/run.sh --version "${{ inputs.version }}" diff --git a/scripts/release-smoke/README.md b/scripts/release-smoke/README.md new file mode 100644 index 00000000..5d052a05 --- /dev/null +++ b/scripts/release-smoke/README.md @@ -0,0 +1,71 @@ +# Release smoke — external consumer projects + +Standalone Maven projects that consume the **published** GraphCompose +coordinates from Maven Central, proving the modular 2.0 release resolves and +behaves as documented *outside* this repository — no reactor, no local +`mvn install` of GraphCompose beforehand. + +These projects are **not** part of the root reactor (the root `pom.xml` +`` does not list them and each pom has no ``), so a normal +build never touches them. Run them explicitly with the harness below. + +## Scenarios + +| Dir | Coordinate(s) under test | Proves | +|---|---|---| +| `s1-graph-compose` | `graph-compose` | the drop-in wrapper renders a PDF out of the box | +| `s2-core-only` | `graph-compose-core` | a lean core throws `MissingBackendException` naming `graph-compose-render-pdf`, and its dependency tree pulls no PDFBox / POI / ZXing / templates / fonts / emoji (enforced by `maven-enforcer` bannedDependencies) | +| `s3-core-render-pdf` | `graph-compose-core` + `graph-compose-render-pdf` | the explicit lean + backend combination renders a PDF | +| `s4-templates` | `graph-compose-templates` | a built-in template composes and renders through the PDF stack | +| `s5-testing` | `graph-compose` + `graph-compose-testing` | the consumer testing helper (`LayoutSnapshotAssertions`) resolves and round-trips a layout snapshot | +| `s6-bundle` | `graph-compose-bundle` | the batteries-included aggregate renders a templated document, exposes the bundled fonts (`DefaultFonts.bundledFontNames()`), and makes the colour-emoji set resolvable (`GraphComposeEmoji.isAvailable()`) | + +The "must pull core + render-pdf" (wrapper) and "must pull the documented +aggregate" (bundle) assertions are proven positively: `s1` / `s6` can only +render because the backend and companions were pulled transitively. + +## Running + +```bash +# Evict the GraphCompose artifacts before each scenario (Central-only, isolated): +./scripts/release-smoke/run.sh + +# Smoke-test a different published version: +./scripts/release-smoke/run.sh --version 2.0.1 + +# Fast dev iteration — keep everything cached: +./scripts/release-smoke/run.sh --warm +``` + +```powershell +pwsh ./scripts/release-smoke/run.ps1 # isolated +pwsh ./scripts/release-smoke/run.ps1 -Version 2.0.1 # a different published version +pwsh ./scripts/release-smoke/run.ps1 -Warm # warm +``` + +Or dispatch the **Release Smoke (consumer verification)** GitHub Actions workflow +(`.github/workflows/release-smoke.yml`) with a `version` input — handy after a +publish, once Central has indexed the release. + +The harness passes an isolated `settings.xml` (`-s`) whose `mirrorOf=*` mirror +forces **every** artifact and plugin request through Maven Central, and uses one +dedicated local repository under `target/` that never receives an `mvn install` +of GraphCompose. In the default (isolated) mode it **evicts the GraphCompose +artifacts** (`io/github/demchaav/**`) before each scenario — hard-failing if the +eviction does not take — so every scenario must re-resolve `graph-compose-*` from +Central, proving the release resolves with no local reactor build behind it. +Maven's own plugins and third-party libraries (PDFBox, JUnit, …) stay cached, +because re-downloading Maven's core plugins onto an empty repository is heavy, +flaky, and tests Maven rather than this release. Classpath isolation between +scenarios (e.g. the lean core never seeing the PDF backend) comes from each +project's declared dependencies and the `s2` enforcer rule, not from the +repository state. + +The harness prints one `RESULT PASS|FAIL` line per project and ends +with a machine-readable `SUMMARY {"version":"…","passed":N,"failed":M,"total":T}` +line. Exit code is non-zero if any scenario fails. + +The version under test defaults to the current published release (`2.0.0`) and is +overridable with `--version` / `-Version` (or the workflow's `version` input); it +is passed to Maven as `-Dgc.version`, overriding the `gc.version` property in each +pom. Release smoke always tests **published** artifacts — never a `-SNAPSHOT`. diff --git a/scripts/release-smoke/run.ps1 b/scripts/release-smoke/run.ps1 new file mode 100644 index 00000000..07cb51fc --- /dev/null +++ b/scripts/release-smoke/run.ps1 @@ -0,0 +1,68 @@ +<# + Release smoke harness (PowerShell) — runs every external consumer project + against the published GraphCompose coordinates on Maven Central. See README.md. + + Isolation model: a dedicated local repository under target/ that never receives + an `mvn install` of GraphCompose, plus an isolated settings.xml whose mirror + forces ALL resolution through Maven Central. Before each scenario the GraphCompose + artifacts (io\github\demchaav\**) are EVICTED — and the harness hard-fails if the + eviction does not take — so every scenario must re-resolve graph-compose-* from + Central. Maven's own plugins and third-party libraries stay cached: re-downloading + Maven's core plugins on an empty repo is heavy, flaky, and tests Maven, not this + release. + + Usage: + pwsh ./scripts/release-smoke/run.ps1 # isolated, tests 2.0.0 + pwsh ./scripts/release-smoke/run.ps1 -Version 2.0.1 # test a different published version + pwsh ./scripts/release-smoke/run.ps1 -Warm # keep everything cached (fast dev iteration) +#> +param( + [switch]$Warm, + # Default version under test: the currently published release. Release smoke + # must test PUBLISHED artifacts — never a -SNAPSHOT. + [string]$Version = '2.0.0' +) + +$ErrorActionPreference = 'Continue' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path +$mvnw = Join-Path $repoRoot 'mvnw.cmd' +$settings = Join-Path $here 'settings.xml' + +$scenarios = @('s1-graph-compose', 's2-core-only', 's3-core-render-pdf', 's4-templates', 's5-testing', 's6-bundle') +$repo = Join-Path $repoRoot 'target\release-smoke-m2\repo' +New-Item -ItemType Directory -Force -Path $repo | Out-Null + +$pass = 0 +$fail = 0 +$results = @() + +foreach ($s in $scenarios) { + if (-not $Warm) { + # Evict only the GraphCompose coordinates, then hard-fail if it did not take. + $gc = Join-Path $repo 'io\github\demchaav' + if (Test-Path $gc) { Remove-Item -Recurse -Force $gc } + if (Test-Path $gc) { + Write-Error "FATAL: could not remove the GraphCompose cache directory: $gc" + exit 3 + } + } + Write-Host "" + Write-Host "==================================================================" + Write-Host "=== SMOKE $s (version=$Version, repo=$repo, evicted=$(if ($Warm) { 'no' } else { 'yes' }))" + Write-Host "==================================================================" + & $mvnw -B -ntp -s $settings "-Dgc.version=$Version" -f (Join-Path $here "$s\pom.xml") "-Dmaven.repo.local=$repo" clean verify + if ($LASTEXITCODE -eq 0) { + $results += "$s PASS"; $pass++ + } else { + $results += "$s FAIL"; $fail++ + } +} + +Write-Host "" +Write-Host "===================== RELEASE SMOKE SUMMARY =====================" +Write-Host "version-under-test: $Version" +foreach ($r in $results) { Write-Host "RESULT $r" } +Write-Host ("SUMMARY {""version"":""$Version"",""passed"":$pass,""failed"":$fail,""total"":$($pass + $fail)}") + +if ($fail -ne 0) { exit 1 } else { exit 0 } diff --git a/scripts/release-smoke/run.sh b/scripts/release-smoke/run.sh new file mode 100755 index 00000000..96f472a8 --- /dev/null +++ b/scripts/release-smoke/run.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# Release smoke harness — runs every external consumer project against the +# published GraphCompose coordinates on Maven Central. See README.md. +# +# Isolation model: a dedicated local repository under target/ that never receives +# an `mvn install` of GraphCompose, plus an isolated settings.xml whose mirror +# forces ALL resolution through Maven Central. Before each scenario the GraphCompose +# artifacts (io/github/demchaav/**) are EVICTED — and the harness hard-fails if the +# eviction does not take — so every scenario must re-resolve graph-compose-* from +# Central. Maven's own plugins and third-party libraries stay cached: re-downloading +# Maven's core plugins on an empty repo is heavy, flaky, and tests Maven, not this +# release. +# +# Usage: +# ./scripts/release-smoke/run.sh # isolated, tests gc.version=2.0.0 +# ./scripts/release-smoke/run.sh --version 2.0.1 # test a different published version +# ./scripts/release-smoke/run.sh --warm # keep everything cached (fast dev iteration) +# +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/../.." && pwd)" +MVNW="$REPO_ROOT/mvnw" +SETTINGS="$HERE/settings.xml" + +SCENARIOS=(s1-graph-compose s2-core-only s3-core-render-pdf s4-templates s5-testing s6-bundle) +REPO="$REPO_ROOT/target/release-smoke-m2/repo" + +# Default version under test: the currently published release. Release smoke must +# test PUBLISHED artifacts — never a -SNAPSHOT. +GC_VERSION="2.0.0" +WARM=0 +while [ $# -gt 0 ]; do + case "$1" in + --warm) WARM=1; shift ;; + --version) GC_VERSION="${2:?--version needs a value}"; shift 2 ;; + --version=*) GC_VERSION="${1#*=}"; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +mkdir -p "$REPO" + +pass=0 +fail=0 +declare -a results + +for s in "${SCENARIOS[@]}"; do + if [ "$WARM" = "0" ]; then + # Evict only the GraphCompose coordinates so they must come from Central, + # then hard-fail if the eviction did not take (a stale cache would mask a + # broken publish). + gc_dir="$REPO/io/github/demchaav" + rm -rf "$gc_dir" + if [ -d "$gc_dir" ]; then + echo "FATAL: could not remove the GraphCompose cache directory: $gc_dir" >&2 + exit 3 + fi + fi + echo "" + echo "==================================================================" + echo "=== SMOKE $s (version=$GC_VERSION, repo=$REPO, evicted=$([ "$WARM" = "0" ] && echo yes || echo no))" + echo "==================================================================" + "$MVNW" -B -ntp -s "$SETTINGS" -Dgc.version="$GC_VERSION" \ + -f "$HERE/$s/pom.xml" -Dmaven.repo.local="$REPO" clean verify + if [ $? -eq 0 ]; then + results+=("$s PASS") + pass=$((pass + 1)) + else + results+=("$s FAIL") + fail=$((fail + 1)) + fi +done + +echo "" +echo "===================== RELEASE SMOKE SUMMARY =====================" +echo "version-under-test: $GC_VERSION" +for r in "${results[@]}"; do + echo "RESULT $r" +done +echo "SUMMARY {\"version\":\"$GC_VERSION\",\"passed\":$pass,\"failed\":$fail,\"total\":$((pass + fail))}" + +[ "$fail" = "0" ] diff --git a/scripts/release-smoke/s1-graph-compose/pom.xml b/scripts/release-smoke/s1-graph-compose/pom.xml new file mode 100644 index 00000000..ce855022 --- /dev/null +++ b/scripts/release-smoke/s1-graph-compose/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + com.demcha.smoke + graph-compose-smoke-graph-compose + 1.0.0 + + + UTF-8 + 17 + 2.0.0 + 5.11.4 + 3.27.3 + 3.5.2 + + + + + io.github.demchaav + graph-compose + ${gc.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + diff --git a/scripts/release-smoke/s1-graph-compose/src/test/java/com/demcha/smoke/WrapperRendersPdfTest.java b/scripts/release-smoke/s1-graph-compose/src/test/java/com/demcha/smoke/WrapperRendersPdfTest.java new file mode 100644 index 00000000..22dc01b3 --- /dev/null +++ b/scripts/release-smoke/s1-graph-compose/src/test/java/com/demcha/smoke/WrapperRendersPdfTest.java @@ -0,0 +1,39 @@ +package com.demcha.smoke; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Scenario 1 — the drop-in {@code graph-compose} wrapper renders a PDF out of + * the box, exactly as a 1.x caller expects after upgrading to 2.0. The wrapper + * pulls {@code graph-compose-core} + {@code graph-compose-render-pdf} + * transitively, so a successful render is positive proof both were resolved. + */ +class WrapperRendersPdfTest { + + @Test + void graphComposeWrapperRendersPdfOutOfTheBox() throws Exception { + Path out = Files.createTempFile("gc-smoke-wrapper", ".pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(DocumentPageSize.A4) + .margin(36f, 36f, 36f, 36f) + .create()) { + document.add(document.dsl().paragraph() + .text("graph-compose 2.0.0 renders PDF out of the box.") + .build()); + document.buildPdf(); + } + + assertThat(Files.size(out)).isGreaterThan(0L); + byte[] head = Arrays.copyOf(Files.readAllBytes(out), 5); + assertThat(new String(head)).isEqualTo("%PDF-"); + } +} diff --git a/scripts/release-smoke/s2-core-only/pom.xml b/scripts/release-smoke/s2-core-only/pom.xml new file mode 100644 index 00000000..d1d90e2e --- /dev/null +++ b/scripts/release-smoke/s2-core-only/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + com.demcha.smoke + graph-compose-smoke-core-only + 1.0.0 + + + UTF-8 + 17 + 2.0.0 + 5.11.4 + 3.27.3 + 3.5.2 + 3.5.0 + + + + + io.github.demchaav + graph-compose-core + ${gc.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + + org.apache.maven.plugins + maven-enforcer-plugin + ${enforcer.version} + + + core-stays-lean + + enforce + + + + + true + + org.apache.pdfbox + org.apache.poi + com.google.zxing + io.github.demchaav:graph-compose-render-pdf + io.github.demchaav:graph-compose-render-docx + io.github.demchaav:graph-compose-render-pptx + io.github.demchaav:graph-compose-templates + io.github.demchaav:graph-compose-fonts + io.github.demchaav:graph-compose-emoji + + + + true + + + + + + + diff --git a/scripts/release-smoke/s2-core-only/src/test/java/com/demcha/smoke/CoreOnlyMissingBackendTest.java b/scripts/release-smoke/s2-core-only/src/test/java/com/demcha/smoke/CoreOnlyMissingBackendTest.java new file mode 100644 index 00000000..2753c706 --- /dev/null +++ b/scripts/release-smoke/s2-core-only/src/test/java/com/demcha/smoke/CoreOnlyMissingBackendTest.java @@ -0,0 +1,41 @@ +package com.demcha.smoke; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.exceptions.MissingBackendException; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Scenario 2 — a lean {@code graph-compose-core} consumer with no render + * backend on the classpath. Asking it to build a PDF must fail with a + * {@link MissingBackendException} whose message names the artifact to add + * ({@code graph-compose-render-pdf}), rather than an opaque NPE or ISE. The + * dependency-tree leanness is asserted separately by the enforcer rule in the + * pom. + */ +class CoreOnlyMissingBackendTest { + + @Test + void coreOnlyRenderThrowsMissingBackendNamingRenderPdf() throws Exception { + Path out = Files.createTempFile("gc-smoke-core-only", ".pdf"); + assertThatThrownBy(() -> { + try (DocumentSession document = GraphCompose.document(out) + .pageSize(DocumentPageSize.A4) + .margin(36f, 36f, 36f, 36f) + .create()) { + document.add(document.dsl().paragraph() + .text("no backend on the classpath") + .build()); + document.buildPdf(); + } + }) + .isInstanceOf(MissingBackendException.class) + .hasMessageContaining("graph-compose-render-pdf"); + } +} diff --git a/scripts/release-smoke/s3-core-render-pdf/pom.xml b/scripts/release-smoke/s3-core-render-pdf/pom.xml new file mode 100644 index 00000000..8e104cce --- /dev/null +++ b/scripts/release-smoke/s3-core-render-pdf/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + com.demcha.smoke + graph-compose-smoke-core-render-pdf + 1.0.0 + + + UTF-8 + 17 + 2.0.0 + 5.11.4 + 3.27.3 + 3.5.2 + + + + + io.github.demchaav + graph-compose-core + ${gc.version} + + + io.github.demchaav + graph-compose-render-pdf + ${gc.version} + runtime + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + diff --git a/scripts/release-smoke/s3-core-render-pdf/src/test/java/com/demcha/smoke/CoreRenderPdfTest.java b/scripts/release-smoke/s3-core-render-pdf/src/test/java/com/demcha/smoke/CoreRenderPdfTest.java new file mode 100644 index 00000000..930fa2da --- /dev/null +++ b/scripts/release-smoke/s3-core-render-pdf/src/test/java/com/demcha/smoke/CoreRenderPdfTest.java @@ -0,0 +1,40 @@ +package com.demcha.smoke; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Scenario 3 — the explicit bring-your-own-backend combination + * ({@code graph-compose-core} + {@code graph-compose-render-pdf}) renders a PDF. + * The PDF backend registers its {@code FixedLayoutBackendProvider} / + * {@code FontMetricsProvider} via {@code META-INF/services}, so the core + * discovers it at runtime through the ServiceLoader SPI. + */ +class CoreRenderPdfTest { + + @Test + void coreWithRenderPdfRendersPdf() throws Exception { + Path out = Files.createTempFile("gc-smoke-core-render-pdf", ".pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(DocumentPageSize.A4) + .margin(36f, 36f, 36f, 36f) + .create()) { + document.add(document.dsl().paragraph() + .text("graph-compose-core + graph-compose-render-pdf renders via the SPI.") + .build()); + document.buildPdf(); + } + + assertThat(Files.size(out)).isGreaterThan(0L); + byte[] head = Arrays.copyOf(Files.readAllBytes(out), 5); + assertThat(new String(head)).isEqualTo("%PDF-"); + } +} diff --git a/scripts/release-smoke/s4-templates/pom.xml b/scripts/release-smoke/s4-templates/pom.xml new file mode 100644 index 00000000..259b95b5 --- /dev/null +++ b/scripts/release-smoke/s4-templates/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + com.demcha.smoke + graph-compose-smoke-templates + 1.0.0 + + + UTF-8 + 17 + 2.0.0 + 5.11.4 + 3.27.3 + 3.5.2 + + + + + io.github.demchaav + graph-compose + ${gc.version} + + + io.github.demchaav + graph-compose-templates + ${gc.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + diff --git a/scripts/release-smoke/s4-templates/src/test/java/com/demcha/smoke/TemplatesRenderTest.java b/scripts/release-smoke/s4-templates/src/test/java/com/demcha/smoke/TemplatesRenderTest.java new file mode 100644 index 00000000..9a6708cf --- /dev/null +++ b/scripts/release-smoke/s4-templates/src/test/java/com/demcha/smoke/TemplatesRenderTest.java @@ -0,0 +1,53 @@ +package com.demcha.smoke; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; +import com.demcha.compose.document.templates.invoice.presets.ModernInvoice; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Scenario 4 — a built-in template ({@code ModernInvoice}) from the opt-in + * {@code graph-compose-templates} module composes over the canonical DSL and + * renders through the PDF stack. + */ +class TemplatesRenderTest { + + @Test + void modernInvoiceTemplateComposesAndRenders() throws Exception { + InvoiceDocumentSpec spec = InvoiceDocumentSpec.builder() + .title("Smoke Invoice") + .invoiceNumber("SMOKE-001") + .issueDate("2026-07-13") + .dueDate("2026-08-13") + .status("DUE") + .fromParty(p -> p.name("Acme Studio")) + .billToParty(p -> p.name("Client Ltd")) + .lineItem("Consulting", "Release smoke test", "1", "$100.00", "$100.00") + .totalRow("Total", "$100.00") + .build(); + + DocumentTemplate template = ModernInvoice.create(); + + Path out = Files.createTempFile("gc-smoke-templates", ".pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(DocumentPageSize.A4) + .margin(36f, 36f, 36f, 36f) + .create()) { + template.compose(document, spec); + document.buildPdf(); + } + + assertThat(Files.size(out)).isGreaterThan(0L); + byte[] head = Arrays.copyOf(Files.readAllBytes(out), 5); + assertThat(new String(head)).isEqualTo("%PDF-"); + } +} diff --git a/scripts/release-smoke/s5-testing/pom.xml b/scripts/release-smoke/s5-testing/pom.xml new file mode 100644 index 00000000..2ae6e839 --- /dev/null +++ b/scripts/release-smoke/s5-testing/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + com.demcha.smoke + graph-compose-smoke-testing + 1.0.0 + + + UTF-8 + 17 + 2.0.0 + 5.11.4 + 3.27.3 + 3.5.2 + + + + + io.github.demchaav + graph-compose + ${gc.version} + + + io.github.demchaav + graph-compose-testing + ${gc.version} + test + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + diff --git a/scripts/release-smoke/s5-testing/src/test/java/com/demcha/smoke/TestingHelperTest.java b/scripts/release-smoke/s5-testing/src/test/java/com/demcha/smoke/TestingHelperTest.java new file mode 100644 index 00000000..0e4c2bd0 --- /dev/null +++ b/scripts/release-smoke/s5-testing/src/test/java/com/demcha/smoke/TestingHelperTest.java @@ -0,0 +1,50 @@ +package com.demcha.smoke; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.testing.layout.LayoutSnapshotAssertions; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Scenario 5 — the consumer testing support {@code graph-compose-testing} + * resolves and its documented helper ({@code LayoutSnapshotAssertions}) works + * end to end: extract a layout snapshot, write a baseline in update mode, then + * assert the freshly rendered layout matches it. Baselines are written under + * {@code target/} so nothing is left in the source tree. + */ +class TestingHelperTest { + + @Test + void layoutSnapshotAssertionsRoundTrips() throws Exception { + Path expected = Path.of("target", "smoke-snapshots", "expected"); + Path actual = Path.of("target", "smoke-snapshots", "actual"); + Path out = Files.createTempFile("gc-smoke-testing", ".pdf"); + + try (DocumentSession document = GraphCompose.document(out) + .pageSize(DocumentPageSize.A4) + .margin(36f, 36f, 36f, 36f) + .create()) { + document.add(document.dsl().paragraph() + .text("graph-compose-testing layout snapshot smoke.") + .build()); + + // 1) update mode: extract + serialize the snapshot to the baseline root. + System.setProperty(LayoutSnapshotAssertions.UPDATE_PROPERTY, "true"); + LayoutSnapshotAssertions.assertMatches(document, expected, actual, "release-smoke"); + + // 2) compare mode: the freshly rendered layout must match the baseline. + System.setProperty(LayoutSnapshotAssertions.UPDATE_PROPERTY, "false"); + LayoutSnapshotAssertions.assertMatches(document, expected, actual, "release-smoke"); + } finally { + System.clearProperty(LayoutSnapshotAssertions.UPDATE_PROPERTY); + } + + assertThat(Files.exists(expected.resolve("release-smoke.json"))).isTrue(); + } +} diff --git a/scripts/release-smoke/s6-bundle/pom.xml b/scripts/release-smoke/s6-bundle/pom.xml new file mode 100644 index 00000000..6ca5a9f9 --- /dev/null +++ b/scripts/release-smoke/s6-bundle/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + com.demcha.smoke + graph-compose-smoke-bundle + 1.0.0 + + + UTF-8 + 17 + 2.0.0 + 5.11.4 + 3.27.3 + 3.5.2 + + + + + io.github.demchaav + graph-compose-bundle + ${gc.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + diff --git a/scripts/release-smoke/s6-bundle/src/test/java/com/demcha/smoke/BundleRendersTest.java b/scripts/release-smoke/s6-bundle/src/test/java/com/demcha/smoke/BundleRendersTest.java new file mode 100644 index 00000000..05e4cc01 --- /dev/null +++ b/scripts/release-smoke/s6-bundle/src/test/java/com/demcha/smoke/BundleRendersTest.java @@ -0,0 +1,63 @@ +package com.demcha.smoke; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; +import com.demcha.compose.document.templates.invoice.presets.ModernInvoice; +import com.demcha.compose.emoji.GraphComposeEmoji; +import com.demcha.compose.font.DefaultFonts; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Scenario 6 — the batteries-included {@code graph-compose-bundle} aggregate. + * A single dependency renders a templated document through the PDF stack, + * exposes the bundled font catalogue, and makes the colour-emoji set + * resolvable — proving the bundle pulled the documented aggregate set + * (wrapper + templates + fonts + emoji) transitively. + */ +class BundleRendersTest { + + @Test + void bundleRendersTemplateAndExposesFontsAndEmoji() throws Exception { + InvoiceDocumentSpec spec = InvoiceDocumentSpec.builder() + .title("Bundle Smoke Invoice") + .invoiceNumber("SMOKE-BUNDLE-001") + .issueDate("2026-07-13") + .dueDate("2026-08-13") + .status("DUE") + .fromParty(p -> p.name("Acme Studio")) + .billToParty(p -> p.name("Client Ltd")) + .lineItem("Consulting", "Bundle smoke test", "1", "$100.00", "$100.00") + .totalRow("Total", "$100.00") + .build(); + + DocumentTemplate template = ModernInvoice.create(); + + Path out = Files.createTempFile("gc-smoke-bundle", ".pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(DocumentPageSize.A4) + .margin(36f, 36f, 36f, 36f) + .create()) { + template.compose(document, spec); + document.buildPdf(); + } + + assertThat(Files.size(out)).isGreaterThan(0L); + byte[] head = Arrays.copyOf(Files.readAllBytes(out), 5); + assertThat(new String(head)).isEqualTo("%PDF-"); + + // Bundled font catalogue is on the classpath (graph-compose-fonts). + assertThat(DefaultFonts.bundledFontNames()).isNotEmpty(); + + // Colour-emoji set is resolvable (graph-compose-emoji). + assertThat(GraphComposeEmoji.isAvailable()).isTrue(); + } +} diff --git a/scripts/release-smoke/settings.xml b/scripts/release-smoke/settings.xml new file mode 100644 index 00000000..834bf4a3 --- /dev/null +++ b/scripts/release-smoke/settings.xml @@ -0,0 +1,21 @@ + + + + + + central-only + Maven Central only + https://repo.maven.apache.org/maven2 + * + + + From 39eaa04431f8fb71d62317a5792ae0f7465ad479 Mon Sep 17 00:00:00 2001 From: Artem Demchyshyn <132658418+DemchaAV@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:11:30 +0100 Subject: [PATCH 5/7] build(release): pre-tag gates + automated post-release bump in cut-release (#397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(release): pre-tag gates + automated post-release bump in cut-release Automate the post-release version bump and gate the tag on the metadata and binary compatibility that a stale README or an accidental break would otherwise slip past. cut-release.ps1: - -PostReleaseOnly now opens the next development line: bumps every train pom to the next patch -SNAPSHOT (idempotent — skipped when already a SNAPSHOT, and when there is no core/pom.xml) and restores the /blob/develop showcase links, leaving the README/showcase install snippets on the just-published release (the version guard requires snippets to advertise the version on Central during a -SNAPSHOT cycle). - Fast pre-tag metadata gate (Step 2b): fails the cut if the CHANGELOG is not dated for the target, the README "Latest stable" prose block does not name it, the README install snippet lags, or a train pom's version is wrong. That prose block has no test guarding it, so this is the only automated guard against tagging with a stale release-status line; a dry run warns on it (the one check needing no mutation). - Binary-compatibility gate (Step 5b): runs japicmp against the published baseline before commit/tag, independent of the PR-time CI gate. Skipped by -SkipVerify and on the legacy 1.x layout. - Post-tag reminders point at the release-smoke suite (post-publish) and the -PostReleaseOnly follow-up. release-process.md: document the new gates and the automated SNAPSHOT bump, the post-publish release-smoke step, and a release-publication failure-recovery table (failed GitHub Release, failed Central validation with the module deploy order, partial publication, stale-tag documentation, and the patch-release criteria). Verified via -DryRun of a full cut and of -PostReleaseOnly, the Assert-ReleaseMetadata regexes against the real CHANGELOG/README, and Get-NextSnapshotVersion across final / patch-carry / pre-release / already-SNAPSHOT inputs. No tag, no publish; project stays on 2.0.1-SNAPSHOT. * build(release): shared preflight, post-bump validation, publish resume Close six release-safety gaps in the cut-release / publish tooling: - Extract the branch / clean-tree (incl. staged) / origin-sync preflight into Assert-BranchPreflight and run it from both a real cut and -PostReleaseOnly, so the post-release path can no longer commit + push from the wrong branch, a dirty tree, or out of sync with origin. - -PostReleaseOnly now runs `mvnw validate` + VersionConsistencyGuardTest after the SNAPSHOT bump, before committing/pushing. The Maven args go through splatted arrays, not inline: the call operator re-tokenizes an inline -Djacoco.skip=true at the dot into a bogus Maven phase. - publish.yml gains a start_at choice input, a plan step, and per-module `if:` guards so a partial Central publication can resume from the first unpublished module. A full re-dispatch always starts at core and Central rejects re-uploading an already-validated coordinate, so it must not be blindly rerun — the recovery doc is corrected to match. - Assert-ReleaseMetadata now fails when the graph-compose README install snippet is missing, not only when its version differs. - New release-script-check.yml runs cut-release.ps1's dry-runs and a unit check of Get-NextSnapshotVersion on any change to the script (the main CI path filters skip it). - Script help text documents that -PostReleaseOnly opens the next SNAPSHOT line. Verified: both dry-runs exit 0; the splat passes -Djacoco.skip=true intact (inline splits it); the publish plan-step start_at logic and the missing-snippet check tested. No tag, no publish; develop stays 2.0.1-SNAPSHOT. * build(release): guard fetch failures, validate start_at, order the stale-README check Three release-safety fixes on top of the release-process hardening: - Assert-BranchPreflight now checks the exit code of `git fetch` and both `git rev-parse` calls (capturing before .Trim() so a failure surfaces the real message, not a null-method error). A silently-failed fetch (network/auth) would otherwise compare against a stale origin ref and wrongly report the branch in sync — a real gap for a release script. - publish.yml's deploy planner rejects an unknown start_at: an unrecognised value would leave every module skipped and publish nothing while the job stayed green. Empty (a tag-triggered run) still means "all". - The README "Latest stable" check moves to Step 0, BEFORE any file is mutated (extracted into Test-ReadmeLatestStable). A stale line now aborts with a clean tree instead of failing at Step 2b after Steps 1-2 dirtied the tree — which the next preflight would then refuse to run over. Assert-ReleaseMetadata keeps the post-mutation checks (CHANGELOG date, install snippet, pom versions). release-script-check.yml now runs the real Test-ReadmeLatestStable against the actual README (not just a dry-run print), so the stale-tag guard's regex is exercised in CI. Verified: both dry-runs exit 0; the Step-0 Latest-stable warning fires before Step 1; start_at accepts empty/all/ and rejects unknown values; Test-ReadmeLatestStable matches the README's version and rejects a bogus one. No tag, no publish; develop stays 2.0.1-SNAPSHOT. * build(release): split final vs pre-release cut paths; ls-remote tag check Two release-safety fixes: - Pre-release path. The script supports X.Y.Z-rc.N / -alpha / -beta, but publish.yml never ships a hyphenated tag to Maven Central — pre-releases go only to the GitHub Release pre-release surface. So gate the Central-facing work on $isFinalRelease = $Version -match '^\d+\.\d+\.\d+$': only a final release checks the README 'Latest stable' block, rewrites the README / module / showcase install snippets, and validates them in Assert-ReleaseMetadata. A pre-release still bumps the train poms (the tag builds at that version) but leaves 'Latest stable' and the Central snippets 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: snippets must equal the pom only for a concrete final version; for a -SNAPSHOT or pre-release pom they must equal the latest published release. - Origin-tag check. The check used `git fetch refs/tags/...`, which exits 128 for a legitimately-absent tag (the normal pre-cut case), so its exit code could never distinguish "tag free" from a network failure. Switched to `git ls-remote --tags` (empty output = free; non-zero = network/auth error), so a broken connection can no longer be mistaken for "tag is free". release-script-check.yml adds a 2.1.0-rc.1 dry-run asserting the pre-release path (no 'Latest stable' check, no install-snippet bump). Verified: final (2.0.2) and pre-release (2.1.0-rc.1) dry-runs both exit 0 with the correct split; VersionConsistencyGuardTest 12/12; ls-remote distinguishes an absent tag (exit 0, empty) from a network error. No tag, no publish; develop stays 2.0.1-SNAPSHOT. * build(release): reject pre-release tags from Central; RC uses final release notes Ensure a pre-release (X.Y.Z-rc.N / -alpha / -beta) never reaches Maven Central, consistent with cut-release.ps1's final-vs-pre-release split. - publish.yml: a manual workflow_dispatch bypassed the job `if:` (its first operand is unconditionally true for dispatch), so a dispatched pre-release tag could publish. Add a first-stage step that rejects any tag not matching ^vX.Y.Z$ (tag is passed via env, not inlined, to avoid ref-name injection). - cut-release.ps1: validate the version up front against ^\d+\.\d+\.\d+(-(rc|alpha|beta)\.\d+)?$, so an arbitrary suffix like 2.1.0-preview.1 is rejected rather than silently taken as a pre-release. Only print the "verify Maven Central" / "smoke published artifacts" reminders for a final release. - release.yml: a pre-release has no CHANGELOG section of its own, so it now uses the upcoming final version's notes (NOTES_TAG=${TAG%%-*}) instead of a generic fallback. - VersionConsistencyGuardTest.latestPublishedRelease(): restrict to a final semver header (\d+\.\d+\.\d+), so a dated pre-release header is never treated as the published Central version. - release-script-check.yml: add a 2.1.0-preview.1 rejection assertion (trapping the script's terminating throw with try/catch — *>&1 does not catch a throw in-process). Verified: final (2.0.2) and pre-release (2.1.0-rc.1) dry-runs exit 0; 2.1.0-preview.1 is rejected in-process; VersionConsistencyGuardTest 12/12; the publish tag guard accepts v2.0.2 and rejects hyphenated/malformed tags; ${TAG%%-*} yields the final tag; both workflow YAMLs valid. No tag, no publish; develop stays 2.0.1-SNAPSHOT. --- .github/workflows/publish.yml | 72 +++ .github/workflows/release-script-check.yml | 112 +++++ .github/workflows/release.yml | 12 +- .../VersionConsistencyGuardTest.java | 31 +- docs/contributing/release-process.md | 23 +- scripts/cut-release.ps1 | 419 +++++++++++++++--- 6 files changed, 579 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/release-script-check.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d7ce9320..0a3b9014 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,6 +37,21 @@ on: description: 'Existing v*-prefixed tag to (re-)publish' required: true type: string + start_at: + description: 'Resume the module deploys from here. Keep "all" for a normal publish; pick a module ONLY to complete a confirmed-partial publication — never blindly re-run a full deploy, Central rejects re-uploading an already-published coordinate.' + required: false + type: choice + default: all + options: + - all + - core + - render-pdf + - wrapper + - render-docx + - render-pptx + - templates + - testing + - bundle permissions: contents: read @@ -63,6 +78,22 @@ jobs: JAVA_TOOL_OPTIONS: -Djava.awt.headless=true steps: + - name: Reject non-final tags (Central publishes vX.Y.Z only) + # This job runs on tag pushes AND workflow_dispatch. The job `if:` skips a PUSHED + # pre-release tag, but a manual dispatch bypasses that clause (the first `if` + # operand is unconditionally true for workflow_dispatch), so guard here as the + # very first stage: Maven Central never receives a pre-release / snapshot tag, + # matching cut-release.ps1's final-vs-pre-release split. The tag is passed via env + # (not inlined into the script) to avoid shell injection from a crafted ref name. + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Maven Central accepts only final vX.Y.Z tags: '$TAG' is a pre-release/snapshot and must not be published." + exit 1 + fi + echo "Publishing final release $TAG to Maven Central." + - name: Check out repository at tag uses: actions/checkout@v7 with: @@ -111,7 +142,41 @@ 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: Plan the deploy set (start_at resume) + id: plan + # A partial Central publication CANNOT be blindly re-dispatched: the deploys + # always start at core, and Central rejects re-uploading a coordinate that + # already validated, so a full re-run fails at the first already-published + # module before ever reaching the missing ones. `start_at` lets a maintainer + # resume from the first UNPUBLISHED module after confirming which coordinates + # are already live (via `mvn dependency:get`, or the release-smoke matrix). The + # default `all` — and every tag-triggered run, where the input is empty — + # deploys the whole train. The `clean install` preflight already seeded local m2 + # with every module, so a skipped earlier deploy never breaks a later one's deps. + env: + START_AT: ${{ github.event.inputs.start_at }} + run: | + order="core render-pdf wrapper render-docx render-pptx templates testing bundle" + # Fail loudly on an unknown start_at: an unrecognised value would leave every + # run_* false and silently publish NOTHING while the job stayed green. Empty + # (a tag-triggered run) is allowed and means "all". + if [ -n "$START_AT" ]; then + case " $order all " in + *" $START_AT "*) ;; + *) echo "::error::Invalid start_at: '$START_AT'"; exit 1 ;; + esac + fi + started="false" + if [ -z "$START_AT" ] || [ "$START_AT" = "all" ]; then started="true"; fi + for m in $order; do + if [ "$m" = "$START_AT" ]; then started="true"; fi + key="run_$(echo "$m" | tr '-' '_')" + echo "$key=$started" >> "$GITHUB_OUTPUT" + done + echo "Deploying the train from: ${START_AT:-all}" + - name: Publish engine to Maven Central + if: steps.plan.outputs.run_core == 'true' # Activates the release profile (sources + javadoc + gpg sign + # central-publishing) and flips gpg.skip=false. The deploy # phase invokes central-publishing-maven-plugin's upload goal @@ -127,6 +192,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish render-pdf to Maven Central + if: steps.plan.outputs.run_render_pdf == 'true' # The PDF render backend, lockstep-versioned with the engine (ships on the # same v* tag). graph-compose-core resolves from the local repo installed by # the engine deploy above; the wrapper below depends on this. @@ -137,6 +203,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish graph-compose (compat wrapper) to Maven Central + if: steps.plan.outputs.run_wrapper == 'true' # The empty jar that keeps the `graph-compose` coordinate a drop-in: it # depends on graph-compose-core + graph-compose-render-pdf, resolved from the # local repo installed by the deploys above. Ships on the same v* tag. @@ -147,6 +214,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish render-docx to Maven Central + if: steps.plan.outputs.run_render_docx == 'true' # The semantic DOCX backend, lockstep-versioned with the engine (ships on # the same v* tag). graph-compose-core resolves from the local repo installed # by the engine deploy above. @@ -157,6 +225,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish render-pptx to Maven Central + if: steps.plan.outputs.run_render_pptx == 'true' # The semantic PPTX backend, lockstep-versioned with the engine (ships on # the same v* tag). graph-compose-core resolves from the local repo installed # by the engine deploy above. @@ -167,6 +236,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish templates to Maven Central + if: steps.plan.outputs.run_templates == 'true' # The built-in document templates, lockstep-versioned with the engine (ships # on the same v* tag). graph-compose-core resolves from the local repo # installed by the engine deploy above. @@ -177,6 +247,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish testing to Maven Central + if: steps.plan.outputs.run_testing == 'true' # Consumer testing support (LayoutSnapshotAssertions / PdfVisualRegression), # lockstep-versioned with the engine (ships on the same v* tag). graph-compose # resolves from the local repo installed by the engine deploy above. @@ -187,6 +258,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish bundle to Maven Central + if: steps.plan.outputs.run_bundle == 'true' # The graph-compose-bundle convenience aggregate pins this engine # version + compatible graph-compose-fonts and graph-compose-emoji # versions. It tracks the engine line, so it ships on the same v* tag. diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml new file mode 100644 index 00000000..4eb13cf2 --- /dev/null +++ b/.github/workflows/release-script-check.yml @@ -0,0 +1,112 @@ +name: Release Script Check + +# Guards scripts/cut-release.ps1. The main CI path filters skip build/release-script +# changes, so without this a break in the release tooling (a parse error, a bad +# parameter set, a throw in the pre-flight / version bump / metadata gate) would ship +# unnoticed and only surface mid-release. This runs the script's dry-runs — which +# exercise every code path except the actual git/Maven mutations — plus a focused unit +# check of the pure version helper. It creates no tag and touches no remote. + +on: + push: + paths: + - 'scripts/cut-release.ps1' + - '.github/workflows/release-script-check.yml' + pull_request: + paths: + - 'scripts/cut-release.ps1' + - '.github/workflows/release-script-check.yml' + +permissions: + contents: read + +jobs: + dry-run: + name: cut-release.ps1 dry-run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Dry-run a full release cut + shell: pwsh + run: | + # -DryRun mutates nothing; -SkipShowcase skips the mvnw install the showcase + # regen would run. A parse error, a bad parameter set, or any throw fails the + # step. The version is a throwaway — nothing is written or pushed. + ./scripts/cut-release.ps1 -Version 9.9.9 -Branch develop -DryRun -SkipShowcase + if ($LASTEXITCODE -ne 0) { throw "full-cut dry-run exited $LASTEXITCODE" } + + - name: Dry-run PostReleaseOnly + shell: pwsh + run: | + ./scripts/cut-release.ps1 -PostReleaseOnly -Branch develop -DryRun + if ($LASTEXITCODE -ne 0) { throw "PostReleaseOnly dry-run exited $LASTEXITCODE" } + + - name: Dry-run a pre-release (RC) cut + shell: pwsh + run: | + # A pre-release (X.Y.Z-rc.N) ships only to the GitHub Release pre-release surface, + # never to Maven Central, so the cut must NOT require 'Latest stable' or rewrite the + # Central install snippets. Capture ALL streams (*>&1) — the notices are Write-Host. + $out = ./scripts/cut-release.ps1 -Version 2.1.0-rc.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "pre-release dry-run exited $LASTEXITCODE" } + if ($out -notmatch 'pre-release \(2\.1\.0-rc\.1\)') { throw "expected the pre-release notice" } + if ($out -notmatch 'skipped .* install-snippet bumps') { throw "expected the snippet-skip notice" } + if ($out -match 'bumped README Maven Central snippet') { throw "pre-release must NOT bump the README install snippet" } + if ($out -match "Latest stable.*does not name v2\.1\.0-rc\.1") { throw "pre-release must NOT check 'Latest stable'" } + Write-Host "pre-release path: Latest-stable + Central snippets correctly left on last stable." + + # An unsupported version (arbitrary suffix) must be REJECTED, not silently + # treated as a pre-release. The script rejects via `throw` — a TERMINATING + # error — which `*>&1 | Out-String` does NOT trap when the script runs + # in-process (dot-invocation, as here); it must be caught with try/catch, and + # $LASTEXITCODE is not set by a throw so it is not a usable signal. + $bad = try { ./scripts/cut-release.ps1 -Version 2.1.0-preview.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String } + catch { $_ | Out-String } + if ($bad -notmatch 'Unsupported version') { throw "expected the unsupported-version error, got: $bad" } + Write-Host "unsupported version 2.1.0-preview.1: correctly rejected." + + - name: Unit-check Get-NextSnapshotVersion + shell: pwsh + run: | + # Extract the pure helper (from its declaration to the first column-0 '}') + # and load it in isolation, then assert the version arithmetic. + $lines = Get-Content scripts/cut-release.ps1 + $match = $lines | Select-String -Pattern '^function Get-NextSnapshotVersion' | Select-Object -First 1 + if (-not $match) { throw "Get-NextSnapshotVersion not found" } + $start = $match.LineNumber - 1 + $end = -1 + for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -eq '}') { $end = $i; break } } + if ($end -lt 0) { throw "could not find the function's closing brace" } + Invoke-Expression (($lines[$start..$end]) -join "`n") + + $cases = @{ '2.0.0' = '2.0.1-SNAPSHOT'; '2.3.9' = '2.3.10-SNAPSHOT'; '1.9.1' = '1.9.2-SNAPSHOT'; + '2.0.0-rc.1' = $null; '2.0.1-SNAPSHOT' = $null } + foreach ($k in $cases.Keys) { + $got = Get-NextSnapshotVersion $k + if ($got -ne $cases[$k]) { throw "Get-NextSnapshotVersion '$k' -> '$got', expected '$($cases[$k])'" } + } + Write-Host "Get-NextSnapshotVersion: all cases passed." + + - name: Unit-check Test-ReadmeLatestStable (real README) + shell: pwsh + run: | + # Load the real stale-tag-README guard and run it against the ACTUAL README — + # exercises its regex on live content, not just the dry-run's print. Robust + # across releases: assert it matches the version the README currently names, + # and rejects a bogus one. + $repoRoot = (Get-Location).Path + $lines = Get-Content scripts/cut-release.ps1 + $match = $lines | Select-String -Pattern '^function Test-ReadmeLatestStable' | Select-Object -First 1 + if (-not $match) { throw "Test-ReadmeLatestStable not found" } + $start = $match.LineNumber - 1 + $end = -1 + for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -eq '}') { $end = $i; break } } + if ($end -lt 0) { throw "could not find the function's closing brace" } + Invoke-Expression (($lines[$start..$end]) -join "`n") + + $cur = [regex]::Match((Get-Content README.md -Raw), '\*\*Latest stable\*\*:\s*\[v([\w\.\-]+)\]').Groups[1].Value + if (-not $cur) { throw "could not read the README 'Latest stable' version" } + if (-not (Test-ReadmeLatestStable $cur)) { throw "Test-ReadmeLatestStable failed for the README's own version $cur" } + if (Test-ReadmeLatestStable '0.0.0-nonexistent') { throw "Test-ReadmeLatestStable matched a bogus version" } + Write-Host "Test-ReadmeLatestStable: README names v$cur (bogus rejected)." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28e91dd8..7e7752f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,18 +51,24 @@ jobs: id: notes run: | TAG="${GITHUB_REF_NAME}" + # A pre-release (vX.Y.Z-rc.N / -alpha / -beta) has no CHANGELOG section of its + # own — it is cut against the upcoming FINAL version's notes. Strip the + # pre-release suffix so the lookup uses the final section (## vX.Y.Z, whether + # still "— Planned" or already dated). A final tag has no suffix, so NOTES_TAG + # == TAG. + NOTES_TAG="${TAG%%-*}" NOTES_FILE="${RUNNER_TEMP}/release-notes.md" - # Print the "## — ..." heading and every line up to the + # Print the "## — ..." heading and every line up to the # next "## v" heading. index()==1 is a literal prefix match, so # the dots in the version are not treated as regex wildcards and # the trailing space stops "## v1.6.5 " from matching "v1.6.50". - awk -v hdr="## ${TAG} " ' + awk -v hdr="## ${NOTES_TAG} " ' index($0, hdr) == 1 { flag = 1; print; next } /^## v/ && flag { flag = 0 } flag { print } ' CHANGELOG.md > "${NOTES_FILE}" if [ ! -s "${NOTES_FILE}" ]; then - echo "::warning::No CHANGELOG section found for ${TAG}; using a generic note." + echo "::warning::No CHANGELOG section found for ${NOTES_TAG}; using a generic note." printf '%s\n' "Release ${TAG}. See [CHANGELOG.md](CHANGELOG.md) for details." > "${NOTES_FILE}" fi echo "notes_file=${NOTES_FILE}" >> "${GITHUB_OUTPUT}" diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index 870ed0d8..a08475d6 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -343,21 +343,22 @@ void companionReadmeInstallSnippetsMatchTheirPomVersions() throws Exception { /** * Returns the set of versions an install snippet may legitimately advertise. * - *

During a normal {@code -SNAPSHOT} development cycle this is only - * the latest published release — the version actually resolvable on - * Maven Central — so a user who copies a snippet always gets a coordinate that - * resolves today, never the in-development {@code -SNAPSHOT} nor a - * forward-looking {@code Planned} version. In the release commit itself - * {@code cut-release.ps1} bumps the pom off {@code -SNAPSHOT} to the new - * release version and rewrites the snippets to match, so once the pom is a - * concrete release version the snippets must equal it.

+ *

Only a final release ({@code X.Y.Z}, no suffix) lands on Maven + * Central, so only then must the snippets equal the pom version. For any non-final + * working version — a normal {@code -SNAPSHOT} development cycle, or a pre-release + * ({@code X.Y.Z-rc.N} / {@code -alpha} / {@code -beta}), which the publish workflow + * never ships to Central — the snippets must stay on the latest published + * release, the version a user can actually resolve today, never the + * in-development or unpublished pre-release version. In the release commit itself + * {@code cut-release.ps1} bumps the pom to the final release version and rewrites the + * snippets to match, so once the pom is a concrete final version the two must agree.

*/ private Set acceptableTargets() throws Exception { String pomVersion = effectiveVersion(PROJECT_ROOT.resolve("core/pom.xml")); - if (pomVersion.endsWith("-SNAPSHOT")) { - return Set.of(latestPublishedRelease()); + if (pomVersion.matches("\\d+\\.\\d+\\.\\d+")) { + return Set.of(pomVersion); } - return Set.of(pomVersion); + return Set.of(latestPublishedRelease()); } /** @@ -367,10 +368,14 @@ private Set acceptableTargets() throws Exception { */ private String latestPublishedRelease() throws Exception { String changelog = Files.readString(PROJECT_ROOT.resolve("CHANGELOG.md")); - Matcher released = Pattern.compile("^## v([0-9][^ \\n]*)\\s*[\\u2014\\-]\\s*\\d{4}-\\d{2}-\\d{2}", Pattern.MULTILINE) + // 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); assertThat(released.find()) - .describedAs("CHANGELOG.md must contain a dated release entry (## vX.Y.Z — YYYY-MM-DD) to anchor the install snippets") + .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); } diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index cd6728b5..c9293736 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -64,13 +64,17 @@ Running `pwsh ./scripts/cut-release.ps1 -Version ` performs: 1. **Pre-flight** — re-checks all of A above (branch, clean tree, in-sync, no existing tag). 2. **Bump versions** to `` across the library `pom.xml`, the `aggregator/pom.xml`, the inherited `` refs in `examples/pom.xml` and `benchmarks/pom.xml`, the standalone `bundle/pom.xml` (`graph-compose-bundle`), **and** the README Maven + Gradle install snippets — all in one pass, so `VersionConsistencyGuardTest` stays green at Step 5. (`fonts/pom.xml` is left alone — it versions independently; see §2.D.) 3. **Date the CHANGELOG** — flips `## v — Planned` to `## v`. +3b. **Validate release metadata** — a fast, build-free pre-tag gate: the CHANGELOG is dated for the target, the README `Latest stable` prose block names the target, the README install snippet reads the target, and every published-train pom carries the target version. Fails immediately (before showcase / verify / commit / tag) if any is stale. This is the **only automated guard against the stale-tag-README bug** — `cut-release.ps1` does not rewrite the `Latest stable` prose block, and no test covers it. (The full per-module + Gradle + showcase snippet consistency is separately enforced by `VersionConsistencyGuardTest` in the Step-5 verify.) 4. **Switch ShowcaseMetadata GH_BASE** from `/blob/develop` to `/blob/v` and regenerate `web/examples.json`. -5. **`mvnw verify -pl .`** — full sanity build (skip with `-SkipVerify` only if you just ran it). +5. **`mvnw verify`** — full reactor sanity build (the older 1.x layout scopes to `-pl .`; the script auto-detects by `core/pom.xml`). 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`. 6. **Commit** as `Release v`. Files committed: the library `pom.xml`, `aggregator/pom.xml`, `examples/pom.xml`, `benchmarks/pom.xml`, `bundle/pom.xml`, `README.md` (install snippets), `CHANGELOG.md`, `ShowcaseMetadata.java`, `web/examples.json`, `web/index.html`, and `web/showcase/`. `examples/README.md` and any other docs 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), and `-PostReleaseOnly` (skip release work entirely, only flip GH_BASE back to `/blob/develop` and push). +The script supports `-DryRun` (preview every step), `-SkipPush` (commit + tag locally only), `-SkipVerify` (skip the verify + japicmp gates), 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` and flips GH_BASE back to `/blob/develop`, then commits and pushes. 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. --- @@ -95,7 +99,8 @@ Run within 1 hour of the tag push. Independent steps can run in parallel. 4. **Verify CI green on main** — `gh run list --branch main --limit 1` shows `success` for the tag commit. 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 26+ `Generated:` lines. -7. **Flip ShowcaseMetadata back to develop** — `pwsh ./scripts/cut-release.ps1 -PostReleaseOnly`. This restores linkable "View Code" buttons for ongoing v1.x.y dev work. +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. 8. **GitHub Release — automated.** Pushing the `v` tag triggers [`.github/workflows/release.yml`](../../.github/workflows/release.yml): it re-runs `./mvnw clean verify -pl .` 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 the four artefacts (main / sources / javadoc / pom) 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. @@ -264,6 +269,18 @@ The published jar is final. **Never force-move a tag** that Maven Central has al | `GenerateAllExamples` dies mid-run on a specific PDF | Windows file lock from an open viewer | Ask the user to close the viewer; do not retry blindly | | `cut-release.ps1` aborts at "Working tree has uncommitted changes" | Untracked junk (zero-byte `{,`, `0)` etc.) or unstaged pre-release fix | Verify each is 0 bytes, delete by exact name; never `git clean -fd` blindly | +### Release-publication failure recovery + +The GitHub Release ([`release.yml`](../../.github/workflows/release.yml)) and the Maven Central publish ([`publish.yml`](../../.github/workflows/publish.yml)) run independently off the same `v*` tag, so one can fail without the other. Recovery never mutates the tag. + +| Symptom | Recovery | +|---|---| +| **GitHub Release not created** (release.yml failed or unavailable) | Re-run the workflow, or create it by hand: `gh release create v --notes-file `. The step is idempotent — safe to re-run. | +| **Central validation failed** (publish.yml red at a deploy/validate step) | The train deploys in dependency order — **core → render-pdf → wrapper → render-docx → render-pptx → templates → testing → bundle** — and each isolated deploy resolves inter-module deps from the local m2 the `clean install` preflight seeds (that is why the preflight uses `install`, not `verify`). Read the failing module's log, fix the cause (commonly a missing signature/sources jar or POM metadata). If it failed at **core**, re-dispatch `publish.yml` with `tag=v` (a full re-run). If earlier modules already validated, re-dispatch with `tag=v` **and `start_at=`** — the deploys always begin at core, and Central rejects re-uploading an already-validated coordinate, so a full re-run would choke on the first already-published module. | +| **Partial module publication** (some coordinates on Central, some not) | Do **not** blindly re-dispatch — the deploys always start at core, and Central rejects re-uploading an already-validated coordinate, so a full re-run fails at the first already-published module before ever reaching the missing ones. Inspect Central state (`mvn dependency:get` per coordinate, or the published-artifact matrix) to find the first **unpublished** module, then re-dispatch `publish.yml` with `tag=v` and `start_at=` to resume from there. Never bump the tag to force a re-publish. | +| **Stale documentation discovered after the tag** | The tag is immutable — do NOT move it. Fix forward on `develop`, fast-forward to `main`; the deployed site and the `main` README correct themselves. If the stale text lives inside the immutable tag's README, clarify it in the GitHub Release body rather than re-tagging. Prose is never grounds for a patch release. | +| **When a patch release IS required** | A published coordinate is missing and cannot be completed via re-dispatch; a published POM has wrong dependencies; the default `graph-compose` wrapper does not render PDF; or a confirmed runtime defect affects normal users. Keep the patch minimal (no features/refactors), explain the exact fix in the CHANGELOG, and repeat the full release verification (including the release-smoke suite, §2.B step 6b). | + --- ## 4. Lessons captured from past releases diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index 4a631ff2..e8aa126d 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -21,12 +21,16 @@ but do NOT push. Useful for staging a release locally before publishing. - -PostReleaseOnly — skip the release work entirely. Just flips - ShowcaseMetadata.GH_BASE back to /blob/, - re-runs ShowcaseSync, and commits + - pushes that change. Use after a release - was cut and you want ongoing branch work - to have linkable View Code buttons. + -PostReleaseOnly — skip the release work entirely; open the next + development line. Bumps every train pom to the next + patch -SNAPSHOT (leaving the README/showcase install + snippets on the just-published release), runs + `mvnw validate` + VersionConsistencyGuardTest to + validate the bump, flips ShowcaseMetadata.GH_BASE back + to /blob/, re-runs ShowcaseSync, then commits + + pushes. Runs the same branch / clean-tree / origin-sync + preflight as a real cut. Idempotent: skips the bump when + the poms are already a -SNAPSHOT. -SkipVerify — skip the mvnw verify gate. Only use when you've just run verify yourself and don't @@ -65,8 +69,8 @@ Post-release reminder: after pushing the tag, merge the release branch into main so GitHub Pages picks up the new docs, then run - this script with -PostReleaseOnly -Branch to flip the - showcase links back for ongoing dev work. + this script with -PostReleaseOnly -Branch to open the next + -SNAPSHOT development line and flip the showcase links back. #> [CmdletBinding(DefaultParameterSetName='Release')] @@ -120,6 +124,54 @@ function Run($command) { } } +function Assert-BranchPreflight($branch) { + # Shared safety gate for BOTH a full release cut and -PostReleaseOnly: the current + # branch is the target branch, the working tree is clean, and the local branch is + # in sync with origin. In -DryRun the gate is relaxed so the flow can be previewed + # from any branch (e.g. while iterating on this script); live runs fail loudly. + $currentBranch = (git rev-parse --abbrev-ref HEAD).Trim() + if ($DryRun) { + Note "branch: $currentBranch (gate relaxed for -DryRun)" + return + } + + # 1. On the target branch? + if ($currentBranch -ne $branch) { + throw "Not on $branch branch (currently on $currentBranch). Switch to $branch first." + } + Note "branch: $branch OK" + + # 2. Working tree clean? `git status --porcelain` reports STAGED (column 1), + # unstaged (column 2), and untracked entries — any output means not clean. + if (git status --porcelain) { + throw "Working tree has uncommitted or staged changes. Commit or stash first." + } + Note "working tree: clean (incl. staged) OK" + + # 3. Local branch in sync with origin? A silently-failed fetch (network / auth) + # would compare against a STALE origin ref and wrongly report "in sync", so + # check every git exit code before trusting the comparison. + git fetch origin $branch --quiet + if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch origin/$branch. Cannot verify branch synchronization." + } + # Capture, THEN check the exit code, THEN .Trim(): calling .Trim() on a failed + # rev-parse's $null output would throw a confusing null-method error and skip the + # message below. + $local = git rev-parse $branch + if ($LASTEXITCODE -ne 0) { + throw "Failed to resolve local $branch (git rev-parse)." + } + $remote = git rev-parse "origin/$branch" + if ($LASTEXITCODE -ne 0) { + throw "Failed to resolve origin/$branch (git rev-parse)." + } + if ($local.Trim() -ne $remote.Trim()) { + throw "Local $branch ($local) is not in sync with origin/$branch ($remote). Pull/push first." + } + Note "in sync with origin/$branch OK" +} + function Update-PomVersion($pomPath, $newVersion) { if (-not (Test-Path $pomPath)) { Note "skip (no file): $pomPath" @@ -171,6 +223,79 @@ function Update-PomVersion($pomPath, $newVersion) { } } +function Get-NextSnapshotVersion($version) { + # A final release X.Y.Z opens the next patch development line X.Y.(Z+1)-SNAPSHOT. + # Pre-release versions (rc / beta / alpha) stay on their own cycle, so return + # $null and let the caller skip the post-release SNAPSHOT bump for them. + if ($version -match '^(\d+)\.(\d+)\.(\d+)$') { + return "$($Matches[1]).$($Matches[2]).$([int]$Matches[3] + 1)-SNAPSHOT" + } + return $null +} + +function Test-ReadmeLatestStable($version) { + # The README 'Latest stable' prose block (> ... **Latest stable**: [vX.Y.Z](...)) is a + # maintainer pre-cut edit that cut-release does NOT rewrite and no test guards. Returns + # $true when it names $version. Checked in Step 0 BEFORE any file mutation, so a stale + # line aborts the cut with a still-clean tree — a post-mutation check would leave a + # dirty tree that the next preflight run then refuses to work over. + $readme = Get-Content (Join-Path $repoRoot 'README.md') -Raw + return $readme -match "\*\*Latest stable\*\*:\s*\[v$([regex]::Escape($version))\]" +} + +function Assert-ReleaseMetadata($version, $isFinalRelease) { + # Fast, build-free validation of the metadata cut-release itself rewrote in Steps 1-2. + # Runs at Step 2b — after those mutations, before commit/tag; VersionConsistencyGuardTest + # re-checks it in the verify gate. The maintainer-authored README 'Latest stable' block + # is validated earlier, in Step 0 (Test-ReadmeLatestStable). The CHANGELOG-date and + # install-snippet checks are FINAL-release only: a pre-release carries no dated CHANGELOG + # entry (it is cut against the upcoming final's notes) and deliberately keeps the Central + # snippets on the last stable version. The pom-version check applies to both — the poms + # move to the (pre-)release version either way. + $problems = @() + + if ($isFinalRelease) { + # 1. CHANGELOG carries a DATED entry for this version (Step 2 flips Planned -> dated). + $changelog = Get-Content (Join-Path $repoRoot 'CHANGELOG.md') -Raw + if ($changelog -notmatch "(?m)^## v$([regex]::Escape($version))\s+[—-]\s+\d{4}-\d{2}-\d{2}\b") { + $problems += "CHANGELOG.md has no dated '## v$version - YYYY-MM-DD' entry (still 'Planned', or missing)." + } + + # 2. README Maven install snippet advertises this version (Step 1 rewrites it; the + # full per-module + Gradle + showcase check is VersionConsistencyGuardTest in the + # verify gate — this is the fast representative check). + $readme = Get-Content (Join-Path $repoRoot 'README.md') -Raw + $snippet = [regex]::Match($readme, 'graph-compose\s*v?([\w\.\-]+)') + if (-not $snippet.Success) { + $problems += "README.md has no graph-compose Maven install snippet (graph-compose ... ...)." + } elseif ($snippet.Groups[1].Value -ne $version) { + $problems += "README.md install snippet is $($snippet.Groups[1].Value), expected $version." + } + } + + # 3. Every train pom's own is this version (belt-and-suspenders ahead + # of the verify-gate guard; fonts/emoji version independently and are skipped). + foreach ($pom in @('core/pom.xml', 'pom.xml', 'render-pdf/pom.xml', 'render-docx/pom.xml', + 'render-pptx/pom.xml', 'templates/pom.xml', 'testing/pom.xml', 'wrapper/pom.xml', 'bundle/pom.xml')) { + $p = Join-Path $repoRoot $pom + if (Test-Path $p) { + $m = [regex]::Match((Get-Content $p -Raw), '([\w\.\-]+)') + if ($m.Success -and $m.Groups[1].Value -ne $version) { + $problems += "$pom is $($m.Groups[1].Value), expected $version." + } + } + } + + if ($problems.Count -gt 0) { + throw ("Release metadata validation failed:`n - " + ($problems -join "`n - ")) + } + if ($isFinalRelease) { + Note "release metadata: CHANGELOG dated, install snippet + train poms consistent OK" + } else { + Note "release metadata (pre-release): train poms consistent OK (snippets/Latest-stable left on last stable)" + } +} + function Update-ReadmeInstallVersion($readmePath, $newVersion) { if (-not (Test-Path $readmePath)) { Note "skip (no file): $readmePath" @@ -451,36 +576,118 @@ function Render-ReadmeBanner { if ($PostReleaseOnly) { Push-Location $repoRoot try { - Step 1 "Switch ShowcaseMetadata GH_BASE back to /blob/$Branch" - $changed = Update-ShowcaseGhBase $Branch + Step 0 "Pre-flight checks" + # Same branch / clean-tree / origin-sync gate as a real cut: -PostReleaseOnly + # also commits and pushes to $Branch, so it must not run from the wrong branch, + # over a dirty tree, or out of sync with origin. + Assert-BranchPreflight $Branch + + # The released version is whatever the train poms currently carry (a cut leaves + # them at the release version). Open the next patch development line from it. The + # 2.0 module layout keeps the engine version in core/pom.xml; the legacy 1.x tree + # has no core/ and retired post-release bumping, so read defensively and skip the + # bump when it is absent (the showcase flip below still runs). + $nextSnapshot = $null + $corePom = Join-Path $repoRoot 'core/pom.xml' + if (Test-Path $corePom) { + $currentVersion = [regex]::Match((Get-Content $corePom -Raw), '([\w\.\-]+)').Groups[1].Value + $nextSnapshot = Get-NextSnapshotVersion $currentVersion + } - if ($changed -or $DryRun) { + Step 1 "Switch ShowcaseMetadata GH_BASE back to /blob/$Branch" + $showcaseChanged = Update-ShowcaseGhBase $Branch + if ($showcaseChanged -or $DryRun) { Step 2 "Regenerate web/examples.json with $Branch links" Run-ShowcaseSync + } else { + Note "GH_BASE already points to $Branch." + } + + # Post-release version bump: move the train off the release version onto the + # next patch -SNAPSHOT so develop builds are distinguishable from the release + # AND the japicmp gate (baseline = the release) actually compares (it short- + # circuits when the working version equals the baseline). The README / showcase + # INSTALL SNIPPETS are deliberately NOT touched here: they keep advertising the + # version actually on Maven Central, which VersionConsistencyGuardTest requires + # during a -SNAPSHOT cycle. cut-release rewrites the snippets to the new version + # at the NEXT release commit. Idempotent: if the poms are already on a -SNAPSHOT, + # Get-NextSnapshotVersion returns $null and the bump is skipped. + $bumpedPoms = @() + 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', + 'qa/pom.xml', 'coverage/pom.xml', 'render-pdf/pom.xml', 'render-docx/pom.xml', + 'render-pptx/pom.xml', 'templates/pom.xml', 'testing/pom.xml', 'wrapper/pom.xml', 'bundle/pom.xml')) { + if (Test-Path (Join-Path $repoRoot $pom)) { + Update-PomVersion (Join-Path $repoRoot $pom) $nextSnapshot + $bumpedPoms += $pom + } + } + } else { + Step 3 "Skipped SNAPSHOT bump (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 + # every module at the new SNAPSHOT (catches an inconsistent bump / unresolvable + # parent), and VersionConsistencyGuardTest confirms the poms stay in lockstep and + # the install snippets still advertise the published release (the -SNAPSHOT-cycle + # rule). Only runs when poms were actually bumped. + if ($bumpedPoms.Count -gt 0) { + Step "3b" "Validate the bump (mvnw validate + VersionConsistencyGuardTest)" + # Pass args as SPLATTED arrays, never inline: the call operator `&` re-tokenizes + # an inline unquoted arg with a dot (e.g. -Djacoco.skip=true splits into + # `-Djacoco` and `.skip=true`), which Maven then reads as a bogus phase. Array + # elements are passed literally (same pattern the japicmp gate uses). + $validateArgs = @('-B', '-ntp', 'validate') + $guardArgs = @('-B', '-ntp', 'test', '-Dtest=VersionConsistencyGuardTest', '-pl', ':graph-compose-core', '-Djacoco.skip=true') + if ($DryRun) { + Write-Host " [DRY RUN] $mvnw $($validateArgs -join ' ')" -ForegroundColor Yellow + Write-Host " [DRY RUN] $mvnw $($guardArgs -join ' ')" -ForegroundColor Yellow + } else { + & $mvnw @validateArgs 2>&1 | ForEach-Object { + if ($_ -match 'BUILD SUCCESS|BUILD FAILURE|ERROR') { Write-Host " $_" -ForegroundColor DarkGray } + } + if ($LASTEXITCODE -ne 0) { throw "mvnw validate failed after the SNAPSHOT bump." } + & $mvnw @guardArgs 2>&1 | ForEach-Object { + if ($_ -match 'Tests run:|BUILD SUCCESS|BUILD FAILURE|ERROR') { Write-Host " $_" -ForegroundColor DarkGray } + } + if ($LASTEXITCODE -ne 0) { throw "VersionConsistencyGuardTest failed after the SNAPSHOT bump." } + Note "bump validated: reactor resolves + version guard green OK" + } + } - Step 3 "Commit" - $msg = "post-release: flip showcase links back to /blob/$Branch" + # Commit whatever changed: the bumped poms and/or the restored showcase files. + $filesToCommit = @() + if ($showcaseChanged -or $DryRun) { $filesToCommit += @($showcaseMetadata, 'web/examples.json') } + $filesToCommit += $bumpedPoms + if ($filesToCommit.Count -gt 0) { + $parts = @() + if ($bumpedPoms.Count -gt 0) { $parts += "open $nextSnapshot" } + if ($showcaseChanged -or $DryRun) { $parts += "restore /blob/$Branch showcase links" } + $msg = "chore(release): " + ($parts -join ' + ') + Step 4 "Commit" if ($DryRun) { + Write-Host " [DRY RUN] git add $($filesToCommit -join ' ')" -ForegroundColor Yellow Write-Host " [DRY RUN] git commit -m `"$msg`"" -ForegroundColor Yellow } else { - git add $showcaseMetadata 'web/examples.json' + git add @filesToCommit git commit -m $msg + Note "commit: $msg" } - - Step 4 "Push $Branch" + Step 5 "Push $Branch" if ($DryRun) { Write-Host " [DRY RUN] git push origin $Branch" -ForegroundColor Yellow } else { git push origin $Branch } } else { - Note "GH_BASE already points to $Branch. Nothing to do." + Note "Nothing to do (showcase already on /blob/$Branch and version already a SNAPSHOT)." } } finally { Pop-Location } Write-Host "" - Write-Host "Done. Ongoing $Branch work has linkable View Code buttons again." -ForegroundColor Green + Write-Host "Done. $Branch is on the next development line with linkable View Code buttons." -ForegroundColor Green return } @@ -490,54 +697,67 @@ if ($PostReleaseOnly) { Push-Location $repoRoot try { $tag = "v$Version" + # Only X.Y.Z (final) and X.Y.Z-{rc|alpha|beta}.N (pre-release) are supported. Reject + # anything else up front, so an unrecognised suffix (e.g. 2.1.0-preview.1) cannot + # silently fall into the pre-release path. + if ($Version -notmatch '^\d+\.\d+\.\d+(-(rc|alpha|beta)\.\d+)?$') { + throw "Unsupported version '$Version'. Use X.Y.Z (final) or X.Y.Z-{rc|alpha|beta}.N (pre-release)." + } + # A FINAL release is X.Y.Z with no suffix. Pre-releases (X.Y.Z-rc.N / -alpha / -beta) + # ship only to the GitHub Release pre-release surface — publish.yml skips them for + # Maven Central — so a pre-release cut must NOT touch the README 'Latest stable' block + # or the Central install snippets: they keep advertising the last stable, on-Central + # version. The poms still move to the pre-release version (the tag builds at it). + $isFinalRelease = $Version -match '^\d+\.\d+\.\d+$' + if (-not $isFinalRelease) { + Note "pre-release ($Version): README 'Latest stable' + Central install snippets stay on the last stable version" + } Step 0 "Pre-flight checks" - # In -DryRun mode the script never mutates anything, so the branch / - # working-tree / origin-sync gates are relaxed: a maintainer can preview - # what a release cut would do from a feature branch (e.g. while iterating - # on the script itself) without having to switch to the release branch and back. - # Live cuts still fail these gates loudly. - $currentBranch = (git rev-parse --abbrev-ref HEAD).Trim() - if ($DryRun) { - Note "branch: $currentBranch (gate relaxed for -DryRun)" - } else { - # 1. On the release branch (-Branch)? - if ($currentBranch -ne $Branch) { - throw "Not on $Branch branch (currently on $currentBranch). Switch to $Branch first." - } - Note "branch: $Branch OK" - - # 2. Working tree clean? - $status = git status --porcelain - if ($status) { - throw "Working tree has uncommitted changes. Commit or stash first." - } - Note "working tree: clean OK" - - # 3. In sync with origin? - git fetch origin $Branch --quiet - $local = (git rev-parse $Branch).Trim() - $remote = (git rev-parse origin/$Branch).Trim() - if ($local -ne $remote) { - throw "Local $Branch ($local) is not in sync with origin/$Branch ($remote). Pull/push first." - } - Note "in sync with origin/$Branch OK" - } + # Branch / clean-tree / origin-sync gate (shared with -PostReleaseOnly). + Assert-BranchPreflight $Branch - # 4. Tag doesn't already exist? - $existingTag = git tag -l $tag - if ($existingTag) { - throw "Tag $tag already exists. Bump version or delete the tag." - } - git fetch origin "refs/tags/$tag`:refs/tags/$tag" 2>&1 | Out-Null + # Tag doesn't already exist — locally or on origin? $existingTag = git tag -l $tag if ($existingTag) { + throw "Tag $tag already exists locally. Bump version or delete the tag." + } + # Query origin with ls-remote, NOT `git fetch refs/tags/...`: fetch exits 128 for a + # legitimately-absent tag (the normal pre-cut case), so its exit code cannot tell + # "tag free" from "network broke". ls-remote returns empty for an absent tag and + # fails only on a real network/auth error, so a broken connection can no longer be + # mistaken for "tag is free". + $remoteTag = git ls-remote --tags origin "refs/tags/$tag" + if ($LASTEXITCODE -ne 0) { + throw "Failed to query origin for tag $tag (git ls-remote). Cannot verify the tag is free." + } + if ($remoteTag) { throw "Tag $tag exists on origin. Bump version or delete the remote tag." } Note ("tag {0}: available OK" -f $tag) - Step 1 "Bump versions to $Version (poms + README install snippets)" + # README 'Latest stable' prose block must already name the target — FINAL releases + # only. Checked HERE, before Step 1 mutates any file, so a stale line aborts with a + # still-clean tree. (cut-release does not rewrite this block; it is a maintainer + # pre-cut edit.) A pre-release never becomes 'Latest stable', so there is nothing to + # check for it. + if ($isFinalRelease) { + if (Test-ReadmeLatestStable $Version) { + Note "README 'Latest stable' = v$Version OK" + } elseif ($DryRun) { + Write-Host " [DRY RUN] WARNING: README 'Latest stable' does not name v$Version - update it on $Branch before the real cut." -ForegroundColor Yellow + } else { + throw "README 'Latest stable' block does not name v$Version. Update it on $Branch before cutting (cut-release.ps1 does not rewrite this block)." + } + } + + if ($isFinalRelease) { + $step1Title = "Bump versions to $Version (poms + README install snippets)" + } else { + $step1Title = "Bump versions to $Version (poms only; snippets stay on last stable)" + } + Step 1 $step1Title # All ENGINE-LINE version sites must move together or # VersionConsistencyGuardTest fails the verify gate below: the standalone # the engine core/pom.xml (the published artifact), the root reactor @@ -580,16 +800,24 @@ try { # graph-compose-fonts dep is ${graphcompose.fonts.version} (stays pinned — # the bump regex does not touch the $-prefixed property reference). Update-PomVersion (Join-Path $repoRoot 'bundle/pom.xml') $Version - Update-ReadmeInstallVersion (Join-Path $repoRoot 'README.md') $Version - # Per-module README install snippets (train modules only — fonts/emoji pin - # their own independent versions). VersionConsistencyGuardTest fails the - # Step-5 verify if any of these lag the pom version. - foreach ($moduleReadme in @('core/README.md', 'render-pdf/README.md', 'render-docx/README.md', - 'render-pptx/README.md', 'templates/README.md', 'testing/README.md', - 'wrapper/README.md', 'bundle/README.md')) { - Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $moduleReadme) $Version + # Maven Central install snippets — FINAL releases only. A pre-release never lands on + # Central (publish.yml skips hyphenated tags), so rewriting these to the pre-release + # version would advertise a coordinate that 404s for any user who copies it; leave + # them on the last stable, published version. VersionConsistencyGuardTest accepts the + # latest published release for a non-final (SNAPSHOT or pre-release) working version. + if ($isFinalRelease) { + Update-ReadmeInstallVersion (Join-Path $repoRoot 'README.md') $Version + # Per-module README install snippets (train modules only — fonts/emoji pin + # their own independent versions). + foreach ($moduleReadme in @('core/README.md', 'render-pdf/README.md', 'render-docx/README.md', + 'render-pptx/README.md', 'templates/README.md', 'testing/README.md', + 'wrapper/README.md', 'bundle/README.md')) { + Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $moduleReadme) $Version + } + Update-IndexHtmlVersion (Join-Path $repoRoot 'web/index.html') $Version + } else { + Note "pre-release: skipped README / module-README / web install-snippet bumps (stay on last stable)" } - Update-IndexHtmlVersion (Join-Path $repoRoot 'web/index.html') $Version # The Next.js site/ and the docs->site/public mirror were retired when the static # showcase moved to web/ (deployed directly via .github/workflows/deploy-web.yml). # Only web/ is version-bumped now. @@ -614,6 +842,19 @@ try { } } + Step "2b" "Validate release metadata (fast pre-tag gate)" + # Runs in milliseconds, before the showcase regen / verify / commit / tag, so a + # misconfigured release fails immediately. Validates the metadata cut-release just + # mutated in Steps 1-2 (CHANGELOG date, install snippet, train pom versions). In + # -DryRun those mutations were only previewed (files still at the pre-cut state), so + # the assertion is deferred to the real cut. (The README 'Latest stable' block was + # already validated in Step 0, before any mutation.) + if ($DryRun) { + Note "[DRY RUN] train pom versions (+ CHANGELOG date & install snippet for a final release) are validated on the real cut" + } else { + Assert-ReleaseMetadata $Version $isFinalRelease + } + if (-not $SkipShowcase) { Step 3 "Switch ShowcaseMetadata GH_BASE to /blob/$tag" Update-ShowcaseGhBase $tag | Out-Null @@ -652,6 +893,37 @@ try { Step 5 "Skipped mvnw verify (-SkipVerify)" } + if (-not $SkipVerify) { + Step "5b" "Binary-compatibility gate (japicmp vs the published baseline)" + # Confirm the graph-compose-core public API stays binary-compatible with the + # japicmp baseline BEFORE the tag is cut — independent of the PR-time CI japicmp + # job, which a direct-to-branch push could bypass. 2.0 module layout only (core/ + # present); the legacy 1.x single-artifact tree has no such gate. Precondition: + # the baseline (japicmp.baseline in core/pom.xml) must already be on Central — so + # this gate is meaningful from 2.0.1 onward (vs the published 2.0.0), not on the + # first-of-a-major cut that publishes the baseline itself. + 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') + if ($DryRun) { + Write-Host " [DRY RUN] $mvnw $($japicmpArgs -join ' ')" -ForegroundColor Yellow + } else { + & $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." + } + Note "japicmp: binary-compatible with the baseline OK" + } + } else { + Note "japicmp gate skipped (1.x single-artifact layout)" + } + } else { + Step "5b" "Skipped japicmp gate (-SkipVerify)" + } + Step 6 "Commit release" $commitMsg = "Release v$Version" # Version/doc files always ship; the showcase files only when it was regenerated. @@ -733,12 +1005,17 @@ try { Write-Host "Release $tag committed locally." -ForegroundColor Green Write-Host "" Write-Host "Next steps (manual):" -ForegroundColor Cyan - Write-Host " 1. Merge $Branch into main on GitHub (PR or fast-forward)." -ForegroundColor Cyan - Write-Host " This makes the deployed GitHub Pages site pick up $tag." -ForegroundColor Cyan - Write-Host " 2. Create a GitHub Release for $tag with the CHANGELOG section as body." -ForegroundColor Cyan - Write-Host " 3. Verify the Maven Central publish (publish.yml) resolved: mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=$Version" -ForegroundColor Cyan - Write-Host " 4. Flip showcase links back to ${Branch}:" -ForegroundColor Cyan - Write-Host " pwsh ./scripts/cut-release.ps1 -PostReleaseOnly -Branch $Branch" -ForegroundColor Cyan + Write-Host " - Merge $Branch into main on GitHub (fast-forward) so GitHub Pages picks up $tag." -ForegroundColor Cyan + Write-Host " - Create a GitHub Release for $tag with the CHANGELOG section as body." -ForegroundColor Cyan + if ($isFinalRelease) { + Write-Host " - Verify the Maven Central publish resolved: mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=$Version" -ForegroundColor Cyan + Write-Host " - Smoke-test the PUBLISHED train once Central has indexed it: dispatch the Release Smoke" -ForegroundColor Cyan + Write-Host " workflow with version=$Version, or run: bash scripts/release-smoke/run.sh --version $Version" -ForegroundColor Cyan + } else { + Write-Host " - Pre-release: NOT published to Maven Central (publish.yml skips hyphenated tags), so there" -ForegroundColor Cyan + Write-Host " is no Central verify or release-smoke; the GitHub Release ships as a pre-release only." -ForegroundColor Cyan + } + Write-Host " - Open the next development line + restore showcase links: pwsh ./scripts/cut-release.ps1 -PostReleaseOnly -Branch $Branch" -ForegroundColor Cyan } finally { Pop-Location } From 2f80193c5477c5790fe3e8718ab840d7cc1322c4 Mon Sep 17 00:00:00 2001 From: Artem Demchyshyn <132658418+DemchaAV@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:40:20 +0100 Subject: [PATCH 6/7] docs(changelog): drop the premature v2.0.1 Planned entry (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.0.1 development line carries no user-facing change — its only note was the japicmp binary-compatibility enforcement, which is repo-internal tooling already documented in docs/api-stability.md. Advertising a "v2.0.1 — Planned" release with nothing installable is misleading, so drop the entry; the CHANGELOG top is the dated v2.0.0 again. develop stays on 2.0.1-SNAPSHOT — the japicmp gate needs a working version distinct from the 2.0.0 baseline — and a version entry is added when a real user-facing change lands. --- CHANGELOG.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5403c24..e1bcf89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,17 +3,6 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. -## v2.0.1 — Planned - -### Build - -- The binary-compatibility gate (japicmp) now **enforces** backward compatibility - against the published `2.0.0` baseline: an accidental binary-incompatible change to - the public API fails the build instead of only being reported. Development proceeds - on the `2.0.1-SNAPSHOT` line, so the gate compares the working tree against that - baseline and catches a regression before it can ship in a 2.x update. Source-level - incompatibilities remain report-only for now. - ## v2.0.0 — 2026-07-13 The 2.0 development line. Binary-breaking by design — japicmp runs report-only From da7d1848daed288a32fc1daaf92b1a183f8c7aee Mon Sep 17 00:00:00 2001 From: Artem Demchyshyn <132658418+DemchaAV@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:25:57 +0100 Subject: [PATCH 7/7] build(coverage): gate aggregate coverage against a non-regression ratchet (#400) The graph-compose-coverage module produced a cross-module JaCoCo aggregate report but enforced no threshold, so coverage could silently decline. Add a non-regression ratchet on the aggregate (core + render-pdf + templates, counting the qa cross-module exec). JaCoCo has no native aggregate-check goal, so the check reproduces the aggregate inputs: unpack the measured modules' classes, merge their execs, and run jacoco:check on the combined BUNDLE for INSTRUCTION and BRANCH covered-ratio. The floors (0.875 / 0.68) sit just below the current baseline (0.87857 / 0.68310, identical on Windows and CI Linux), leaving only rounding and cross-platform headroom. The check runs in the existing build-and-test verify step, which feeds the required CI Gate, so a coverage regression fails the PR with no new CI job. --- .github/workflows/ci.yml | 19 ++++--- coverage/pom.xml | 106 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1673a1d3..134fee62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,9 +154,11 @@ jobs: # only via the examples job), the extracted graph-compose-testing module, the # graph-compose-qa cross-module suites (which need the testing module + the # engine test-jar on the classpath and so cannot live in the engine's own test - # scope), and the graph-compose-coverage aggregator (report-only JaCoCo over - # core + qa exec). -am pulls the fonts / emoji upstreams; only examples / - # benchmarks are excluded (their own CI jobs cover them). + # scope), and the graph-compose-coverage aggregator (JaCoCo over core + + # render-pdf + templates, counting the qa exec, now with a NON-REGRESSION + # RATCHET — this step fails if aggregate INSTRUCTION or BRANCH coverage drops + # below the floor in coverage/pom.xml). -am pulls the fonts / emoji upstreams; + # only examples / benchmarks are excluded (their own CI jobs cover them). run: ./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose,:graph-compose-bundle,:graph-compose-qa,:graph-compose-coverage -am - name: Generate Javadoc @@ -168,14 +170,15 @@ jobs: run: ./mvnw -B -ntp javadoc:javadoc -pl :graph-compose-core - name: Upload aggregate coverage report - # Report-only: the cross-module JaCoCo report (core coverage counting the - # qa suites) is published as an artifact for inspection; no threshold gate. - # One JDK is enough — coverage is identical across JVMs. + # The cross-module JaCoCo report (core + render-pdf + templates coverage, + # counting the qa suites) is published as an artifact for inspection. The + # non-regression gate itself is enforced by the `verify` step above; this just + # captures the numbers. One JDK is enough — coverage is identical across JVMs. if: matrix.java == '17' uses: actions/upload-artifact@v7 with: - # Core-scope aggregate (engine coverage counting the qa suites); - # render-pdf / templates are a follow-up, so the name is explicit. + # Cross-module aggregate (core + render-pdf + templates, counting the qa + # suites). The artifact name is kept for continuity with earlier runs. name: coverage-core-aggregate-${{ github.run_id }} path: coverage/target/site/jacoco-aggregate/** if-no-files-found: error diff --git a/coverage/pom.xml b/coverage/pom.xml index fb9b6ae2..e1be3587 100644 --- a/coverage/pom.xml +++ b/coverage/pom.xml @@ -13,10 +13,13 @@ traverse test-scope dependencies, hence this dedicated module that compile-depends on the measured modules). - Report-only: no coverage threshold is enforced yet. The aggregate HTML/XML - lands in target/site/jacoco-aggregate/. Sits at the reactor tail (listed - last in the aggregator) so every measured module's exec exists first, and - never publishes (maven.deploy.skip=true, inherited from the parent). + Enforced: a non-regression ratchet on the aggregate (the jacoco check-aggregate + execution below fails the build if INSTRUCTION or BRANCH coverage drops below + the floor). The aggregate HTML/XML also lands in target/site/jacoco-aggregate/. + Sits at the reactor tail (listed last in the aggregator) so every measured + module's exec exists first, and never publishes (maven.deploy.skip=true, + inherited from the parent). The check runs in CI's build-and-test job (which + runs `verify` on this module), which feeds the required CI Gate. Scope: the engine (graph-compose-core), the PDF backend (graph-compose-render-pdf), and the built-in templates @@ -64,8 +67,54 @@ + + + 0.875 + 0.68 + 3.8.1 + + + + + org.apache.maven.plugins + maven-dependency-plugin + ${maven.dependency.plugin.version} + + + unpack-measured-classes + process-classes + + unpack-dependencies + + + + graph-compose-core,graph-compose-render-pdf,graph-compose-templates + **/*.class + ${project.build.outputDirectory} + true + true + + + + org.jacoco jacoco-maven-plugin @@ -78,6 +127,55 @@ report-aggregate + + merge-exec + verify + + merge + + + + + ${project.basedir}/.. + + core/target/jacoco.exec + render-pdf/target/jacoco.exec + templates/target/jacoco.exec + qa/target/jacoco.exec + + + + ${project.build.directory}/aggregate.exec + + + + check-aggregate + verify + + check + + + ${project.build.directory}/aggregate.exec + true + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${coverage.min.instruction} + + + BRANCH + COVEREDRATIO + ${coverage.min.branch} + + + + + +