id: cosign-verified-deploy-gate
namespace: company.team
inputs:
- id: images
type: ARRAY
itemType: STRING
displayName: Images to deploy
description: Image references as they appear in your release, tags allowed. Each
one is verified, then pinned to the digest that was verified.
defaults:
- cgr.dev/chainguard/static:latest
- cgr.dev/chainguard/busybox:latest
- id: add_unsigned_image
type: BOOL
displayName: Add an unsigned image
description: Append docker.io/library/busybox:1.36, which carries no Sigstore
signature, to watch the gate block the release.
defaults: false
- id: certificate_identity_regexp
type: STRING
displayName: Allowed signer identity (regex)
description: Keyless signatures carry the identity of the workflow that signed
them. Only signatures from a matching identity are accepted. The default
is the Chainguard images release workflow.
defaults: "^https://github.com/chainguard-images/images/"
- id: certificate_oidc_issuer
type: STRING
displayName: Allowed OIDC issuer
defaults: https://token.actions.githubusercontent.com
- id: app_name
type: STRING
displayName: Application name
description: Name of the Kubernetes Deployment rendered from the verified images.
defaults: demo-app
- id: apply_to_cluster
type: BOOL
displayName: Apply to Kubernetes
description: Apply the digest-pinned Deployment with kubectl. Leave false to
only publish the approved manifest.
defaults: false
- id: k8s_namespace
type: STRING
displayName: Kubernetes namespace
defaults: default
variables:
manifest_path: "deploy/approved/{{ inputs.app_name }}.yaml"
triggers:
- id: on_release
type: io.kestra.plugin.core.trigger.Webhook
description: Call this from CI before a rollout. Replace the key with a long
random value first.
key: replace-with-a-long-random-key
tasks:
- id: verify_signatures
type: io.kestra.plugin.scripts.shell.Commands
description: >
Run cosign verify on every image against the allowed identity and issuer.
A verified image is pinned to the exact digest that was verified, so a tag
that moves after the check cannot change what gets deployed. Failures are
recorded, not raised, so every image is reported in one run.
containerImage: alpine:3.20
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
retry:
type: constant
interval: PT15S
maxAttempts: 2
env:
IMAGES: "{{ (inputs.add_unsigned_image ? (inputs.images | jq('. +
[\"docker.io/library/busybox:1.36\"]') | first) : inputs.images) |
join(' ') }}"
IDENTITY_REGEXP: "{{ inputs.certificate_identity_regexp }}"
OIDC_ISSUER: "{{ inputs.certificate_oidc_issuer }}"
beforeCommands:
- apk add -q jq
- wget -q -t 3 -T 30 -O /usr/local/bin/cosign
https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64
- echo
"4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 /usr/local/bin/cosign"
| sha256sum -c -
- chmod +x /usr/local/bin/cosign
commands:
- |
: > results.jsonl
for ref in $IMAGES; do
if cosign verify "$ref" --certificate-identity-regexp="$IDENTITY_REGEXP" --certificate-oidc-issuer="$OIDC_ISSUER" > sig.json 2> err.txt; then
digest=$(jq -r '.[0].critical.image["docker-manifest-digest"]' sig.json)
signer=$(jq -r '.[0].optional.Subject // .[0].optional["1.3.6.1.4.1.57264.1.5"] // "verified"' sig.json)
repo=$(echo "$ref" | sed -E 's/@sha256:.*$//; s/:[^/:]+$//')
jq -nc --arg ref "$ref" --arg pinned "$repo@$digest" --arg signer "$signer" '{ref:$ref, verified:true, pinned:$pinned, reason:$signer}' >> results.jsonl
echo "VERIFIED $ref -> $repo@$digest"
else
reason=$(grep -oE 'no signatures found|no matching signatures[^,]*|none of the expected identities matched|MANIFEST_UNKNOWN|UNAUTHORIZED' err.txt | head -1)
[ -n "$reason" ] || reason=$(tail -1 err.txt | cut -c1-160)
jq -nc --arg ref "$ref" --arg reason "$reason" '{ref:$ref, verified:false, pinned:"", reason:$reason}' >> results.jsonl
echo "REJECTED $ref: $reason"
fi
done
jq -sc '{outputs: {
checked: length,
verified: (map(select(.verified)) | length),
rejected: (map(select(.verified | not)) | map(.ref + " (" + .reason + ")") | join("; ")),
pinned: (map(select(.verified)) | map(.pinned)),
results: .
}}' results.jsonl | sed 's/^/::/; s/$/::/'
- id: gate
type: io.kestra.plugin.core.flow.If
description: Release only when every image was verified. One unsigned or
foreign-signed image blocks the whole release.
condition: "{{ outputs.verify_signatures.vars.verified ==
outputs.verify_signatures.vars.checked }}"
then:
- id: render_manifest
type: io.kestra.plugin.scripts.shell.Commands
description: Render a Deployment that references the verified digests, never the
tags.
containerImage: alpine:3.20
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
outputFiles:
- manifest.yaml
commands:
- |
cat > manifest.yaml <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ inputs.app_name }}
labels:
app: {{ inputs.app_name }}
annotations:
kestra.io/verified-by: "{{ flow.id }}/{{ execution.id }}"
spec:
replicas: 1
selector:
matchLabels:
app: {{ inputs.app_name }}
template:
metadata:
labels:
app: {{ inputs.app_name }}
spec:
containers:
{% for image in outputs.verify_signatures.vars.pinned %}
- name: c{{ loop.index }}
image: {{ image }}
{% endfor %}
YAML
cat manifest.yaml
- id: publish_manifest
type: io.kestra.plugin.core.namespace.UploadFiles
description: Keep the approved, digest-pinned manifest as the record of what was
allowed to ship.
namespace: "{{ flow.namespace }}"
filesMap:
"{{ render(vars.manifest_path) }}": "{{ outputs.render_manifest.outputFiles['manifest.yaml'] }}"
- id: deploy
type: io.kestra.plugin.core.flow.If
condition: "{{ inputs.apply_to_cluster }}"
then:
- id: kubectl_apply
type: io.kestra.plugin.kubernetes.kubectl.Apply
description: Apply the digest-pinned Deployment. Uses the worker's in-cluster or
kubeconfig credentials.
namespace: "{{ inputs.k8s_namespace }}"
spec: "{{ read(outputs.render_manifest.outputFiles['manifest.yaml']) }}"
else:
- id: deploy_skipped
type: io.kestra.plugin.core.log.Log
message: "All {{ outputs.verify_signatures.vars.checked }} images verified.
Approved manifest published to {{ render(vars.manifest_path) }}.
apply_to_cluster is false, so nothing was applied."
else:
- id: block_release
type: io.kestra.plugin.core.execution.Fail
description: End the run as FAILED, so the pipeline that called the webhook
stops the rollout. No manifest is published.
errorMessage: "{{ outputs.verify_signatures.vars.checked -
outputs.verify_signatures.vars.verified }} of {{
outputs.verify_signatures.vars.checked }} images failed signature
verification: {{ outputs.verify_signatures.vars.rejected }}"