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
26 changes: 14 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 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 @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
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)."
41 changes: 41 additions & 0 deletions .github/workflows/release-smoke.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
Loading
Loading