diff --git a/.github/actions/delete-image/action.yml b/.github/actions/delete-image/action.yml index f7bd69c..87c496d 100644 --- a/.github/actions/delete-image/action.yml +++ b/.github/actions/delete-image/action.yml @@ -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 @@ -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 @@ -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)" @@ -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 echo "::warning::Failed to delete referrer ${r} (version ${rid}); leaving it in place." fi @@ -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}" @@ -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 @@ -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}" @@ -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}" diff --git a/.github/actions/stage-deletion-events/action.yml b/.github/actions/stage-deletion-events/action.yml new file mode 100644 index 0000000..bd512ad --- /dev/null +++ b/.github/actions/stage-deletion-events/action.yml @@ -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}" diff --git a/.github/workflows/_promote-from-quarantine-sbom.yml b/.github/workflows/_promote-from-quarantine-sbom.yml index 4e50a11..10e8fb0 100644 --- a/.github/workflows/_promote-from-quarantine-sbom.yml +++ b/.github/workflows/_promote-from-quarantine-sbom.yml @@ -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`) diff --git a/.github/workflows/_promote-from-quarantine.yml b/.github/workflows/_promote-from-quarantine.yml index c2cca76..d002a19 100644 --- a/.github/workflows/_promote-from-quarantine.yml +++ b/.github/workflows/_promote-from-quarantine.yml @@ -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 diff --git a/.github/workflows/_promote-override.yml b/.github/workflows/_promote-override.yml index 4a0ec13..1ccf690 100644 --- a/.github/workflows/_promote-override.yml +++ b/.github/workflows/_promote-override.yml @@ -295,6 +295,7 @@ 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: @@ -302,6 +303,28 @@ jobs: 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). diff --git a/docs/architecture/observability/supply-chain-graph.md b/docs/architecture/observability/supply-chain-graph.md index f0e000b..d8aa27b 100644 --- a/docs/architecture/observability/supply-chain-graph.md +++ b/docs/architecture/observability/supply-chain-graph.md @@ -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 | diff --git a/docs/reference/workflow-actions.md b/docs/reference/workflow-actions.md index ac42182..f54a2e7 100644 --- a/docs/reference/workflow-actions.md +++ b/docs/reference/workflow-actions.md @@ -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