Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/build-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -82,6 +97,8 @@ jobs:
- name: Collect state upon failure
if: failure()
run: |
echo "Docker Images:"
docker images
echo "Git:"
git status
echo "Env:"
Expand Down
148 changes: 148 additions & 0 deletions .github/workflows/cleanup-ghcr.yml
Original file line number Diff line number Diff line change
@@ -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}.`);
}
127 changes: 126 additions & 1 deletion .github/workflows/gradle-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,4 +122,64 @@ jobs:
pwd
echo "Files:"
find * -ls
./gradlew javaToolchains
./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
15 changes: 15 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ tasks.register("integrationTestOnly") {
dependsOn(
':publishAllPublicationsToMyLocalRepositoryForFullIntegrationTestsRepository',
':htmlSanityCheck-cli:installDist',
':htmlSanityCheck-cli:dockerBuildLocal'
)

doLast {
Expand Down
14 changes: 14 additions & 0 deletions htmlSanityCheck-cli/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading