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..99a4dd234 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,9 +5,28 @@ 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. +# 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 +# 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 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. # # Hyphenated tags (rc / alpha / beta / SNAPSHOT) are skipped: those # ship only to the GitHub Release pre-release surface, never to Central @@ -37,30 +56,22 @@ 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. 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: 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 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 @@ -157,146 +168,77 @@ 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 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 # 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) + 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 + # 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 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, …). + # + # The whole train shares the plugin's waitMaxTime (1800 s). A run that + # 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 + # 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, + # 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: + # 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 5d6b10637..e51ae22a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,46 @@ 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 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 + 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 can run before anything is published.** `run.sh --staged-repo ` / + `run.ps1 -StagedRepo ` resolves the GraphCompose coordinates from an unzipped + `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 +- **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/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..4780c1a25 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/PublishTrainGuardTest.java @@ -0,0 +1,298 @@ +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 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.

+ * + *

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"); + + /** + * 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*central-publishing-maven-plugin\\s*" + + ".*?", + Pattern.DOTALL); + + private static final Pattern CENTRAL_PLUGIN_VERSION = Pattern.compile( + "\\s*([^<]+?)\\s*"); + + /** + * A line that uploads to Central: the {@code deploy} goal as a word (not + * {@code deployment}, not {@code deploy-web}), or the central-publishing plugin's + * {@code publish} goal invoked directly, which uploads without ever saying "deploy". + */ + private static final Pattern UPLOADS = Pattern.compile( + "(? 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); + // 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) + .contains("-pl", "-P") + .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) + .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(); + } + + /** + * 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. 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 { + 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("#") || !UPLOADS.matcher(code).find()) { + continue; + } + if (!parsed.contains(code)) { + unread.add(name + ": " + code); + } + } + } + } + + assertThat(unread) + .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") + .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")); + 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) : ""); + + 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", ""); + 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 " + + "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"); + 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 5d7fb0a25..b629ca578 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,27 @@ 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,…}, 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|--pr[a-z]*)[\"']?(?:\\s+|=)[\"']?([^\\s\"']+)"); + + private static final Pattern VERSION = Pattern.compile("\\s*([^<]+?)\\s*"); /** * The modules a release actually deploys, read from the publish workflows. @@ -69,6 +84,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 +93,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 +107,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); + while (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 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); + } + } + 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 declaredTrain(Path repoRoot) throws IOException { - Path workflow = repoRoot.resolve(".github/workflows/publish.yml"); - if (!Files.isRegularFile(workflow)) { - return List.of(); + 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); + } } - Matcher order = TRAIN_ORDER.matcher(Files.readString(workflow)); - return order.find() ? List.of(order.group(1).strip().split("\\s+")) : List.of(); + 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. */ diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index aa7382593..609e87bd4 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 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. @@ -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 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 + 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 @@ -329,11 +376,22 @@ 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: + +- **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 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. | -| **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; 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** 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. 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/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 d75756c33..68fff0066 100644 --- a/scripts/release-smoke/README.md +++ b/scripts/release-smoke/README.md @@ -43,14 +43,46 @@ 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 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-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 publish, once Central has indexed the release. diff --git a/scripts/release-smoke/run.ps1 b/scripts/release-smoke/run.ps1 index 2ad956bca..71ad38955 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,110 @@ $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, + # 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 -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 -LiteralPath $StagedRepo).Path -replace '\\', '/')) + if (-not $PSBoundParameters.ContainsKey('Version')) { + $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 + } + $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' + $stagedSettings = @" + + + + central-only + https://repo.maven.apache.org/maven2 + *,!staged + + + + + staged + + + staged + file:///$($stagedAbs.TrimStart('/')) + true + false + + + + + + staged + + +"@ + # 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 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 { + $seen = 0 + $ok = $true + $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 +} + $pass = 0 $fail = 0 $results = @() @@ -49,10 +161,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 +175,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..18b3cace1 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,129 @@ REPO="$REPO_ROOT/target/release-smoke-m2/repo" # test PUBLISHED artifacts — never a -SNAPSHOT. 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}"; 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 + [ -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 ;; 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, + # 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 + # 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 + # 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 + 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_URL_PATH} + true + false + + + + + + staged + + +EOF +fi + +# 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 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 [ ! -f "$dir/_remote.repositories" ]; then + echo "PROVENANCE: $dir records no source repository" >&2 + bad=$((bad + 1)) + continue + fi + while IFS= read -r line || [ -n "$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 + return 1 + fi + [ "$bad" = "0" ] +} + pass=0 fail=0 declare -a results @@ -59,11 +179,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 +198,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