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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <X.Y.Z>`** 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) &mdash; 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) &mdash; the workflow re-runs `mvnw verify` at the tagged commit, signs each module's artefacts (main / sources / javadoc / pom &mdash; 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).
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,12 @@ Both are already included in `graph-compose-bundle`.
<summary><b>Distribution</b> &mdash; Maven Central, hosted Javadocs, legacy JitPack</summary>

Maven Central is the canonical channel from **v1.6.6** onwards
(`io.github.demchaav:graph-compose:<version>`). Hosted Javadocs auto-publish to
(`io.github.demchaav:graph-compose:<version>`). 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<version>`) remains resolvable for callers
pinned to v1.6.5 and earlier but is no longer the documented install option.

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*/
class PublishedJavadocCoordinateGuardTest {

private static final Path PROJECT_ROOT = RepoRoot.get();

/** Pages that may advertise an API reference. */
private static final List<String> 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("<module>([^<]+)</module>");
private static final Pattern ARTIFACT_ID = Pattern.compile("<artifactId>([^<]+)</artifactId>");
private static final Pattern PARENT_BLOCK = Pattern.compile("<parent>.*?</parent>", Pattern.DOTALL);
private static final Pattern DEPENDENCY_SOURCE_INCLUDE =
Pattern.compile("<dependencySourceInclude>([^<]+)</dependencySourceInclude>");

@Test
void everyDocumentedJavadocCoordinatePublishesAJavadocJar() throws IOException {
Set<String> 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<String, Path> modules = modulesByArtifactId();
List<String> 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("<includeDependencySources>true</includeDependencySources>")) {
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<String> aggregated = aggregatedSourceModules(pom, modules);
if (aggregated.isEmpty()) {
problems.add(artifactId + " (" + relative(module) + ") sets includeDependencySources "
+ "but no <dependencySourceInclude> 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<String> documentedJavadocCoordinates() throws IOException {
Set<String> 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<String, Path> modulesByArtifactId() throws IOException {
Map<String, Path> 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 <parent> 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 <dependencySourceInclude>} entries name and
* that actually carry sources — the ones whose API the aggregated javadoc will
* document.
*/
private static List<String> aggregatedSourceModules(String pom, Map<String, Path> modules) throws IOException {
List<String> 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<Path> 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('\\', '/');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading