Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
112 changes: 112 additions & 0 deletions .github/workflows/release-script-check.yml
Original file line number Diff line number Diff line change
@@ -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)."
12 changes: 9 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "## <tag> — ..." heading and every line up to the
# Print the "## <NOTES_TAG> — ..." 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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,21 +343,22 @@ void companionReadmeInstallSnippetsMatchTheirPomVersions() throws Exception {
/**
* Returns the set of versions an install snippet may legitimately advertise.
*
* <p>During a normal {@code -SNAPSHOT} development cycle this is <strong>only
* the latest published release</strong> — 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.</p>
* <p>Only a <strong>final</strong> 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 <strong>latest published
* release</strong>, 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.</p>
*/
private Set<String> 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());
}

/**
Expand All @@ -367,10 +368,14 @@ private Set<String> 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);
}
Expand Down
Loading
Loading