From 04c73ab9d6d4e2cd4e330381f8b6f2c852a76ee9 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 3 Aug 2026 13:05:19 +0100 Subject: [PATCH] build(wrapper): the graph-compose coordinate publishes an API reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only javadoc.io link in the repository points at `graph-compose`, and that module has no sources of its own. maven-javadoc-plugin's jar goal does not build an empty archive in that case — it builds none, and attaches nothing. Every 2.x release of the coordinate therefore shipped without a -javadoc.jar, and javadoc.io, which falls back to the newest version that carries one, kept serving the 1.9.1 API under the title "latest": complete, convincing, two majors stale, and sitting beside prose that promised documentation fresh after each Central release. The jar is now built from the engine's sources, which is the surface a caller of this coordinate authors against. Lombok is fed to the javadoc classpath explicitly because it is provided-scope in the engine and does not arrive transitively; doc lint is off, matching the engine's own release jar; failOnError is not, so an aggregation that breaks fails the publish instead of quietly reproducing the empty state that caused this. PublishedJavadocCoordinateGuardTest fails when a coordinate the docs advertise as an API reference cannot produce a javadoc jar, and the release checklist builds the wrapper's before a cut. README and CONTRIBUTING say which version the hosted page reflects until 2.1.1 lands. ./mvnw -B -ntp clean verify — BUILD SUCCESS. The release build now yields graph-compose-2.1.1-SNAPSHOT-javadoc.jar: 1024 entries, 903 HTML pages, including com/demcha/compose/document/api/DocumentSession.html titled "DocumentSession (GraphCompose 2.1.1-SNAPSHOT API)". Where it was 404. The guard was confirmed red first, by turning includeDependencySources off. --- CHANGELOG.md | 13 ++ CONTRIBUTING.md | 2 +- README.md | 7 +- .../PublishedJavadocCoordinateGuardTest.java | 175 ++++++++++++++++++ .../VersionConsistencyGuardTest.java | 17 ++ docs/contributing/release-process.md | 8 +- wrapper/pom.xml | 52 +++++- 7 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 core/src/test/java/com/demcha/documentation/PublishedJavadocCoordinateGuardTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index eab3eb9d..376ef4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,19 @@ follow semantic versioning; release dates are ISO 8601. any job that can run on a pull request is left out of it. Schedule-only jobs are recognised from their own `if:` condition rather than an exclusion list, so a new job either joins the gate or fails the guard. +- **`graph-compose` publishes an API reference again.** The coordinate the README + sends readers to for Javadoc carries no sources of its own, so the javadoc goal + found nothing to archive and attached no artifact — not an empty one, none. Every + 2.x release shipped without it, and javadoc.io, which serves the newest version + that carries one, kept rendering the **1.9.1** API: complete, convincing, and two + majors stale, beside prose promising documentation fresh after each release. The + wrapper now builds that jar from the engine's sources, which is the surface a + caller of this coordinate authors against — 903 pages where there were none. Doc + lint stays off, as it is for the engine's own release jar, but a hard failure is + no longer swallowed: the `failOnError=false` that hid the empty state is gone. + `PublishedJavadocCoordinateGuardTest` fails if a coordinate the documentation + advertises as an API reference cannot produce a javadoc jar, and the release + checklist builds the wrapper's before a cut. - **A documentation-only pull request is compiled.** Markdown was not a change-detection input, so a PR touching only `.md` skipped the reactor build and merged without `DocumentationSnippetCompileTest` ever compiling the java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff75325d..2e2dea05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,7 @@ Almost all work targets **`develop`**, the ongoing 2.x line. The `1.x` branch ta 1. **Release prep** lands on `develop` — version bumps propagate via the root reactor `pom.xml` to all modules in one pass; fresh CHANGELOG entry; migration guide for minor releases. **README install snippet stays pinned to the previously published version** until Maven Central confirms the new artifact, otherwise consumers copying the snippet during the publish window hit a 404. 2. **`scripts/cut-release.ps1 -Version `** automates the bump + CHANGELOG date + commit + tag + push from `develop`. The maintainer fast-forwards `main` from `develop` after the tag lands (`git push origin develop:main`). -3. **Maven Central** picks up the new tag automatically via [`.github/workflows/publish.yml`](./.github/workflows/publish.yml) — the workflow re-runs `mvnw verify` at the tagged commit, signs the four artefacts (main / sources / javadoc / pom) with the repo's GPG key, and uploads via the `central-publishing-maven-plugin`. Hyphenated tags (`-rc`, `-alpha`, `-beta`) are skipped on Central; they ship only to the GitHub Release pre-release surface. Javadocs auto-publish to [javadoc.io/doc/io.github.demchaav/graph-compose](https://javadoc.io/doc/io.github.demchaav/graph-compose) shortly after each Central release. +3. **Maven Central** picks up the new tag automatically via [`.github/workflows/publish.yml`](./.github/workflows/publish.yml) — the workflow 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 via the `central-publishing-maven-plugin`. Hyphenated tags (`-rc`, `-alpha`, `-beta`) are skipped on Central; they ship only to the GitHub Release pre-release surface. Javadocs for the engine API publish to [javadoc.io/doc/io.github.demchaav/graph-compose](https://javadoc.io/doc/io.github.demchaav/graph-compose) shortly after each Central release, from v2.1.1 onwards — the `graph-compose` coordinate carries no sources of its own, so its javadoc jar is built from the engine's (`wrapper/pom.xml`, `includeDependencySources`). Before that it shipped no javadoc artifact at all and the hosted page stayed on 1.9.1. 4. **GitHub Release** is created with notes from the matching `CHANGELOG.md` section. See [docs/contributing/release-process.md](./docs/contributing/release-process.md) for the full checklist (audit gates, hotfix protocol, lessons learned). diff --git a/README.md b/README.md index 7f49c0fc..d6d38b46 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,12 @@ Both are already included in `graph-compose-bundle`. Distribution — Maven Central, hosted Javadocs, legacy JitPack Maven Central is the canonical channel from **v1.6.6** onwards -(`io.github.demchaav:graph-compose:`). Hosted Javadocs auto-publish to +(`io.github.demchaav:graph-compose:`). Hosted Javadocs for the engine API +publish to [javadoc.io/doc/io.github.demchaav/graph-compose](https://javadoc.io/doc/io.github.demchaav/graph-compose) -shortly after each Central release. The legacy JitPack URL +shortly after each Central release, from **v2.1.1** onwards — the 2.0 and 2.1.0 +releases of that coordinate shipped no javadoc artifact, so the page there still +renders the 1.9.1 API until the next release lands. The legacy JitPack URL (`com.github.DemchaAV:GraphCompose:v`) remains resolvable for callers pinned to v1.6.5 and earlier but is no longer the documented install option. diff --git a/core/src/test/java/com/demcha/documentation/PublishedJavadocCoordinateGuardTest.java b/core/src/test/java/com/demcha/documentation/PublishedJavadocCoordinateGuardTest.java new file mode 100644 index 00000000..3dc34f22 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/PublishedJavadocCoordinateGuardTest.java @@ -0,0 +1,175 @@ +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards that the coordinate the docs send readers to for an API reference is one + * that actually publishes a javadoc jar. + * + *

javadoc.io renders the newest version of a coordinate that carries a + * {@code -javadoc.jar}, and quietly falls back to an older one when the newest does + * not. That is not an error page a reader would notice: it is a complete, correct + * API reference for the wrong version, presented as current.

+ * + *

Which is what happened. The {@code graph-compose} wrapper has no sources of its + * own, so the javadoc goal found nothing to archive and attached no artifact at all + * — not an empty one. Every 2.x release shipped without it, and the only + * API-reference link in the repository served the 1.9.1 API for two releases running + * while the prose beside it promised documentation fresh after each release.

+ * + *

So a documented coordinate must resolve to a module that will produce that jar: + * either it has sources of its own, or its javadoc execution builds the jar from a + * dependency's ({@code includeDependencySources}). Checking the build files rather + * than the hosted page keeps the guard offline and deterministic.

+ */ +class PublishedJavadocCoordinateGuardTest { + + private static final Path PROJECT_ROOT = RepoRoot.get(); + + /** Pages that may advertise an API reference. */ + private static final List DOC_PAGES = List.of("README.md", "CONTRIBUTING.md"); + + /** A javadoc.io link, capturing the artifactId it points at. */ + private static final Pattern JAVADOC_IO_LINK = + Pattern.compile("javadoc\\.io/doc/io\\.github\\.demchaav/([A-Za-z0-9.-]+)"); + + private static final Pattern MODULE_ENTRY = Pattern.compile("([^<]+)"); + private static final Pattern ARTIFACT_ID = Pattern.compile("([^<]+)"); + private static final Pattern PARENT_BLOCK = Pattern.compile(".*?", Pattern.DOTALL); + private static final Pattern DEPENDENCY_SOURCE_INCLUDE = + Pattern.compile("([^<]+)"); + + @Test + void everyDocumentedJavadocCoordinatePublishesAJavadocJar() throws IOException { + Set documented = documentedJavadocCoordinates(); + + assertThat(documented) + .describedAs("no javadoc.io link found in %s — this guard is reading pages that " + + "no longer advertise an API reference, so it guards nothing", DOC_PAGES) + .isNotEmpty(); + + Map modules = modulesByArtifactId(); + List problems = new ArrayList<>(); + + for (String artifactId : documented) { + Path module = modules.get(artifactId); + if (module == null) { + problems.add(artifactId + " is documented as an API reference but no module in the " + + "reactor builds it"); + continue; + } + String pom = Files.readString(module.resolve("pom.xml")); + if (!pom.contains("attach-javadocs")) { + problems.add(artifactId + " (" + relative(module) + ") has no attach-javadocs " + + "execution, so its release ships no javadoc jar"); + continue; + } + if (hasOwnSources(module)) { + continue; + } + if (!pom.contains("true")) { + problems.add(artifactId + " (" + relative(module) + ") has no sources of its own and " + + "does not set includeDependencySources: the javadoc goal will archive " + + "nothing and attach no artifact, and javadoc.io will keep serving " + + "whichever older version last carried one"); + continue; + } + // Aggregating from a dependency only works if the named dependency exists + // and has sources to aggregate. A typo here fails the same way an absent + // config does — silently, with no artifact — so it is checked, not assumed. + List aggregated = aggregatedSourceModules(pom, modules); + if (aggregated.isEmpty()) { + problems.add(artifactId + " (" + relative(module) + ") sets includeDependencySources " + + "but no names a reactor module that has sources; " + + "the aggregation would produce nothing"); + } + } + + assertThat(problems) + .describedAs("a coordinate the documentation points at for an API reference must " + + "publish a javadoc jar") + .isEmpty(); + } + + private static Set documentedJavadocCoordinates() throws IOException { + Set coordinates = new LinkedHashSet<>(); + for (String page : DOC_PAGES) { + Matcher link = JAVADOC_IO_LINK.matcher(Files.readString(PROJECT_ROOT.resolve(page))); + while (link.find()) { + coordinates.add(link.group(1)); + } + } + return coordinates; + } + + /** Reactor modules keyed by the artifactId they build. */ + private static Map modulesByArtifactId() throws IOException { + Map modules = new LinkedHashMap<>(); + Matcher entry = MODULE_ENTRY.matcher(Files.readString(PROJECT_ROOT.resolve("pom.xml"))); + while (entry.find()) { + Path module = PROJECT_ROOT.resolve(entry.group(1).trim()); + Path pom = module.resolve("pom.xml"); + if (!Files.exists(pom)) { + continue; + } + // Drop the block first: qa and coverage inherit from the + // aggregator, and its artifactId is declared before their own. + String text = PARENT_BLOCK.matcher(Files.readString(pom)).replaceFirst(""); + Matcher artifact = ARTIFACT_ID.matcher(text); + if (artifact.find()) { + modules.putIfAbsent(artifact.group(1).trim(), module); + } + } + return modules; + } + + /** + * The reactor modules a pom's {@code } entries name and + * that actually carry sources — the ones whose API the aggregated javadoc will + * document. + */ + private static List aggregatedSourceModules(String pom, Map modules) throws IOException { + List documented = new ArrayList<>(); + Matcher include = DEPENDENCY_SOURCE_INCLUDE.matcher(pom); + while (include.find()) { + String[] coordinate = include.group(1).trim().split(":"); + if (coordinate.length != 2) { + continue; + } + Path source = modules.get(coordinate[1].trim()); + if (source != null && hasOwnSources(source)) { + documented.add(coordinate[1].trim()); + } + } + return documented; + } + + private static boolean hasOwnSources(Path module) throws IOException { + Path sources = module.resolve("src/main/java"); + if (!Files.isDirectory(sources)) { + return false; + } + try (Stream tree = Files.walk(sources)) { + return tree.anyMatch(file -> file.getFileName().toString().endsWith(".java")); + } + } + + private static String relative(Path path) { + return PROJECT_ROOT.relativize(path).toString().replace('\\', '/'); + } +} diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index 7a055d36..19e3bf65 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -167,6 +167,23 @@ void jacocoPluginVersionAgreesAcrossModules() throws Exception { .isEqualTo(core); } + /** + * The wrapper builds its javadoc jar from the engine's sources, which are + * Lombok-annotated, so it feeds Lombok to the javadoc plugin as an additional + * dependency. That pins a second literal of a version the engine already owns — + * and a javadoc classpath one release behind fails with "package lombok does not + * exist" only during a release build, which is the worst place to find out. + */ + @Test + void wrapperJavadocLombokVersionTracksTheEngine() throws Exception { + String engine = pinnedVersionProperty(PROJECT_ROOT.resolve("core/pom.xml"), "lombok.version"); + + assertThat(pinnedVersionProperty(PROJECT_ROOT.resolve("wrapper/pom.xml"), "lombok.version")) + .describedAs("wrapper lombok.version feeds the javadoc plugin that documents the " + + "engine's sources; it must match the engine pom's (%s)", engine) + .isEqualTo(engine); + } + @Test void graphComposeWrapperStaysAJarOverCore() throws Exception { Element project = parse(PROJECT_ROOT.resolve("wrapper/pom.xml")).getDocumentElement(); diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index 49276f2a..9d800ccd 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -35,6 +35,12 @@ The shell setup and exact PowerShell commands live in the `graphcompose-release- - [ ] All examples regenerate: `./mvnw -B -ntp -q -f examples/pom.xml exec:java -Dexec.mainClass=com.demcha.examples.GenerateAllExamples` exits 0, prints one `Generated:` line per example, and emits no `Fixed column ... is smaller than required natural width` or `Spanned cell ... requires extra width` errors. (Requires `./mvnw install -DskipTests` once first so the local `~/.m2` resolves the current SNAPSHOT version — any standalone goal that resolves train modules from `~/.m2`, including the `qa` suite and `javadoc:javadoc`, needs this after a version bump.) - [ ] Architecture-guard suite explicitly green: `./mvnw -B -ntp test -pl :graph-compose-core -Dtest='CanonicalSurfaceGuardTest,DocumentationCoverageTest,InternalAnnotationCoverageTest,PublicApiNoEngineLeakTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest'` exits 0. These guard against legacy-API leakage in docs and engine internals leaking into the public surface, and — via `VersionConsistencyGuardTest` — against version drift between the train poms and the README install snippets. Both markdown guards skip the gitignored `docs/private/`, so local planning notes cannot fail a run that CI is unable to reproduce. - [ ] Javadoc gate green on the published modules: `./mvnw -B -ntp javadoc:javadoc -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing` exits 0. The release profile publishes a javadoc jar per module with `failOnError=false`, so a broken link only surfaces here. +- [ ] `graph-compose` publishes a javadoc jar with content. The wrapper has no sources of its own and builds the jar from the engine's, so the engine must first be installed **with the release profile** — a plain `install` attaches no `-sources.jar`, and on a cold `~/.m2` the aggregation then has nothing to read: + ```bash + ./mvnw -B -ntp -f core/pom.xml -P release -DskipTests -Dgpg.skip=true install + ./mvnw -B -ntp -f wrapper/pom.xml -P release -DskipTests -Dgpg.skip=true package + ``` + Expect `wrapper/target/graph-compose--javadoc.jar` to exist and to contain `index.html`, `com/demcha/compose/GraphCompose.html` and `com/demcha/compose/document/api/DocumentSession.html`. An unresolvable dependency source set attaches nothing, and the release then ships the coordinate without an API reference — which is what left javadoc.io serving 1.9.1 through the whole 2.0 / 2.1.0 line. ### C. Documentation freeze (matches target version) @@ -109,7 +115,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) **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). The workflow titles it `GraphCompose v`; for a **minor** release, edit the title to add the codename (`v1.4`=cinematic, `v1.5`=intuitive, `v1.6`=expressive; patches drop it). Create the Release by hand (`gh release create v --notes-file `) only if the workflow is unavailable. -9. **Maven Central publish — automated (from v1.6.6).** The same `v` tag push triggers [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml): it re-runs `mvnw verify` at the tagged commit, signs the four artefacts (main / sources / javadoc / pom) with the repo's GPG key, and uploads to Maven Central via the `central-publishing-maven-plugin`. Hyphenated tags (`-rc`, `-alpha`, `-beta`, `-snapshot`) are skipped — those go only to the GitHub Release pre-release surface. `autoPublish=false` in the plugin config means the artefact lands in the Central validation queue; the maintainer flips the switch on [central.sonatype.com](https://central.sonatype.com) for the first publish, then can opt into auto-release in a follow-up. Verify via `mvn dependency:get -DgroupId=io.github.demchaav -DartifactId=graph-compose -Dversion=` once the artifact appears (usually 5–15 minutes after the workflow turns green). +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). 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. diff --git a/wrapper/pom.xml b/wrapper/pom.xml index d51245b6..98198c19 100644 --- a/wrapper/pom.xml +++ b/wrapper/pom.xml @@ -65,6 +65,11 @@ 3.4.0 3.12.0 3.2.8 + + 1.18.46 0.11.0 @@ -107,9 +112,23 @@ + profile. + + This module carries no sources of its own, so `jar-no-fork` and the + javadoc `jar` goal both find nothing to archive and attach NOTHING — + they do not produce empty jars, they produce no artifact at all. Every + 2.x release of this coordinate therefore shipped without a -javadoc.jar + or a -sources.jar, and javadoc.io, which serves the newest version that + carries one, kept rendering 1.9.1 for the coordinate the README sends + readers to. + + The javadoc jar is built from the engine's sources instead. That is the + surface a `graph-compose` consumer authors against, and it means the + hosted API reference advances with each release on its own rather than + waiting for anyone to notice. render-pdf is deliberately left out: it is + the backend the wrapper carries, not the API the caller writes. + The sources jar stays absent — there are none to publish, and Central + accepts the coordinate without it. --> release @@ -140,7 +159,32 @@ jar - false + + true + + io.github.demchaav:graph-compose-core + + + + + org.projectlombok + lombok + ${lombok.version} + + + + none true