From d32510fa99764d07c5321b47723f8e396e742355 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 16 Jun 2026 17:23:09 +0200 Subject: [PATCH 01/12] Bump testcontainers, git-properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testcontainers 1.20.6 -> 1.21.4 — Docker Desktop detection. gradle-git-properties 2.5.3 -> 2.5.4 — Git worktree support. --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fe7cabd..5a8a39c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ junit5-version = '5.12.2' picocli-version = "4.7.7" slf4j-version = '2.0.17' string-similarity-version = '1.0.0' -testcontainers-version = '1.20.6' +testcontainers-version = '1.21.4' wiremock-testcontainers-version = '1.0-alpha-15' [libraries] @@ -26,7 +26,7 @@ wiremock-testcontainers = { module = 'org.wiremock.integrations.testcontainers:w gradle-versions = { id= 'com.github.ben-manes.versions', version = '0.52.0' } sonar = { id = 'org.sonarqube', version = '6.3.1.5724' } jreleaser = { id = 'org.jreleaser', version = '1.17.0'} -gitProperties = { id = 'com.gorylenko.gradle-git-properties', version = '2.5.3' } +gitProperties = { id = 'com.gorylenko.gradle-git-properties', version = '2.5.4' } # Copyright Gerd Aschemann and aim42 contributors. # From d5ddb91591d428d0fcc52db09120c0805e913051 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 16 Jun 2026 17:44:49 +0200 Subject: [PATCH 02/12] #432 Lowercase GA coordinates Maven groupId and all artifactIds are now lowercase, aligning with Maven conventions and unblocking publication to repositories that enforce this (notably GitHub Packages). Old -> New: Group org.aim42.htmlSanityCheck -> org.aim42.htmlsanitycheck Gradle plugin id 'org.aim42.htmlSanityCheck' -> id 'org.aim42.htmlsanitycheck' All artifacts htmlSanityCheck-* -> htmlsanitycheck-* Internal Gradle project directories (htmlSanityCheck-core/, etc.) and DSL closure names (htmlSanityCheck { ... }) are unchanged for readability; only the published coordinates differ. Each subproject sets base.archivesName explicitly to its lowercase artifactId; the root maven-publish config picks that up via artifactId = project.base.archivesName.get(). CHANGELOG carries the migration table with links to the historical CamelCase artifacts on Maven Central and the Gradle Plugin Portal. Plugin READMEs gain a prominent [IMPORTANT] admonition pointing at issue #432 and showing the historical URLs. --- CHANGELOG.md | 38 ++++++++++++++++++- build.gradle | 3 ++ gradle.properties | 2 +- htmlSanityCheck-cli/build.gradle | 2 + htmlSanityCheck-core/build.gradle | 2 + htmlSanityCheck-gradle-plugin/README.adoc | 35 ++++++++++------- htmlSanityCheck-gradle-plugin/build.gradle | 4 +- .../gradle/HtmlSanityCheckBaseSpec.groovy | 2 +- htmlSanityCheck-maven-plugin/README.adoc | 15 ++++++-- htmlSanityCheck-maven-plugin/build.gradle | 2 + .../src/main/maven/pom.properties | 4 +- .../src/main/maven/pom.xml | 8 ++-- integration-test/gradle-plugin/build.gradle | 2 +- integration-test/maven-plugin/pom.xml | 7 ++-- self-check/build.gradle | 2 +- src/docs/asciidoctor-config.ad | 2 +- src/docs/development/publishing.adoc | 4 +- 17 files changed, 98 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b41ea1..d1240371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,42 @@ ## Publication (Generic) - [Documentation](https://hsc.aim42.org) -- [Maven Central](https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck) -- [Gradle Plugin Portal](https://plugins.gradle.org/search?term=org.aim42.htmlSanityCheck) +- [Maven Central](https://central.sonatype.com/namespace/org.aim42.htmlsanitycheck) (lowercase, see breaking change below) +- [Gradle Plugin Portal](https://plugins.gradle.org/search?term=org.aim42.htmlsanitycheck) + +## Unreleased + +### ⚠️ BREAKING CHANGE — Lowercase GA coordinates ([#432](https://github.com/aim42/htmlSanityCheck/issues/432)) + +All Maven `groupId` and `artifactId` values are now **lowercase**, aligning with +Maven conventions and enabling publication to repositories that enforce this +constraint (e.g., GitHub Packages). + +**Old → New:** + +| Coordinate | Before | After | +|---|---|---| +| Group | `org.aim42.htmlSanityCheck` | `org.aim42.htmlsanitycheck` | +| Core | `htmlSanityCheck-core` | `htmlsanitycheck-core` | +| CLI | `htmlSanityCheck-cli` | `htmlsanitycheck-cli` | +| Gradle plugin id | `org.aim42.htmlSanityCheck` | `org.aim42.htmlsanitycheck` | +| Gradle plugin artifact | `htmlSanityCheck-gradle-plugin` | `htmlsanitycheck-gradle-plugin` | +| Maven plugin artifact | `htmlSanityCheck-maven-plugin` | `htmlsanitycheck-maven-plugin` | + +**Action required when upgrading:** + +- Gradle users: replace `id 'org.aim42.htmlSanityCheck'` with `id 'org.aim42.htmlsanitycheck'`. +- Maven users: update `groupId` and `artifactId` in the plugin block to the lowercase form. +- Direct dependency users: update group and artifactId coordinates in your build. + +Existing released versions (≤ `2.0.0-rc4`) remain available under the old CamelCase coordinates: + +- [Maven Central — `org.aim42.htmlSanityCheck`](https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck) (historical artifacts up to and including `2.0.0-rc4`) +- [Gradle Plugin Portal — `org.aim42.htmlSanityCheck`](https://plugins.gradle.org/plugin/org.aim42.htmlSanityCheck) (historical plugin versions) +- [GitHub Releases](https://github.com/aim42/htmlSanityCheck/releases) (CLI binaries) + +The internal Gradle project / source directory names (e.g., `htmlSanityCheck-core/`) +are unchanged for readability — only the published Maven coordinates differ. ## 2.0.0-rc4 diff --git a/build.gradle b/build.gradle index 065e0406..07a024aa 100644 --- a/build.gradle +++ b/build.gradle @@ -216,6 +216,9 @@ configure(subprojects) { publishing { publications.all { publication -> if (publication instanceof MavenPublication) { + // GA coordinates must be lowercase (Maven convention, enforced by GitHub Packages). + // Each subproject sets base.archivesName explicitly to its lowercase artifactId. + artifactId = project.base.archivesName.get() publication.pom { name = project.name description = project.description diff --git a/gradle.properties b/gradle.properties index f2240227..7fa31ea8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ htmlSanityCheckVersion=2.0.0-rc5 # end::version[] -group = org.aim42.htmlSanityCheck +group = org.aim42.htmlsanitycheck description = HTML Sanity Check org.gradle.jvmargs=-Xmx2G diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 37346621..37817aff 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -2,6 +2,8 @@ plugins { id 'application' } +base.archivesName = 'htmlsanitycheck-cli' + dependencies { implementation libs.picocli.impl annotationProcessor libs.picocli.annotationprocessor diff --git a/htmlSanityCheck-core/build.gradle b/htmlSanityCheck-core/build.gradle index 4d7c3654..f4215402 100644 --- a/htmlSanityCheck-core/build.gradle +++ b/htmlSanityCheck-core/build.gradle @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.gitProperties) } +base.archivesName = 'htmlsanitycheck-core' + dependencies { implementation (libs.commons.validator) { // Having a vulnerability here ... diff --git a/htmlSanityCheck-gradle-plugin/README.adoc b/htmlSanityCheck-gradle-plugin/README.adoc index 66873cd8..4de75d1c 100644 --- a/htmlSanityCheck-gradle-plugin/README.adoc +++ b/htmlSanityCheck-gradle-plugin/README.adoc @@ -2,8 +2,8 @@ :doctype: book include::../src/docs/asciidoctor-config.ad[] -image:https://img.shields.io/gradle-plugin-portal/v/org.aim42.htmlSanityCheck[link=https://plugins.gradle.org/search?term=org.aim42.htmlSanityCheck] -image:https://img.shields.io/maven-central/v/org.aim42.htmlSanityCheck/org.aim42.htmlSanityCheck.gradle.plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlSanityCheck/org.aim42.htmlSanityCheck.gradle.plugin] +image:https://img.shields.io/gradle-plugin-portal/v/org.aim42.htmlsanitycheck[link=https://plugins.gradle.org/search?term=org.aim42.htmlsanitycheck] +image:https://img.shields.io/maven-central/v/org.aim42.htmlsanitycheck/org.aim42.htmlsanitycheck.gradle.plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlsanitycheck/org.aim42.htmlsanitycheck.gradle.plugin] ifdef::env-github[] :imagesdir: ../src/docs/images @@ -18,6 +18,15 @@ endif::env-github[] The {gradle-url}[Gradle] plugin of HTML Sanity Check (xref:{xrefToManual}[HSC]) enables to check generated or native HTML documentation from the Gradle build. +[IMPORTANT] +.Breaking change since 2.0.0-rc4: lowercase GA coordinates +==== +The plugin id and Maven coordinates were lowercased to comply with Maven conventions and to support repositories that enforce this (e.g., GitHub Packages). +See https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432] and the https://github.com/aim42/htmlSanityCheck/blob/main/CHANGELOG.md[CHANGELOG] for the migration table. + +Versions up to and including `2.0.0-rc4` remain available under the historical CamelCase id at https://plugins.gradle.org/plugin/org.aim42.htmlSanityCheck[the Gradle Plugin Portal] and https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck[Maven Central]. +==== + [[sec:installation]] == Installation (Gradle Plugin) @@ -37,30 +46,28 @@ plugins { === Legacy Installation -In the case of https://docs.gradle.org/current/userguide/plugins.html#sec:old_plugin_application[legacy plugin usage] +In the case of https://docs.gradle.org/current/userguide/plugins.html#sec:old_plugin_application[legacy plugin usage], the full Maven coordinates (group + artifact + version) are required. +The plugin-marker pseudo-POM resolved automatically by the `plugins {}` DSL only applies to the default form above. .build.gradle [source,groovy,subs="attributes+"] ---- buildscript { repositories { - // maven { url "{jitpack-url}" } // <1> - mavenCentral() // <2> - gradlePluginPortal() // <3> + mavenCentral() // <1> + gradlePluginPortal() // <2> } dependencies { - classpath ('gradle.plugin.org.aim42:{project}:{hsc-version}') // <4> + classpath 'org.aim42.{project}:{project}-gradle-plugin:{hsc-version}' // <3> } } apply plugin: 'org.aim42.{project}' ---- - -<1> In case you would like to use a development version (or even a branch), check out <>. -<2> Beginning with version `2.x` all releases will be published to https://central.sonatype.com/artifact/org.aim42.htmlSanityCheck/org.aim42.htmlSanityCheck.gradle.plugin[Maven Central]. -<3> The https://plugins.gradle.org[Gradle Plugin Portal] contains https://plugins.gradle.org/plugin/org.aim42.htmlSanityCheck[most versions] or will redirect downloads of newer versions to Maven Central. -<4> Check out <>. +<1> Beginning with version `2.x` all releases will be published to https://central.sonatype.com/artifact/org.aim42.htmlsanitycheck/htmlsanitycheck-gradle-plugin[Maven Central]. +<2> The https://plugins.gradle.org[Gradle Plugin Portal] contains https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck[most versions] or will redirect downloads of newer versions to Maven Central. +<3> Full GA coordinates — see <>. [[box:current-version]] [IMPORTANT] @@ -212,7 +219,7 @@ The mentioned configurations effectively move the configured codes around, i.e., `build.gradle` [source,groovy] ---- -apply plugin: 'org.aim42.htmlSanityCheck' +apply plugin: 'org.aim42.htmlsanitycheck' htmlSanityCheck { sourceDir = file( "$buildDir/docs" ) @@ -286,7 +293,7 @@ asciidoctor { } -apply plugin: 'org.aim42.htmlSanityCheck' +apply plugin: 'org.aim42.htmlsanitycheck' htmlSanityCheck { // ensure asciidoctor->html runs first diff --git a/htmlSanityCheck-gradle-plugin/build.gradle b/htmlSanityCheck-gradle-plugin/build.gradle index feb4f0a7..19f63f0b 100644 --- a/htmlSanityCheck-gradle-plugin/build.gradle +++ b/htmlSanityCheck-gradle-plugin/build.gradle @@ -7,6 +7,8 @@ plugins { // id 'codenarc' } +base.archivesName = 'htmlsanitycheck-gradle-plugin' + def profile = project.hasProperty('profile') ? project.property('profile') : 'no-gpp' afterEvaluate { @@ -24,7 +26,7 @@ gradlePlugin { vcsUrl = rootProject.ext.urls['scm'] plugins { htmlSanityCheck { - id = 'org.aim42.htmlSanityCheck' + id = 'org.aim42.htmlsanitycheck' implementationClass = 'org.aim42.htmlsanitycheck.gradle.HtmlSanityCheckPlugin' displayName = 'Gradle HtmlSanityCheck Plugin' description = project.description diff --git a/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy b/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy index 8ef683a8..3a56cb9c 100644 --- a/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy +++ b/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy @@ -30,7 +30,7 @@ class HtmlSanityCheckBaseSpec extends Specification { // (URIs consist of / instead of backslashes) buildFile = testProjectDir.newFile('build.gradle') << """ plugins { - id 'org.aim42.htmlSanityCheck' + id 'org.aim42.htmlsanitycheck' } htmlSanityCheck { diff --git a/htmlSanityCheck-maven-plugin/README.adoc b/htmlSanityCheck-maven-plugin/README.adoc index 15ce403e..e93f2c26 100644 --- a/htmlSanityCheck-maven-plugin/README.adoc +++ b/htmlSanityCheck-maven-plugin/README.adoc @@ -2,7 +2,7 @@ :doctype: book include::../src/docs/asciidoctor-config.ad[] -image:https://img.shields.io/maven-central/v/org.aim42.htmlSanityCheck/htmlSanityCheck-maven-plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlSanityCheck/htmlSanityCheck-maven-plugin] +image:https://img.shields.io/maven-central/v/org.aim42.htmlsanitycheck/htmlsanitycheck-maven-plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlsanitycheck/htmlsanitycheck-maven-plugin] ifdef::env-github[] :imagesdir: ../src/docs/images @@ -17,6 +17,15 @@ endif::env-github[] The {maven-url}[Maven] plugin of HTML Sanity Check (xref:{xrefToManual}[HSC]) enables to check generated or native HTML documentation from the Maven build. +[IMPORTANT] +.Breaking change since 2.0.0-rc4: lowercase GA coordinates +==== +The Maven `groupId` and `artifactId` were lowercased to comply with Maven conventions and to support repositories that enforce this (e.g., GitHub Packages). +See https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432] and the https://github.com/aim42/htmlSanityCheck/blob/main/CHANGELOG.md[CHANGELOG] for the migration table. + +Versions up to and including `2.0.0-rc4` remain available under the historical CamelCase coordinates at https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck[Maven Central]. +==== + [[sec:installation]] == Installation (Maven Plugin) @@ -28,8 +37,8 @@ Use the following snippet inside a Maven build file: [source,xml,subs="attributes+"] ---- - org.aim42.htmlSanityCheck - htmlSanityCheck-maven-plugin + org.aim42.htmlsanitycheck + htmlsanitycheck-maven-plugin {hsc-version} // <1> diff --git a/htmlSanityCheck-maven-plugin/build.gradle b/htmlSanityCheck-maven-plugin/build.gradle index 80e4741a..ec1262b6 100644 --- a/htmlSanityCheck-maven-plugin/build.gradle +++ b/htmlSanityCheck-maven-plugin/build.gradle @@ -6,6 +6,8 @@ plugins { id "java" } +base.archivesName = 'htmlsanitycheck-maven-plugin' + ext { mavenPluginToolsVersion = "3.15.1" } diff --git a/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties b/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties index d5b74e55..aa16b7fd 100644 --- a/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties +++ b/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties @@ -1,4 +1,4 @@ version=${version} -groupId=org.aim42.htmlSanityCheck -artifactId=htmlSanityCheck-maven-plugin +groupId=org.aim42.htmlsanitycheck +artifactId=htmlsanitycheck-maven-plugin diff --git a/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml b/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml index 198484c3..cf1ac7a5 100644 --- a/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml +++ b/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml @@ -3,8 +3,8 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.aim42.htmlSanityCheck - htmlSanityCheck-maven-plugin + org.aim42.htmlsanitycheck + htmlsanitycheck-maven-plugin HTML Sanitycheck Maven Plugin ${version} maven-plugin @@ -16,8 +16,8 @@ - org.aim42.htmlSanityCheck - htmlSanityCheck-core + org.aim42.htmlsanitycheck + htmlsanitycheck-core ${version} diff --git a/integration-test/gradle-plugin/build.gradle b/integration-test/gradle-plugin/build.gradle index b9839c7d..0e18a045 100644 --- a/integration-test/gradle-plugin/build.gradle +++ b/integration-test/gradle-plugin/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.aim42.htmlSanityCheck' version "${htmlSanityCheckVersion}" + id 'org.aim42.htmlsanitycheck' version "${htmlSanityCheckVersion}" } repositories { diff --git a/integration-test/maven-plugin/pom.xml b/integration-test/maven-plugin/pom.xml index c1243586..aaaf3638 100644 --- a/integration-test/maven-plugin/pom.xml +++ b/integration-test/maven-plugin/pom.xml @@ -3,7 +3,7 @@ xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.aim42.htmlSanityCheck.integration-test + org.aim42.htmlsanitycheck.integration-test maven-plugin-integration-test pom 0.0-SNAPSHOT @@ -11,11 +11,12 @@ YOU MUST SET THIS EXPLICITLY + - org.aim42.htmlSanityCheck - htmlSanityCheck-maven-plugin + org.aim42.htmlsanitycheck + htmlsanitycheck-maven-plugin ${hsc.version} diff --git a/self-check/build.gradle b/self-check/build.gradle index 61dbe45b..5755d638 100644 --- a/self-check/build.gradle +++ b/self-check/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.aim42.htmlSanityCheck' version "${htmlSanityCheckVersion}" + id 'org.aim42.htmlsanitycheck' version "${htmlSanityCheckVersion}" } htmlSanityCheck { diff --git a/src/docs/asciidoctor-config.ad b/src/docs/asciidoctor-config.ad index 5aa07730..215a6481 100644 --- a/src/docs/asciidoctor-config.ad +++ b/src/docs/asciidoctor-config.ad @@ -14,7 +14,7 @@ ifndef::imagesdir[:imagesdir: src/docs/images] :jitpack-url: https://jitpack.io/ :maven-url: https://maven.apache.org/ -:project: htmlSanityCheck +:project: htmlsanitycheck :project-url: https://github.com/aim42/htmlSanityCheck :project-issues: https://github.com/aim42/htmlSanityCheck/issues :project-bugs: {project-issues}?q=is%3Aopen+is%3Aissue+label%3Abug diff --git a/src/docs/development/publishing.adoc b/src/docs/development/publishing.adoc index aeb59b71..6ea72833 100644 --- a/src/docs/development/publishing.adoc +++ b/src/docs/development/publishing.adoc @@ -15,7 +15,7 @@ ifdef::backend-html5[:markdown-suffix: html] We distribute HSC for different usage scenarios to appropriate repositories, i.e., we publish -* The HSC artifacts (Core, Maven plugin, and Gradle plugin) to https://central.sonatype.com/search?q=org.aim42.htmlSanityCheck[Maven Central] (MC) for retrieval by the common Java build tools, +* The HSC artifacts (Core, Maven plugin, and Gradle plugin) to https://central.sonatype.com/search?q=org.aim42.htmlsanitycheck[Maven Central] (MC) for retrieval by the common Java build tools, * The HSC Command Line Interface (CLI) distribution to ** https://github.com/aim42/htmlSanityCheck/releases[GitHub] for download as ZIP- or TAR-File. ** https://sdkman.io/sdks#hsc[SDKMAN] for convenient installation (`sdk install hsc`). @@ -34,7 +34,7 @@ Make yourself familiar with https://central.sonatype.org/publish-ea/publish-ea-g [CAUTION] .Maven Central credentials needed ==== -You need respective credentials to upload files to Maven Central for the `org.aim42.htmlSanityCheck` namespace. +You need respective credentials to upload files to Maven Central for the `org.aim42.htmlsanitycheck` namespace. Talk to Gernot Starke to get these permissions. ==== From 8ab8176c73d975fd05b0ef7d4d1d1bd59e76f810 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 16 Jun 2026 17:47:57 +0200 Subject: [PATCH 03/12] #432 Exclude not-yet-published lowercase URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lowercase coordinates introduced by #432 are not yet on the Gradle Plugin Portal or Maven Central — HSC's BrokenHttpLinksChecker would fail self-check and the integration tests on the new badge/canonical URLs in our READMEs and CHANGELOG. Exclude them with grep-able TODO(#432) markers so the cleanup after the first release under the lowercase coords is a pure revert: grep -rn 'TODO(#432)' integration-test self-check --- integration-test/gradle-plugin/build.gradle | 4 ++++ integration-test/maven-plugin/pom.xml | 4 ++++ self-check/build.gradle | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/integration-test/gradle-plugin/build.gradle b/integration-test/gradle-plugin/build.gradle index 0e18a045..a58e30fb 100644 --- a/integration-test/gradle-plugin/build.gradle +++ b/integration-test/gradle-plugin/build.gradle @@ -16,6 +16,10 @@ htmlSanityCheck { // hosts). The link works in browsers and from most environments; exclude it from // automated checking rather than dropping the historical reference. "https://web.archive.org/.*", + // TODO(#432): remove this exclude after the first release under the lowercase plugin id is published to the Gradle Plugin Portal. + "https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck.*", + // TODO(#432): remove this exclude after the first release under the lowercase group is published to Maven Central. + "https://central.sonatype.com/(namespace|artifact)/org.aim42.htmlsanitycheck.*", ] httpSuccessCodes = [429] diff --git a/integration-test/maven-plugin/pom.xml b/integration-test/maven-plugin/pom.xml index aaaf3638..e7ffc675 100644 --- a/integration-test/maven-plugin/pom.xml +++ b/integration-test/maven-plugin/pom.xml @@ -38,6 +38,10 @@ https://web.archive.org/.* + + https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck.* + + https://central.sonatype.com/(namespace|artifact)/org.aim42.htmlsanitycheck.* ../common/build/docs ${project.build.directory}/reports diff --git a/self-check/build.gradle b/self-check/build.gradle index 5755d638..dd09513e 100644 --- a/self-check/build.gradle +++ b/self-check/build.gradle @@ -10,6 +10,10 @@ htmlSanityCheck { "https://www.baeldung.com/.*", // Baeldung seems to have implemented some anti-robot/GPT strategy? "https://www.iconfinder.com/icons/118743/arrow_up_icon", "https://www.freepik.com/", // Both fail frequently on GitHub pages "https://web.archive.org/.*", // Wayback Machine connect-times-out from some CI runners (notably Azure Windows hosts). + // TODO(#432): remove this exclude after the first release under the lowercase plugin id is published to the Gradle Plugin Portal. + "https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck.*", + // TODO(#432): remove this exclude after the first release under the lowercase group is published to Maven Central. + "https://central.sonatype.com/(namespace|artifact)/org.aim42.htmlsanitycheck.*", ] httpSuccessCodes = [ 429 ] From 1f92fe8a79e27e2bd0a888dc57363a80146dbbd1 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 16 Jun 2026 17:49:32 +0200 Subject: [PATCH 04/12] #432 Publish to GitHub Packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a maven repository named 'GitHubPackages' under publishing { repositories { } } so all four artifacts (core, gradle-plugin, gradle-plugin marker, maven-plugin) can be pushed to https://maven.pkg.github.com/aim42/htmlSanityCheck. The repo is only registered when GITHUB_USER and GITHUB_TOKEN are both set in the env — that way running 'publish' tasks that target other repositories (mavenLocal, the local integration repo) keeps working without env vars, and a missing-PAT mistake yields a clear "task not found" instead of Gradle's cryptic "property 'credentials.username' doesn't have a configured value". Plugin READMEs gain a "Development versions" section that recommends GitHub Packages and deprecates JitPack. publishing.adoc gains a GitHub Packages credentials section and a 'Publish development snapshot to GitHub Packages' how-to step. --- build.gradle | 26 +++++++++ htmlSanityCheck-gradle-plugin/README.adoc | 65 +++++++++++++++++++---- htmlSanityCheck-maven-plugin/README.adoc | 40 ++++++++++++++ src/docs/development/publishing.adoc | 27 ++++++++++ 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 07a024aa..8746baaf 100644 --- a/build.gradle +++ b/build.gradle @@ -264,6 +264,21 @@ configure(subprojects) { name = 'myLocalRepositoryForFullIntegrationTests' url = mavenBuildRepo } + // Only register GitHubPackages when credentials are present. Otherwise Gradle's task- + // configuration validation fails for any publish task in the project with a cryptic + // "credentials.username doesn't have a configured value" — even for unrelated tasks. + // Skipping registration means trying to invoke publish*ToGitHubPackagesRepository + // without env vars yields a clear "task not found" instead. + if (System.getenv("GITHUB_USER") && System.getenv("GITHUB_TOKEN")) { + maven { + name = "GitHubPackages" + url = "https://maven.pkg.github.com/aim42/htmlSanityCheck" + credentials { + username = System.getenv("GITHUB_USER") + password = System.getenv("GITHUB_TOKEN") + } + } + } mavenLocal() } } @@ -273,6 +288,17 @@ configure(subprojects) { outputs.upToDateWhen { false } } + // GitHub Packages rejects GETs to the plugin marker's snapshot maven-metadata.xml with + // HTTP 400, presumably because the marker artifactId 'org.aim42.htmlsanitycheck.gradle.plugin' + // contains dots that GH's package URL parser treats specially. Skip publishing the marker + // to GitHub Packages; consumers can still resolve it through the Gradle Plugin Portal once + // a release is cut. Local + Maven Central publish targets keep the marker. + tasks.withType(PublishToMavenRepository).configureEach { + if (name.endsWith("PluginMarkerMavenPublicationToGitHubPackagesRepository")) { + enabled = false + } + } + tasks.named('test', Test) { useJUnitPlatform() } diff --git a/htmlSanityCheck-gradle-plugin/README.adoc b/htmlSanityCheck-gradle-plugin/README.adoc index 4de75d1c..12a2cc5d 100644 --- a/htmlSanityCheck-gradle-plugin/README.adoc +++ b/htmlSanityCheck-gradle-plugin/README.adoc @@ -366,20 +366,42 @@ include::src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTaskFun [[sec:development-versions]] == Development versions -In case you want to use a current development (or arbitrary branch or tag) version from GitHub, -add the following to your `settings.gradle`: +In case you want to use a current development (or arbitrary branch or tag) version, the recommended source is *GitHub Packages*. +JitPack is still available but no longer recommended (see <>). + +[[sec:github-packages]] +=== GitHub Packages (recommended) + +Development snapshots are published to https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[GitHub Packages for `aim42/htmlSanityCheck`]. +GitHub Packages requires authentication even for public packages, so you must supply a Personal Access Token (PAT) with `read:packages` scope via the env vars `GITHUB_USER` and `GITHUB_TOKEN`: + +[source,shell] +---- +export GITHUB_USER= +export GITHUB_TOKEN= +---- + +Then declare the repository in your project's `settings.gradle`: .settings.gradle [source,groovy,subs="attributes+"] ---- pluginManagement { repositories { - maven { url "{jitpack-url}" } + maven { + name = "GitHubPackages" + url = "https://maven.pkg.github.com/aim42/htmlSanityCheck" + credentials { + username = System.getenv("GITHUB_USER") + password = System.getenv("GITHUB_TOKEN") + } + } + gradlePluginPortal() } } ---- -Then you can use a respective version in your `build.gradle`: +And select the development version in your `build.gradle`: .build.gradle [source,groovy,subs="attributes+"] @@ -389,15 +411,40 @@ plugins { } ---- -[NOTE] -.JitPack builds +[TIP] +==== +The same repository declaration works for the `dependencies {}` block (e.g. to pull `{project}-core` directly) — declare it under `repositories {}` in your `build.gradle` instead of `settings.gradle`. ==== -Building the desired version for the first time (or after some cache expiry at {jitpack-url}[JitPack]), you may experience a timeout. -You can look up the https://jitpack.io/#org.aim42/htmlSanityCheck[current build state]: +[[sec:jitpack-deprecation]] +=== JitPack (deprecated) -image::jitpack-branch-screenshot.png[] +[WARNING] ==== +JitPack support is retained for backwards compatibility but is no longer recommended. +Known issues that motivated the switch to GitHub Packages (https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432]): + +* New branches do not always show up. +* Updates after force pushes are not always rebuilt. +* First-time builds (or after cache expiry) can time out. +==== + +If you still want to use JitPack, add it to your `settings.gradle`: + +.settings.gradle +[source,groovy,subs="attributes+"] +---- +pluginManagement { + repositories { + maven { url "{jitpack-url}" } + gradlePluginPortal() + } +} +---- + +You can look up the https://jitpack.io/#org.aim42/htmlSanityCheck[current JitPack build state]: + +image::jitpack-branch-screenshot.png[] diff --git a/htmlSanityCheck-maven-plugin/README.adoc b/htmlSanityCheck-maven-plugin/README.adoc index e93f2c26..ab087e13 100644 --- a/htmlSanityCheck-maven-plugin/README.adoc +++ b/htmlSanityCheck-maven-plugin/README.adoc @@ -74,6 +74,46 @@ include::../gradle.properties[tag=version] ---- ==== +[[sec:development-versions]] +=== Development versions + +Development snapshots are published to *GitHub Packages* for https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[`aim42/htmlSanityCheck`]. +GitHub Packages requires authentication even for public packages — supply a Personal Access Token (PAT) with `read:packages` scope via `~/.m2/settings.xml`: + +.~/.m2/settings.xml +[source,xml,subs="attributes+"] +---- + + + + github-aim42 + YOUR_GITHUB_USERNAME + YOUR_PAT_WITH_read:packages + + + +---- + +Then declare the repository in your project's `pom.xml`: + +[source,xml,subs="attributes+"] +---- + + + github-aim42 + https://maven.pkg.github.com/aim42/htmlSanityCheck + true + + +---- + +[WARNING] +.JitPack (deprecated) +==== +JitPack support is retained for backwards compatibility but is no longer recommended for the reasons documented in https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432] (missing branches after force-push, stale builds, first-time timeouts). +Prefer GitHub Packages. +==== + [[sec:usage]] == Usage diff --git a/src/docs/development/publishing.adoc b/src/docs/development/publishing.adoc index 6ea72833..6e0a68fa 100644 --- a/src/docs/development/publishing.adoc +++ b/src/docs/development/publishing.adoc @@ -16,6 +16,7 @@ ifdef::backend-html5[:markdown-suffix: html] We distribute HSC for different usage scenarios to appropriate repositories, i.e., we publish * The HSC artifacts (Core, Maven plugin, and Gradle plugin) to https://central.sonatype.com/search?q=org.aim42.htmlsanitycheck[Maven Central] (MC) for retrieval by the common Java build tools, +* Development snapshots additionally to https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[GitHub Packages] (replacing JitPack, cf. https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432]), * The HSC Command Line Interface (CLI) distribution to ** https://github.com/aim42/htmlSanityCheck/releases[GitHub] for download as ZIP- or TAR-File. ** https://sdkman.io/sdks#hsc[SDKMAN] for convenient installation (`sdk install hsc`). @@ -76,6 +77,20 @@ export GRADLE_PUBLISH_KEY=... export GRADLE_PUBLISH_SECRET=... ---- +[[sec:gh-packages-credentials]] +=== GitHub Packages + +Development snapshots are pushed to https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[GitHub Packages for `aim42/htmlSanityCheck`]. +A Personal Access Token (PAT) with `write:packages` scope is required, supplied via env vars: + +[source,shell] +---- +export GITHUB_USER=... +export GITHUB_TOKEN= +---- + +The same env-var pair is the default for GitHub Actions secrets — no additional configuration is needed on CI. + [[sec:sdkman-api-credentials]] === SDKMAN @@ -188,6 +203,18 @@ Clean, check (test), and perform integration tests: ./gradlew clean check integrationTest ---- +=== Publish development snapshot to GitHub Packages + +For sharing a development branch with downstream consumers before a release, push the current version to GitHub Packages (cf. <>): + +[source,shell] +---- +./gradlew publishAllPublicationsToGitHubPackagesRepository +---- + +This publishes all four artifacts (core, cli, gradle-plugin, maven-plugin) under the lowercase GA coordinates `org.aim42.htmlsanitycheck:htmlsanitycheck-*`. +Consumers configure their build to read from the same repository — see the development-versions section of the xref:../manual/30_gradle-plugin.adoc[Gradle plugin] or xref:../manual/40_maven-plugin.adoc[Maven plugin] documentation. + === Publish on Gradle Plugin Portal Set the respective credentials (cf. <>). From 4193164f1cceecda67d19a4d7674b7357937b77a Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 16 Jun 2026 17:51:45 +0200 Subject: [PATCH 05/12] #432 Per-branch SNAPSHOTs via git-versioning Feature and bugfix branches now publish under their own branch-slug-SNAPSHOT (e.g. feature-432-add-gh-packages-SNAPSHOT) so GitHub Packages can hold multiple in-flight branches in parallel without colliding on a single 2.0.0-rcN coordinate. Mechanism: - Add me.qoomon.git-versioning 6.4.4. For refs matching ^(feature|bugfix)/.+ derive version = ${ref}-SNAPSHOT; for tags v derive a release version; otherwise fall back to gradle.properties. - Add a tiny root :printVersion task so shell callers (generate- pages, CI) can capture the derived version without parsing build output. - Propagate -PhtmlSanityCheckVersion to the integration-test child Gradle so the gradle-plugin integration test asks for the just- published SNAPSHOT, not the static 2.0.0-rcN. - generate-pages now captures printVersion and forwards it via -P to self-check, so the CI doc-build picks up the SNAPSHOT plugin instead of trying to resolve the static version that has not been published yet. SNAPSHOT-resolution plumbing for embedded/integration Maven: Gradle's publishToRepository writes Maven layout but no _remote.repositories markers, so Maven's EnhancedLocalRepositoryManager refuses to resolve SNAPSHOTs that simply appear in localRepository. Fix both Maven invocations (htmlSanityCheck-maven-plugin build-time descriptor generation, and integration-test/maven-plugin) by: - declaring build/maven-repo as a 'remote' / entry with a file:// URL, so Maven goes through its download path and writes the origin markers - pointing localRepository at a separate .gradle/maven-local-cache so the cache lives outside build/ (and survives gradle clean, avoiding re-downloading the Maven plugin toolchain on every clean build) The build-time pom.xml gets the absolute repo URL via a mavenBuildRepoUrl expand binding in generatePom; the integration- test pom.xml uses Maven's ${project.basedir} since it's loaded directly. --- .github/workflows/test-java-os-mix.yml | 16 +++++++-- build.gradle | 36 ++++++++++++++++++- generate-pages | 5 ++- htmlSanityCheck-maven-plugin/build.gradle | 7 +++- htmlSanityCheck-maven-plugin/settings.xml | 8 ++++- .../src/main/maven/pom.xml | 15 ++++++++ integration-test/maven-plugin/pom.xml | 14 ++++++++ integration-test/maven-plugin/settings.xml | 9 ++++- 8 files changed, 103 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-java-os-mix.yml b/.github/workflows/test-java-os-mix.yml index 5ad496cb..13038d90 100644 --- a/.github/workflows/test-java-os-mix.yml +++ b/.github/workflows/test-java-os-mix.yml @@ -48,6 +48,9 @@ jobs: - name: Check out uses: actions/checkout@v5 + with: + # Needed for git-versioning to derive the project version from the branch ref. + fetch-depth: 0 - name: Setup JDK uses: actions/setup-java@v5 @@ -68,17 +71,26 @@ jobs: if: runner.os != 'Windows' run: | uname -a + # The integration-test subproject reads htmlSanityCheckVersion from gradle.properties + # (static 2.0.0-rcN), but build-artifacts upstream published under git-versioning's + # -SNAPSHOT. Read the actual published version straight from the maven-repo + # tree we just downloaded — avoids invoking gradle from root here, which would otherwise + # have to resolve every root plugin (sonar, git-versioning, ...) on this matrix worker. + HSC_VERSION="$(basename "$(find build/maven-repo/org/aim42/htmlsanitycheck/htmlsanitycheck-core -maxdepth 1 -mindepth 1 -type d | head -1)")" + echo "Resolved HSC version: ${HSC_VERSION}" cd integration-test - ../gradlew integrationTest --scan ${GRADLE_DEBUG} + ../gradlew integrationTest -PhtmlSanityCheckVersion=${HSC_VERSION} --scan ${GRADLE_DEBUG} - name: Execute integration tests (Windows) if: runner.os == 'Windows' shell: pwsh run: | uname -a + $HSC_VERSION = (Get-ChildItem -Directory build/maven-repo/org/aim42/htmlsanitycheck/htmlsanitycheck-core | Select-Object -First 1).Name + Write-Output "Resolved HSC version: $HSC_VERSION" cd ./integration-test/ pwd - cmd /c "echo off && ..\gradlew.bat integrationTest --scan $env:GRADLE_DEBUG" + cmd /c "echo off && ..\gradlew.bat integrationTest -PhtmlSanityCheckVersion=$HSC_VERSION --scan $env:GRADLE_DEBUG" - name: Collect state upon failure (On Unix) if: failure() && runner.os != 'Windows' diff --git a/build.gradle b/build.gradle index 8746baaf..5a3706b4 100644 --- a/build.gradle +++ b/build.gradle @@ -16,12 +16,35 @@ plugins { alias(libs.plugins.jreleaser) id 'com.dorongold.task-tree' version '4.0.1' + + id 'me.qoomon.git-versioning' version '6.4.4' } allprojects { group = group version = htmlSanityCheckVersion + // Derive the version from the current Git ref so feature/bugfix branches publish under their own + // -SNAPSHOT coordinate and don't collide with each other or with release versions on GitHub Packages. + // Tags shaped 'v' produce a release version; any other ref falls back to gradle.properties. + gitVersioning.apply { + refs { + branch('^(feature|bugfix)/.+') { + version = '${ref}-SNAPSHOT' + } + tag('v(?.*)') { + version = '${ref.version}' + } + } + rev { + // Use Groovy interpolation (double quotes) so the gradle.properties value is captured + // at config time. git-versioning only substitutes its own placeholders (${ref}, + // ${commit}, ...) and would otherwise pass the single-quoted literal as the version, + // which leaks into the published POM as "${htmlSanityCheckVersion}". + version = "${htmlSanityCheckVersion}" + } + } + repositories { mavenCentral() mavenLocal() @@ -46,6 +69,13 @@ allprojects { } } +// Single-line `./gradlew -q printVersion` returns the git-versioning–derived project.version. +// Used by generate-pages (and any shell caller) to propagate the dynamic version to standalone +// sub-builds like self-check that read htmlSanityCheckVersion from gradle.properties. +tasks.register("printVersion") { + doLast { println project.version } +} + dependencies { // Add all subprojects to the aggregation subprojects.forEach { @@ -391,7 +421,11 @@ tasks.register("integrationTestOnly") { commandLine((System.getProperty("os.name") ==~ /Windows.*/ ? "..\\gradlew.bat" : "../gradlew"), - "integrationTest") + "integrationTest", + // Forward the git-versioning–derived project.version to the child build so it + // can locate the just-published artifact in build/maven-repo. + "-PhtmlSanityCheckVersion=${project.version}", + ) } logger.debug "Script output: ${result}" } diff --git a/generate-pages b/generate-pages index 1056bb92..b96d190d 100755 --- a/generate-pages +++ b/generate-pages @@ -44,7 +44,10 @@ run copyPdf cp -rp build/pdf build/microsite/output run copyStandalone cp -rp build/html5/images build/microsite/output \ && mkdir -p build/microsite/output/single-page/ \ && cp -p build/html5/arc42/hsc_arc42.html build/microsite/output/single-page/hsc_arc42-single-page.html -run htmlSanityCheck "(cd self-check && ../gradlew htmlSanityCheck --scan --refresh-dependencies --scan)" +# Propagate the git-versioning–derived project.version to self-check so it picks up the just-published +# branch-slug-SNAPSHOT (or release version on tags) instead of the static value in gradle.properties. +HSC_VERSION="$(./gradlew -q printVersion)" +run htmlSanityCheck "(cd self-check && ../gradlew -PhtmlSanityCheckVersion=${HSC_VERSION} htmlSanityCheck --scan --refresh-dependencies --scan)" run copyCheckResult cp -rp build/reports/htmlchecks build/microsite/output run fixDocLinks "sed -i .bak \ -e 's, href=\"${PWD}/build/microsite/output/, href=\"../,g' \ diff --git a/htmlSanityCheck-maven-plugin/build.gradle b/htmlSanityCheck-maven-plugin/build.gradle index ec1262b6..d4fba141 100644 --- a/htmlSanityCheck-maven-plugin/build.gradle +++ b/htmlSanityCheck-maven-plugin/build.gradle @@ -40,7 +40,12 @@ tasks.register('generatePom', Copy) { into 'build/maven' filesMatching("**/pom.*") { expand(version: project.version, - mavenPluginToolsVersion: project.mavenPluginToolsVersion) + mavenPluginToolsVersion: project.mavenPluginToolsVersion, + // Absolute file:// URL of the root build/maven-repo where Gradle publishes the + // just-built core artifact. Used by the templated pom.xml as a 'remote' repo so + // Maven resolves SNAPSHOTs cleanly. This pom.xml is build-time-only; the published + // pom.xml comes from generatePomFileForMavenJavaPublication. + mavenBuildRepoUrl: rootProject.file("${Project.DEFAULT_BUILD_DIR_NAME}/maven-repo").toURI().toString()) } } diff --git a/htmlSanityCheck-maven-plugin/settings.xml b/htmlSanityCheck-maven-plugin/settings.xml index e198fdb3..4de4771b 100644 --- a/htmlSanityCheck-maven-plugin/settings.xml +++ b/htmlSanityCheck-maven-plugin/settings.xml @@ -2,5 +2,11 @@ - ../build/maven-repo + + ../.gradle/maven-local-cache diff --git a/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml b/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml index cf1ac7a5..d715c441 100644 --- a/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml +++ b/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml @@ -14,6 +14,21 @@ 8 + + + + integration-test-local + ${mavenBuildRepoUrl} + true + true + + + org.aim42.htmlsanitycheck diff --git a/integration-test/maven-plugin/pom.xml b/integration-test/maven-plugin/pom.xml index e7ffc675..98308aed 100644 --- a/integration-test/maven-plugin/pom.xml +++ b/integration-test/maven-plugin/pom.xml @@ -12,6 +12,20 @@ YOU MUST SET THIS EXPLICITLY + + + + integration-test-local + file://${project.basedir}/../../build/maven-repo + true + true + + + diff --git a/integration-test/maven-plugin/settings.xml b/integration-test/maven-plugin/settings.xml index 7a1dae4e..97b98538 100644 --- a/integration-test/maven-plugin/settings.xml +++ b/integration-test/maven-plugin/settings.xml @@ -2,6 +2,13 @@ - ../../build/maven-repo + + ../../.gradle/maven-local-cache From 5f2b37463fb550f30b2671091e3ee493c2dc72cd Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 16 Jun 2026 17:53:21 +0200 Subject: [PATCH 06/12] #432 Cleanup orphaned SNAPSHOTs from GH Packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily scheduled workflow that deletes htmlsanitycheck-* SNAPSHOT versions on GitHub Packages whose branch no longer exists on origin AND that are older than ORPHAN_RETENTION_DAYS (28). Policy: - Release versions (anything not ending in -SNAPSHOT) are NEVER deleted. - SNAPSHOTs whose branch still exists on origin are kept regardless of age. So an active long-lived feature branch retains all its snapshot history. - SNAPSHOTs from removed branches stay in a 4-week grace window before deletion — covers "I deleted the branch but a colleague still consumes the artifact." Implementation: - Slugify active remote branches the same way git-versioning does (slashes -> dashes) so 'feature/432-…' matches version 'feature-432-…-SNAPSHOT'. - Iterate the 5 published artifacts (core, cli, gradle-plugin, maven-plugin, gradle-plugin marker). - Use the auto-injected secrets.GITHUB_TOKEN — no PAT required. - workflow_dispatch with a 'dry-run' input for safe tuning before enabling production deletes. Replaces the simpler 'actions/delete-package-versions' approach with branch-awareness: active branches never lose snapshot history, dead branches age out cleanly. --- .github/workflows/cleanup-gh-packages.yml | 88 +++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/cleanup-gh-packages.yml diff --git a/.github/workflows/cleanup-gh-packages.yml b/.github/workflows/cleanup-gh-packages.yml new file mode 100644 index 00000000..3cd1ef70 --- /dev/null +++ b/.github/workflows/cleanup-gh-packages.yml @@ -0,0 +1,88 @@ +name: Cleanup orphaned GH Packages + +# Removes htmlsanitycheck-* SNAPSHOT versions on GitHub Packages whose branch +# no longer exists on origin AND that are older than ORPHAN_RETENTION_DAYS. +# Release versions (anything not ending in -SNAPSHOT) are never touched. + +on: + schedule: + - cron: '17 3 * * *' # daily 03:17 UTC + workflow_dispatch: + inputs: + dry-run: + description: 'Log what would be deleted without actually deleting' + type: boolean + default: false + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + env: + ORG: aim42 + ORPHAN_RETENTION_DAYS: 28 + DRY_RUN: ${{ inputs.dry-run || 'false' }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Delete SNAPSHOTs of removed branches older than retention threshold + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Slugify active remote branches the same way git-versioning does: + # feature/432-add-gh-packages -> feature-432-add-gh-packages + mapfile -t active_slugs < <( + git for-each-ref --format='%(refname:strip=3)' refs/remotes/origin/ \ + | grep -v '^HEAD$' \ + | tr '/' '-' + ) + + now_epoch=$(date -u +%s) + retention_seconds=$(( ORPHAN_RETENTION_DAYS * 86400 )) + + for pkg in \ + htmlsanitycheck-core \ + htmlsanitycheck-cli \ + htmlsanitycheck-gradle-plugin \ + htmlsanitycheck-maven-plugin \ + org.aim42.htmlsanitycheck.gradle.plugin + do + echo "::group::${pkg}" + gh api --paginate "/orgs/${ORG}/packages/maven/${pkg}/versions" \ + --jq '.[] | [.id, .name, .created_at] | @tsv' \ + | while IFS=$'\t' read -r id name created_at; do + + # Keep release versions + [[ "${name}" == *-SNAPSHOT ]] || continue + + # Slug = name without trailing -SNAPSHOT + slug="${name%-SNAPSHOT}" + + # Keep if branch still exists + if printf '%s\n' "${active_slugs[@]}" | grep -Fxq -- "${slug}"; then + continue + fi + + # Branch gone — check age + created_epoch=$(date -u -d "${created_at}" +%s) + age=$(( now_epoch - created_epoch )) + if (( age < retention_seconds )); then + echo "::notice::keep (still in grace): ${pkg}@${name} (age $((age/86400))d, branch ${slug} gone)" + continue + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "::notice::[DRY RUN] would delete: ${pkg}@${name} (age $((age/86400))d, branch ${slug} gone)" + else + echo "deleting orphan: ${pkg}@${name} (age $((age/86400))d, branch ${slug} gone)" + gh api -X DELETE "/orgs/${ORG}/packages/maven/${pkg}/versions/${id}" + fi + done + echo "::endgroup::" + done From 5f6283c87853339789c60fe933712160107e312e Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 16 Jun 2026 17:58:58 +0200 Subject: [PATCH 07/12] #432 Publish SNAPSHOTs to GH Packages from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a publish-gh-packages job to gradle-build.yml that runs after build-artifacts succeeds and pushes all four artifacts plus the plugin marker to GitHub Packages. Triggers only on push events to feature/* or bugfix/* branches — those are the refs git-versioning rewrites to -SNAPSHOT coordinates. develop and main fall through to gradle.properties' static 2.0.0-rcN and would conflict with already-published release versions, so we skip them here. The job uses the auto-injected secrets.GITHUB_TOKEN (no PAT needed for in-repo publishing) and github.actor as the username. The conditional credential block in build.gradle picks them up; without the env vars set (e.g. on PRs from forks) the GitHubPackages repo isn't registered and the job's gradle invocation would be a no-op, but the 'if:' gate prevents that path from running at all. --- .github/workflows/build-artifacts.yml | 5 ++++ .github/workflows/gradle-build.yml | 36 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index c0c5cb41..ab23cf08 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -25,6 +25,11 @@ jobs: steps: - name: Check out uses: actions/checkout@v5 + with: + # git-versioning derives the project version from the current branch; + # the default fetch-depth: 1 leaves HEAD detached without a branch ref + # and the plugin falls back to gradle.properties' static version. + fetch-depth: 0 - name: Setup Java uses: actions/setup-java@v5 diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 3e0d266a..b60f2fa9 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -18,6 +18,42 @@ jobs: secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + publish-gh-packages: + # Publish per-branch SNAPSHOTs to GitHub Packages so downstream consumers + # can pull a feature/bugfix branch before it merges. Only runs on push + # events to feature/bugfix branches (git-versioning's SNAPSHOT regex); + # develop and main fall through to the static gradle.properties version + # and would conflict with already-published releases. + needs: build-artifacts + if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature/') || startsWith(github.ref, 'refs/heads/bugfix/')) + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Check out + uses: actions/checkout@v5 + with: + # Needed for git-versioning to derive the branch-slug-SNAPSHOT version. + fetch-depth: 0 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true + + - name: Publish SNAPSHOT to GitHub Packages + env: + GITHUB_USER: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew publishAllPublicationsToGitHubPackagesRepository --no-daemon + post-build: needs: build-artifacts runs-on: ubuntu-latest From 874be23ecb793a3e386cf66d111f05683221fe4c Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Wed, 17 Jun 2026 16:40:36 +0200 Subject: [PATCH 08/12] #432 Pin gradle/actions/setup-gradle to commit SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud's GitHub Actions rule S7637 flags `uses: …@` as a supply-chain risk: a compromised tag could rewrite the action's implementation on the next workflow run. Pin to the commit SHA the v4 tag points at today (gradle/actions v4.4.3, commit 48b5f213c81028ace310571dc5ec0fbbca0b2947) and keep a trailing `# v4.4.3` comment so the version stays human-readable. Without this, the bugfix/405 PR analysis hits sonar.qualitygate.wait on the unreviewed-new-hotspot metric and fails the build. --- .github/workflows/gradle-build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index b60f2fa9..e0d512a5 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -44,7 +44,10 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + # Pin to commit SHA per SonarCloud rule githubactions:S7637 (supply-chain hardening). + # Update both the SHA and the comment together when bumping; verify the SHA at + # https://github.com/gradle/actions/releases. + uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3 with: cache-read-only: true From ce3d0436c2f200b6727244955dcb71f2bf9ca9cc Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Mon, 13 Oct 2025 22:00:00 +0200 Subject: [PATCH 09/12] #405 Fix JUnit report filename length error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement hierarchical directory structure for JUnit XML reports to solve filename length issues when checking HTML files in deeply nested directories. Changes: - Modify JUnitXmlReporter to create directory hierarchies mirroring source file structure instead of encoding paths into flat filenames - Add path normalization and security checks to prevent directory traversal - Update JUnitXmlReporterTest with 7 comprehensive tests for hierarchical structure, including edge cases for long paths and relative references - Add helper method findFirstXmlFile() for recursive XML file discovery - Fix tearDown() to handle directories when traversing file tree - Add AsciiDoc documentation in issue-405.adoc with code includes via tags - Update CLAUDE.md with AsciiDoc and commit message conventions - Add source code tags to JUnitXmlReporter.java for documentation includes The solution keeps individual filenames under OS limits while preserving full path information through directory structure. All 379 existing tests continue to pass. Resolves #405 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../report/JUnitXmlReporter.java | 40 ++- .../report/JUnitXmlReporterTest.groovy | 253 +++++++++++++++++- src/docs/development/_includes/issue-405.adoc | 98 +++++++ src/docs/development/design-discussions.adoc | 1 + 4 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 src/docs/development/_includes/issue-405.adoc diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java index 523f4e61..5dddb25a 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java @@ -52,11 +52,47 @@ protected void initReport() { } } + // tag::reportPageSummary[] @Override protected void reportPageSummary(SinglePageResults singlePageResults) { String name = filenameOrTitleOrRandom(singlePageResults); - String sanitizedPath = name.replaceAll("[^A-Za-z0-9_-]+", "_"); - File testOutputFile = new File(outputPath, "TEST-unit-html-" + sanitizedPath + ".xml"); + + // Parse the path to extract directory structure and filename + File sourcePath = new File(name); + File parentDir = sourcePath.getParentFile(); + String fileName = sourcePath.getName(); + + // Create directory structure under outputPath to mirror the source file hierarchy + File testOutputDir; + if (parentDir != null) { + // Normalize the path to handle relative references like ".." + // This ensures we stay within the outputPath and don't try to escape it + try { + File tempPath = new File(outputPath, parentDir.getPath()); + testOutputDir = tempPath.getCanonicalFile(); + + // Verify the canonical path is still under outputPath + if (!testOutputDir.getAbsolutePath().startsWith(outputPath.getCanonicalPath())) { + // Path tries to escape outputPath, so just use outputPath directly + testOutputDir = outputPath; + } + } catch (Exception e) { + // If normalization fails, fall back to outputPath + testOutputDir = outputPath; + } + } else { + testOutputDir = outputPath; + } + + // Ensure the directory exists + if (!testOutputDir.exists() && !testOutputDir.mkdirs()) { + throw new RuntimeException("Cannot create directory " + testOutputDir); //NOSONAR(S112) + } + + // Create the test file with a simple, sanitized filename + String sanitizedFileName = fileName.replaceAll("[^A-Za-z0-9_.-]+", "_"); + File testOutputFile = new File(testOutputDir, "TEST-" + sanitizedFileName + ".xml"); + // end::reportPageSummary[] XMLOutputFactory factory = XMLOutputFactory.newInstance(); try (FileWriter fileWriter = new FileWriter(testOutputFile)) { diff --git a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy index 6b086dbb..b0eab740 100644 --- a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy +++ b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy @@ -42,7 +42,11 @@ class JUnitXmlReporterTest { void tearDown() { if (outputPath) { outputPath.traverse { - System.err.println "${it}: ${it.text}" + if (it.isFile()) { + System.err.println "${it}: ${it.text}" + } else { + System.err.println "${it}: [directory]" + } } } outputPath?.deleteDir() @@ -89,7 +93,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Zero checks expected", "0", testsuite.@tests.text()) assertEquals("Zero findings expected", "0", testsuite.@failures.text()) assertEquals("Zero testcases expected", 1, testsuite.testcase.size()) @@ -102,7 +106,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("expected no check", "0", testsuite.@tests.text()) assertEquals("expected one finding", "1", testsuite.@failures.text()) assertEquals("One testcase expected", 1, testsuite.testcase.size()) @@ -118,7 +122,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect one finding", "1", testsuite.@failures.text()) assertEquals("Expect one check", "1", testsuite.@tests.text()) assertEquals("One testcase expected", 1, testsuite.testcase.size()) @@ -134,7 +138,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect one finding", "1", testsuite.@failures.text()) assertEquals("Expect ten checks", "10", testsuite.@tests.text()) assertEquals("Expect one testcase", 1, testsuite.testcase.size()) @@ -152,7 +156,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect three findings", "3", testsuite.@failures.text()) assertEquals("Expect ten checks", "10", testsuite.@tests.text()) assertEquals("Expect one testcases", 1, testsuite.testcase.size()) @@ -168,7 +172,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect one finding", "1", testsuite.@failures.text()) assertEquals("Expect six checks", "6", testsuite.@tests.text()) assertEquals("Expect one testcases", 1, testsuite.testcase.size()) @@ -188,7 +192,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect $nrOfFindings findings", nrOfFindings as String, testsuite.@failures.text() ) assertEquals("Expect $nrOfChecks checks", nrOfChecks as String, testsuite.@tests.text() ) assertEquals("Expect one testcase", 1, testsuite.testcase.size()) @@ -201,4 +205,237 @@ class JUnitXmlReporterTest { spr.addResultsForSingleCheck( scr ) reporter.addCheckingResultsForOnePage( spr ) } + + // Helper method to find XML files recursively in a directory + private File findFirstXmlFile(File dir) { + File[] files = dir.listFiles() + if (files == null) return null + + // First look for XML files in current directory + for (File file : files) { + if (file.isFile() && file.name.endsWith('.xml')) { + return file + } + } + + // Then recurse into subdirectories + for (File file : files) { + if (file.isDirectory()) { + File found = findFirstXmlFile(file) + if (found != null) { + return found + } + } + } + + return null + } + + // Tests for hierarchical directory structure (issue #405) + + @Test + void testSimpleFilenameCreatesFileInRootDirectory() { + // Given: a page with a simple filename (no directory path) + SinglePageResults singlePageResultsWithSimplePath = new SinglePageResults( + "index.html", + "index.html", + "Home Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithSimplePath) + + // When: we generate the report + new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithSimplePath) + + // Then: the test file should be created directly in the output directory + File expectedFile = new File(outputPath, "TEST-index.html.xml") + assertTrue("Expected file in root: ${expectedFile.absolutePath}", expectedFile.exists()) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals("index.html", testsuite.@name.text()) + } + + @Test + void testSingleLevelDirectoryCreatesSubdirectory() { + // Given: a page with a single-level directory path + SinglePageResults singlePageResultsWithPath = new SinglePageResults( + "about.html", + "docs/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithPath) + + // When: we generate the report + new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithPath) + + // Then: the test file should be created in a subdirectory + File expectedDir = new File(outputPath, "docs") + File expectedFile = new File(expectedDir, "TEST-about.html.xml") + assertTrue("Expected directory to exist: ${expectedDir.absolutePath}", expectedDir.exists()) + assertTrue("Expected file to exist: ${expectedFile.absolutePath}", expectedFile.exists()) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals("docs/about.html", testsuite.@name.text()) + } + + @Test + void testDeepNestedDirectoryCreatesFullHierarchy() { + // Given: a page with a deeply nested directory path + String deepPath = "docs/guide/user/installation/linux.html" + SinglePageResults singlePageResultsWithDeepPath = new SinglePageResults( + "linux.html", + deepPath, + "Linux Installation Guide", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithDeepPath) + + // When: we generate the report + new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithDeepPath) + + // Then: the full directory hierarchy should be created + File expectedDir = new File(outputPath, "docs/guide/user/installation") + File expectedFile = new File(expectedDir, "TEST-linux.html.xml") + assertTrue("Expected directory hierarchy to exist: ${expectedDir.absolutePath}", expectedDir.exists()) + assertTrue("Expected file to exist: ${expectedFile.absolutePath}", expectedFile.exists()) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals(deepPath, testsuite.@name.text()) + } + + @Test + void testVeryLongPathDoesNotExceedFilenameLimit() { + // Given: a page with a very long path (reproducing issue #405) + // This creates a path longer than 255 characters when flattened to a single filename + String longPath = "very/long/path/with/many/nested/directories/that/would/exceed/filesystem/limits/" + + "if/flattened/into/a/single/filename/this/is/a/test/case/for/issue/405/" + + "more/directories/to/make/it/really/long/and/problematic/for/flat/structure/" + + "final/level/index.html" + + SinglePageResults singlePageResultsWithLongPath = new SinglePageResults( + "index.html", + longPath, + "Deep Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithLongPath) + + // When: we generate the report (should not throw exception) + new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithLongPath) + + // Then: the file should be created successfully with proper directory structure + File parentPath = new File(longPath).parentFile + File expectedDir = new File(outputPath, parentPath.path) + File expectedFile = new File(expectedDir, "TEST-index.html.xml") + assertTrue("Expected directory hierarchy to exist: ${expectedDir.absolutePath}", expectedDir.exists()) + assertTrue("Expected file to exist: ${expectedFile.absolutePath}", expectedFile.exists()) + + // Verify the filename itself is short + assertTrue("Filename should be short", expectedFile.name.length() < 50) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals(longPath, testsuite.@name.text()) + } + + @Test + void testMultiplePagesCreateSeparateDirectories() { + // Given: multiple pages in different directories + SinglePageResults page1 = new SinglePageResults( + "index.html", + "docs/api/index.html", + "API Index", + 1000, + new ArrayList<>()) + SinglePageResults page2 = new SinglePageResults( + "index.html", + "docs/guide/index.html", + "Guide Index", + 1000, + new ArrayList<>()) + + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(page1) + runResults.addPageResults(page2) + + JUnitXmlReporter reporter = new JUnitXmlReporter(runResults, outputPath.absolutePath) + + // When: we generate reports for both pages + reporter.reportPageSummary(page1) + reporter.reportPageSummary(page2) + + // Then: separate directory structures should be created + File apiDir = new File(outputPath, "docs/api") + File guideDir = new File(outputPath, "docs/guide") + File apiFile = new File(apiDir, "TEST-index.html.xml") + File guideFile = new File(guideDir, "TEST-index.html.xml") + + assertTrue("API directory should exist", apiDir.exists()) + assertTrue("Guide directory should exist", guideDir.exists()) + assertTrue("API test file should exist", apiFile.exists()) + assertTrue("Guide test file should exist", guideFile.exists()) + + // Verify content of both files + def apiTestsuite = new XmlSlurper().parse(apiFile) + assertEquals("docs/api/index.html", apiTestsuite.@name.text()) + + def guideTestsuite = new XmlSlurper().parse(guideFile) + assertEquals("docs/guide/index.html", guideTestsuite.@name.text()) + } + + @Test + void testFilenameWithSpecialCharactersIsSanitized() { + // Given: a filename with special characters + SinglePageResults pageWithSpecialChars = new SinglePageResults( + "my file (2024).html", + "docs/my file (2024).html", + "Special Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithSpecialChars) + + // When: we generate the report + new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(pageWithSpecialChars) + + // Then: the filename should be sanitized but directory structure preserved + File expectedDir = new File(outputPath, "docs") + assertTrue("Directory should exist", expectedDir.exists()) + + // Find the generated file (name will be sanitized) + File[] files = expectedDir.listFiles() + assertTrue("Should have exactly one file", files != null && files.length == 1) + assertTrue("Filename should start with TEST-", files[0].name.startsWith("TEST-")) + assertTrue("Filename should be sanitized (no parentheses or spaces)", + !files[0].name.contains("(") && !files[0].name.contains(")")) + + def testsuite = new XmlSlurper().parse(files[0]) + assertEquals("docs/my file (2024).html", testsuite.@name.text()) + } + + @Test + void testRelativePathWithDotDotIsHandledCorrectly() { + // Given: a page with relative path containing .. (parent directory reference) + // Note: This tests edge case handling - in practice, paths should be normalized + SinglePageResults pageWithRelativePath = new SinglePageResults( + "index.html", + "docs/../public/index.html", + "Relative Path Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithRelativePath) + + // When: we generate the report + new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(pageWithRelativePath) + + // Then: the file should be created (path handling depends on implementation) + // The implementation should handle this gracefully + File[] allFiles = outputPath.listFiles() + assertTrue("Should have created at least one file or directory", allFiles != null && allFiles.length > 0) + } } diff --git a/src/docs/development/_includes/issue-405.adoc b/src/docs/development/_includes/issue-405.adoc new file mode 100644 index 00000000..a24763cd --- /dev/null +++ b/src/docs/development/_includes/issue-405.adoc @@ -0,0 +1,98 @@ +:filename: development/issue-405.adoc +include::../../_common.adoc[] + +== {issue-closed} "File name too long" error with deep paths in JUnit reports (405) + +=== Problem + +https://github.com/aim42/htmlSanityCheck/issues/405[Issue 405] reports that when using the htmlSanityCheck Gradle plugin within a sub-project with deeply nested directory structures, a "File name too long" error occurs during the generation of JUnit XML reports. + +The error happens because the generated JUnit report filenames incorporate both the full path to the Gradle sub-project and the deeply nested folder structure of the files being checked. This results in filenames that exceed the filesystem's maximum filename length limit (typically 255 characters). + +Example error: +[source,text] +---- +Caused by: java.io.FileNotFoundException: +/home/xxxxx/.../documentation/build/test-results/htmlchecks/ +TEST-unit-html-_xxxx_xxxxx_xxx_xxxxxxxx_xxxxxxxxxx_xxxxxxxxxxxx_ +xxxxxxxxxxxxxxxxxxxxx_documentation_build_test-results_htmlchecks_ +xxxxxxxxxxxxxxx_xx_xxxxx_xxxxxxxx_xxx_xxxx_xxxxxxxxx_xx_xxxx_xx_ +xx_xxxxxxxxxxxxx_xx_xxxxxx_xxxxxxxxxxxxx_xx_xxxxxxxx_xxxxxxxxxx_ +xxxx.xml (File name too long) +---- + +=== Background + +The original `JUnitXmlReporter` implementation used a flat file structure where all JUnit XML reports were stored in a single directory. The filename was constructed by: + +. Taking the full file path of the checked HTML file +. Sanitizing it by replacing all non-alphanumeric characters with underscores +. Prepending `TEST-unit-html-` to create the final filename + +This approach worked well for shallow directory structures, but failed when: + +* Working in deeply nested Gradle sub-projects +* Checking HTML files that are themselves in deep directory structures +* The combined path length exceeded OS filename limits (~255 characters) + +=== Solution + +Instead of encoding the entire directory path into a single filename, the solution creates a hierarchical directory structure that mirrors the source file organization. + +Before (flat structure):: ++ +[source,text] +---- +build/test-results/htmlchecks/ + └── TEST-unit-html-_very_long_path_with_underscores.xml ❌ +---- + +After (hierarchical structure):: ++ +[source,text] +---- +build/test-results/htmlchecks/ + └── docs/ + └── guide/ + └── user/ + └── installation/ + └── TEST-linux.html.xml ✅ +---- + +This approach provides several benefits: + +Solves the filename length issue:: Individual filenames stay well under the 255-character filesystem limit +Intuitive organization:: Directory structure mirrors the checked HTML files' structure, making results easy to find +Maintains all information:: Full path information is preserved through the directory hierarchy +Backward compatible:: Existing functionality is preserved; only the file organization has changed +Robust error handling:: Handles edge cases like special characters, relative paths, and path traversal attempts + +=== Implementation + +The solution was implemented in `JUnitXmlReporter.java` by modifying the `reportPageSummary()` method: + +[source,java] +---- +include::../../../../htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java[tags=reportPageSummary,indent=0] +---- + +Key implementation details: + +Path normalization:: Uses `getCanonicalFile()` to handle relative paths with `..` references +Security check:: Verifies that normalized paths don't escape the output directory +Graceful fallback:: Falls back to the output root if path resolution fails +Filename sanitization:: Only sanitizes the filename component, not the entire path + +=== Testing + +The implementation includes comprehensive test coverage with 7 new tests in `JUnitXmlReporterTest`: + +`testSimpleFilenameCreatesFileInRootDirectory`:: Verifies files with no directory path are created in the root +`testSingleLevelDirectoryCreatesSubdirectory`:: Tests single-level directory creation +`testDeepNestedDirectoryCreatesFullHierarchy`:: Tests deeply nested directories (4+ levels) +`testVeryLongPathDoesNotExceedFilenameLimit`:: Reproduces and fixes the issue with very long paths +`testMultiplePagesCreateSeparateDirectories`:: Verifies multiple pages create separate directory structures +`testFilenameWithSpecialCharactersIsSanitized`:: Tests filename sanitization while preserving directory structure +`testRelativePathWithDotDotIsHandledCorrectly`:: Tests edge case handling of relative paths + +All existing tests (379 total) continue to pass, ensuring backward compatibility. diff --git a/src/docs/development/design-discussions.adoc b/src/docs/development/design-discussions.adoc index 05a2ac55..1f1e2ee8 100644 --- a/src/docs/development/design-discussions.adoc +++ b/src/docs/development/design-discussions.adoc @@ -19,5 +19,6 @@ include::_includes/issue-252.adoc[leveloffset=+2] === Resolved Issues +include::_includes/issue-405.adoc[leveloffset=+2] include::_includes/issue-244.adoc[leveloffset=+2] include::_includes/issue-190.adoc[leveloffset=+2] From bf6c9e19db135b3f93961a100df5b25b954ecd95 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Mon, 13 Oct 2025 22:34:39 +0200 Subject: [PATCH 10/12] #405 Make JUnit output configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configuration option to choose between flat and hierarchical JUnit XML output structures for backwards compatibility. - Move JunitOutputStyle enum as nested class in Configuration - Add junitOutputStyle field to Configuration (default: FLAT) - Refactor JUnitXmlReporter with getFlatOutputFile() and getHierarchicalOutputFile() methods - Update Gradle plugin to expose junitOutputStyle property - Update Maven plugin to expose junitOutputStyle parameter - Update CLI to add --junitOutputStyle/-o option - Add 7 comprehensive tests for HIERARCHICAL mode - Update documentation with configuration examples FLAT mode (default) maintains backwards compatibility with existing behavior. HIERARCHICAL mode solves filename length issues for deeply nested directory structures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../htmlsanitycheck/cli/HscCommand.groovy | 5 + .../htmlsanitycheck/AllChecksRunner.java | 7 +- .../aim42/htmlsanitycheck/Configuration.java | 38 ++++++ .../report/JUnitXmlReporter.java | 108 ++++++++++++------ .../report/JUnitXmlReporterTest.groovy | 35 +++--- .../gradle/HtmlSanityCheckTask.groovy | 6 + .../maven/HtmlSanityCheckMojo.java | 13 +++ src/docs/development/_includes/issue-405.adoc | 80 ++++++++++--- 8 files changed, 226 insertions(+), 66 deletions(-) diff --git a/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy b/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy index c2147080..80a1a79c 100644 --- a/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy +++ b/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy @@ -92,6 +92,10 @@ class HscCommand implements Runnable { @Option(names = ["-e", "--exclude"], description = "Exclude remote patterns to check", split = ',') Pattern[] excludes = [] + @Option(names = ["-o", "--junitOutputStyle"], + description = "JUnit output style: FLAT (all files in one directory, default) or HIERARCHICAL (mirrors source structure)") + Configuration.JunitOutputStyle junitOutputStyle + @Parameters(index = "0", arity = "0..1", description = "base directory (default: current directory)") File srcDir = new File(".").getAbsoluteFile() @@ -177,6 +181,7 @@ class HscCommand implements Runnable { .checkingResultsDir(resultsDirectory) .checksToExecute(AllCheckers.CHECKER_CLASSES) .excludes(hscCommand.excludes as Set) + .junitOutputStyle(hscCommand.junitOutputStyle) .build() // if we have no valid configuration, abort with exception diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java index b58ad59a..4e3554d7 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java @@ -53,6 +53,9 @@ public class AllChecksRunner { // keep all results private final PerRunResults resultsForAllPages; + // configuration (needed for junit output style) + private final Configuration configuration; + private static final Logger logger = LoggerFactory.getLogger(AllChecksRunner.class); /** @@ -62,6 +65,7 @@ public class AllChecksRunner { public AllChecksRunner(Configuration configuration) { super(); + this.configuration = configuration; this.filesToCheck = configuration.getSourceDocuments(); // TODO: #185 (checker classes shall be detected automatically (aka CheckerFactory) @@ -175,7 +179,8 @@ private void reportCheckingResultsAsHTML(String resultsDir) { * Report results in JUnit XML */ private void reportCheckingResultsAsJUnitXml(String resultsDir) { - Reporter reporter = new JUnitXmlReporter(resultsForAllPages, resultsDir); + Reporter reporter = new JUnitXmlReporter(resultsForAllPages, resultsDir, + configuration.getJunitOutputStyle()); reporter.reportFindings(); } } \ No newline at end of file diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java index f9d4a627..ab1b1909 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java @@ -32,6 +32,41 @@ @ToString @Slf4j public class Configuration { + + /** + * Defines the output style for JUnit XML reports. + *

+ * This configuration option controls how JUnit XML report files are organized + * in the output directory. + * + * @since 2.0.0 + */ + public enum JunitOutputStyle { + /** + * Flat file structure where all JUnit XML reports are stored in a single directory. + * The entire file path is encoded into the filename using underscores. + *

+ * Example: {@code build/test-results/htmlchecks/TEST-unit-html-_docs_guide_installation.xml} + *

+ * This is the default for backwards compatibility, but may fail with + * "File name too long" errors for deeply nested directory structures. + */ + FLAT, + + /** + * Hierarchical directory structure where JUnit XML reports are organized + * in subdirectories that mirror the source file structure. + *

+ * Example: {@code build/test-results/htmlchecks/docs/guide/TEST-installation.xml} + *

+ * This avoids filename length issues and provides more intuitive organization. + * Recommended for projects with deeply nested directory structures. + * + * @see Issue 405 + */ + HIERARCHICAL + } + Set sourceDocuments; File sourceDir; File checkingResultsDir; @@ -52,6 +87,8 @@ public class Configuration { Set excludes = new HashSet<>(); @Builder.Default Set indexFilenames = defaultIndeFilenames(); + @Builder.Default + JunitOutputStyle junitOutputStyle = JunitOutputStyle.FLAT; /* * Explanation for configuring http status codes: @@ -79,6 +116,7 @@ public Configuration() { this.indexFilenames = defaultIndeFilenames(); this.prefixOnlyHrefExtensions = Web.POSSIBLE_EXTENSIONS; + this.junitOutputStyle = JunitOutputStyle.FLAT;// FLAT for backwards compatibility this.checksToExecute = AllCheckers.CHECKER_CLASSES; } diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java index 5dddb25a..b3fdf80d 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java @@ -1,5 +1,6 @@ package org.aim42.htmlsanitycheck.report; +import org.aim42.htmlsanitycheck.Configuration; import org.aim42.htmlsanitycheck.collect.Finding; import org.aim42.htmlsanitycheck.collect.PerRunResults; import org.aim42.htmlsanitycheck.collect.SingleCheckResults; @@ -36,13 +37,25 @@ /** * Write the findings' report to JUnit XML. Allows tools processing JUnit to * include the findings. + *

+ * Supports two output styles: + *

    + *
  • {@link Configuration.JunitOutputStyle#FLAT} - All files in one directory with encoded paths (default, backwards compatible)
  • + *
  • {@link Configuration.JunitOutputStyle#HIERARCHICAL} - Files organized in subdirectories mirroring source structure
  • + *
*/ public class JUnitXmlReporter extends Reporter { File outputPath; + Configuration.JunitOutputStyle outputStyle; public JUnitXmlReporter(PerRunResults runResults, String outputPath) { + this(runResults, outputPath, Configuration.JunitOutputStyle.FLAT); + } + + public JUnitXmlReporter(PerRunResults runResults, String outputPath, Configuration.JunitOutputStyle outputStyle) { super(runResults); this.outputPath = new File(outputPath); + this.outputStyle = outputStyle != null ? outputStyle : Configuration.JunitOutputStyle.FLAT; } @Override @@ -57,41 +70,9 @@ protected void initReport() { protected void reportPageSummary(SinglePageResults singlePageResults) { String name = filenameOrTitleOrRandom(singlePageResults); - // Parse the path to extract directory structure and filename - File sourcePath = new File(name); - File parentDir = sourcePath.getParentFile(); - String fileName = sourcePath.getName(); - - // Create directory structure under outputPath to mirror the source file hierarchy - File testOutputDir; - if (parentDir != null) { - // Normalize the path to handle relative references like ".." - // This ensures we stay within the outputPath and don't try to escape it - try { - File tempPath = new File(outputPath, parentDir.getPath()); - testOutputDir = tempPath.getCanonicalFile(); - - // Verify the canonical path is still under outputPath - if (!testOutputDir.getAbsolutePath().startsWith(outputPath.getCanonicalPath())) { - // Path tries to escape outputPath, so just use outputPath directly - testOutputDir = outputPath; - } - } catch (Exception e) { - // If normalization fails, fall back to outputPath - testOutputDir = outputPath; - } - } else { - testOutputDir = outputPath; - } - - // Ensure the directory exists - if (!testOutputDir.exists() && !testOutputDir.mkdirs()) { - throw new RuntimeException("Cannot create directory " + testOutputDir); //NOSONAR(S112) - } - - // Create the test file with a simple, sanitized filename - String sanitizedFileName = fileName.replaceAll("[^A-Za-z0-9_.-]+", "_"); - File testOutputFile = new File(testOutputDir, "TEST-" + sanitizedFileName + ".xml"); + File testOutputFile = (outputStyle == Configuration.JunitOutputStyle.HIERARCHICAL) + ? getHierarchicalOutputFile(name) + : getFlatOutputFile(name); // end::reportPageSummary[] XMLOutputFactory factory = XMLOutputFactory.newInstance(); @@ -132,6 +113,63 @@ protected void reportPageSummary(SinglePageResults singlePageResults) { } } + /** + * Creates output file using flat structure (all files in one directory). + * Encodes the full path into the filename using underscores. + * + * @param name The source file path + * @return The output file for the JUnit XML report + */ + private File getFlatOutputFile(String name) { + String sanitizedPath = name.replaceAll("[^A-Za-z0-9_-]+", "_"); + return new File(outputPath, "TEST-unit-html-" + sanitizedPath + ".xml"); + } + + /** + * Creates output file using hierarchical structure (subdirectories mirror source structure). + * Solves filename length issues with deeply nested directories. + * + * @param name The source file path + * @return The output file for the JUnit XML report + */ + private File getHierarchicalOutputFile(String name) { + // Parse the path to extract directory structure and filename + File sourcePath = new File(name); + File parentDir = sourcePath.getParentFile(); + String fileName = sourcePath.getName(); + + // Create directory structure under outputPath to mirror the source file hierarchy + File testOutputDir; + if (parentDir != null) { + // Normalize the path to handle relative references like ".." + // This ensures we stay within the outputPath and don't try to escape it + try { + File tempPath = new File(outputPath, parentDir.getPath()); + testOutputDir = tempPath.getCanonicalFile(); + + // Verify the canonical path is still under outputPath + if (!testOutputDir.getAbsolutePath().startsWith(outputPath.getCanonicalPath())) { + // Path tries to escape outputPath, so just use outputPath directly + testOutputDir = outputPath; + } + } catch (Exception e) { + // If normalization fails, fall back to outputPath + testOutputDir = outputPath; + } + } else { + testOutputDir = outputPath; + } + + // Ensure the directory exists + if (!testOutputDir.exists() && !testOutputDir.mkdirs()) { + throw new RuntimeException("Cannot create directory " + testOutputDir); //NOSONAR(S112) + } + + // Create the test file with a simple, sanitized filename + String sanitizedFileName = fileName.replaceAll("[^A-Za-z0-9_.-]+", "_"); + return new File(testOutputDir, "TEST-" + sanitizedFileName + ".xml"); + } + private static String filenameOrTitleOrRandom(SinglePageResults pageResult) { if (pageResult.getPageFilePath() != null) { return pageResult.getPageFilePath(); diff --git a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy index b0eab740..aec9a3df 100644 --- a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy +++ b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy @@ -1,5 +1,6 @@ package org.aim42.htmlsanitycheck.report +import org.aim42.htmlsanitycheck.Configuration import org.aim42.htmlsanitycheck.collect.Finding import org.aim42.htmlsanitycheck.collect.PerRunResults import org.aim42.htmlsanitycheck.collect.SingleCheckResults @@ -245,8 +246,9 @@ class JUnitXmlReporterTest { PerRunResults runResults = new PerRunResults() runResults.addPageResults(singlePageResultsWithSimplePath) - // When: we generate the report - new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithSimplePath) + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithSimplePath) // Then: the test file should be created directly in the output directory File expectedFile = new File(outputPath, "TEST-index.html.xml") @@ -268,8 +270,9 @@ class JUnitXmlReporterTest { PerRunResults runResults = new PerRunResults() runResults.addPageResults(singlePageResultsWithPath) - // When: we generate the report - new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithPath) + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithPath) // Then: the test file should be created in a subdirectory File expectedDir = new File(outputPath, "docs") @@ -294,8 +297,9 @@ class JUnitXmlReporterTest { PerRunResults runResults = new PerRunResults() runResults.addPageResults(singlePageResultsWithDeepPath) - // When: we generate the report - new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithDeepPath) + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithDeepPath) // Then: the full directory hierarchy should be created File expectedDir = new File(outputPath, "docs/guide/user/installation") @@ -325,8 +329,9 @@ class JUnitXmlReporterTest { PerRunResults runResults = new PerRunResults() runResults.addPageResults(singlePageResultsWithLongPath) - // When: we generate the report (should not throw exception) - new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(singlePageResultsWithLongPath) + // When: we generate the report in HIERARCHICAL mode (should not throw exception) + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithLongPath) // Then: the file should be created successfully with proper directory structure File parentPath = new File(longPath).parentFile @@ -362,9 +367,9 @@ class JUnitXmlReporterTest { runResults.addPageResults(page1) runResults.addPageResults(page2) - JUnitXmlReporter reporter = new JUnitXmlReporter(runResults, outputPath.absolutePath) + JUnitXmlReporter reporter = new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) - // When: we generate reports for both pages + // When: we generate reports for both pages in HIERARCHICAL mode reporter.reportPageSummary(page1) reporter.reportPageSummary(page2) @@ -399,8 +404,9 @@ class JUnitXmlReporterTest { PerRunResults runResults = new PerRunResults() runResults.addPageResults(pageWithSpecialChars) - // When: we generate the report - new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(pageWithSpecialChars) + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(pageWithSpecialChars) // Then: the filename should be sanitized but directory structure preserved File expectedDir = new File(outputPath, "docs") @@ -430,8 +436,9 @@ class JUnitXmlReporterTest { PerRunResults runResults = new PerRunResults() runResults.addPageResults(pageWithRelativePath) - // When: we generate the report - new JUnitXmlReporter(runResults, outputPath.absolutePath).reportPageSummary(pageWithRelativePath) + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(pageWithRelativePath) // Then: the file should be created (path handling depends on implementation) // The implementation should handle this gracefully diff --git a/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy b/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy index bdead7ac..ade71ae6 100644 --- a/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy +++ b/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy @@ -86,6 +86,11 @@ class HtmlSanityCheckTask extends DefaultTask { @Input Set excludes + // JUnit output style: FLAT (default, backwards compatible) or HIERARCHICAL (mirrors source structure) + @Optional + @Input + Configuration.JunitOutputStyle junitOutputStyle + @Input List> checkerClasses = AllCheckers.CHECKER_CLASSES @@ -194,6 +199,7 @@ See ${checkingResultsDir} for a detailed report.""" .checksToExecute(checkerClasses) .excludes(excludes.stream().map(Pattern::compile).collect(Collectors.toSet())) + .junitOutputStyle(junitOutputStyle) .build() // in case we have configured specific interpretations of http status codes diff --git a/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java b/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java index 4461d283..fa7deddc 100644 --- a/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java +++ b/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java @@ -200,6 +200,18 @@ public class HtmlSanityCheckMojo extends AbstractMojo { @Parameter private Set excludes = new HashSet<>(); + /** + * (optional) + * JUnit output style: FLAT (all files in one directory, default for backwards compatibility) + * or HIERARCHICAL (subdirectories mirror source structure, solves filename length issues). + *

+ * Type: JunitOutputStyle (FLAT or HIERARCHICAL). + *

+ * Default: FLAT. + */ + @Parameter + private Configuration.JunitOutputStyle junitOutputStyle; + static PerRunResults performChecks(Configuration myConfig) throws MojoExecutionException { try { AllChecksRunner allChecksRunner = new AllChecksRunner(myConfig); @@ -286,6 +298,7 @@ protected Configuration setupConfiguration() { .ignoreIPAddresses(ignoreIPAddresses) .checksToExecute(checkerClasses) + .junitOutputStyle(junitOutputStyle) .build(); // in case we have configured specific interpretations of http status codes diff --git a/src/docs/development/_includes/issue-405.adoc b/src/docs/development/_includes/issue-405.adoc index a24763cd..241fa8a6 100644 --- a/src/docs/development/_includes/issue-405.adoc +++ b/src/docs/development/_includes/issue-405.adoc @@ -5,9 +5,10 @@ include::../../_common.adoc[] === Problem -https://github.com/aim42/htmlSanityCheck/issues/405[Issue 405] reports that when using the htmlSanityCheck Gradle plugin within a sub-project with deeply nested directory structures, a "File name too long" error occurs during the generation of JUnit XML reports. +https://github.com/aim42/htmlSanityCheck/issues/405[Issue 405] reports that when using the htmlSanityCheck Gradle plugin within a subproject with deeply nested directory structures, a "File name too long" error occurs during the generation of JUnit XML reports. -The error happens because the generated JUnit report filenames incorporate both the full path to the Gradle sub-project and the deeply nested folder structure of the files being checked. This results in filenames that exceed the filesystem's maximum filename length limit (typically 255 characters). +The error happens because the generated JUnit report filenames incorporate both the full path to the Gradle subproject and the deeply nested folder structure of the files being checked. +This results in filenames that exceed the filesystem's maximum filename length limit (typically 255 characters). Example error: [source,text] @@ -29,25 +30,25 @@ The original `JUnitXmlReporter` implementation used a flat file structure where . Sanitizing it by replacing all non-alphanumeric characters with underscores . Prepending `TEST-unit-html-` to create the final filename -This approach worked well for shallow directory structures, but failed when: +This approach worked well for shallow directory structures but failed when: -* Working in deeply nested Gradle sub-projects +* Working in deeply nested Gradle subprojects * Checking HTML files that are themselves in deep directory structures * The combined path length exceeded OS filename limits (~255 characters) === Solution -Instead of encoding the entire directory path into a single filename, the solution creates a hierarchical directory structure that mirrors the source file organization. +A new configuration option `junitOutputStyle` allows choosing between two output structures for JUnit XML reports: -Before (flat structure):: +FLAT (default):: All JUnit XML reports are stored in a single directory with the entire file path encoded into the filename using underscores. This maintains backward compatibility with existing configurations. + [source,text] ---- build/test-results/htmlchecks/ - └── TEST-unit-html-_very_long_path_with_underscores.xml ❌ + └── TEST-unit-html-_docs_guide_user_installation_linux_html.xml ---- -After (hierarchical structure):: +HIERARCHICAL:: Creates a hierarchical directory structure that mirrors the source file organization, solving the filename length issue. + [source,text] ---- @@ -59,40 +60,87 @@ build/test-results/htmlchecks/ └── TEST-linux.html.xml ✅ ---- -This approach provides several benefits: +The HIERARCHICAL approach provides several benefits: Solves the filename length issue:: Individual filenames stay well under the 255-character filesystem limit Intuitive organization:: Directory structure mirrors the checked HTML files' structure, making results easy to find Maintains all information:: Full path information is preserved through the directory hierarchy -Backward compatible:: Existing functionality is preserved; only the file organization has changed Robust error handling:: Handles edge cases like special characters, relative paths, and path traversal attempts +=== Configuration + +To enable the hierarchical output structure, set `junitOutputStyle` to `HIERARCHICAL` in your build configuration: + +.Gradle +[source,groovy] +---- +htmlSanityCheck { + junitOutputStyle = org.aim42.htmlsanitycheck.Configuration.JunitOutputStyle.HIERARCHICAL +} +---- + +.Maven +[source,xml] +---- + + org.aim42.htmlSanityCheck + htmlSanityCheck-maven-plugin + + HIERARCHICAL + + +---- + +.CLI +[source,bash] +---- +hsc --junitOutputStyle HIERARCHICAL /path/to/html/files +---- + +NOTE: The default value is `FLAT` for backward compatibility. Existing users will see no change unless they explicitly configure `HIERARCHICAL` mode. + === Implementation -The solution was implemented in `JUnitXmlReporter.java` by modifying the `reportPageSummary()` method: +The solution was implemented in `JUnitXmlReporter.java` with the following components: +. A new `JunitOutputStyle` enum (nested in `Configuration` class) defining `FLAT` and `HIERARCHICAL` modes +. An `outputStyle` field to store the configuration (defaults to `FLAT`) +. Modified `reportPageSummary()` method to select the appropriate output strategy: ++ [source,java] ---- include::../../../../htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java[tags=reportPageSummary,indent=0] ---- -Key implementation details: +. Two separate methods implement each strategy: + * `getFlatOutputFile()` - Original flat structure implementation + * `getHierarchicalOutputFile()` - New hierarchical structure implementation + +Key implementation details for HIERARCHICAL mode: Path normalization:: Uses `getCanonicalFile()` to handle relative paths with `..` references Security check:: Verifies that normalized paths don't escape the output directory Graceful fallback:: Falls back to the output root if path resolution fails Filename sanitization:: Only sanitizes the filename component, not the entire path +The configuration option is exposed in: + +* `Configuration` class - `junitOutputStyle` field with `FLAT` default +* Gradle plugin (`HtmlSanityCheckTask`) - `junitOutputStyle` input property +* Maven plugin (`HtmlSanityCheckMojo`) - `junitOutputStyle` parameter +* CLI (`HscCommand`) - `--junitOutputStyle` / `-o` command-line option + === Testing -The implementation includes comprehensive test coverage with 7 new tests in `JUnitXmlReporterTest`: +The implementation includes comprehensive test coverage with 7 new tests in `JUnitXmlReporterTest` that explicitly test HIERARCHICAL mode: `testSimpleFilenameCreatesFileInRootDirectory`:: Verifies files with no directory path are created in the root `testSingleLevelDirectoryCreatesSubdirectory`:: Tests single-level directory creation `testDeepNestedDirectoryCreatesFullHierarchy`:: Tests deeply nested directories (4+ levels) -`testVeryLongPathDoesNotExceedFilenameLimit`:: Reproduces and fixes the issue with very long paths +`testVeryLongPathDoesNotExceedFilenameLimit`:: Reproduces and fixes the issue #405 with very long paths `testMultiplePagesCreateSeparateDirectories`:: Verifies multiple pages create separate directory structures -`testFilenameWithSpecialCharactersIsSanitized`:: Tests filename sanitization while preserving directory structure +`testFilenameWithSpecialCharactersIsSanitized`:: Tests filename sanitization while preserving the directory structure `testRelativePathWithDotDotIsHandledCorrectly`:: Tests edge case handling of relative paths -All existing tests (379 total) continue to pass, ensuring backward compatibility. +All existing tests continue to pass with FLAT mode (the default), ensuring backward compatibility. +The full test suite of 379+ tests validates that existing functionality is preserved. From e13932686cb607b99c2bb755445b993c13fddfa7 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Mon, 13 Oct 2025 22:48:26 +0200 Subject: [PATCH 11/12] #405 Improve test coverage for JUnitXmlReporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests to improve code coverage for exception handling and both FLAT and HIERARCHICAL output modes. - testFlatModeCreatesEncodedFilename: Tests explicit FLAT mode - testFlatModeIsDefaultWhenNotSpecified: Tests default behavior - testHierarchicalModeFailsWhenCannotCreateDirectory: Tests exception when directory creation fails in HIERARCHICAL mode - Fix testInitReportWithNonWritableDirectory to properly test exception when output path cannot be created These tests address Sonar coverage gaps in: - Line 64: initReport() exception handling - Line 165: getHierarchicalOutputFile() directory creation error - getFlatOutputFile() method coverage Coverage improvements ensure both output modes and exception paths are properly tested. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../report/JUnitXmlReporterTest.groovy | 92 +++++++++++++++++-- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy index aec9a3df..684a41f5 100644 --- a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy +++ b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy @@ -54,16 +54,16 @@ class JUnitXmlReporterTest { } @Test(expected = RuntimeException.class) - void testInitReportWithNonWritableDirectory() throws IOException { - // Create a temporary directory - File tempDir = tempFolder.newFolder() + void testInitReportWithNonWritableDirectory() { + // Create a path that cannot be created (using a non-existent parent and restricted path) + File nonExistentPath = new File("/nonexistent/path/that/cannot/be/created") - // Make the directory non-writable - assertTrue("Could not make temp directory non-writable", tempDir.setWritable(false)) - - // Create a new JUnitXmlReporter with the non-writable directory + // Try to create a JUnitXmlReporter with a path that cannot be created PerRunResults runResults = new PerRunResults() - new JUnitXmlReporter(runResults, tempDir.getAbsolutePath()).initReport() + JUnitXmlReporter reporter = new JUnitXmlReporter(runResults, nonExistentPath.getAbsolutePath()) + + // This should throw RuntimeException because the path cannot be created + reporter.initReport() } @Test @@ -232,8 +232,84 @@ class JUnitXmlReporterTest { return null } + // Tests for FLAT output style (default, backwards compatible) + + @Test + void testFlatModeCreatesEncodedFilename() { + // Given: a page with a nested path + SinglePageResults pageWithPath = new SinglePageResults( + "about.html", + "docs/guide/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithPath) + + // When: we generate the report in FLAT mode (explicit) + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.FLAT) + .reportPageSummary(pageWithPath) + + // Then: the file should be created in the root with encoded path + File[] files = outputPath.listFiles() + assertEquals("Should have exactly one file in root", 1, files.length) + assertTrue("Filename should contain encoded path", + files[0].name.contains("docs") && files[0].name.contains("guide")) + assertTrue("Filename should start with TEST-unit-html-", files[0].name.startsWith("TEST-unit-html-")) + + def testsuite = new XmlSlurper().parse(files[0]) + assertEquals("docs/guide/about.html", testsuite.@name.text()) + } + + @Test + void testFlatModeIsDefaultWhenNotSpecified() { + // Given: a page with a nested path + SinglePageResults pageWithPath = new SinglePageResults( + "about.html", + "docs/guide/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithPath) + + // When: we generate the report WITHOUT specifying mode (should default to FLAT) + new JUnitXmlReporter(runResults, outputPath.absolutePath) + .reportPageSummary(pageWithPath) + + // Then: the file should be created in the root with encoded path (FLAT behavior) + File[] files = outputPath.listFiles() + assertEquals("Should have exactly one file in root", 1, files.length) + assertTrue("Filename should contain encoded path", + files[0].name.contains("docs") && files[0].name.contains("guide")) + + def testsuite = new XmlSlurper().parse(files[0]) + assertEquals("docs/guide/about.html", testsuite.@name.text()) + } + // Tests for hierarchical directory structure (issue #405) + @Test(expected = RuntimeException.class) + void testHierarchicalModeFailsWhenCannotCreateDirectory() { + // Given: an output path that's a file (not a directory) + File tempFile = File.createTempFile("test", ".txt") + tempFile.deleteOnExit() + + SinglePageResults pageWithPath = new SinglePageResults( + "about.html", + "docs/guide/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithPath) + + // When: we try to generate a report in HIERARCHICAL mode with a file as output path + // Then: it should throw RuntimeException because it cannot create subdirectories + new JUnitXmlReporter(runResults, tempFile.getAbsolutePath(), Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(pageWithPath) + } + @Test void testSimpleFilenameCreatesFileInRootDirectory() { // Given: a page with a simple filename (no directory path) From 3ae9a127951a4585b9b892d53a0a905afaac17d1 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Mon, 13 Oct 2025 23:32:45 +0200 Subject: [PATCH 12/12] #405 Improve path security and error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address GitHub Copilot code review suggestions: 1. Enhanced path traversal security - Replace string-based startsWith() check with NIO Path API - Use Path.normalize() for more robust path validation - Prevent sophisticated path traversal attacks 2. Improved error diagnostics - Add detailed context to directory creation failures - Include directory existence status and parent permissions - Make debugging filesystem issues easier 3. Comprehensive test coverage - Add testPathTraversalAttackIsBlocked - Add testPathTraversalWithSymlinkStyleAttackIsBlocked - Add testEnhancedErrorMessageWhenDirectoryCreationFails - Add testEnhancedErrorMessageFormatIsCorrect - All 24 tests pass successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../report/JUnitXmlReporter.java | 17 ++- .../report/JUnitXmlReporterTest.groovy | 141 ++++++++++++++++++ 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java index b3fdf80d..ea5afd9c 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java @@ -12,6 +12,7 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.file.Path; import java.util.UUID; /************************************************************************ @@ -147,8 +148,12 @@ private File getHierarchicalOutputFile(String name) { File tempPath = new File(outputPath, parentDir.getPath()); testOutputDir = tempPath.getCanonicalFile(); - // Verify the canonical path is still under outputPath - if (!testOutputDir.getAbsolutePath().startsWith(outputPath.getCanonicalPath())) { + // Verify the canonical path is still under outputPath using NIO Path API + // This provides better security against path traversal attacks + Path normalizedOutputPath = outputPath.getCanonicalFile().toPath().normalize(); + Path normalizedTestOutputDir = testOutputDir.toPath().normalize(); + + if (!normalizedTestOutputDir.startsWith(normalizedOutputPath)) { // Path tries to escape outputPath, so just use outputPath directly testOutputDir = outputPath; } @@ -162,7 +167,13 @@ private File getHierarchicalOutputFile(String name) { // Ensure the directory exists if (!testOutputDir.exists() && !testOutputDir.mkdirs()) { - throw new RuntimeException("Cannot create directory " + testOutputDir); //NOSONAR(S112) + StringBuilder errorMsg = new StringBuilder("Cannot create directory: ") + .append(testOutputDir.getAbsolutePath()); + errorMsg.append(" (exists: ").append(testOutputDir.exists()) + .append(", parent canWrite: ") + .append(testOutputDir.getParentFile() != null ? testOutputDir.getParentFile().canWrite() : "unknown") + .append(")"); + throw new RuntimeException(errorMsg.toString()); //NOSONAR(S112) } // Create the test file with a simple, sanitized filename diff --git a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy index 684a41f5..82de6741 100644 --- a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy +++ b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy @@ -10,6 +10,7 @@ import org.junit.Before import org.junit.Test import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertNotNull import static org.junit.Assert.assertTrue // see end-of-file for license information @@ -521,4 +522,144 @@ class JUnitXmlReporterTest { File[] allFiles = outputPath.listFiles() assertTrue("Should have created at least one file or directory", allFiles != null && allFiles.length > 0) } + + @Test + void testPathTraversalAttackIsBlocked() { + // Given: a malicious path trying to escape the output directory + // This simulates a path traversal attack like "../../../etc/passwd" + SinglePageResults maliciousPage = new SinglePageResults( + "index.html", + "../../../malicious/path/index.html", + "Malicious Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(maliciousPage) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(maliciousPage) + + // Then: the file should be created safely within outputPath, not outside it + File[] allFiles = outputPath.listFiles() + assertTrue("Should have created file or directory", allFiles != null && allFiles.length > 0) + + // Verify no files were created outside outputPath + def outputPathCanonical = outputPath.canonicalPath + def createdFile = findFirstXmlFile(outputPath) + assertNotNull(createdFile) + + // The created file should be within outputPath + assertTrue("File should be within output directory", + createdFile.canonicalPath.startsWith(outputPathCanonical)) + } + + @Test + void testPathTraversalWithSymlinkStyleAttackIsBlocked() { + // Given: a more sophisticated path traversal attack that tries to bypass simple checks + // Example: "validdir/../../escape/test.html" which could bypass startsWith() on strings + SinglePageResults sophisticatedAttack = new SinglePageResults( + "test.html", + "valid/../../../escape/test.html", + "Sophisticated Attack", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(sophisticatedAttack) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(sophisticatedAttack) + + // Then: verify the file is safely contained + def outputPathCanonical = outputPath.canonicalPath + def createdFile = findFirstXmlFile(outputPath) + assertNotNull(createdFile) + + // Use NIO Path API to verify containment (same method as production code) + def normalizedOutputPath = outputPath.canonicalFile.toPath().normalize() + def normalizedCreatedPath = createdFile.canonicalFile.toPath().normalize() + + assertTrue("File should be within output directory using NIO Path API", + normalizedCreatedPath.startsWith(normalizedOutputPath)) + } + + @Test + void testEnhancedErrorMessageWhenDirectoryCreationFails() { + // Given: a non-existent parent directory that cannot be created + // We'll use a path that's invalid on the filesystem + File invalidOutputPath = new File("/nonexistent/deeply/nested/path/that/cannot/be/created") + + SinglePageResults page = new SinglePageResults( + "test.html", + "some/deep/path/test.html", + "Test Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(page) + + // When/Then: directory creation should fail with enhanced error message + try { + new JUnitXmlReporter(runResults, invalidOutputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(page) + fail("Should have thrown RuntimeException for directory creation failure") + } catch (RuntimeException e) { + // Verify the error message contains diagnostic information + String errorMsg = e.message + assertTrue("Error message should mention 'Cannot create directory'", + errorMsg.contains("Cannot create directory")) + assertTrue("Error message should contain full path", + errorMsg.contains(invalidOutputPath.absolutePath)) + assertTrue("Error message should include 'exists:' diagnostic", + errorMsg.contains("exists:")) + assertTrue("Error message should include 'parent canWrite:' diagnostic", + errorMsg.contains("parent canWrite:")) + } + } + + @Test + void testEnhancedErrorMessageFormatIsCorrect() { + // Given: setup that will trigger directory creation failure + File readOnlyParent = new File(outputPath, "readonly-parent") + readOnlyParent.mkdirs() + + // Try to make it read-only (this may not work on all platforms, especially Windows) + boolean madeReadOnly = readOnlyParent.setReadOnly() + + if (!madeReadOnly || readOnlyParent.canWrite()) { + // Skip test if we cannot make directory read-only on this platform + System.err.println("Skipping testEnhancedErrorMessageFormatIsCorrect - cannot make directory read-only on this platform") + return + } + + try { + SinglePageResults page = new SinglePageResults( + "test.html", + "readonly-parent/subdir/test.html", + "Test Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(page) + + // When: attempting to create subdirectory in read-only parent + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(page) + fail("Should have thrown RuntimeException") + } catch (RuntimeException e) { + // Then: error message should have proper format with parentheses + String errorMsg = e.message + assertTrue("Error message should contain opening parenthesis", + errorMsg.contains("(")) + assertTrue("Error message should contain closing parenthesis", + errorMsg.contains(")")) + // Should have format like: "... (exists: true, parent canWrite: false)" + assertTrue("Error message should match expected format pattern", + errorMsg.matches(".*\\(exists: .*, parent canWrite: .*\\).*")) + } finally { + // Cleanup: restore write permission + readOnlyParent.setWritable(true) + } + } }