From 6829146ddcc3f901dd9108c60d29316dc3f8f96b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 13:06:55 +0100 Subject: [PATCH 1/6] build(publish): ship the release train to Maven Central as one deployment publish.yml deployed the lockstep train as eight separate `-f /pom.xml -P release deploy` runs, so one GraphCompose version was eight Central deployments - eight Release Count events, eight validation queues, eight Publish clicks - and a failure part-way left the first modules of a version published and the rest missing. The train now deploys in one reactor run over the root aggregator, `-P release deploy -pl` the eight train artifacts. The central-publishing plugin stages each module and uploads one central-bundle.zip from the last, so a version is one deployment holding eight components. The root aggregator, the build-only modules, fonts and emoji are not selected; fonts and emoji keep their own workflows. start_at is replaced by skip_published (boolean, default false), passed as -DignorePublishedComponents for recovering a partially published version. The uploaded zip is kept as a workflow artifact. PublishTrainGuardTest holds the deploy to one -pl run over exactly the lockstep modules derived from the poms, forbids also-make, keeps fonts, emoji and build-only modules out, requires identical plugin declarations across the train, and keeps the recovery switch opt-in. PublishedModules reads -pl deploys so the CodeQL scope guard keeps its inventory. --- .github/workflows/ci.yml | 7 +- .github/workflows/publish.yml | 221 ++++++------------ .../documentation/CodeQlScopeGuardTest.java | 50 +--- .../documentation/PublishTrainGuardTest.java | 210 +++++++++++++++++ .../documentation/PublishedModules.java | 151 +++++++++--- 5 files changed, 415 insertions(+), 224 deletions(-) create mode 100644 core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aae100913..b7674fffe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,8 +335,8 @@ jobs: # reader lands on, the entry point every snippet starts from, and the type # they spend the rest of their time in. # - # Three passes, mirroring the order publish.yml deploys in — and the order - # is the whole point, because getting it wrong is silent. + # Three passes, and the order is the whole point, because getting it wrong + # is silent. # # The wrapper's javadoc jar is configured in its `release` profile with # includeDependencySources, so it needs the ENGINE'S SOURCES JAR in the @@ -349,7 +349,8 @@ jobs: # published), which render-pdf needs at test scope. So: build the reactor # profile-free to get every module including that tests jar, re-install the # engine under `release` to add its sources jar, then build the wrapper. - # publish.yml gets there by deploying the engine before the wrapper. + # publish.yml gets there by building both in one `-P release` reactor run, + # where the engine's sources jar is attached before the wrapper needs it. if: matrix.java == '17' run: | set -euo pipefail diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8a64a547..878891248 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,6 +9,18 @@ name: Publish to Maven Central # workflow alone via workflow_dispatch if Central had a transient # validator hiccup without re-cutting the tag. # +# The whole lockstep train ships as ONE Central deployment: a single +# `-P release deploy -pl ` over the root reactor. The plugin stages +# every selected module, then bundles and uploads them together from the +# last one, so one GraphCompose version is one publish operation (one +# Release Count event) holding eight components, each still its own +# coordinate for consumers. The deployment validates and publishes as a +# unit, so a release can no longer end half on Central. The root +# aggregator is not selected (and nothing inherits its maven.deploy.skip); +# examples, benchmarks, qa, coverage, fonts and emoji are not selected. +# fonts and emoji keep their own tags and workflows (publish-fonts.yml, +# publish-emoji.yml) and publish only when their own version moves. +# # Hyphenated tags (rc / alpha / beta / SNAPSHOT) are skipped: those # ship only to the GitHub Release pre-release surface, never to Central # (Central's validator rejects SNAPSHOT-style coordinates anyway). @@ -37,30 +49,20 @@ 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.' + skip_published: + description: 'Recovery only: leave out components of this version that are already published on Maven Central, and publish the rest as one deployment. Keep false for a normal publish; set it ONLY after confirming which coordinates are live. A deployment that is still VALIDATED but unpublished is not "published" — publish or drop it in the Central portal first.' required: false - type: choice - default: all - options: - - all - - core - - render-pdf - - wrapper - - render-docx - - render-pptx - - templates - - testing - - bundle + type: boolean + default: false permissions: contents: read -# Serialize Maven Central publishes. A publish is a global, non-atomic sequence -# of eight module uploads, so two runs must never overlap — a constant group -# name funnels every tag / dispatch through one lane. cancel-in-progress is -# false so a re-tag or dispatch never aborts a publish already mid-upload; the -# second run queues behind it instead. +# Serialize Maven Central publishes. Two runs must never overlap — two +# uploads of the same version would race to validate the same coordinates — +# so a constant group name funnels every tag / dispatch through one lane. +# cancel-in-progress is false so a re-tag or dispatch never aborts a publish +# already mid-upload; the second run queues behind it instead. concurrency: group: publish-maven-central cancel-in-progress: false @@ -157,146 +159,63 @@ jobs: test -s templates/target/japicmp/japicmp-against-major-floor.xml test -s templates/target/japicmp/japicmp-against-previous-release.xml - - name: Build, test and install to local m2 (verify + seed the deploys) + - name: Build, test and install to local m2 (verify + seed the deploy) # Re-verify the tagged commit before publishing (defence in depth # against a tag pushed from a broken branch). `install` — not `verify` — # seeds the runner's local m2 with EVERY module artifact, including the # core tests-jar that render-pdf depends on at test scope but the release - # profile never publishes. Each per-module `-P release deploy` below - # builds in isolation and resolves its inter-module deps (Maven resolves - # test-scope deps even with -DskipTests) from this local m2, so the - # unpublished tests-jar no longer fails the deploy. It runs after the gate - # above, so the artifacts it installs cannot serve as baselines. + # profile never publishes (it unbinds that jar). The reactor deploy below + # therefore resolves it (Maven resolves test-scope deps even with + # -DskipTests) from this local m2, while every published artifact comes + # from the reactor itself. It runs after the gate above, so the artifacts + # it installs cannot serve as baselines. run: ./mvnw -B -ntp clean install - - 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 - # which blocks until Sonatype's validator confirms validation. - # The release profile also disables the tests-jar execution, so - # only the main + sources + javadoc + pom artefacts are uploaded. - # Inter-module deps (incl. the core tests-jar render-pdf needs) resolve - # from the local m2 seeded by the install step above. - run: ./mvnw -B -ntp -f core/pom.xml -P release -DskipTests -Dgpg.skip=false deploy - env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} - 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. - run: ./mvnw -B -ntp -f render-pdf/pom.xml -P release -DskipTests -Dgpg.skip=false deploy - env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} - 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. - run: ./mvnw -B -ntp -f wrapper/pom.xml -P release -DskipTests -Dgpg.skip=false deploy - env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} - 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. - run: ./mvnw -B -ntp -f render-docx/pom.xml -P release -DskipTests -Dgpg.skip=false deploy - env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - - - name: Publish render-pptx to Maven Central - if: steps.plan.outputs.run_render_pptx == 'true' - # The PPTX render backend — a fixed-layout POI XSLF backend consuming the - # same resolved LayoutGraph as the PDF one, alongside the older semantic - # manifest skeleton. 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. - run: ./mvnw -B -ntp -f render-pptx/pom.xml -P release -DskipTests -Dgpg.skip=false deploy + - name: Announce a recovery run + if: github.event.inputs.skip_published == 'true' env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} - 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. - run: ./mvnw -B -ntp -f templates/pom.xml -P release -DskipTests -Dgpg.skip=false deploy - env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} - 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. - run: ./mvnw -B -ntp -f testing/pom.xml -P release -DskipTests -Dgpg.skip=false deploy - env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} - 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. - # Packaged as an empty jar carrying only (like the - # graph-compose wrapper), so the signed jar + pom are uploaded. The - # train artifacts resolve from the preceding deploy steps; the - # independently versioned fonts and emoji artifacts resolve from Maven Central. - run: ./mvnw -B -ntp -f bundle/pom.xml -P release -DskipTests -Dgpg.skip=false deploy + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: echo "::warning::skip_published=true — components of $TAG already published on Maven Central are left out of this deployment. Recovery only." + + - name: Publish the release train to Maven Central (one deployment) + # One reactor run over exactly the lockstep train. `-P release` adds the + # sources + javadoc jars, GPG-signs every artefact at verify, and lets the + # central-publishing plugin replace deploy: each module is STAGED into one + # shared directory, and the last module bundles all of them into + # central-bundle.zip and uploads it once. The upload settings (autoPublish, + # waitUntil) are therefore read from that last module's release profile — + # PublishTrainGuardTest holds all eight identical. waitUntil=validated + # blocks until Central validates the whole deployment; autoPublish=false + # then leaves ONE deployment for the maintainer to publish on + # central.sonatype.com. + # + # A failure in any module before the upload stops the plugin from + # uploading anything ("Earlier build failures detected"), so nothing + # partial reaches Central. Never add -am / -amd: also-make would pull + # fonts and emoji (core's test-scope deps) into the reactor and the + # deployment, re-uploading coordinates they already published. + # + # ignorePublishedComponents is false on every tag push and on a dispatch + # that leaves skip_published unset. Set, the plugin asks the Central + # Portal which of these components are already published and leaves those + # out — the recovery for a version that is partially live. env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + SKIP_PUBLISHED: ${{ github.event.inputs.skip_published == 'true' }} CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: ./mvnw -B -ntp -P release -DskipTests -Dgpg.skip=false -DdeploymentName="GraphCompose $TAG" -DignorePublishedComponents=$SKIP_PUBLISHED deploy -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-bundle + + - name: Keep the deployment bundle with the run + # The exact zip the plugin uploaded (or would have uploaded), for audit + # and for a manual upload through the Central portal if the API path is + # unavailable. The plugin writes it under the first staged module's + # target/, hence the wildcard. + if: always() + uses: actions/upload-artifact@v7 + with: + name: central-bundle-${{ github.event.inputs.tag || github.ref_name }} + path: '*/target/central-publishing/central-bundle.zip' + if-no-files-found: warn + retention-days: 90 diff --git a/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java index 2065bfca9..35feb316a 100644 --- a/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java @@ -100,13 +100,14 @@ void everyDeployedModuleWithSourcesIsScanned() throws IOException { /** * The inventory the second test compares against is itself read out of the publish * workflows, so it can be emptied by editing them — and an emptier inventory is an - * easier comparison, not a failing one. Both halves below key on the absence of a - * positive signal instead: a publish workflow that deploys nothing, and a train - * whose declared modules are not among the steps read from it. + * easier comparison, not a failing one. This keys on the absence of a positive signal + * instead: a publish workflow that deploys nothing this guard can read. That the + * release train's deploy names every lockstep module is held separately, against the + * poms, by {@link PublishTrainGuardTest}. * - *

Writing a deploy as {@code -pl :graph-compose-fonts} rather than - * {@code -f fonts/pom.xml} is enough to do it, and nothing about that edit looks - * like it touches the scan.

+ *

Writing a deploy in a shape {@link PublishedModules} does not read — neither + * {@code -f /pom.xml} nor {@code -pl :artifact,…} — is enough to empty it, and + * nothing about that edit looks like it touches the scan.

*/ @Test void everyPublishWorkflowContributesToTheInventoryItIsRead() throws IOException { @@ -130,41 +131,8 @@ void everyPublishWorkflowContributesToTheInventoryItIsRead() throws IOException + "which will pass without ever asking about it. Either the workflow " + "stopped deploying — in which case it should stop being a publish " + "workflow — or its deploy is written some way other than " - + "`-f /pom.xml`, and this guard has to learn that shape before " - + "the edit lands") - .isEmpty(); - } - - /** - * The train {@code publish.yml} declares matches the steps it carries. - * - *

The workflow states its module set twice and neither statement is derived from - * the other: the {@code order} the resume input is validated against, and the deploy - * steps themselves. A module dropped from the steps — or written in a shape this - * guard cannot read — leaves the two disagreeing, which is the signal that the - * inventory shrank rather than the train.

- */ - @Test - void theDeployStepsCoverThePublishTrainTheWorkflowDeclares() throws IOException { - List declared = PublishedModules.declaredTrain(PROJECT_ROOT); - List steps = PublishedModules.deployedByWorkflow(PROJECT_ROOT) - .getOrDefault("publish.yml", List.of()); - - assertThat(declared) - .describedAs("publish.yml no longer declares its train as `order=\"...\"` — the " - + "resume validation moved, and with it the second, independent statement " - + "of what a release publishes that this guard holds the steps against") - .isNotEmpty(); - - Set missing = new TreeSet<>(declared); - missing.removeAll(steps); - - assertThat(missing) - .describedAs("publish.yml names these in its train but this guard finds no deploy " - + "step for them. Either the module stopped shipping and belongs out of " - + "the train, or its step is written some way other than " - + "`-f /pom.xml` — in which case the module is deployed, absent " - + "from the inventory, and therefore never checked against the scan") + + "`-f /pom.xml` or `-pl :artifact,…`, and PublishedModules has to " + + "learn that shape before the edit lands") .isEmpty(); } diff --git a/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java new file mode 100644 index 000000000..46971ad7a --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java @@ -0,0 +1,210 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Holds {@code publish.yml} to shipping the lockstep train as one Maven Central + * deployment, and nothing else. + * + *

Central counts every distinct publish operation against an organisation's monthly + * Release Count, so the train goes up as a single {@code -P release deploy -pl } + * reactor run: the central-publishing plugin stages each selected module and uploads + * them together from the last one. That shape has failure modes no build notices. A + * module left out of {@code -pl} is simply never released; an {@code -am} quietly pulls + * the independently versioned fonts and emoji into the deployment and re-uploads + * coordinates Central already holds; and because the upload runs with whichever + * module's plugin settings the reactor happens to order last, eight release profiles + * that drift apart publish with settings nobody chose.

+ * + *

The train is derived from the poms ({@link PublishedModules#lockstepPublished}), + * not restated here, so the workflow is held against an independent statement of what + * a release ships.

+ */ +class PublishTrainGuardTest { + + private static final Path PROJECT_ROOT = RepoRoot.get(); + private static final Path PUBLISH = PROJECT_ROOT.resolve(".github/workflows/publish.yml"); + + /** The central-publishing plugin declaration inside a pom. */ + private static final Pattern CENTRAL_PLUGIN = Pattern.compile( + "\\s*org\\.sonatype\\.central\\s*" + + "central-publishing-maven-plugin.*?", + Pattern.DOTALL); + + private static final Pattern CENTRAL_PLUGIN_VERSION = Pattern.compile( + "\\s*([^<]+?)\\s*"); + + /** Options that widen a {@code -pl} selection beyond the modules it names. */ + private static final Set ALSO_MAKE = Set.of("-am", "--also-make", "-amd", "--also-make-dependents"); + + /** + * Plugin settings that would silently change what a deployment carries if a pom set + * them: a pom value overrides the {@code -D} the workflow passes, so a stray + * {@code ignorePublishedComponents} turns recovery mode on for every release, and a + * {@code skipPublishing} or {@code excludeArtifacts} drops a module from the train + * while the job stays green. + */ + private static final List DEPLOYMENT_SHAPING = List.of( + "ignorePublishedComponents", "skipPublishing", "excludeArtifacts"); + + @Test + void theTrainShipsAsExactlyOneReactorDeploy() throws IOException { + List deploys = PublishedModules.deployCommands(PUBLISH); + + assertThat(deploys) + .describedAs("publish.yml must deploy the train in exactly one `./mvnw … deploy` " + + "invocation: each separate invocation is its own Central deployment, " + + "and each deployment is its own Release Count event") + .hasSize(1); + + String deploy = deploys.get(0); + List tokens = List.of(deploy.split("\\s+")); + assertThat(tokens) + .describedAs("the train deploy must select its modules with -pl over the root " + + "reactor and run the release profile: %s", deploy) + .contains("-pl", "-P") + .doesNotContain("-f"); + assertThat(deploy).contains("-P release"); + + Set widening = new TreeSet<>(ALSO_MAKE); + widening.retainAll(tokens); + assertThat(widening) + .describedAs("the train deploy must not widen its -pl selection: also-make pulls " + + "core's test-scope fonts and emoji into the reactor, and therefore into " + + "the deployment, re-uploading coordinates already on Central") + .isEmpty(); + } + + @Test + void theDeploySelectsExactlyTheLockstepTrain() throws IOException { + List train = PublishedModules.lockstepPublished(PROJECT_ROOT); + List deployed = PublishedModules.deployedByWorkflow(PROJECT_ROOT) + .getOrDefault("publish.yml", List.of()); + + assertThat(train) + .describedAs("no lockstep module was derived from the poms — the derivation " + + "(standalone pom, central-publishing plugin, reactor version) no longer " + + "matches the layout, and the comparison below would be against nothing") + .contains("core"); + + Set unpublished = new TreeSet<>(train); + unpublished.removeAll(deployed); + assertThat(unpublished) + .describedAs("lockstep modules a release would never publish: add them to the " + + "-pl list of the deploy in publish.yml") + .isEmpty(); + + Set unexpected = new TreeSet<>(deployed); + unexpected.removeAll(train); + assertThat(unexpected) + .describedAs("publish.yml deploys modules outside the lockstep train (or names a " + + "selector that resolves to no reactor module)") + .isEmpty(); + } + + @Test + void buildOnlyModulesAreNeverDeployed() throws IOException { + List buildOnly = PublishedModules.buildOnly(PROJECT_ROOT); + + assertThat(buildOnly) + .describedAs("no build-only aggregator child was found — the root pom's module " + + "list moved, and this guard is checking an empty set") + .isNotEmpty(); + + Set leaked = new TreeSet<>(buildOnly); + leaked.retainAll(PublishedModules.deployed(PROJECT_ROOT)); + assertThat(leaked) + .describedAs("a publish workflow deploys a build-only module (examples, " + + "benchmarks, qa, coverage …), which has no Central metadata and must " + + "never be published") + .isEmpty(); + } + + @Test + void fontsAndEmojiPublishOnlyThroughTheirOwnWorkflows() throws IOException { + Map> byWorkflow = PublishedModules.deployedByWorkflow(PROJECT_ROOT); + + assertThat(byWorkflow.get("publish-fonts.yml")) + .describedAs("publish-fonts.yml must deploy graph-compose-fonts, and only it") + .containsExactly("fonts"); + assertThat(byWorkflow.get("publish-emoji.yml")) + .describedAs("publish-emoji.yml must deploy graph-compose-emoji, and only it") + .containsExactly("emoji"); + assertThat(byWorkflow.get("publish.yml")) + .describedAs("fonts and emoji carry their own version lines and publish only when " + + "those move; the engine train must not carry them") + .doesNotContain("fonts", "emoji"); + assertThat(PublishedModules.lockstepPublished(PROJECT_ROOT)) + .describedAs("fonts or emoji now carry the engine version, which makes them look " + + "like train modules — their versions are independent by design") + .doesNotContain("fonts", "emoji"); + } + + @Test + void everyTrainModuleConfiguresCentralPublishingIdentically() throws IOException { + Map declarations = new LinkedHashMap<>(); + Map versions = new LinkedHashMap<>(); + for (String module : PublishedModules.lockstepPublished(PROJECT_ROOT)) { + String pom = Files.readString(PROJECT_ROOT.resolve(module).resolve("pom.xml")); + Matcher plugin = CENTRAL_PLUGIN.matcher(pom); + assertThat(plugin.find()) + .describedAs("%s/pom.xml declares no central-publishing plugin block", module) + .isTrue(); + declarations.put(module, plugin.group().replaceAll("\\s+", " ")); + Matcher version = CENTRAL_PLUGIN_VERSION.matcher(pom); + versions.put(module, version.find() ? version.group(1) : ""); + + for (String setting : DEPLOYMENT_SHAPING) { + assertThat(pom) + .describedAs("%s/pom.xml sets %s — that changes what the train deployment " + + "carries for every release; pass it from publish.yml instead", + module, setting) + .doesNotContain("<" + setting + ">"); + } + } + + assertThat(Set.copyOf(declarations.values())) + .describedAs("the train's central-publishing declarations differ. The upload runs " + + "with the settings of whichever module the reactor orders last, so every " + + "train module must declare the plugin identically: %s", declarations) + .hasSize(1); + assertThat(Set.copyOf(versions.values())) + .describedAs("the train's central.publishing.plugin.version properties differ: %s", + versions) + .hasSize(1); + } + + @Test + void skippingPublishedComponentsIsAnExplicitRecoveryOnly() throws IOException { + // A Windows checkout carries CRLF; the patterns below are written against LF. + String workflow = Files.readString(PUBLISH).replace("\r", ""); + String deploy = PublishedModules.deployCommands(PUBLISH).get(0); + + assertThat(workflow) + .describedAs("publish.yml must offer skip_published as a boolean dispatch input " + + "that defaults to false") + .containsPattern("(?m)^ skip_published:\\s*$") + .containsPattern("skip_published:(?:\\n {8}.*)*\\n {8}type: boolean") + .containsPattern("skip_published:(?:\\n {8}.*)*\\n {8}default: false"); + assertThat(workflow) + .describedAs("SKIP_PUBLISHED must be true only when a dispatch set skip_published — " + + "never on a tag push, where the input is absent") + .contains("SKIP_PUBLISHED: ${{ github.event.inputs.skip_published == 'true' }}"); + assertThat(deploy) + .describedAs("the deploy must pass the recovery switch through, and only that way") + .contains("-DignorePublishedComponents=$SKIP_PUBLISHED"); + } +} diff --git a/core/src/test/java/com/demcha/documentation/PublishedModules.java b/core/src/test/java/com/demcha/documentation/PublishedModules.java index 5d7fb0a25..d75da5cc9 100644 --- a/core/src/test/java/com/demcha/documentation/PublishedModules.java +++ b/core/src/test/java/com/demcha/documentation/PublishedModules.java @@ -14,10 +14,11 @@ * The reactor's modules, resolved from the root {@code pom.xml} for the guards that * reason about them. * - *

Two guards need the same answer to "what is a module, and which directory is it" — - * one checks that every backend package is documented, the other that every compiled - * module is scanned. Answering it twice is how the lists this repository keeps fixing - * came apart in the first place.

+ *

Several guards need the same answer to "what is a module, and which directory is + * it" — one checks that every backend package is documented, one that every compiled + * module is scanned, one that a release publishes exactly the lockstep train. Answering + * it more than once is how the lists this repository keeps fixing came apart in the + * first place.

*/ final class PublishedModules { @@ -29,13 +30,23 @@ final class PublishedModules { private PublishedModules() { } - /** A publish workflow's deploy step: {@code -f /pom.xml … deploy}. */ + /** + * A {@code mvnw} invocation that runs the {@code deploy} goal, with or without the + * {@code run:} key in front. A comment that mentions deploying is not one, which is + * why the line has to start with the command rather than merely contain it. + */ + private static final Pattern DEPLOY_COMMAND = + Pattern.compile("^\\s*(?:run:\\s*)?\\./mvnw\\b.*\\sdeploy(?:\\s|$).*"); + + /** A standalone deploy's module: {@code -f /pom.xml}. */ private static final Pattern DEPLOY_STEP = Pattern.compile("-f\\s+([\\w-]+)/pom\\.xml"); - /** The {@code order="core render-pdf ..."} line publish.yml validates its resume against. */ - private static final Pattern TRAIN_ORDER = - Pattern.compile("order=\"([^\"]+)\""); + /** A reactor deploy's module selection: {@code -pl :a,:b,…}. */ + private static final Pattern DEPLOY_SELECTION = + Pattern.compile("\\s-pl\\s+(\\S+)"); + + private static final Pattern VERSION = Pattern.compile("\\s*([^<]+?)\\s*"); /** * The modules a release actually deploys, read from the publish workflows. @@ -69,6 +80,7 @@ static List deployed(Path repoRoot) throws IOException { */ static Map> deployedByWorkflow(Path repoRoot) throws IOException { Path workflows = repoRoot.resolve(".github/workflows"); + Map byArtifactId = byArtifactId(repoRoot); Map> byWorkflow = new LinkedHashMap<>(); try (var files = Files.list(workflows)) { for (Path workflow : files.sorted().toList()) { @@ -77,13 +89,11 @@ static Map> deployedByWorkflow(Path repoRoot) throws IOExce continue; } List modules = new ArrayList<>(); - for (String line : Files.readAllLines(workflow)) { - if (!line.contains("deploy")) { - continue; - } - Matcher module = DEPLOY_STEP.matcher(line); - if (module.find() && !modules.contains(module.group(1))) { - modules.add(module.group(1)); + for (String command : deployCommands(workflow)) { + for (String module : modulesOf(command, byArtifactId)) { + if (!modules.contains(module)) { + modules.add(module); + } } } byWorkflow.put(name, modules); @@ -93,25 +103,108 @@ static Map> deployedByWorkflow(Path repoRoot) throws IOExce } /** - * The publish train {@code publish.yml} declares for itself, in order. + * Every {@code mvnw … deploy} invocation in a workflow, one per element. + * + * @param workflow the workflow file + * @return the deploy command lines, in file order + * @throws IOException when the workflow cannot be read + */ + static List deployCommands(Path workflow) throws IOException { + List commands = new ArrayList<>(); + for (String line : Files.readAllLines(workflow)) { + if (DEPLOY_COMMAND.matcher(line).matches()) { + commands.add(line.strip()); + } + } + return commands; + } + + /** + * The module directories one deploy command ships: the {@code -f} module of a + * standalone deploy, and each {@code -pl} selector of a reactor deploy. + * + *

A selector that names no reactor module is kept as written rather than dropped. + * Dropping it would shrink the inventory, and a shorter inventory is exactly what a + * guard comparing against it wants to see; kept, it fails every comparison it enters.

+ */ + private static List modulesOf(String command, Map byArtifactId) { + List modules = new ArrayList<>(); + Matcher standalone = DEPLOY_STEP.matcher(command); + if (standalone.find()) { + modules.add(standalone.group(1)); + } + Matcher selection = DEPLOY_SELECTION.matcher(command); + if (selection.find()) { + for (String selector : selection.group(1).split(",")) { + String artifactId = selector.strip().replaceFirst("^:", ""); + if (artifactId.isEmpty()) { + continue; + } + Path directory = byArtifactId.get(artifactId); + modules.add(directory == null ? selector.strip() : directory.getFileName().toString()); + } + } + return modules; + } + + /** + * The modules that move with the engine version and publish to Maven Central — the + * train a GraphCompose release ships — read from the poms rather than from any + * workflow, so it can be held against the workflow without restating either. * - *

The workflow states its module set twice — once as the {@code order} the resume - * input is validated against, and once as the deploy steps themselves. Neither is - * derived from the other, so holding them together catches the step list drifting - * away from the train without anything having to restate it a third time.

+ *

A module belongs when its pom stands alone (no {@code }, so it is not one + * of the aggregator's build-only children), declares the + * {@code central-publishing-maven-plugin}, and carries the reactor's own version. The + * independently versioned companions (fonts, emoji) fail the last test by design, + * which is what keeps them out of the train.

* * @param repoRoot the repository root - * @return the module directories the train names, or an empty list when the - * declaration is absent - * @throws IOException when the workflow cannot be read + * @return the train's module directories, in reactor declaration order + * @throws IOException when a pom cannot be read */ - static List declaredTrain(Path repoRoot) throws IOException { - Path workflow = repoRoot.resolve(".github/workflows/publish.yml"); - if (!Files.isRegularFile(workflow)) { - return List.of(); + static List lockstepPublished(Path repoRoot) throws IOException { + String reactorVersion = ownVersion(Files.readString(repoRoot.resolve("pom.xml"))); + List train = new ArrayList<>(); + for (String module : of(repoRoot)) { + Path pom = repoRoot.resolve(module).resolve("pom.xml"); + if (!Files.isRegularFile(pom)) { + continue; + } + String text = Files.readString(pom); + if (PARENT_BLOCK.matcher(text).find() + || !text.contains("central-publishing-maven-plugin")) { + continue; + } + if (reactorVersion.equals(ownVersion(text))) { + train.add(module); + } } - Matcher order = TRAIN_ORDER.matcher(Files.readString(workflow)); - return order.find() ? List.of(order.group(1).strip().split("\\s+")) : List.of(); + return train; + } + + /** + * The modules the root aggregator builds but never publishes — its own children, + * which inherit {@code maven.deploy.skip} and carry no publishing setup. + * + * @param repoRoot the repository root + * @return the build-only module directories, in reactor declaration order + * @throws IOException when a pom cannot be read + */ + static List buildOnly(Path repoRoot) throws IOException { + List children = new ArrayList<>(); + for (String module : of(repoRoot)) { + Path pom = repoRoot.resolve(module).resolve("pom.xml"); + if (Files.isRegularFile(pom) && PARENT_BLOCK.matcher(Files.readString(pom)).find()) { + children.add(module); + } + } + return children; + } + + /** A pom's own {@code }, ignoring the one inside {@code }. */ + private static String ownVersion(String pom) { + Matcher version = VERSION.matcher(PARENT_BLOCK.matcher(pom).replaceFirst("")); + return version.find() ? version.group(1) : ""; } /** The module directories the root reactor builds, in declaration order. */ From 3300f95513f1ea52f8957d49d3044683f1e32f6a Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 13:06:55 +0100 Subject: [PATCH 2/6] test(release-smoke): smoke a staged deployment before it is uploaded --staged-repo / -StagedRepo resolves the GraphCompose coordinates from a Maven repository-layout directory - the unzipped central-bundle.zip - and everything else from Central, through a settings file whose Central-only mirror excludes that one repository. The version defaults to the single core version staged there. A scenario passes only if every GraphCompose artifact of that version it resolved records the staged repository in _remote.repositories, so a stale cache or a Central copy cannot stand in for the staged bytes, and a scenario that resolved none fails. Staged mode refuses --warm. --- scripts/release-smoke/README.md | 22 ++++++++ scripts/release-smoke/run.ps1 | 92 +++++++++++++++++++++++++++++-- scripts/release-smoke/run.sh | 98 +++++++++++++++++++++++++++++++-- 3 files changed, 203 insertions(+), 9 deletions(-) diff --git a/scripts/release-smoke/README.md b/scripts/release-smoke/README.md index d75756c33..1ae0dd06e 100644 --- a/scripts/release-smoke/README.md +++ b/scripts/release-smoke/README.md @@ -43,14 +43,36 @@ render because the backend and companions were pulled transitively. # Fast dev iteration — keep everything cached: ./scripts/release-smoke/run.sh --warm + +# Before upload — consume the staged deployment instead of Central: +./scripts/release-smoke/run.sh --staged-repo target/staged-bundle ``` ```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 +pwsh ./scripts/release-smoke/run.ps1 -StagedRepo target/staged-bundle # staged ``` +### Staged mode (before the upload) + +`--staged-repo ` / `-StagedRepo ` runs the same scenarios against a +deployment that has not reached Central yet. `` is a Maven repository-layout +directory — the unzipped `central-bundle.zip` the release reactor builds (see +[`docs/contributing/release-process.md`](../../docs/contributing/release-process.md), +*Dry-running the Central deployment*). The harness writes a settings file whose +Central-only mirror excludes one repository, `staged`, pointing at ``; the +GraphCompose coordinates resolve from there and everything else (third-party +libraries, the independently versioned fonts and emoji) from Central. The version +defaults to the single `graph-compose-core` version staged in ``. + +A scenario passes only if, besides its own assertions, every GraphCompose artifact +of that version it resolved records `staged` as its source in +`_remote.repositories` — so a stale cache or a Central copy cannot stand in for the +staged bytes, and a scenario that resolved none of them fails. Staged mode refuses +`--warm` for the same reason. + 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. diff --git a/scripts/release-smoke/run.ps1 b/scripts/release-smoke/run.ps1 index 2ad956bca..155b5ecce 100644 --- a/scripts/release-smoke/run.ps1 +++ b/scripts/release-smoke/run.ps1 @@ -15,12 +15,20 @@ pwsh ./scripts/release-smoke/run.ps1 # isolated, tests 2.4.1 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) + pwsh ./scripts/release-smoke/run.ps1 -StagedRepo + # before upload: resolve the GraphCompose coordinates from , a Maven + # repository-layout directory such as the unzipped central-bundle.zip, and + # everything else from Central. The version defaults to the single version + # staged there. After each scenario every GraphCompose artifact of that + # version must have come from — one resolved from anywhere else (a stale + # cache, Central) fails the scenario, so a pass proves the staged bytes. #> param( [switch]$Warm, # Default version under test: the currently published release. Release smoke # must test PUBLISHED artifacts — never a -SNAPSHOT. - [string]$Version = '2.4.1' + [string]$Version = '2.4.1', + [string]$StagedRepo = '' ) $ErrorActionPreference = 'Continue' @@ -33,6 +41,80 @@ $scenarios = @('s1-graph-compose', 's2-core-only', 's3-core-render-pdf', 's4-tem $repo = Join-Path $repoRoot 'target\release-smoke-m2\repo' New-Item -ItemType Directory -Force -Path $repo | Out-Null +if ($StagedRepo) { + if ($Warm) { + # A warm cache can satisfy a coordinate without consulting the staged repo, + # which is the one thing staged mode exists to rule out. + Write-Error '-StagedRepo cannot be combined with -Warm' + exit 2 + } + $stagedGc = Join-Path $StagedRepo 'io\github\demchaav' + if (-not (Test-Path $stagedGc)) { + Write-Error "FATAL: $StagedRepo is not a Maven repository layout holding io/github/demchaav" + exit 2 + } + $stagedAbs = (Resolve-Path $StagedRepo).Path -replace '\\', '/' + if (-not $PSBoundParameters.ContainsKey('Version')) { + $staged = @(Get-ChildItem -Directory (Join-Path $stagedGc 'graph-compose-core') -ErrorAction SilentlyContinue) + if ($staged.Count -ne 1) { + Write-Error "FATAL: expected exactly one staged graph-compose-core version, found: $($staged.Name -join ', ')" + exit 2 + } + $Version = $staged[0].Name + } + # The isolated settings, with one exception carved out of the Central-only mirror: + # the staged repository, active for every scenario. + $settings = Join-Path $repoRoot 'target\release-smoke-m2\settings-staged.xml' + @" + + + + central-only + https://repo.maven.apache.org/maven2 + *,!staged + + + + + staged + + + staged + file:///$($stagedAbs.TrimStart('/')) + true + false + + + + + + staged + + +"@ | Set-Content -Encoding utf8 -Path $settings +} + +# Staged mode only: every GraphCompose artifact of the version under test that the +# scenario resolved must record the staged repository as its source. Fails closed — +# a scenario that resolved none of them proves nothing about the staged bytes. +function Test-StagedProvenance { + $dirs = @(Get-ChildItem -Directory (Join-Path $repo 'io\github\demchaav') -ErrorAction SilentlyContinue | + ForEach-Object { Join-Path $_.FullName $Version } | Where-Object { Test-Path $_ }) + if ($dirs.Count -eq 0) { + Write-Host "PROVENANCE: no GraphCompose $Version artifact was resolved at all" + return $false + } + $ok = $true + foreach ($dir in $dirs) { + $marker = Join-Path $dir '_remote.repositories' + if (-not ((Test-Path $marker) -and (Select-String -Path $marker -SimpleMatch '>staged=' -Quiet))) { + Write-Host "PROVENANCE: $dir was not resolved from the staged repository" + $ok = $false + } + } + return $ok +} + $pass = 0 $fail = 0 $results = @() @@ -49,10 +131,12 @@ foreach ($s in $scenarios) { } Write-Host "" Write-Host "==================================================================" - Write-Host "=== SMOKE $s (version=$Version, repo=$repo, evicted=$(if ($Warm) { 'no' } else { 'yes' }))" + Write-Host "=== SMOKE $s (version=$Version, repo=$repo, evicted=$(if ($Warm) { 'no' } else { 'yes' })$(if ($StagedRepo) { ", staged=$StagedRepo" }))" 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) { + $passed = $LASTEXITCODE -eq 0 + if ($passed -and $StagedRepo -and -not (Test-StagedProvenance)) { $passed = $false } + if ($passed) { $results += "$s PASS"; $pass++ } else { $results += "$s FAIL"; $fail++ @@ -61,7 +145,7 @@ foreach ($s in $scenarios) { Write-Host "" Write-Host "===================== RELEASE SMOKE SUMMARY =====================" -Write-Host "version-under-test: $Version" +Write-Host "version-under-test: $Version$(if ($StagedRepo) { " (staged: $StagedRepo)" })" foreach ($r in $results) { Write-Host "RESULT $r" } Write-Host ("SUMMARY {""version"":""$Version"",""passed"":$pass,""failed"":$fail,""total"":$($pass + $fail)}") diff --git a/scripts/release-smoke/run.sh b/scripts/release-smoke/run.sh index 10dfb7773..025a5a84b 100755 --- a/scripts/release-smoke/run.sh +++ b/scripts/release-smoke/run.sh @@ -16,6 +16,13 @@ # ./scripts/release-smoke/run.sh # isolated, tests gc.version=2.4.1 # ./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) +# ./scripts/release-smoke/run.sh --staged-repo +# # before upload: resolve the GraphCompose coordinates from , a Maven +# # repository-layout directory such as the unzipped central-bundle.zip, and +# # everything else from Central. The version defaults to the single version +# # staged there. After each scenario every GraphCompose artifact of that +# # version must have come from — one resolved from anywhere else (a stale +# # cache, Central) fails the scenario, so a pass proves the staged bytes. # set -u @@ -31,16 +38,93 @@ REPO="$REPO_ROOT/target/release-smoke-m2/repo" # test PUBLISHED artifacts — never a -SNAPSHOT. GC_VERSION="2.4.1" WARM=0 +STAGED="" +VERSION_SET=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 ;; + --version) GC_VERSION="${2:?--version needs a value}"; VERSION_SET=1; shift 2 ;; + --version=*) GC_VERSION="${1#*=}"; VERSION_SET=1; shift ;; + --staged-repo) STAGED="${2:?--staged-repo needs a directory}"; shift 2 ;; + --staged-repo=*) STAGED="${1#*=}"; shift ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done mkdir -p "$REPO" +if [ -n "$STAGED" ]; then + if [ "$WARM" = "1" ]; then + # A warm cache can satisfy a coordinate without consulting the staged repo, + # which is the one thing staged mode exists to rule out. + echo "--staged-repo cannot be combined with --warm" >&2 + exit 2 + fi + if [ ! -d "$STAGED/io/github/demchaav" ]; then + echo "FATAL: $STAGED is not a Maven repository layout holding io/github/demchaav" >&2 + exit 2 + fi + # pwd -W gives C:/... under Git Bash; elsewhere it fails and plain pwd is right. + STAGED_ABS="$(cd "$STAGED" && (pwd -W 2>/dev/null || pwd))" + if [ "$VERSION_SET" = "0" ]; then + staged_versions="$(ls "$STAGED/io/github/demchaav/graph-compose-core" 2>/dev/null)" + if [ "$(printf '%s\n' "$staged_versions" | grep -c .)" != "1" ]; then + echo "FATAL: expected exactly one staged graph-compose-core version, found: $staged_versions" >&2 + exit 2 + fi + GC_VERSION="$staged_versions" + fi + # The isolated settings, with one exception carved out of the Central-only mirror: + # the staged repository, active for every scenario. + SETTINGS="$REPO_ROOT/target/release-smoke-m2/settings-staged.xml" + cat > "$SETTINGS" < + + + central-only + https://repo.maven.apache.org/maven2 + *,!staged + + + + + staged + + + staged + file:///${STAGED_ABS#/} + true + false + + + + + + staged + + +EOF +fi + +# Staged mode only: every GraphCompose artifact of the version under test that the +# scenario resolved must record the staged repository as its source. Fails closed — +# a scenario that resolved none of them proves nothing about the staged bytes. +staged_provenance_ok() { + local seen=0 bad=0 dir + for dir in "$REPO"/io/github/demchaav/*/"$GC_VERSION"; do + [ -d "$dir" ] || continue + seen=$((seen + 1)) + if ! grep -qs '>staged=' "$dir/_remote.repositories"; then + echo "PROVENANCE: $dir was not resolved from the staged repository" >&2 + bad=$((bad + 1)) + fi + done + if [ "$seen" = "0" ]; then + echo "PROVENANCE: no GraphCompose $GC_VERSION artifact was resolved at all" >&2 + return 1 + fi + [ "$bad" = "0" ] +} + pass=0 fail=0 declare -a results @@ -59,11 +143,15 @@ for s in "${SCENARIOS[@]}"; do fi echo "" echo "==================================================================" - echo "=== SMOKE $s (version=$GC_VERSION, repo=$REPO, evicted=$([ "$WARM" = "0" ] && echo yes || echo no))" + echo "=== SMOKE $s (version=$GC_VERSION, repo=$REPO, evicted=$([ "$WARM" = "0" ] && echo yes || echo no)${STAGED:+, staged=$STAGED})" 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 + status=$? + if [ "$status" -eq 0 ] && [ -n "$STAGED" ] && ! staged_provenance_ok; then + status=1 + fi + if [ "$status" -eq 0 ]; then results+=("$s PASS") pass=$((pass + 1)) else @@ -74,7 +162,7 @@ done echo "" echo "===================== RELEASE SMOKE SUMMARY =====================" -echo "version-under-test: $GC_VERSION" +echo "version-under-test: $GC_VERSION${STAGED:+ (staged: $STAGED)}" for r in "${results[@]}"; do echo "RESULT $r" done From 410649e9e5829671f8cb000b978472280b25193d Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 13:06:56 +0100 Subject: [PATCH 3/6] docs(release): describe the single Central deployment and its recovery The runbook explains the one-deployment train, how to dry-run it against a dead endpoint and smoke the staged bundle, and replaces the per-module start_at recovery with the failure cases a single deployment has. CHANGELOG entry under v2.4.2. --- CHANGELOG.md | 35 ++++++++++++++++ docs/contributing/release-process.md | 63 +++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6b10637..b54b62818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,43 @@ follow semantic versioning; release dates are ISO 8601. ## v2.4.2 — Planned +### Build + +- **A release reaches Maven Central as one deployment instead of eight.** Central now counts + every distinct publish operation against an organisation's monthly Release Count, and + `publish.yml` deployed the train as eight separate `-f /pom.xml deploy` runs — eight + deployments, eight validation queues and eight Publish clicks for one version. It now runs + the release profile once over the root reactor, `-P release deploy -pl` the eight train + artifacts: the `central-publishing-maven-plugin` stages every module and uploads one + `central-bundle.zip` from the last. Every coordinate, POM, jar, sources and javadoc jar and + signature is what it was — only the transaction is shared. The deployment validates as a unit, + so a failure no longer leaves the first modules of a version published and the rest missing. + `graph-compose-fonts` and `graph-compose-emoji` keep their own tags and workflows. The + `start_at` resume input is replaced by `skip_published`, off by default, which leaves out + components the Portal already reports as published, for recovering a version that is + partially live. The uploaded zip is kept as a workflow artifact. + +### Tests + +- **`PublishTrainGuardTest`** holds the deploy to exactly one `-pl` reactor run, its selection + to the lockstep modules derived from the poms (standalone, publishing, on the reactor + version), and keeps fonts, emoji and the build-only modules out of it. It forbids `-am`, which + would pull fonts and emoji into the deployment, and requires the eight `release` profiles to + declare the plugin identically, because the upload runs with the settings of the module the + reactor orders last. `PublishedModules` reads `-pl` deploys as well as `-f` ones, so the + CodeQL scope guard keeps its inventory. +- **The release smoke runs before the upload.** `run.sh --staged-repo ` / + `run.ps1 -StagedRepo ` resolves the GraphCompose coordinates from an unzipped + `central-bundle.zip` and everything else from Central, and fails a scenario unless every + GraphCompose artifact it resolved came from the staged directory. + ### Documentation +- **The release runbook describes the single deployment.** `docs/contributing/release-process.md` + explains the one-deployment train, how to dry-run it against a dead endpoint and smoke the + staged bundle, and replaces the per-module `start_at` recovery with the failure cases a single + deployment has. + - **A card of a preset shows the code that draws that preset.** The catalogue carries one compiled block per family — the CVs' builds `BoxedSections`, the invoices' `ModernInvoice` — and every other card of the family was shown it under a caption saying it came from the diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index aa7382593..e48408854 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -136,7 +136,7 @@ Run within 1 hour of the tag push. Independent steps can run in parallel. 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), moves the `graph-compose-templates` japicmp previous-release pin (`japicmp.baseline.previous` in `templates/pom.xml`) onto the release just published, **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` over the whole reactor 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). GitHub refuses a Release body over 125 000 characters, so the section first goes through [`scripts/release-notes.mjs`](../../scripts/release-notes.mjs): one that fits is published as written, and a longer one is published as its subsection headings and each entry's bold lead, with a link to the full section at the tag. The 2.4.0 section, at over 156 000 characters, failed its tag's Release before this existed. 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 each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) 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). +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 each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) with the repo's GPG key, and uploads the whole train to Maven Central via the `central-publishing-maven-plugin` as **one deployment** (§2.F), named `GraphCompose v` in the Central portal. 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 deployment stops at `VALIDATED`; the maintainer publishes it with **one** click on [central.sonatype.com](https://central.sonatype.com) (Deployments → `GraphCompose v` → Publish), which releases all eight components together. The run keeps the uploaded `central-bundle.zip` as a workflow artifact. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the deployment is published), and check that the [Usage Center](https://central.sonatype.com/publishing/usage) Release Count moved by one for the release. 10. **Optional**: GitHub Discussions announcement (mirror the prior release's style; close with *"author intent, not coordinates"*), LinkedIn post, r/java post. The release is **done** only when steps 1–7 are all green; step 9 adds Maven Central availability once the D-track of v1.6.6 has shipped. @@ -153,7 +153,8 @@ is a convenience aggregate `io.github.demchaav:graph-compose-bundle` (under `${project.version}`, its `graph-compose` dependency). `VersionConsistencyGuardTest` enforces `bundle == engine`. The engine `v` tag's [`publish.yml`](../../.github/workflows/publish.yml) deploys the engine **and** - the bundle. Nothing extra to do for the bundle at release time. + the bundle, in the same Central deployment (§2.F). Nothing extra to do for the + bundle at release time. - **The fonts artifact is NOT bumped by the engine release.** It carries its own version line (started at `1.0.0`) and is bumped **only when the font set changes**. `cut-release.ps1` deliberately does not touch `fonts/pom.xml`, and @@ -246,6 +247,52 @@ aggregate. They all carry the **same** version. the whole train to the **GitHub Release pre-release surface only** — `publish.yml` skips Central for hyphenated tags (§2.B step 9). A beta is therefore installable from the GitHub pre-release (and from JitPack, which builds any tag), not from Central. +- **One Central deployment per train release.** Maven Central counts every distinct + publish operation against the organisation's monthly Release Count (the + [Usage Center](https://central.sonatype.com/publishing/usage) is the source of truth + for the limits). `publish.yml` therefore ships the train in a single reactor run, + `./mvnw -P release deploy -pl `: the + `central-publishing-maven-plugin` stages every selected module into one directory, + and the last one bundles them into `central-bundle.zip` and uploads it once. One + version is one deployment holding eight components; each stays its own coordinate + for consumers. The deployment validates and publishes as a unit, so a train can no + longer land half on Central. Fonts and emoji are not selected — they keep their own + tags and single-module workflows. `PublishTrainGuardTest` holds the `-pl` list to + the lockstep modules derived from the poms, forbids `-am` (which would pull fonts and + emoji in), and requires the eight `release` profiles to declare the plugin + identically, because the upload runs with the settings of whichever module the + reactor orders last. + +#### Dry-running the Central deployment + +To see exactly what a tag would upload — without credentials, and without anything +reaching Central — build the bundle locally against a dead endpoint. Use a throwaway +worktree and a separate local repository, because the flip below writes a fake final +version that must never land in your `~/.m2`. Run it from **Linux or WSL**: the plugin +writes the zip with the platform's path separator, and a Windows-built zip carries +backslash paths. + +```bash +# in a throwaway worktree at the commit to release +sed -i 's/-SNAPSHOT//g' pom.xml core/pom.xml render-pdf/pom.xml wrapper/pom.xml render-docx/pom.xml render-pptx/pom.xml templates/pom.xml testing/pom.xml bundle/pom.xml examples/pom.xml benchmarks/pom.xml qa/pom.xml coverage/pom.xml +R=$PWD/../m2-dryrun +TRAIN=:graph-compose-core,:graph-compose-render-pdf,:graph-compose,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-bundle +./mvnw -B -ntp -Dmaven.repo.local=$R -f fonts/pom.xml -DskipTests install +./mvnw -B -ntp -Dmaven.repo.local=$R -f emoji/pom.xml -DskipTests install +./mvnw -B -ntp -Dmaven.repo.local=$R -DskipTests install -pl $TRAIN +./mvnw -B -ntp -Dmaven.repo.local=$R -s \ + -P release -DskipTests -DcentralBaseUrl=http://127.0.0.1:9 deploy -pl $TRAIN +``` + +The last command fails at the upload with `Connection refused` — that is the point. +Before it does, the log shows each module `Staging …` and then a single +`Created bundle successfully … central-bundle.zip` and a single `Going to upload`. +The zip lands under `core/target/central-publishing/`. Unzip it into a directory and +run the release smoke against it before any upload: +`bash scripts/release-smoke/run.sh --staged-repo ` (see +[`scripts/release-smoke/README.md`](../../scripts/release-smoke/README.md)). GPG signing is +skipped in the dry run (`gpg.skip` defaults to true), so the zip carries no `.asc` +files; the tagged run signs every file with the same `release` profile. ### 2.G Branch flow @@ -257,8 +304,8 @@ guard skips `core/pom.xml` on the older 1.x layout, so the same script serves bo - **Release candidate:** `pwsh ./scripts/cut-release.ps1 -Version -rc.1`. The hyphenated `-rc` tag ships to the GitHub pre-release surface only (Central skipped, §2.F). - **GA:** `pwsh ./scripts/cut-release.ps1 -Version `. The plain `v` tag fires - `publish.yml`, which deploys the eight-module train to Maven Central in dependency order - — that sequence is version-agnostic and needs no per-release change. + `publish.yml`, which deploys the eight-module train to Maven Central as one deployment + (§2.F) — that run is version-agnostic and needs no per-release change. - **1.9.x backport:** `pwsh ./scripts/cut-release.ps1 -Version 1.9. -Branch 1.x`. After a GA the branches settle back into their standing roles: `main` is fast-forwarded @@ -332,8 +379,12 @@ The GitHub Release ([`release.yml`](../../.github/workflows/release.yml)) and th | 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. | +| **publish.yml red before the upload** (build, test, javadoc or signing failed) | Nothing was uploaded: the plugin refuses to upload after an earlier failure in the reactor (`Earlier build failures detected. Central publishing will not continue`). Fix the cause (an environment one — an expired GPG key, a rotated token — is fixed in the repo secrets) and re-dispatch `publish.yml` with `tag=v`. | +| **Upload failed** (network / Portal error at `Going to upload`) | Check the Central portal's Deployments page. If a deployment for the version was created, wait for its state; if it is `FAILED`, drop it. Then re-dispatch `publish.yml` with `tag=v`. | +| **Central validation failed** (deployment `FAILED`, publish.yml red at the deploy step) | The deployment is one unit, so **nothing** was published. The portal (and the run log) list the errors per component. An environment cause (signature key not on a keyserver, token): fix it, drop the `FAILED` deployment, re-dispatch with `tag=v`. A content cause (POM metadata, a missing sources/javadoc jar) needs a commit, and the tag is immutable, so fix forward with a patch version. The workflow artifact `central-bundle-v` holds the exact zip that was rejected. | +| **Validated but wrong** (deployment `VALIDATED`, not yet published) | Drop it in the portal — nothing reaches Central until the maintainer publishes. Re-dispatch once fixed. | +| **Partial module publication** (some coordinates of the version on Central, some not — only possible for a version a pre-consolidation run half-published, or after a manual portal upload) | Confirm what is live (`mvn dependency:get` per coordinate, or the release-smoke matrix), make sure no deployment for the version is still `VALIDATED` (publish or drop it first — an unpublished deployment does not count as published), then re-dispatch `publish.yml` with `tag=v` **and `skip_published=true`**. The plugin asks the Portal which components are already published, leaves those out, and publishes the rest as one deployment. A run in which everything is already published stages nothing and stops. Never bump the tag to force a re-publish, and never set `skip_published` on a normal release. | +| **Re-dispatch of an already-published version** | Without `skip_published`, Central rejects re-uploading the published coordinates and the deployment fails without changing anything. With it, the run is a no-op. | | **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). | From 51206732ea192e725c5236d204db0585305bd8aa Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 13:50:39 +0100 Subject: [PATCH 4/6] build(publish): guard every deploy shape and check staged provenance per file PublishTrainGuardTest fails on any non-comment line of a publish workflow that names the deploy goal in a shape PublishedModules does not read (`mvn`, a `- run:` list item, a `\` continuation), so a second deployment cannot ship invisible to the one-deploy and train checks. The deploy step's id and the install step's name no longer contain the word. The staged release smoke checks every file line of _remote.repositories, not one line per artifact, and fails a train artifact resolved at any version other than the staged one: a staged POM pinning a sibling at a drifted version would otherwise pull it from Central unchecked. fonts and emoji stay exempt. The settings file is written without a BOM, its path is XML-escaped, and run.sh converts the path with cygpath where it exists. The bundle artifact uploads whenever the deploy step ran rather than always. The runbook adds the validation-timeout case (do not re-dispatch: a deployment exists) and smoking the VALIDATED deployment's bundle artifact before publishing. The root aggregator's comment describes the reactor deploy. --- .github/workflows/publish.yml | 26 ++++++--- CHANGELOG.md | 8 ++- .../documentation/PublishTrainGuardTest.java | 42 ++++++++++++++ docs/contributing/release-process.md | 3 +- pom.xml | 16 +++--- scripts/release-smoke/README.md | 17 ++++-- scripts/release-smoke/run.ps1 | 57 +++++++++++++------ scripts/release-smoke/run.sh | 46 +++++++++++---- 8 files changed, 164 insertions(+), 51 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 878891248..2593cc5a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -159,7 +159,7 @@ jobs: test -s templates/target/japicmp/japicmp-against-major-floor.xml test -s templates/target/japicmp/japicmp-against-previous-release.xml - - name: Build, test and install to local m2 (verify + seed the deploy) + - name: Build, test and install to local m2 (verify + seed the release reactor) # Re-verify the tagged commit before publishing (defence in depth # against a tag pushed from a broken branch). `install` — not `verify` — # seeds the runner's local m2 with EVERY module artifact, including the @@ -178,6 +178,7 @@ jobs: run: echo "::warning::skip_published=true — components of $TAG already published on Maven Central are left out of this deployment. Recovery only." - name: Publish the release train to Maven Central (one deployment) + id: train # One reactor run over exactly the lockstep train. `-P release` adds the # sources + javadoc jars, GPG-signs every artefact at verify, and lets the # central-publishing plugin replace deploy: each module is STAGED into one @@ -191,9 +192,15 @@ jobs: # # A failure in any module before the upload stops the plugin from # uploading anything ("Earlier build failures detected"), so nothing - # partial reaches Central. Never add -am / -amd: also-make would pull + # partial reaches Central. Never widen the selection: -am would pull # fonts and emoji (core's test-scope deps) into the reactor and the - # deployment, re-uploading coordinates they already published. + # deployment, re-uploading coordinates they already published, and -amd + # would pull in the build-only dependents (examples, qa, …). + # + # The whole train shares the plugin's waitMaxTime (1800 s). A run that + # goes red on that wait may still leave a deployment that later reaches + # VALIDATED — check the Central portal before re-dispatching, or the + # re-dispatch uploads a second deployment. # # ignorePublishedComponents is false on every tag push and on a dispatch # that leaves skip_published unset. Set, the plugin asks the Central @@ -208,11 +215,14 @@ jobs: run: ./mvnw -B -ntp -P release -DskipTests -Dgpg.skip=false -DdeploymentName="GraphCompose $TAG" -DignorePublishedComponents=$SKIP_PUBLISHED deploy -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-bundle - name: Keep the deployment bundle with the run - # The exact zip the plugin uploaded (or would have uploaded), for audit - # and for a manual upload through the Central portal if the API path is - # unavailable. The plugin writes it under the first staged module's - # target/, hence the wildcard. - if: always() + # The exact zip the plugin uploaded (or would have uploaded), for audit, + # for smoking the VALIDATED deployment before publishing it + # (scripts/release-smoke --staged-repo), and for a manual upload through + # the Central portal if the API path is unavailable. The plugin writes it + # under the first staged module's target/, hence the wildcard. Runs + # whenever the deploy step ran, pass or fail — not when an earlier step + # (an invalid tag, a red gate) kept it from running. + if: ${{ !cancelled() && steps.train.outcome != 'skipped' }} uses: actions/upload-artifact@v7 with: name: central-bundle-${{ github.event.inputs.tag || github.ref_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b54b62818..c53bd352f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,10 +30,12 @@ follow semantic versioning; release dates are ISO 8601. declare the plugin identically, because the upload runs with the settings of the module the reactor orders last. `PublishedModules` reads `-pl` deploys as well as `-f` ones, so the CodeQL scope guard keeps its inventory. -- **The release smoke runs before the upload.** `run.sh --staged-repo ` / +- **The release smoke can run before anything is published.** `run.sh --staged-repo ` / `run.ps1 -StagedRepo ` resolves the GraphCompose coordinates from an unzipped - `central-bundle.zip` and everything else from Central, and fails a scenario unless every - GraphCompose artifact it resolved came from the staged directory. + `central-bundle.zip` — a local dry run's, or the tagged run's workflow artifact while the + deployment waits at `VALIDATED` — and everything else from Central. A scenario fails unless + every file of every train artifact it resolved came from the staged directory at the staged + version. ### Documentation diff --git a/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java index 46971ad7a..fbcd04f21 100644 --- a/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java @@ -47,6 +47,9 @@ class PublishTrainGuardTest { private static final Pattern CENTRAL_PLUGIN_VERSION = Pattern.compile( "\\s*([^<]+?)\\s*"); + /** The {@code deploy} goal as a word — not {@code deployment}, not {@code deploy-web}. */ + private static final Pattern DEPLOY_GOAL = Pattern.compile("(? ALSO_MAKE = Set.of("-am", "--also-make", "-amd", "--also-make-dependents"); @@ -88,6 +91,45 @@ void theTrainShipsAsExactlyOneReactorDeploy() throws IOException { .isEmpty(); } + /** + * Every other test here reads deploys through {@link PublishedModules#deployCommands}, + * which recognises one shape: a single-line {@code ./mvnw … deploy}. A deploy written + * any other way — {@code mvn}, a {@code - run:} list item, a command wrapped with + * {@code \} continuations — would be invisible to all of them, so a second deployment + * could ship while "exactly one deploy" stayed green. This keys on the positive + * signal instead: any non-comment line of a publish workflow naming the + * {@code deploy} goal must be one the parser read. + */ + @Test + void everyDeployInAPublishWorkflowIsOneTheGuardsRead() throws IOException { + Set unread = new TreeSet<>(); + try (var files = Files.list(PROJECT_ROOT.resolve(".github/workflows"))) { + for (Path workflow : files.sorted().toList()) { + String name = workflow.getFileName().toString(); + if (!name.startsWith("publish") || !name.endsWith(".yml")) { + continue; + } + List parsed = PublishedModules.deployCommands(workflow); + for (String line : Files.readAllLines(workflow)) { + String code = line.strip(); + if (code.startsWith("#") || !DEPLOY_GOAL.matcher(code).find()) { + continue; + } + if (!parsed.contains(code)) { + unread.add(name + ": " + code); + } + } + } + } + + assertThat(unread) + .describedAs("publish workflow lines that name the deploy goal in a shape " + + "PublishedModules does not read, so no guard checks what they ship. " + + "Write the deploy as one `./mvnw … deploy` line, or teach " + + "PublishedModules the new shape") + .isEmpty(); + } + @Test void theDeploySelectsExactlyTheLockstepTrain() throws IOException { List train = PublishedModules.lockstepPublished(PROJECT_ROOT); diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index e48408854..cd78b24ec 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -136,7 +136,7 @@ Run within 1 hour of the tag push. Independent steps can run in parallel. 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), moves the `graph-compose-templates` japicmp previous-release pin (`japicmp.baseline.previous` in `templates/pom.xml`) onto the release just published, **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` over the whole reactor 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). GitHub refuses a Release body over 125 000 characters, so the section first goes through [`scripts/release-notes.mjs`](../../scripts/release-notes.mjs): one that fits is published as written, and a longer one is published as its subsection headings and each entry's bold lead, with a link to the full section at the tag. The 2.4.0 section, at over 156 000 characters, failed its tag's Release before this existed. 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 each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) with the repo's GPG key, and uploads the whole train to Maven Central via the `central-publishing-maven-plugin` as **one deployment** (§2.F), named `GraphCompose v` in the Central portal. 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 deployment stops at `VALIDATED`; the maintainer publishes it with **one** click on [central.sonatype.com](https://central.sonatype.com) (Deployments → `GraphCompose v` → Publish), which releases all eight components together. The run keeps the uploaded `central-bundle.zip` as a workflow artifact. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the deployment is published), and check that the [Usage Center](https://central.sonatype.com/publishing/usage) Release Count moved by one for the release. +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 each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) with the repo's GPG key, and uploads the whole train to Maven Central via the `central-publishing-maven-plugin` as **one deployment** (§2.F), named `GraphCompose v` in the Central portal. 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 deployment stops at `VALIDATED`; the maintainer publishes it with **one** click on [central.sonatype.com](https://central.sonatype.com) (Deployments → `GraphCompose v` → Publish), which releases all eight components together. The run keeps the uploaded `central-bundle.zip` as the workflow artifact `central-bundle-v`; before pressing Publish, download it, unzip it and run `bash scripts/release-smoke/run.sh --staged-repo ` to prove every coordinate in the deployment resolves and works for a consumer. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the deployment is published), and check that the [Usage Center](https://central.sonatype.com/publishing/usage) Release Count moved by one for the release. 10. **Optional**: GitHub Discussions announcement (mirror the prior release's style; close with *"author intent, not coordinates"*), LinkedIn post, r/java post. The release is **done** only when steps 1–7 are all green; step 9 adds Maven Central availability once the D-track of v1.6.6 has shipped. @@ -381,6 +381,7 @@ The GitHub Release ([`release.yml`](../../.github/workflows/release.yml)) and th | **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. | | **publish.yml red before the upload** (build, test, javadoc or signing failed) | Nothing was uploaded: the plugin refuses to upload after an earlier failure in the reactor (`Earlier build failures detected. Central publishing will not continue`). Fix the cause (an environment one — an expired GPG key, a rotated token — is fixed in the repo secrets) and re-dispatch `publish.yml` with `tag=v`. | | **Upload failed** (network / Portal error at `Going to upload`) | Check the Central portal's Deployments page. If a deployment for the version was created, wait for its state; if it is `FAILED`, drop it. Then re-dispatch `publish.yml` with `tag=v`. | +| **Timed out waiting for validation** (publish.yml red after the upload; the whole train shares the plugin's 1800 s `waitMaxTime`) | The upload happened, so a deployment exists and may still reach `VALIDATED`. **Do not re-dispatch** — that uploads a second deployment. Watch it in the portal: `VALIDATED` → publish it; `FAILED` → treat as *Central validation failed*. | | **Central validation failed** (deployment `FAILED`, publish.yml red at the deploy step) | The deployment is one unit, so **nothing** was published. The portal (and the run log) list the errors per component. An environment cause (signature key not on a keyserver, token): fix it, drop the `FAILED` deployment, re-dispatch with `tag=v`. A content cause (POM metadata, a missing sources/javadoc jar) needs a commit, and the tag is immutable, so fix forward with a patch version. The workflow artifact `central-bundle-v` holds the exact zip that was rejected. | | **Validated but wrong** (deployment `VALIDATED`, not yet published) | Drop it in the portal — nothing reaches Central until the maintainer publishes. Re-dispatch once fixed. | | **Partial module publication** (some coordinates of the version on Central, some not — only possible for a version a pre-consolidation run half-published, or after a manual portal upload) | Confirm what is live (`mvn dependency:get` per coordinate, or the release-smoke matrix), make sure no deployment for the version is still `VALIDATED` (publish or drop it first — an unpublished deployment does not count as published), then re-dispatch `publish.yml` with `tag=v` **and `skip_published=true`**. The plugin asks the Portal which components are already published, leaves those out, and publishes the rest as one deployment. A run in which everything is already published stages nothing and stops. Never bump the tag to force a re-publish, and never set `skip_published` on a normal release. | diff --git a/pom.xml b/pom.xml index cea33f08f..a7b12056b 100644 --- a/pom.xml +++ b/pom.xml @@ -24,13 +24,15 @@ true diff --git a/scripts/release-smoke/README.md b/scripts/release-smoke/README.md index 1ae0dd06e..b8c62944c 100644 --- a/scripts/release-smoke/README.md +++ b/scripts/release-smoke/README.md @@ -67,11 +67,18 @@ GraphCompose coordinates resolve from there and everything else (third-party libraries, the independently versioned fonts and emoji) from Central. The version defaults to the single `graph-compose-core` version staged in ``. -A scenario passes only if, besides its own assertions, every GraphCompose artifact -of that version it resolved records `staged` as its source in -`_remote.repositories` — so a stale cache or a Central copy cannot stand in for the -staged bytes, and a scenario that resolved none of them fails. Staged mode refuses -`--warm` for the same reason. +A scenario passes only if, besides its own assertions, every file of every train +artifact it resolved records `staged` as its source in `_remote.repositories`, and +no train artifact was resolved at a version other than the one staged — so a stale +cache or a Central copy cannot stand in for the staged bytes, a staged POM that +pins a sibling at a drifted version fails, and a scenario that resolved no train +artifact fails. `graph-compose-fonts` and `graph-compose-emoji` are exempt: they +version independently and come from Central. Staged mode refuses `--warm` for the +same reason. + +The bundle a tagged run uploaded is kept as the workflow artifact +`central-bundle-v`. While the deployment waits at `VALIDATED`, download it, +unzip it and smoke it with `--staged-repo` before pressing Publish. Or dispatch the **Release Smoke (consumer verification)** GitHub Actions workflow (`.github/workflows/release-smoke.yml`) with a `version` input — handy after a diff --git a/scripts/release-smoke/run.ps1 b/scripts/release-smoke/run.ps1 index 155b5ecce..736993ea0 100644 --- a/scripts/release-smoke/run.ps1 +++ b/scripts/release-smoke/run.ps1 @@ -53,7 +53,8 @@ if ($StagedRepo) { Write-Error "FATAL: $StagedRepo is not a Maven repository layout holding io/github/demchaav" exit 2 } - $stagedAbs = (Resolve-Path $StagedRepo).Path -replace '\\', '/' + # The path goes into XML; escape the characters that would break it. + $stagedAbs = [System.Security.SecurityElement]::Escape(((Resolve-Path $StagedRepo).Path -replace '\\', '/')) if (-not $PSBoundParameters.ContainsKey('Version')) { $staged = @(Get-ChildItem -Directory (Join-Path $stagedGc 'graph-compose-core') -ErrorAction SilentlyContinue) if ($staged.Count -ne 1) { @@ -65,7 +66,7 @@ if ($StagedRepo) { # The isolated settings, with one exception carved out of the Central-only mirror: # the staged repository, active for every scenario. $settings = Join-Path $repoRoot 'target\release-smoke-m2\settings-staged.xml' - @" + $stagedSettings = @" @@ -91,27 +92,49 @@ if ($StagedRepo) { staged -"@ | Set-Content -Encoding utf8 -Path $settings +"@ + # Without a BOM: Windows PowerShell's `-Encoding utf8` writes one. + [System.IO.File]::WriteAllText($settings, $stagedSettings, (New-Object System.Text.UTF8Encoding($false))) } -# Staged mode only: every GraphCompose artifact of the version under test that the -# scenario resolved must record the staged repository as its source. Fails closed — -# a scenario that resolved none of them proves nothing about the staged bytes. +# Staged mode only. Every file of every train artifact the scenario resolved must +# record the staged repository as its source, and every train artifact must be at +# the version under test — a train module resolved at another version is lockstep +# drift in a staged POM, and it would have come from Central unchecked. fonts and +# emoji are exempt: they version independently and always come from Central. Fails +# closed — a scenario that resolved no train artifact proves nothing. function Test-StagedProvenance { - $dirs = @(Get-ChildItem -Directory (Join-Path $repo 'io\github\demchaav') -ErrorAction SilentlyContinue | - ForEach-Object { Join-Path $_.FullName $Version } | Where-Object { Test-Path $_ }) - if ($dirs.Count -eq 0) { - Write-Host "PROVENANCE: no GraphCompose $Version artifact was resolved at all" - return $false - } + $seen = 0 $ok = $true - foreach ($dir in $dirs) { - $marker = Join-Path $dir '_remote.repositories' - if (-not ((Test-Path $marker) -and (Select-String -Path $marker -SimpleMatch '>staged=' -Quiet))) { - Write-Host "PROVENANCE: $dir was not resolved from the staged repository" - $ok = $false + $artifacts = @(Get-ChildItem -Directory (Join-Path $repo 'io\github\demchaav') -ErrorAction SilentlyContinue) + foreach ($artifact in $artifacts) { + if ($artifact.Name -in @('graph-compose-fonts', 'graph-compose-emoji')) { continue } + foreach ($versionDir in @(Get-ChildItem -Directory $artifact.FullName)) { + if ($versionDir.Name -ne $Version) { + Write-Host "PROVENANCE: $($artifact.Name) resolved at $($versionDir.Name), not the staged $Version" + $ok = $false + continue + } + $seen++ + $marker = Join-Path $versionDir.FullName '_remote.repositories' + if (-not (Test-Path $marker)) { + Write-Host "PROVENANCE: $($versionDir.FullName) records no source repository" + $ok = $false + continue + } + foreach ($line in Get-Content $marker) { + if (-not $line -or $line.StartsWith('#')) { continue } + if (-not $line.EndsWith('>staged=')) { + Write-Host "PROVENANCE: $($artifact.Name) ${Version}: '$line' was not resolved from the staged repository" + $ok = $false + } + } } } + if ($seen -eq 0) { + Write-Host "PROVENANCE: no GraphCompose $Version artifact was resolved at all" + return $false + } return $ok } diff --git a/scripts/release-smoke/run.sh b/scripts/release-smoke/run.sh index 025a5a84b..93f353755 100755 --- a/scripts/release-smoke/run.sh +++ b/scripts/release-smoke/run.sh @@ -63,8 +63,12 @@ if [ -n "$STAGED" ]; then echo "FATAL: $STAGED is not a Maven repository layout holding io/github/demchaav" >&2 exit 2 fi - # pwd -W gives C:/... under Git Bash; elsewhere it fails and plain pwd is right. - STAGED_ABS="$(cd "$STAGED" && (pwd -W 2>/dev/null || pwd))" + # Java on Windows needs C:/... — cygpath gives it under Git Bash and Cygwin; + # elsewhere (Linux, WSL, macOS) the plain absolute path is right. + STAGED_ABS="$(cd "$STAGED" && pwd)" + if command -v cygpath >/dev/null 2>&1; then STAGED_ABS="$(cygpath -m "$STAGED_ABS")"; fi + # The path goes into XML; escape the characters that would break it. + STAGED_URL_PATH="$(printf '%s' "${STAGED_ABS#/}" | sed -e 's/&/\&/g' -e 's//\>/g')" if [ "$VERSION_SET" = "0" ]; then staged_versions="$(ls "$STAGED/io/github/demchaav/graph-compose-core" 2>/dev/null)" if [ "$(printf '%s\n' "$staged_versions" | grep -c .)" != "1" ]; then @@ -91,7 +95,7 @@ if [ -n "$STAGED" ]; then staged - file:///${STAGED_ABS#/} + file:///${STAGED_URL_PATH} true false @@ -105,18 +109,40 @@ if [ -n "$STAGED" ]; then EOF fi -# Staged mode only: every GraphCompose artifact of the version under test that the -# scenario resolved must record the staged repository as its source. Fails closed — -# a scenario that resolved none of them proves nothing about the staged bytes. +# Staged mode only. Every file of every train artifact the scenario resolved must +# record the staged repository as its source, and every train artifact must be at +# the version under test — a train module resolved at another version is lockstep +# drift in a staged POM, and it would have come from Central unchecked. fonts and +# emoji are exempt: they version independently and always come from Central. Fails +# closed — a scenario that resolved no train artifact proves nothing. staged_provenance_ok() { - local seen=0 bad=0 dir - for dir in "$REPO"/io/github/demchaav/*/"$GC_VERSION"; do + local seen=0 bad=0 dir artifact version line + for dir in "$REPO"/io/github/demchaav/*/*/; do [ -d "$dir" ] || continue + dir="${dir%/}" + version="${dir##*/}" + artifact="$(basename "$(dirname "$dir")")" + case "$artifact" in graph-compose-fonts|graph-compose-emoji) continue ;; esac + if [ "$version" != "$GC_VERSION" ]; then + echo "PROVENANCE: $artifact resolved at $version, not the staged $GC_VERSION" >&2 + bad=$((bad + 1)) + continue + fi seen=$((seen + 1)) - if ! grep -qs '>staged=' "$dir/_remote.repositories"; then - echo "PROVENANCE: $dir was not resolved from the staged repository" >&2 + if [ ! -f "$dir/_remote.repositories" ]; then + echo "PROVENANCE: $dir records no source repository" >&2 bad=$((bad + 1)) + continue fi + while IFS= read -r line; do + line="${line%$'\r'}" + case "$line" in ''|'#'*) continue ;; esac + case "$line" in + *'>staged=') ;; + *) echo "PROVENANCE: $artifact $version: '$line' was not resolved from the staged repository" >&2 + bad=$((bad + 1)) ;; + esac + done < "$dir/_remote.repositories" done if [ "$seen" = "0" ]; then echo "PROVENANCE: no GraphCompose $GC_VERSION artifact was resolved at all" >&2 From 4844767b94597b65e577cc004b4931bbb93b71c3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 14:28:28 +0100 Subject: [PATCH 5/6] fix(publish): dispatch recovery from the tag and never re-dispatch after an upload A workflow_dispatch runs the workflow file of the ref it is dispatched from, and main - the Actions UI default - is merged only after the tag, so a recovery dispatched from main could run the previous per-module publish workflow. Every recovery now dispatches from the tag: gh workflow run publish.yml --ref vX.Y.Z -f tag=vX.Y.Z. Once the log shows "Uploaded bundle successfully ... deploymentId", a deployment exists whatever turned the job red afterwards; the plugin does not retry its status polling, so one transient error is enough. The runbook now states that rule once and acts on the deployment's portal state instead of re-dispatching, which would upload a second deployment of the same coordinates. Upload rejections without a deploymentId (a bad token, an outage) get their own row; the staged smoke is documented as unavailable for a skip_published bundle. The release smoke refuses an empty --staged-repo / -StagedRepo value, which used to switch staged mode off and smoke the published default from Central. run.sh reads a final _remote.repositories line without a newline and infers the staged version from directories only, matching run.ps1; run.ps1 requires the staged path to be a directory. PublishTrainGuardTest requires the deploy line to invoke Maven once, chain no other command and carry one module selection, treats a direct central-publishing publish goal as an upload, holds SKIP_PUBLISHED to a single definition, and requires one plugin declaration per train pom. PublishedModules reads every -pl / --projects selection. The bundle artifact name carries the run attempt, and the concurrency comment records that GitHub keeps one pending run per group. --- .github/workflows/publish.yml | 27 ++++--- CHANGELOG.md | 3 +- .../documentation/PublishTrainGuardTest.java | 74 ++++++++++++++----- .../documentation/PublishedModules.java | 9 ++- docs/contributing/release-process.md | 22 ++++-- scripts/release-smoke/README.md | 7 +- scripts/release-smoke/run.ps1 | 9 ++- scripts/release-smoke/run.sh | 17 ++++- 8 files changed, 121 insertions(+), 47 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2593cc5a3..4bd071f2a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,10 @@ name: Publish to Maven Central # Release; this one publishes the Maven Central artefacts. They can # succeed or fail independently, and a maintainer can re-run this # workflow alone via workflow_dispatch if Central had a transient -# validator hiccup without re-cutting the tag. +# validator hiccup without re-cutting the tag. Dispatch it FROM THE TAG +# (gh workflow run publish.yml --ref vX.Y.Z -f tag=vX.Y.Z): a dispatch runs +# the workflow file of the ref it is dispatched from, and main, the UI's +# default, is merged only after the tag. # # The whole lockstep train ships as ONE Central deployment: a single # `-P release deploy -pl ` over the root reactor. The plugin stages @@ -15,8 +18,9 @@ name: Publish to Maven Central # last one, so one GraphCompose version is one publish operation (one # Release Count event) holding eight components, each still its own # coordinate for consumers. The deployment validates and publishes as a -# unit, so a release can no longer end half on Central. The root -# aggregator is not selected (and nothing inherits its maven.deploy.skip); +# unit, so a release can no longer end split across deployments. The root +# aggregator is not selected (and no train module inherits its +# maven.deploy.skip); # examples, benchmarks, qa, coverage, fonts and emoji are not selected. # fonts and emoji keep their own tags and workflows (publish-fonts.yml, # publish-emoji.yml) and publish only when their own version moves. @@ -50,7 +54,7 @@ on: required: true type: string skip_published: - description: 'Recovery only: leave out components of this version that are already published on Maven Central, and publish the rest as one deployment. Keep false for a normal publish; set it ONLY after confirming which coordinates are live. A deployment that is still VALIDATED but unpublished is not "published" — publish or drop it in the Central portal first.' + description: 'Recovery only: leave out components of this version that are already published on Maven Central, and publish the rest as one deployment. Keep false for a normal publish; set it ONLY after confirming which coordinates are live. A deployment that is still VALIDATED but unpublished is not "published" — publish or drop it in the Central portal first. Dispatch from the tag: gh workflow run publish.yml --ref vX.Y.Z -f tag=vX.Y.Z -f skip_published=true.' required: false type: boolean default: false @@ -62,7 +66,9 @@ permissions: # uploads of the same version would race to validate the same coordinates — # so a constant group name funnels every tag / dispatch through one lane. # cancel-in-progress is false so a re-tag or dispatch never aborts a publish -# already mid-upload; the second run queues behind it instead. +# already mid-upload; the next run waits behind it. GitHub keeps only ONE +# pending run per group: a third run cancels the one already waiting, so +# never queue two publishes behind a running one. concurrency: group: publish-maven-central cancel-in-progress: false @@ -190,9 +196,10 @@ jobs: # then leaves ONE deployment for the maintainer to publish on # central.sonatype.com. # - # A failure in any module before the upload stops the plugin from - # uploading anything ("Earlier build failures detected"), so nothing - # partial reaches Central. Never widen the selection: -am would pull + # A failure in any module stops the build before the last module, the one + # that uploads, so nothing partial reaches Central (and under + # --fail-at-end the plugin refuses on its own: "Earlier build failures + # detected"). Never widen the selection: -am would pull # fonts and emoji (core's test-scope deps) into the reactor and the # deployment, re-uploading coordinates they already published, and -amd # would pull in the build-only dependents (examples, qa, …). @@ -225,7 +232,9 @@ jobs: if: ${{ !cancelled() && steps.train.outcome != 'skipped' }} uses: actions/upload-artifact@v7 with: - name: central-bundle-${{ github.event.inputs.tag || github.ref_name }} + # The attempt number keeps a "Re-run jobs" attempt from colliding with + # the artifact an earlier attempt of the same run already uploaded. + name: central-bundle-${{ github.event.inputs.tag || github.ref_name }}-attempt-${{ github.run_attempt }} path: '*/target/central-publishing/central-bundle.zip' if-no-files-found: warn retention-days: 90 diff --git a/CHANGELOG.md b/CHANGELOG.md index c53bd352f..e51ae22a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ follow semantic versioning; release dates are ISO 8601. artifacts: the `central-publishing-maven-plugin` stages every module and uploads one `central-bundle.zip` from the last. Every coordinate, POM, jar, sources and javadoc jar and signature is what it was — only the transaction is shared. The deployment validates as a unit, - so a failure no longer leaves the first modules of a version published and the rest missing. + so a failure no longer leaves the first modules of a version uploaded as separate deployments + and the rest missing. `graph-compose-fonts` and `graph-compose-emoji` keep their own tags and workflows. The `start_at` resume input is replaced by `skip_published`, off by default, which leaves out components the Portal already reports as published, for recovering a version that is diff --git a/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java index fbcd04f21..44905f489 100644 --- a/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java @@ -24,8 +24,8 @@ * reactor run: the central-publishing plugin stages each selected module and uploads * them together from the last one. That shape has failure modes no build notices. A * module left out of {@code -pl} is simply never released; an {@code -am} quietly pulls - * the independently versioned fonts and emoji into the deployment and re-uploads - * coordinates Central already holds; and because the upload runs with whichever + * the independently versioned fonts and emoji into the train's deployment, re-uploading + * them when their version is already on Central; and because the upload runs with whichever * module's plugin settings the reactor happens to order last, eight release profiles * that drift apart publish with settings nobody chose.

* @@ -47,8 +47,22 @@ class PublishTrainGuardTest { private static final Pattern CENTRAL_PLUGIN_VERSION = Pattern.compile( "\\s*([^<]+?)\\s*"); - /** The {@code deploy} goal as a word — not {@code deployment}, not {@code deploy-web}. */ - private static final Pattern DEPLOY_GOAL = Pattern.compile("(? ALSO_MAKE = Set.of("-am", "--also-make", "-amd", "--also-make-dependents"); @@ -82,6 +96,19 @@ void theTrainShipsAsExactlyOneReactorDeploy() throws IOException { .doesNotContain("-f"); assertThat(deploy).contains("-P release"); + assertThat(CHAINING.matcher(deploy).find()) + .describedAs("the train deploy line chains another command — a second `./mvnw " + + "… deploy` after `&&` is a second deployment no other check sees: %s", deploy) + .isFalse(); + assertThat(tokens.stream().filter(token -> MAVEN_LAUNCHER.matcher(token).find()).count()) + .describedAs("the train deploy line must invoke Maven exactly once: %s", deploy) + .isEqualTo(1); + assertThat(tokens.stream().filter(token -> SELECTION_OPTION.matcher(token).matches()).count()) + .describedAs("the train deploy must carry exactly one module selection — Maven " + + "merges a second -pl / --projects, which could add a module no train " + + "check reads: %s", deploy) + .isEqualTo(1); + Set widening = new TreeSet<>(ALSO_MAKE); widening.retainAll(tokens); assertThat(widening) @@ -96,9 +123,11 @@ void theTrainShipsAsExactlyOneReactorDeploy() throws IOException { * which recognises one shape: a single-line {@code ./mvnw … deploy}. A deploy written * any other way — {@code mvn}, a {@code - run:} list item, a command wrapped with * {@code \} continuations — would be invisible to all of them, so a second deployment - * could ship while "exactly one deploy" stayed green. This keys on the positive - * signal instead: any non-comment line of a publish workflow naming the - * {@code deploy} goal must be one the parser read. + * could ship while "exactly one deploy" stayed green. So could the plugin's + * {@code publish} goal called directly. This keys on the positive signal instead: any + * non-comment line of a publish workflow naming either must be one the parser read. + * It cannot see an upload hidden behind a script the workflow calls, or a deploy in + * a workflow not named {@code publish*.yml}. */ @Test void everyDeployInAPublishWorkflowIsOneTheGuardsRead() throws IOException { @@ -112,7 +141,7 @@ void everyDeployInAPublishWorkflowIsOneTheGuardsRead() throws IOException { List parsed = PublishedModules.deployCommands(workflow); for (String line : Files.readAllLines(workflow)) { String code = line.strip(); - if (code.startsWith("#") || !DEPLOY_GOAL.matcher(code).find()) { + if (code.startsWith("#") || !UPLOADS.matcher(code).find()) { continue; } if (!parsed.contains(code)) { @@ -123,7 +152,8 @@ void everyDeployInAPublishWorkflowIsOneTheGuardsRead() throws IOException { } assertThat(unread) - .describedAs("publish workflow lines that name the deploy goal in a shape " + .describedAs("publish workflow lines that upload (the deploy goal, or the " + + "central-publishing publish goal called directly) in a shape " + "PublishedModules does not read, so no guard checks what they ship. " + "Write the deploy as one `./mvnw … deploy` line, or teach " + "PublishedModules the new shape") @@ -201,11 +231,13 @@ void everyTrainModuleConfiguresCentralPublishingIdentically() throws IOException Map versions = new LinkedHashMap<>(); for (String module : PublishedModules.lockstepPublished(PROJECT_ROOT)) { String pom = Files.readString(PROJECT_ROOT.resolve(module).resolve("pom.xml")); - Matcher plugin = CENTRAL_PLUGIN.matcher(pom); - assertThat(plugin.find()) - .describedAs("%s/pom.xml declares no central-publishing plugin block", module) - .isTrue(); - declarations.put(module, plugin.group().replaceAll("\\s+", " ")); + List blocks = CENTRAL_PLUGIN.matcher(pom).results() + .map(match -> match.group().replaceAll("\\s+", " ")).toList(); + assertThat(blocks) + .describedAs("%s/pom.xml must declare the central-publishing plugin exactly once " + + "— a second declaration would escape the comparison below", module) + .hasSize(1); + declarations.put(module, blocks.get(0)); Matcher version = CENTRAL_PLUGIN_VERSION.matcher(pom); versions.put(module, version.find() ? version.group(1) : ""); @@ -233,7 +265,9 @@ void everyTrainModuleConfiguresCentralPublishingIdentically() throws IOException void skippingPublishedComponentsIsAnExplicitRecoveryOnly() throws IOException { // A Windows checkout carries CRLF; the patterns below are written against LF. String workflow = Files.readString(PUBLISH).replace("\r", ""); - String deploy = PublishedModules.deployCommands(PUBLISH).get(0); + List deploys = PublishedModules.deployCommands(PUBLISH); + assertThat(deploys).describedAs("publish.yml carries no readable deploy").isNotEmpty(); + String deploy = deploys.get(0); assertThat(workflow) .describedAs("publish.yml must offer skip_published as a boolean dispatch input " @@ -241,10 +275,12 @@ void skippingPublishedComponentsIsAnExplicitRecoveryOnly() throws IOException { .containsPattern("(?m)^ skip_published:\\s*$") .containsPattern("skip_published:(?:\\n {8}.*)*\\n {8}type: boolean") .containsPattern("skip_published:(?:\\n {8}.*)*\\n {8}default: false"); - assertThat(workflow) - .describedAs("SKIP_PUBLISHED must be true only when a dispatch set skip_published — " - + "never on a tag push, where the input is absent") - .contains("SKIP_PUBLISHED: ${{ github.event.inputs.skip_published == 'true' }}"); + List definitions = workflow.lines().map(String::strip) + .filter(line -> line.startsWith("SKIP_PUBLISHED:")).toList(); + assertThat(definitions) + .describedAs("SKIP_PUBLISHED must be defined once, and be true only when a dispatch " + + "set skip_published — never on a tag push, where the input is absent") + .containsExactly("SKIP_PUBLISHED: ${{ github.event.inputs.skip_published == 'true' }}"); assertThat(deploy) .describedAs("the deploy must pass the recovery switch through, and only that way") .contains("-DignorePublishedComponents=$SKIP_PUBLISHED"); diff --git a/core/src/test/java/com/demcha/documentation/PublishedModules.java b/core/src/test/java/com/demcha/documentation/PublishedModules.java index d75da5cc9..47861fc8c 100644 --- a/core/src/test/java/com/demcha/documentation/PublishedModules.java +++ b/core/src/test/java/com/demcha/documentation/PublishedModules.java @@ -42,9 +42,12 @@ private PublishedModules() { private static final Pattern DEPLOY_STEP = Pattern.compile("-f\\s+([\\w-]+)/pom\\.xml"); - /** A reactor deploy's module selection: {@code -pl :a,:b,…}. */ + /** + * A reactor deploy's module selection: {@code -pl :a,:b,…}, in either spelling and with + * a space or {@code =}. Every occurrence is read, because Maven merges repeated ones. + */ private static final Pattern DEPLOY_SELECTION = - Pattern.compile("\\s-pl\\s+(\\S+)"); + Pattern.compile("\\s(?:-pl|--projects)(?:\\s+|=)(\\S+)"); private static final Pattern VERSION = Pattern.compile("\\s*([^<]+?)\\s*"); @@ -134,7 +137,7 @@ private static List modulesOf(String command, Map byArtifa modules.add(standalone.group(1)); } Matcher selection = DEPLOY_SELECTION.matcher(command); - if (selection.find()) { + while (selection.find()) { for (String selector : selection.group(1).split(",")) { String artifactId = selector.strip().replaceFirst("^:", ""); if (artifactId.isEmpty()) { diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index cd78b24ec..efe0f5229 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -136,7 +136,7 @@ Run within 1 hour of the tag push. Independent steps can run in parallel. 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), moves the `graph-compose-templates` japicmp previous-release pin (`japicmp.baseline.previous` in `templates/pom.xml`) onto the release just published, **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` over the whole reactor 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). GitHub refuses a Release body over 125 000 characters, so the section first goes through [`scripts/release-notes.mjs`](../../scripts/release-notes.mjs): one that fits is published as written, and a longer one is published as its subsection headings and each entry's bold lead, with a link to the full section at the tag. The 2.4.0 section, at over 156 000 characters, failed its tag's Release before this existed. 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 each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) with the repo's GPG key, and uploads the whole train to Maven Central via the `central-publishing-maven-plugin` as **one deployment** (§2.F), named `GraphCompose v` in the Central portal. 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 deployment stops at `VALIDATED`; the maintainer publishes it with **one** click on [central.sonatype.com](https://central.sonatype.com) (Deployments → `GraphCompose v` → Publish), which releases all eight components together. The run keeps the uploaded `central-bundle.zip` as the workflow artifact `central-bundle-v`; before pressing Publish, download it, unzip it and run `bash scripts/release-smoke/run.sh --staged-repo ` to prove every coordinate in the deployment resolves and works for a consumer. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the deployment is published), and check that the [Usage Center](https://central.sonatype.com/publishing/usage) Release Count moved by one for the release. +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 each module's artefacts (main / sources / javadoc / pom — the `graph-compose` wrapper has no sources of its own, so it publishes no sources jar) with the repo's GPG key, and uploads the whole train to Maven Central via the `central-publishing-maven-plugin` as **one deployment** (§2.F), named `GraphCompose v` in the Central portal. 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 deployment stops at `VALIDATED`; the maintainer publishes it with **one** click on [central.sonatype.com](https://central.sonatype.com) (Deployments → `GraphCompose v` → Publish), which releases all eight components together. The run keeps the uploaded `central-bundle.zip` as the workflow artifact `central-bundle-v-attempt-`; before pressing Publish, download it (`gh run download `), unzip the `central-bundle.zip` inside it into a directory and run `bash scripts/release-smoke/run.sh --staged-repo ` to prove every coordinate in the deployment resolves and works for a consumer. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the deployment is published), and check that the [Usage Center](https://central.sonatype.com/publishing/usage) Release Count moved by one for the release. 10. **Optional**: GitHub Discussions announcement (mirror the prior release's style; close with *"author intent, not coordinates"*), LinkedIn post, r/java post. The release is **done** only when steps 1–7 are all green; step 9 adds Maven Central availability once the D-track of v1.6.6 has shipped. @@ -256,7 +256,7 @@ aggregate. They all carry the **same** version. and the last one bundles them into `central-bundle.zip` and uploads it once. One version is one deployment holding eight components; each stays its own coordinate for consumers. The deployment validates and publishes as a unit, so a train can no - longer land half on Central. Fonts and emoji are not selected — they keep their own + longer end up split across several deployments in different states. Fonts and emoji are not selected — they keep their own tags and single-module workflows. `PublishTrainGuardTest` holds the `-pl` list to the lockstep modules derived from the poms, forbids `-am` (which would pull fonts and emoji in), and requires the eight `release` profiles to declare the plugin @@ -376,15 +376,21 @@ The published jar is final. **Never force-move a tag** that Maven Central has al 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. +Two rules govern every `publish.yml` recovery below: + +- **Dispatch from the tag, not from a branch.** A `workflow_dispatch` runs the workflow file of the ref it is dispatched *from* — the `tag` input only chooses what is checked out. The Actions UI defaults to `main`, and `main` is merged after the tag (§2.B step 3), so dispatching from it can run an older publish workflow. Always: + `gh workflow run publish.yml --ref v -f tag=v` (add `-f skip_published=true` only where a row says so). +- **Once the log shows `deploymentId`, never re-dispatch.** `Uploaded bundle successfully, deployment name: …, deploymentId: …` means a deployment exists, whatever turned the job red afterwards (a validation timeout, a transient error while the plugin polls the status — it does not retry). A re-dispatch would upload a **second** deployment of the same coordinates. Act on the deployment's state in the portal instead: `VALIDATED` → smoke the bundle artifact and publish it; `FAILED` → see *Central validation failed*. + | 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. | -| **publish.yml red before the upload** (build, test, javadoc or signing failed) | Nothing was uploaded: the plugin refuses to upload after an earlier failure in the reactor (`Earlier build failures detected. Central publishing will not continue`). Fix the cause (an environment one — an expired GPG key, a rotated token — is fixed in the repo secrets) and re-dispatch `publish.yml` with `tag=v`. | -| **Upload failed** (network / Portal error at `Going to upload`) | Check the Central portal's Deployments page. If a deployment for the version was created, wait for its state; if it is `FAILED`, drop it. Then re-dispatch `publish.yml` with `tag=v`. | -| **Timed out waiting for validation** (publish.yml red after the upload; the whole train shares the plugin's 1800 s `waitMaxTime`) | The upload happened, so a deployment exists and may still reach `VALIDATED`. **Do not re-dispatch** — that uploads a second deployment. Watch it in the portal: `VALIDATED` → publish it; `FAILED` → treat as *Central validation failed*. | -| **Central validation failed** (deployment `FAILED`, publish.yml red at the deploy step) | The deployment is one unit, so **nothing** was published. The portal (and the run log) list the errors per component. An environment cause (signature key not on a keyserver, token): fix it, drop the `FAILED` deployment, re-dispatch with `tag=v`. A content cause (POM metadata, a missing sources/javadoc jar) needs a commit, and the tag is immutable, so fix forward with a patch version. The workflow artifact `central-bundle-v` holds the exact zip that was rejected. | -| **Validated but wrong** (deployment `VALIDATED`, not yet published) | Drop it in the portal — nothing reaches Central until the maintainer publishes. Re-dispatch once fixed. | -| **Partial module publication** (some coordinates of the version on Central, some not — only possible for a version a pre-consolidation run half-published, or after a manual portal upload) | Confirm what is live (`mvn dependency:get` per coordinate, or the release-smoke matrix), make sure no deployment for the version is still `VALIDATED` (publish or drop it first — an unpublished deployment does not count as published), then re-dispatch `publish.yml` with `tag=v` **and `skip_published=true`**. The plugin asks the Portal which components are already published, leaves those out, and publishes the rest as one deployment. A run in which everything is already published stages nothing and stops. Never bump the tag to force a re-publish, and never set `skip_published` on a normal release. | +| **publish.yml red before the upload** (build, test, javadoc or signing failed; no `deploymentId` in the log) | Nothing was uploaded: a failing module stops the build before the last module, which is the one that uploads. Fix the cause (an environment one — an expired GPG key — is fixed in the repo secrets) and dispatch from the tag. | +| **Upload rejected** (the upload itself errors — network, Portal outage, a rotated or wrong `CENTRAL_TOKEN` — and the log has no `deploymentId`) | Check the portal's Deployments page to be sure no deployment was created. Fix the cause (a token in the repo secrets) and dispatch from the tag. If a deployment *was* created, the `deploymentId` rule applies. | +| **Red after the upload** (`deploymentId` in the log, then a timeout on the plugin's 1800 s `waitMaxTime`, which the whole train shares, or a status-polling error) | Do **not** re-dispatch (rule above). Watch the deployment in the portal: `VALIDATED` → smoke and publish it; `FAILED` → *Central validation failed*. | +| **Central validation failed** (deployment `FAILED`) | The deployment is one unit, so **nothing** was published. The portal (and the run log) list the errors per component. An environment cause (the signing key not on a keyserver): fix it, drop the `FAILED` deployment, dispatch from the tag. A content cause (POM metadata, a missing sources/javadoc jar) needs a commit, and the tag is immutable, so fix forward with a patch version. The run's workflow artifact holds the exact zip that was rejected. | +| **Validated but wrong** (deployment `VALIDATED`, not yet published) | Drop it in the portal — nothing reaches Central until the maintainer publishes. Dispatch from the tag once fixed. | +| **Partial module publication** (some coordinates of the version on Central, some not — only possible for a version a pre-consolidation run left partly published, or after a manual portal upload) | Confirm what is live (`mvn dependency:get` per coordinate, or the release-smoke matrix), make sure no deployment for the version is still `VALIDATED` (publish or drop it first — an unpublished deployment does not count as published), then dispatch from the tag **with `-f skip_published=true`**. The plugin asks the Portal which components are already published, leaves those out, and uploads the rest as one deployment, which stops at `VALIDATED` for the usual Publish click. A run in which everything is already published stages nothing and stops. Such a bundle omits the live modules, so the staged smoke cannot consume it — smoke the version from Central after publishing instead. Never bump the tag to force a re-publish, and never set `skip_published` on a normal release. | | **Re-dispatch of an already-published version** | Without `skip_published`, Central rejects re-uploading the published coordinates and the deployment fails without changing anything. With it, the run is a no-op. | | **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). | diff --git a/scripts/release-smoke/README.md b/scripts/release-smoke/README.md index b8c62944c..68fff0066 100644 --- a/scripts/release-smoke/README.md +++ b/scripts/release-smoke/README.md @@ -77,8 +77,11 @@ version independently and come from Central. Staged mode refuses `--warm` for th same reason. The bundle a tagged run uploaded is kept as the workflow artifact -`central-bundle-v`. While the deployment waits at `VALIDATED`, download it, -unzip it and smoke it with `--staged-repo` before pressing Publish. +`central-bundle-v-attempt-`. While the deployment waits at `VALIDATED`, +download it (`gh run download `), unzip the `central-bundle.zip` inside it +into a directory, and smoke that directory with `--staged-repo` before pressing +Publish. A recovery bundle (`skip_published`) omits the modules already live, so it +cannot be smoked this way — smoke that version from Central after publishing. Or dispatch the **Release Smoke (consumer verification)** GitHub Actions workflow (`.github/workflows/release-smoke.yml`) with a `version` input — handy after a diff --git a/scripts/release-smoke/run.ps1 b/scripts/release-smoke/run.ps1 index 736993ea0..c743f7a53 100644 --- a/scripts/release-smoke/run.ps1 +++ b/scripts/release-smoke/run.ps1 @@ -41,6 +41,13 @@ $scenarios = @('s1-graph-compose', 's2-core-only', 's3-core-render-pdf', 's4-tem $repo = Join-Path $repoRoot 'target\release-smoke-m2\repo' New-Item -ItemType Directory -Force -Path $repo | Out-Null +# An empty value (an unset variable) must not quietly turn staged mode off and smoke +# the published default from Central instead. +if ($PSBoundParameters.ContainsKey('StagedRepo') -and -not $StagedRepo) { + Write-Error '-StagedRepo needs a directory (got an empty value)' + exit 2 +} + if ($StagedRepo) { if ($Warm) { # A warm cache can satisfy a coordinate without consulting the staged repo, @@ -49,7 +56,7 @@ if ($StagedRepo) { exit 2 } $stagedGc = Join-Path $StagedRepo 'io\github\demchaav' - if (-not (Test-Path $stagedGc)) { + if (-not (Test-Path $stagedGc -PathType Container)) { Write-Error "FATAL: $StagedRepo is not a Maven repository layout holding io/github/demchaav" exit 2 } diff --git a/scripts/release-smoke/run.sh b/scripts/release-smoke/run.sh index 93f353755..1ceab7664 100755 --- a/scripts/release-smoke/run.sh +++ b/scripts/release-smoke/run.sh @@ -39,19 +39,27 @@ REPO="$REPO_ROOT/target/release-smoke-m2/repo" GC_VERSION="2.4.1" WARM=0 STAGED="" +STAGED_SET=0 VERSION_SET=0 while [ $# -gt 0 ]; do case "$1" in --warm) WARM=1; shift ;; --version) GC_VERSION="${2:?--version needs a value}"; VERSION_SET=1; shift 2 ;; --version=*) GC_VERSION="${1#*=}"; VERSION_SET=1; shift ;; - --staged-repo) STAGED="${2:?--staged-repo needs a directory}"; shift 2 ;; - --staged-repo=*) STAGED="${1#*=}"; shift ;; + --staged-repo) STAGED="${2-}"; STAGED_SET=1; shift; [ $# -gt 0 ] && shift ;; + --staged-repo=*) STAGED="${1#*=}"; STAGED_SET=1; shift ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done mkdir -p "$REPO" +# An empty value (an unset shell variable) must not quietly turn staged mode off and +# smoke the published default from Central instead. +if [ "$STAGED_SET" = "1" ] && [ -z "$STAGED" ]; then + echo "--staged-repo needs a directory (got an empty value)" >&2 + exit 2 +fi + if [ -n "$STAGED" ]; then if [ "$WARM" = "1" ]; then # A warm cache can satisfy a coordinate without consulting the staged repo, @@ -70,7 +78,8 @@ if [ -n "$STAGED" ]; then # The path goes into XML; escape the characters that would break it. STAGED_URL_PATH="$(printf '%s' "${STAGED_ABS#/}" | sed -e 's/&/\&/g' -e 's//\>/g')" if [ "$VERSION_SET" = "0" ]; then - staged_versions="$(ls "$STAGED/io/github/demchaav/graph-compose-core" 2>/dev/null)" + # Version directories only — a maven-metadata.xml beside them is not a version. + staged_versions="$(cd "$STAGED/io/github/demchaav/graph-compose-core" 2>/dev/null && ls -d -- */ 2>/dev/null | tr -d /)" if [ "$(printf '%s\n' "$staged_versions" | grep -c .)" != "1" ]; then echo "FATAL: expected exactly one staged graph-compose-core version, found: $staged_versions" >&2 exit 2 @@ -134,7 +143,7 @@ staged_provenance_ok() { bad=$((bad + 1)) continue fi - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do line="${line%$'\r'}" case "$line" in ''|'#'*) continue ;; esac case "$line" in From 0bdb5a552280cf7222c82af47eddbbac7cb679a7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 15:03:53 +0100 Subject: [PATCH 6/6] fix(publish): recover pre-consolidation tags with their own start_at, never re-run after an upload A tag cut before the single-deployment workflow carries the old publish workflow, which declares start_at and no skip_published, so dispatching it from the tag with -f skip_published=true is rejected as an unexpected input. The partial-publication recovery now names both cases: skip_published for tags cut with the single-deployment workflow, the tag's own start_at for v2.4.1 and earlier. "Re-run failed jobs" after the log shows deploymentId uploads a second deployment just as a dispatch does. The rule now covers both, and names a re-run of the tag's own run as the other way to get the tag's workflow file. The publish.yml header and deploy-step comment no longer suggest running the workflow again after an upload. A validated-but-wrong deployment needs a patch version when the cause is in the content. PublishTrainGuardTest reads deploy tokens unquoted, treats a single `&` as chaining, and counts the --projects prefixes Maven accepts as a module selection; PublishedModules reads those spellings too. A central-publishing plugin block is recognised whatever order its child elements are written in. run.sh refuses an empty --version= value; run.ps1 resolves the staged path literally. --- .github/workflows/publish.yml | 22 +++++++++------- .../documentation/PublishTrainGuardTest.java | 26 +++++++++++++------ .../documentation/PublishedModules.java | 7 ++--- docs/contributing/release-process.md | 10 +++---- scripts/release-smoke/run.ps1 | 6 ++--- scripts/release-smoke/run.sh | 3 ++- 6 files changed, 45 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4bd071f2a..99a4dd234 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,12 +5,15 @@ name: Publish to Maven Central # Triggered by the same `v*` tag push that fires release.yml (Track D4). # The two workflows are independent — release.yml creates the GitHub # Release; this one publishes the Maven Central artefacts. They can -# succeed or fail independently, and a maintainer can re-run this -# workflow alone via workflow_dispatch if Central had a transient -# validator hiccup without re-cutting the tag. Dispatch it FROM THE TAG -# (gh workflow run publish.yml --ref vX.Y.Z -f tag=vX.Y.Z): a dispatch runs -# the workflow file of the ref it is dispatched from, and main, the UI's -# default, is merged only after the tag. +# succeed or fail independently, and a maintainer can run this workflow +# again for the same tag, without re-cutting it, when it failed BEFORE +# anything was uploaded (no "deploymentId" in the log). Once a deployment +# exists, never run the deploy again — act on it in the Central portal (see +# the recovery table in docs/contributing/release-process.md). Run the tag's +# own workflow: "Re-run failed jobs" on the tag's run, or dispatch FROM THE +# TAG (gh workflow run publish.yml --ref vX.Y.Z -f tag=vX.Y.Z) — a dispatch +# runs the workflow file of the ref it is dispatched from, and main, the +# UI's default, is merged only after the tag. # # The whole lockstep train ships as ONE Central deployment: a single # `-P release deploy -pl ` over the root reactor. The plugin stages @@ -205,9 +208,10 @@ jobs: # would pull in the build-only dependents (examples, qa, …). # # The whole train shares the plugin's waitMaxTime (1800 s). A run that - # goes red on that wait may still leave a deployment that later reaches - # VALIDATED — check the Central portal before re-dispatching, or the - # re-dispatch uploads a second deployment. + # goes red on that wait (or on any status-polling error, which the plugin + # does not retry) still leaves a deployment once "deploymentId" is in the + # log: act on it in the Central portal, and never dispatch or re-run the + # deploy again — that uploads a second deployment. # # ignorePublishedComponents is false on every tag push and on a dispatch # that leaves skip_published unset. Set, the plugin asks the Central diff --git a/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java index 44905f489..4780c1a25 100644 --- a/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java @@ -38,10 +38,13 @@ class PublishTrainGuardTest { private static final Path PROJECT_ROOT = RepoRoot.get(); private static final Path PUBLISH = PROJECT_ROOT.resolve(".github/workflows/publish.yml"); - /** The central-publishing plugin declaration inside a pom. */ + /** + * A {@code } block naming the central-publishing plugin, whatever order its + * child elements are written in: the block may not close before the artifactId. + */ private static final Pattern CENTRAL_PLUGIN = Pattern.compile( - "\\s*org\\.sonatype\\.central\\s*" - + "central-publishing-maven-plugin.*?", + "(?:(?!).)*?\\s*central-publishing-maven-plugin\\s*" + + ".*?", Pattern.DOTALL); private static final Pattern CENTRAL_PLUGIN_VERSION = Pattern.compile( @@ -58,11 +61,17 @@ class PublishTrainGuardTest { /** A Maven launcher token: {@code mvn}, {@code ./mvnw}, {@code mvnw.cmd}, … */ private static final Pattern MAVEN_LAUNCHER = Pattern.compile("(?:^|/)mvnw?(?:\\.cmd)?$"); - /** A module-selection option, in either spelling, with or without {@code =value}. */ - private static final Pattern SELECTION_OPTION = Pattern.compile("^(?:-pl|--projects)(?:=.*)?$"); + /** + * A module-selection option: {@code -pl}, or {@code --projects} and the prefixes of it + * Maven's option parser also accepts, with or without {@code =value}. + */ + private static final Pattern SELECTION_OPTION = Pattern.compile("^(?:-pl|--pr[a-z]*)(?:=.*)?$"); - /** Shell operators that would chain a second command onto the deploy line. */ - private static final Pattern CHAINING = Pattern.compile("&&|\\|\\||;|\\|"); + /** + * Shell operators that would chain or background a second command onto the deploy + * line ({@code &&}, {@code ||}, {@code ;}, {@code |}, {@code &}). + */ + private static final Pattern CHAINING = Pattern.compile("[;&|]"); /** Options that widen a {@code -pl} selection beyond the modules it names. */ private static final Set ALSO_MAKE = Set.of("-am", "--also-make", "-amd", "--also-make-dependents"); @@ -88,7 +97,8 @@ void theTrainShipsAsExactlyOneReactorDeploy() throws IOException { .hasSize(1); String deploy = deploys.get(0); - List tokens = List.of(deploy.split("\\s+")); + // Unquoted, so `"./mvnw"` or `'-pl'` counts the same as the bare token the shell sees. + List tokens = List.of(deploy.replaceAll("[\"']", "").split("\\s+")); assertThat(tokens) .describedAs("the train deploy must select its modules with -pl over the root " + "reactor and run the release profile: %s", deploy) diff --git a/core/src/test/java/com/demcha/documentation/PublishedModules.java b/core/src/test/java/com/demcha/documentation/PublishedModules.java index 47861fc8c..b629ca578 100644 --- a/core/src/test/java/com/demcha/documentation/PublishedModules.java +++ b/core/src/test/java/com/demcha/documentation/PublishedModules.java @@ -43,11 +43,12 @@ private PublishedModules() { Pattern.compile("-f\\s+([\\w-]+)/pom\\.xml"); /** - * A reactor deploy's module selection: {@code -pl :a,:b,…}, in either spelling and with - * a space or {@code =}. Every occurrence is read, because Maven merges repeated ones. + * A reactor deploy's module selection: {@code -pl :a,:b,…}, or {@code --projects} and + * its accepted prefixes, with a space or {@code =}, quoted or not. Every occurrence is + * read, because Maven merges repeated ones. */ private static final Pattern DEPLOY_SELECTION = - Pattern.compile("\\s(?:-pl|--projects)(?:\\s+|=)(\\S+)"); + Pattern.compile("\\s[\"']?(?:-pl|--pr[a-z]*)[\"']?(?:\\s+|=)[\"']?([^\\s\"']+)"); private static final Pattern VERSION = Pattern.compile("\\s*([^<]+?)\\s*"); diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index efe0f5229..609e87bd4 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -378,19 +378,19 @@ The GitHub Release ([`release.yml`](../../.github/workflows/release.yml)) and th Two rules govern every `publish.yml` recovery below: -- **Dispatch from the tag, not from a branch.** A `workflow_dispatch` runs the workflow file of the ref it is dispatched *from* — the `tag` input only chooses what is checked out. The Actions UI defaults to `main`, and `main` is merged after the tag (§2.B step 3), so dispatching from it can run an older publish workflow. Always: +- **Run the tag's own workflow.** A `workflow_dispatch` runs the workflow file of the ref it is dispatched *from* — the `tag` input only chooses what is checked out. The Actions UI defaults to `main`, and `main` is merged after the tag (§2.B step 3), so dispatching from it can run a different publish workflow. Either use **Re-run failed jobs** on the tag's own run (a re-run reuses that run's workflow file), or dispatch from the tag: `gh workflow run publish.yml --ref v -f tag=v` (add `-f skip_published=true` only where a row says so). -- **Once the log shows `deploymentId`, never re-dispatch.** `Uploaded bundle successfully, deployment name: …, deploymentId: …` means a deployment exists, whatever turned the job red afterwards (a validation timeout, a transient error while the plugin polls the status — it does not retry). A re-dispatch would upload a **second** deployment of the same coordinates. Act on the deployment's state in the portal instead: `VALIDATED` → smoke the bundle artifact and publish it; `FAILED` → see *Central validation failed*. +- **Once the log shows `deploymentId`, never run the deploy again** — neither a dispatch nor a re-run. `Uploaded bundle successfully, deployment name: …, deploymentId: …` means a deployment exists, whatever turned the job red afterwards (a validation timeout, a transient error while the plugin polls the status — it does not retry). Running the deploy again would upload a **second** deployment of the same coordinates. Act on the deployment's state in the portal instead: `VALIDATED` → smoke the bundle artifact and publish it; `FAILED` → see *Central validation failed*. | 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. | | **publish.yml red before the upload** (build, test, javadoc or signing failed; no `deploymentId` in the log) | Nothing was uploaded: a failing module stops the build before the last module, which is the one that uploads. Fix the cause (an environment one — an expired GPG key — is fixed in the repo secrets) and dispatch from the tag. | | **Upload rejected** (the upload itself errors — network, Portal outage, a rotated or wrong `CENTRAL_TOKEN` — and the log has no `deploymentId`) | Check the portal's Deployments page to be sure no deployment was created. Fix the cause (a token in the repo secrets) and dispatch from the tag. If a deployment *was* created, the `deploymentId` rule applies. | -| **Red after the upload** (`deploymentId` in the log, then a timeout on the plugin's 1800 s `waitMaxTime`, which the whole train shares, or a status-polling error) | Do **not** re-dispatch (rule above). Watch the deployment in the portal: `VALIDATED` → smoke and publish it; `FAILED` → *Central validation failed*. | +| **Red after the upload** (`deploymentId` in the log, then a timeout on the plugin's 1800 s `waitMaxTime`, which the whole train shares, or a status-polling error) | Do **not** dispatch or re-run the deploy (rule above). Watch the deployment in the portal: `VALIDATED` → smoke and publish it; `FAILED` → *Central validation failed*. | | **Central validation failed** (deployment `FAILED`) | The deployment is one unit, so **nothing** was published. The portal (and the run log) list the errors per component. An environment cause (the signing key not on a keyserver): fix it, drop the `FAILED` deployment, dispatch from the tag. A content cause (POM metadata, a missing sources/javadoc jar) needs a commit, and the tag is immutable, so fix forward with a patch version. The run's workflow artifact holds the exact zip that was rejected. | -| **Validated but wrong** (deployment `VALIDATED`, not yet published) | Drop it in the portal — nothing reaches Central until the maintainer publishes. Dispatch from the tag once fixed. | -| **Partial module publication** (some coordinates of the version on Central, some not — only possible for a version a pre-consolidation run left partly published, or after a manual portal upload) | Confirm what is live (`mvn dependency:get` per coordinate, or the release-smoke matrix), make sure no deployment for the version is still `VALIDATED` (publish or drop it first — an unpublished deployment does not count as published), then dispatch from the tag **with `-f skip_published=true`**. The plugin asks the Portal which components are already published, leaves those out, and uploads the rest as one deployment, which stops at `VALIDATED` for the usual Publish click. A run in which everything is already published stages nothing and stops. Such a bundle omits the live modules, so the staged smoke cannot consume it — smoke the version from Central after publishing instead. Never bump the tag to force a re-publish, and never set `skip_published` on a normal release. | +| **Validated but wrong** (deployment `VALIDATED`, not yet published) | Drop it in the portal — nothing reaches Central until the maintainer publishes. An environment cause: fix it and dispatch from the tag. A content cause: fix forward with a patch version, as for a failed validation. | +| **Partial module publication** (some coordinates of the version on Central, some not — after a manual portal upload, or for a version a pre-consolidation run left partly published) | Confirm what is live (`mvn dependency:get` per coordinate, or the release-smoke matrix) and make sure no deployment for the version is still `VALIDATED` (publish or drop it first — an unpublished deployment does not count as published). **A tag cut with the single-deployment workflow:** dispatch from the tag **with `-f skip_published=true`**. The plugin asks the Portal which components are already published, leaves those out, and uploads the rest as one deployment, which stops at `VALIDATED` for the usual Publish click; a run in which everything is already published stages nothing and stops. Such a bundle omits the live modules, so the staged smoke cannot consume it — smoke the version from Central after publishing instead. **A tag cut before it (v2.4.1 and earlier):** that tag's workflow has no `skip_published` input (GitHub rejects it as unexpected); dispatch from the tag with its own `-f start_at=`, which deploys from that module onwards module by module. Never bump the tag to force a re-publish, and never set `skip_published` on a normal release. | | **Re-dispatch of an already-published version** | Without `skip_published`, Central rejects re-uploading the published coordinates and the deployment fails without changing anything. With it, the run is a no-op. | | **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). | diff --git a/scripts/release-smoke/run.ps1 b/scripts/release-smoke/run.ps1 index c743f7a53..71ad38955 100644 --- a/scripts/release-smoke/run.ps1 +++ b/scripts/release-smoke/run.ps1 @@ -56,14 +56,14 @@ if ($StagedRepo) { exit 2 } $stagedGc = Join-Path $StagedRepo 'io\github\demchaav' - if (-not (Test-Path $stagedGc -PathType Container)) { + if (-not (Test-Path -LiteralPath $stagedGc -PathType Container)) { Write-Error "FATAL: $StagedRepo is not a Maven repository layout holding io/github/demchaav" exit 2 } # The path goes into XML; escape the characters that would break it. - $stagedAbs = [System.Security.SecurityElement]::Escape(((Resolve-Path $StagedRepo).Path -replace '\\', '/')) + $stagedAbs = [System.Security.SecurityElement]::Escape(((Resolve-Path -LiteralPath $StagedRepo).Path -replace '\\', '/')) if (-not $PSBoundParameters.ContainsKey('Version')) { - $staged = @(Get-ChildItem -Directory (Join-Path $stagedGc 'graph-compose-core') -ErrorAction SilentlyContinue) + $staged = @(Get-ChildItem -Directory -LiteralPath (Join-Path $stagedGc 'graph-compose-core') -ErrorAction SilentlyContinue) if ($staged.Count -ne 1) { Write-Error "FATAL: expected exactly one staged graph-compose-core version, found: $($staged.Name -join ', ')" exit 2 diff --git a/scripts/release-smoke/run.sh b/scripts/release-smoke/run.sh index 1ceab7664..18b3cace1 100755 --- a/scripts/release-smoke/run.sh +++ b/scripts/release-smoke/run.sh @@ -45,7 +45,8 @@ while [ $# -gt 0 ]; do case "$1" in --warm) WARM=1; shift ;; --version) GC_VERSION="${2:?--version needs a value}"; VERSION_SET=1; shift 2 ;; - --version=*) GC_VERSION="${1#*=}"; VERSION_SET=1; shift ;; + --version=*) GC_VERSION="${1#*=}"; VERSION_SET=1; shift + [ -n "$GC_VERSION" ] || { echo "--version needs a value" >&2; exit 2; } ;; --staged-repo) STAGED="${2-}"; STAGED_SET=1; shift; [ $# -gt 0 ] && shift ;; --staged-repo=*) STAGED="${1#*=}"; STAGED_SET=1; shift ;; *) echo "unknown argument: $1" >&2; exit 2 ;;