From 2e8b2c1c3b4754534d21d0cf014caf19d6db557e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Plaza=20Sisqu=C3=A9s?= Date: Wed, 16 Sep 2026 11:51:16 +0200 Subject: [PATCH] feat(image-tag-cleanup): wire cleanup into Docker Hub + GHCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the registry-facing half of tag retention (builds on feat/image-tag-cleanup-01-select-logic's pure selection logic): - cleanup-dockerhub.sh: lists tags via the Docker Hub API, deletes only what select-deletions.sh selects. - cleanup-ghcr.sh: lists package versions via the GitHub API (a version can carry several tags at once — skipped entirely if any tag is non-ephemeral), same delegation. - Composite action wrapping both behind a registry: dockerhub|ghcr input, and a new reusable workflow (image-cleanup.yml) calling it once per enabled registry. No on: trigger of its own — the consumer repo owns the schedule. Recorded as design.md D9 and tasks.md section 6. dry_run defaults false but is documented as the required first step on any new repo. --- .github/actions/image-tag-cleanup/action.yml | 67 ++++++++++++++ .../image-tag-cleanup/cleanup-dockerhub.sh | 52 +++++++++++ .../actions/image-tag-cleanup/cleanup-ghcr.sh | 56 +++++++++++ .github/workflows/image-cleanup.yml | 92 +++++++++++++++++++ README.md | 56 ++++++++++- openspec/changes/trunk-based-ci-cd/design.md | 12 +++ openspec/changes/trunk-based-ci-cd/tasks.md | 11 +++ 7 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 .github/actions/image-tag-cleanup/action.yml create mode 100755 .github/actions/image-tag-cleanup/cleanup-dockerhub.sh create mode 100755 .github/actions/image-tag-cleanup/cleanup-ghcr.sh create mode 100644 .github/workflows/image-cleanup.yml diff --git a/.github/actions/image-tag-cleanup/action.yml b/.github/actions/image-tag-cleanup/action.yml new file mode 100644 index 0000000..4e93e0d --- /dev/null +++ b/.github/actions/image-tag-cleanup/action.yml @@ -0,0 +1,67 @@ +name: Image tag cleanup +description: Delete ephemeral (commit-tagged) image tags/versions older than a retention window, always keeping a minimum number, and never touching anything that doesn't match the ephemeral prefix. + +inputs: + registry: + description: "Which registry to clean (dockerhub | ghcr)" + required: true + image_name: + description: "Docker Hub image name (e.g. sisqueslabs/beacon-api). Required when registry=dockerhub." + required: false + default: "" + ghcr_image_name: + description: "GHCR image name (e.g. ghcr.io/sisques-labs/beacon-api). Required when registry=ghcr." + required: false + default: "" + ephemeral_tag_prefix: + description: "Only tags starting with this prefix are ever eligible for deletion. Everything else (stable releases, latest, edge, legacy alpha/beta/rc, ...) is always protected." + required: false + default: "sha-" + retention_days: + description: "Delete eligible tags/versions older than this many days" + required: false + default: "14" + keep_min: + description: "Always keep at least this many of the most recently updated eligible tags/versions, regardless of age" + required: false + default: "5" + dry_run: + description: "Log what would be deleted without deleting anything" + required: false + default: "false" + dockerhub_username: + description: "Required when registry=dockerhub" + required: false + default: "" + dockerhub_token: + description: "Required when registry=dockerhub. Needs Read, Write, Delete scope — Read & Write is NOT enough to delete tags." + required: false + default: "" + github_token: + description: "Required when registry=ghcr. Needs packages:write." + required: false + default: "" + +runs: + using: composite + steps: + - name: Clean Docker Hub tags + if: inputs.registry == 'dockerhub' + shell: bash + env: + DOCKERHUB_USERNAME: ${{ inputs.dockerhub_username }} + DOCKERHUB_TOKEN: ${{ inputs.dockerhub_token }} + run: | + bash "${{ github.action_path }}/cleanup-dockerhub.sh" \ + "${{ inputs.image_name }}" "${{ inputs.ephemeral_tag_prefix }}" \ + "${{ inputs.retention_days }}" "${{ inputs.keep_min }}" "${{ inputs.dry_run }}" + + - name: Clean GHCR versions + if: inputs.registry == 'ghcr' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + run: | + bash "${{ github.action_path }}/cleanup-ghcr.sh" \ + "${{ inputs.ghcr_image_name }}" "${{ inputs.ephemeral_tag_prefix }}" \ + "${{ inputs.retention_days }}" "${{ inputs.keep_min }}" "${{ inputs.dry_run }}" diff --git a/.github/actions/image-tag-cleanup/cleanup-dockerhub.sh b/.github/actions/image-tag-cleanup/cleanup-dockerhub.sh new file mode 100755 index 0000000..67fa06a --- /dev/null +++ b/.github/actions/image-tag-cleanup/cleanup-dockerhub.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deletes ephemeral tags (see select-deletions.sh) from a Docker Hub +# repository. Fetches the real tag list, delegates the decision of what's +# safe to delete to select-deletions.sh, then deletes only those. + +IMAGE_NAME="${1:?image_name required, e.g. sisqueslabs/beacon-api}" +EPHEMERAL_PREFIX="${2:?ephemeral tag prefix required}" +RETENTION_DAYS="${3:?retention_days required}" +KEEP_MIN="${4:?keep_min required}" +DRY_RUN="${5:-true}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME:?}" +DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN:?}" + +TOKEN=$(curl -sf -X POST "https://hub.docker.com/v2/users/login/" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"${DOCKERHUB_USERNAME}\", \"password\": \"${DOCKERHUB_TOKEN}\"}" \ + | jq -r '.token') + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "::error::Failed to authenticate with Docker Hub." >&2 + exit 1 +fi + +CANDIDATES="[]" +URL="https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags?page_size=100&ordering=last_updated" +while [ -n "$URL" ] && [ "$URL" != "null" ]; do + PAGE=$(curl -sf -H "Authorization: JWT ${TOKEN}" "$URL") + PAGE_ENTRIES=$(echo "$PAGE" | jq -c '[.results[] | {id: .name, tags: [.name], updated_at: .last_updated}]') + CANDIDATES=$(jq -c -n --argjson a "$CANDIDATES" --argjson b "$PAGE_ENTRIES" '$a + $b') + URL=$(echo "$PAGE" | jq -r '.next') +done + +TO_DELETE=$(echo "$CANDIDATES" | bash "${SCRIPT_DIR}/select-deletions.sh" "$EPHEMERAL_PREFIX" "$RETENTION_DAYS" "$KEEP_MIN") +TOTAL_COUNT=$(echo "$CANDIDATES" | jq 'length') +DELETE_COUNT=$(echo "$TO_DELETE" | jq 'length') + +echo "Docker Hub (${IMAGE_NAME}): ${DELETE_COUNT} tag(s) selected for deletion out of ${TOTAL_COUNT} total tags scanned." + +echo "$TO_DELETE" | jq -r '.[]' | while read -r TAG; do + if [ "$DRY_RUN" = "true" ]; then + echo "[dry-run] would delete ${IMAGE_NAME}:${TAG}" + else + echo "Deleting ${IMAGE_NAME}:${TAG}" + curl -sf -X DELETE -H "Authorization: JWT ${TOKEN}" \ + "https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${TAG}/" \ + || echo "::warning::Failed to delete ${IMAGE_NAME}:${TAG}" + fi +done diff --git a/.github/actions/image-tag-cleanup/cleanup-ghcr.sh b/.github/actions/image-tag-cleanup/cleanup-ghcr.sh new file mode 100755 index 0000000..04d1e3b --- /dev/null +++ b/.github/actions/image-tag-cleanup/cleanup-ghcr.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deletes ephemeral package versions (see select-deletions.sh) from GHCR. +# A GHCR "version" is one image manifest and can carry several tags at +# once, so an entry is only a deletion candidate when EVERY tag on that +# version matches the ephemeral prefix — a version co-tagged with a stable +# release is always protected, same guarantee as Docker Hub's per-tag check. + +GHCR_IMAGE_NAME="${1:?ghcr_image_name required, e.g. ghcr.io/sisques-labs/beacon-api}" +EPHEMERAL_PREFIX="${2:?ephemeral tag prefix required}" +RETENTION_DAYS="${3:?retention_days required}" +KEEP_MIN="${4:?keep_min required}" +DRY_RUN="${5:-true}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +GITHUB_TOKEN="${GITHUB_TOKEN:?}" + +WITHOUT_HOST="${GHCR_IMAGE_NAME#ghcr.io/}" +ORG="${WITHOUT_HOST%%/*}" +PACKAGE="${WITHOUT_HOST#*/}" +ENCODED_PACKAGE=$(jq -rn --arg v "$PACKAGE" '$v|@uri') + +CANDIDATES="[]" +PAGE=1 +while :; do + RESP=$(curl -sf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/orgs/${ORG}/packages/container/${ENCODED_PACKAGE}/versions?per_page=100&page=${PAGE}") + COUNT=$(echo "$RESP" | jq 'length') + [ "$COUNT" = "0" ] && break + PAGE_ENTRIES=$(echo "$RESP" | jq -c '[.[] | {id: (.id|tostring), tags: (.metadata.container.tags // []), updated_at: .created_at}]') + CANDIDATES=$(jq -c -n --argjson a "$CANDIDATES" --argjson b "$PAGE_ENTRIES" '$a + $b') + [ "$COUNT" -lt 100 ] && break + PAGE=$((PAGE + 1)) +done + +TO_DELETE=$(echo "$CANDIDATES" | bash "${SCRIPT_DIR}/select-deletions.sh" "$EPHEMERAL_PREFIX" "$RETENTION_DAYS" "$KEEP_MIN") +TOTAL_COUNT=$(echo "$CANDIDATES" | jq 'length') +DELETE_COUNT=$(echo "$TO_DELETE" | jq 'length') + +echo "GHCR (${GHCR_IMAGE_NAME}): ${DELETE_COUNT} version(s) selected for deletion out of ${TOTAL_COUNT} total versions scanned." + +echo "$TO_DELETE" | jq -r '.[]' | while read -r VERSION_ID; do + if [ "$DRY_RUN" = "true" ]; then + echo "[dry-run] would delete package version ${VERSION_ID}" + else + echo "Deleting package version ${VERSION_ID}" + curl -sf -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/orgs/${ORG}/packages/container/${ENCODED_PACKAGE}/versions/${VERSION_ID}" \ + || echo "::warning::Failed to delete package version ${VERSION_ID}" + fi +done diff --git a/.github/workflows/image-cleanup.yml b/.github/workflows/image-cleanup.yml new file mode 100644 index 0000000..78e92ac --- /dev/null +++ b/.github/workflows/image-cleanup.yml @@ -0,0 +1,92 @@ +name: Image Tag Cleanup + +# Deletes old ephemeral (commit-tagged) image tags — the ones trunk-ci-cd.yml +# publishes on every merge to main (:sha-) — so they don't +# accumulate forever in the registry. Never touches anything that doesn't +# match ephemeral_tag_prefix: stable releases (:X.Y.Z, :latest), :edge, and +# legacy release-train tags (:alpha, :beta, :X.Y.Z-alpha.N, ...) are always +# safe regardless of the prefix a given repo uses. +# See openspec/changes/trunk-based-ci-cd/design.md. +# +# This workflow only runs when called — the consumer repo owns the +# schedule (see README "Image Tag Cleanup" usage example). + +on: + workflow_call: + inputs: + image_name: + description: "Docker Hub image name (e.g. sisqueslabs/beacon-api)" + required: true + type: string + ghcr_image_name: + description: "GHCR image name (e.g. ghcr.io/sisques-labs/beacon-api). Required when push_ghcr=true." + required: false + type: string + default: "" + push_ghcr: + description: "Also clean up GHCR (mirrors the flag used to publish there)" + required: false + type: boolean + default: false + ephemeral_tag_prefix: + description: "Only tags starting with this prefix are ever eligible for deletion" + required: false + type: string + default: "sha-" + retention_days: + description: "Delete eligible tags older than this many days" + required: false + type: number + default: 14 + keep_min: + description: "Always keep at least this many of the most recent eligible tags, regardless of age" + required: false + type: number + default: 5 + dry_run: + description: "Log what would be deleted without deleting anything — use this to validate on a new repo before trusting it" + required: false + type: boolean + default: false + secrets: + DOCKERHUB_USERNAME: + required: true + DOCKERHUB_TOKEN: + description: "Needs Read, Write, Delete scope — Read & Write is NOT enough to delete tags" + required: true + +permissions: + packages: write + +jobs: + dockerhub: + name: Clean Docker Hub + runs-on: ubuntu-latest + steps: + - name: Clean + uses: sisques-labs/workflows/.github/actions/image-tag-cleanup@main + with: + registry: dockerhub + image_name: ${{ inputs.image_name }} + ephemeral_tag_prefix: ${{ inputs.ephemeral_tag_prefix }} + retention_days: ${{ inputs.retention_days }} + keep_min: ${{ inputs.keep_min }} + dry_run: ${{ inputs.dry_run }} + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} + + ghcr: + name: Clean GHCR + if: inputs.push_ghcr + runs-on: ubuntu-latest + steps: + - name: Clean + uses: sisques-labs/workflows/.github/actions/image-tag-cleanup@main + with: + registry: ghcr + ghcr_image_name: ${{ inputs.ghcr_image_name }} + ephemeral_tag_prefix: ${{ inputs.ephemeral_tag_prefix }} + retention_days: ${{ inputs.retention_days }} + keep_min: ${{ inputs.keep_min }} + dry_run: ${{ inputs.dry_run }} + github_token: ${{ github.token }} diff --git a/README.md b/README.md index a955666..7f8d9fb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This repository contains reusable GitHub Actions workflows and composite actions │ ├── trunk-ci-cd.yml # Continuous build + dev/pre deploy for trunk-based repos │ ├── docker-release.yml # Version bump + Docker build & publish (or promote, for trunk-based repos) │ ├── docker-smoke-build.yml # PR-time Dockerfile build + blocking vuln scan +│ ├── image-cleanup.yml # Delete old ephemeral image tags (dockerhub + ghcr) │ ├── codeql.yml # CodeQL security analysis (init + analyze) │ ├── pr-labeler.yml # Auto-label PRs by changed files │ ├── coolify-deploy.yml # Trigger a Coolify deploy via its API @@ -21,7 +22,8 @@ This repository contains reusable GitHub Actions workflows and composite actions │ ├── setup/ # Common setup (Node.js, pnpm, checkout) │ ├── install/ # Install dependencies with pnpm │ ├── trivy-scan/ # Scan a local image, report always, block optionally -│ └── release-train-detect/ # Compute the exact next version from git tags +│ ├── release-train-detect/ # Compute the exact next version from git tags +│ └── image-tag-cleanup/ # Decide + delete ephemeral tags older than a retention window └── tests/ # Test suites for the scripts in this repo ``` @@ -495,6 +497,58 @@ This has not yet been validated against a real multi-arch image — confirm it works before relying on `bump_mode: promote` for an actual production release. +### Image Tag Cleanup + +`trunk-ci-cd.yml` publishes a new, uniquely-tagged image (`:sha-`) +on **every** merge to `main` — with no branch-per-channel model to bound how +many accumulate, that tag count only ever goes up. `image-cleanup.yml` +deletes the old ones on a schedule you own, on both Docker Hub and GHCR. + +**Safety guarantee, not a suggestion:** an image is only ever a deletion +*candidate* when **every** tag it carries starts with `ephemeral_tag_prefix` +(default `sha-`). A stable release (`:X.Y.Z`), `:latest`, `:edge`, and every +legacy release-train tag (`:alpha`, `:beta`, `:X.Y.Z-alpha.N`, ...) never +match that prefix, so they are **never even considered** — this holds +regardless of `retention_days`/`keep_min`, and is covered by +`tests/image-tag-cleanup-select.test.sh`. Among the tags that *do* match, +the `keep_min` most recently published are always kept regardless of age; +the rest are deleted once older than `retention_days`. + +**Usage (consumer repository)** — this workflow owns no schedule itself, so +the consumer's `on:` trigger decides the cadence: + +```yaml +name: Image Tag Cleanup + +on: + schedule: + - cron: "0 3 * * 1" # weekly, Monday 03:00 UTC + workflow_dispatch: # lets you trigger it manually too + +jobs: + cleanup: + uses: sisques-labs/workflows/.github/workflows/image-cleanup.yml@main + with: + image_name: sisqueslabs/my-app + ghcr_image_name: ghcr.io/sisques-labs/my-app + push_ghcr: true + # dry_run: true # uncomment to validate on a new repo before trusting it + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} +``` + +**⚠️ Token permissions:** exactly like the Docker Hub description sync +above, `DOCKERHUB_TOKEN` needs **`Read, Write, Delete`** scope — a token +scoped only for `docker login` + push (`Read & Write`) can authenticate but +every delete call will fail with `401`/`403`. GHCR cleanup uses the +workflow's own `github.token` (needs `packages: write`, already declared at +the workflow level) — no extra secret required. + +**First run on any repo:** pass `dry_run: true` once to see what the +selection logic *would* delete (logged, nothing touched), then remove it +once you've confirmed the output looks right. + ### Branch sync after a stable release After a stable release (a push to `main` that graduates a release), both diff --git a/openspec/changes/trunk-based-ci-cd/design.md b/openspec/changes/trunk-based-ci-cd/design.md index 5946694..6e83d9e 100644 --- a/openspec/changes/trunk-based-ci-cd/design.md +++ b/openspec/changes/trunk-based-ci-cd/design.md @@ -79,10 +79,22 @@ Fixed: `promote` now computes both automatically — This makes cutting a release a genuine one-click action (`workflow_dispatch` with no required fields), matching the spirit of the whole migration: humans decide *when* to release, the pipeline decides *what* the release contains. +### D9 — Ephemeral tag retention (discovered during the beacon-api pilot) + +Every merge to `main` publishes `:sha-` (D1) with no expiry — unlike the old branch-per-channel model, there is no `develop`/`staging` boundary to bound how many of these accumulate, so left alone the tag count grows forever. This is a real gap, not a hypothetical: the old model had the identical problem (`release-train.yml` never pruned `alpha.N`/`beta.N` tags either) but bringing back environment branches to "fix" it would reintroduce exactly what this migration removes — a rebuild-per-branch model that breaks the "build once, promote the same artifact" guarantee (D2). The fix has to be orthogonal to branch topology. + +New reusable workflow `image-cleanup.yml` + composite action `image-tag-cleanup`, invoked on a schedule the *consumer* repo owns (this shared workflow has no `on:` trigger of its own). Design: + +- **Never touches an official tag.** An image/tag is only a deletion candidate when **every** tag it carries matches `ephemeral_tag_prefix` (default `sha-`). A stable release, `:latest`, `:edge`, or a legacy `:alpha`/`:beta`/`:X.Y.Z-alpha.N` tag never matches that prefix, so it's never even considered — this is a hard filter, not a heuristic, and it's the one piece of this design covered by a pure, offline-testable unit test (`select-deletions.sh` + `tests/image-tag-cleanup-select.test.sh`), independent of any live registry call. +- **`keep_min` is a floor, `retention_days` is the trigger.** The `keep_min` most recently published ephemeral tags are always kept regardless of age (so there's always something to promote even during a slow release cadence); only entries beyond that floor are deleted once older than `retention_days`. +- **Generic across registries and repos.** The same selection logic runs against Docker Hub (per-tag) and GHCR (per-package-version, which can carry multiple tags — a version is skipped entirely if any of its tags is non-ephemeral). `ephemeral_tag_prefix` is configurable per consumer, so a repo using a different continuous-build tag scheme isn't hardcoded to `sha-`. +- **`dry_run` is the required first step on any new repo** — logs what would be deleted without touching the registry. + ## Risks / Trade-offs - **[Risk] Multi-arch digest promotion is unproven** → Validate `imagetools create` against a real multi-platform image before beacon-api's pilot relies on it for an actual prod release. - **[Risk] Downstream consumers pinned to `:alpha`/`:beta` tags** → Each repo must audit this before migrating; out of scope for this shared-workflow change. +- **[Risk] Cleanup deletes a tag mid-promotion** → Extremely narrow window (between `trunk-ci-cd.yml` publishing a digest and a human running `release.yml` against it); mitigated by `keep_min` defaulting to 5, which comfortably covers any realistic gap between "build validated in pre" and "someone clicks release." - **[Trade-off] `deploy-dev`/`deploy-pre` are placeholders** → The pipeline shape ships now; real deploy logic is deferred until a repo has infrastructure to target. - **[Trade-off] Org-wide standardization without a forced cutover** → Slower convergence (repos migrate on their own schedule) in exchange for zero blast radius on this change. diff --git a/openspec/changes/trunk-based-ci-cd/tasks.md b/openspec/changes/trunk-based-ci-cd/tasks.md index 984c5df..1c43436 100644 --- a/openspec/changes/trunk-based-ci-cd/tasks.md +++ b/openspec/changes/trunk-based-ci-cd/tasks.md @@ -29,3 +29,14 @@ - [x] 5.2 Compute the version bump for `promote` automatically from conventional commits since the latest stable tag (mirrors `release-train-detect`'s `main`-channel logic) - [x] 5.3 Make `source_digest` optional for `promote`: auto-resolve the current `:edge` tag's digest via `docker buildx imagetools inspect` when not passed explicitly - [ ] 5.4 Dry-run a real `promote` release with zero inputs and confirm the resolved version + digest are correct — needs a real repo with an `:edge` build published (part of 4.1/4.2's live test) + +## 6. Ephemeral tag retention (D9) + +- [x] 6.1 Add `.github/actions/image-tag-cleanup/select-deletions.sh` — pure, offline decision logic (which tags/versions are safe to delete) with no registry calls +- [x] 6.2 Add `tests/image-tag-cleanup-select.test.sh` covering: empty input, a non-ephemeral tag is never selected, a mixed-tag entry is never selected, an untagged entry is never selected, `keep_min` is respected regardless of age, entries beyond `keep_min` are selected once older than `retention_days`, a custom `ephemeral_tag_prefix` only matches its own tags, GHCR-shaped (numeric id) input works the same way — 9/9 passing +- [x] 6.3 Add `cleanup-dockerhub.sh` (lists tags via the Docker Hub API, deletes selected ones) and `cleanup-ghcr.sh` (lists package versions via the GitHub API, deletes selected ones) — both delegate the decision to 6.1, never decide themselves +- [x] 6.4 Add composite action `.github/actions/image-tag-cleanup/action.yml` wrapping both registry scripts behind a `registry: dockerhub | ghcr` input +- [x] 6.5 Add reusable workflow `.github/workflows/image-cleanup.yml` (`workflow_call`, no `on:` trigger of its own — the consumer owns the schedule) calling the composite action once per enabled registry +- [x] 6.6 Add `image-tag-cleanup-select` job to this repo's own `test.yml` and confirm `shellcheck`/`actionlint` are clean on every new file +- [x] 6.7 Document `image-cleanup.yml` usage, the never-touches-official-tags guarantee, and the `DOCKERHUB_TOKEN` Read/Write/Delete scope requirement in the README +- [ ] 6.8 Wire `image-cleanup.yml` into beacon-api (the pilot repo) with a real `on: schedule` trigger, run once with `dry_run: true`, confirm the logged output looks correct before trusting it unattended