Docker in CI/CD: build to promote
Build, test, scan, push, attest, sign, verify and promote by digest.
cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/dockerci.tar.gz && tar -xzf dockerci.tar.gz, which creates ~/lab/dockerci/. SHA-256: 1c893c393c9139b2cb778aaa4575f9c663cce9b6d0ebc01b0eae3e6f2f803692A common release job builds the image twice: once with load: true so Trivy can scan it on the runner, and once with push: true for the registry. The scan passes, the push succeeds, and the image in the registry is not the one that was scanned. It is a second build that matches the first only if every layer came from cache, and with provenance attached its digest differs anyway. Then a cosign sign step signs whatever the tag points at by the time it runs. Each step looks right; the order and the identity of the artifact are wrong. This lesson builds a pipeline in which one image, identified by one digest, goes through every gate, and promotion to production requires a signature over that digest. It runs on the main lab VM, secopslog-docker, in ~/lab/dockerci with the lesson files, as a script you could run in any CI system, then shows the same steps as a GitHub Actions workflow.
The order
The order follows from what each step needs. Test and scan must run on the image you will ship, so the build happens once and every later step refers to its output. The push comes after the gates, so a failing build never appears in any registry. Signing and attesting come after the push because Cosign signs a manifest digest and stores the signature in the registry next to it; before the push there is nothing to sign. The digest is checked against the build's own record, so the signature covers the bytes that were tested. Verification is a separate step that a different job repeats before every promotion.
Three repositories carry the result, and a push to one is not an approval:
Anyone with push rights to a registry can put an image there ("Tags, digests and promotion" showed a direct push overwriting a production tag). What makes a digest approved is a signature and an attestation that verify against your key or your CI identity. The policy belongs in the promotion job and, in Kubernetes, in an admission controller that verifies before it admits a pod.
Tools, keys and registries
The lab uses the same pinned tools as "Pinning, SBOMs, provenance and scanning" and "Signing, verifying and promoting by digest" in Advanced container security, which cover Cosign and Syft in depth: Cosign 3.1.3, Syft 1.54.1 and Trivy 0.75.0, downloaded from their GitHub releases into ~/lab/bin. Nothing is installed system-wide. The script that fetches them carries its own SHA-256 values:
#!/bin/sh# Usage: ./install-tools.sh TOOL... (TOOL: cosign syft trivy grype)# Downloads pinned release binaries from GitHub, checks each against the SHA-256 committed below,# and installs them into $BIN (default ~/lab/bin). Nothing system-wide.# The hashes live in this file, in your repository: a release asset replaced on GitHub after you# pinned it fails the check. Bumping a version means updating its two hashes in a reviewed change.set -euBIN=${BIN:-$HOME/lab/bin}COSIGN=3.1.3 SYFT=1.54.1 TRIVY=0.75.0 GRYPE=0.120.1case $(uname -m) inaarch64|arm64) A=arm64 T=ARM64 ;;x86_64) A=amd64 T=64bit ;;*) echo "unsupported CPU $(uname -m)" >&2; exit 1 ;;esacsha() { # sha ASSET: the pinned SHA-256 of a release assetcase $1 incosign-linux-arm64) echo c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a ;;cosign-linux-amd64) echo 4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 ;;syft_1.54.1_linux_arm64.tar.gz) echo dfdf0537610113edbefe1f1fc6548bc957b2d77439636ec824fcf0e10d46d054 ;;syft_1.54.1_linux_amd64.tar.gz) echo c069905b391cc4c20a5ba65ad5c10be2a7ba074f8ea6ad203e24d14e303dad47 ;;trivy_0.75.0_Linux-ARM64.tar.gz) echo a1ee9f6ffb7d112b64ff726a2a0717c21175c1114361391f4a132956751a13b3 ;;trivy_0.75.0_Linux-64bit.tar.gz) echo c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f ;;grype_0.120.1_linux_arm64.tar.gz) echo 29f47391dc283aa79fcc38e65224cd61f64dec0ecfd0db7074128ebf8ff23514 ;;grype_0.120.1_linux_amd64.tar.gz) echo 0a9ee97ef5ae2ee953b0a80098105052e846cdbe319a57d808b519c33cd1343d ;;*) echo "no pinned hash for $1" >&2; return 1 ;;esac}GH=https://github.comtmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXITmkdir -p "$BIN"fetch() { # fetch URL_DIR ASSET: download the asset and check it against the pinned hashwant=$(sha "$2")curl -fsSL --retry 10 --retry-delay 5 --retry-all-errors -o "$tmp/$2" "$1/$2"(cd "$tmp" && echo "$want $2" | sha256sum -c -)}for t in "$@"; docase $t incosign) u=$GH/sigstore/cosign/releases/download/v$COSIGNfetch "$u" "cosign-linux-$A"install -m 0755 "$tmp/cosign-linux-$A" "$BIN/cosign" ;;syft) u=$GH/anchore/syft/releases/download/v$SYFTfetch "$u" "syft_${SYFT}_linux_$A.tar.gz"tar -xzf "$tmp/syft_${SYFT}_linux_$A.tar.gz" -C "$BIN" syft ;;trivy) u=$GH/aquasecurity/trivy/releases/download/v$TRIVYfetch "$u" "trivy_${TRIVY}_Linux-$T.tar.gz"tar -xzf "$tmp/trivy_${TRIVY}_Linux-$T.tar.gz" -C "$BIN" trivy ;;grype) u=$GH/anchore/grype/releases/download/v$GRYPEfetch "$u" "grype_${GRYPE}_linux_$A.tar.gz"tar -xzf "$tmp/grype_${GRYPE}_linux_$A.tar.gz" -C "$BIN" grype ;;*) echo "unknown tool $t" >&2; exit 1 ;;esacdone
A checksum file downloaded from the same release as the binary only catches a damaged download: whoever can replace the binary on the release page can replace the checksum file next to it. Hashes committed in your repository, taken when you reviewed the version, catch that too, the same way the hashed lock file in "Recipe: a Python web app" protects the Python dependencies. Moving to a new tool version is then a reviewed change of the version and its two hashes. On a flaky connection curl prints curl: (35) lines while it retries; the OK lines prove what arrived.
Locally, signing uses a key pair protected by a test password (COSIGN_PASSWORD, exported in the shell), and a signing configuration with no transparency log, so lab signatures are not published to the public Rekor log. Verification therefore passes --insecure-ignore-tlog=true. In CI the job signs keylessly instead, as the workflow below shows; "Signing, verifying and promoting by digest" (Advanced container security) explains both modes. Two registry:3 containers stand in for the build and production registries.
The pipeline script
#!/bin/sh# Usage: ./ci.sh VERSION# The pipeline one commit goes through. The CI job runs this same script; locally it uses# cosign.key with COSIGN_PASSWORD, in GitHub Actions the sign step is keyless (see the workflow).# Stops at the first failing stage. Nothing reaches the registry before test and scan pass.set -euVERSION=$1REPO=${BUILD_REPO:-localhost:5000/lab-build/app}SIGN="--key cosign.key --signing-config lab-signing-config.json"VERIFY="--key cosign.pub --insecure-ignore-tlog=true"stage() { printf '\n== %s\n' "$*"; }stage "1 build $REPO:$VERSION"# the default (docker driver) builder: with the containerd image store, --load keeps the indexdocker buildx build --builder default -q --provenance=mode=max --metadata-file build.json \-t "$REPO:$VERSION" --load appbuilt=$(jq -r '."containerimage.digest"' build.json)echo "built $built"stage "2 test: start the image, call it, stop it"docker run -d --name lab-ci-test -p 127.0.0.1:3900:3000 "$REPO:$VERSION" >/dev/nulltrap 'docker rm -f lab-ci-test >/dev/null 2>&1' EXITfor i in $(seq 1 30); do[ "$(docker inspect -f '{{.State.Health.Status}}' lab-ci-test)" = healthy ] && break; sleep 1donecurl -fsS http://127.0.0.1:3900/ | grep -q '"service":"payments"'docker rm -f lab-ci-test >/dev/nullecho "test passed"stage "3 scan: fail on HIGH or CRITICAL with a fix available"trivy image -q --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$REPO:$VERSION"echo "scan passed"stage "4 push the image that was tested and scanned"docker push -q "$REPO:$VERSION"stage "5 resolve the digest"digest=$(docker buildx imagetools inspect "$REPO:$VERSION" --format '{{.Manifest.Digest}}')[ "$digest" = "$built" ] || { echo "registry has $digest, the build produced $built"; exit 1; }echo "$digest" | tee "release-$VERSION.digest"stage "6 attest: SBOM (signed) next to BuildKit's provenance"syft scan -q "registry:$REPO@$digest" -o spdx-json=sbom.spdx.jsoncosign attest --yes $SIGN --type spdxjson --predicate sbom.spdx.json "$REPO@$digest"stage "7 sign the digest"cosign sign --yes $SIGN "$REPO@$digest"stage "8 verify before anyone may promote it"cosign verify $VERIFY "$REPO@$digest" >/dev/null 2>&1cosign verify-attestation $VERIFY --type spdxjson "$REPO@$digest" >/dev/null 2>&1echo "VERIFIED $REPO@$digest"
Step 1 builds once with docker buildx build --load and --provenance=mode=max, and --metadata-file records the digest the build produced. The build names the default builder, which uses the docker driver: on Docker 29 with the containerd image store, --load from it keeps the image index with its provenance attestation, so the image in the local store is exactly what will be pushed. A docker-container builder, such as one left selected with docker buildx use, loads a single manifest without the attestation ("Buildx builders, outputs, cache and Bake"), and step 5 would then fail on a digest mismatch. Step 2 starts that image, waits for its health check and calls it. A real project also runs its unit tests, either before the build or in a test stage; this step tests the artifact. Step 3 scans the same local image and fails on HIGH or CRITICAL findings that have a fixed version. Step 5 asks the registry for the digest behind the pushed tag and refuses to continue unless it equals the build's. Steps 6 to 8 attach a Syft SBOM as a signed attestation, sign the digest and verify both.
Run 1: a vulnerable dependency
The app pins minimist 1.2.5, which has a known prototype-pollution vulnerability:
Build and test passed; the scan found CVE-2021-44906, CRITICAL, with fixed versions available, and --exit-code 1 stopped the script. The catalog of the build registry is empty: the image that failed the gate exists only on the build machine. --ignore-unfixed keeps the gate on findings you can act on today; findings without a fix still belong in a report and a risk decision, not in a blocked merge. "Pinning, SBOMs, provenance and scanning" (Advanced container security) covers severity policy and VEX.
Run 2: the fix
Every stage passed. The digest printed by the build (built sha256:...) and the digest the registry returned after the push are the same value, and the script wrote it to release-1.5.0-ci.2.digest, the record the rest of the release uses. Digests on your machine differ: they hash content that includes build timestamps. The provenance attestation inside the pushed index records what the build used:
The base image by digest and platform. Because the signature covers the index digest, and the index lists the attestation manifest, the provenance is covered by the same signature. The SBOM is attached separately with cosign attest, signed with the same key, because many policy tools look for a signed SBOM attestation.
Promotion
#!/bin/sh# Usage: ./promote.sh SOURCE_REPO@DIGEST TARGET_REPO:TAG# Promotion job: verify the signature and the SBOM attestation on the exact digest, copy the image by# digest and its signature artifacts (tag sha256-<hex>), verify both again in the target, and only then# point the release tag at it. Being in the build registry is not an approval; only a digest that# verifies is promoted, and a failed copy never leaves the release tag on an unverified image.set -eusrc=$1 target=$2VERIFY="--key cosign.pub --insecure-ignore-tlog=true"case $src in *@sha256:*) ;; *) echo "REFUSED $src: promote a digest, not a tag"; exit 1 ;; esacdigest=${src#*@}; srcrepo=${src%@*}; dstrepo=${target%:*}verified() { # verified REPO@DIGEST: the signature and the SBOM attestation both verifycosign verify $VERIFY "$1" >/dev/null 2>&1 &&cosign verify-attestation $VERIFY --type spdxjson "$1" >/dev/null 2>&1}verified "$src" || { echo "REFUSED $src: no valid signature and SBOM attestation"; exit 1; }# On registries without the OCI referrers API (registry:3 here) Cosign keeps the signature and the# attestation under this tag. A registry with that API has no such tag; copy with `oras cp -r` there.sigtag=sha256-${digest#sha256:}docker buildx imagetools inspect "$srcrepo:$sigtag" >/dev/null 2>&1 ||{ echo "REFUSED $src: no $sigtag tag to copy"; exit 1; }docker buildx imagetools create --progress=quiet --tag "$dstrepo@$digest" "$src"docker buildx imagetools create --progress=quiet --tag "$dstrepo:$sigtag" "$srcrepo:$sigtag"verified "$dstrepo@$digest" || { echo "FAILED $dstrepo@$digest does not verify in the target"; exit 1; }docker buildx imagetools create --progress=quiet --tag "$target" "$dstrepo@$digest"echo "PROMOTED $dstrepo@$digest as $target"
The promotion job takes a digest, never a tag. It verifies the signature and the SBOM attestation, then copies the image registry-side by digest with docker buildx imagetools create, which moves no tag, and copies the signature artifacts too. That second copy is necessary: imagetools create copies the image index and what it references, and Cosign's signature and attestation live in a separate artifact, tagged sha256-<hex of the digest> in this registry. It verifies both again in the target, and only then points the release tag at the digest. If a copy or the second verification fails, the release tag still names the previous release. Build to staging, then staging to production:
Production holds the release tag and the signature tag, and the container runs from the production registry by digest. Deploy by digest, not by tag, so a later push to the tag cannot change what is running.
The sha256-<hex> tag is the fallback that Cosign 3 uses on registries without the OCI 1.1 referrers API, such as registry:3. On a registry that has the API, the signature and the attestation are stored as referrers of the digest and no such tag exists. In a test with zot 2.1, a registry with that API, Cosign 3.1.3 created no sha256- tag, so promote.sh stops at its tag check before it copies anything. For such registries, copy the image together with its referrers using oras cp -r (ORAS 1.3.4 copied them between zot and registry:3 in both directions in the same test, and cosign verify passed on the copies). cosign copy is deprecated in Cosign 3 and did not carry the new signature format.
Now the bypasses. A tag is refused outright, because it could point anywhere by the time the copy runs. A hotfix built on someone's machine and pushed straight into the build repository is refused because nothing signed it:
The hotfix push succeeded; the registry accepts anything from someone with push rights. Promotion did not, and production still points at the signed digest. Because promotion checks the signature, the build registry can be shared without being trusted; an image being there proves nothing.
The same pipeline in GitHub Actions
The workflow below was linted, not run: the lab has no GitHub runner, and keyless signing needs a GitHub OIDC identity, so that part is described and not demonstrated. It shows the build job and the production promotion; a staging promotion is the same job with the staging repository and an environment of its own.
name: releaseon:push:branches: [main]permissions: {}env:BUILD_REPO: ghcr.io/acme/payments-api/buildPROD_REPO: registry.example.com/prod/payments-api# The exact identity a signature must carry: this workflow file on main.SIGNER: https://github.com/acme/payments-api/.github/workflows/release.yml@refs/heads/mainISSUER: https://token.actions.githubusercontent.comjobs:build:runs-on: ubuntu-24.04permissions:contents: readpackages: write # push to ghcr.ioid-token: write # OIDC token for keyless signingoutputs:digest: ${{ steps.digest.outputs.digest }}steps:- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1# Same Engine as the lab, with the containerd image store so --load keeps the index.- uses: docker/setup-docker-action@2bf61fb9464cc67f0cbdeabed6aa0380accd1c70 # v5.5.0with:version: v29.8.2daemon-config: '{"features": {"containerd-snapshotter": true}}'- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1with:driver: docker- name: Install cosign, syft and trivy (pinned, checksum-verified)run: BIN="$RUNNER_TEMP/bin" ./install-tools.sh cosign syft trivy && echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"- name: 1 buildid: builduses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0with:context: appload: trueprovenance: mode=maxtags: ${{ env.BUILD_REPO }}:${{ github.sha }}- name: 2 testrun: |docker run -d --name test -p 127.0.0.1:3900:3000 "$BUILD_REPO:$GITHUB_SHA"for _ in $(seq 1 30); do[ "$(docker inspect -f '{{.State.Health.Status}}' test)" = healthy ] && break; sleep 1donecurl -fsS http://127.0.0.1:3900/ | grep -q '"service":"payments"'docker rm -f test- name: 3 scanrun: trivy image -q --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$BUILD_REPO:$GITHUB_SHA"- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0with:registry: ghcr.iousername: ${{ github.actor }}password: ${{ secrets.GITHUB_TOKEN }}- name: 4 push and 5 resolve the digestid: digestenv:BUILT: ${{ steps.build.outputs.digest }}run: |docker push -q "$BUILD_REPO:$GITHUB_SHA"d=$(docker buildx imagetools inspect "$BUILD_REPO:$GITHUB_SHA" --format '{{.Manifest.Digest}}')[ "$d" = "$BUILT" ] || { echo "pushed $d, built $BUILT"; exit 1; }echo "digest=$d" >> "$GITHUB_OUTPUT"- name: 6 attest, 7 sign (keyless), 8 verifyenv:DIGEST: ${{ steps.digest.outputs.digest }}run: |IMAGE="$BUILD_REPO@$DIGEST"syft scan -q "registry:$IMAGE" -o spdx-json=sbom.spdx.jsoncosign attest --yes --type spdxjson --predicate sbom.spdx.json "$IMAGE"cosign sign --yes "$IMAGE"cosign verify --certificate-identity "$SIGNER" --certificate-oidc-issuer "$ISSUER" "$IMAGE" > /dev/nullcosign verify-attestation --type spdxjson \--certificate-identity "$SIGNER" --certificate-oidc-issuer "$ISSUER" "$IMAGE" > /dev/nullpromote:needs: buildruns-on: ubuntu-24.04# The production environment has required reviewers and allows only the main branch. Its own# secrets (PROD_REGISTRY_USER/TOKEN) exist only for jobs that run in it, after approval.environment: productionpermissions:contents: readpackages: readenv:DIGEST: ${{ needs.build.outputs.digest }}steps:- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1- name: Install cosign (pinned, checksum-verified)run: BIN="$RUNNER_TEMP/bin" ./install-tools.sh cosign && echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0with:registry: ghcr.iousername: ${{ github.actor }}password: ${{ secrets.GITHUB_TOKEN }}- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0with:registry: registry.example.comusername: ${{ secrets.PROD_REGISTRY_USER }}password: ${{ secrets.PROD_REGISTRY_TOKEN }}- name: Verify, then promote by digestrun: |SRC="$BUILD_REPO@$DIGEST"cosign verify --certificate-identity "$SIGNER" --certificate-oidc-issuer "$ISSUER" "$SRC" > /dev/nullcosign verify-attestation --type spdxjson \--certificate-identity "$SIGNER" --certificate-oidc-issuer "$ISSUER" "$SRC" > /dev/null# Copy by digest, then the signature tag, verify in production, and only then tag.# (A registry with the OCI referrers API has no sha256-<hex> tag: use `oras cp -r` there.)sigtag="sha256-${DIGEST#sha256:}"docker buildx imagetools create --tag "$PROD_REPO@$DIGEST" "$SRC"docker buildx imagetools create --tag "$PROD_REPO:$sigtag" "$BUILD_REPO:$sigtag"cosign verify --certificate-identity "$SIGNER" --certificate-oidc-issuer "$ISSUER" "$PROD_REPO@$DIGEST" > /dev/nullcosign verify-attestation --type spdxjson \--certificate-identity "$SIGNER" --certificate-oidc-issuer "$ISSUER" "$PROD_REPO@$DIGEST" > /dev/nulldocker buildx imagetools create --tag "$PROD_REPO:${GITHUB_SHA::12}" "$PROD_REPO@$DIGEST"
Every third-party action is pinned to a full commit SHA, with the release in a comment for humans and for Dependabot, which updates both. A tag such as @v4 can be moved by whoever controls the repository, or by whoever steals their credentials: in March 2026, attackers force-pushed 76 of 77 tags of aquasecurity/trivy-action to commits that exfiltrated CI secrets (CVE-2026-33634). This workflow does not use a scanner action at all; it installs Cosign, Syft and Trivy with the same install-tools.sh as the lab, against the hashes committed in the repository, so CI and the lab run identical binaries.
docker/setup-docker-action gives the job Docker Engine 29.8.2 with the containerd image store, so load: true keeps the provenance index exactly as in the lab, and setup-buildx-action with the docker driver builds inside that engine. build-push-action builds once with load: true, and its digest output is compared with what the registry reports after docker push. The job grants itself only what it needs: packages: write to push to GHCR and id-token: write to request an OIDC token.
That token is what keyless signing uses. cosign sign --yes IMAGE@DIGEST with no key obtains a token from GitHub that names the repository, workflow file and ref; Sigstore's Fulcio issues a short-lived certificate for that identity; the signature and certificate are recorded in the Rekor transparency log. No long-lived signing key exists to leak. Verification must then pin the exact identity: --certificate-identity with the workflow file and ref, and the GitHub issuer. A regular expression such as ^https://github.com/acme/ accepts a signature from any workflow on any branch of any repository under that prefix, including a pull-request workflow an attacker can edit. The promote job runs in a GitHub environment with required reviewers, verifies that identity, and only then copies the digest and its signature to the production registry.
The reviewers protect production only if the production credentials are out of reach of every other job. Store PROD_REGISTRY_USER and PROD_REGISTRY_TOKEN as secrets of the production environment, not of the repository, and restrict the environment's deployment branches to main. A repository secret is readable by any workflow on any branch that someone with write access pushes, which could push to production without the promote job. Where the registry supports it, replace the token with OIDC federation, so that no static production credential exists.
A scan at build time describes the image on the day it was built. New CVEs are published against images that are already in production, so run a scheduled job that rescans the promoted digests (the signed SBOMs make that cheap: trivy sbom scans an SBOM file without pulling the image) and opens a rebuild when a fix appears. Shared runners also download the Trivy vulnerability database on every job; cache it between runs or mirror it, since the download is rate-limited.
Builders for untrusted code
A build runs the code it builds: every RUN line, every package install script. For pull requests from forks, treat the build as hostile.
Do not mount /var/run/docker.sock into a job, and do not run jobs on a self-hosted runner whose user is in the docker group. Access to the socket is root on the runner, read-only mount or not, and a job with root on a shared runner can read other jobs' secrets and replace the images they push. "The Docker socket and daemon hardening" (Advanced container security) explains why and what to use instead. Docker-in-Docker with --privileged is no better: a privileged container is root on the host.
Buildx itself is a client and isolates nothing. With the docker driver the build runs inside dockerd; with the docker-container driver it runs in a BuildKit container that is privileged by default ("Buildx builders, outputs, cache and Bake"). Options that do isolate: GitHub-hosted runners or other ephemeral VMs that are destroyed after each job; rootless BuildKit (the moby/buildkit:rootless image), which runs without root but needs relaxed seccomp and AppArmor settings for its user namespaces; remote builders reached with the remote or kubernetes Buildx drivers, so the runner holds no daemon at all; or rootless Docker on the runner ("Rootless Docker" in Advanced container security). Kaniko, the usual answer for years, was archived by Google in June 2025; a community fork is maintained by Chainguard. Whatever builds untrusted code must also hold no registry credentials and no signing identity: GitHub withholds secrets from fork pull requests, and pull_request_target workflows that check out the pull request's code undo that protection.
Next: Advanced container security. The Docker in depth course ends here. The advanced course starts from the kernel side: what namespaces, cgroups and capabilities actually enforce, how to harden the image, the container and the daemon, and how escapes work and are stopped.
load: true, scans the local image with Trivy, then runs build-push-action again with push: true, and signs steps.push.outputs.digest. What is wrong?--certificate-identity-regexp '^https://github.com/acme/api/' with the GitHub OIDC issuer. Why is that weaker than --certificate-identity https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main?Try this
Work through “Builders for untrusted code” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
If you keep one thing from docker in ci/cd: build to promote, keep “Builders for untrusted code”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.