diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d7ce9320..0a3b9014 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,6 +37,21 @@ on: description: 'Existing v*-prefixed tag to (re-)publish' required: true type: string + start_at: + description: 'Resume the module deploys from here. Keep "all" for a normal publish; pick a module ONLY to complete a confirmed-partial publication — never blindly re-run a full deploy, Central rejects re-uploading an already-published coordinate.' + required: false + type: choice + default: all + options: + - all + - core + - render-pdf + - wrapper + - render-docx + - render-pptx + - templates + - testing + - bundle permissions: contents: read @@ -63,6 +78,22 @@ jobs: JAVA_TOOL_OPTIONS: -Djava.awt.headless=true steps: + - name: Reject non-final tags (Central publishes vX.Y.Z only) + # This job runs on tag pushes AND workflow_dispatch. The job `if:` skips a PUSHED + # pre-release tag, but a manual dispatch bypasses that clause (the first `if` + # operand is unconditionally true for workflow_dispatch), so guard here as the + # very first stage: Maven Central never receives a pre-release / snapshot tag, + # matching cut-release.ps1's final-vs-pre-release split. The tag is passed via env + # (not inlined into the script) to avoid shell injection from a crafted ref name. + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Maven Central accepts only final vX.Y.Z tags: '$TAG' is a pre-release/snapshot and must not be published." + exit 1 + fi + echo "Publishing final release $TAG to Maven Central." + - name: Check out repository at tag uses: actions/checkout@v7 with: @@ -111,7 +142,41 @@ jobs: # already ran the full suite on this commit. run: ./mvnw -B -ntp -f core/pom.xml -P japicmp -Dmaven.test.skip=true verify + - name: Plan the deploy set (start_at resume) + id: plan + # A partial Central publication CANNOT be blindly re-dispatched: the deploys + # always start at core, and Central rejects re-uploading a coordinate that + # already validated, so a full re-run fails at the first already-published + # module before ever reaching the missing ones. `start_at` lets a maintainer + # resume from the first UNPUBLISHED module after confirming which coordinates + # are already live (via `mvn dependency:get`, or the release-smoke matrix). The + # default `all` — and every tag-triggered run, where the input is empty — + # deploys the whole train. The `clean install` preflight already seeded local m2 + # with every module, so a skipped earlier deploy never breaks a later one's deps. + env: + START_AT: ${{ github.event.inputs.start_at }} + run: | + order="core render-pdf wrapper render-docx render-pptx templates testing bundle" + # Fail loudly on an unknown start_at: an unrecognised value would leave every + # run_* false and silently publish NOTHING while the job stayed green. Empty + # (a tag-triggered run) is allowed and means "all". + if [ -n "$START_AT" ]; then + case " $order all " in + *" $START_AT "*) ;; + *) echo "::error::Invalid start_at: '$START_AT'"; exit 1 ;; + esac + fi + started="false" + if [ -z "$START_AT" ] || [ "$START_AT" = "all" ]; then started="true"; fi + for m in $order; do + if [ "$m" = "$START_AT" ]; then started="true"; fi + key="run_$(echo "$m" | tr '-' '_')" + echo "$key=$started" >> "$GITHUB_OUTPUT" + done + echo "Deploying the train from: ${START_AT:-all}" + - name: Publish engine to Maven Central + if: steps.plan.outputs.run_core == 'true' # Activates the release profile (sources + javadoc + gpg sign + # central-publishing) and flips gpg.skip=false. The deploy # phase invokes central-publishing-maven-plugin's upload goal @@ -127,6 +192,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish render-pdf to Maven Central + if: steps.plan.outputs.run_render_pdf == 'true' # The PDF render backend, lockstep-versioned with the engine (ships on the # same v* tag). graph-compose-core resolves from the local repo installed by # the engine deploy above; the wrapper below depends on this. @@ -137,6 +203,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish graph-compose (compat wrapper) to Maven Central + if: steps.plan.outputs.run_wrapper == 'true' # The empty jar that keeps the `graph-compose` coordinate a drop-in: it # depends on graph-compose-core + graph-compose-render-pdf, resolved from the # local repo installed by the deploys above. Ships on the same v* tag. @@ -147,6 +214,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish render-docx to Maven Central + if: steps.plan.outputs.run_render_docx == 'true' # The semantic DOCX backend, lockstep-versioned with the engine (ships on # the same v* tag). graph-compose-core resolves from the local repo installed # by the engine deploy above. @@ -157,6 +225,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish render-pptx to Maven Central + if: steps.plan.outputs.run_render_pptx == 'true' # The semantic PPTX backend, lockstep-versioned with the engine (ships on # the same v* tag). graph-compose-core resolves from the local repo installed # by the engine deploy above. @@ -167,6 +236,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish templates to Maven Central + if: steps.plan.outputs.run_templates == 'true' # The built-in document templates, lockstep-versioned with the engine (ships # on the same v* tag). graph-compose-core resolves from the local repo # installed by the engine deploy above. @@ -177,6 +247,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish testing to Maven Central + if: steps.plan.outputs.run_testing == 'true' # Consumer testing support (LayoutSnapshotAssertions / PdfVisualRegression), # lockstep-versioned with the engine (ships on the same v* tag). graph-compose # resolves from the local repo installed by the engine deploy above. @@ -187,6 +258,7 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - name: Publish bundle to Maven Central + if: steps.plan.outputs.run_bundle == 'true' # The graph-compose-bundle convenience aggregate pins this engine # version + compatible graph-compose-fonts and graph-compose-emoji # versions. It tracks the engine line, so it ships on the same v* tag. diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml new file mode 100644 index 00000000..4eb13cf2 --- /dev/null +++ b/.github/workflows/release-script-check.yml @@ -0,0 +1,112 @@ +name: Release Script Check + +# Guards scripts/cut-release.ps1. The main CI path filters skip build/release-script +# changes, so without this a break in the release tooling (a parse error, a bad +# parameter set, a throw in the pre-flight / version bump / metadata gate) would ship +# unnoticed and only surface mid-release. This runs the script's dry-runs — which +# exercise every code path except the actual git/Maven mutations — plus a focused unit +# check of the pure version helper. It creates no tag and touches no remote. + +on: + push: + paths: + - 'scripts/cut-release.ps1' + - '.github/workflows/release-script-check.yml' + pull_request: + paths: + - 'scripts/cut-release.ps1' + - '.github/workflows/release-script-check.yml' + +permissions: + contents: read + +jobs: + dry-run: + name: cut-release.ps1 dry-run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Dry-run a full release cut + shell: pwsh + run: | + # -DryRun mutates nothing; -SkipShowcase skips the mvnw install the showcase + # regen would run. A parse error, a bad parameter set, or any throw fails the + # step. The version is a throwaway — nothing is written or pushed. + ./scripts/cut-release.ps1 -Version 9.9.9 -Branch develop -DryRun -SkipShowcase + if ($LASTEXITCODE -ne 0) { throw "full-cut dry-run exited $LASTEXITCODE" } + + - name: Dry-run PostReleaseOnly + shell: pwsh + run: | + ./scripts/cut-release.ps1 -PostReleaseOnly -Branch develop -DryRun + if ($LASTEXITCODE -ne 0) { throw "PostReleaseOnly dry-run exited $LASTEXITCODE" } + + - name: Dry-run a pre-release (RC) cut + shell: pwsh + run: | + # A pre-release (X.Y.Z-rc.N) ships only to the GitHub Release pre-release surface, + # never to Maven Central, so the cut must NOT require 'Latest stable' or rewrite the + # Central install snippets. Capture ALL streams (*>&1) — the notices are Write-Host. + $out = ./scripts/cut-release.ps1 -Version 2.1.0-rc.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "pre-release dry-run exited $LASTEXITCODE" } + if ($out -notmatch 'pre-release \(2\.1\.0-rc\.1\)') { throw "expected the pre-release notice" } + if ($out -notmatch 'skipped .* install-snippet bumps') { throw "expected the snippet-skip notice" } + if ($out -match 'bumped README Maven Central snippet') { throw "pre-release must NOT bump the README install snippet" } + if ($out -match "Latest stable.*does not name v2\.1\.0-rc\.1") { throw "pre-release must NOT check 'Latest stable'" } + Write-Host "pre-release path: Latest-stable + Central snippets correctly left on last stable." + + # An unsupported version (arbitrary suffix) must be REJECTED, not silently + # treated as a pre-release. The script rejects via `throw` — a TERMINATING + # error — which `*>&1 | Out-String` does NOT trap when the script runs + # in-process (dot-invocation, as here); it must be caught with try/catch, and + # $LASTEXITCODE is not set by a throw so it is not a usable signal. + $bad = try { ./scripts/cut-release.ps1 -Version 2.1.0-preview.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String } + catch { $_ | Out-String } + if ($bad -notmatch 'Unsupported version') { throw "expected the unsupported-version error, got: $bad" } + Write-Host "unsupported version 2.1.0-preview.1: correctly rejected." + + - name: Unit-check Get-NextSnapshotVersion + shell: pwsh + run: | + # Extract the pure helper (from its declaration to the first column-0 '}') + # and load it in isolation, then assert the version arithmetic. + $lines = Get-Content scripts/cut-release.ps1 + $match = $lines | Select-String -Pattern '^function Get-NextSnapshotVersion' | Select-Object -First 1 + if (-not $match) { throw "Get-NextSnapshotVersion not found" } + $start = $match.LineNumber - 1 + $end = -1 + for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -eq '}') { $end = $i; break } } + if ($end -lt 0) { throw "could not find the function's closing brace" } + Invoke-Expression (($lines[$start..$end]) -join "`n") + + $cases = @{ '2.0.0' = '2.0.1-SNAPSHOT'; '2.3.9' = '2.3.10-SNAPSHOT'; '1.9.1' = '1.9.2-SNAPSHOT'; + '2.0.0-rc.1' = $null; '2.0.1-SNAPSHOT' = $null } + foreach ($k in $cases.Keys) { + $got = Get-NextSnapshotVersion $k + if ($got -ne $cases[$k]) { throw "Get-NextSnapshotVersion '$k' -> '$got', expected '$($cases[$k])'" } + } + Write-Host "Get-NextSnapshotVersion: all cases passed." + + - name: Unit-check Test-ReadmeLatestStable (real README) + shell: pwsh + run: | + # Load the real stale-tag-README guard and run it against the ACTUAL README — + # exercises its regex on live content, not just the dry-run's print. Robust + # across releases: assert it matches the version the README currently names, + # and rejects a bogus one. + $repoRoot = (Get-Location).Path + $lines = Get-Content scripts/cut-release.ps1 + $match = $lines | Select-String -Pattern '^function Test-ReadmeLatestStable' | Select-Object -First 1 + if (-not $match) { throw "Test-ReadmeLatestStable not found" } + $start = $match.LineNumber - 1 + $end = -1 + for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -eq '}') { $end = $i; break } } + if ($end -lt 0) { throw "could not find the function's closing brace" } + Invoke-Expression (($lines[$start..$end]) -join "`n") + + $cur = [regex]::Match((Get-Content README.md -Raw), '\*\*Latest stable\*\*:\s*\[v([\w\.\-]+)\]').Groups[1].Value + if (-not $cur) { throw "could not read the README 'Latest stable' version" } + if (-not (Test-ReadmeLatestStable $cur)) { throw "Test-ReadmeLatestStable failed for the README's own version $cur" } + if (Test-ReadmeLatestStable '0.0.0-nonexistent') { throw "Test-ReadmeLatestStable matched a bogus version" } + Write-Host "Test-ReadmeLatestStable: README names v$cur (bogus rejected)." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28e91dd8..7e7752f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,18 +51,24 @@ jobs: id: notes run: | TAG="${GITHUB_REF_NAME}" + # A pre-release (vX.Y.Z-rc.N / -alpha / -beta) has no CHANGELOG section of its + # own — it is cut against the upcoming FINAL version's notes. Strip the + # pre-release suffix so the lookup uses the final section (## vX.Y.Z, whether + # still "— Planned" or already dated). A final tag has no suffix, so NOTES_TAG + # == TAG. + NOTES_TAG="${TAG%%-*}" NOTES_FILE="${RUNNER_TEMP}/release-notes.md" - # Print the "## — ..." heading and every line up to the + # Print the "## — ..." heading and every line up to the # next "## v" heading. index()==1 is a literal prefix match, so # the dots in the version are not treated as regex wildcards and # the trailing space stops "## v1.6.5 " from matching "v1.6.50". - awk -v hdr="## ${TAG} " ' + awk -v hdr="## ${NOTES_TAG} " ' index($0, hdr) == 1 { flag = 1; print; next } /^## v/ && flag { flag = 0 } flag { print } ' CHANGELOG.md > "${NOTES_FILE}" if [ ! -s "${NOTES_FILE}" ]; then - echo "::warning::No CHANGELOG section found for ${TAG}; using a generic note." + echo "::warning::No CHANGELOG section found for ${NOTES_TAG}; using a generic note." printf '%s\n' "Release ${TAG}. See [CHANGELOG.md](CHANGELOG.md) for details." > "${NOTES_FILE}" fi echo "notes_file=${NOTES_FILE}" >> "${GITHUB_OUTPUT}" diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index 870ed0d8..a08475d6 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -343,21 +343,22 @@ void companionReadmeInstallSnippetsMatchTheirPomVersions() throws Exception { /** * Returns the set of versions an install snippet may legitimately advertise. * - *

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

+ *

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

*/ private Set acceptableTargets() throws Exception { String pomVersion = effectiveVersion(PROJECT_ROOT.resolve("core/pom.xml")); - if (pomVersion.endsWith("-SNAPSHOT")) { - return Set.of(latestPublishedRelease()); + if (pomVersion.matches("\\d+\\.\\d+\\.\\d+")) { + return Set.of(pomVersion); } - return Set.of(pomVersion); + return Set.of(latestPublishedRelease()); } /** @@ -367,10 +368,14 @@ private Set acceptableTargets() throws Exception { */ private String latestPublishedRelease() throws Exception { String changelog = Files.readString(PROJECT_ROOT.resolve("CHANGELOG.md")); - Matcher released = Pattern.compile("^## v([0-9][^ \\n]*)\\s*[\\u2014\\-]\\s*\\d{4}-\\d{2}-\\d{2}", Pattern.MULTILINE) + // Only a FINAL semver header (## vX.Y.Z — YYYY-MM-DD) counts as published on + // Maven Central. A dated pre-release header (## vX.Y.Z-rc.N — …) must NOT be + // treated as the published version — pre-releases never ship to Central — so the + // version group is anchored to \d+\.\d+\.\d+ with no suffix. + Matcher released = Pattern.compile("^## v(\\d+\\.\\d+\\.\\d+)\\s*[\\u2014\\-]\\s*\\d{4}-\\d{2}-\\d{2}", Pattern.MULTILINE) .matcher(changelog); assertThat(released.find()) - .describedAs("CHANGELOG.md must contain a dated release entry (## vX.Y.Z — YYYY-MM-DD) to anchor the install snippets") + .describedAs("CHANGELOG.md must contain a dated final release entry (## vX.Y.Z — YYYY-MM-DD) to anchor the install snippets") .isTrue(); return released.group(1); } diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index cd6728b5..c9293736 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -64,13 +64,17 @@ Running `pwsh ./scripts/cut-release.ps1 -Version ` performs: 1. **Pre-flight** — re-checks all of A above (branch, clean tree, in-sync, no existing tag). 2. **Bump versions** to `` across the library `pom.xml`, the `aggregator/pom.xml`, the inherited `` refs in `examples/pom.xml` and `benchmarks/pom.xml`, the standalone `bundle/pom.xml` (`graph-compose-bundle`), **and** the README Maven + Gradle install snippets — all in one pass, so `VersionConsistencyGuardTest` stays green at Step 5. (`fonts/pom.xml` is left alone — it versions independently; see §2.D.) 3. **Date the CHANGELOG** — flips `## v — Planned` to `## v`. +3b. **Validate release metadata** — a fast, build-free pre-tag gate: the CHANGELOG is dated for the target, the README `Latest stable` prose block names the target, the README install snippet reads the target, and every published-train pom carries the target version. Fails immediately (before showcase / verify / commit / tag) if any is stale. This is the **only automated guard against the stale-tag-README bug** — `cut-release.ps1` does not rewrite the `Latest stable` prose block, and no test covers it. (The full per-module + Gradle + showcase snippet consistency is separately enforced by `VersionConsistencyGuardTest` in the Step-5 verify.) 4. **Switch ShowcaseMetadata GH_BASE** from `/blob/develop` to `/blob/v` and regenerate `web/examples.json`. -5. **`mvnw verify -pl .`** — full sanity build (skip with `-SkipVerify` only if you just ran it). +5. **`mvnw verify`** — full reactor sanity build (the older 1.x layout scopes to `-pl .`; the script auto-detects by `core/pom.xml`). Skip with `-SkipVerify` only if you just ran it. +5b. **Binary-compatibility gate** — `mvnw -P japicmp verify -pl :graph-compose-core` against the published baseline (2.0 module layout only). Fails the cut if the tagged code breaks binary compatibility of the `graph-compose-core` public API (the japicmp profile lives only in `core/pom.xml`) with the baseline — a second line of defence independent of the PR-time CI japicmp job, which a direct-to-branch push could bypass. Skipped by `-SkipVerify`. 6. **Commit** as `Release v`. Files committed: the library `pom.xml`, `aggregator/pom.xml`, `examples/pom.xml`, `benchmarks/pom.xml`, `bundle/pom.xml`, `README.md` (install snippets), `CHANGELOG.md`, `ShowcaseMetadata.java`, `web/examples.json`, `web/index.html`, and `web/showcase/`. `examples/README.md` and any other docs are NOT touched by the script — fix those pre-release. 7. **Annotated tag** `v` (`git tag -a -m "Release v"`). 8. **Push** `develop` and the tag to `origin` (skip with `-SkipPush`). -The script supports `-DryRun` (preview every step), `-SkipPush` (commit + tag locally only), and `-PostReleaseOnly` (skip release work entirely, only flip GH_BASE back to `/blob/develop` and push). +The script supports `-DryRun` (preview every step), `-SkipPush` (commit + tag locally only), `-SkipVerify` (skip the verify + japicmp gates), and `-PostReleaseOnly`. The latter skips release work entirely and instead **opens the next development line**: it bumps every train pom to the next patch `-SNAPSHOT` and flips GH_BASE back to `/blob/develop`, then commits and pushes. It deliberately leaves the README/showcase **install snippets on the just-published release** — during a `-SNAPSHOT` cycle they must advertise the version actually on Central, which `VersionConsistencyGuardTest` enforces; `cut-release.ps1` rewrites them to the new version at the next release commit. `-PostReleaseOnly` is idempotent: if the poms already carry a `-SNAPSHOT`, the bump is skipped (only the showcase flip runs, if needed). + +**Final vs pre-release.** A **final** release (`X.Y.Z`, no suffix) is the only kind that lands on Maven Central. A **pre-release** (`X.Y.Z-rc.N` / `-alpha` / `-beta`) ships only to the GitHub Release pre-release surface — [`publish.yml`](../../.github/workflows/publish.yml) skips hyphenated tags for Central. So `cut-release.ps1` splits its behaviour on `$isFinalRelease`: it bumps the train poms to the (pre-)release version either way, but for a **pre-release** it does **not** check the README `Latest stable` block and does **not** rewrite the README / module-README / showcase install snippets — those stay on the last stable, on-Central version (rewriting them to an RC would advertise a coordinate that 404s for anyone who copies it). `VersionConsistencyGuardTest` matches this: the snippets must equal the pom only when the pom is a concrete final version; for a `-SNAPSHOT` or a pre-release pom they must equal the latest *published* release. --- @@ -95,7 +99,8 @@ Run within 1 hour of the tag push. Independent steps can run in parallel. 4. **Verify CI green on main** — `gh run list --branch main --limit 1` shows `success` for the tag commit. 5. **Smoke-test the install snippet** — minimal POM in `$env:TEMP`, `mvn dependency:resolve` against the snippet copy-pasted from README, expect 0 exit. 6. **Re-run all examples against the published artifact** — `./mvnw -f examples/pom.xml clean package` followed by `exec:java -Dexec.mainClass=com.demcha.examples.GenerateAllExamples`. Expect 26+ `Generated:` lines. -7. **Flip ShowcaseMetadata back to develop** — `pwsh ./scripts/cut-release.ps1 -PostReleaseOnly`. This restores linkable "View Code" buttons for ongoing v1.x.y dev work. +6b. **Run the external release-smoke suite** — once Central has indexed the train, dispatch the **Release Smoke** workflow ([`.github/workflows/release-smoke.yml`](../../.github/workflows/release-smoke.yml)) with `version=`, or run `bash scripts/release-smoke/run.sh --version `. This resolves every published coordinate from Maven Central in a clean, GraphCompose-evicted repository (no reactor / local install) and exercises the documented consumer scenarios — the wrapper renders PDF, `graph-compose-core` alone throws `MissingBackendException`, core+render-pdf renders, and templates/testing/bundle perform their roles. It is the authoritative "a real user can install and use this" check; the minimal step-5 snippet resolve is a faster subset. (Release smoke tests **published** artifacts, so it necessarily runs post-publish, not pre-tag.) +7. **Open the next development line** — `pwsh ./scripts/cut-release.ps1 -PostReleaseOnly`. This bumps the train poms to the next patch `-SNAPSHOT` (so develop builds are distinguishable from the release and the japicmp gate compares against it) **and** restores linkable "View Code" buttons by flipping ShowcaseMetadata back to `/blob/develop`. The README/showcase install snippets stay on the just-published release. 8. **GitHub Release — automated.** Pushing the `v` tag triggers [`.github/workflows/release.yml`](../../.github/workflows/release.yml): it re-runs `./mvnw clean verify -pl .` against the tagged commit, then creates the Release with that version's CHANGELOG section as the body (hyphenated tags like `v1.7.0-rc.1` ship as pre-releases; the step is idempotent — it edits the notes if the Release already exists). The workflow titles it `GraphCompose v`; for a **minor** release, edit the title to add the codename (`v1.4`=cinematic, `v1.5`=intuitive, `v1.6`=expressive; patches drop it). Create the Release by hand (`gh release create v --notes-file `) only if the workflow is unavailable. 9. **Maven Central publish — automated (from v1.6.6).** The same `v` tag push triggers [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml): it re-runs `mvnw verify` at the tagged commit, signs the four artefacts (main / sources / javadoc / pom) with the repo's GPG key, and uploads to Maven Central via the `central-publishing-maven-plugin`. Hyphenated tags (`-rc`, `-alpha`, `-beta`, `-snapshot`) are skipped — those go only to the GitHub Release pre-release surface. `autoPublish=false` in the plugin config means the artefact lands in the Central validation queue; the maintainer flips the switch on [central.sonatype.com](https://central.sonatype.com) for the first publish, then can opt into auto-release in a follow-up. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the workflow turns green). 10. **Optional**: GitHub Discussions announcement (mirror the prior release's style; close with *"author intent, not coordinates"*), LinkedIn post, r/java post. @@ -264,6 +269,18 @@ The published jar is final. **Never force-move a tag** that Maven Central has al | `GenerateAllExamples` dies mid-run on a specific PDF | Windows file lock from an open viewer | Ask the user to close the viewer; do not retry blindly | | `cut-release.ps1` aborts at "Working tree has uncommitted changes" | Untracked junk (zero-byte `{,`, `0)` etc.) or unstaged pre-release fix | Verify each is 0 bytes, delete by exact name; never `git clean -fd` blindly | +### Release-publication failure recovery + +The GitHub Release ([`release.yml`](../../.github/workflows/release.yml)) and the Maven Central publish ([`publish.yml`](../../.github/workflows/publish.yml)) run independently off the same `v*` tag, so one can fail without the other. Recovery never mutates the tag. + +| Symptom | Recovery | +|---|---| +| **GitHub Release not created** (release.yml failed or unavailable) | Re-run the workflow, or create it by hand: `gh release create v --notes-file `. The step is idempotent — safe to re-run. | +| **Central validation failed** (publish.yml red at a deploy/validate step) | The train deploys in dependency order — **core → render-pdf → wrapper → render-docx → render-pptx → templates → testing → bundle** — and each isolated deploy resolves inter-module deps from the local m2 the `clean install` preflight seeds (that is why the preflight uses `install`, not `verify`). Read the failing module's log, fix the cause (commonly a missing signature/sources jar or POM metadata). If it failed at **core**, re-dispatch `publish.yml` with `tag=v` (a full re-run). If earlier modules already validated, re-dispatch with `tag=v` **and `start_at=`** — the deploys always begin at core, and Central rejects re-uploading an already-validated coordinate, so a full re-run would choke on the first already-published module. | +| **Partial module publication** (some coordinates on Central, some not) | Do **not** blindly re-dispatch — the deploys always start at core, and Central rejects re-uploading an already-validated coordinate, so a full re-run fails at the first already-published module before ever reaching the missing ones. Inspect Central state (`mvn dependency:get` per coordinate, or the published-artifact matrix) to find the first **unpublished** module, then re-dispatch `publish.yml` with `tag=v` and `start_at=` to resume from there. Never bump the tag to force a re-publish. | +| **Stale documentation discovered after the tag** | The tag is immutable — do NOT move it. Fix forward on `develop`, fast-forward to `main`; the deployed site and the `main` README correct themselves. If the stale text lives inside the immutable tag's README, clarify it in the GitHub Release body rather than re-tagging. Prose is never grounds for a patch release. | +| **When a patch release IS required** | A published coordinate is missing and cannot be completed via re-dispatch; a published POM has wrong dependencies; the default `graph-compose` wrapper does not render PDF; or a confirmed runtime defect affects normal users. Keep the patch minimal (no features/refactors), explain the exact fix in the CHANGELOG, and repeat the full release verification (including the release-smoke suite, §2.B step 6b). | + --- ## 4. Lessons captured from past releases diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index 4a631ff2..e8aa126d 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -21,12 +21,16 @@ but do NOT push. Useful for staging a release locally before publishing. - -PostReleaseOnly — skip the release work entirely. Just flips - ShowcaseMetadata.GH_BASE back to /blob/, - re-runs ShowcaseSync, and commits + - pushes that change. Use after a release - was cut and you want ongoing branch work - to have linkable View Code buttons. + -PostReleaseOnly — skip the release work entirely; open the next + development line. Bumps every train pom to the next + patch -SNAPSHOT (leaving the README/showcase install + snippets on the just-published release), runs + `mvnw validate` + VersionConsistencyGuardTest to + validate the bump, flips ShowcaseMetadata.GH_BASE back + to /blob/, re-runs ShowcaseSync, then commits + + pushes. Runs the same branch / clean-tree / origin-sync + preflight as a real cut. Idempotent: skips the bump when + the poms are already a -SNAPSHOT. -SkipVerify — skip the mvnw verify gate. Only use when you've just run verify yourself and don't @@ -65,8 +69,8 @@ Post-release reminder: after pushing the tag, merge the release branch into main so GitHub Pages picks up the new docs, then run - this script with -PostReleaseOnly -Branch to flip the - showcase links back for ongoing dev work. + this script with -PostReleaseOnly -Branch to open the next + -SNAPSHOT development line and flip the showcase links back. #> [CmdletBinding(DefaultParameterSetName='Release')] @@ -120,6 +124,54 @@ function Run($command) { } } +function Assert-BranchPreflight($branch) { + # Shared safety gate for BOTH a full release cut and -PostReleaseOnly: the current + # branch is the target branch, the working tree is clean, and the local branch is + # in sync with origin. In -DryRun the gate is relaxed so the flow can be previewed + # from any branch (e.g. while iterating on this script); live runs fail loudly. + $currentBranch = (git rev-parse --abbrev-ref HEAD).Trim() + if ($DryRun) { + Note "branch: $currentBranch (gate relaxed for -DryRun)" + return + } + + # 1. On the target branch? + if ($currentBranch -ne $branch) { + throw "Not on $branch branch (currently on $currentBranch). Switch to $branch first." + } + Note "branch: $branch OK" + + # 2. Working tree clean? `git status --porcelain` reports STAGED (column 1), + # unstaged (column 2), and untracked entries — any output means not clean. + if (git status --porcelain) { + throw "Working tree has uncommitted or staged changes. Commit or stash first." + } + Note "working tree: clean (incl. staged) OK" + + # 3. Local branch in sync with origin? A silently-failed fetch (network / auth) + # would compare against a STALE origin ref and wrongly report "in sync", so + # check every git exit code before trusting the comparison. + git fetch origin $branch --quiet + if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch origin/$branch. Cannot verify branch synchronization." + } + # Capture, THEN check the exit code, THEN .Trim(): calling .Trim() on a failed + # rev-parse's $null output would throw a confusing null-method error and skip the + # message below. + $local = git rev-parse $branch + if ($LASTEXITCODE -ne 0) { + throw "Failed to resolve local $branch (git rev-parse)." + } + $remote = git rev-parse "origin/$branch" + if ($LASTEXITCODE -ne 0) { + throw "Failed to resolve origin/$branch (git rev-parse)." + } + if ($local.Trim() -ne $remote.Trim()) { + throw "Local $branch ($local) is not in sync with origin/$branch ($remote). Pull/push first." + } + Note "in sync with origin/$branch OK" +} + function Update-PomVersion($pomPath, $newVersion) { if (-not (Test-Path $pomPath)) { Note "skip (no file): $pomPath" @@ -171,6 +223,79 @@ function Update-PomVersion($pomPath, $newVersion) { } } +function Get-NextSnapshotVersion($version) { + # A final release X.Y.Z opens the next patch development line X.Y.(Z+1)-SNAPSHOT. + # Pre-release versions (rc / beta / alpha) stay on their own cycle, so return + # $null and let the caller skip the post-release SNAPSHOT bump for them. + if ($version -match '^(\d+)\.(\d+)\.(\d+)$') { + return "$($Matches[1]).$($Matches[2]).$([int]$Matches[3] + 1)-SNAPSHOT" + } + return $null +} + +function Test-ReadmeLatestStable($version) { + # The README 'Latest stable' prose block (> ... **Latest stable**: [vX.Y.Z](...)) is a + # maintainer pre-cut edit that cut-release does NOT rewrite and no test guards. Returns + # $true when it names $version. Checked in Step 0 BEFORE any file mutation, so a stale + # line aborts the cut with a still-clean tree — a post-mutation check would leave a + # dirty tree that the next preflight run then refuses to work over. + $readme = Get-Content (Join-Path $repoRoot 'README.md') -Raw + return $readme -match "\*\*Latest stable\*\*:\s*\[v$([regex]::Escape($version))\]" +} + +function Assert-ReleaseMetadata($version, $isFinalRelease) { + # Fast, build-free validation of the metadata cut-release itself rewrote in Steps 1-2. + # Runs at Step 2b — after those mutations, before commit/tag; VersionConsistencyGuardTest + # re-checks it in the verify gate. The maintainer-authored README 'Latest stable' block + # is validated earlier, in Step 0 (Test-ReadmeLatestStable). The CHANGELOG-date and + # install-snippet checks are FINAL-release only: a pre-release carries no dated CHANGELOG + # entry (it is cut against the upcoming final's notes) and deliberately keeps the Central + # snippets on the last stable version. The pom-version check applies to both — the poms + # move to the (pre-)release version either way. + $problems = @() + + if ($isFinalRelease) { + # 1. CHANGELOG carries a DATED entry for this version (Step 2 flips Planned -> dated). + $changelog = Get-Content (Join-Path $repoRoot 'CHANGELOG.md') -Raw + if ($changelog -notmatch "(?m)^## v$([regex]::Escape($version))\s+[—-]\s+\d{4}-\d{2}-\d{2}\b") { + $problems += "CHANGELOG.md has no dated '## v$version - YYYY-MM-DD' entry (still 'Planned', or missing)." + } + + # 2. README Maven install snippet advertises this version (Step 1 rewrites it; the + # full per-module + Gradle + showcase check is VersionConsistencyGuardTest in the + # verify gate — this is the fast representative check). + $readme = Get-Content (Join-Path $repoRoot 'README.md') -Raw + $snippet = [regex]::Match($readme, 'graph-compose\s*v?([\w\.\-]+)') + if (-not $snippet.Success) { + $problems += "README.md has no graph-compose Maven install snippet (graph-compose ... ...)." + } elseif ($snippet.Groups[1].Value -ne $version) { + $problems += "README.md install snippet is $($snippet.Groups[1].Value), expected $version." + } + } + + # 3. Every train pom's own is this version (belt-and-suspenders ahead + # of the verify-gate guard; fonts/emoji version independently and are skipped). + foreach ($pom in @('core/pom.xml', 'pom.xml', 'render-pdf/pom.xml', 'render-docx/pom.xml', + 'render-pptx/pom.xml', 'templates/pom.xml', 'testing/pom.xml', 'wrapper/pom.xml', 'bundle/pom.xml')) { + $p = Join-Path $repoRoot $pom + if (Test-Path $p) { + $m = [regex]::Match((Get-Content $p -Raw), '([\w\.\-]+)') + if ($m.Success -and $m.Groups[1].Value -ne $version) { + $problems += "$pom is $($m.Groups[1].Value), expected $version." + } + } + } + + if ($problems.Count -gt 0) { + throw ("Release metadata validation failed:`n - " + ($problems -join "`n - ")) + } + if ($isFinalRelease) { + Note "release metadata: CHANGELOG dated, install snippet + train poms consistent OK" + } else { + Note "release metadata (pre-release): train poms consistent OK (snippets/Latest-stable left on last stable)" + } +} + function Update-ReadmeInstallVersion($readmePath, $newVersion) { if (-not (Test-Path $readmePath)) { Note "skip (no file): $readmePath" @@ -451,36 +576,118 @@ function Render-ReadmeBanner { if ($PostReleaseOnly) { Push-Location $repoRoot try { - Step 1 "Switch ShowcaseMetadata GH_BASE back to /blob/$Branch" - $changed = Update-ShowcaseGhBase $Branch + Step 0 "Pre-flight checks" + # Same branch / clean-tree / origin-sync gate as a real cut: -PostReleaseOnly + # also commits and pushes to $Branch, so it must not run from the wrong branch, + # over a dirty tree, or out of sync with origin. + Assert-BranchPreflight $Branch + + # The released version is whatever the train poms currently carry (a cut leaves + # them at the release version). Open the next patch development line from it. The + # 2.0 module layout keeps the engine version in core/pom.xml; the legacy 1.x tree + # has no core/ and retired post-release bumping, so read defensively and skip the + # bump when it is absent (the showcase flip below still runs). + $nextSnapshot = $null + $corePom = Join-Path $repoRoot 'core/pom.xml' + if (Test-Path $corePom) { + $currentVersion = [regex]::Match((Get-Content $corePom -Raw), '([\w\.\-]+)').Groups[1].Value + $nextSnapshot = Get-NextSnapshotVersion $currentVersion + } - if ($changed -or $DryRun) { + Step 1 "Switch ShowcaseMetadata GH_BASE back to /blob/$Branch" + $showcaseChanged = Update-ShowcaseGhBase $Branch + if ($showcaseChanged -or $DryRun) { Step 2 "Regenerate web/examples.json with $Branch links" Run-ShowcaseSync + } else { + Note "GH_BASE already points to $Branch." + } + + # Post-release version bump: move the train off the release version onto the + # next patch -SNAPSHOT so develop builds are distinguishable from the release + # AND the japicmp gate (baseline = the release) actually compares (it short- + # circuits when the working version equals the baseline). The README / showcase + # INSTALL SNIPPETS are deliberately NOT touched here: they keep advertising the + # version actually on Maven Central, which VersionConsistencyGuardTest requires + # during a -SNAPSHOT cycle. cut-release rewrites the snippets to the new version + # at the NEXT release commit. Idempotent: if the poms are already on a -SNAPSHOT, + # Get-NextSnapshotVersion returns $null and the bump is skipped. + $bumpedPoms = @() + if ($nextSnapshot) { + Step 3 "Open the next development line: bump train poms to $nextSnapshot" + foreach ($pom in @('core/pom.xml', 'pom.xml', 'examples/pom.xml', 'benchmarks/pom.xml', + 'qa/pom.xml', 'coverage/pom.xml', 'render-pdf/pom.xml', 'render-docx/pom.xml', + 'render-pptx/pom.xml', 'templates/pom.xml', 'testing/pom.xml', 'wrapper/pom.xml', 'bundle/pom.xml')) { + if (Test-Path (Join-Path $repoRoot $pom)) { + Update-PomVersion (Join-Path $repoRoot $pom) $nextSnapshot + $bumpedPoms += $pom + } + } + } else { + Step 3 "Skipped SNAPSHOT bump (no core/pom.xml, or the current version is not a final X.Y.Z release)" + } + + # Validate the bump BEFORE committing or pushing: a reactor `validate` resolves + # every module at the new SNAPSHOT (catches an inconsistent bump / unresolvable + # parent), and VersionConsistencyGuardTest confirms the poms stay in lockstep and + # the install snippets still advertise the published release (the -SNAPSHOT-cycle + # rule). Only runs when poms were actually bumped. + if ($bumpedPoms.Count -gt 0) { + Step "3b" "Validate the bump (mvnw validate + VersionConsistencyGuardTest)" + # Pass args as SPLATTED arrays, never inline: the call operator `&` re-tokenizes + # an inline unquoted arg with a dot (e.g. -Djacoco.skip=true splits into + # `-Djacoco` and `.skip=true`), which Maven then reads as a bogus phase. Array + # elements are passed literally (same pattern the japicmp gate uses). + $validateArgs = @('-B', '-ntp', 'validate') + $guardArgs = @('-B', '-ntp', 'test', '-Dtest=VersionConsistencyGuardTest', '-pl', ':graph-compose-core', '-Djacoco.skip=true') + if ($DryRun) { + Write-Host " [DRY RUN] $mvnw $($validateArgs -join ' ')" -ForegroundColor Yellow + Write-Host " [DRY RUN] $mvnw $($guardArgs -join ' ')" -ForegroundColor Yellow + } else { + & $mvnw @validateArgs 2>&1 | ForEach-Object { + if ($_ -match 'BUILD SUCCESS|BUILD FAILURE|ERROR') { Write-Host " $_" -ForegroundColor DarkGray } + } + if ($LASTEXITCODE -ne 0) { throw "mvnw validate failed after the SNAPSHOT bump." } + & $mvnw @guardArgs 2>&1 | ForEach-Object { + if ($_ -match 'Tests run:|BUILD SUCCESS|BUILD FAILURE|ERROR') { Write-Host " $_" -ForegroundColor DarkGray } + } + if ($LASTEXITCODE -ne 0) { throw "VersionConsistencyGuardTest failed after the SNAPSHOT bump." } + Note "bump validated: reactor resolves + version guard green OK" + } + } - Step 3 "Commit" - $msg = "post-release: flip showcase links back to /blob/$Branch" + # Commit whatever changed: the bumped poms and/or the restored showcase files. + $filesToCommit = @() + if ($showcaseChanged -or $DryRun) { $filesToCommit += @($showcaseMetadata, 'web/examples.json') } + $filesToCommit += $bumpedPoms + if ($filesToCommit.Count -gt 0) { + $parts = @() + if ($bumpedPoms.Count -gt 0) { $parts += "open $nextSnapshot" } + if ($showcaseChanged -or $DryRun) { $parts += "restore /blob/$Branch showcase links" } + $msg = "chore(release): " + ($parts -join ' + ') + Step 4 "Commit" if ($DryRun) { + Write-Host " [DRY RUN] git add $($filesToCommit -join ' ')" -ForegroundColor Yellow Write-Host " [DRY RUN] git commit -m `"$msg`"" -ForegroundColor Yellow } else { - git add $showcaseMetadata 'web/examples.json' + git add @filesToCommit git commit -m $msg + Note "commit: $msg" } - - Step 4 "Push $Branch" + Step 5 "Push $Branch" if ($DryRun) { Write-Host " [DRY RUN] git push origin $Branch" -ForegroundColor Yellow } else { git push origin $Branch } } else { - Note "GH_BASE already points to $Branch. Nothing to do." + Note "Nothing to do (showcase already on /blob/$Branch and version already a SNAPSHOT)." } } finally { Pop-Location } Write-Host "" - Write-Host "Done. Ongoing $Branch work has linkable View Code buttons again." -ForegroundColor Green + Write-Host "Done. $Branch is on the next development line with linkable View Code buttons." -ForegroundColor Green return } @@ -490,54 +697,67 @@ if ($PostReleaseOnly) { Push-Location $repoRoot try { $tag = "v$Version" + # Only X.Y.Z (final) and X.Y.Z-{rc|alpha|beta}.N (pre-release) are supported. Reject + # anything else up front, so an unrecognised suffix (e.g. 2.1.0-preview.1) cannot + # silently fall into the pre-release path. + if ($Version -notmatch '^\d+\.\d+\.\d+(-(rc|alpha|beta)\.\d+)?$') { + throw "Unsupported version '$Version'. Use X.Y.Z (final) or X.Y.Z-{rc|alpha|beta}.N (pre-release)." + } + # A FINAL release is X.Y.Z with no suffix. Pre-releases (X.Y.Z-rc.N / -alpha / -beta) + # ship only to the GitHub Release pre-release surface — publish.yml skips them for + # Maven Central — so a pre-release cut must NOT touch the README 'Latest stable' block + # or the Central install snippets: they keep advertising the last stable, on-Central + # version. The poms still move to the pre-release version (the tag builds at it). + $isFinalRelease = $Version -match '^\d+\.\d+\.\d+$' + if (-not $isFinalRelease) { + Note "pre-release ($Version): README 'Latest stable' + Central install snippets stay on the last stable version" + } Step 0 "Pre-flight checks" - # In -DryRun mode the script never mutates anything, so the branch / - # working-tree / origin-sync gates are relaxed: a maintainer can preview - # what a release cut would do from a feature branch (e.g. while iterating - # on the script itself) without having to switch to the release branch and back. - # Live cuts still fail these gates loudly. - $currentBranch = (git rev-parse --abbrev-ref HEAD).Trim() - if ($DryRun) { - Note "branch: $currentBranch (gate relaxed for -DryRun)" - } else { - # 1. On the release branch (-Branch)? - if ($currentBranch -ne $Branch) { - throw "Not on $Branch branch (currently on $currentBranch). Switch to $Branch first." - } - Note "branch: $Branch OK" - - # 2. Working tree clean? - $status = git status --porcelain - if ($status) { - throw "Working tree has uncommitted changes. Commit or stash first." - } - Note "working tree: clean OK" - - # 3. In sync with origin? - git fetch origin $Branch --quiet - $local = (git rev-parse $Branch).Trim() - $remote = (git rev-parse origin/$Branch).Trim() - if ($local -ne $remote) { - throw "Local $Branch ($local) is not in sync with origin/$Branch ($remote). Pull/push first." - } - Note "in sync with origin/$Branch OK" - } + # Branch / clean-tree / origin-sync gate (shared with -PostReleaseOnly). + Assert-BranchPreflight $Branch - # 4. Tag doesn't already exist? - $existingTag = git tag -l $tag - if ($existingTag) { - throw "Tag $tag already exists. Bump version or delete the tag." - } - git fetch origin "refs/tags/$tag`:refs/tags/$tag" 2>&1 | Out-Null + # Tag doesn't already exist — locally or on origin? $existingTag = git tag -l $tag if ($existingTag) { + throw "Tag $tag already exists locally. Bump version or delete the tag." + } + # Query origin with ls-remote, NOT `git fetch refs/tags/...`: fetch exits 128 for a + # legitimately-absent tag (the normal pre-cut case), so its exit code cannot tell + # "tag free" from "network broke". ls-remote returns empty for an absent tag and + # fails only on a real network/auth error, so a broken connection can no longer be + # mistaken for "tag is free". + $remoteTag = git ls-remote --tags origin "refs/tags/$tag" + if ($LASTEXITCODE -ne 0) { + throw "Failed to query origin for tag $tag (git ls-remote). Cannot verify the tag is free." + } + if ($remoteTag) { throw "Tag $tag exists on origin. Bump version or delete the remote tag." } Note ("tag {0}: available OK" -f $tag) - Step 1 "Bump versions to $Version (poms + README install snippets)" + # README 'Latest stable' prose block must already name the target — FINAL releases + # only. Checked HERE, before Step 1 mutates any file, so a stale line aborts with a + # still-clean tree. (cut-release does not rewrite this block; it is a maintainer + # pre-cut edit.) A pre-release never becomes 'Latest stable', so there is nothing to + # check for it. + if ($isFinalRelease) { + if (Test-ReadmeLatestStable $Version) { + Note "README 'Latest stable' = v$Version OK" + } elseif ($DryRun) { + Write-Host " [DRY RUN] WARNING: README 'Latest stable' does not name v$Version - update it on $Branch before the real cut." -ForegroundColor Yellow + } else { + throw "README 'Latest stable' block does not name v$Version. Update it on $Branch before cutting (cut-release.ps1 does not rewrite this block)." + } + } + + if ($isFinalRelease) { + $step1Title = "Bump versions to $Version (poms + README install snippets)" + } else { + $step1Title = "Bump versions to $Version (poms only; snippets stay on last stable)" + } + Step 1 $step1Title # All ENGINE-LINE version sites must move together or # VersionConsistencyGuardTest fails the verify gate below: the standalone # the engine core/pom.xml (the published artifact), the root reactor @@ -580,16 +800,24 @@ try { # graph-compose-fonts dep is ${graphcompose.fonts.version} (stays pinned — # the bump regex does not touch the $-prefixed property reference). Update-PomVersion (Join-Path $repoRoot 'bundle/pom.xml') $Version - Update-ReadmeInstallVersion (Join-Path $repoRoot 'README.md') $Version - # Per-module README install snippets (train modules only — fonts/emoji pin - # their own independent versions). VersionConsistencyGuardTest fails the - # Step-5 verify if any of these lag the pom version. - foreach ($moduleReadme in @('core/README.md', 'render-pdf/README.md', 'render-docx/README.md', - 'render-pptx/README.md', 'templates/README.md', 'testing/README.md', - 'wrapper/README.md', 'bundle/README.md')) { - Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $moduleReadme) $Version + # Maven Central install snippets — FINAL releases only. A pre-release never lands on + # Central (publish.yml skips hyphenated tags), so rewriting these to the pre-release + # version would advertise a coordinate that 404s for any user who copies it; leave + # them on the last stable, published version. VersionConsistencyGuardTest accepts the + # latest published release for a non-final (SNAPSHOT or pre-release) working version. + if ($isFinalRelease) { + Update-ReadmeInstallVersion (Join-Path $repoRoot 'README.md') $Version + # Per-module README install snippets (train modules only — fonts/emoji pin + # their own independent versions). + foreach ($moduleReadme in @('core/README.md', 'render-pdf/README.md', 'render-docx/README.md', + 'render-pptx/README.md', 'templates/README.md', 'testing/README.md', + 'wrapper/README.md', 'bundle/README.md')) { + Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $moduleReadme) $Version + } + Update-IndexHtmlVersion (Join-Path $repoRoot 'web/index.html') $Version + } else { + Note "pre-release: skipped README / module-README / web install-snippet bumps (stay on last stable)" } - Update-IndexHtmlVersion (Join-Path $repoRoot 'web/index.html') $Version # The Next.js site/ and the docs->site/public mirror were retired when the static # showcase moved to web/ (deployed directly via .github/workflows/deploy-web.yml). # Only web/ is version-bumped now. @@ -614,6 +842,19 @@ try { } } + Step "2b" "Validate release metadata (fast pre-tag gate)" + # Runs in milliseconds, before the showcase regen / verify / commit / tag, so a + # misconfigured release fails immediately. Validates the metadata cut-release just + # mutated in Steps 1-2 (CHANGELOG date, install snippet, train pom versions). In + # -DryRun those mutations were only previewed (files still at the pre-cut state), so + # the assertion is deferred to the real cut. (The README 'Latest stable' block was + # already validated in Step 0, before any mutation.) + if ($DryRun) { + Note "[DRY RUN] train pom versions (+ CHANGELOG date & install snippet for a final release) are validated on the real cut" + } else { + Assert-ReleaseMetadata $Version $isFinalRelease + } + if (-not $SkipShowcase) { Step 3 "Switch ShowcaseMetadata GH_BASE to /blob/$tag" Update-ShowcaseGhBase $tag | Out-Null @@ -652,6 +893,37 @@ try { Step 5 "Skipped mvnw verify (-SkipVerify)" } + if (-not $SkipVerify) { + Step "5b" "Binary-compatibility gate (japicmp vs the published baseline)" + # Confirm the graph-compose-core public API stays binary-compatible with the + # japicmp baseline BEFORE the tag is cut — independent of the PR-time CI japicmp + # job, which a direct-to-branch push could bypass. 2.0 module layout only (core/ + # present); the legacy 1.x single-artifact tree has no such gate. Precondition: + # the baseline (japicmp.baseline in core/pom.xml) must already be on Central — so + # this gate is meaningful from 2.0.1 onward (vs the published 2.0.0), not on the + # first-of-a-major cut that publishes the baseline itself. + if (Test-Path (Join-Path $repoRoot 'core/pom.xml')) { + $japicmpArgs = @('-B', '-ntp', '-P', 'japicmp', '-Dmaven.test.skip=true', '-Djacoco.skip=true', 'verify', '-pl', ':graph-compose-core') + if ($DryRun) { + Write-Host " [DRY RUN] $mvnw $($japicmpArgs -join ' ')" -ForegroundColor Yellow + } else { + & $mvnw @japicmpArgs 2>&1 | ForEach-Object { + if ($_ -match 'BUILD SUCCESS|BUILD FAILURE|ERROR|incompatib') { + Write-Host " $_" -ForegroundColor DarkGray + } + } + if ($LASTEXITCODE -ne 0) { + throw "japicmp gate failed: the tagged code breaks binary compatibility with the published baseline." + } + Note "japicmp: binary-compatible with the baseline OK" + } + } else { + Note "japicmp gate skipped (1.x single-artifact layout)" + } + } else { + Step "5b" "Skipped japicmp gate (-SkipVerify)" + } + Step 6 "Commit release" $commitMsg = "Release v$Version" # Version/doc files always ship; the showcase files only when it was regenerated. @@ -733,12 +1005,17 @@ try { Write-Host "Release $tag committed locally." -ForegroundColor Green Write-Host "" Write-Host "Next steps (manual):" -ForegroundColor Cyan - Write-Host " 1. Merge $Branch into main on GitHub (PR or fast-forward)." -ForegroundColor Cyan - Write-Host " This makes the deployed GitHub Pages site pick up $tag." -ForegroundColor Cyan - Write-Host " 2. Create a GitHub Release for $tag with the CHANGELOG section as body." -ForegroundColor Cyan - Write-Host " 3. Verify the Maven Central publish (publish.yml) resolved: mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=$Version" -ForegroundColor Cyan - Write-Host " 4. Flip showcase links back to ${Branch}:" -ForegroundColor Cyan - Write-Host " pwsh ./scripts/cut-release.ps1 -PostReleaseOnly -Branch $Branch" -ForegroundColor Cyan + Write-Host " - Merge $Branch into main on GitHub (fast-forward) so GitHub Pages picks up $tag." -ForegroundColor Cyan + Write-Host " - Create a GitHub Release for $tag with the CHANGELOG section as body." -ForegroundColor Cyan + if ($isFinalRelease) { + Write-Host " - Verify the Maven Central publish resolved: mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=$Version" -ForegroundColor Cyan + Write-Host " - Smoke-test the PUBLISHED train once Central has indexed it: dispatch the Release Smoke" -ForegroundColor Cyan + Write-Host " workflow with version=$Version, or run: bash scripts/release-smoke/run.sh --version $Version" -ForegroundColor Cyan + } else { + Write-Host " - Pre-release: NOT published to Maven Central (publish.yml skips hyphenated tags), so there" -ForegroundColor Cyan + Write-Host " is no Central verify or release-smoke; the GitHub Release ships as a pre-release only." -ForegroundColor Cyan + } + Write-Host " - Open the next development line + restore showcase links: pwsh ./scripts/cut-release.ps1 -PostReleaseOnly -Branch $Branch" -ForegroundColor Cyan } finally { Pop-Location }