From ef6d4570463ccb4cf7874ebcec5aa2061c525a60 Mon Sep 17 00:00:00 2001 From: Sean Tronsen Date: Tue, 13 Oct 2026 13:44:22 -0600 Subject: [PATCH 1/2] feat!: clean every discovered image, one job per image Signed-off-by: Sean Tronsen --- .github/workflows/pr-registry-cleanup.yml | 100 +++++++++++++--------- README.md | 8 +- 2 files changed, 65 insertions(+), 43 deletions(-) diff --git a/.github/workflows/pr-registry-cleanup.yml b/.github/workflows/pr-registry-cleanup.yml index ee7be5c..e5d7f4f 100644 --- a/.github/workflows/pr-registry-cleanup.yml +++ b/.github/workflows/pr-registry-cleanup.yml @@ -2,64 +2,84 @@ # SPDX-License-Identifier: MIT # # Reusable workflow: deletes the GHCR container image versions a pull request -# published, once that pull request closes. +# published, once that pull request closes. Call it from a `pull_request` +# `closed` trigger; the PR number comes from the event. GITHUB_TOKEN can't list +# packages, so image names come from `images`, else the ghcr.io entries in the +# GoReleaser config (`dockers_v2[].images`), else the repository name (see +# actions/discover-ghcr-images). name: pr-registry-cleanup on: workflow_call: inputs: - pr-number: - description: 'Number of the PR whose container tags should be removed' - default: ${{ github.event.pull_request.number || github.event.number }} - type: string - tag-prefix: - description: 'Prefix of the tags published by PR builds; the base tag is ``' - default: 'pr-' + images: + description: 'Space- or newline-separated GHCR package names to clean up (default: from the GoReleaser config, else the repository name)' + required: false type: string + default: '' permissions: + contents: read # checkout, to read the GoReleaser config packages: write # delete container image versions from GHCR jobs: - cleanup: + discover: + # Fork PRs publish nothing (and get a read-only token). Other events run + # so the trigger check can fail them. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-slim timeout-minutes: 10 + outputs: + packages: ${{ steps.packages.outputs.packages }} steps: - - name: Resolve PR number + - name: Check trigger + if: ${{ github.event_name != 'pull_request' }} env: - PR_NUMBER: ${{ inputs.pr-number }} + EVENT: ${{ github.event_name }} run: | - set -euo pipefail + echo "::error::pr-registry-cleanup runs on pull_request (closed); got ${EVENT}" + exit 1 - if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then - echo "pr-number must be a PR number, got '${PR_NUMBER}'" - exit 1 - fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Discover images + id: discover + uses: OpenCHAMI/github-actions/actions/discover-ghcr-images@26748ab38b9b17fa895919ef937aca32376a23e2 + with: + images: ${{ inputs.images }} + + - name: List packages for the matrix + id: packages + env: + NAMES: ${{ steps.discover.outputs.names }} + run: echo "packages=$(jq -cn --arg names "$NAMES" '$names | split(" ")')" >> "$GITHUB_OUTPUT" + + cleanup: + needs: discover + runs-on: ubuntu-slim + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.discover.outputs.packages) }} + steps: - name: Delete PR container image versions - # Cleanup is best effort: a package that was never published, or a tag a - # concurrent run already removed, must not fail the caller's PR close. - continue-on-error: true env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} OWNER: ${{ github.repository_owner }} - PACKAGE: ${{ github.event.repository.name }} - PR_NUMBER: ${{ inputs.pr-number }} - TAG_PREFIX: ${{ inputs.tag-prefix }} + PACKAGE: ${{ matrix.package }} + # PR builds are tagged pr- by build-release-goreleaser. + BASE_TAG: pr-${{ github.event.pull_request.number }} run: | set -euo pipefail - - base_tag="${TAG_PREFIX}${PR_NUMBER}" - - echo "Cleaning up container images of ${PACKAGE} tagged ${base_tag}*..." - # A version is ours when one of its tags is the base tag itself or the # base tag plus a separated qualifier (pr-12, pr-12-arm64, # pr-12-dirty-abc123). Requiring the separator keeps a cleanup of # pr-12 away from pr-123. - versions=$(gh api --paginate \ - "/orgs/${OWNER}/packages/container/${PACKAGE}/versions" \ - | jq -r --arg base "${base_tag}" ' + versions=$(gh api --paginate "/orgs/${OWNER}/packages/container/${PACKAGE}/versions" \ + | jq -r --arg base "${BASE_TAG}" ' .[] | select( (.metadata.container.tags // []) @@ -69,18 +89,20 @@ jobs: ) ) | .id - ') + ') || { echo "::error::Can't read versions of ghcr.io package '${PACKAGE}'."; exit 1; } if [[ -z "${versions}" ]]; then - echo "No versions found for ${PACKAGE} tagged ${base_tag}*" + echo "No versions tagged ${BASE_TAG}*" exit 0 fi + failed=false while read -r version_id; do - echo "Deleting version ID ${version_id}" - gh api --method DELETE \ - "/orgs/${OWNER}/packages/container/${PACKAGE}/versions/${version_id}" \ - || echo "Failed to delete version ${version_id}" + echo "Deleting version ${version_id}" + if ! gh api --method DELETE "/orgs/${OWNER}/packages/container/${PACKAGE}/versions/${version_id}"; then + echo "::error::Can't delete version ${version_id} of ghcr.io package '${PACKAGE}'." + failed=true + fi done <<< "${versions}" - - echo "Cleanup completed for PR ${PR_NUMBER}" + [[ "$failed" == false ]] || exit 1 + echo "Cleanup completed for ${BASE_TAG}" diff --git a/README.md b/README.md index 5f268c7..163f0af 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,7 @@ jobs: ``` ### pr-registry-cleanup (Reusable Workflow) -Deletes the GHCR container image versions a pull request published, once that pull request closes. Matches the PR's base tag and any separated qualifier (`pr-12`, `pr-12-amd64`, `pr-12-dirty-abc123`). +Deletes the GHCR container image versions a pull request published, once that pull request closes. Matches the PR's base tag and any separated qualifier (`pr-12`, `pr-12-amd64`, `pr-12-dirty-abc123`) in each image, one job per image. Image names come from `images`, else the GoReleaser config, else the repository name (see `discover-ghcr-images`). Fork PRs are skipped, since they publish nothing. Every name must be an existing package; all failed lookups and deletes are reported, then the run fails. **Usage:** ```yaml @@ -497,11 +497,11 @@ jobs: cleanup: uses: OpenCHAMI/github-actions/.github/workflows/pr-registry-cleanup.yml@v4.0 permissions: + contents: read packages: write # Optional overrides: # with: - # pr-number: 123 - # tag-prefix: pr- + # images: foo foo-init # default: from the GoReleaser config, else the repo name ``` ### stale (Reusable Workflow) @@ -598,7 +598,7 @@ Signs an RPM using a provided GPG fingerprint (works with the ephemeral key outp Fails CI if the provided signing key is expired or expiring within a threshold. See the [action README](actions/gpg-check-key-expiration/README.md). ### discover-ghcr-images -Finds a repo's GHCR image names from the `images` input, else the GoReleaser config, else the repo name, and verifies each one exists. Used by `trivy-image-scan`. See the [action README](actions/discover-ghcr-images/README.md). +Finds a repo's GHCR image names from the `images` input, else the GoReleaser config, else the repo name, and verifies each one exists. Used by `trivy-image-scan` and `pr-registry-cleanup`. See the [action README](actions/discover-ghcr-images/README.md). ### gpg-verify-trust-chain (Deprecated) From 73e0addccb858bd9afdd3340d7fdeb5544b19d27 Mon Sep 17 00:00:00 2001 From: Sean Tronsen Date: Mon, 19 Oct 2026 10:17:45 -0600 Subject: [PATCH 2/2] fix: use url-encoding for package names in pr cleanup Signed-off-by: Sean Tronsen --- .github/workflows/pr-registry-cleanup.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-registry-cleanup.yml b/.github/workflows/pr-registry-cleanup.yml index e5d7f4f..e03633b 100644 --- a/.github/workflows/pr-registry-cleanup.yml +++ b/.github/workflows/pr-registry-cleanup.yml @@ -74,11 +74,13 @@ jobs: BASE_TAG: pr-${{ github.event.pull_request.number }} run: | set -euo pipefail + # Nested package names (tools/foo) need their slash encoded. + encoded=$(jq -rn --arg name "$PACKAGE" '$name | @uri') # A version is ours when one of its tags is the base tag itself or the # base tag plus a separated qualifier (pr-12, pr-12-arm64, # pr-12-dirty-abc123). Requiring the separator keeps a cleanup of # pr-12 away from pr-123. - versions=$(gh api --paginate "/orgs/${OWNER}/packages/container/${PACKAGE}/versions" \ + versions=$(gh api --paginate "/orgs/${OWNER}/packages/container/${encoded}/versions" \ | jq -r --arg base "${BASE_TAG}" ' .[] | select( @@ -99,7 +101,7 @@ jobs: failed=false while read -r version_id; do echo "Deleting version ${version_id}" - if ! gh api --method DELETE "/orgs/${OWNER}/packages/container/${PACKAGE}/versions/${version_id}"; then + if ! gh api --method DELETE "/orgs/${OWNER}/packages/container/${encoded}/versions/${version_id}"; then echo "::error::Can't delete version ${version_id} of ghcr.io package '${PACKAGE}'." failed=true fi