Why verify in CI and again at admission?
An image can be correctly signed while a statement names the wrong source, a later GitOps change can select a different digest, or a direct Kubernetes request can bypass the CI handoff altogether. CI verification examines the artifact just produced by the run. Admission examines the image in the request now presented to Kubernetes. They enforce a claim at different times and boundaries.
The second check is valuable because deployment state and registry access can change after CI. It is not an independent guarantee that the original workflow was honest: both stages trust the same signing identity and its claims. Understanding the exact field overlap is more useful than counting this as “two layers of security.”
- Both paths check the trusted keyless identity and digest-bound artifact claims.
- CI additionally matches the invocation source commit and source material against github.sha.
- Admission checks builder, workflow entry point and canonical source URI without that commit/material predicate.
- Shared workflow/policy authority is a correlated failure dependency; these are not automatically independent roots.
Solid arrows indicate the stated handoff, not a claim of independent trust. Optional relationships are described in the text equivalent. Historical components are labeled in the caption.
The CI actor and input
Section titled “The CI actor and input”Verify Attestations runs after both build and sign jobs succeed. It authenticates to GAR, installs Cosign, and constructs repository@${inputs.digest} using the build output. It also knows github.sha for the run, so it can compare origin statements with the source it expects.
Each verification shell block uses set -euo pipefail. Cosign checks the certificate subject and GitHub issuer; jq rejects output that lacks a matching statement. A file existing or a command printing fields is insufficient. The final workflow turns field selection into an explicit failure condition.
Signature check
Section titled “Signature check”The expected subject names .github/workflows/sign-attest.yml@refs/heads/main in github.repository; the issuer is https://token.actions.githubusercontent.com. After cosign verify, jq selects signatures whose .critical.image["docker-manifest-digest"] equals inputs.digest. An empty selection errors with no signature matched the expected immutable digest.
The output provides subject, issuer, and signer digest for the summary. Those displayed fields are derived from accepted verification output. They are still a result for one invocation; a historical summary is not ongoing registry verification.
SPDX attestation check
Section titled “SPDX attestation check”CI calls cosign verify-attestation --type spdxjson with the same certificate constraints. It decodes the envelope payload, requires predicateType == "https://spdx.dev/Document", and requires at least one subject digest to match the built SHA-256 digest. If none matches, the job fails. It prints the package count but does not impose a package-count threshold or inspect package vulnerability/license policy.
The matching rule uses any(.subject[]?), while the summary displays the first subject’s digest. In normal workflow-generated statements there is one image subject; for a multi-subject statement, summary display and matching subject need not be the same entry. This is another reason to evaluate conditions instead of treating the presentation as the entire contract.
Provenance check
Section titled “Provenance check”CI calls cosign verify-attestation --type slsaprovenance with the trusted subject/issuer and accepts a statement only when all of the following match:
- Predicate type
https://slsa.dev/provenance/v0.2. - At least one subject SHA-256 equal to the built image digest.
- Builder ID
https://github.com/actions/runner. - Entrypoint
.github/workflows/sign-attest.yml. - Source URI
git+https://github.com/${github.repository}@refs/heads/main. - Source digest SHA-1 equal to the run’s
github.sha. - At least one material whose URI is the repository without the ref suffix and whose SHA-1 equals the same source commit.
The script explicitly errors if no statement meets this conjunction. It does not enforce buildType, event, workflow display name, or every material. It does not independently reconstruct the image from those materials. A compromised accepted signer can still manufacture matching assertions.
What admission repeats and omits
Section titled “What admission repeats and omits”The Kyverno policy requires signature, SPDX, and provenance verification for matching images. It configures the same workflow subject/issuer and verifyDigest: true; its provenance conditions compare builder, entrypoint, and source URI.
Admission does not compare invocation.configSource.digest.sha1 to an operator-selected source commit, nor require a matching repository material. The cluster policy therefore accepts claims from the allowed main workflow/repository without proving that the current Pod selects the source revision checked by a particular CI run. The immutable digest in desired state carries that selection across the handoff, subject to operator review.
This difference is deliberate documentation of the actual source. It would be misleading to copy the stronger CI contract into the admission description or to infer a source-commit allowlist from a source URI.
The release handoff is a candidate, not deployment
Section titled “The release handoff is a candidate, not deployment”The workflow summary marks verification passed only after its checks. The orchestrator has no job that updates Helm values, opens a promotion PR, runs kubectl, or calls Argo CD. Its result is a candidate artifact that meets this run’s verification contract.
The later strict CI record reports a verified a0073… digest while Helm remains on the earlier 32a90… digest. This is exactly the separation between artifact verification and deployment authorization. Do not attribute the final strict checks to the earlier artifact unless its own execution record supports that conclusion.
Shared failures remain shared
Section titled “Shared failures remain shared”Both verifiers depend on GitHub’s accepted identity, Sigstore trust, registry metadata availability, and policy/code integrity. A compromised GitHub workflow that retains the accepted subject can sign a malicious image and fabricate matching provenance. A compromised policy administrator can weaken admission. An unavailable verifier or registry creates a different failure path whose API behavior must be inspected separately from content-denial tests.
Use the reference comparison for exact fields and shared failure reasoning for the trust model. Continue with reviewed promotion: the next decision is which verified artifact should actually run.