From e3f133329fd62f1aa2b3ebf7477d3a5ad0b0ef67 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 17 Aug 2026 08:58:06 +0100 Subject: [PATCH] ci(benchmarks): install the modules the JMH job measures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JMH workflow installed the engine from source and let Maven resolve the rest from Central. One of them is not there to resolve: the benchmarks read their document fixtures out of the tests-classifier jar of graph-compose-templates, which the templates release profile unbinds so it is never published. The job died at dependency resolution before a single benchmark ran — five of its last six weekly runs, across four versions, each looking for the jar under the version the release before it had just published. It now installs render-pdf and templates from source, the same sequence the two benchmark jobs in ci.yml already run. BenchmarkDependencyInstallGuardTest reads every workflow and fails when a job builds benchmarks/pom.xml without first installing each first-party dependency the benchmarks pom declares. It takes that list from the pom rather than a copy of it, so a new sibling dependency is guarded the day it is added, and it runs in the guard job on every pull request — which is what the workflow itself cannot do, firing only from the default branch on a schedule. ./mvnw clean -Dtest= test -pl :graph-compose-core — BUILD SUCCESS, 52 tests. The install sequence verified end to end against the failing step: the JMH runner jar builds and lists all 11 benchmarks. The guard was confirmed red three ways first — templates install deleted, downgraded to a comment, and the benchmark build renamed so no job matches it. --- .github/workflows/benchmarks-jmh.yml | 14 +- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 22 ++ .../BenchmarkDependencyInstallGuardTest.java | 270 ++++++++++++++++++ 4 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 core/src/test/java/com/demcha/documentation/BenchmarkDependencyInstallGuardTest.java diff --git a/.github/workflows/benchmarks-jmh.yml b/.github/workflows/benchmarks-jmh.yml index 824c71ad..7ea8d951 100644 --- a/.github/workflows/benchmarks-jmh.yml +++ b/.github/workflows/benchmarks-jmh.yml @@ -50,9 +50,21 @@ jobs: - name: Install graph-compose-emoji (resolved at test scope by core) run: ./mvnw -B -ntp -f emoji/pom.xml -DskipTests install - - name: Install root artifact (graph-compose + test-jar) + - name: Install root artifact run: ./mvnw -B -ntp -DskipTests install -pl :graph-compose-core + - name: Install graph-compose-render-pdf (consumed by the benchmarks module) + # benchmarks call buildPdf and reference the PDF backend types directly, so + # it depends on render-pdf; install it after the core, before the benchmarks + # build resolves it. + run: ./mvnw -B -ntp -f render-pdf/pom.xml -DskipTests install + + - name: Install graph-compose-templates (consumed by the benchmarks module) + # CanonicalBenchmarkSupport reads the template data fixtures out of the + # tests-classifier jar, which the release profile unbinds and Central + # therefore never carries. Only a source build produces it. + run: ./mvnw -B -ntp -f templates/pom.xml -DskipTests install + - name: Build the JMH runner jar run: ./mvnw -B -ntp -f benchmarks/pom.xml clean package -DskipTests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bd2b561..a79df8a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: # in qa) run in build-and-test below, which now also covers docs-only PRs. run: | ./mvnw -B -ntp clean \ - "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest" \ + "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" \ test -pl :graph-compose-core changes: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d68c394..a6680edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. +## v2.2.1 — Planned + +### Build + +- **The weekly benchmark run builds the modules it measures.** The JMH workflow + installed the engine from source and then let Maven resolve the rest from Central, + and one of them is not there to resolve: the benchmarks read their document fixtures + out of the `tests`-classifier jar of `graph-compose-templates`, which the templates + release profile unbinds precisely so it is never published. The job died at + dependency resolution before a single benchmark ran: five of the six weekly runs + since the 2.0 module split reached the default branch failed that way, on four + different versions, each looking for the jar under the version the release before it + had just published. It now installs `render-pdf` and `templates` from source the way + the two benchmark jobs in the main pipeline already do. + + `BenchmarkDependencyInstallGuardTest` reads every workflow and fails when a job + builds `benchmarks/pom.xml` without first installing each first-party dependency the + benchmarks pom declares. It takes that list from the pom rather than a copy of it, so + a new sibling dependency is guarded the day it is added, and it runs in the guard job + on every pull request — which is what the workflow itself cannot do, running only + from the default branch on a schedule. + ## v2.2.0 — 2026-08-15 ### Public API diff --git a/core/src/test/java/com/demcha/documentation/BenchmarkDependencyInstallGuardTest.java b/core/src/test/java/com/demcha/documentation/BenchmarkDependencyInstallGuardTest.java new file mode 100644 index 00000000..3b852505 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/BenchmarkDependencyInstallGuardTest.java @@ -0,0 +1,270 @@ +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.Optional; +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 every workflow job that builds the benchmarks module against resolving a + * sibling module from Maven Central. + * + *

{@code benchmarks/pom.xml} depends on four first-party artifacts, and one of + * them cannot come from Central at any version: the {@code tests}-classifier jar of + * {@code graph-compose-templates}, which carries the template data fixtures + * {@code CanonicalBenchmarkSupport} renders. The templates pom builds it as a local + * aid and its {@code release} profile unbinds it, so it is never deployed. A job + * that does not install the templates module from source therefore cannot resolve + * the benchmarks module, whatever else it gets right.

+ * + *

The failure is invisible where it is introduced. The JMH workflow runs weekly + * from the default branch and on manual dispatch, never on a pull request, so a job + * missing an install step goes red every Monday while every PR that could have + * caught it stays green. That job resolved {@code graph-compose-templates:jar:tests} + * from Central and died at dependency resolution on five of its last six weekly runs, + * each looking for the jar under the version the release before it had just + * published.

+ * + *

The check is keyed on the presence of an install, never on recognising a + * command that looks wrong: for each job that points Maven at + * {@code benchmarks/pom.xml}, every first-party dependency the benchmarks pom + * declares must be installed by an earlier step of the same job. Reading that list + * out of the pom instead of hard-coding it means a newly added sibling dependency + * arrives already guarded. A job that installs its dependencies transitively + * ({@code -pl … -am}) reads as missing here, deliberately: this repository installs + * each module explicitly, and a guard that reasons about reachability is a guard + * that can be argued with.

+ */ +class BenchmarkDependencyInstallGuardTest { + + private static final Path PROJECT_ROOT = RepoRoot.get(); + private static final Path WORKFLOWS = PROJECT_ROOT.resolve(".github/workflows"); + private static final Path BENCHMARKS_POM = PROJECT_ROOT.resolve("benchmarks/pom.xml"); + private static final Path ROOT_POM = PROJECT_ROOT.resolve("pom.xml"); + + /** The build the guarded jobs run: anything that points Maven at the benchmarks pom. */ + private static final String BENCHMARK_BUILD = "-f benchmarks/pom.xml"; + + /** The group every module of this repository publishes under. */ + private static final String GROUP = "io.github.demchaav"; + + /** + * XML comments, stripped before either pom is read. The root pom documents the + * per-module deploy invocations in prose that spells {@code } literally, + * and a commented-out dependency is not a dependency; parsing either as live + * markup makes the guard require an install nobody owes. + */ + private static final Pattern XML_COMMENT = Pattern.compile("(?s)"); + + private static final Pattern DEPENDENCY = Pattern.compile("(?s)(.*?)"); + private static final Pattern PARENT = Pattern.compile("(?s).*?"); + private static final Pattern GROUP_ID = Pattern.compile("\\s*([^<]+?)\\s*"); + private static final Pattern ARTIFACT_ID = Pattern.compile("\\s*([^<]+?)\\s*"); + private static final Pattern MODULE = Pattern.compile("\\s*([^<]+?)\\s*"); + + /** The {@code jobs:} mapping — from its key down to the next top-level key or EOF. */ + private static final Pattern JOBS_REGION = Pattern.compile("(?ms)^jobs:[ \\t]*$(.*?)(?=^\\S|\\z)"); + + /** A job key: a two-space-indented mapping key inside {@code jobs:}. */ + private static final Pattern JOB_KEY = + Pattern.compile("(?m)^ (?!#)(\\S[^:]*?):(?=[ \\t]|$)[^\\r\\n]*$"); + + /** + * A step boundary: a {@code - name:} sequence item at any indentation. A step + * written without a name folds into the one above it, which can only cost an + * install its own step and report a job that has one as missing — wrong in the + * direction that gets looked at. + */ + private static final Pattern STEP_BOUNDARY = Pattern.compile("(?m)^\\s*- name:"); + + /** A YAML comment line: never executed, so never evidence that a step installs anything. */ + private static final Pattern COMMENT_LINE = Pattern.compile("(?m)^\\s*#.*$"); + + @Test + void everyJobThatBuildsTheBenchmarksInstallsItsSiblingModulesFromSource() throws IOException { + Set required = firstPartyDependenciesOfTheBenchmarks(); + Map moduleDirectories = moduleDirectoriesByArtifactId(); + + assertThat(required) + .describedAs("no '%s' dependency parsed out of %s — this guard is reading a pom " + + "shape that moved, so it is no longer guarding anything", + GROUP, relative(BENCHMARKS_POM)) + .isNotEmpty(); + assertThat(moduleDirectories.keySet()) + .describedAs("every '%s' dependency of %s must be a module of the reactor: a " + + "dependency with no module behind it cannot be built from source, " + + "so no workflow step can satisfy this guard for it", + GROUP, relative(BENCHMARKS_POM)) + .containsAll(required); + + List uninstalled = new ArrayList<>(); + int guardedJobs = 0; + + for (Path workflow : workflows()) { + String yaml = Files.readString(workflow); + for (Map.Entry job : jobs(yaml).entrySet()) { + List steps = steps(job.getValue()); + int build = indexOfTheBenchmarkBuild(steps); + if (build < 0) { + continue; + } + guardedJobs++; + List earlier = steps.subList(0, build); + for (String artifact : required) { + String moduleDirectory = moduleDirectories.get(artifact); + if (earlier.stream().noneMatch(step -> installs(step, artifact, moduleDirectory))) { + uninstalled.add(("%s → job '%s' builds the benchmarks without first " + + "installing %s (./mvnw -f %s/pom.xml -DskipTests install)") + .formatted(relative(workflow), job.getKey(), artifact, moduleDirectory)); + } + } + } + } + + assertThat(guardedJobs) + .describedAs("no job under %s runs '%s' — this guard is reading a workflow shape " + + "that moved, so it is no longer guarding anything", + relative(WORKFLOWS), BENCHMARK_BUILD) + .isPositive(); + assertThat(uninstalled) + .describedAs("a benchmark job resolves a sibling module from Maven Central. The " + + "templates 'tests' jar is never published, so such a job fails at " + + "dependency resolution — and on the JMH workflow it fails weekly, off " + + "the pull request that introduced it") + .isEmpty(); + } + + /** The artifact ids of the {@link #GROUP} dependencies {@link #BENCHMARKS_POM} declares. */ + private static Set firstPartyDependenciesOfTheBenchmarks() throws IOException { + Set artifacts = new LinkedHashSet<>(); + Matcher dependency = DEPENDENCY.matcher(markup(BENCHMARKS_POM)); + while (dependency.find()) { + String block = dependency.group(1); + if (first(GROUP_ID, block).filter(GROUP::equals).isPresent()) { + first(ARTIFACT_ID, block).ifPresent(artifacts::add); + } + } + return artifacts; + } + + /** Every reactor module, keyed by the artifact id its own pom declares. */ + private static Map moduleDirectoriesByArtifactId() throws IOException { + Map byArtifactId = new LinkedHashMap<>(); + Matcher module = MODULE.matcher(markup(ROOT_POM)); + while (module.find()) { + String directory = module.group(1); + String pom = PARENT.matcher(markup(PROJECT_ROOT.resolve(directory).resolve("pom.xml"))) + .replaceFirst(""); + first(ARTIFACT_ID, pom).ifPresent(artifactId -> byArtifactId.put(artifactId, directory)); + } + return byArtifactId; + } + + /** + * Whether {@code step} installs {@code artifactId} from source, by module pom + * ({@code -f templates/pom.xml … install}) or by reactor selector + * ({@code -pl :graph-compose-core … install}). + */ + private static boolean installs(String step, String artifactId, String moduleDirectory) { + String executable = COMMENT_LINE.matcher(step).replaceAll(""); + return executable.contains("install") + && (executable.contains("-f " + moduleDirectory + "/pom.xml") + || Pattern.compile("-pl [^\\r\\n]*:" + Pattern.quote(artifactId) + "(?![\\w-])") + .matcher(executable).find()); + } + + private static int indexOfTheBenchmarkBuild(List steps) { + for (int i = 0; i < steps.size(); i++) { + if (COMMENT_LINE.matcher(steps.get(i)).replaceAll("").contains(BENCHMARK_BUILD)) { + return i; + } + } + return -1; + } + + /** The jobs of one workflow, in file order, keyed by job id. */ + private static Map jobs(String yaml) { + Matcher region = JOBS_REGION.matcher(yaml); + if (!region.find()) { + return Map.of(); + } + String jobs = region.group(1); + + Map byId = new LinkedHashMap<>(); + Matcher key = JOB_KEY.matcher(jobs); + List ids = new ArrayList<>(); + List starts = new ArrayList<>(); + while (key.find()) { + ids.add(unquoted(key.group(1))); + starts.add(key.start()); + } + for (int i = 0; i < ids.size(); i++) { + int end = i + 1 < ids.size() ? starts.get(i + 1) : jobs.length(); + byId.put(ids.get(i), jobs.substring(starts.get(i), end)); + } + return byId; + } + + /** The steps of one job, in run order. Text before the first step is job configuration. */ + private static List steps(String job) { + List starts = new ArrayList<>(); + Matcher boundary = STEP_BOUNDARY.matcher(job); + while (boundary.find()) { + starts.add(boundary.start()); + } + List steps = new ArrayList<>(); + for (int i = 0; i < starts.size(); i++) { + int end = i + 1 < starts.size() ? starts.get(i + 1) : job.length(); + steps.add(job.substring(starts.get(i), end)); + } + return steps; + } + + /** + * Every workflow in the directory, under either spelling GitHub accepts. A guard + * that only reads {@code .yml} is blind to a job written as {@code .yaml} — the + * one way this check could go green over a workflow it never opened. + */ + private static List workflows() throws IOException { + try (Stream files = Files.list(WORKFLOWS)) { + return files.filter(file -> { + String name = file.getFileName().toString(); + return name.endsWith(".yml") || name.endsWith(".yaml"); + }).sorted().toList(); + } + } + + private static String markup(Path pom) throws IOException { + return XML_COMMENT.matcher(Files.readString(pom)).replaceAll(""); + } + + private static Optional first(Pattern pattern, String text) { + Matcher matcher = pattern.matcher(text); + return matcher.find() ? Optional.of(matcher.group(1)) : Optional.empty(); + } + + private static String unquoted(String key) { + String trimmed = key.trim(); + boolean quoted = trimmed.length() > 1 + && (trimmed.startsWith("\"") && trimmed.endsWith("\"") + || trimmed.startsWith("'") && trimmed.endsWith("'")); + return quoted ? trimmed.substring(1, trimmed.length() - 1) : trimmed; + } + + private static String relative(Path path) { + return PROJECT_ROOT.relativize(path).toString().replace('\\', '/'); + } +}