From 4e45c3f8a46687b643131892e661d39a907d63d3 Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Wed, 5 Aug 2026 16:28:02 +0700 Subject: [PATCH 01/10] add: add basic workflow template betterleaks-Scan: passed --- .../ci_cd_pipeline.properties.json | 6 + .github/workflow-templates/ci_cd_pipeline.yml | 189 ++++++++++++++++++ .../cleanup.properties.json | 6 + .github/workflow-templates/cleanup.yml | 71 +++++++ 4 files changed, 272 insertions(+) create mode 100644 .github/workflow-templates/ci_cd_pipeline.properties.json create mode 100644 .github/workflow-templates/ci_cd_pipeline.yml create mode 100644 .github/workflow-templates/cleanup.properties.json create mode 100644 .github/workflow-templates/cleanup.yml diff --git a/.github/workflow-templates/ci_cd_pipeline.properties.json b/.github/workflow-templates/ci_cd_pipeline.properties.json new file mode 100644 index 0000000..21f0e62 --- /dev/null +++ b/.github/workflow-templates/ci_cd_pipeline.properties.json @@ -0,0 +1,6 @@ +{ + "name": "CI/CD Pipeline", + "description": "Builds a Docker image, runs repository tests, and optionally promotes semantic releases.", + "iconName": "octicon package", + "categories": ["Docker", "Continuous integration", "Deployment"] +} diff --git a/.github/workflow-templates/ci_cd_pipeline.yml b/.github/workflow-templates/ci_cd_pipeline.yml new file mode 100644 index 0000000..0d161b7 --- /dev/null +++ b/.github/workflow-templates/ci_cd_pipeline.yml @@ -0,0 +1,189 @@ +name: CI/CD Pipeline + +on: + push: + branches: [$default-branch] + pull_request: + branches: [$default-branch] + workflow_dispatch: + inputs: + commit_id: + description: Commit SHA to build. Defaults to the triggered commit. + required: false + type: string + run_test: + description: Run the repository test command. + required: false + default: true + type: boolean + keep_image: + description: Keep the commit image in GHCR when tests fail. + required: false + default: false + type: boolean + +permissions: + contents: read + +env: + COMMIT_FULL_ID: ${{ inputs.commit_id || github.sha }} + IMAGE_NAME: ${{ vars.CI_IMAGE_NAME || github.event.repository.name }} + CONTAINER_NAME: ${{ vars.CI_CONTAINER_NAME || github.event.repository.name }} + CONTAINER_MAPPING_PORT: ${{ vars.CI_CONTAINER_MAPPING_PORT || '80:80' }} + DOCKERFILE_PATH: ${{ vars.CI_DOCKERFILE_PATH || './Dockerfile' }} + +jobs: + build: + name: Build image + runs-on: ${{ vars.CI_BUILD_RUNNER || 'ubuntu-dind' }} + permissions: + contents: read + packages: write + security-events: write + actions: read + outputs: + check_no_ci: ${{ steps.build.outputs.check_no_ci }} + check_no_scan: ${{ steps.build.outputs.check_no_scan }} + check_no_cache: ${{ steps.build.outputs.check_no_cache }} + is_latest_or_release_image: ${{ steps.build.outputs.is_latest_or_release_image }} + image_ref: ${{ steps.build.outputs.image_ref }} + commit_short_id: ${{ steps.build.outputs.commit_short_id }} + steps: + - name: Build, scan, and publish image + id: build + uses: svtechnmaa/.github/actions/run_build@main + with: + image_name: ${{ env.IMAGE_NAME }} + container_name: ${{ env.CONTAINER_NAME }} + container_mapping_port: ${{ env.CONTAINER_MAPPING_PORT }} + dockerfile_path: ${{ env.DOCKERFILE_PATH }} + commit_id: ${{ env.COMMIT_FULL_ID }} + github_token: ${{ secrets.GITHUB_TOKEN }} + push_token: ${{ secrets.PUSH_TOKEN }} + docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + trivy_server: ${{ secrets.TRIVY_SERVER }} + mail_username: ${{ vars.MAIL_USERNAME }} + mail_password: ${{ secrets.MAIL_PASSWORD }} + default_mail_recipients: ${{ vars.DEFAULT_MAIL_RECIPIENTS }} + + test: + name: Test image + needs: build + if: >- + needs.build.result == 'success' && + needs.build.outputs.check_no_ci != 'true' && + (github.event_name != 'workflow_dispatch' || inputs.run_test == true) + runs-on: ${{ vars.CI_TEST_RUNNER || 'robot-dind' }} + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.COMMIT_FULL_ID }} + fetch-depth: 0 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Pull commit image + run: docker pull "${{ needs.build.outputs.image_ref }}" + - name: Run application container + uses: svtechnmaa/.github/actions/run_docker_container@main + with: + imageName: ${{ needs.build.outputs.image_ref }} + containerName: ${{ env.CONTAINER_NAME }} + containerMappingPort: ${{ env.CONTAINER_MAPPING_PORT }} + - name: Run repository tests + shell: bash + env: + TEST_COMMAND: ${{ vars.CI_TEST_COMMAND || 'robot tests/' }} + run: bash -euo pipefail -c "${TEST_COMMAND}" + - name: Delete commit image after test failure + if: failure() && inputs.keep_image != true + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_TAG: ${{ needs.build.outputs.commit_short_id }} + run: | + set -euo pipefail + if [[ "${{ needs.build.outputs.is_latest_or_release_image }}" == "true" ]]; then + echo "The image is protected by latest or a release tag; it will not be deleted." + exit 0 + fi + package_name="${GITHUB_REPOSITORY##*/}" + owner_endpoint="users" + if [[ "${{ github.event.repository.owner.type }}" == "Organization" ]]; then + owner_endpoint="orgs" + fi + versions_url="${GITHUB_API_URL}/${owner_endpoint}/${GITHUB_REPOSITORY_OWNER}/packages/container/${package_name}/versions?per_page=100" + version_id="$(curl --silent --show-error --fail \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "Accept: application/vnd.github+json" \ + "${versions_url}" \ + | jq -r --arg tag "${COMMIT_TAG}" \ + '[.[] | select((.metadata.container.tags // []) | index($tag))][0].id // empty')" + if [[ -n "${version_id}" ]]; then + curl --silent --show-error --fail --request DELETE \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "Accept: application/vnd.github+json" \ + "${GITHUB_API_URL}/${owner_endpoint}/${GITHUB_REPOSITORY_OWNER}/packages/container/${package_name}/versions/${version_id}" + echo "Deleted GHCR package version ${version_id}." + else + echo "No GHCR package version found for tag ${COMMIT_TAG}." + fi + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ github.run_number }} + path: | + output.xml + log.html + report.html + if-no-files-found: ignore + + release: + name: Publish release image + needs: [build, test] + if: >- + needs.build.result == 'success' && + needs.test.result == 'success' && + needs.build.outputs.check_no_ci != 'true' && + (github.event_name == 'push' || github.event_name == 'pull_request') && + vars.CI_ENABLE_RELEASE == 'true' + runs-on: ${{ vars.CI_RELEASE_RUNNER || 'ubuntu-dind' }} + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Create semantic release + id: semantic + uses: cycjimmy/semantic-release-action@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Login to GitHub Container Registry + if: steps.semantic.outputs.new_release_published == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Promote release image + if: steps.semantic.outputs.new_release_published == 'true' + env: + REPO: ${{ github.repository }} + COMMIT_IMAGE: ${{ needs.build.outputs.image_ref }} + RELEASE_TAG: v${{ steps.semantic.outputs.new_release_version }} + run: | + set -euo pipefail + docker pull "${COMMIT_IMAGE}" + docker tag "${COMMIT_IMAGE}" "ghcr.io/${REPO}:${RELEASE_TAG}" + docker tag "${COMMIT_IMAGE}" "ghcr.io/${REPO}:latest" + docker push "ghcr.io/${REPO}:${RELEASE_TAG}" + docker push "ghcr.io/${REPO}:latest" diff --git a/.github/workflow-templates/cleanup.properties.json b/.github/workflow-templates/cleanup.properties.json new file mode 100644 index 0000000..b6fef07 --- /dev/null +++ b/.github/workflow-templates/cleanup.properties.json @@ -0,0 +1,6 @@ +{ + "name": "GHCR Image Cleanup", + "description": "Deletes expired test images from GitHub Container Registry while preserving release and latest images.", + "iconName": "octicon trash", + "categories": ["Deployment", "Docker"] +} diff --git a/.github/workflow-templates/cleanup.yml b/.github/workflow-templates/cleanup.yml new file mode 100644 index 0000000..950428c --- /dev/null +++ b/.github/workflow-templates/cleanup.yml @@ -0,0 +1,71 @@ +name: GHCR Image Cleanup + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 1' + +permissions: + packages: write + +env: + CLEANUP_RETENTION_DAYS: ${{ vars.CI_GHCR_CLEANUP_RETENTION_DAYS || '7' }} + TEST_TAG_PREFIX: ${{ vars.CI_GHCR_TEST_TAG_PREFIX || 'test-v' }} + PACKAGE_NAME: ${{ vars.CI_GHCR_PACKAGE_NAME || github.event.repository.name }} + +jobs: + cleanup: + name: Delete expired test images + runs-on: ${{ vars.CI_GHCR_CLEANUP_RUNNER || 'ubuntu-dind' }} + steps: + - name: Delete old test images + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if ! [[ "${CLEANUP_RETENTION_DAYS}" =~ ^[0-9]+$ ]]; then + echo "CI_GHCR_CLEANUP_RETENTION_DAYS must be a non-negative integer." >&2 + exit 1 + fi + + owner_endpoint="users" + if [[ "${{ github.event.repository.owner.type }}" == "Organization" ]]; then + 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}" | + while IFS= read -r image; do + 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})" + 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}" + sleep 1 + done From 4bf5191141d997eb1add05e6b5fde847e1e6411b Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Thu, 6 Aug 2026 14:42:32 +0700 Subject: [PATCH 02/10] add: run_build action betterleaks-Scan: passed --- actions/run_build/action.yml | 323 +++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 actions/run_build/action.yml diff --git a/actions/run_build/action.yml b/actions/run_build/action.yml new file mode 100644 index 0000000..3db2049 --- /dev/null +++ b/actions/run_build/action.yml @@ -0,0 +1,323 @@ +name: Build, scan, run, and publish a Docker image +description: >- + Builds, optionally scans and starts, and publishes a Docker image to GitHub + Container Registry with commit and test tags. + +inputs: + image_name: + description: Local Docker image name. Defaults to the repository name. + required: false + default: "" + container_name: + description: Container name used for the startup check. Defaults to image_name. + required: false + default: "" + container_mapping_port: + description: Docker port mapping used for the startup check. + required: false + default: "80:80" + build_context: + description: Docker build context. + required: false + default: "." + dockerfile_path: + description: Path to the Dockerfile. + required: false + default: "./Dockerfile" + commit_id: + description: Commit to check out and build. Defaults to github.sha. + required: false + default: "" + enable_scans: + description: Run the shared Dockerfile and image Trivy scans. + required: false + default: "true" + run_container: + description: Start the built image and verify that its container stays running. + required: false + default: "true" + github_token: + description: Token used to read and publish GHCR packages. + required: false + default: "" + push_token: + description: Token used to query the current Actions job. Defaults to github_token. + required: false + default: "" + docker_hub_username: + description: Docker Hub username. Login is skipped when either credential is empty. + required: false + default: "" + docker_hub_access_token: + description: Docker Hub access token. + required: false + default: "" + trivy_server: + description: Trivy server URL. The image scan falls back to a local scan when unavailable. + required: false + default: "" + mail_username: + description: SMTP username used by the shared Trivy scan actions. + required: false + default: "" + mail_password: + description: SMTP password used by the shared Trivy scan actions. + required: false + default: "" + default_mail_recipients: + description: Additional comma-separated vulnerability report recipients. + required: false + default: "" + +outputs: + check_no_ci: + description: Whether the commit message contains no-ci. + value: ${{ steps.check_commit_message.outputs.no_ci }} + check_no_scan: + description: Whether the commit message contains no-scan. + value: ${{ steps.check_commit_message.outputs.no_scan }} + check_no_cache: + description: Whether the commit message contains no-cache. + value: ${{ steps.check_commit_message.outputs.no_cache }} + is_image_tag_by_commit_id_existed: + description: Whether GHCR already contained an image tagged with the short commit ID. + value: ${{ steps.check_existing_image.outputs.image_exists }} + is_latest_or_release_image: + description: Whether the matching GHCR image also has latest or a semantic-version tag. + value: ${{ steps.check_existing_image.outputs.is_latest_or_release_image }} + commit_short_id: + description: Short ID of the commit that was built. + value: ${{ steps.prepare.outputs.commit_short_id }} + image_ref: + description: GHCR image reference tagged with the short commit ID. + value: ${{ steps.prepare.outputs.image_ref }} + +runs: + using: composite + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit_id || github.sha }} + fetch-depth: 0 + + - name: Prepare build metadata + id: prepare + shell: bash + env: + INPUT_IMAGE_NAME: ${{ inputs.image_name }} + INPUT_CONTAINER_NAME: ${{ inputs.container_name }} + run: | + set -euo pipefail + repo="${GITHUB_REPOSITORY,,}" + commit_full_id="$(git rev-parse HEAD)" + commit_short_id="$(git rev-parse --short HEAD)" + image_name="${INPUT_IMAGE_NAME:-${repo#*/}}" + image_name="${image_name,,}" + container_name="${INPUT_CONTAINER_NAME:-${image_name}}" + image_test_tag="test-v${GITHUB_RUN_NUMBER}" + image_ref="ghcr.io/${repo}:${commit_short_id}" + { + echo "REPO=${repo}" + echo "COMMIT_FULL_ID=${commit_full_id}" + echo "COMMIT_SHORT_ID=${commit_short_id}" + echo "IMAGE_NAME=${image_name}" + echo "CONTAINER_NAME=${container_name}" + echo "IMAGE_TEST_TAG=${image_test_tag}" + } >> "${GITHUB_ENV}" + { + echo "commit_short_id=${commit_short_id}" + echo "image_ref=${image_ref}" + } >> "${GITHUB_OUTPUT}" + + - name: Check commit message + id: check_commit_message + shell: bash + run: | + set -euo pipefail + commit_message="$(git log -1 --pretty=%B "${COMMIT_FULL_ID}")" + no_ci=false + no_scan=false + no_cache=false + if [[ "${commit_message}" == *"no-ci"* ]]; then no_ci=true; fi + if [[ "${commit_message}" == *"no-scan"* ]]; then no_scan=true; fi + if [[ "${commit_message}" == *"no-cache"* ]]; then no_cache=true; fi + { + echo "no_ci=${no_ci}" + echo "no_scan=${no_scan}" + echo "no_cache=${no_cache}" + } >> "${GITHUB_OUTPUT}" + + - name: Check existing image in GHCR + id: check_existing_image + if: steps.check_commit_message.outputs.no_ci != 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token || github.token }} + OWNER_TYPE: ${{ github.event.repository.owner.type }} + run: | + set -euo pipefail + package_name="${REPO#*/}" + owner_endpoint="users" + if [[ "${OWNER_TYPE}" == "Organization" ]]; then owner_endpoint="orgs"; fi + response_file="$(mktemp)" + trap 'rm -f "${response_file}"' EXIT + status="$(curl --silent --show-error --output "${response_file}" --write-out '%{http_code}' \ + --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?per_page=100")" + if [[ "${status}" == "404" ]]; then + echo "image_exists=false" >> "${GITHUB_OUTPUT}" + echo "is_latest_or_release_image=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if [[ "${status}" != "200" ]]; then + cat "${response_file}" >&2 + exit 1 + fi + existing_image="$(jq -c --arg tag "${COMMIT_SHORT_ID}" \ + '[.[] | select((.metadata.container.tags // []) | index($tag))][0] // empty' "${response_file}")" + if [[ -z "${existing_image}" ]]; then + echo "image_exists=false" >> "${GITHUB_OUTPUT}" + echo "is_latest_or_release_image=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + protected_image="$(jq -r '(.metadata.container.tags // []) | map(. == "latest" or test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) | any' <<< "${existing_image}")" + echo "image_exists=true" >> "${GITHUB_OUTPUT}" + echo "is_latest_or_release_image=${protected_image}" >> "${GITHUB_OUTPUT}" + + - name: Determine whether a build is required + id: build_plan + if: steps.check_commit_message.outputs.no_ci != 'true' + shell: bash + env: + IMAGE_EXISTS: ${{ steps.check_existing_image.outputs.image_exists }} + run: | + should_build=false + if [[ "${GITHUB_EVENT_NAME}" == "push" || "${GITHUB_EVENT_NAME}" == "pull_request" || "${IMAGE_EXISTS}" != "true" ]]; then + should_build=true + fi + echo "should_build=${should_build}" >> "${GITHUB_OUTPUT}" + + - name: Get job ID + id: get_job_id + if: >- + steps.build_plan.outputs.should_build == 'true' && + inputs.enable_scans == 'true' && + steps.check_commit_message.outputs.no_scan != 'true' + shell: bash + env: + ACTIONS_TOKEN: ${{ inputs.push_token || inputs.github_token || github.token }} + run: | + set -euo pipefail + job_id="$(curl --silent --show-error --fail \ + --header "Authorization: Bearer ${ACTIONS_TOKEN}" \ + --header "Accept: application/vnd.github+json" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \ + | jq -r --arg job "${GITHUB_JOB}" '[.jobs[] | select(.name == $job)][0].id // empty')" + echo "job_id=${job_id}" >> "${GITHUB_OUTPUT}" + + - name: Determine commit author + id: commit_author + if: steps.build_plan.outputs.should_build == 'true' + shell: bash + run: | + { + echo "email=$(git show -s --format='%ae' "${COMMIT_FULL_ID}")" + echo "name=$(git show -s --format='%aN' "${COMMIT_FULL_ID}")" + } >> "${GITHUB_OUTPUT}" + + - name: Login to Docker Hub + if: >- + steps.build_plan.outputs.should_build == 'true' && + inputs.docker_hub_username != '' && + inputs.docker_hub_access_token != '' + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ inputs.docker_hub_username }} + password: ${{ inputs.docker_hub_access_token }} + + - name: Login to GitHub Container Registry + if: steps.build_plan.outputs.should_build == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ inputs.github_token || github.token }} + + - name: Build Docker image without cache + if: >- + steps.build_plan.outputs.should_build == 'true' && + steps.check_commit_message.outputs.no_cache == 'true' + shell: bash + env: + BUILD_CONTEXT: ${{ inputs.build_context }} + DOCKERFILE_PATH: ${{ inputs.dockerfile_path }} + run: docker build --no-cache --file "${DOCKERFILE_PATH}" --tag "${IMAGE_NAME}" "${BUILD_CONTEXT}" + + - name: Build Docker image with cache + if: >- + steps.build_plan.outputs.should_build == 'true' && + steps.check_commit_message.outputs.no_cache != 'true' + shell: bash + env: + BUILD_CONTEXT: ${{ inputs.build_context }} + DOCKERFILE_PATH: ${{ inputs.dockerfile_path }} + run: | + docker pull "ghcr.io/${REPO}:latest" || true + docker build --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from "ghcr.io/${REPO}:latest" \ + --file "${DOCKERFILE_PATH}" --tag "${IMAGE_NAME}" "${BUILD_CONTEXT}" + + - name: Scan Dockerfile using Trivy + if: >- + steps.build_plan.outputs.should_build == 'true' && + inputs.enable_scans == 'true' && + steps.check_commit_message.outputs.no_scan != 'true' + uses: svtechnmaa/.github/actions/run_trivy_scan_dockerfile@main + with: + dockerfile_path: ${{ inputs.dockerfile_path }} + commit_id: ${{ env.COMMIT_FULL_ID }} + mail_username: ${{ inputs.mail_username }} + mail_password: ${{ inputs.mail_password }} + mail_receivers: ${{ steps.commit_author.outputs.email }},${{ inputs.default_mail_recipients }} + commit_author_name: ${{ steps.commit_author.outputs.name }} + job_id: ${{ steps.get_job_id.outputs.job_id }} + + - name: Scan Docker image using Trivy + if: >- + steps.build_plan.outputs.should_build == 'true' && + inputs.enable_scans == 'true' && + steps.check_commit_message.outputs.no_scan != 'true' + uses: svtechnmaa/.github/actions/run_trivy_scan_image@main + with: + trivy_server: ${{ inputs.trivy_server }} + image_name: ${{ env.IMAGE_NAME }} + image_tag: latest + commit_id: ${{ env.COMMIT_FULL_ID }} + mail_username: ${{ inputs.mail_username }} + mail_password: ${{ inputs.mail_password }} + mail_receivers: ${{ steps.commit_author.outputs.email }},${{ inputs.default_mail_recipients }} + commit_author_name: ${{ steps.commit_author.outputs.name }} + job_id: ${{ steps.get_job_id.outputs.job_id }} + + - name: Run application container + if: >- + steps.build_plan.outputs.should_build == 'true' && + inputs.run_container == 'true' + uses: svtechnmaa/.github/actions/run_docker_container@main + with: + imageName: ${{ env.IMAGE_NAME }} + containerName: ${{ env.CONTAINER_NAME }} + containerMappingPort: ${{ inputs.container_mapping_port }} + + - name: Push image to GitHub Container Registry + if: steps.build_plan.outputs.should_build == 'true' + shell: bash + run: | + docker tag "${IMAGE_NAME}:latest" "ghcr.io/${REPO}:${IMAGE_TEST_TAG}" + docker tag "${IMAGE_NAME}:latest" "ghcr.io/${REPO}:${COMMIT_SHORT_ID}" + docker push "ghcr.io/${REPO}:${IMAGE_TEST_TAG}" + docker push "ghcr.io/${REPO}:${COMMIT_SHORT_ID}" From 2615114b1095ddf0dba61e0a0f6b2881a135e9f9 Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Thu, 6 Aug 2026 15:26:16 +0700 Subject: [PATCH 03/10] chore: move the templates to root directory betterleaks-Scan: passed --- .../ci_cd_pipeline.properties.json | 0 .../workflow-templates => workflow-templates}/ci_cd_pipeline.yml | 0 .../cleanup.properties.json | 0 {.github/workflow-templates => workflow-templates}/cleanup.yml | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {.github/workflow-templates => workflow-templates}/ci_cd_pipeline.properties.json (100%) rename {.github/workflow-templates => workflow-templates}/ci_cd_pipeline.yml (100%) rename {.github/workflow-templates => workflow-templates}/cleanup.properties.json (100%) rename {.github/workflow-templates => workflow-templates}/cleanup.yml (100%) diff --git a/.github/workflow-templates/ci_cd_pipeline.properties.json b/workflow-templates/ci_cd_pipeline.properties.json similarity index 100% rename from .github/workflow-templates/ci_cd_pipeline.properties.json rename to workflow-templates/ci_cd_pipeline.properties.json diff --git a/.github/workflow-templates/ci_cd_pipeline.yml b/workflow-templates/ci_cd_pipeline.yml similarity index 100% rename from .github/workflow-templates/ci_cd_pipeline.yml rename to workflow-templates/ci_cd_pipeline.yml diff --git a/.github/workflow-templates/cleanup.properties.json b/workflow-templates/cleanup.properties.json similarity index 100% rename from .github/workflow-templates/cleanup.properties.json rename to workflow-templates/cleanup.properties.json diff --git a/.github/workflow-templates/cleanup.yml b/workflow-templates/cleanup.yml similarity index 100% rename from .github/workflow-templates/cleanup.yml rename to workflow-templates/cleanup.yml From f5c56ea4fec44a974ea89580c99b8f783601f7f5 Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Fri, 7 Aug 2026 11:36:14 +0700 Subject: [PATCH 04/10] feat: minor refactor github template betterleaks-Scan: passed --- .github/workflows/scan-secret.yml | 5 +- actions/publish_chart_updates/action.yml | 201 +++++++++++++++++++ actions/run_build/action.yml | 14 +- actions/run_trivy_scan_dockerfile/action.yml | 2 +- actions/run_trivy_scan_image/action.yml | 2 +- actions/trivy-scan/action.yml | 4 +- workflow-templates/ci_cd_pipeline.yml | 46 +++-- workflow-templates/cleanup.yml | 2 +- 8 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 actions/publish_chart_updates/action.yml diff --git a/.github/workflows/scan-secret.yml b/.github/workflows/scan-secret.yml index 410d3d3..4748f33 100644 --- a/.github/workflows/scan-secret.yml +++ b/.github/workflows/scan-secret.yml @@ -14,7 +14,7 @@ jobs: runs-on: trivy-dind steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -60,7 +60,7 @@ jobs: needs: check-trailer steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 @@ -116,4 +116,3 @@ jobs: image-ref: "" # enable-image-scan: ${{ steps.check_dockerfile.outputs.has_dockerfile }} # image-ref: ${{ steps.build_images.outputs.image_refs }} - diff --git a/actions/publish_chart_updates/action.yml b/actions/publish_chart_updates/action.yml new file mode 100644 index 0000000..954ea95 --- /dev/null +++ b/actions/publish_chart_updates/action.yml @@ -0,0 +1,201 @@ +name: Publish chart updates +description: >- + Updates and pushes image-version changes to a charts repository and a stacked + charts repository after a semantic release. + +inputs: + release_version: + description: Release image tag, for example v2.0.40. + required: true + charts_repository: + description: Full charts repository name, for example owner/charts. + required: true + charts_ref: + description: Branch or tag to update in the charts repository. + required: false + default: main + stacked_charts_repository: + description: Full stacked charts repository name. + required: true + stacked_charts_ref: + description: Branch or tag to update in the stacked charts repository. + required: false + default: master + utilities_repository: + description: Full CI utilities repository name. + required: true + utilities_ref: + description: Branch or tag of the CI utilities repository. + required: false + default: dev-jenkins + chart_values_file: + description: Values file path relative to the charts repository. + required: true + chart_image_repository: + description: Image repository value to update in the chart values file. + required: true + charts_repository_name: + description: Repository name passed to the stacked-chart release utility. + required: false + default: charts + stack_charts_string: + description: Chart identifier string passed to the stacked-chart release utility. + required: true + push_token: + description: Token used to clone and push the chart repositories. + required: true + gpg_private_key: + description: Private GPG key used to sign chart commits. + required: true + gpg_passphrase: + description: Optional passphrase for the GPG key. + required: false + default: "" + gpg_key_id: + description: GPG key fingerprint or ID used for git signing. + required: true + git_user_name: + description: Commit author name. + required: false + default: github-actions[bot] + git_user_email: + description: Commit author email. + required: false + default: 41898282+github-actions[bot]@users.noreply.github.com + +runs: + using: composite + steps: + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: latest + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Clone charts repository + uses: actions/checkout@v6 + with: + repository: ${{ inputs.charts_repository }} + ref: ${{ inputs.charts_ref }} + token: ${{ inputs.push_token }} + path: charts + + - name: Clone stacked charts repository + uses: actions/checkout@v6 + with: + repository: ${{ inputs.stacked_charts_repository }} + ref: ${{ inputs.stacked_charts_ref }} + token: ${{ inputs.push_token }} + path: stacked_charts + + - name: Clone CI utilities repository + uses: actions/checkout@v6 + with: + repository: ${{ inputs.utilities_repository }} + ref: ${{ inputs.utilities_ref }} + token: ${{ inputs.push_token }} + path: SVTECH_CI_utilities + + - name: Import GPG signing key + uses: crazy-max/ghaction-import-gpg@v6.2.0 + with: + gpg_private_key: ${{ inputs.gpg_private_key }} + passphrase: ${{ inputs.gpg_passphrase }} + fingerprint: ${{ inputs.gpg_key_id }} + + - name: Update image version in charts + shell: bash + env: + CHART_VALUES_FILE: ${{ inputs.chart_values_file }} + CHART_IMAGE_REPOSITORY: ${{ inputs.chart_image_repository }} + RELEASE_IMAGE_TAG: ${{ inputs.release_version }} + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import os + + path = Path("charts") / os.environ["CHART_VALUES_FILE"] + repository = os.environ["CHART_IMAGE_REPOSITORY"].lower() + release_tag = os.environ["RELEASE_IMAGE_TAG"] + if not path.is_file(): + raise SystemExit(f"Chart values file does not exist: {path}") + lines = path.read_text().splitlines(keepends=True) + for index, line in enumerate(lines[:-1]): + if line.strip() == f"repository: {repository}" and lines[index + 1].lstrip().startswith("tag:"): + indentation = lines[index + 1][:len(lines[index + 1]) - len(lines[index + 1].lstrip())] + newline = "\n" if lines[index + 1].endswith("\n") else "" + lines[index + 1] = f"{indentation}tag: {release_tag}{newline}" + break + else: + raise SystemExit(f"Could not find repository '{repository}' followed by a tag in {path}") + path.write_text("".join(lines)) + PY + + - name: Update stacked charts metadata + shell: bash + env: + CHARTS_REPOSITORY_NAME: ${{ inputs.charts_repository_name }} + STACK_CHARTS_STRING: ${{ inputs.stack_charts_string }} + PUSH_TOKEN: ${{ inputs.push_token }} + run: | + set -euo pipefail + python3 -m pip install requests pyyaml + python3 SVTECH_CI_utilities/auto_testing/python_script/public_helmchart_release.py \ + --BUILD_VARS_PATH='.' \ + --ID='.' \ + --GITHUB_TOKEN="${PUSH_TOKEN}" \ + --OWNER="${GITHUB_REPOSITORY_OWNER}" \ + --charts_string="${STACK_CHARTS_STRING}" \ + --REPO="${CHARTS_REPOSITORY_NAME}" + + - name: Commit and push charts changes + shell: bash + env: + GIT_USER_NAME: ${{ inputs.git_user_name }} + GIT_USER_EMAIL: ${{ inputs.git_user_email }} + CHARTS_REF: ${{ inputs.charts_ref }} + RELEASE_IMAGE_TAG: ${{ inputs.release_version }} + GPG_KEY_ID: ${{ inputs.gpg_key_id }} + run: | + set -euo pipefail + cd charts + helm repo index artifacthub + if [[ -n "$(git status --porcelain)" ]]; then + git config user.name "${GIT_USER_NAME}" + git config user.email "${GIT_USER_EMAIL}" + git config commit.gpgSign true + git config user.signingkey "${GPG_KEY_ID}" + git add . + git commit --no-verify -m "no-ci: Update image version to ${RELEASE_IMAGE_TAG}" + git push origin "${CHARTS_REF}" + else + echo "No chart changes to commit." + fi + + - name: Commit and push stacked charts changes + shell: bash + env: + GIT_USER_NAME: ${{ inputs.git_user_name }} + GIT_USER_EMAIL: ${{ inputs.git_user_email }} + STACK_CHARTS_REF: ${{ inputs.stacked_charts_ref }} + RELEASE_IMAGE_TAG: ${{ inputs.release_version }} + GPG_KEY_ID: ${{ inputs.gpg_key_id }} + run: | + set -euo pipefail + cd stacked_charts + if [[ -n "$(git status --porcelain)" ]]; then + git config user.name "${GIT_USER_NAME}" + git config user.email "${GIT_USER_EMAIL}" + git config commit.gpgSign true + git config user.signingkey "${GPG_KEY_ID}" + git add . + git commit -S -m "no-ci: Update image version to ${RELEASE_IMAGE_TAG}" + git push origin "${STACK_CHARTS_REF}" + else + echo "No stacked charts changes to commit." + fi diff --git a/actions/run_build/action.yml b/actions/run_build/action.yml index 3db2049..61fa94f 100644 --- a/actions/run_build/action.yml +++ b/actions/run_build/action.yml @@ -91,12 +91,18 @@ outputs: image_ref: description: GHCR image reference tagged with the short commit ID. value: ${{ steps.prepare.outputs.image_ref }} + image_name: + description: Normalized lowercase local Docker image name. + value: ${{ steps.prepare.outputs.image_name }} + container_name: + description: Normalized container name used for the startup check. + value: ${{ steps.prepare.outputs.container_name }} runs: using: composite steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ inputs.commit_id || github.sha }} fetch-depth: 0 @@ -128,6 +134,8 @@ runs: { echo "commit_short_id=${commit_short_id}" echo "image_ref=${image_ref}" + echo "image_name=${image_name}" + echo "container_name=${container_name}" } >> "${GITHUB_OUTPUT}" - name: Check commit message @@ -233,7 +241,7 @@ runs: steps.build_plan.outputs.should_build == 'true' && inputs.docker_hub_username != '' && inputs.docker_hub_access_token != '' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ inputs.docker_hub_username }} @@ -241,7 +249,7 @@ runs: - name: Login to GitHub Container Registry if: steps.build_plan.outputs.should_build == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/actions/run_trivy_scan_dockerfile/action.yml b/actions/run_trivy_scan_dockerfile/action.yml index 8489116..f11e3b3 100644 --- a/actions/run_trivy_scan_dockerfile/action.yml +++ b/actions/run_trivy_scan_dockerfile/action.yml @@ -79,7 +79,7 @@ runs: - name: Upload SARIF to Security tab if: steps.logging_vulns.outputs.detected_vulns == 'true' && github.event.repository.private == 'false' - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-report.sarif category: trivy-dockerfile diff --git a/actions/run_trivy_scan_image/action.yml b/actions/run_trivy_scan_image/action.yml index da778ab..82458f1 100644 --- a/actions/run_trivy_scan_image/action.yml +++ b/actions/run_trivy_scan_image/action.yml @@ -36,7 +36,7 @@ runs: using: "composite" steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Scan Docker image id: scan_docker_image diff --git a/actions/trivy-scan/action.yml b/actions/trivy-scan/action.yml index 9ad815e..5d0aca3 100644 --- a/actions/trivy-scan/action.yml +++ b/actions/trivy-scan/action.yml @@ -355,7 +355,7 @@ runs: - name: Upload scan artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: trivy-report-${{ github.sha }} path: | @@ -375,4 +375,4 @@ runs: [ "$MISCONF" -gt 0 ] && echo "❌ $MISCONF fixable misconfiguration(s)" && FAIL=1 [ "$IMAGE_VULN" -gt 0 ] && echo "❌ $IMAGE_VULN fixable vuln(s) in image" && FAIL=1 [ "$FAIL" -eq 0 ] && echo "✅ No blocking findings. OK to merge." - exit "$FAIL" \ No newline at end of file + exit "$FAIL" diff --git a/workflow-templates/ci_cd_pipeline.yml b/workflow-templates/ci_cd_pipeline.yml index 0d161b7..9768f81 100644 --- a/workflow-templates/ci_cd_pipeline.yml +++ b/workflow-templates/ci_cd_pipeline.yml @@ -27,15 +27,13 @@ permissions: env: COMMIT_FULL_ID: ${{ inputs.commit_id || github.sha }} - IMAGE_NAME: ${{ vars.CI_IMAGE_NAME || github.event.repository.name }} - CONTAINER_NAME: ${{ vars.CI_CONTAINER_NAME || github.event.repository.name }} CONTAINER_MAPPING_PORT: ${{ vars.CI_CONTAINER_MAPPING_PORT || '80:80' }} DOCKERFILE_PATH: ${{ vars.CI_DOCKERFILE_PATH || './Dockerfile' }} jobs: build: name: Build image - runs-on: ${{ vars.CI_BUILD_RUNNER || 'ubuntu-dind' }} + runs-on: ubuntu-dind permissions: contents: read packages: write @@ -48,13 +46,13 @@ jobs: is_latest_or_release_image: ${{ steps.build.outputs.is_latest_or_release_image }} image_ref: ${{ steps.build.outputs.image_ref }} commit_short_id: ${{ steps.build.outputs.commit_short_id }} + image_name: ${{ steps.build.outputs.image_name }} + container_name: ${{ steps.build.outputs.container_name }} steps: - name: Build, scan, and publish image id: build uses: svtechnmaa/.github/actions/run_build@main with: - image_name: ${{ env.IMAGE_NAME }} - container_name: ${{ env.CONTAINER_NAME }} container_mapping_port: ${{ env.CONTAINER_MAPPING_PORT }} dockerfile_path: ${{ env.DOCKERFILE_PATH }} commit_id: ${{ env.COMMIT_FULL_ID }} @@ -74,16 +72,16 @@ jobs: needs.build.result == 'success' && needs.build.outputs.check_no_ci != 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_test == true) - runs-on: ${{ vars.CI_TEST_RUNNER || 'robot-dind' }} + runs-on: ubuntu-dind permissions: contents: read packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ env.COMMIT_FULL_ID }} fetch-depth: 0 - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -94,7 +92,7 @@ jobs: uses: svtechnmaa/.github/actions/run_docker_container@main with: imageName: ${{ needs.build.outputs.image_ref }} - containerName: ${{ env.CONTAINER_NAME }} + containerName: ${{ needs.build.outputs.container_name }} containerMappingPort: ${{ env.CONTAINER_MAPPING_PORT }} - name: Run repository tests shell: bash @@ -136,7 +134,7 @@ jobs: fi - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: test-results-${{ github.run_number }} path: | @@ -154,12 +152,12 @@ jobs: needs.build.outputs.check_no_ci != 'true' && (github.event_name == 'push' || github.event_name == 'pull_request') && vars.CI_ENABLE_RELEASE == 'true' - runs-on: ${{ vars.CI_RELEASE_RUNNER || 'ubuntu-dind' }} + runs-on: ubuntu-dind permissions: contents: write packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Create semantic release @@ -169,7 +167,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Login to GitHub Container Registry if: steps.semantic.outputs.new_release_published == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -187,3 +185,25 @@ jobs: docker tag "${COMMIT_IMAGE}" "ghcr.io/${REPO}:latest" docker push "ghcr.io/${REPO}:${RELEASE_TAG}" docker push "ghcr.io/${REPO}:latest" + + - name: Publish chart and stacked-chart updates + if: steps.semantic.outputs.new_release_published == 'true' && vars.CI_ENABLE_CHARTS_CD == 'true' + uses: svtechnmaa/.github/actions/publish_chart_updates@main + with: + release_version: v${{ steps.semantic.outputs.new_release_version }} + charts_repository: ${{ vars.CI_CHARTS_REPOSITORY || format('{0}/charts', github.repository_owner) }} + charts_ref: ${{ vars.CI_CHARTS_REF || 'main' }} + stacked_charts_repository: ${{ vars.CI_STACK_CHARTS_REPOSITORY || format('{0}/stacked_charts', github.repository_owner) }} + stacked_charts_ref: ${{ vars.CI_STACK_CHARTS_REF || 'master' }} + utilities_repository: ${{ vars.CI_UTILITIES_REPOSITORY || format('{0}/SVTECH_CI_utilities', github.repository_owner) }} + utilities_ref: ${{ vars.CI_UTILITIES_REF || 'dev-jenkins' }} + chart_values_file: ${{ vars.CI_CHART_VALUES_FILE }} + chart_image_repository: ${{ vars.CI_CHART_IMAGE_REPOSITORY || github.repository }} + charts_repository_name: ${{ vars.CI_CHARTS_REPOSITORY_NAME || 'charts' }} + stack_charts_string: ${{ vars.CI_STACK_CHARTS_STRING }} + push_token: ${{ secrets.PUSH_TOKEN }} + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} + gpg_key_id: ${{ secrets.GPG_KEY_ID }} + git_user_name: ${{ vars.CI_RELEASE_GIT_USER_NAME || 'github-actions[bot]' }} + git_user_email: ${{ vars.CI_RELEASE_GIT_USER_EMAIL || '41898282+github-actions[bot]@users.noreply.github.com' }} diff --git a/workflow-templates/cleanup.yml b/workflow-templates/cleanup.yml index 950428c..e0b169b 100644 --- a/workflow-templates/cleanup.yml +++ b/workflow-templates/cleanup.yml @@ -16,7 +16,7 @@ env: jobs: cleanup: name: Delete expired test images - runs-on: ${{ vars.CI_GHCR_CLEANUP_RUNNER || 'ubuntu-dind' }} + runs-on: ubuntu-dind steps: - name: Delete old test images shell: bash From a1fe403fdaf391d13137acc962cce79c553e6d33 Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Fri, 7 Aug 2026 11:44:15 +0700 Subject: [PATCH 05/10] feat: remove unesscary var betterleaks-Scan: passed --- workflow-templates/ci_cd_pipeline.yml | 39 +++++++++++++-------------- workflow-templates/cleanup.yml | 8 +++--- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/workflow-templates/ci_cd_pipeline.yml b/workflow-templates/ci_cd_pipeline.yml index 9768f81..4412b9f 100644 --- a/workflow-templates/ci_cd_pipeline.yml +++ b/workflow-templates/ci_cd_pipeline.yml @@ -27,8 +27,8 @@ permissions: env: COMMIT_FULL_ID: ${{ inputs.commit_id || github.sha }} - CONTAINER_MAPPING_PORT: ${{ vars.CI_CONTAINER_MAPPING_PORT || '80:80' }} - DOCKERFILE_PATH: ${{ vars.CI_DOCKERFILE_PATH || './Dockerfile' }} + CONTAINER_MAPPING_PORT: '8505:8505' + DOCKERFILE_PATH: './Dockerfile' jobs: build: @@ -95,10 +95,9 @@ jobs: containerName: ${{ needs.build.outputs.container_name }} containerMappingPort: ${{ env.CONTAINER_MAPPING_PORT }} - name: Run repository tests - shell: bash - env: - TEST_COMMAND: ${{ vars.CI_TEST_COMMAND || 'robot tests/' }} - run: bash -euo pipefail -c "${TEST_COMMAND}" + run: | + echo "##[command]robot tests/" + robot tests/ - name: Delete commit image after test failure if: failure() && inputs.keep_image != true shell: bash @@ -151,7 +150,7 @@ jobs: needs.test.result == 'success' && needs.build.outputs.check_no_ci != 'true' && (github.event_name == 'push' || github.event_name == 'pull_request') && - vars.CI_ENABLE_RELEASE == 'true' + true runs-on: ubuntu-dind permissions: contents: write @@ -187,23 +186,23 @@ jobs: docker push "ghcr.io/${REPO}:latest" - name: Publish chart and stacked-chart updates - if: steps.semantic.outputs.new_release_published == 'true' && vars.CI_ENABLE_CHARTS_CD == 'true' + if: steps.semantic.outputs.new_release_published == 'true' uses: svtechnmaa/.github/actions/publish_chart_updates@main with: release_version: v${{ steps.semantic.outputs.new_release_version }} - charts_repository: ${{ vars.CI_CHARTS_REPOSITORY || format('{0}/charts', github.repository_owner) }} - charts_ref: ${{ vars.CI_CHARTS_REF || 'main' }} - stacked_charts_repository: ${{ vars.CI_STACK_CHARTS_REPOSITORY || format('{0}/stacked_charts', github.repository_owner) }} - stacked_charts_ref: ${{ vars.CI_STACK_CHARTS_REF || 'master' }} - utilities_repository: ${{ vars.CI_UTILITIES_REPOSITORY || format('{0}/SVTECH_CI_utilities', github.repository_owner) }} - utilities_ref: ${{ vars.CI_UTILITIES_REF || 'dev-jenkins' }} - chart_values_file: ${{ vars.CI_CHART_VALUES_FILE }} - chart_image_repository: ${{ vars.CI_CHART_IMAGE_REPOSITORY || github.repository }} - charts_repository_name: ${{ vars.CI_CHARTS_REPOSITORY_NAME || 'charts' }} - stack_charts_string: ${{ vars.CI_STACK_CHARTS_STRING }} + charts_repository: ${{ github.repository_owner }}/charts + charts_ref: main + stacked_charts_repository: ${{ github.repository_owner }}/stacked_charts + stacked_charts_ref: master + utilities_repository: ${{ github.repository_owner }}/SVTECH_CI_utilities + utilities_ref: dev-jenkins + chart_values_file: kubernetes/bngblaster/values.yaml + chart_image_repository: svtechnmaa/bngblaster_web_client + charts_repository_name: charts + stack_charts_string: bngblaster push_token: ${{ secrets.PUSH_TOKEN }} gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} gpg_key_id: ${{ secrets.GPG_KEY_ID }} - git_user_name: ${{ vars.CI_RELEASE_GIT_USER_NAME || 'github-actions[bot]' }} - git_user_email: ${{ vars.CI_RELEASE_GIT_USER_EMAIL || '41898282+github-actions[bot]@users.noreply.github.com' }} + git_user_name: svtechnmaa + git_user_email: nmaa@svtech.com.vn diff --git a/workflow-templates/cleanup.yml b/workflow-templates/cleanup.yml index e0b169b..06859fa 100644 --- a/workflow-templates/cleanup.yml +++ b/workflow-templates/cleanup.yml @@ -9,9 +9,9 @@ permissions: packages: write env: - CLEANUP_RETENTION_DAYS: ${{ vars.CI_GHCR_CLEANUP_RETENTION_DAYS || '7' }} - TEST_TAG_PREFIX: ${{ vars.CI_GHCR_TEST_TAG_PREFIX || 'test-v' }} - PACKAGE_NAME: ${{ vars.CI_GHCR_PACKAGE_NAME || github.event.repository.name }} + CLEANUP_RETENTION_DAYS: '7' + TEST_TAG_PREFIX: 'test-v' + PACKAGE_NAME: ${{ github.event.repository.name }} jobs: cleanup: @@ -26,7 +26,7 @@ jobs: set -euo pipefail if ! [[ "${CLEANUP_RETENTION_DAYS}" =~ ^[0-9]+$ ]]; then - echo "CI_GHCR_CLEANUP_RETENTION_DAYS must be a non-negative integer." >&2 + echo "Cleanup retention days must be a non-negative integer." >&2 exit 1 fi From 8aa9843306326918bf11877d9d51e4ea8c2aeec5 Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Wed, 19 Aug 2026 14:34:58 +0700 Subject: [PATCH 06/10] chore: rename workflow template betterleaks-Scan: passed --- .../{ci_cd_pipeline.properties.json => ci.properties.json} | 2 +- workflow-templates/{ci_cd_pipeline.yml => ci.yml} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename workflow-templates/{ci_cd_pipeline.properties.json => ci.properties.json} (86%) rename workflow-templates/{ci_cd_pipeline.yml => ci.yml} (99%) diff --git a/workflow-templates/ci_cd_pipeline.properties.json b/workflow-templates/ci.properties.json similarity index 86% rename from workflow-templates/ci_cd_pipeline.properties.json rename to workflow-templates/ci.properties.json index 21f0e62..cb31b84 100644 --- a/workflow-templates/ci_cd_pipeline.properties.json +++ b/workflow-templates/ci.properties.json @@ -1,5 +1,5 @@ { - "name": "CI/CD Pipeline", + "name": "Actions CI Workflow", "description": "Builds a Docker image, runs repository tests, and optionally promotes semantic releases.", "iconName": "octicon package", "categories": ["Docker", "Continuous integration", "Deployment"] diff --git a/workflow-templates/ci_cd_pipeline.yml b/workflow-templates/ci.yml similarity index 99% rename from workflow-templates/ci_cd_pipeline.yml rename to workflow-templates/ci.yml index 4412b9f..9cf002f 100644 --- a/workflow-templates/ci_cd_pipeline.yml +++ b/workflow-templates/ci.yml @@ -1,4 +1,4 @@ -name: CI/CD Pipeline +name: Actions CI Workflow on: push: From 04f67a1eebfeb567ce15d5f95bd447a04dea7e7b Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Fri, 21 Aug 2026 15:57:37 +0700 Subject: [PATCH 07/10] chore(ci): change test workflow to use robot-dind betterleaks-Scan: passed --- workflow-templates/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflow-templates/ci.yml b/workflow-templates/ci.yml index b757d4c..fc18d8f 100644 --- a/workflow-templates/ci.yml +++ b/workflow-templates/ci.yml @@ -85,7 +85,7 @@ jobs: needs.build.result == 'success' && needs.build.outputs.check_no_ci != 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_test == true) - runs-on: ubuntu-dind + runs-on: robot-dind permissions: contents: read packages: write From afad78328eaad1f6eb0de44a11cd7a1e3831d78e Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Thu, 27 Aug 2026 14:44:45 +0700 Subject: [PATCH 08/10] fix(ci): reslove and add readme to workflow template betterleaks-Scan: passed --- README.md | 6 +++- actions/publish_chart_updates/action.yml | 4 +-- workflow-templates/README.md | 46 ++++++++++++++++++++++++ workflow-templates/ci.yml | 28 +++++++++++---- 4 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 workflow-templates/README.md diff --git a/README.md b/README.md index 02fe869..a7092ba 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ # .github A magic repo for workflow template. -This template will show up in github market place. \ No newline at end of file +These templates appear in the **Actions → New workflow** picker for repositories +in the organization. + +For chart/CD configuration instructions, see +[`workflow-templates/README.md`](workflow-templates/README.md). diff --git a/actions/publish_chart_updates/action.yml b/actions/publish_chart_updates/action.yml index 954ea95..70e22a9 100644 --- a/actions/publish_chart_updates/action.yml +++ b/actions/publish_chart_updates/action.yml @@ -98,7 +98,7 @@ runs: repository: ${{ inputs.utilities_repository }} ref: ${{ inputs.utilities_ref }} token: ${{ inputs.push_token }} - path: SVTECH_CI_utilities + path: SVTECH_CICD_Utilities - name: Import GPG signing key uses: crazy-max/ghaction-import-gpg@v6.2.0 @@ -145,7 +145,7 @@ runs: run: | set -euo pipefail python3 -m pip install requests pyyaml - python3 SVTECH_CI_utilities/auto_testing/python_script/public_helmchart_release.py \ + python3 SVTECH_CICD_Utilities/auto_testing/python_script/public_helmchart_release.py \ --BUILD_VARS_PATH='.' \ --ID='.' \ --GITHUB_TOKEN="${PUSH_TOKEN}" \ diff --git a/workflow-templates/README.md b/workflow-templates/README.md new file mode 100644 index 0000000..048a48e --- /dev/null +++ b/workflow-templates/README.md @@ -0,0 +1,46 @@ +# CI/CD Pipeline chart settings + +The `ci.yml` starter workflow can update your Helm chart repositories after a +semantic release. When you choose the workflow from **Actions → New workflow**, +review the four chart settings in the `Publish chart and stacked-chart updates` +step before committing the generated workflow. + +```yaml +chart_values_file: kubernetes/my-app/values.yaml +chart_image_repository: my-org/my-app +charts_repository_name: charts +stack_charts_string: my-app +``` + +## What to change + +| Setting | What it means | Example | +| --- | --- | --- | +| `chart_values_file` | Path to the image values file inside the `charts_repository`. The file must contain an image `repository:` line followed by its `tag:` line. | `kubernetes/my-app/values.yaml` | +| `chart_image_repository` | Exact value of the chart’s `repository:` field. The action finds this value and changes the following `tag:` to the new release tag. | `my-org/my-app` | +| `charts_repository_name` | Repository name passed to the stacked-chart update utility. Use the repository name only, not the full `owner/name`. | `charts` | +| `stack_charts_string` | Chart or application identifier understood by your stacked-chart update utility. | `my-app` | + +## Related repository settings + +The generated workflow assumes these repositories and branches by default: + +- `OWNER/charts` on `main` +- `OWNER/stacked_charts` on `master` +- `OWNER/SVTECH_CICD_Utilities` on `dev-jenkins` + +Change the `*_repository` and `*_ref` values in the generated workflow if your +organization uses different names or branches. + +## Required secrets + +Chart CD runs only after a semantic release and requires: + +- `PUSH_TOKEN`: permission to clone and push the chart repositories +- `GPG_PRIVATE_KEY`: private key used to sign commits +- `GPG_KEY_ID`: key fingerprint or signing key ID +- `GPG_PASSPHRASE`: passphrase, when the key is protected + +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. diff --git a/workflow-templates/ci.yml b/workflow-templates/ci.yml index fc18d8f..8a93b02 100644 --- a/workflow-templates/ci.yml +++ b/workflow-templates/ci.yml @@ -26,7 +26,7 @@ permissions: contents: read env: - COMMIT_FULL_ID: ${{ inputs.commit_id || github.sha }} + COMMIT_FULL_ID: ${{ inputs.commit_id || github.event.pull_request.head.sha || github.event.after || github.sha }} CONTAINER_MAPPING_PORT: '8505:8505' DOCKERFILE_PATH: './Dockerfile' @@ -225,15 +225,31 @@ jobs: charts_ref: main stacked_charts_repository: ${{ github.repository_owner }}/stacked_charts stacked_charts_ref: master - utilities_repository: ${{ github.repository_owner }}/SVTECH_CI_utilities + utilities_repository: ${{ github.repository_owner }}/SVTECH_CICD_Utilities utilities_ref: dev-jenkins - chart_values_file: kubernetes/bngblaster/values.yaml - chart_image_repository: svtechnmaa/bngblaster_web_client - charts_repository_name: charts - stack_charts_string: bngblaster + # Configure these four values for the repositories used by your project. + # See workflow-templates/README.md in the central .github repository. + chart_values_file: kubernetes/bngblaster/values.yaml # path inside charts_repository to the values.yaml file + chart_image_repository: svtechnmaa/bngblaster_web_client # repository: value paired with tag: in that values file + charts_repository_name: charts # short repository name passed to the stacked-chart updater + stack_charts_string: bngblaster # chart/application identifier understood by the stacked-chart updater push_token: ${{ secrets.PUSH_TOKEN }} gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} gpg_key_id: ${{ secrets.GPG_KEY_ID }} git_user_name: svtechnmaa git_user_email: nmaa@svtech.com.vn + + check_job_results: + name: Check job results + if: always() + needs: + - build + - test + - release + runs-on: ubuntu-dind + steps: + - name: Check job results + uses: svtechnmaa/.github/actions/check_job_results@main + with: + jobs: ${{ toJSON(needs) }} From e3f5adbd400c2ca3c9fc3c937d3d82b1b53054a2 Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Thu, 27 Aug 2026 16:23:57 +0700 Subject: [PATCH 09/10] chore(ci): fix minor syntax betterleaks-Scan: passed --- actions/run_build/action.yml | 6 +++--- workflow-templates/ci.yml | 12 ++++++------ workflow-templates/cleanup.yml | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/actions/run_build/action.yml b/actions/run_build/action.yml index 61fa94f..4689a51 100644 --- a/actions/run_build/action.yml +++ b/actions/run_build/action.yml @@ -161,7 +161,7 @@ runs: if: steps.check_commit_message.outputs.no_ci != 'true' shell: bash env: - GH_TOKEN: ${{ inputs.github_token || github.token }} + GH_TOKEN: ${{ inputs.github_token }} OWNER_TYPE: ${{ github.event.repository.owner.type }} run: | set -euo pipefail @@ -216,7 +216,7 @@ runs: steps.check_commit_message.outputs.no_scan != 'true' shell: bash env: - ACTIONS_TOKEN: ${{ inputs.push_token || inputs.github_token || github.token }} + ACTIONS_TOKEN: ${{ inputs.push_token || inputs.github_token }} run: | set -euo pipefail job_id="$(curl --silent --show-error --fail \ @@ -253,7 +253,7 @@ runs: with: registry: ghcr.io username: ${{ github.repository_owner }} - password: ${{ inputs.github_token || github.token }} + password: ${{ inputs.github_token }} - name: Build Docker image without cache if: >- diff --git a/workflow-templates/ci.yml b/workflow-templates/ci.yml index 8a93b02..887d441 100644 --- a/workflow-templates/ci.yml +++ b/workflow-templates/ci.yml @@ -56,7 +56,7 @@ jobs: container_mapping_port: ${{ env.CONTAINER_MAPPING_PORT }} dockerfile_path: ${{ env.DOCKERFILE_PATH }} commit_id: ${{ env.COMMIT_FULL_ID }} - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.GH_TOKEN }} push_token: ${{ secrets.PUSH_TOKEN }} docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} @@ -107,7 +107,7 @@ jobs: with: registry: ghcr.io username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.GH_TOKEN }} - name: Pull commit image run: docker pull "${{ steps.resolve_image.outputs.image_ref }}" - name: Run application container @@ -124,7 +124,7 @@ jobs: if: failure() && inputs.keep_image != true shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} COMMIT_TAG: ${{ steps.resolve_image.outputs.commit_short_id }} run: | set -euo pipefail @@ -194,14 +194,14 @@ jobs: id: semantic uses: cycjimmy/semantic-release-action@v4 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - name: Login to GitHub Container Registry if: steps.semantic.outputs.new_release_published == 'true' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.GH_TOKEN }} - name: Promote release image if: steps.semantic.outputs.new_release_published == 'true' env: @@ -226,7 +226,7 @@ jobs: stacked_charts_repository: ${{ github.repository_owner }}/stacked_charts stacked_charts_ref: master utilities_repository: ${{ github.repository_owner }}/SVTECH_CICD_Utilities - utilities_ref: dev-jenkins + utilities_ref: main # Configure these four values for the repositories used by your project. # See workflow-templates/README.md in the central .github repository. chart_values_file: kubernetes/bngblaster/values.yaml # path inside charts_repository to the values.yaml file diff --git a/workflow-templates/cleanup.yml b/workflow-templates/cleanup.yml index 06859fa..ba43884 100644 --- a/workflow-templates/cleanup.yml +++ b/workflow-templates/cleanup.yml @@ -21,7 +21,7 @@ jobs: - name: Delete old test images shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} run: | set -euo pipefail From dfd6654a92c53ee5993a321a280b60b52f077f64 Mon Sep 17 00:00:00 2001 From: "r.redgrave11621" Date: Fri, 4 Sep 2026 15:27:57 +0700 Subject: [PATCH 10/10] chore(ci): change ci to run with docker compose and change to safeguard for the cleanup action betterleaks-Scan: passed --- workflow-templates/README.md | 29 ++++ workflow-templates/ci.properties.json | 2 +- workflow-templates/ci.yml | 44 +++++- workflow-templates/cleanup.properties.json | 2 +- workflow-templates/cleanup.yml | 153 ++++++++++++++++----- 5 files changed, 196 insertions(+), 34 deletions(-) diff --git a/workflow-templates/README.md b/workflow-templates/README.md index 048a48e..ff54e9a 100644 --- a/workflow-templates/README.md +++ b/workflow-templates/README.md @@ -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. diff --git a/workflow-templates/ci.properties.json b/workflow-templates/ci.properties.json index cb31b84..a38dd44 100644 --- a/workflow-templates/ci.properties.json +++ b/workflow-templates/ci.properties.json @@ -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"] } diff --git a/workflow-templates/ci.yml b/workflow-templates/ci.yml index 887d441..22f8f2c 100644 --- a/workflow-templates/ci.yml +++ b/workflow-templates/ci.yml @@ -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 @@ -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 }} diff --git a/workflow-templates/cleanup.properties.json b/workflow-templates/cleanup.properties.json index b6fef07..5448f55 100644 --- a/workflow-templates/cleanup.properties.json +++ b/workflow-templates/cleanup.properties.json @@ -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"] } diff --git a/workflow-templates/cleanup.yml b/workflow-templates/cleanup.yml index ba43884..98714b2 100644 --- a/workflow-templates/cleanup.yml +++ b/workflow-templates/cleanup.yml @@ -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 @@ -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}"