docker-build.yml publishes the image and only then gates on it, so a build that fails the CRITICAL/HIGH scan has already moved latest.
The ordering, as of v3.5.0:
- name: Build & push # tags: type=sha,format=short + type=raw,value=latest
uses: docker/build-push-action@…
with:
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
- name: Sign with cosign # signs the tags at that digest
- name: Trivy scan (fail on findings)
with:
image-ref: …@${{ steps.build.outputs.digest }}
exit-code: '1'
By the time the scan runs, the tags are live. The job goes red, the check reports the failure, and consumers still pull the image that failed it.
Observed
HordiaLabs/extractor-llm run 35353826558: the Trivy step failed on two HIGH findings, and ghcr.io/hordialabs/extractor-llm shows the image it scanned published as sha-095c975 — carrying latest and main until the next successful build replaced them a day later. Anything doing docker compose pull in that window got the image the gate had rejected.
Cosign is a smaller version of the same thing: the signature attests where the image came from, which stays true, but it is issued before the image is known to pass.
Suggested fix
Push by digest first, tag only after the scan passes. docker/build-push-action supports this and it stays multi-arch-safe:
- name: Build & push by digest
id: build
with:
push: ${{ inputs.push }}
tags: '' # no tags yet
outputs: type=image,name=${{ steps.imageref.outputs.repo }},push-by-digest=true,name-canonical=true,push=${{ inputs.push }}
- name: Trivy scan (fail on findings)
with:
image-ref: ${{ steps.imageref.outputs.repo }}@${{ steps.build.outputs.digest }}
exit-code: '1'
- name: Tag the scanned digest
if: inputs.push
run: |
while IFS= read -r tag; do
[ -z "$tag" ] && continue
docker buildx imagetools create --tag "$tag" \
"${{ steps.imageref.outputs.repo }}@${{ steps.build.outputs.digest }}"
done <<< "${{ steps.meta.outputs.tags }}"
Cosign then moves below the scan too, signing the digest that passed.
An untagged digest is still reachable by anyone who knows it, and it will sit in the registry until GC — but nothing resolves to it, which is the property that matters: latest never advances to an image that failed the gate.
Two smaller alternatives, both worse here: load: true and scan locally before pushing (breaks multi-arch), or delete the tags in an if: failure() step (racy — the bad latest is live for the length of the scan, and a cleanup step can itself fail).
Blast radius
Every consumer with run-trivy-scan: true. It has been latent because the scan had never actually run until v3.3.0 fixed the mixed-case image reference — the first honest scan is also the first time a red gate has had a published image behind it.
docker-build.ymlpublishes the image and only then gates on it, so a build that fails the CRITICAL/HIGH scan has already movedlatest.The ordering, as of v3.5.0:
By the time the scan runs, the tags are live. The job goes red, the check reports the failure, and consumers still pull the image that failed it.
Observed
HordiaLabs/extractor-llmrun 35353826558: the Trivy step failed on two HIGH findings, andghcr.io/hordialabs/extractor-llmshows the image it scanned published assha-095c975— carryinglatestandmainuntil the next successful build replaced them a day later. Anything doingdocker compose pullin that window got the image the gate had rejected.Cosign is a smaller version of the same thing: the signature attests where the image came from, which stays true, but it is issued before the image is known to pass.
Suggested fix
Push by digest first, tag only after the scan passes.
docker/build-push-actionsupports this and it stays multi-arch-safe:Cosign then moves below the scan too, signing the digest that passed.
An untagged digest is still reachable by anyone who knows it, and it will sit in the registry until GC — but nothing resolves to it, which is the property that matters:
latestnever advances to an image that failed the gate.Two smaller alternatives, both worse here:
load: trueand scan locally before pushing (breaks multi-arch), or delete the tags in anif: failure()step (racy — the badlatestis live for the length of the scan, and a cleanup step can itself fail).Blast radius
Every consumer with
run-trivy-scan: true. It has been latent because the scan had never actually run until v3.3.0 fixed the mixed-case image reference — the first honest scan is also the first time a red gate has had a published image behind it.