From a6b71c3b3645f33feaaea2da6d7fd917776c6a81 Mon Sep 17 00:00:00 2001 From: Toddy Mladenov Date: Thu, 13 Aug 2026 19:27:10 -0700 Subject: [PATCH 1/2] Promote and delete referrers with the image (OCI 1.0 + 1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Referrers (SBOM/provenance/VEX/signatures) were dropped on the standard promote path and left dangling in quarantine after the image tag was deleted. This makes the referrer graph travel with the subject and be cleaned up with it. Copy: all three promote paths now set copy-referrers=true so oras cp -r carries the index, child manifests and their referrers to golden. - _promote-from-quarantine.yml: add copy-referrers to the promote step. - _promote-override.yml: always copy referrers (was sbom-only). Delete: delete-image now removes the tag's referrer closure before the image — every OCI 1.1 referrer manifest (untagged version, by digest) and OCI 1.0 sha256- fallback index, for the image index and each child manifest, discovered recursively (referrers-of-referrers). Best-effort and idempotent; degrades to image-only when crane/oras are not logged in. Adds delete-referrers input (default true) and a referrers-deleted output. Refs #198 --- .github/actions/delete-image/action.yml | 95 ++++++++++++++++++- .../workflows/_promote-from-quarantine.yml | 1 + .github/workflows/_promote-override.yml | 9 +- docs/reference/workflow-actions.md | 11 ++- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/.github/actions/delete-image/action.yml b/.github/actions/delete-image/action.yml index 31217f8..7bccc79 100644 --- a/.github/actions/delete-image/action.yml +++ b/.github/actions/delete-image/action.yml @@ -5,6 +5,14 @@ # a PAT carrying `delete:packages` is required. Used to remove a tag from # quarantine after it has been promoted. # +# When `delete-referrers` is set (default), the tag's referrer closure is removed +# first: every OCI 1.1 referrer manifest (untagged package version, resolved by +# digest) and OCI 1.0 `sha256-` fallback referrer index, for the +# image index and each per-platform child manifest, discovered recursively so +# referrers-of-referrers are included. This requires crane/oras to be logged in +# to the registry (the promote jobs already are); it degrades to image-only +# deletion otherwise. Referrer cleanup is best-effort and never fails the caller. +# # Resolves the owner type (user vs organization) and the package-version id for # the tag, then issues the delete. When the tag is the package's last tagged # version (which GHCR refuses to delete via the versions API), it falls back to @@ -13,7 +21,7 @@ # # Terminology: see docs/reference/workflow-actions.md. name: delete-image -description: Delete one tag from a GHCR repository via the Packages REST API. +description: Delete one tag (and its referrer closure) from a GHCR repository via the Packages REST API. inputs: repository: @@ -26,11 +34,18 @@ inputs: description: "PAT with delete:packages. When empty, deletion is skipped with a warning." required: false default: "" + delete-referrers: + description: "Also delete the image's referrer closure (OCI 1.1 manifests + sha256-* fallback tags, recursively). Requires crane/oras to be logged in to the registry." + required: false + default: "true" outputs: status: description: "deleted | skipped | failed." value: ${{ steps.delete.outputs.status }} + referrers-deleted: + description: "Number of referrer artifacts (manifests + fallback tags) removed from the repository." + value: ${{ steps.delete.outputs.referrers-deleted }} runs: using: composite @@ -42,6 +57,7 @@ runs: REPOSITORY: "${{ inputs.repository }}" TAG: "${{ inputs.tag }}" DELETE_TOKEN: "${{ inputs.token }}" + DELETE_REFERRERS: "${{ inputs.delete-referrers }}" run: | set -euo pipefail export LC_ALL=C @@ -65,9 +81,80 @@ runs: base="user/packages/container/${pkg_enc}" fi - vid="$(GH_TOKEN="${DELETE_TOKEN}" gh api --paginate "${base}/versions" \ - --jq ".[] | select(.metadata.container.tags[]? == \"${TAG}\") | .id" 2>/dev/null \ - | head -n1 || true)" + # Snapshot every package version once (id + digest name + tags). Referrer + # manifests are untagged versions keyed by their digest (.name); OCI 1.0 + # fallback referrer indexes are tagged sha256-. + versions="$(GH_TOKEN="${DELETE_TOKEN}" gh api --paginate "${base}/versions" 2>/dev/null || echo '[]')" + id_for_digest() { printf '%s' "${versions}" | jq -r --arg d "$1" 'map(select(.name == $d)) | .[0].id // empty'; } + id_for_tag() { printf '%s' "${versions}" | jq -r --arg t "$1" 'map(select(.metadata.container.tags[]? == $t)) | .[0].id // empty'; } + + referrers_deleted=0 + + # --- Delete the referrer closure before the image itself. --------------- + # Deletion runs last in the promotion, so the image + referrers still + # exist here and can be enumerated. This is best-effort: any failure only + # warns (a completed promotion must not be failed by cleanup), and it is + # a no-op when crane/oras are not logged in. + if [ "${DELETE_REFERRERS}" = "true" ] && command -v crane >/dev/null 2>&1; then + subject_digest="$(crane digest "${REPOSITORY}:${TAG}" 2>/dev/null || true)" + if [ -z "${subject_digest}" ]; then + echo "::notice::Could not resolve a digest for ${REPOSITORY}:${TAG}; skipping referrer cleanup." + else + # Subjects = the image index plus every per-platform child manifest. + children="$(crane manifest "${REPOSITORY}@${subject_digest}" 2>/dev/null | jq -r '.manifests[]?.digest // empty' 2>/dev/null || true)" + subjects="$(printf '%s\n%s\n' "${subject_digest}" "${children}" | sed '/^$/d' | sort -u)" + + # Walk the referrer graph with a worklist so referrers-of-referrers + # (e.g. a signature on an SBOM) are included. oras JSON nesting varies + # by version, so collect any object carrying both artifactType+digest. + processed="" + pending="${subjects}" + referrer_digests="" + if command -v oras >/dev/null 2>&1; then + while [ -n "$(printf '%s' "${pending}" | sed '/^$/d')" ]; do + subj="$(printf '%s\n' "${pending}" | sed '/^$/d' | head -n1)" + pending="$(printf '%s\n' "${pending}" | sed '/^$/d' | grep -vxF "${subj}" || true)" + printf '%s\n' "${processed}" | grep -qxF "${subj}" && continue + processed="$(printf '%s\n%s\n' "${processed}" "${subj}" | sed '/^$/d')" + refs="$(oras discover --format json "${REPOSITORY}@${subj}" 2>/dev/null \ + | jq -r '[.. | objects | select(has("artifactType") and has("digest")) | .digest] | unique | .[]' 2>/dev/null || true)" + for r in ${refs}; do + referrer_digests="$(printf '%s\n%s\n' "${referrer_digests}" "${r}" | sed '/^$/d')" + printf '%s\n' "${processed}" | grep -qxF "${r}" || pending="$(printf '%s\n%s\n' "${pending}" "${r}" | sed '/^$/d')" + done + done + fi + + # Delete referrer manifests (untagged versions, keyed by digest). + for r in $(printf '%s\n' "${referrer_digests}" | sed '/^$/d' | sort -u); do + rid="$(id_for_digest "${r}")" + [ -n "${rid}" ] || continue + if GH_TOKEN="${DELETE_TOKEN}" gh api -X DELETE "${base}/versions/${rid}" >/dev/null 2>&1; then + echo "Deleted referrer ${r} (version ${rid})." + referrers_deleted=$((referrers_deleted + 1)) + else + echo "::warning::Failed to delete referrer ${r} (version ${rid}); leaving it in place." + fi + done + + # Delete OCI 1.0 fallback referrer indexes (sha256-). + for s in ${subjects}; do + fb="$(printf '%s' "${s}" | sed 's/:/-/')" + fid="$(id_for_tag "${fb}")" + [ -n "${fid}" ] || continue + if GH_TOKEN="${DELETE_TOKEN}" gh api -X DELETE "${base}/versions/${fid}" >/dev/null 2>&1; then + echo "Deleted fallback referrer tag ${fb} (version ${fid})." + referrers_deleted=$((referrers_deleted + 1)) + else + echo "::warning::Failed to delete fallback referrer tag ${fb} (version ${fid}); leaving it in place." + fi + done + fi + fi + echo "referrers-deleted=${referrers_deleted}" >> "${GITHUB_OUTPUT}" + + # --- Delete the image tag itself. --------------------------------------- + vid="$(id_for_tag "${TAG}")" if [ -z "${vid}" ]; then echo "::warning::Could not resolve a package version id for tag '${TAG}'; skipping deletion." echo "status=failed" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/_promote-from-quarantine.yml b/.github/workflows/_promote-from-quarantine.yml index a11ece5..d8fc07c 100644 --- a/.github/workflows/_promote-from-quarantine.yml +++ b/.github/workflows/_promote-from-quarantine.yml @@ -235,6 +235,7 @@ jobs: dest-image: ${{ inputs.dest_repo }} dest-tag: ${{ matrix.tag }} force: "true" + copy-referrers: "true" - name: Attach scan-report attestation if: steps.evaluate.outputs.decision == 'promote' diff --git a/.github/workflows/_promote-override.yml b/.github/workflows/_promote-override.yml index 1c24b75..c0e522c 100644 --- a/.github/workflows/_promote-override.yml +++ b/.github/workflows/_promote-override.yml @@ -123,12 +123,9 @@ jobs: echo "scanner-version=$(get '.scanner_version')" # Pipe-join the blocking CVE array for the referrer annotation. echo "cves=$(printf '%s' "${METADATA_JSON}" | jq -r '(.blocking_cves // []) | join("|")')" - # SBOM promotions must carry referrers along. - if [ "$(get '.method')" = "sbom" ]; then - echo "copy-referrers=true" - else - echo "copy-referrers=false" - fi + # Always carry referrers (SBOM/provenance/VEX/signatures) with the + # promoted image so golden holds the complete attestation set. + echo "copy-referrers=true" } >> "${GITHUB_OUTPUT}" - name: Set up crane diff --git a/docs/reference/workflow-actions.md b/docs/reference/workflow-actions.md index 4639784..ac42182 100644 --- a/docs/reference/workflow-actions.md +++ b/docs/reference/workflow-actions.md @@ -204,15 +204,22 @@ for the full annotation schema. ### delete-image -Delete one tag from a GHCR repository via the Packages REST API. +Delete one tag from a GHCR repository via the Packages REST API. When +`delete-referrers` is set (default), the tag's referrer closure is removed +first — every OCI 1.1 referrer manifest (untagged version, by digest) and +OCI 1.0 `sha256-` fallback referrer index, for the image +index and each child manifest, discovered recursively. Referrer cleanup +needs crane/oras logged in to the registry (the promote jobs already are) +and is best-effort. | Input | Required | Default | Description | | ----- | -------- | ------- | ----------- | | `repository` | yes | — | GHCR repository, without tag. | | `tag` | yes | — | Tag to delete. | | `token` | no | `""` | PAT with `delete:packages`; deletion is skipped when empty. | +| `delete-referrers` | no | `true` | Also delete the tag's OCI 1.0/1.1 referrer closure. | -Outputs: `status` (`deleted` / `skipped` / `failed`). +Outputs: `status` (`deleted` / `skipped` / `failed`), `referrers-deleted` (count). ### notify-slack From 9ecd63fd115d01d54a4f6df9dc0fa0afd0005030 Mon Sep 17 00:00:00 2001 From: Toddy Mladenov Date: Thu, 13 Aug 2026 19:36:21 -0700 Subject: [PATCH 2/2] delete-image: emit referrers-deleted on the skipped path Copilot review: the early token-empty exit set status=skipped but never wrote referrers-deleted, leaving it unset for callers. Emit 0. --- .github/actions/delete-image/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/delete-image/action.yml b/.github/actions/delete-image/action.yml index 7bccc79..f7bd69c 100644 --- a/.github/actions/delete-image/action.yml +++ b/.github/actions/delete-image/action.yml @@ -65,6 +65,7 @@ runs: if [ -z "${DELETE_TOKEN}" ]; then echo "::warning::No delete token provided; skipping deletion of ${REPOSITORY}:${TAG}." echo "status=skipped" >> "${GITHUB_OUTPUT}" + echo "referrers-deleted=0" >> "${GITHUB_OUTPUT}" exit 0 fi