From 119868fa2ed8001001760fe4e100394504d833bf Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Tue, 25 Aug 2026 04:12:30 +0000 Subject: [PATCH] ci: clean up old Docker preview images (#2539) Add a weekly workflow that deletes old Docker preview images from `ghcr.io/voidzero-dev/vite-plus`. The workflow keeps the 10 newest preview versions. It also keeps each preview version for three calendar months. It selects only `pr-` and `pr--` tags. It does not select release tags or untagged versions. Preview builds now add a permanent `pr--` tag. This tag keeps each preview identifiable when the mutable `pr-` tag moves. Historical untagged versions remain outside this workflow. Maintainers can remove those versions manually. A temporary pull request trigger ran the tag-based cleanup against the live package. The [test run](https://github.com/voidzero-dev/vite-plus/actions/runs/32634499358) found 113 package versions and 45 tagged preview versions. It deleted no versions because no tagged preview was older than three months. --- .../cleanup-docker-preview-images.yml | 95 +++++++++++++++++++ .../workflows/publish-preview-register.yml | 10 +- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/cleanup-docker-preview-images.yml diff --git a/.github/workflows/cleanup-docker-preview-images.yml b/.github/workflows/cleanup-docker-preview-images.yml new file mode 100644 index 0000000000..ad5748dc8b --- /dev/null +++ b/.github/workflows/cleanup-docker-preview-images.yml @@ -0,0 +1,95 @@ +name: Cleanup Docker preview images + +on: + schedule: + # Weekly: Monday 03:17 UTC. The non-zero minute avoids common + # top-of-hour scheduling congestion. + - cron: '17 3 * * 1' + workflow_dispatch: + +permissions: {} + +concurrency: + group: cleanup-docker-preview-images + cancel-in-progress: false + +jobs: + cleanup: + if: github.repository == 'voidzero-dev/vite-plus' && github.event.repository.fork == false + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + packages: write + steps: + - name: Delete expired preview images + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const packageType = 'container'; + const packageName = 'vite-plus'; + const minimumToKeep = 10; + const retentionMonths = 3; + const previewTag = /^pr-[1-9]\d*(?:-[0-9a-f]{40})?$/; + const { owner: org } = context.repo; + + const cutoff = new Date(); + const cutoffDay = cutoff.getUTCDate(); + cutoff.setUTCDate(1); + cutoff.setUTCMonth(cutoff.getUTCMonth() - retentionMonths); + const lastCutoffDay = new Date( + Date.UTC(cutoff.getUTCFullYear(), cutoff.getUTCMonth() + 1, 0), + ).getUTCDate(); + cutoff.setUTCDate(Math.min(cutoffDay, lastCutoffDay)); + + const versions = await github.paginate( + 'GET /orgs/{org}/packages/{package_type}/{package_name}/versions', + { + org, + package_type: packageType, + package_name: packageName, + per_page: 100, + }, + ); + + // A version is a preview only when every tag uses a preview-only + // form from publish-preview-register.yml. Release and untagged + // versions are never eligible for deletion. + const previews = versions + .filter((version) => { + const tags = version.metadata?.container?.tags ?? []; + return tags.length > 0 && tags.every((tag) => previewTag.test(tag)); + }) + .sort((left, right) => { + const byCreation = Date.parse(right.created_at) - Date.parse(left.created_at); + return byCreation || right.id - left.id; + }); + + const expired = previews + .slice(minimumToKeep) + .filter((version) => new Date(version.created_at) < cutoff); + + core.info( + `Found ${versions.length} package versions and ${previews.length} preview versions.`, + ); + core.info( + `Keeping the newest ${Math.min(minimumToKeep, previews.length)} previews and ` + + `all previews created on or after ${cutoff.toISOString()}.`, + ); + + for (const version of expired) { + const tags = version.metadata.container.tags.join(', '); + core.info( + `Deleting ${tags} (version ${version.id}, created ${version.created_at}).`, + ); + await github.request( + 'DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}', + { + org, + package_type: packageType, + package_name: packageName, + package_version_id: version.id, + }, + ); + } + + core.info(`Deleted ${expired.length} expired preview versions.`); diff --git a/.github/workflows/publish-preview-register.yml b/.github/workflows/publish-preview-register.yml index 03339c0e15..2f294af714 100644 --- a/.github/workflows/publish-preview-register.yml +++ b/.github/workflows/publish-preview-register.yml @@ -455,8 +455,10 @@ jobs: } # Build and push a preview Docker image from the registry bridge build so the - # image can be verified before a real release. Tagged `pr-`; never - # `latest`. See docker/Dockerfile and docs/guide/docker.md. + # image can be verified before a real release. Tagged `pr-` and + # `pr--`; never `latest`. The commit tag remains on older + # builds when the mutable PR tag moves, so cleanup can identify them safely. + # See docker/Dockerfile and docs/guide/docker.md. # # Same-repo PRs only, for now. This job installs the preview package, which # executes the PR's code, and pushes the result to the org's GHCR namespace as @@ -501,7 +503,9 @@ jobs: file: docker/Dockerfile platforms: linux/amd64 push: true - tags: ${{ env.IMAGE }}:pr-${{ needs.authorize.outputs.pr }} + tags: | + ${{ env.IMAGE }}:pr-${{ needs.authorize.outputs.pr }} + ${{ env.IMAGE }}:pr-${{ needs.authorize.outputs.pr }}-${{ github.event.workflow_run.head_sha }} build-args: | VP_PR_VERSION=${{ needs.authorize.outputs.pr }} # Single-manifest image (no attestation index): simpler for consumers