diff --git a/.github/actions/push-signed-test-image/action.yml b/.github/actions/push-signed-test-image/action.yml new file mode 100644 index 0000000..e98a47b --- /dev/null +++ b/.github/actions/push-signed-test-image/action.yml @@ -0,0 +1,47 @@ +name: Push signed test image +description: Build a tiny multi-arch image, push it, sign it with cosign and attest its provenance, like a head workflow does +inputs: + image: + description: Image without a tag + required: true + tag: + description: Tag to push + required: true + variant: + description: Written into the image, so two pushes to the same tag differ + required: true +outputs: + digest: + description: Digest of the pushed image index + value: ${{ steps.build.outputs.digest }} +runs: + using: composite + steps: + - name: Write a unique marker + shell: bash + env: + MARKER: ${{ inputs.variant }}-${{ github.run_id }}-${{ github.run_attempt }} + run: echo "${MARKER}" > test-fixtures/delete-previous-head-image/marker + + - name: Build and push + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: test-fixtures/delete-previous-head-image + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ inputs.image }}:${{ inputs.tag }} + + - name: Sign + shell: bash + env: + IMAGE: ${{ inputs.image }} + DIGEST: ${{ steps.build.outputs.digest }} + run: cosign sign --yes "${IMAGE}@${DIGEST}" + + - name: Attest build provenance + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ inputs.image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/caller-delete-previous-head-image.yaml b/.github/workflows/caller-delete-previous-head-image.yaml new file mode 100644 index 0000000..a9e97a9 --- /dev/null +++ b/.github/workflows/caller-delete-previous-head-image.yaml @@ -0,0 +1,232 @@ +name: Delete previous head image (real delete) + +# Integration test for loft-sh/github-actions delete-previous-head-image with a +# real delete, against a throwaway package, before any production repo uses it. +# +# It pushes a signed release and two signed head images, then deletes the first +# head image with the job's GITHUB_TOKEN and checks that: +# - every version of the first head image is gone +# - head and the release still pull on both platforms and pass cosign verify +# - running delete again with the same digest is a no-op +# +# A second job covers an old head that shares a platform manifest with a release +# that signs each platform: the shared manifest and its signature must stay. + +on: + push: + branches: + - devops-1807/** + paths: + - .github/workflows/caller-delete-previous-head-image.yaml + - .github/actions/push-signed-test-image/** + - test-fixtures/delete-previous-head-image/** + workflow_dispatch: + +concurrency: + group: delete-previous-head-image + cancel-in-progress: false + +permissions: {} + +env: + IMAGE: ghcr.io/vclusterlabs-experiments/delete-previous-head-image-test + COSIGN_IDENTITY: ^https://github.com/vClusterLabs-Experiments/github-actions-test/\.github/workflows/caller-delete-previous-head-image\.yaml@ + +jobs: + real-delete: + name: Delete the previous head image for real + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push, and delete versions of the package this repo publishes + id-token: write # cosign keyless signing and provenance + attestations: write # provenance attestation + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + with: + cosign-release: "v3.0.6" + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 + + - name: Push a signed release + uses: ./.github/actions/push-signed-test-image + with: + image: ${{ env.IMAGE }} + tag: v0.0.1 + variant: release + + - name: Push the first head image + id: first + uses: ./.github/actions/push-signed-test-image + with: + image: ${{ env.IMAGE }} + tag: head + variant: first-head + + - name: Record the current head image + id: previous-head + uses: loft-sh/github-actions/.github/actions/delete-previous-head-image@delete-previous-head-image/v1 + with: + mode: record + image: ${{ env.IMAGE }} + + - name: Check record returned the first head image + env: + RECORDED: ${{ steps.previous-head.outputs.digest }} + PUSHED: ${{ steps.first.outputs.digest }} + run: test "${RECORDED}" = "${PUSHED}" + + - name: Snapshot the first head image + env: + GITHUB_TOKEN: ${{ github.token }} + PREVIOUS: ${{ steps.previous-head.outputs.digest }} + run: bash test-fixtures/delete-previous-head-image/check.sh snapshot "${PREVIOUS}" | tee "${RUNNER_TEMP}/previous.txt" + + - name: Push the second head image + id: second + uses: ./.github/actions/push-signed-test-image + with: + image: ${{ env.IMAGE }} + tag: head + variant: second-head + + - name: Delete the previous head image + uses: loft-sh/github-actions/.github/actions/delete-previous-head-image@delete-previous-head-image/v1 + with: + mode: delete + image: ${{ env.IMAGE }} + previous-digest: ${{ steps.previous-head.outputs.digest }} + current-digest: ${{ steps.second.outputs.digest }} + + - name: Check every version of the first head image is gone + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash test-fixtures/delete-previous-head-image/check.sh gone "${RUNNER_TEMP}/previous.txt" + + - name: Check head still pulls and verifies + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash test-fixtures/delete-previous-head-image/check.sh intact head + + - name: Check the release still pulls and verifies + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash test-fixtures/delete-previous-head-image/check.sh intact v0.0.1 + + - name: Delete again with the same digest + uses: loft-sh/github-actions/.github/actions/delete-previous-head-image@delete-previous-head-image/v1 + with: + mode: delete + image: ${{ env.IMAGE }} + previous-digest: ${{ steps.previous-head.outputs.digest }} + current-digest: ${{ steps.second.outputs.digest }} + + shared-platform: + name: Keep the signature of a platform shared with a release + needs: real-delete + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push, and delete versions of the package this repo publishes + id-token: write # cosign keyless signing and provenance + attestations: write # provenance attestation + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + with: + cosign-release: "v3.0.6" + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 + + - name: Push a signed release + uses: ./.github/actions/push-signed-test-image + with: + image: ${{ env.IMAGE }} + tag: v0.0.2 + variant: shared-release + + - name: Sign the release's amd64 manifest on its own + id: platform + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + amd64="$(bash test-fixtures/delete-previous-head-image/check.sh platform v0.0.2 amd64)" + test -n "${amd64}" + cosign sign --yes "${IMAGE}@${amd64}" + echo "digest=${amd64}" >> "${GITHUB_OUTPUT}" + + - name: Push a signed head image that reuses the release's amd64 manifest + id: old-head + env: + GITHUB_TOKEN: ${{ github.token }} + AMD64: ${{ steps.platform.outputs.digest }} + run: | + docker buildx imagetools create --tag "${IMAGE}:shared-head" \ + --annotation "index:org.opencontainers.image.description=shared-head-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + "${IMAGE}@${AMD64}" + digest="$(bash test-fixtures/delete-previous-head-image/check.sh digest shared-head)" + test -n "${digest}" + cosign sign --yes "${IMAGE}@${digest}" + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + + - name: Record the current head image + id: previous-head + uses: loft-sh/github-actions/.github/actions/delete-previous-head-image@delete-previous-head-image/v1 + with: + mode: record + image: ${{ env.IMAGE }} + tag: shared-head + + - name: Check record returned the old head image + env: + RECORDED: ${{ steps.previous-head.outputs.digest }} + PUSHED: ${{ steps.old-head.outputs.digest }} + run: test "${RECORDED}" = "${PUSHED}" + + - name: Push the new head image + id: new-head + uses: ./.github/actions/push-signed-test-image + with: + image: ${{ env.IMAGE }} + tag: shared-head + variant: shared-new-head + + - name: Delete the previous head image + uses: loft-sh/github-actions/.github/actions/delete-previous-head-image@delete-previous-head-image/v1 + with: + mode: delete + image: ${{ env.IMAGE }} + tag: shared-head + previous-digest: ${{ steps.previous-head.outputs.digest }} + current-digest: ${{ steps.new-head.outputs.digest }} + + - name: Check the shared manifest kept its signature + env: + GITHUB_TOKEN: ${{ github.token }} + OLD: ${{ steps.previous-head.outputs.digest }} + AMD64: ${{ steps.platform.outputs.digest }} + run: bash test-fixtures/delete-previous-head-image/check.sh shared "${OLD}" "${AMD64}" + + - name: Check the release still pulls and verifies + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash test-fixtures/delete-previous-head-image/check.sh intact v0.0.2 diff --git a/test-fixtures/delete-previous-head-image/Dockerfile b/test-fixtures/delete-previous-head-image/Dockerfile new file mode 100644 index 0000000..1d1d682 --- /dev/null +++ b/test-fixtures/delete-previous-head-image/Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +COPY marker /marker diff --git a/test-fixtures/delete-previous-head-image/check.sh b/test-fixtures/delete-previous-head-image/check.sh new file mode 100755 index 0000000..01bf727 --- /dev/null +++ b/test-fixtures/delete-previous-head-image/check.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Registry checks for caller-delete-previous-head-image.yaml. +# check.sh snapshot print every manifest digest that belongs to one image +# check.sh gone fail unless every digest in is gone +# check.sh intact fail unless pulls on both platforms and verifies +# check.sh digest print the digest points to +# check.sh platform print the linux/ manifest digest of +# check.sh shared fail unless and its signatures are gone while +# and its signatures remain and verify +set -euo pipefail + +image="${IMAGE:?IMAGE is required, such as ghcr.io//}" +repo="${image#ghcr.io/}" +accept="application/vnd.oci.image.index.v1+json,application/vnd.oci.image.manifest.v1+json" + +registry_token() { + curl -fsS -u "token:${GITHUB_TOKEN:?}" "https://ghcr.io/token?scope=repository:${repo}:pull" | jq -r .token +} +token="$(registry_token)" + +# status : HTTP status of the manifest +status() { + curl -sS -o /dev/null -w '%{http_code}' -I -H "Authorization: Bearer ${token}" -H "Accept: ${accept}" \ + "https://ghcr.io/v2/${repo}/manifests/$1" +} + +# manifest : manifest body, empty when missing +manifest() { + curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: ${accept}" \ + "https://ghcr.io/v2/${repo}/manifests/$1" 2>/dev/null || true +} + +# digest +digest() { + curl -fsS -I -H "Authorization: Bearer ${token}" -H "Accept: ${accept}" \ + "https://ghcr.io/v2/${repo}/manifests/$1" | tr -d '\r' | awk 'tolower($1)=="docker-content-digest:"{print $2}' +} + +snapshot() { + local root="$1" subject referrers + echo "${root}" + local subjects=("${root}") + while read -r child; do subjects+=("${child}"); echo "${child}"; done \ + < <(manifest "${root}" | jq -r '.manifests[]?.digest') + for subject in "${subjects[@]}"; do + referrers="${subject/:/-}" + [[ "$(status "${referrers}")" == 200 ]] || continue + digest "${referrers}" + manifest "${referrers}" | jq -r '.manifests[]?.digest' + done +} + +gone() { + local failed=0 count=0 + while read -r d; do + count=$((count + 1)) + code="$(status "${d}")" + if [[ "${code}" != 404 ]]; then + echo "::error::${d} still exists (HTTP ${code})" + failed=1 + fi + done < "$1" + [[ "${count}" -ge 4 ]] || { echo "::error::snapshot has only ${count} digests"; exit 1; } + [[ "${failed}" == 0 ]] && echo "all ${count} versions of the previous head are gone" + return "${failed}" +} + +intact() { + local tag="$1" d + d="$(digest "${tag}")" + [[ -n "${d}" ]] || { echo "::error::${image}:${tag} does not resolve"; exit 1; } + for platform in linux/amd64 linux/arm64; do + docker pull -q --platform "${platform}" "${image}:${tag}" >/dev/null + echo "pulled ${image}:${tag} for ${platform}" + done + cosign verify "${image}@${d}" \ + --certificate-identity-regexp "${COSIGN_IDENTITY:?}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com >/dev/null + echo "cosign verify passed for ${image}:${tag} (${d})" +} + +platform() { + manifest "$1" | jq -r --arg arch "$2" \ + '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch) | .digest' +} + +shared() { + local old="$1" platform="$2" failed=0 ref + for ref in "${old}" "${old/:/-}"; do + if [[ "$(status "${ref}")" != 404 ]]; then + echo "::error::${ref} from the previous head still exists" + failed=1 + fi + done + for ref in "${platform}" "${platform/:/-}"; do + if [[ "$(status "${ref}")" != 200 ]]; then + echo "::error::${ref}, shared with the release, is gone" + failed=1 + fi + done + [[ "${failed}" == 0 ]] || return 1 + cosign verify "${image}@${platform}" \ + --certificate-identity-regexp "${COSIGN_IDENTITY:?}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com >/dev/null + echo "previous head and its signature are gone; shared ${platform} and its signature remain and verify" +} + +case "${1:-}" in + snapshot) snapshot "${2:?digest}" ;; + gone) gone "${2:?file}" ;; + intact) intact "${2:?tag}" ;; + digest) digest "${2:?ref}" ;; + platform) platform "${2:?tag}" "${3:?arch}" ;; + shared) shared "${2:?old digest}" "${3:?platform digest}" ;; + *) echo "usage: check.sh snapshot|gone|intact|digest|platform|shared " >&2; exit 2 ;; +esac