diff --git a/.github/contract-tests/pom.xml b/.github/contract-tests/pom.xml
new file mode 100644
index 000000000..d70d903a7
--- /dev/null
+++ b/.github/contract-tests/pom.xml
@@ -0,0 +1,74 @@
+
+
+
+ 4.0.0
+
+ org.gephi
+ gephi-plugins-contract-tests
+ 1.0.0
+ jar
+
+
+ UTF-8
+ 1.2.0
+
+
+
+
+
+
+ org.gephi
+ gephi-plugin-parent
+ ${gephi.version}
+ pom
+ import
+
+
+
+
+
+
+ org.gephi
+ gephi-plugins-tests
+ ${gephi-plugins-tests.version}
+ test-jar
+ test
+
+
+ ${plugin.groupId}
+ ${plugin.artifactId}
+ ${plugin.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.6.1
+
+
+ -Djava.awt.headless=true --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.desktop/javax.swing=ALL-UNNAMED --add-exports=java.desktop/sun.awt=ALL-UNNAMED
+
+
+ ${plugin.artifactId}-${plugin.version}.jar
+
+ ${project.build.directory}/contract-warnings.tsv
+
+
+ org.gephi:gephi-plugins-tests
+
+
+
+
+
+
diff --git a/.github/contract-tests/run.sh b/.github/contract-tests/run.sh
new file mode 100755
index 000000000..2adc2836b
--- /dev/null
+++ b/.github/contract-tests/run.sh
@@ -0,0 +1,179 @@
+#!/usr/bin/env bash
+# Runs gephi-plugins-tests' SPI contract tests (see pom.xml in this folder) against this
+# checkout's plugin modules, one Maven run per NBM module so each failure names a single plugin.
+#
+# Usage: .github/contract-tests/run.sh [modules/ ...]
+#
+# With no arguments, every module in the root pom.xml's list is tested. Otherwise only
+# NBM modules under the given folders are; a folder outside the reactor (e.g. a disabled plugin)
+# is skipped with a warning. Checks that are only advisory (e.g. a layout adding nodes) don't fail
+# the run: they are reported as warning annotations, and in the job summary when running on GitHub
+# Actions. Modules must be installed in the local Maven repository first
+# (mvn install): a module that is missing there, or was rebuilt since, fails instead of testing a
+# stale jar.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+HARNESS_DIR="$ROOT/.github/contract-tests"
+GEPHI_VERSION=$(sed -nE 's#.*(.*).*#\1#p' "$ROOT/pom.xml" | head -1)
+if [ -z "$GEPHI_VERSION" ]; then
+ echo "::error::Could not read from $ROOT/pom.xml"
+ exit 1
+fi
+
+# One "packaging|groupId|artifactId|version|basedir|built jar|local repository" line per project
+if ! PROJECTS=$(cd "$ROOT" && mvn -B -q -ntp org.codehaus.mojo:exec-maven-plugin:3.5.0:exec \
+ -Dexec.executable=echo \
+ '-Dexec.args=${project.packaging}|${project.groupId}|${project.artifactId}|${project.version}|${project.basedir}|${project.build.directory}/${project.build.finalName}.jar|${settings.localRepository}'); then
+ echo "$PROJECTS"
+ echo "::error::Could not list the reactor's modules, see the Maven output above"
+ exit 1
+fi
+
+FOLDERS=()
+for arg in "$@"; do
+ folder="${arg#"$ROOT"/}"
+ folder="${folder#./}"
+ FOLDERS+=("${folder%/}")
+done
+
+in_folder() {
+ case "$1/" in
+ */"$2"/*) return 0 ;;
+ esac
+ return 1
+}
+
+TARGETS=()
+while IFS='|' read -r packaging group artifact version basedir jar repo; do
+ [ "$packaging" = nbm ] || continue
+ if [ ${#FOLDERS[@]} -eq 0 ]; then
+ TARGETS+=("$group|$artifact|$version|$jar|$repo")
+ continue
+ fi
+ for folder in "${FOLDERS[@]}"; do
+ if in_folder "$basedir" "$folder"; then
+ TARGETS+=("$group|$artifact|$version|$jar|$repo")
+ break
+ fi
+ done
+done <<< "$PROJECTS"
+
+for folder in ${FOLDERS[@]+"${FOLDERS[@]}"}; do
+ matched=false
+ while IFS='|' read -r packaging _ _ _ basedir _; do
+ if [ "$packaging" = nbm ] && in_folder "$basedir" "$folder"; then
+ matched=true
+ break
+ fi
+ done <<< "$PROJECTS"
+ if [ "$matched" = false ]; then
+ echo "::warning::$folder has no plugin module in the root pom.xml's list; skipping"
+ fi
+done
+
+if [ ${#TARGETS[@]} -eq 0 ]; then
+ echo "No plugin modules to test."
+ exit 0
+fi
+
+# Advisory test warnings, one "plugincheckmessage" entry each
+WARNINGS_FILE="$HARNESS_DIR/target/contract-warnings.tsv"
+WARNINGS=()
+
+# Workflow command data can't contain %, the warnings are single lines already
+annotation_escape() {
+ printf '%s' "$1" | sed -e 's/%/%25/g'
+}
+
+# Plugin names and messages come from the plugin's code, so don't let them format the summary
+markdown_escape() {
+ printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g' -e 's/|/\\|/g' -e 's/`/\\`/g'
+}
+
+collect_warnings() {
+ [ -f "$WARNINGS_FILE" ] || return 0
+ local check message
+ while IFS=$'\t' read -r check message; do
+ WARNINGS+=("$1"$'\t'"$check"$'\t'"$message")
+ echo "::warning title=SPI contract test warning::$(annotation_escape "$1: $check: $message")"
+ done < "$WARNINGS_FILE"
+}
+
+# The jars of the modules that depend on the one being tested, installed in the local repository. A plugin split in
+# modules keeps parts of it (e.g. its user interface) in modules that depend on the main one, and the tests look
+# those up too.
+dependent_jars() {
+ local packaging group artifact version basedir repo jar
+ while IFS='|' read -r packaging group artifact version basedir _ repo; do
+ [ "$packaging" = nbm ] || continue
+ [ "$artifact" != "$1" ] || continue
+ grep -q "$1" "$basedir/pom.xml" || continue
+ jar="$repo/${group//.//}/$artifact/$version/$artifact-$version.jar"
+ [ -f "$jar" ] && echo "$jar"
+ done <<< "$PROJECTS"
+ return 0
+}
+
+PASSED=()
+FAILED=()
+for target in "${TARGETS[@]}"; do
+ IFS='|' read -r group artifact version jar repo <<< "$target"
+ gav="$group:$artifact:$version"
+ installed="$repo/${group//.//}/$artifact/$version/$artifact-$version.jar"
+ if [ ! -f "$installed" ]; then
+ echo "::error::$gav isn't installed in $repo, run mvn install first"
+ FAILED+=("$gav")
+ continue
+ fi
+ if [ -f "$jar" ] && ! cmp -s "$jar" "$installed"; then
+ echo "::error::$gav was rebuilt since it was installed, run mvn install again"
+ FAILED+=("$gav")
+ continue
+ fi
+
+ EXTRA_ARGS=()
+ EXTRA_JARS=$(dependent_jars "$artifact" | paste -sd, -)
+ if [ -n "$EXTRA_JARS" ]; then
+ EXTRA_ARGS=(-Dmaven.test.additionalClasspath="$EXTRA_JARS")
+ fi
+
+ rm -rf "$HARNESS_DIR/target"
+ echo "::group::Contract tests: $gav"
+ if mvn -B -ntp -f "$HARNESS_DIR/pom.xml" test -Dgephi.version="$GEPHI_VERSION" \
+ -Dplugin.groupId="$group" -Dplugin.artifactId="$artifact" -Dplugin.version="$version" \
+ ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}; then
+ echo "::endgroup::"
+ else
+ echo "::endgroup::"
+ echo "::error::SPI contract tests failed for $gav"
+ collect_warnings "$gav"
+ FAILED+=("$gav")
+ continue
+ fi
+ collect_warnings "$gav"
+ PASSED+=("$gav")
+done
+
+if [ ${#WARNINGS[@]} -gt 0 ] && [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
+ {
+ echo "### SPI contract test warnings"
+ echo
+ echo "These checks don't fail the build, but are worth a look in review."
+ echo
+ echo "| Plugin | Check | Warning |"
+ echo "| --- | --- | --- |"
+ for warning in "${WARNINGS[@]}"; do
+ IFS=$'\t' read -r gav check message <<< "$warning"
+ echo "| $(markdown_escape "$gav") | $(markdown_escape "$check") | $(markdown_escape "$message") |"
+ done
+ } >> "$GITHUB_STEP_SUMMARY"
+fi
+
+if [ ${#PASSED[@]} -gt 0 ]; then
+ echo "SPI contract tests passed for: ${PASSED[*]}"
+fi
+if [ ${#FAILED[@]} -gt 0 ]; then
+ echo "SPI contract tests failed for: ${FAILED[*]}"
+ exit 1
+fi
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f1f4f852a..84f721c52 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -24,4 +24,7 @@ jobs:
echo "No plugin modules present under modules/; skipping build/validate (see test-generation workflow for scaffold validation)."
exit 0
fi
- mvn -B package --file pom.xml
\ No newline at end of file
+ mvn -B install --file pom.xml
+ - name: Run SPI contract tests
+ if: hashFiles('modules/*/pom.xml') != ''
+ run: .github/contract-tests/run.sh
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 06615863c..a120762d2 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -10,10 +10,32 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
+ with:
+ # The PR's merge commit and its first parent (the base), to diff against
+ fetch-depth: 2
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
- name: Build project with Maven
- run: mvn -B package --file pom.xml
\ No newline at end of file
+ run: mvn -B install --file pom.xml
+ - name: Run SPI contract tests on changed plugins
+ run: |
+ CHANGED_FILES=$(git -c core.quotePath=false diff --name-only HEAD^1 HEAD)
+ # A root pom.xml change (gephi.version bump, re-enabled , ...) or a harness change
+ # can affect every plugin, so test them all
+ if grep -qxE 'pom\.xml|\.github/contract-tests/.*' <<< "$CHANGED_FILES"; then
+ exec .github/contract-tests/run.sh
+ fi
+ FOLDERS=()
+ while IFS= read -r folder; do
+ if [ -d "$folder" ]; then
+ FOLDERS+=("$folder")
+ fi
+ done < <(grep '^modules/' <<< "$CHANGED_FILES" | cut -d/ -f1-2 | sort -u)
+ if [ ${#FOLDERS[@]} -eq 0 ]; then
+ echo "No plugin modules changed; skipping."
+ exit 0
+ fi
+ .github/contract-tests/run.sh "${FOLDERS[@]}"
diff --git a/.github/workflows/test-generation.yml b/.github/workflows/test-generation.yml
index c1794c069..b3b20de0f 100644
--- a/.github/workflows/test-generation.yml
+++ b/.github/workflows/test-generation.yml
@@ -49,7 +49,23 @@ jobs:
grep -q 'modules/CiIntegrationTestPlugin' pom.xml
- name: Build, validate and generate the autoupdate site
- run: mvn -B package -P release --file pom.xml
+ run: mvn -B install -P release --file pom.xml
+
+ # The fixture implements no SPI, so this runs no test cases: it checks the contract-test
+ # harness still resolves gephi-plugins-tests and gephi-plugin-parent for this gephi.version.
+ - name: Run SPI contract tests against the fixture
+ run: .github/contract-tests/run.sh modules/CiIntegrationTestPlugin
+
+ # Positive control: core Gephi's layout-plugin implements a covered SPI, so the harness must
+ # run actual test cases against it. Guards against the harness silently testing nothing.
+ - name: Check the contract-test harness runs test cases
+ run: |
+ set -e
+ GEPHI_VERSION=$(sed -nE 's/.*(.*)<\/gephi\.version>.*/\1/p' pom.xml | head -1)
+ rm -rf .github/contract-tests/target
+ mvn -B -f .github/contract-tests/pom.xml test -Dgephi.version="$GEPHI_VERSION" \
+ -Dplugin.groupId=org.gephi -Dplugin.artifactId=layout-plugin -Dplugin.version="$GEPHI_VERSION"
+ grep -ho 'tests="[0-9]*"' .github/contract-tests/target/surefire-reports/TEST-*.xml | grep -qv 'tests="0"'
- name: Assert the generation process produced a working autoupdate site
run: |
diff --git a/AGENTS.md b/AGENTS.md
index 1082a1c96..616df2c2c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,9 @@ Requires JDK 17 and Maven.
- Build and validate every plugin currently listed in `pom.xml`: `mvn clean package`
- Build/test a single plugin module: `mvn -pl modules/ clean package`
- Run a single module's unit tests only: `mvn -pl modules/ test`
+- Run the SPI contract tests CI runs (from `gephi-plugins-tests`) on a module, after `mvn install`:
+ `.github/contract-tests/run.sh modules/`. A failing check fails the run; advisory checks
+ (e.g. a layout adding nodes) only print `::warning::` lines and, in CI, a job summary table.
- Run Gephi with the module(s) installed (build first): `mvn org.gephi:gephi-maven-plugin:run`
- IDE run/debug configs are defined in `nbactions.xml`; the IntelliJ debug setup (remote debugger +
`-Drun.params.debug` VM option) is documented in `README.md`.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 605edae87..74020a9d5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -38,9 +38,21 @@ different purposes on three different branches. Confusion here (e.g. "why is ``.
## How Gephi can be extended
@@ -144,7 +156,8 @@ everything above. Its goals, bound in this repo's root `pom.xml` or invoked dire
## Root files
```text
-.github/workflows/ # build.yml (feature branches), test-generation.yml (master), release-pom.yml (parent-pom)
+.github/workflows/ # build.yml (feature branches), pr.yml (PRs to master-forge), test-generation.yml (master), release-pom.yml (parent-pom)
+.github/contract-tests/ # CI harness running gephi-plugins-tests' SPI contract tests against plugin modules
modules/ # one folder per plugin/suite; empty list on master until you add one
plugins/ # static assets (images) used by the gephi.org plugin listing, not source code
pom.xml # reactor POM for this checkout's plugin modules — NOT the plugin parent POM
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 17d4c09ec..6ebd82d28 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -337,6 +337,11 @@ See "Gephi platform conventions" above for the reasoning; on review, check that
using the `org-netbeans-modules-nbjunit` dependency per `README.md`'s testing section.
- Tests are not disabled or skipped (`@Ignore`, `-DskipTests` baked into the module's own `pom.xml`)
to force a green build.
+- The PR's "Run SPI contract tests on changed plugins" CI step passes. It runs
+ `gephi-plugins-tests`' contract tests for every SPI implementation each changed module registers;
+ locally, `mvn install` then `.github/contract-tests/run.sh modules/`. The step runs the
+ PR's own copy of `.github/`, so a pass only counts if the PR leaves `.github/` untouched (see
+ "Build actually passes" above).
### PR completeness