From a24ee72547e2961cf75f6cce8ea034a9203b3bef Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 07:11:37 +0200 Subject: [PATCH 01/20] #343 Make Baeldung exclusion a real regex --- htmlSanityCheck-gradle-plugin/README.adoc | 2 +- integration-test/build.gradle | 63 ++++++++++++++++++++- integration-test/gradle-plugin/build.gradle | 2 +- integration-test/maven-plugin/pom.xml | 2 +- self-check/build.gradle | 2 +- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/htmlSanityCheck-gradle-plugin/README.adoc b/htmlSanityCheck-gradle-plugin/README.adoc index 66873cd8..d5d8c211 100644 --- a/htmlSanityCheck-gradle-plugin/README.adoc +++ b/htmlSanityCheck-gradle-plugin/README.adoc @@ -328,7 +328,7 @@ htmlSanityCheck { checkerClasses = [DuplicateIdChecker, MissingImageFilesChecker] // Exclude from checking - excludes = ["(http|https)://exclude.this/url.*", ".*skip-host.org.*", "https://www.baeldung.com/.*"] // <1> + excludes = ["(http|https)://exclude\\.this/url.*", ".*skip-host\\.org.*", "https://www\\.baeldung\\.com/.*"] // <1> } ---- diff --git a/integration-test/build.gradle b/integration-test/build.gradle index a88ae6f6..5a4bcc78 100644 --- a/integration-test/build.gradle +++ b/integration-test/build.gradle @@ -115,7 +115,7 @@ tasks.register("integrationTestCli") { buildReportsDirectory.mkdirs() final String hscScriptFileName = "../htmlSanityCheck-cli/${BUILD_DIRECTORY}/install/hsc/bin/hsc" String params = "-r ${buildReportsDirectory} " + - "--exclude https://www.baeldung.com/.*" + + "--exclude https://www\\.baeldung\\.com/.*" + "${INTEGRATION_TEST_DIRECTORY_COMMON_BUILD}/docs" if (System.getProperty("os.name") ==~ /Windows.*/) { commandLine 'cmd', '/c', "echo off && ${hscScriptFileName.replace('/', '\\')}.bat ${params}" @@ -179,6 +179,67 @@ tasks.register("integrationTestMavenPlugin") { } } +final String INTEGRATION_TEST_DIRECTORY_DOCKER = "docker" +final String INTEGRATION_TEST_DIRECTORY_DOCKER_BUILD = "${INTEGRATION_TEST_DIRECTORY_DOCKER}/${BUILD_DIRECTORY}" + +tasks.register("cleanIntegrationTestDocker", Delete) { + group("Build") + description("Deletes the result directory from Docker integration tests") + + def filesToDelete = files(INTEGRATION_TEST_DIRECTORY_DOCKER_BUILD) + delete(filesToDelete) + outputs.upToDateWhen { !filesToDelete.files.any { it.exists() } } +} + +tasks.register("integrationTestDocker") { + group("Verification") + description("Run Docker integration tests (only)") + + final File buildReportsDirectory = file("${INTEGRATION_TEST_DIRECTORY_DOCKER_BUILD}/reports") + final File testIndex = new File(buildReportsDirectory, "index.html") + outputs.file testIndex + + dependsOn(integrationTestPrepare) + mustRunAfter(cleanIntegrationTestDocker) + + doLast { + try { + if (System.getProperty("os.name") ==~ /Windows.*/) { + 'cmd /c "docker --version"'.execute().waitFor() + logger.quiet("Though Docker is available we currently skip Docker integration tests as there is no Windows image of HSC provided") + return + } else { + 'docker --version'.execute().waitFor() + } + } catch (Exception e) { + logger.quiet("Docker is not available - skipping Docker integration tests", e) + return + } + + buildReportsDirectory.mkdirs() + // Compute the image tag exactly as in the CLI module: use branch name tag + String currentBranch = "${'git branch --show-current'.execute().text.trim()}" + String branchTag = currentBranch.replaceAll('/', '-') + String image = "ghcr.io/aim42/hsc:${branchTag}" + + // Prepare absolute paths for volume mounts + String docsDir = file("${INTEGRATION_TEST_DIRECTORY_COMMON_BUILD}/docs").absolutePath + String reportsDir = buildReportsDirectory.absolutePath + + String params = "--rm -v \"${docsDir}:${docsDir}\" -v \"${reportsDir}\":/reports -w \"${docsDir}\" ${image} -r /reports --exclude https://www\\.baeldung\\.com/.*" + def result = exec { + if (System.getProperty("os.name") ==~ /Windows.*/) { + // Use cmd to run docker on Windows + commandLine 'cmd', '/c', "docker run ${params}" + } else { + commandLine 'sh', '-c', "docker run ${params}" + } + } + logger.debug "Script output: ${result}" + assert testIndex.exists() + } +} + tasks.register("integrationTest") { group("Verification") description("Run all integration tests (without any installations etc.)") diff --git a/integration-test/gradle-plugin/build.gradle b/integration-test/gradle-plugin/build.gradle index 39a77ed1..fc54b3db 100644 --- a/integration-test/gradle-plugin/build.gradle +++ b/integration-test/gradle-plugin/build.gradle @@ -10,7 +10,7 @@ File reports = file("${Project.DEFAULT_BUILD_DIR_NAME}/reports") htmlSanityCheck { sourceDir = file("../common/${Project.DEFAULT_BUILD_DIR_NAME}/docs") - excludes = ["https://www.baeldung.com/.*"] + excludes = ["https://www\\.baeldung\\.com/.*"] httpSuccessCodes = [429] diff --git a/integration-test/maven-plugin/pom.xml b/integration-test/maven-plugin/pom.xml index 8e1b8368..fc397a6a 100644 --- a/integration-test/maven-plugin/pom.xml +++ b/integration-test/maven-plugin/pom.xml @@ -33,7 +33,7 @@ ../common/build/docs/README.html - https://www.baeldung.com/.* + https://www\.baeldung\.com/.* ../common/build/docs ${project.build.directory}/reports diff --git a/self-check/build.gradle b/self-check/build.gradle index 29a057a6..db5c3a84 100644 --- a/self-check/build.gradle +++ b/self-check/build.gradle @@ -7,7 +7,7 @@ htmlSanityCheck { checkingResultsDir = file("../build/reports/htmlchecks") excludes = [ - "https://www.baeldung.com/.*", // Baeldung seems to have implemented some anti-robot/GPT strategy? + "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 ] From c4a25d20be97949eaeaaa6c8f8eb7862c9a15869 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Sat, 15 Feb 2025 15:05:05 +0100 Subject: [PATCH 02/20] #369 Add shadow Jar for CLI (aka. Fat Jar) to prepare Docker build and GH action as it contains HSC and all dependencies. --- .github/workflows/build-artifacts.yml | 2 +- htmlSanityCheck-cli/build.gradle | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index c0c5cb41..e31278e5 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -55,7 +55,7 @@ jobs: uses: gradle/actions/wrapper-validation@v4 - name: Execute Gradle build - run: ./gradlew clean check integrationTest --scan --stacktrace + run: ./gradlew clean check integrationTest build --scan --stacktrace - name: Cache SonarCloud packages uses: actions/cache@v4 diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 37346621..10a24200 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -1,5 +1,6 @@ plugins { id 'application' + id 'com.github.johnrengelman.shadow' version '8.1.1' } dependencies { @@ -24,6 +25,12 @@ application { applicationName = 'hsc' } +shadowJar { + archiveClassifier.set('all') + mergeServiceFiles() + append 'META-INF/services' +} + /* * Copyright Gerd Aschemann and aim42 contributors. * @@ -51,4 +58,4 @@ application { * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * - */ + */ \ No newline at end of file From c58fd0bb4b81e6d402dc655d37a000b42aae179a Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 18 Feb 2025 08:34:01 +0100 Subject: [PATCH 03/20] #369 Add Docker build and GitHub action The primary goal is to provide a GitHub action. Additionally, we create and publish a multi-platform Docker image for usage in other scenarios (Standalone, GitLab CI, ...). --- .github/workflows/gradle-build.yml | 56 ++++++++++++++++++++++++++++++ action.yml | 15 ++++++++ htmlSanityCheck-cli/Dockerfile | 14 ++++++++ htmlSanityCheck-cli/build.gradle | 25 +++++++++++++ htmlSanityCheck-cli/hsc.sh | 6 ++++ settings.gradle | 1 + 6 files changed, 117 insertions(+) create mode 100644 action.yml create mode 100644 htmlSanityCheck-cli/Dockerfile create mode 100644 htmlSanityCheck-cli/hsc.sh diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 3e0d266a..a718f45a 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -49,6 +49,62 @@ jobs: - name: Collect state upon failure if: failure() run: | + echo "Git:" + git status + echo "Env:" + env | sort + echo "PWD:" + pwd + echo "Files:" + find * -ls + ./gradlew javaToolchains + + publish-docker-images: + needs: build-artifacts + permissions: + packages: write + contents: read + env: + DOCKER_USERNAME: ${{ github.repository_owner }} + DOCKER_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Cache Gradle Toolchain JDKs + uses: actions/cache@v4 + with: + path: ~/.gradle/jdks + key: "${{ runner.os }}-gradle-jdks" + restore-keys: ${{ runner.os }}-gradle-jdks + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: "${{ runner.os }}-gradle-caches }}" + restore-keys: ${{ runner.os }}-gradle-caches + + - name: Execute Gradle build + run: ./gradlew dockerPush --scan --stacktrace + + - name: Collect state upon failure + if: failure() + run: | + echo "Maven Repo:" + (cd $HOME && find .m2 -ls) echo "Git:" git status echo "Env:" diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..f5c58ee1 --- /dev/null +++ b/action.yml @@ -0,0 +1,15 @@ +name: 'hsc' +description: | + HSC (HTML Sanity Check) is a fast and lightweight tool for checking HTML, links, and accessibility issues. + It helps ensure clean, error-free web content and integrates seamlessly into CI/CD workflows. +inputs: + args: + description: 'CLI arguments (cf. https://hsc.aim42.org/manual/20_cli.html)' + required: false +runs: + using: 'docker' + image: 'docker://ghcr.io/aim42/hsc:v2' + entrypoint: '/bin/sh' + args: + - -c + - java -jar /hsc.jar ${{ inputs.args }} diff --git a/htmlSanityCheck-cli/Dockerfile b/htmlSanityCheck-cli/Dockerfile new file mode 100644 index 00000000..152853f5 --- /dev/null +++ b/htmlSanityCheck-cli/Dockerfile @@ -0,0 +1,14 @@ +FROM eclipse-temurin:21-jre-alpine + +ARG DESCRIPTION='HSC (HTML Sanity Check) is a fast and lightweight tool for checking HTML, links, and accessibility issues.' +ARG VERSION=Unknown + +LABEL version=${VERSION} +LABEL org.opencontainers.image.description=${DESCRIPTION} + +COPY hsc.sh /hsc.sh +RUN chmod 755 /hsc.sh + +COPY build/libs/htmlSanityCheck-cli-${VERSION}-all.jar /hsc.jar + +ENTRYPOINT ["/hsc.sh"] \ No newline at end of file diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 10a24200..7b5c3626 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -1,6 +1,8 @@ plugins { id 'application' id 'com.github.johnrengelman.shadow' version '8.1.1' + id 'com.fussionlabs.gradle.docker-plugin' version '0.6.0' + } dependencies { @@ -31,6 +33,29 @@ shadowJar { append 'META-INF/services' } +def dockerTags(Project project) { + String currentBranch = "${'git branch --show-current'.execute().text.trim()}" + def result = [ + "${currentBranch == 'main' ? 'latest' : java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern('yyyyMMddHHmmss'))}" as String, + "${currentBranch.replaceAll('/', '-')}" as String, + ] + if (currentBranch == 'main') { + // On the main branch we should have the latest major prepended with 'v' for GH actions + result += [ 'v' + project.version.substring(0, 1) ] + } + + return result +} + +docker { + registry = "ghcr.io" + repository = "aim42/hsc" + platforms = ["linux/amd64", "linux/arm64"] + buildArgs = [VERSION: project.version] + dockerFilePath = "." + tags = dockerTags(project) +} + /* * Copyright Gerd Aschemann and aim42 contributors. * diff --git a/htmlSanityCheck-cli/hsc.sh b/htmlSanityCheck-cli/hsc.sh new file mode 100644 index 00000000..e533a0b4 --- /dev/null +++ b/htmlSanityCheck-cli/hsc.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +set -euo pipefail + +# shellcheck disable=SC2068 +exec java -jar /hsc.jar ${*} diff --git a/settings.gradle b/settings.gradle index 7ac31d31..79e89ebc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,7 @@ pluginManagement { repositories { gradlePluginPortal() mavenCentral() + mavenLocal() } } From de5d1b596c590e56ed2d3498851e5c4da0f60438 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Wed, 1 Oct 2025 16:18:27 +0200 Subject: [PATCH 04/20] #369 Add integration test for Docker --- integration-test/build.gradle | 4 ++-- integration-test/docker/.gitkeep | 0 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 integration-test/docker/.gitkeep diff --git a/integration-test/build.gradle b/integration-test/build.gradle index 5a4bcc78..24b9bcca 100644 --- a/integration-test/build.gradle +++ b/integration-test/build.gradle @@ -244,7 +244,7 @@ tasks.register("integrationTest") { group("Verification") description("Run all integration tests (without any installations etc.)") - dependsOn(integrationTestGradlePlugin, integrationTestCli, integrationTestMavenPlugin) + dependsOn(integrationTestGradlePlugin, integrationTestCli, integrationTestMavenPlugin, integrationTestDocker) } tasks.register("cleanIntegrationTest", Delete) { @@ -252,6 +252,6 @@ tasks.register("cleanIntegrationTest", Delete) { description("Deletes all integration test builds") dependsOn(cleanIntegrationTestCommon, cleanIntegrationTestGradlePlugin, - cleanIntegrationTestCli, cleanIntegrationTestMavenPlugin) + cleanIntegrationTestCli, cleanIntegrationTestMavenPlugin, cleanIntegrationTestDocker) } clean.dependsOn(cleanIntegrationTest) diff --git a/integration-test/docker/.gitkeep b/integration-test/docker/.gitkeep new file mode 100644 index 00000000..e69de29b From a7d11b0fafe011f409e9136e8d0542021a4541f2 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 03:08:05 +0200 Subject: [PATCH 05/20] #369 Restrict Integration test for Docker Execute the Docker integration test only if Docker is available. --- integration-test/build.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/integration-test/build.gradle b/integration-test/build.gradle index 24b9bcca..7e3c8db8 100644 --- a/integration-test/build.gradle +++ b/integration-test/build.gradle @@ -206,8 +206,6 @@ tasks.register("integrationTestDocker") { try { if (System.getProperty("os.name") ==~ /Windows.*/) { 'cmd /c "docker --version"'.execute().waitFor() - logger.quiet("Though Docker is available we currently skip Docker integration tests as there is no Windows image of HSC provided") - return } else { 'docker --version'.execute().waitFor() } From 4103350ebed46303e2939e9172a4d77a1248d3d0 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 03:33:00 +0200 Subject: [PATCH 06/20] #369 Use Docker "latest" only on "main" branch --- htmlSanityCheck-cli/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 7b5c3626..bdee329c 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -2,7 +2,6 @@ plugins { id 'application' id 'com.github.johnrengelman.shadow' version '8.1.1' id 'com.fussionlabs.gradle.docker-plugin' version '0.6.0' - } dependencies { @@ -36,12 +35,12 @@ shadowJar { def dockerTags(Project project) { String currentBranch = "${'git branch --show-current'.execute().text.trim()}" def result = [ - "${currentBranch == 'main' ? 'latest' : java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern('yyyyMMddHHmmss'))}" as String, + java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern('yyyyMMddHHmmss')) as String, "${currentBranch.replaceAll('/', '-')}" as String, ] if (currentBranch == 'main') { // On the main branch we should have the latest major prepended with 'v' for GH actions - result += [ 'v' + project.version.substring(0, 1) ] + result += ['v' + project.version.substring(0, 1), 'latest'] } return result @@ -54,6 +53,7 @@ docker { buildArgs = [VERSION: project.version] dockerFilePath = "." tags = dockerTags(project) + applyLatestTag = false } /* From 690cef7c78a6e5e424d3037cadf47fd62a3fa277 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 04:25:56 +0200 Subject: [PATCH 07/20] #369 Run Docker integration test on local image Will only be pushed on successful build and test by GitHub workflow. --- build.gradle | 1 + htmlSanityCheck-cli/build.gradle | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index 065e0406..208a0058 100644 --- a/build.gradle +++ b/build.gradle @@ -354,6 +354,7 @@ tasks.register("integrationTestOnly") { dependsOn( ':publishAllPublicationsToMyLocalRepositoryForFullIntegrationTestsRepository', ':htmlSanityCheck-cli:installDist', + ':htmlSanityCheck-cli:dockerBuildLocal' ) doLast { diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index bdee329c..d33b5ae0 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -56,6 +56,13 @@ docker { applyLatestTag = false } +tasks.register('dockerBuildLocal', com.fussionlabs.gradle.docker.tasks.DockerBuildx) { + loadImage = true + pushImage = false + + dependsOn shadowJar +} + /* * Copyright Gerd Aschemann and aim42 contributors. * From ba8fc1aae46c084318ad87ab0f394b2ebbf6b582 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 04:32:13 +0200 Subject: [PATCH 08/20] #369 Skip Docker integration test on Windows --- integration-test/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration-test/build.gradle b/integration-test/build.gradle index 7e3c8db8..24b9bcca 100644 --- a/integration-test/build.gradle +++ b/integration-test/build.gradle @@ -206,6 +206,8 @@ tasks.register("integrationTestDocker") { try { if (System.getProperty("os.name") ==~ /Windows.*/) { 'cmd /c "docker --version"'.execute().waitFor() + logger.quiet("Though Docker is available we currently skip Docker integration tests as there is no Windows image of HSC provided") + return } else { 'docker --version'.execute().waitFor() } From 61d0fbd9374a7f45bb7b61f0835605a19d41efa3 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 04:47:03 +0200 Subject: [PATCH 09/20] #369 Fix Docker build by pushing anyway Switching off Docker push leads to ERROR: failed to build: docker exporter does not currently support exporting manifest lists --- build.gradle | 2 +- htmlSanityCheck-cli/build.gradle | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/build.gradle b/build.gradle index 208a0058..2bf381f0 100644 --- a/build.gradle +++ b/build.gradle @@ -354,7 +354,7 @@ tasks.register("integrationTestOnly") { dependsOn( ':publishAllPublicationsToMyLocalRepositoryForFullIntegrationTestsRepository', ':htmlSanityCheck-cli:installDist', - ':htmlSanityCheck-cli:dockerBuildLocal' + ':htmlSanityCheck-cli:dockerBuild' ) doLast { diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index d33b5ae0..bdee329c 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -56,13 +56,6 @@ docker { applyLatestTag = false } -tasks.register('dockerBuildLocal', com.fussionlabs.gradle.docker.tasks.DockerBuildx) { - loadImage = true - pushImage = false - - dependsOn shadowJar -} - /* * Copyright Gerd Aschemann and aim42 contributors. * From f7ea603bf00a5efe46f0daec7481d9a75c2abf11 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 05:17:38 +0200 Subject: [PATCH 10/20] #369 Add documentation for Docker run --- htmlSanityCheck-cli/README.adoc | 96 +++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/htmlSanityCheck-cli/README.adoc b/htmlSanityCheck-cli/README.adoc index 54e746d8..18ec4c9b 100644 --- a/htmlSanityCheck-cli/README.adoc +++ b/htmlSanityCheck-cli/README.adoc @@ -17,23 +17,48 @@ The Command Line Interface (CLI) module of HTML Sanity Check (xref:{xrefToManual == Installation (Command Line Interface) -=== Prerequisites +You can run HSC on your machine + +* As a <>{nbsp}->{nbsp}<>, or +* As a <>{nbsp}->{nbsp}<>, or + + +[[sec:java-installation]] +=== Java Installation + +==== Prerequisites The HSC CLI needs Java 8 (`java` executable) or higher on the respective OS search path (`+${PATH}+` on Linux/macOS, `%path%` on Windows). -=== SDKMAN +==== SDKMAN If you use https://sdkman.io[SDKMAN], you can install HSC as SDKMAN candidate: `sdk install hsc`. -=== Download +==== Download -Alternatively, +Alternatively, you can download and unpack the CLI release: * Download the HSC CLI from the https://github.com/aim42/htmlSanityCheck/releases[release{nbsp}page]. * Unpack the file in a convenient place, e.g., `/usr/local/hsc`. +* Add the unpacked bin directory to your path, e.g. (for Unix), `+PATH=${PATH}:/usr/local/hsc/bin+`. + +[[sec:container-installation]] +=== Container (Docker) Installation + +Alternatively, you can use a local Container runtime (such as https://www.docker.com/[Docker] or https://podman.io/[Podman]) and execute HSC as a container. +In this case, no preliminary installation is necessary (except for the container runtime) you can directly <>. + +[NOTE] +==== +While HSC supports both Docker and Podman as container runtimes, our examples and automated tests currently only use Docker. +The commands should work similarly with Podman by replacing `docker` with `podman` in the examples. +==== == Usage +[[sec:java-usage]] +=== Java usage + Execute the CLI tool with the following command. If the `sourceDirectory` is omitted, HSC uses the current directory as base-directory to search for HTML files. @@ -51,6 +76,69 @@ Windows:: hsc.bat [ options ] [ sourceDirectory ] ---- +[[sec:container-usage]] +=== Using the Container image + +You can run HSC without installing Java or the CLI by using the Container image published at the GitHub Container Registry: +`ghcr.io/aim42/hsc`. + +Unix (shell) example (Linux/macOS):: ++ +[source,sh] +---- +# Assume your HTML files are under ./docs and you want the report under ./build/reports +docs_dir="${PWD}/docs" +reports_dir="${PWD}/build/reports" +mkdir -p "$reports_dir" + +# Run the Docker image, mount the docs and a host reports directory, and set the working directory +docker run --rm \ + -v "${docs_dir}:${docs_dir}" \ + -v "${reports_dir}:/reports" \ + -w "${docs_dir}" \ + ghcr.io/aim42/hsc:latest \ + -r /reports +---- + +Add options as necessary (example):: ++ +[source,sh] +---- +# Exclude some external links via regex and restrict checked suffixes +docker run --rm \ + -v "$docs_dir:$docs_dir" \ + -v "$reports_dir:/reports" \ + -w "$docs_dir" \ + ghcr.io/aim42/hsc:latest \ + -r /reports \ + --exclude https://www.example.com/.* \ + --suffix html,htm +---- + +Windows PowerShell example:: ++ +[source,powershell] +---- +$DocsDir = (Resolve-Path ./docs).Path +$ReportsDir = (Resolve-Path ./build/reports).Path +New-Item -Force -ItemType Directory $ReportsDir | Out-Null + +docker run --rm ` + -v "$DocsDir`:$DocsDir" ` + -v "$ReportsDir`:/reports" ` + -w "$DocsDir" ` + ghcr.io/aim42/hsc:latest ` + -r /reports +---- + +Notes:: + +* The container entrypoint simply invokes the CLI (`java -jar /hsc.jar`) and forwards all arguments, so you can pass the same options as described below. +* Reports will be written to the directory you mount at `/reports` when using `-r /reports`. +* Tags: use `:latest` for the latest released image. +Development builds may also be available using branch-name tags (used in integration tests). +* The integration test `integration-test/build.gradle` contains a full working example of the Docker invocation used in CI. + === Options The CLI tool exposes a few options as part of its configuration: From f658832a112176b5d63cb006c8e77fecfdcb47cf Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 08:19:55 +0200 Subject: [PATCH 11/20] #369 Enable fail-on-error for CLI --- .../groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy | 4 ++++ 1 file changed, 4 insertions(+) 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..70d1e5af 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,9 @@ class HscCommand implements Runnable { @Option(names = ["-e", "--exclude"], description = "Exclude remote patterns to check", split = ',') Pattern[] excludes = [] + @Option(names = ["-f", "--fail-on-errors"], description = "Fail On Error(s)") + boolean failOnErrors + @Parameters(index = "0", arity = "0..1", description = "base directory (default: current directory)") File srcDir = new File(".").getAbsoluteFile() @@ -177,6 +180,7 @@ class HscCommand implements Runnable { .checkingResultsDir(resultsDirectory) .checksToExecute(AllCheckers.CHECKER_CLASSES) .excludes(hscCommand.excludes as Set) + .failOnErrors(hscCommand.failOnErrors) .build() // if we have no valid configuration, abort with exception From a81384fa2e68cc637f684ea2a466666f393c3d71 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 06:09:06 +0200 Subject: [PATCH 12/20] #369 Test GitHub action --- .github/workflows/gradle-build.yml | 54 +++++++++++++++++++++++++++++- action.yml | 1 + 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index a718f45a..3f97478c 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -18,6 +18,58 @@ jobs: secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + test-gh-action: + needs: build-artifacts + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v5 + + - name: Prepare Docker image for test + run: | + tag=$(git branch --show-current | tr '/' '-') + docker pull ghcr.io/aim42/hsc:${tag} + docker tag ghcr.io/aim42/hsc:${tag} ghcr.io/aim42/hsc:v2 + + - name: Download Artifacts + uses: actions/download-artifact@v5 + with: + name: build-artifacts + path: . + + - name: Run GH Action as provided by this repository + uses: ./ + with: + args: -r build/gh-action-test-report integration-test/common/build/docs --exclude 'https://www\.baeldung\.com/.*' --fail-on-errors + + - name: Upload GH Action test results + uses: actions/upload-artifact@v4 + with: + path: build/gh-action-test-report + + - name: Test Result of GH Action + run: | + if test -s build/gh-action-test-report/index.html; then + echo "Test Successful" + else + echo "Test Failed: Report not found" >&2 + exit 1 + fi + + - name: Collect state upon failure + if: failure() + run: | + echo "Git:" + git status + echo "Env:" + env | sort + echo "PWD:" + pwd + echo "Files:" + find * -ls + ./gradlew javaToolchains + post-build: needs: build-artifacts runs-on: ubuntu-latest @@ -60,7 +112,7 @@ jobs: ./gradlew javaToolchains publish-docker-images: - needs: build-artifacts + needs: test-gh-action permissions: packages: write contents: read diff --git a/action.yml b/action.yml index f5c58ee1..978e4cef 100644 --- a/action.yml +++ b/action.yml @@ -8,6 +8,7 @@ inputs: required: false runs: using: 'docker' + # If the image tag changes, e.g., to v3, the action in the test workflow (workflows/gradle-build.yml) must be adjusted accordingly image: 'docker://ghcr.io/aim42/hsc:v2' entrypoint: '/bin/sh' args: From 1bb0eb3416b0d480f2b5722c205d21f383e764b6 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 09:17:38 +0200 Subject: [PATCH 13/20] #369 Build Docker locally and fail on errors The integration test must run with a local image first --- build.gradle | 2 +- htmlSanityCheck-cli/build.gradle | 7 +++++++ integration-test/build.gradle | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 2bf381f0..208a0058 100644 --- a/build.gradle +++ b/build.gradle @@ -354,7 +354,7 @@ tasks.register("integrationTestOnly") { dependsOn( ':publishAllPublicationsToMyLocalRepositoryForFullIntegrationTestsRepository', ':htmlSanityCheck-cli:installDist', - ':htmlSanityCheck-cli:dockerBuild' + ':htmlSanityCheck-cli:dockerBuildLocal' ) doLast { diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index bdee329c..d33b5ae0 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -56,6 +56,13 @@ docker { applyLatestTag = false } +tasks.register('dockerBuildLocal', com.fussionlabs.gradle.docker.tasks.DockerBuildx) { + loadImage = true + pushImage = false + + dependsOn shadowJar +} + /* * Copyright Gerd Aschemann and aim42 contributors. * diff --git a/integration-test/build.gradle b/integration-test/build.gradle index 24b9bcca..852b1e63 100644 --- a/integration-test/build.gradle +++ b/integration-test/build.gradle @@ -226,7 +226,7 @@ tasks.register("integrationTestDocker") { String docsDir = file("${INTEGRATION_TEST_DIRECTORY_COMMON_BUILD}/docs").absolutePath String reportsDir = buildReportsDirectory.absolutePath - String params = "--rm -v \"${docsDir}:${docsDir}\" -v \"${reportsDir}\":/reports -w \"${docsDir}\" ${image} -r /reports --exclude https://www\\.baeldung\\.com/.*" + String params = "--rm -v \"${docsDir}:${docsDir}\" -v \"${reportsDir}\":/reports -w \"${docsDir}\" ${image} -r /reports --exclude https://www\\.baeldung\\.com/.* --fail-on-errors" def result = exec { if (System.getProperty("os.name") ==~ /Windows.*/) { // Use cmd to run docker on Windows From 7aacf0c035eab642f91de12fd2cbab477dce1427 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 10:46:04 +0200 Subject: [PATCH 14/20] #369 Separate Docker build(s) Locally we build for the respective platform and test with it. Then we build a multi-platform image and push that to the Registry. --- htmlSanityCheck-cli/build.gradle | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index d33b5ae0..9a5f2e38 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -49,7 +49,6 @@ def dockerTags(Project project) { docker { registry = "ghcr.io" repository = "aim42/hsc" - platforms = ["linux/amd64", "linux/arm64"] buildArgs = [VERSION: project.version] dockerFilePath = "." tags = dockerTags(project) @@ -60,9 +59,25 @@ tasks.register('dockerBuildLocal', com.fussionlabs.gradle.docker.tasks.DockerBui loadImage = true pushImage = false + def arch = System.getProperty("os.arch") + project.extensions.getByType(com.fussionlabs.gradle.docker.DockerPluginExtension).platforms = ["linux/${(arch == 'amd64') ? 'amd64' : 'arm64'}"] + logger.quiet("Using Docker platform(s): ${project.extensions.getByType(com.fussionlabs.gradle.docker.DockerPluginExtension).platforms} (for arch '${arch}')") + dependsOn shadowJar } +tasks.register('dockerBuildMulti', com.fussionlabs.gradle.docker.tasks.DockerBuildx) { + loadImage = false + pushImage = true + + project.extensions.getByType(com.fussionlabs.gradle.docker.DockerPluginExtension).platforms = ["linux/amd64", "linux/arm64"] + logger.quiet("Using Docker platform(s): ${project.extensions.getByType(com.fussionlabs.gradle.docker.DockerPluginExtension).platforms}") + + dependsOn shadowJar +} + +dockerPush.dependsOn("dockerBuildMulti") + /* * Copyright Gerd Aschemann and aim42 contributors. * From ce446c365ccdf698d797968ea8a8471c3fd4c797 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 16:53:04 +0200 Subject: [PATCH 15/20] #369 Enable additional Docker tags Allow a push of the Docker image with additional tags to override or extend given images. --- .github/workflows/gradle-build.yml | 9 ++++++++- action.yml | 5 ++--- htmlSanityCheck-cli/build.gradle | 8 +++++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 3f97478c..5ae2a989 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -7,6 +7,11 @@ on: pull_request: push: workflow_dispatch: + inputs: + additional_tags: + description: 'Additional tags for Docker images (comma-separated)' + required: false + type: string jobs: build-artifacts: @@ -150,7 +155,9 @@ jobs: restore-keys: ${{ runner.os }}-gradle-caches - name: Execute Gradle build - run: ./gradlew dockerPush --scan --stacktrace + env: + ADDITIONAL_TAGS: ${{ inputs.additional_tags }} + run: ./gradlew dockerPush -Ddocker.image.additional.tags="${ADDITIONAL_TAGS}" --scan --stacktrace - name: Collect state upon failure if: failure() diff --git a/action.yml b/action.yml index 978e4cef..2d953be4 100644 --- a/action.yml +++ b/action.yml @@ -10,7 +10,6 @@ runs: using: 'docker' # If the image tag changes, e.g., to v3, the action in the test workflow (workflows/gradle-build.yml) must be adjusted accordingly image: 'docker://ghcr.io/aim42/hsc:v2' - entrypoint: '/bin/sh' + entrypoint: '/hsc.sh' args: - - -c - - java -jar /hsc.jar ${{ inputs.args }} + - ${{ inputs.args }} diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 9a5f2e38..96e978ec 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -43,13 +43,19 @@ def dockerTags(Project project) { result += ['v' + project.version.substring(0, 1), 'latest'] } + def additionalTags = System.getProperty('docker.image.additional.tags') + if (additionalTags) { + logger.quiet("Adding additional tags '${additionalTags}'") + result += additionalTags.split(',').collect { it.trim() } + } + return result } docker { registry = "ghcr.io" repository = "aim42/hsc" - buildArgs = [VERSION: project.version] + buildArgs = ["VERSION": "${project.version}" as String] dockerFilePath = "." tags = dockerTags(project) applyLatestTag = false From 19a95a315a03b00e063e228b6b69ec2beb28317e Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 2 Oct 2025 17:29:47 +0200 Subject: [PATCH 16/20] #369 Add housekeeping for timestamped images We only need the timestamped Docker images for some time to enable testing of certain builds. --- .github/workflows/cleanup-ghcr.yml | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/cleanup-ghcr.yml diff --git a/.github/workflows/cleanup-ghcr.yml b/.github/workflows/cleanup-ghcr.yml new file mode 100644 index 00000000..fe900475 --- /dev/null +++ b/.github/workflows/cleanup-ghcr.yml @@ -0,0 +1,102 @@ +name: Clean up old GHCR images + +on: + schedule: + - cron: '0 2 * * *' # every night at 02:00 UTC + workflow_dispatch: + inputs: + retention_days: + description: 'Delete images older than this many days' + required: false + default: '14' + +permissions: + contents: read + packages: write + +jobs: + cleanup-ghcr: + name: Remove timestamped images older than 14 days + runs-on: ubuntu-latest + if: ${{ github.event_name != 'schedule' || github.ref == 'refs/heads/main' }} + steps: + - name: Clean up old container versions + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const org = 'aim42'; + const package_type = 'container'; + const package_name = 'hsc'; + const per_page = 100; + const tsTagRegex = /^\d{14}$/; // yyyyMMddHHmmss + const daysInput = (context.payload && context.payload.inputs && context.payload.inputs.retention_days) || '14'; + const daysParsed = parseInt(daysInput, 10); + const retentionDays = Number.isFinite(daysParsed) && daysParsed > 0 ? daysParsed : 14; + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); // retentionDays days ago + core.info(`Using retention period: ${retentionDays} day(s).`); + + function parseTimestampTag(tag) { + // tag format: yyyyMMddHHmmss, interpreted as UTC + const y = parseInt(tag.slice(0, 4), 10); + const m = parseInt(tag.slice(4, 6), 10) - 1; + const d = parseInt(tag.slice(6, 8), 10); + const hh = parseInt(tag.slice(8, 10), 10); + const mm = parseInt(tag.slice(10, 12), 10); + const ss = parseInt(tag.slice(12, 14), 10); + return new Date(Date.UTC(y, m, d, hh, mm, ss)); + } + + let page = 1; + let totalDeleted = 0; + let scanned = 0; + + while (true) { + const { data: versions } = await github.request( + 'GET /orgs/{org}/packages/{package_type}/{package_name}/versions', + { org, package_type, package_name, per_page, page } + ); + + if (!versions || versions.length === 0) break; + + for (const v of versions) { + scanned++; + const tags = (v.metadata && v.metadata.container && v.metadata.container.tags) || []; + if (!tags || tags.length === 0) continue; + + // Skip protected tags to avoid removing latest or release tags that share the same version + const hasProtected = tags.some(t => t === 'latest' || /^v\d+/.test(t)); + if (hasProtected) { + core.info(`Skipping protected version ${v.id} with tags: ${tags.join(', ')}`); + continue; + } + + const tsTags = tags.filter(t => tsTagRegex.test(t)); + if (tsTags.length === 0) continue; + + // If any timestamp tag is older than cutoff, delete the entire version + let shouldDelete = tsTags.some(t => parseTimestampTag(t) < cutoff); + + if (shouldDelete) { + try { + await github.request( + 'DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}', + { + org, + package_type, + package_name, + package_version_id: v.id, + } + ); + totalDeleted++; + core.info(`Deleted version ${v.id} with tags: ${tags.join(', ')}`); + } catch (err) { + core.warning(`Failed to delete version ${v.id}: ${err.message}`); + } + } + } + + page++; + } + + core.info(`Scanned versions: ${scanned}. Deleted versions: ${totalDeleted}.`); From 09784cff09ad423056843b32555758bac3fb443b Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Sun, 5 Oct 2025 04:58:32 +0200 Subject: [PATCH 17/20] #369 Clean up untagged packages also and enable optional dry-run (default true) --- .github/workflows/cleanup-ghcr.yml | 90 ++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/.github/workflows/cleanup-ghcr.yml b/.github/workflows/cleanup-ghcr.yml index fe900475..bcbb0a28 100644 --- a/.github/workflows/cleanup-ghcr.yml +++ b/.github/workflows/cleanup-ghcr.yml @@ -9,6 +9,11 @@ on: description: 'Delete images older than this many days' required: false default: '14' + dry_run: + description: 'If true, only print which versions would be deleted (no deletion performed)' + required: false + type: boolean + default: true permissions: contents: read @@ -30,11 +35,18 @@ jobs: const package_name = 'hsc'; const per_page = 100; const tsTagRegex = /^\d{14}$/; // yyyyMMddHHmmss + const sha256Regex = /^[a-f0-9]{64}$/i; // sha256 digest-like tag + const daysInput = (context.payload && context.payload.inputs && context.payload.inputs.retention_days) || '14'; const daysParsed = parseInt(daysInput, 10); const retentionDays = Number.isFinite(daysParsed) && daysParsed > 0 ? daysParsed : 14; const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); // retentionDays days ago - core.info(`Using retention period: ${retentionDays} day(s).`); + + const dryRunInput = context.payload && context.payload.inputs ? context.payload.inputs.dry_run : undefined; + const dryRun = (typeof dryRunInput === 'boolean') ? dryRunInput + : (dryRunInput === undefined ? true : String(dryRunInput).toLowerCase() === 'true'); + + core.info(`Using retention period: ${retentionDays} day(s). Dry-run: ${dryRun}.`); function parseTimestampTag(tag) { // tag format: yyyyMMddHHmmss, interpreted as UTC @@ -49,6 +61,7 @@ jobs: let page = 1; let totalDeleted = 0; + let wouldDelete = 0; let scanned = 0; while (true) { @@ -56,47 +69,80 @@ jobs: 'GET /orgs/{org}/packages/{package_type}/{package_name}/versions', { org, package_type, package_name, per_page, page } ); - + if (!versions || versions.length === 0) break; for (const v of versions) { scanned++; const tags = (v.metadata && v.metadata.container && v.metadata.container.tags) || []; - if (!tags || tags.length === 0) continue; + const createdAt = new Date(v.created_at || v.updated_at || 0); + + core.debug (`Checking version '${v.id}' (${v.name}) of '${createdAt}' with tags: '${tags.join(', ')}'`); // Skip protected tags to avoid removing latest or release tags that share the same version - const hasProtected = tags.some(t => t === 'latest' || /^v\d+/.test(t)); - if (hasProtected) { + const isProtected = tags.some(t => t === 'latest' || /^v\d[\.\d]*/.test(t)); + if (isProtected) { core.info(`Skipping protected version ${v.id} with tags: ${tags.join(', ')}`); continue; } const tsTags = tags.filter(t => tsTagRegex.test(t)); - if (tsTags.length === 0) continue; + const shaTags = tags.filter(t => sha256Regex.test(t)); + const nonShaTags = tags.filter(t => !sha256Regex.test(t)); + + let shouldDelete = false; // If any timestamp tag is older than cutoff, delete the entire version - let shouldDelete = tsTags.some(t => parseTimestampTag(t) < cutoff); + if (tsTags.length > 0) { + shouldDelete = tsTags.some(t => parseTimestampTag(t) < cutoff); + } + + // Additionally handle versions that are tagged only by sha256 values. + // Delete if older than retention (by created_at/updated_at) unless there is any non-sha256 tag. + if (!shouldDelete && shaTags.length > 0 && nonShaTags.length === 0) { + if (createdAt instanceof Date && !isNaN(createdAt) && createdAt < cutoff) { + shouldDelete = true; + } + } + + // Handle versions with no tags at all: delete if older than retention by created/updated timestamp + if (!shouldDelete && (!tags || tags.length === 0)) { + if (createdAt instanceof Date && !isNaN(createdAt) && createdAt < cutoff) { + shouldDelete = true; + } + } if (shouldDelete) { - try { - await github.request( - 'DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}', - { - org, - package_type, - package_name, - package_version_id: v.id, - } - ); - totalDeleted++; - core.info(`Deleted version ${v.id} with tags: ${tags.join(', ')}`); - } catch (err) { - core.warning(`Failed to delete version ${v.id}: ${err.message}`); + if (dryRun) { + wouldDelete++; + core.info(`[DRY-RUN] Would delete version '${v.id}' (${v.name}) of '${createdAt}' with tags: '${tags.join(', ')}'`); + } else { + try { + await github.request( + 'DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}', + { + org, + package_type, + package_name, + package_version_id: v.id, + } + ); + totalDeleted++; + core.info(`Deleted version '${v.id}' (${v.name}) of '${createdAt}' with tags: ${tags.join(', ')}`); + } catch (err) { + core.warning(`Failed to delete version ${v.id}: ${err.message}`); + } } + } else { + core.debug (`Not deleting '${v.id}' (${v.name}) of '${createdAt}' with tags: '${tags.join(', ')}'`); } } page++; } - core.info(`Scanned versions: ${scanned}. Deleted versions: ${totalDeleted}.`); + if (dryRun) { + core.info(`Scanned versions: ${scanned}. Would delete versions: ${wouldDelete}.`); + } else { + core.info(`Scanned versions: ${scanned}. Deleted versions: ${totalDeleted}.`); + } From 89d686c812404f08d240ca8079a831a5951ea6f7 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Fri, 10 Oct 2025 23:03:32 +0200 Subject: [PATCH 18/20] Fix GH Caching key YAML Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/gradle-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 5ae2a989..8894045e 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -151,7 +151,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.gradle/caches - key: "${{ runner.os }}-gradle-caches }}" + key: "${{ runner.os }}-gradle-caches" restore-keys: ${{ runner.os }}-gradle-caches - name: Execute Gradle build From 675d6d72a850a9700673e435d522730b4dce611a Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Fri, 10 Oct 2025 23:51:50 +0200 Subject: [PATCH 19/20] #369 Use Git SHA as unique Docker tag Building a multi-platform Docker image only works in cooperation with a remote registry, which implies a push. The image is not directly available in the local image store. A pull is necessary to make it locally available. For the GitHub Action test it was necessary to use the Git SHA as unique identifier for the system to be tested (its testing the Docker image which is used as GitHub action). Other identifiers provided no clear distinction as there could be other/older images with the same tag in the remote registry. --- .github/workflows/build-artifacts.yml | 17 +++++++++++++++++ .github/workflows/gradle-build.yml | 18 ++++++++++++++---- htmlSanityCheck-cli/build.gradle | 2 ++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index e31278e5..fa8a62e6 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -6,6 +6,10 @@ on: required: true type: string default: '17' + push-docker-sha: + required: false + type: boolean + default: false run-sonar: required: false type: boolean @@ -21,6 +25,9 @@ env: jobs: build: + env: + DOCKER_USERNAME: ${{ github.repository_owner }} + DOCKER_PASSWORD: ${{ secrets.GITHUB_TOKEN }} runs-on: ubuntu-latest steps: - name: Check out @@ -57,6 +64,14 @@ jobs: - name: Execute Gradle build run: ./gradlew clean check integrationTest build --scan --stacktrace + - name: Push Docker with Git SHA as tag for subsequent tests + if: ${{ inputs.push-docker-sha }} + run: | + ./gradlew dockerPush -Ddocker.image.additional.tags=${{ github.sha }} + docker pull "ghcr.io/aim42/hsc:${{ github.sha }}" + echo "Docker Images:" + docker images + - name: Cache SonarCloud packages uses: actions/cache@v4 if: ${{ inputs.run-sonar }} @@ -82,6 +97,8 @@ jobs: - name: Collect state upon failure if: failure() run: | + echo "Docker Images:" + docker images echo "Git:" git status echo "Env:" diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 8894045e..6fc94279 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -15,11 +15,15 @@ on: jobs: build-artifacts: + permissions: + packages: write + contents: read uses: ./.github/workflows/build-artifacts.yml with: # SonarQube requires JDK 17 or higher java-version: '17' run-sonar: ${{ github.repository == 'aim42/htmlSanityCheck' }} + push-docker-sha: true secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -33,9 +37,11 @@ jobs: - name: Prepare Docker image for test run: | - tag=$(git branch --show-current | tr '/' '-') - docker pull ghcr.io/aim42/hsc:${tag} - docker tag ghcr.io/aim42/hsc:${tag} ghcr.io/aim42/hsc:v2 + tag="${{ github.sha }}" + docker pull "ghcr.io/aim42/hsc:${tag}" + echo "Docker Images:" + docker images + docker tag "ghcr.io/aim42/hsc:${tag}" ghcr.io/aim42/hsc:v2 - name: Download Artifacts uses: actions/download-artifact@v5 @@ -65,6 +71,8 @@ jobs: - name: Collect state upon failure if: failure() run: | + echo "Docker Images:" + docker images echo "Git:" git status echo "Env:" @@ -162,6 +170,8 @@ jobs: - name: Collect state upon failure if: failure() run: | + echo "Docker Images:" + docker images echo "Maven Repo:" (cd $HOME && find .m2 -ls) echo "Git:" @@ -172,4 +182,4 @@ jobs: pwd echo "Files:" find * -ls - ./gradlew javaToolchains \ No newline at end of file + ./gradlew javaToolchains diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 96e978ec..f32a2e33 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -62,6 +62,8 @@ docker { } tasks.register('dockerBuildLocal', com.fussionlabs.gradle.docker.tasks.DockerBuildx) { + def tag = "${'git branch --show-current'.execute().text.trim().replaceAll('/', '-')}" + logger.quiet("Using tag '${tag}' for dockerBuildLocal") loadImage = true pushImage = false From 689d304cec0002bf217c4b96d11bfc3c7fb08e38 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Sun, 12 Oct 2025 11:13:41 +0200 Subject: [PATCH 20/20] #369 Use SHA for Docker tags if no branch detected On GH pull requests the `git branch --show-current` does not return the branch name. In this case we use the SHA tag (only) for Docker tagging. --- htmlSanityCheck-cli/build.gradle | 25 +++++++++++++++++++------ integration-test/build.gradle | 7 +++++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index f32a2e33..297ae7f8 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -34,10 +34,20 @@ shadowJar { def dockerTags(Project project) { String currentBranch = "${'git branch --show-current'.execute().text.trim()}" - def result = [ - java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern('yyyyMMddHHmmss')) as String, - "${currentBranch.replaceAll('/', '-')}" as String, - ] + // Determine a safe tag: prefer branch name, fallback to Git SHA (from env or git) + String githubSha = System.getenv('GITHUB_SHA') ?: "${'git rev-parse HEAD'.execute().text.trim()}" + String branchOrShaTag = currentBranch ? currentBranch.replaceAll('/', '-') : (githubSha ? "${githubSha}" : null) + + def result = [] as List + // Always include a timestamp tag for traceability + result += java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern('yyyyMMddHHmmss')) as String + // Include branch or SHA-derived tag if available + if (branchOrShaTag) { + result += branchOrShaTag as String + } else { + logger.quiet("No branch name or Git SHA could be determined; only timestamp tag will be used") + } + if (currentBranch == 'main') { // On the main branch we should have the latest major prepended with 'v' for GH actions result += ['v' + project.version.substring(0, 1), 'latest'] @@ -49,7 +59,8 @@ def dockerTags(Project project) { result += additionalTags.split(',').collect { it.trim() } } - return result + // Avoid duplicate tags (e.g., if additionalTags repeats a SHA) + return result.findAll { it && it.trim() }.unique() } docker { @@ -62,7 +73,9 @@ docker { } tasks.register('dockerBuildLocal', com.fussionlabs.gradle.docker.tasks.DockerBuildx) { - def tag = "${'git branch --show-current'.execute().text.trim().replaceAll('/', '-')}" + def branch = "${'git branch --show-current'.execute().text.trim()}" + def sha = System.getenv('GITHUB_SHA') ?: "${'git rev-parse HEAD'.execute().text.trim()}" + def tag = branch ? branch.replaceAll('/', '-') : (sha ?: 'timestamp-only') logger.quiet("Using tag '${tag}' for dockerBuildLocal") loadImage = true pushImage = false diff --git a/integration-test/build.gradle b/integration-test/build.gradle index 852b1e63..03b4e6dd 100644 --- a/integration-test/build.gradle +++ b/integration-test/build.gradle @@ -219,14 +219,17 @@ tasks.register("integrationTestDocker") { buildReportsDirectory.mkdirs() // Compute the image tag exactly as in the CLI module: use branch name tag String currentBranch = "${'git branch --show-current'.execute().text.trim()}" - String branchTag = currentBranch.replaceAll('/', '-') - String image = "ghcr.io/aim42/hsc:${branchTag}" + String branchTag = currentBranch?.replaceAll('/', '-') + String sha = System.getenv('GITHUB_SHA') ?: "${'git rev-parse HEAD'.execute().text.trim()}" + String tag = branchTag ?: sha + String image = "ghcr.io/aim42/hsc:${tag}" // Prepare absolute paths for volume mounts String docsDir = file("${INTEGRATION_TEST_DIRECTORY_COMMON_BUILD}/docs").absolutePath String reportsDir = buildReportsDirectory.absolutePath String params = "--rm -v \"${docsDir}:${docsDir}\" -v \"${reportsDir}\":/reports -w \"${docsDir}\" ${image} -r /reports --exclude https://www\\.baeldung\\.com/.* --fail-on-errors" + logger.quiet("Executing Docker run with '${params}'") def result = exec { if (System.getProperty("os.name") ==~ /Windows.*/) { // Use cmd to run docker on Windows