diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index c0c5cb41..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 @@ -55,7 +62,15 @@ 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: 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 @@ -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/cleanup-ghcr.yml b/.github/workflows/cleanup-ghcr.yml new file mode 100644 index 00000000..bcbb0a28 --- /dev/null +++ b/.github/workflows/cleanup-ghcr.yml @@ -0,0 +1,148 @@ +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' + dry_run: + description: 'If true, only print which versions would be deleted (no deletion performed)' + required: false + type: boolean + default: true + +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 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 + + 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 + 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 wouldDelete = 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) || []; + 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 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)); + 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 + 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) { + 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++; + } + + if (dryRun) { + core.info(`Scanned versions: ${scanned}. Would delete versions: ${wouldDelete}.`); + } else { + core.info(`Scanned versions: ${scanned}. Deleted versions: ${totalDeleted}.`); + } diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 3e0d266a..6fc94279 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -7,17 +7,82 @@ on: pull_request: push: workflow_dispatch: + inputs: + additional_tags: + description: 'Additional tags for Docker images (comma-separated)' + required: false + type: string 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 }} + 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="${{ 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 + 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 "Docker Images:" + docker images + 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 @@ -57,4 +122,64 @@ jobs: pwd echo "Files:" find * -ls - ./gradlew javaToolchains \ No newline at end of file + ./gradlew javaToolchains + + publish-docker-images: + needs: test-gh-action + 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 + env: + ADDITIONAL_TAGS: ${{ inputs.additional_tags }} + run: ./gradlew dockerPush -Ddocker.image.additional.tags="${ADDITIONAL_TAGS}" --scan --stacktrace + + - name: Collect state upon failure + if: failure() + run: | + echo "Docker Images:" + docker images + echo "Maven Repo:" + (cd $HOME && find .m2 -ls) + echo "Git:" + git status + echo "Env:" + env | sort + echo "PWD:" + pwd + echo "Files:" + find * -ls + ./gradlew javaToolchains diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..2d953be4 --- /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' + # 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: '/hsc.sh' + args: + - ${{ inputs.args }} 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/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/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: diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 37346621..297ae7f8 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -1,5 +1,7 @@ plugins { id 'application' + id 'com.github.johnrengelman.shadow' version '8.1.1' + id 'com.fussionlabs.gradle.docker-plugin' version '0.6.0' } dependencies { @@ -24,6 +26,79 @@ application { applicationName = 'hsc' } +shadowJar { + archiveClassifier.set('all') + mergeServiceFiles() + append 'META-INF/services' +} + +def dockerTags(Project project) { + String currentBranch = "${'git branch --show-current'.execute().text.trim()}" + // 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'] + } + + def additionalTags = System.getProperty('docker.image.additional.tags') + if (additionalTags) { + logger.quiet("Adding additional tags '${additionalTags}'") + result += additionalTags.split(',').collect { it.trim() } + } + + // Avoid duplicate tags (e.g., if additionalTags repeats a SHA) + return result.findAll { it && it.trim() }.unique() +} + +docker { + registry = "ghcr.io" + repository = "aim42/hsc" + buildArgs = ["VERSION": "${project.version}" as String] + dockerFilePath = "." + tags = dockerTags(project) + applyLatestTag = false +} + +tasks.register('dockerBuildLocal', com.fussionlabs.gradle.docker.tasks.DockerBuildx) { + 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 + + 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. * @@ -51,4 +126,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 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/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 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..03b4e6dd 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,11 +179,75 @@ 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 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 + 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.)") - dependsOn(integrationTestGradlePlugin, integrationTestCli, integrationTestMavenPlugin) + dependsOn(integrationTestGradlePlugin, integrationTestCli, integrationTestMavenPlugin, integrationTestDocker) } tasks.register("cleanIntegrationTest", Delete) { @@ -191,6 +255,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 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 ] 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() } }