Skip to content
Closed
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
29 changes: 29 additions & 0 deletions workflow-templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,32 @@ Chart CD runs only after a semantic release and requires:
The chart update action fails early if the values file or image repository entry
cannot be found, so verify the path and repository string before enabling a
release.

## Optional Docker Compose testing

The CI template normally starts the built application image with the reusable
container action. For repositories that need multiple services, manually run
the workflow with `run_compose` enabled and set `compose_file` if the file is
not `docker-compose.yml`. Compose replaces the single-container launcher to
avoid port conflicts. The workflow validates the file, waits for services to
start, and removes Compose containers and networks afterward without removing
volumes. It assigns a run-specific Compose project name; avoid fixed
`container_name` values in Compose files if multiple repositories share a
self-hosted runner.

If the Compose file should test the exact image built by CI, reference the
provided `COMPOSE_IMAGE` environment variable in its service definition, for
example: `image: ${COMPOSE_IMAGE}`.

## GHCR cleanup settings

The `cleanup.yml` starter workflow is safe by default: scheduled runs and manual
runs start in preview-only mode. It only deletes package versions when a manual
run explicitly sets `dry_run` to `false` and types `DELETE` into
`confirm_delete`.
Each run also has a configurable deletion cap (`max_deletions`, default `20`,
maximum `100`). Release-like semantic-version tags (`vX.Y.Z`, including
prerelease/build suffixes) and the `latest` tag are always excluded; only
expired tags beginning with `test-v` are eligible.
Runs are serialized per repository so overlapping cleanup runs cannot race each
other.
2 changes: 1 addition & 1 deletion workflow-templates/ci.properties.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Actions CI Workflow",
"description": "Builds a Docker image, runs repository tests, and optionally promotes semantic releases.",
"description": "Builds a Docker image, runs repository tests or Docker Compose services, and optionally promotes semantic releases.",
"iconName": "octicon package",
"categories": ["Docker", "Continuous integration", "Deployment"]
}
44 changes: 43 additions & 1 deletion workflow-templates/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ on:
required: false
default: true
type: boolean
run_compose:
description: Start services with Docker Compose instead of one application container.
required: false
default: false
type: boolean
compose_file:
description: Compose file to use when run_compose is enabled.
required: false
default: docker-compose.yml
type: string
keep_image:
description: Keep the commit image in GHCR when tests fail.
required: false
Expand Down Expand Up @@ -111,17 +121,49 @@ jobs:
- name: Pull commit image
run: docker pull "${{ steps.resolve_image.outputs.image_ref }}"
- name: Run application container
if: inputs.run_compose != true
uses: svtechnmaa/.github/actions/run_docker_container@main
with:
imageName: ${{ steps.resolve_image.outputs.image_ref }}
containerName: ${{ needs.build.outputs.container_name }}
containerMappingPort: ${{ env.CONTAINER_MAPPING_PORT }}
- name: Start Docker Compose services
id: compose_up
if: inputs.run_compose == true
shell: bash
env:
COMPOSE_FILE_PATH: ${{ inputs.compose_file }}
COMPOSE_IMAGE: ${{ steps.resolve_image.outputs.image_ref }}
COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}
run: |
set -euo pipefail
if [[ -z "${COMPOSE_FILE_PATH}" || ! -f "${COMPOSE_FILE_PATH}" ]]; then
echo "Docker Compose file not found: ${COMPOSE_FILE_PATH}" >&2
exit 1
fi
docker compose --file "${COMPOSE_FILE_PATH}" config --quiet
docker compose --file "${COMPOSE_FILE_PATH}" up --detach --wait
- name: Run repository tests
run: |
echo "##[command]robot tests/"
robot tests/
- name: Stop Docker Compose services
id: compose_down
if: always() && inputs.run_compose == true
shell: bash
env:
COMPOSE_FILE_PATH: ${{ inputs.compose_file }}
COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}
run: |
set -euo pipefail
if [[ -f "${COMPOSE_FILE_PATH}" ]]; then
docker compose --file "${COMPOSE_FILE_PATH}" down --remove-orphans
fi
- name: Delete commit image after test failure
if: failure() && inputs.keep_image != true
if: >-
failure() &&
steps.compose_down.outcome != 'failure' &&
inputs.keep_image != true
shell: bash
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion workflow-templates/cleanup.properties.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "GHCR Image Cleanup",
"description": "Deletes expired test images from GitHub Container Registry while preserving release and latest images.",
"description": "Safely previews expired GHCR test images and optionally deletes them with confirmation and a deletion limit.",
"iconName": "octicon trash",
"categories": ["Deployment", "Docker"]
}
153 changes: 122 additions & 31 deletions workflow-templates/cleanup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,64 @@ name: GHCR Image Cleanup

on:
workflow_dispatch:
inputs:
dry_run:
description: Preview matching images without deleting anything.
required: false
default: true
type: boolean
confirm_delete:
description: Type DELETE to authorize package-version deletion.
required: false
default: ''
type: string
max_deletions:
description: Maximum number of package versions this run may delete.
required: false
default: 20
type: number
schedule:
- cron: '0 0 * * 1'

permissions:
packages: write

concurrency:
group: ghcr-cleanup-${{ github.repository }}
cancel-in-progress: false

env:
CLEANUP_RETENTION_DAYS: '7'
TEST_TAG_PREFIX: 'test-v'
PACKAGE_NAME: ${{ github.event.repository.name }}
DRY_RUN: ${{ github.event_name != 'workflow_dispatch' || inputs.dry_run }}
DELETE_CONFIRMATION: ${{ github.event_name == 'workflow_dispatch' && inputs.confirm_delete }}
MAX_DELETIONS: ${{ inputs.max_deletions || 20 }}

jobs:
cleanup:
name: Delete expired test images
name: Preview or delete expired test images
runs-on: ubuntu-dind
steps:
- name: Delete old test images
- name: Find and optionally delete old test images
shell: bash
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail

if ! [[ "${CLEANUP_RETENTION_DAYS}" =~ ^[0-9]+$ ]]; then
echo "Cleanup retention days must be a non-negative integer." >&2
if ! [[ "${CLEANUP_RETENTION_DAYS}" =~ ^[1-9][0-9]*$ ]]; then
echo "Cleanup retention days must be a positive integer." >&2
exit 1
fi
if ! [[ "${MAX_DELETIONS}" =~ ^[1-9][0-9]*$ ]] || (( MAX_DELETIONS > 100 )); then
echo "Maximum deletions must be an integer from 1 through 100." >&2
exit 1
fi

package_name="${PACKAGE_NAME,,}"
if ! [[ "${package_name}" =~ ^[a-z0-9._-]+$ ]]; then
echo "Invalid GHCR package name: ${PACKAGE_NAME}" >&2
exit 1
fi

Expand All @@ -35,37 +68,95 @@ jobs:
owner_endpoint="orgs"
fi

versions_url="${GITHUB_API_URL}/${owner_endpoint}/${GITHUB_REPOSITORY_OWNER}/packages/container/${PACKAGE_NAME}/versions?per_page=100"
response_file="$(mktemp)"
trap 'rm -f "${response_file}"' EXIT

curl --silent --show-error --fail \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${versions_url}" > "${response_file}"

jq -c \
--arg prefix "${TEST_TAG_PREFIX}" \
--argjson retention_days "${CLEANUP_RETENTION_DAYS}" \
'.[]
| (.metadata.container.tags // []) as $tags
| select(($tags | index("latest")) == null)
| select(($tags | any(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))) | not)
| select($tags | any(startswith($prefix)))
| select((.updated_at | fromdateiso8601) < (now - ($retention_days * 24 * 60 * 60)))
| {id, tags: $tags, updated_at}' \
"${response_file}" |
work_dir="$(mktemp -d)"
response_file="${work_dir}/response.json"
candidates_file="${work_dir}/candidates.jsonl"
trap 'rm -rf "${work_dir}"' EXIT
: > "${candidates_file}"

page=1
while true; do
versions_url="${GITHUB_API_URL}/${owner_endpoint}/${GITHUB_REPOSITORY_OWNER}/packages/container/${package_name}/versions?per_page=100&page=${page}"
curl --silent --show-error --fail \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${versions_url}" > "${response_file}"

jq -e 'type == "array"' "${response_file}" >/dev/null
page_count="$(jq 'length' "${response_file}")"
(( page_count == 0 )) && break

jq -c \
--arg prefix "${TEST_TAG_PREFIX}" \
--argjson retention_days "${CLEANUP_RETENTION_DAYS}" \
'.[]
| (.metadata.container.tags // []) as $tags
| select(($tags | index("latest")) == null)
| select(($tags | any(test("^v[0-9]+\\.[0-9]+\\.[0-9]+([+-][0-9A-Za-z.-]+)?$"))) | not)
| select($tags | any(startswith($prefix)))
| select((.updated_at | fromdateiso8601) < (now - ($retention_days * 24 * 60 * 60)))
| {id, tags: $tags, updated_at}' \
"${response_file}" >> "${candidates_file}"

page=$((page + 1))
done

candidate_count="$(wc -l < "${candidates_file}" | tr -d ' ')"
{
echo "## GHCR cleanup"
echo ""
echo "- Package: \`${package_name}\`"
echo "- Retention: ${CLEANUP_RETENTION_DAYS} day(s)"
echo "- Matching versions: ${candidate_count}"
echo "- Maximum deletions: ${MAX_DELETIONS}"
echo ""
} >> "${GITHUB_STEP_SUMMARY}"

if (( candidate_count == 0 )); then
echo "No expired test images found."
echo "- Result: nothing to delete" >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi

echo "Matching package versions:"
while IFS= read -r image; do
jq -r '"- id=\(.id), tags=\(.tags | join(", ")), updated=\(.updated_at)"' <<< "${image}"
done < "${candidates_file}"

if [[ "${DRY_RUN}" != "false" || "${DELETE_CONFIRMATION}" != "DELETE" ]]; then
echo "Dry-run mode: no package versions were deleted."
{
echo "- Result: preview only; no deletions performed"
echo ""
echo "Set \`dry_run=false\` and type \`DELETE\` in \`confirm_delete\` during a manual run to enable deletion."
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi

deleted_count=0
while IFS= read -r image; do
if (( deleted_count >= MAX_DELETIONS )); then
echo "Deletion limit reached (${MAX_DELETIONS}); remaining versions were not deleted."
break
fi

version_id="$(jq -r '.id' <<< "${image}")"
tags="$(jq -r '.tags | join(", ")' <<< "${image}")"
updated_at="$(jq -r '.updated_at' <<< "${image}")"

echo "Deleting package version ${version_id} (tags: ${tags}, updated: ${updated_at})"
echo "Deleting package version ${version_id} (tags: ${tags})"
curl --silent --show-error --fail --request DELETE \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/${owner_endpoint}/${GITHUB_REPOSITORY_OWNER}/packages/container/${PACKAGE_NAME}/versions/${version_id}"
"${GITHUB_API_URL}/${owner_endpoint}/${GITHUB_REPOSITORY_OWNER}/packages/container/${package_name}/versions/${version_id}"
deleted_count=$((deleted_count + 1))
sleep 1
done
done < "${candidates_file}"

echo "Deleted ${deleted_count} package version(s)."
{
echo "- Result: ${deleted_count} deletion(s) completed"
if (( deleted_count < candidate_count )); then
echo "- Remaining matches: $((candidate_count - deleted_count))"
fi
} >> "${GITHUB_STEP_SUMMARY}"