diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 448f01be..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 @@ -291,12 +294,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..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: @@ -99,7 +130,53 @@ 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: 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 @@ -115,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. @@ -125,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. @@ -135,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. @@ -145,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. @@ -155,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. @@ -165,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. @@ -175,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-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/.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/benchmarks/pom.xml b/benchmarks/pom.xml index 620837ce..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 @@ -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/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 f8a6739a..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. @@ -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.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..a08475d6 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,43 @@ 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. + * + *

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 { - Set targets = new LinkedHashSet<>(); - targets.add(effectiveVersion(PROJECT_ROOT.resolve("core/pom.xml"))); + String pomVersion = effectiveVersion(PROJECT_ROOT.resolve("core/pom.xml")); + if (pomVersion.matches("\\d+\\.\\d+\\.\\d+")) { + return Set.of(pomVersion); + } + return Set.of(latestPublishedRelease()); + } + + /** + * 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) + // 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); - if (planned.find()) { - targets.add(planned.group(1)); - } - return targets; + assertThat(released.find()) + .describedAs("CHANGELOG.md must contain a dated final release entry (## vX.Y.Z — YYYY-MM-DD) to anchor the install snippets") + .isTrue(); + return released.group(1); } /** diff --git a/coverage/pom.xml b/coverage/pom.xml index eebd7c9d..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 @@ -26,7 +29,7 @@ io.github.demchaav graph-compose-build - 2.0.0 + 2.0.1-SNAPSHOT ../pom.xml @@ -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} + + + + + + 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. 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/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/examples/pom.xml b/examples/pom.xml index 3e545bc5..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 @@ -18,10 +18,10 @@ ${project.version} - 1.5.37 + 1.5.38 17 - 6.1.1 + 6.1.2 3.27.7 diff --git a/pom.xml b/pom.xml index dae0d2a9..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 @@ -51,7 +51,7 @@ coverage (qa) and aggregate it (coverage). Keep in lockstep with the standalone engine pom's jacoco.plugin.version. --> - 0.8.12 + 0.8.15 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. @@ -58,9 +58,9 @@ 17 5.5.1 - 6.1.1 + 6.1.2 3.27.7 - 1.5.37 + 1.5.38 1.0.0 1.0.0 diff --git a/render-pdf/pom.xml b/render-pdf/pom.xml index e01f19b2..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. @@ -64,13 +64,13 @@ 17 1.18.46 - 3.0.7 + 3.0.8 3.5.4 2.0.18 - 6.1.1 + 6.1.2 3.27.7 5.23.0 - 1.5.37 + 1.5.38 1.0.0 1.0.0 @@ -79,7 +79,7 @@ 3.5.6 - 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..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). @@ -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/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 } 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/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 + * + + + diff --git a/templates/pom.xml b/templates/pom.xml index aabb19ce..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. @@ -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..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. @@ -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 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.