Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions .github/actions/delete-image/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ outputs:
referrers-deleted:
description: "Number of referrer artifacts (manifests + fallback tags) removed from the repository."
value: ${{ steps.delete.outputs.referrers-deleted }}
deleted-json:
description: "JSON array of the artifacts actually removed (image + referrers), each {digest, tag?}. Feeds stage-deletion-events."
value: ${{ steps.delete.outputs.deleted-json }}

runs:
using: composite
Expand All @@ -66,6 +69,7 @@ runs:
echo "::warning::No delete token provided; skipping deletion of ${REPOSITORY}:${TAG}."
echo "status=skipped" >> "${GITHUB_OUTPUT}"
echo "referrers-deleted=0" >> "${GITHUB_OUTPUT}"
echo "deleted-json=[]" >> "${GITHUB_OUTPUT}"
exit 0
fi

Expand All @@ -88,19 +92,25 @@ runs:
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'; }
digest_for_tag() { printf '%s' "${versions}" | jq -r --arg t "$1" 'map(select(.metadata.container.tags[]? == $t)) | .[0].name // empty'; }

referrers_deleted=0
# JSON array of artifacts actually removed (image + referrers), for the
# caller to turn into ArtifactDeleted graph events.
deleted_json='[]'
# Resolve the image digest once (best-effort); used for referrer
# discovery below and for the image's deletion event.
subject_digest=""
if command -v crane >/dev/null 2>&1; then
subject_digest="$(crane digest "${REPOSITORY}:${TAG}" 2>/dev/null || true)"
fi

# --- 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
if [ "${DELETE_REFERRERS}" = "true" ] && [ -n "${subject_digest}" ]; then
# 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)"
Expand Down Expand Up @@ -133,6 +143,7 @@ runs:
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))
deleted_json="$(printf '%s' "${deleted_json}" | jq -c --arg d "${r}" '. + [{digest: $d}]')"
else
Comment thread
toddysm marked this conversation as resolved.
echo "::warning::Failed to delete referrer ${r} (version ${rid}); leaving it in place."
fi
Expand All @@ -143,14 +154,17 @@ runs:
fb="$(printf '%s' "${s}" | sed 's/:/-/')"
fid="$(id_for_tag "${fb}")"
[ -n "${fid}" ] || continue
fdig="$(digest_for_tag "${fb}")"
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))
if [ -n "${fdig}" ]; then
deleted_json="$(printf '%s' "${deleted_json}" | jq -c --arg d "${fdig}" --arg t "${fb}" '. + [{digest: $d, tag: $t}]')"
fi
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}"

Expand All @@ -159,6 +173,7 @@ runs:
if [ -z "${vid}" ]; then
echo "::warning::Could not resolve a package version id for tag '${TAG}'; skipping deletion."
echo "status=failed" >> "${GITHUB_OUTPUT}"
echo "deleted-json=${deleted_json}" >> "${GITHUB_OUTPUT}"
exit 0
fi

Expand All @@ -169,14 +184,17 @@ runs:
# quarantine package is the intended cleanup once its only image was
# promoted;
# - otherwise warn and report status=failed instead of aborting.
image_deleted=0
if err="$(GH_TOKEN="${DELETE_TOKEN}" gh api -X DELETE "${base}/versions/${vid}" 2>&1 >/dev/null)"; then
echo "Deleted ${REPOSITORY}:${TAG} (version ${vid})."
echo "status=deleted" >> "${GITHUB_OUTPUT}"
image_deleted=1
elif printf '%s' "${err}" | grep -qiF "last tagged version"; then
echo "::notice::'${TAG}' is the last tagged version of ${REPOSITORY}; deleting the whole package instead."
if perr="$(GH_TOKEN="${DELETE_TOKEN}" gh api -X DELETE "${base}" 2>&1 >/dev/null)"; then
echo "Deleted package ${REPOSITORY} (removed last tag '${TAG}')."
echo "status=deleted" >> "${GITHUB_OUTPUT}"
image_deleted=1
else
echo "::warning::Failed to delete package ${REPOSITORY}; leaving it in place. ${perr}"
echo "status=failed" >> "${GITHUB_OUTPUT}"
Expand All @@ -185,3 +203,9 @@ runs:
echo "::warning::Failed to delete ${REPOSITORY}:${TAG} (version ${vid}); leaving it in place. ${err}"
echo "status=failed" >> "${GITHUB_OUTPUT}"
fi

# Record the image's own deletion (with its tag) for the graph event.
if [ "${image_deleted}" = "1" ] && [ -n "${subject_digest}" ]; then
deleted_json="$(printf '%s' "${deleted_json}" | jq -c --arg d "${subject_digest}" --arg t "${TAG}" '. + [{digest: $d, tag: $t}]')"
fi
echo "deleted-json=${deleted_json}" >> "${GITHUB_OUTPUT}"
111 changes: 111 additions & 0 deletions .github/actions/stage-deletion-events/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Composite action: stage-deletion-events
#
# Stage one `ArtifactDeleted` supply-chain-graph event per artifact that
# delete-image removed from a repository (the image and each referrer), for the
# record-graph-events collector to commit. Consumes the `deleted-json` output of
# delete-image (a JSON array of {digest, tag?}); the occurrence registry +
# repository come from `ref`.
#
# Best-effort: skips entries without a digest and stages nothing for an empty
# array. The content id / filename match build-graph-event
# (cssc_graph.identity.content_id).
#
# Terminology and the action catalogue: see docs/reference/workflow-actions.md.
name: stage-deletion-events
description: Stage an ArtifactDeleted event per artifact delete-image removed (image + referrers).

inputs:
ref:
description: "Fully-qualified repository the artifacts were deleted from (registry/repository)."
required: true
deleted-json:
description: "JSON array of removed artifacts, each {digest, tag?} (the delete-image deleted-json output)."
required: true
reason:
description: "Why the artifacts were removed: promoted | denied-cleanup | manual."
required: false
default: "promoted"
output-dir:
description: "Staging directory to write event files into."
required: true
workflow:
description: "Producing workflow name (source.workflow)."
required: true
run-url:
description: "URL of the producing run (source.runUrl)."
required: true
run-id:
description: "Producing run id (source.runId)."
required: true
run-attempt:
description: "Producing run attempt (source.runAttempt)."
required: true

outputs:
staged:
description: "Number of ArtifactDeleted events staged."
value: ${{ steps.stage.outputs.staged }}

runs:
using: composite
steps:
- name: Stage deletion events for ${{ inputs.ref }}
id: stage
shell: bash
env:
REF: "${{ inputs.ref }}"
DELETED_JSON: "${{ inputs.deleted-json }}"
REASON: "${{ inputs.reason }}"
OUTPUT_DIR: "${{ inputs.output-dir }}"
WORKFLOW: "${{ inputs.workflow }}"
RUN_URL: "${{ inputs.run-url }}"
RUN_ID: "${{ inputs.run-id }}"
RUN_ATTEMPT: "${{ inputs.run-attempt }}"
RUNNER_TEMP_DIR: "${{ runner.temp }}"
run: |
set -euo pipefail

outdir="${OUTPUT_DIR:-${RUNNER_TEMP_DIR}/graph-events}"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
staged=0

# Nothing to do for an empty / missing array.
if [ -z "${DELETED_JSON}" ] || [ "$(printf '%s' "${DELETED_JSON}" | jq 'length' 2>/dev/null || echo 0)" = "0" ]; then
echo "staged=0" >> "${GITHUB_OUTPUT}"
exit 0
fi

# Split registry (first path component) from repository (the rest).
reg="${REF%%/*}"; repo="${REF#*/}"
if [ "${reg}" = "${REF}" ] || [ -z "${repo}" ]; then
echo "::error::stage-deletion-events: not a fully-qualified ref: '${REF}'"; exit 1
fi

source_json="$(jq -nc \
--arg w "${WORKFLOW}" --arg u "${RUN_URL}" \
--arg rid "${RUN_ID}" --arg ra "${RUN_ATTEMPT}" \
'{type:"github-actions", workflow:$w, runUrl:$u, runId:$rid, runAttempt:$ra}')"

while IFS= read -r entry; do
[ -n "${entry}" ] || continue
dig="$(printf '%s' "${entry}" | jq -r '.digest // empty')"
tag="$(printf '%s' "${entry}" | jq -r '.tag // empty')"
[ -n "${dig}" ] || continue
record="$(jq -nc \
--argjson src "${source_json}" --arg now "${now}" \
--arg reg "${reg}" --arg repo "${repo}" \
--arg dig "${dig}" --arg tag "${tag}" --arg reason "${REASON}" \
'{schemaVersion:1, kind:"ArtifactDeleted", recordedAt:$now, source:$src,
occurrence:{registry:$reg, repository:$repo},
digest:$dig, reason:$reason, deletedAt:$now}
+ (if $tag == "" then {} else {tag:$tag} end)')"
hex="$(printf '%s' "${record}" | jq -S -c -j 'del(.id, .recordedAt, .source)' | sha256sum | cut -d' ' -f1)"
record="$(printf '%s' "${record}" | jq -c --arg id "sha256:${hex}" '. + {id: $id}')"
dir="${outdir}/events/artifact-deleted"
mkdir -p "${dir}"
printf '%s\n' "${record}" | jq . > "${dir}/${hex}.json"
staged=$((staged + 1))
done <<< "$(printf '%s' "${DELETED_JSON}" | jq -c '.[]')"

echo "Staged ${staged} ArtifactDeleted event(s) for ${REF}."
echo "staged=${staged}" >> "${GITHUB_OUTPUT}"
22 changes: 22 additions & 0 deletions .github/workflows/_promote-from-quarantine-sbom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,28 @@ jobs:
tag: ${{ matrix.tag }}
token: ${{ secrets.ghcr_delete_token }}

- name: Stage deletion graph events
if: steps.evaluate.outputs.decision == 'promote' && inputs.delete_source
uses: ./.github/actions/stage-deletion-events
with:
ref: ${{ inputs.source_repo }}
deleted-json: ${{ steps.delete.outputs.deleted-json }}
reason: promoted
output-dir: ${{ runner.temp }}/graph-events-del
workflow: ${{ github.workflow }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run-id: ${{ github.run_id }}
run-attempt: ${{ github.run_attempt }}

- name: Upload deletion graph events
if: steps.evaluate.outputs.decision == 'promote' && inputs.delete_source
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: graph-events-del-${{ strategy.job-index }}
path: ${{ runner.temp }}/graph-events-del
if-no-files-found: ignore
retention-days: 1

# When approval is enabled and the image is blocked (including a missing
# SBOM), persist a machine-readable metadata block alongside the per-tag
# result. The downstream `notify` job (which alone holds `issues: write`)
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/_promote-from-quarantine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,28 @@ jobs:
tag: ${{ matrix.tag }}
token: ${{ secrets.ghcr_delete_token }}

- name: Stage deletion graph events
if: steps.evaluate.outputs.decision == 'promote' && inputs.delete_source
uses: ./.github/actions/stage-deletion-events
with:
ref: ${{ inputs.source_repo }}
deleted-json: ${{ steps.delete.outputs.deleted-json }}
reason: promoted
output-dir: ${{ runner.temp }}/graph-events-del
workflow: ${{ github.workflow }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run-id: ${{ github.run_id }}
run-attempt: ${{ github.run_attempt }}

- name: Upload deletion graph events
if: steps.evaluate.outputs.decision == 'promote' && inputs.delete_source
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: graph-events-del-${{ strategy.job-index }}
path: ${{ runner.temp }}/graph-events-del
if-no-files-found: ignore
retention-days: 1

# When approval is enabled and the image is blocked, persist a machine-
# readable metadata block alongside the per-tag result. The downstream
# `notify` job (which alone holds `issues: write`) reads it back to open
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/_promote-override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,36 @@ jobs:
override-cves: ${{ steps.meta.outputs.cves }}

- name: Delete overridden tag from quarantine
id: delete
if: inputs.decision == 'approve' && inputs.delete_source && steps.promote.outputs.digest != ''
uses: ./.github/actions/delete-image
with:
repository: ${{ steps.meta.outputs.source-repo }}
tag: ${{ steps.meta.outputs.tag }}
token: ${{ secrets.ghcr_delete_token }}

- name: Stage deletion graph events
if: inputs.decision == 'approve' && inputs.delete_source && steps.promote.outputs.digest != ''
uses: ./.github/actions/stage-deletion-events
with:
ref: ${{ steps.meta.outputs.source-repo }}
deleted-json: ${{ steps.delete.outputs.deleted-json }}
reason: promoted
output-dir: ${{ runner.temp }}/graph-events-del
workflow: ${{ github.workflow }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run-id: ${{ github.run_id }}
run-attempt: ${{ github.run_attempt }}

- name: Upload deletion graph events
if: inputs.decision == 'approve' && inputs.delete_source && steps.promote.outputs.digest != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: graph-events-del
path: ${{ runner.temp }}/graph-events-del
if-no-files-found: ignore
retention-days: 1

# Close + notify once the promotion itself has succeeded, regardless of
# the optional quarantine-delete outcome (best-effort cleanup must not
# leave the tracking issue open after a successful override promotion).
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/observability/supply-chain-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ by people through pull requests, not by workflows.
| Workflow (stage) | Event kind(s) | Key fields |
|---|---|---|
| `mirror-*` (acquire) | `ArtifactMirrored`, `TagObserved`, `ReferrerObserved` | source ref+digest, dest repo+digest, dest tag, referrer digests+types, run URL |
| `promote-from-quarantine*` (catalog) | `ArtifactPromoted`, `ScanRecorded`, `TagObserved`, `ReferrerObserved` | src/dest repo+digest, tag, referrer digests+types, vuln-attestation digest, issue URL, run URL |
| `promote-from-quarantine*` (catalog) | `ArtifactPromoted`, `ScanRecorded`, `TagObserved`, `ReferrerObserved`, `ArtifactDeleted` | src/dest repo+digest, tag, referrer digests+types, deleted digests, vuln-attestation digest, issue URL, run URL |
| `build-cssc-dashboard` (build) | `ArtifactBuilt`, `BaseImageObserved`, `TagObserved` | image repo+digest, base name@digest + base tag, SBOM/provenance referrer digests, source commit, run URL |
| deploy (deploy/run) | `ArtifactDeployed` | image repo+digest, environment/cluster/namespace, chart+version, run URL |

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/workflow-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ and is best-effort.
| `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`), `referrers-deleted` (count).
Outputs: `status` (`deleted` / `skipped` / `failed`), `referrers-deleted` (count), `deleted-json` (array of removed artifacts, for deletion graph events).

### notify-slack

Expand Down
Loading