Skip to content
Draft
5 changes: 5 additions & 0 deletions .changeset/rane-4683-manifest-cosign-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"build-push-docker-manifest": minor
---

Harden manifest create and cosign sign/verify for idempotent build-publish reruns (RANE-4683): skip imagetools create when the tag already points at the expected platform digests, skip cosign sign when a valid signature is already present, and retry cosign verify after signing to absorb Sigstore propagation lag
184 changes: 170 additions & 14 deletions actions/build-push-docker-manifest/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ runs:
}}
registries: ${{ inputs.aws-account-number }}

- name: Ensure jq is available
shell: bash
run: |
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq is required but not installed on this runner (preinstalled on GitHub-hosted ubuntu-24.04). Install jq before running this action."
exit 1
fi
echo "jq available: $(jq --version)"

- name: Generate manifest annotations
id: generate-annotations
shell: bash
Expand Down Expand Up @@ -315,7 +324,7 @@ runs:
fi

- name: Create and push Docker manifest
id: create-push-docker-manifest
id: manifest-create
shell: bash
env:
DOCKER_MANIFEST_NAME: ${{ steps.manifest-name.outputs.name }}
Expand All @@ -327,10 +336,103 @@ runs:
${{ steps.generate-annotations.outputs.annotation-flags }}
TAG_FLAGS: ${{ steps.process-additional-tags.outputs.tag-flags }}
run: |
set -euo pipefail

Comment thread
HashWrangler marked this conversation as resolved.
normalize_digest_csv() {
local input="$1"
IFS=',' read -ra _digests <<< "$input"
local normalized=()
for digest in "${_digests[@]}"; do
digest=$(echo "$digest" | xargs)
if [[ -z "$digest" ]]; then
continue
fi
if [[ ! "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "::error::docker-image-name-digests contains invalid digest '${digest}'; expected sha256:<64-hex>"
exit 1
fi
normalized+=("$digest")
done
if [[ ${#normalized[@]} -eq 0 ]]; then
echo ""
return
fi
mapfile -t sorted < <(printf '%s\n' "${normalized[@]}" | sort -u)
local IFS=','
echo "${sorted[*]}"
}

get_existing_platform_digests() {
local tag="$1"
local inspect_json inspect_stderr inspect_status

inspect_stderr=$(mktemp)
set +e
inspect_json=$(docker buildx imagetools inspect "${tag}" --format '{{json .}}' 2>"${inspect_stderr}")
inspect_status=$?
set -e

if [[ "${inspect_status}" -ne 0 ]]; then
if grep -qiE 'not found|manifest unknown|name unknown|404|does not exist|no such manifest' "${inspect_stderr}"; then
rm -f "${inspect_stderr}"
return 1
fi

echo "::error::Failed to inspect manifest tag ${tag}"
cat "${inspect_stderr}" >&2
rm -f "${inspect_stderr}"
exit 1
fi

rm -f "${inspect_stderr}"

local existing_digests
existing_digests=$(echo "${inspect_json}" | jq -r '
.manifest.manifests[]?
| select(
((.platform.os // "") | ascii_downcase) != "unknown"
and ((.annotations."vnd.docker.reference.type" // "") != "attestation-manifest")
)
| .digest
' | sort -u | paste -sd, -)

if [[ -z "${existing_digests}" ]]; then
echo "::error::Manifest tag ${tag} exists but no platform image digests could be extracted"
exit 1
fi

echo "${existing_digests}"
}

DOCKER_MANIFEST_NAME_WITH_TAG="${DOCKER_MANIFEST_NAME}:${DOCKER_MANIFEST_TAG}"
# Convert comma-separated list into array and pass as separate arguments
IFS=',' read -ra DIGESTS <<< "$DOCKER_IMAGE_NAME_DIGESTS"
# Map each digest to include the manifest name
EXPECTED_DIGESTS=$(normalize_digest_csv "${DOCKER_IMAGE_NAME_DIGESTS}")

if [[ -z "${EXPECTED_DIGESTS}" ]]; then
echo "::error::docker-image-name-digests must contain at least one sha256 digest"
exit 1
fi

if EXISTING_DIGESTS=$(get_existing_platform_digests "${DOCKER_MANIFEST_NAME_WITH_TAG}"); then
echo "Found existing manifest for ${DOCKER_MANIFEST_NAME_WITH_TAG}"
echo " Expected platform digests: ${EXPECTED_DIGESTS}"
echo " Existing platform digests: ${EXISTING_DIGESTS}"

if [[ "${EXISTING_DIGESTS}" == "${EXPECTED_DIGESTS}" ]]; then
echo "✅ Manifest already exists with expected platform digests; skipping imagetools create (idempotent rerun)"
echo "skipped=true" | tee -a "${GITHUB_OUTPUT}"
exit 0
fi

echo "::error::Manifest tag ${DOCKER_MANIFEST_NAME_WITH_TAG} already exists with different platform digests"
echo "::error::Expected: ${EXPECTED_DIGESTS}"
echo "::error::Existing: ${EXISTING_DIGESTS}"
exit 1
fi

echo "skipped=false" | tee -a "${GITHUB_OUTPUT}"

# Use normalized, de-duplicated digests for manifest create
IFS=',' read -ra DIGESTS <<< "$EXPECTED_DIGESTS"
PREFIXED_DIGESTS=()
for digest in "${DIGESTS[@]}"; do
PREFIXED_DIGESTS+=("${DOCKER_MANIFEST_NAME}@${digest}")
Expand Down Expand Up @@ -387,9 +489,11 @@ runs:
fi
fi

echo "Attempt ${i}/${MAX_RETRIES}: Manifest not yet available (got: '${MANIFEST_DIGEST}'), retrying in ${RETRY_DELAY}s..."

sleep $RETRY_DELAY
echo "Attempt ${i}/${MAX_RETRIES}: Manifest not yet available (got: '${MANIFEST_DIGEST}')..."
if [[ "${i}" -lt "${MAX_RETRIES}" ]]; then
echo "Retrying in ${RETRY_DELAY}s..."
sleep "${RETRY_DELAY}"
fi
MANIFEST_DIGEST=""
done

Expand All @@ -412,17 +516,38 @@ runs:

- name: Sign Docker Manifest using GH OIDC
if: inputs.docker-manifest-sign == 'true'
shell: sh
id: sign-manifest
shell: bash
env:
MANIFEST_NAME_WITH_DIGEST:
${{ steps.inspect-docker-manifest.outputs.manifest-name-with-digest }}
run: cosign sign "${MANIFEST_NAME_WITH_DIGEST}" --yes
GITHUB_WORKFLOW_REPOSITORY: ${{ inputs.github-workflow-repository }}
OIDC_ISSUER: ${{ inputs.cosign-oidc-issuer }}
OIDC_IDENTITY_REGEXP: ${{ inputs.cosign-oidc-identity-regexp }}
run: |
set -euo pipefail

verify_manifest_signature() {
cosign verify "${MANIFEST_NAME_WITH_DIGEST}" \
--certificate-oidc-issuer "${OIDC_ISSUER}" \
--certificate-identity-regexp "${OIDC_IDENTITY_REGEXP}" \
--certificate-github-workflow-repository "${GITHUB_WORKFLOW_REPOSITORY}"
}

if [[ -n "${OIDC_IDENTITY_REGEXP}" ]] && verify_manifest_signature >/dev/null 2>&1; then
echo "✅ Manifest already signed with expected identity; skipping cosign sign (idempotent rerun)"
echo "skipped=true" | tee -a "${GITHUB_OUTPUT}"
exit 0
fi

echo "skipped=false" | tee -a "${GITHUB_OUTPUT}"
cosign sign "${MANIFEST_NAME_WITH_DIGEST}" --yes

- name: Verify Docker image signature
if:
inputs.docker-manifest-sign == 'true' &&
inputs.cosign-oidc-identity-regexp != ''
shell: sh
shell: bash
env:
MANIFEST_NAME_WITH_DIGEST: >-
${{
Expand All @@ -432,10 +557,33 @@ runs:
OIDC_ISSUER: ${{ inputs.cosign-oidc-issuer }}
OIDC_IDENTITY_REGEXP: ${{ inputs.cosign-oidc-identity-regexp }}
run: |
cosign verify "${MANIFEST_NAME_WITH_DIGEST}" \
--certificate-oidc-issuer "${OIDC_ISSUER}" \
--certificate-identity-regexp "${OIDC_IDENTITY_REGEXP}" \
--certificate-github-workflow-repository "${GITHUB_WORKFLOW_REPOSITORY}"
MAX_RETRIES=5
RETRY_DELAY=10
VERIFY_OK=false

for i in $(seq 1 $MAX_RETRIES); do
echo "Attempt ${i}/${MAX_RETRIES}: Verifying cosign signature for ${MANIFEST_NAME_WITH_DIGEST}..."

if cosign verify "${MANIFEST_NAME_WITH_DIGEST}" \
--certificate-oidc-issuer "${OIDC_ISSUER}" \
--certificate-identity-regexp "${OIDC_IDENTITY_REGEXP}" \
--certificate-github-workflow-repository "${GITHUB_WORKFLOW_REPOSITORY}"; then
echo "Successfully verified signature on attempt ${i}"
VERIFY_OK=true
break
fi

echo "Attempt ${i}/${MAX_RETRIES}: Signature not yet available..."
if [[ "${i}" -lt "${MAX_RETRIES}" ]]; then
echo "Retrying in ${RETRY_DELAY}s..."
sleep "${RETRY_DELAY}"
fi
done

if [[ "$VERIFY_OK" != "true" ]]; then
echo "::error::Failed to verify cosign signature for ${MANIFEST_NAME_WITH_DIGEST} after ${MAX_RETRIES} attempts"
exit 1
fi

- name: Summary output
shell: bash
Expand All @@ -456,6 +604,8 @@ runs:
steps.inspect-docker-manifest.outputs.manifest-name-with-tag
}}
MANIFEST_TAG: ${{ inputs.docker-manifest-tag }}
CREATE_SKIPPED: ${{ steps.manifest-create.outputs.skipped }}
SIGN_SKIPPED: ${{ steps.sign-manifest.outputs.skipped }}
OIDC_ISSUER: ${{ inputs.cosign-oidc-issuer }}
OIDC_IDENTITY_REGEXP: ${{ inputs.cosign-oidc-identity-regexp }}
run: |
Expand All @@ -467,10 +617,16 @@ runs:
echo "Manifest additional tags: \`${MANIFEST_ADDITIONAL_TAGS:-None}\`" | tee -a "${GITHUB_STEP_SUMMARY}"
echo "Manifest name with tag: \`${MANIFEST_NAME_WITH_TAG}\`" | tee -a "${GITHUB_STEP_SUMMARY}"
echo "Manifest name with digest: \`${MANIFEST_NAME_WITH_DIGEST}\`" | tee -a "${GITHUB_STEP_SUMMARY}"
if [[ "${CREATE_SKIPPED}" == "true" ]]; then
echo "Manifest create: skipped (existing tag already points at expected platform digests)" | tee -a "${GITHUB_STEP_SUMMARY}"
fi
if [[ "${DOCKER_MANIFEST_SIGNED}" == 'true' ]]; then
echo >> "${GITHUB_STEP_SUMMARY}"
echo "#### Docker Manifest signed 📝" | tee -a "${GITHUB_STEP_SUMMARY}"
echo "Manifest signed with cosign. To verify, run:" | tee -a "${GITHUB_STEP_SUMMARY}"
if [[ "${SIGN_SKIPPED}" == "true" ]]; then
echo "Cosign sign: skipped (valid signature already present)" | tee -a "${GITHUB_STEP_SUMMARY}"
fi
echo "\`\`\`shell" >> "${GITHUB_STEP_SUMMARY}"
echo "cosign verify ${MANIFEST_NAME_WITH_DIGEST} --certificate-oidc-issuer ${OIDC_ISSUER} --certificate-identity-regexp '${OIDC_IDENTITY_REGEXP}' --certificate-github-workflow-repository ${GITHUB_WORKFLOW_REPOSITORY}" | tee -a "${GITHUB_STEP_SUMMARY}"
echo "\`\`\`" >> "${GITHUB_STEP_SUMMARY}"
Expand Down
Loading