diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 11cea66..2a4c914 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -112,31 +112,55 @@ jobs: # the artifact release.yml produces instead. Bounded: a genuinely # broken/skipped release run fails this job loudly rather than # hanging forever. + # + # The loop retries TWO distinct not-ready states (measured live on + # run 2265dbb 2026-09-15: deploy pulled the image at 02:36:45 and + # failed verify at 02:37:03 with "no signatures found" while the + # release run attached the signature at 02:37:11 — an 8-second race + # that the pull-only retry missed): + # 1. tag not pushed yet (release still building) + # 2. tag pushed but signature not attached yet (release between + # push and cosign sign) attempt=0 max_attempts=40 - until docker pull "$IMAGE_REF" >/tmp/pull.log 2>&1; do + digest="" + while true; do attempt=$((attempt + 1)) if [ "$attempt" -ge "$max_attempts" ]; then - echo "::error::no signed image found at $IMAGE_REF after $max_attempts attempts (10 min) — release.yml has not produced an artifact for sha-$SHORT. Refusing to deploy an unverified rebuild." - cat /tmp/pull.log + echo "::error::no signed image found at $IMAGE_REF after $max_attempts attempts (10 min) — release.yml has not produced a signed artifact for sha-$SHORT. Refusing to deploy an unverified rebuild." + cat /tmp/pull.log 2>/dev/null || true + cat /tmp/verify.log 2>/dev/null || true exit 1 fi - echo "image not yet available at $IMAGE_REF (attempt $attempt/$max_attempts) — waiting for release.yml..." - sleep 15 + if ! docker pull "$IMAGE_REF" >/tmp/pull.log 2>&1; then + echo "image not yet available at $IMAGE_REF (attempt $attempt/$max_attempts) — waiting for release.yml..." + sleep 15 + continue + fi + + # Read back the digest the registry actually stored for this tag — + # same filter release.yml itself uses (RepoDigests can hold multiple + # entries; never trust index 0) — and verify THAT digest, never the + # mutable tag alone, so a tag reassigned after signing can't slip past. + repo_digest="$(docker inspect --format='{{json .RepoDigests}}' "$IMAGE_REF" \ + | node -e "const rd=JSON.parse(require('fs').readFileSync(0,'utf8')); const prefix=process.env.IMAGE_REPO+'@'; const m=rd.find(r=>r.startsWith(prefix)); if(!m){console.error('no RepoDigests entry starting with '+prefix+' among: '+JSON.stringify(rd));process.exit(1);} console.log(m);")" + digest="${repo_digest##*@}" + echo "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo "::error::unexpected digest shape: $digest"; exit 1; } + + echo "verifying cosign signature on ${IMAGE_REPO}@${digest} ..." + if cosign verify --key cosign.pub "${IMAGE_REPO}@${digest}" >/tmp/verify.log 2>&1; then + break + fi + if grep -q "no signatures found" /tmp/verify.log; then + echo "image present but signature not attached yet (attempt $attempt/$max_attempts) — waiting for release.yml's cosign sign..." + sleep 15 + continue + fi + echo "::error::cosign verify FAILED for ${IMAGE_REPO}@${digest} (not a not-ready state — refusing to deploy)" + cat /tmp/verify.log + exit 1 done - # Read back the digest the registry actually stored for this tag — - # same filter release.yml itself uses (RepoDigests can hold multiple - # entries; never trust index 0) — and verify THAT digest, never the - # mutable tag alone, so a tag reassigned after signing can't slip past. - repo_digest="$(docker inspect --format='{{json .RepoDigests}}' "$IMAGE_REF" \ - | node -e "const rd=JSON.parse(require('fs').readFileSync(0,'utf8')); const prefix=process.env.IMAGE_REPO+'@'; const m=rd.find(r=>r.startsWith(prefix)); if(!m){console.error('no RepoDigests entry starting with '+prefix+' among: '+JSON.stringify(rd));process.exit(1);} console.log(m);")" - digest="${repo_digest##*@}" - echo "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo "::error::unexpected digest shape: $digest"; exit 1; } - - echo "verifying cosign signature on ${IMAGE_REPO}@${digest} ..." - cosign verify --key cosign.pub "${IMAGE_REPO}@${digest}" - echo "digest=$digest" >> "$GITHUB_OUTPUT" echo "SIGNED ARTIFACT VERIFIED: ${IMAGE_REPO}@${digest} — authorizing Coolify redeploy of sha-$SHORT"