id: skopeo-container-image-promotion-and-provenance-gate
namespace: company.platform
inputs:
- id: source_image
type: STRING
displayName: Staging Source Image Reference
description: Remote container image reference in the staging or development
registry (e.g. docker.io/library/alpine:3.20.0 or
staging.registry.internal/app:v1.2.0).
defaults: "docker.io/library/alpine:3.20.0"
- id: destination_image
type: STRING
displayName: Production Destination Image Reference
description: Target container image reference in the production registry.
defaults: "localhost:5000/production/alpine:3.20.0"
- id: require_provenance_labels
type: BOOL
displayName: Require Git SHA Provenance Labels
description: When true, rejects promotion if the image lacks standard OCI build
provenance labels (org.opencontainers.image.revision or vcs-ref).
defaults: false
- id: dry_run
type: BOOL
displayName: Dry Run (Audit Only)
description: When true, inspects manifests and evaluates immutability policies
without executing the skopeo copy promotion step.
defaults: true
- id: enforce_gate
type: BOOL
displayName: Enforce Gate on Policy Failure
description: Automatically fails the execution if tag hijack or provenance
violations are detected, halting automated CD deployment pipelines.
defaults: true
- id: notify_slack
type: BOOL
displayName: Send Slack Alert
description: Dispatches high-priority security notifications to Slack on tag
hijack or policy rejection. Requires the SLACK_WEBHOOK_URL secret.
defaults: false
triggers:
- id: on_staging_image_push
type: io.kestra.plugin.core.trigger.Webhook
description: Webhook trigger invoked by container build systems (e.g. GitHub
Actions, GitLab CI) upon completing staging image publication.
key: "{{ secret('CONTAINER_PROMOTION_WEBHOOK_KEY') }}"
disabled: true
- id: scheduled_registry_promotion_sweep
type: io.kestra.plugin.core.trigger.Schedule
description: Scheduled batch sweep for pending release candidates.
cron: "0 2 * * 1-5"
disabled: true
tasks:
- id: inspect_staging_manifest
type: io.kestra.plugin.skopeo.cli.SkopeoCLI
description: Inspects raw OCI manifest headers, layer counts, and image labels
without pulling heavy layer tarballs.
commands:
- "skopeo inspect --raw {{ secret('STAGING_REGISTRY_USERNAME') != '' ?
('--creds ' ~ secret('STAGING_REGISTRY_USERNAME') ~ ':' ~
secret('STAGING_REGISTRY_PASSWORD')) : '--no-creds' }} docker://{{
inputs.source_image }} > staging_raw.json"
- "skopeo inspect {{ secret('STAGING_REGISTRY_USERNAME') != '' ? ('--creds
' ~ secret('STAGING_REGISTRY_USERNAME') ~ ':' ~
secret('STAGING_REGISTRY_PASSWORD')) : '--no-creds' }} docker://{{
inputs.source_image }} > staging_inspect.json"
outputFiles:
- staging_raw.json
- staging_inspect.json
- id: inspect_production_target
type: io.kestra.plugin.skopeo.cli.SkopeoCLI
description: Queries the target production repository to verify tag presence and
capture existing cryptographic digests.
allowFailure: true
commands:
- "skopeo inspect {{ secret('PRODUCTION_REGISTRY_USERNAME') != '' ?
('--creds ' ~ secret('PRODUCTION_REGISTRY_USERNAME') ~ ':' ~
secret('PRODUCTION_REGISTRY_PASSWORD')) : '--no-creds' }} docker://{{
inputs.destination_image }} > prod_inspect.json || echo
'{\"tag_exists\": false}' > prod_inspect.json"
outputFiles:
- prod_inspect.json
- id: evaluate_promotion_policies
type: io.kestra.plugin.scripts.python.Script
description: Evaluates tag immutability, cryptographic digest parity, OCI
provenance labels, and multi-architecture metadata.
containerImage: python:3.11-slim
beforeCommands:
- pip install kestra
inputFiles:
staging_inspect.json: "{{
outputs.inspect_staging_manifest.outputFiles['staging_inspect.json'] }}"
prod_inspect.json: "{{
outputs.inspect_production_target.outputFiles['prod_inspect.json'] }}"
env:
REQUIRE_PROVENANCE: "{{ inputs.require_provenance_labels }}"
SOURCE_IMAGE: "{{ inputs.source_image }}"
DEST_IMAGE: "{{ inputs.destination_image }}"
DRY_RUN: "{{ inputs.dry_run }}"
script: |
import os
import json
from kestra import Kestra
with open("staging_inspect.json") as f:
staging = json.load(f)
try:
with open("prod_inspect.json") as f:
prod = json.load(f)
except Exception:
prod = {"tag_exists": False}
digest = staging.get("Digest", "")
labels = staging.get("Labels") or {}
created = staging.get("Created", "")
arch = staging.get("Architecture", "")
os_name = staging.get("Os", "")
layers = staging.get("Layers", [])
layers_count = len(layers)
git_sha = labels.get("org.opencontainers.image.revision", labels.get("vcs-ref", ""))
source_url = labels.get("org.opencontainers.image.source", labels.get("url", ""))
prod_digest = prod.get("Digest")
tag_already_exists = prod_digest is not None
require_provenance = os.environ.get("REQUIRE_PROVENANCE", "false").lower() == "true"
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
verdict = "APPROVED_PROMOTION"
details = []
if tag_already_exists and prod_digest != digest:
verdict = "REJECTED_TAG_HIJACK"
details.append(f"CRITICAL IMMUTABILITY VIOLATION: Tag exists in destination with DIFFERENT digest ({prod_digest[:19]}... vs staging {digest[:19]}...). Overwrite blocked.")
elif require_provenance and not git_sha:
verdict = "REJECTED_MISSING_PROVENANCE"
details.append("Security Policy Failure: Image lacks required 'org.opencontainers.image.revision' (Git commit SHA) build provenance label.")
elif layers_count == 0:
verdict = "REJECTED_EMPTY_MANIFEST"
details.append("Manifest Error: Image reports 0 filesystem layers.")
else:
if tag_already_exists and prod_digest == digest:
details.append(f"Idempotent verification: Destination already contains identical image digest ({digest[:19]}...).")
else:
details.append(f"Image passed all security policies: Git SHA '{git_sha or 'unspecified'}', arch '{os_name}/{arch}', digest '{digest[:19]}...'.")
if dry_run and verdict == "APPROVED_PROMOTION":
details.append("DRY RUN: Copy step bypassed.")
print(f"Promotion Gate Verdict: {verdict} - {'; '.join(details)}")
Kestra.outputs({
"verdict": verdict,
"staging_digest": digest,
"prod_digest": prod_digest or "NOT_FOUND",
"git_sha": git_sha or "NONE",
"architecture": f"{os_name}/{arch}",
"layer_count": layers_count,
"tag_already_exists": tag_already_exists,
"is_dry_run": dry_run,
"summary": " ".join(details)
})
- id: route_promotion_decision
type: io.kestra.plugin.core.flow.Switch
description: Routes artifact promotion execution, security isolation, or
provenance hold based on policy verdict.
value: "{{ outputs.evaluate_promotion_policies.vars.verdict }}"
cases:
APPROVED_PROMOTION:
- id: check_execute_copy
type: io.kestra.plugin.core.flow.If
description: Executes Skopeo copy when dry_run is false and destination does not
already match.
condition: "{{ not inputs.dry_run and
outputs.evaluate_promotion_policies.vars.staging_digest !=
outputs.evaluate_promotion_policies.vars.prod_digest }}"
then:
- id: promote_image_to_production
type: io.kestra.plugin.skopeo.cli.SkopeoCLI
description: Atomically copies the container image directly between registries
without local Docker daemon privileges.
commands:
- "skopeo copy {{ secret('STAGING_REGISTRY_USERNAME') != '' ?
('--src-creds ' ~ secret('STAGING_REGISTRY_USERNAME') ~ ':' ~
secret('STAGING_REGISTRY_PASSWORD')) : '--src-no-creds' }} {{
secret('PRODUCTION_REGISTRY_USERNAME') != '' ? ('--dest-creds
' ~ secret('PRODUCTION_REGISTRY_USERNAME') ~ ':' ~
secret('PRODUCTION_REGISTRY_PASSWORD')) : '--dest-no-creds' }}
docker://{{ inputs.source_image }} docker://{{
inputs.destination_image }}"
- id: persist_promotion_receipt
type: io.kestra.plugin.core.storage.Write
description: Persists cryptographic promotion receipt to internal storage for
compliance and audit logs.
extension: .json
content: |
{
"verdict": "APPROVED_PROMOTION",
"sourceImage": "{{ inputs.source_image }}",
"destinationImage": "{{ inputs.destination_image }}",
"sourceDigest": "{{ outputs.evaluate_promotion_policies.vars.staging_digest }}",
"gitSha": "{{ outputs.evaluate_promotion_policies.vars.git_sha }}",
"architecture": "{{ outputs.evaluate_promotion_policies.vars.architecture }}",
"dryRun": {{ inputs.dry_run }},
"executionId": "{{ execution.id }}"
}
- id: log_promotion_complete
type: io.kestra.plugin.core.log.Log
description: Logs audit record confirming successful image verification and
promotion status.
message: "PROMOTION SUCCESS: {{ outputs.evaluate_promotion_policies.vars.summary
}}"
REJECTED_TAG_HIJACK:
- id: log_security_breach
type: io.kestra.plugin.core.log.Log
description: Emits critical security alert on detected tag immutability violation.
message: "SECURITY ALERT: {{ outputs.evaluate_promotion_policies.vars.summary
}}"
- id: persist_security_incident_manifest
type: io.kestra.plugin.core.storage.Write
description: Stores security violation manifest containing colliding digests and
source metadata.
extension: .json
content: |
{
"verdict": "REJECTED_TAG_HIJACK",
"sourceImage": "{{ inputs.source_image }}",
"destinationImage": "{{ inputs.destination_image }}",
"stagingDigest": "{{ outputs.evaluate_promotion_policies.vars.staging_digest }}",
"conflictingProdDigest": "{{ outputs.evaluate_promotion_policies.vars.prod_digest }}",
"executionId": "{{ execution.id }}",
"actionTaken": "PROMOTION_BLOCKED"
}
- id: check_slack_alert
type: io.kestra.plugin.core.flow.If
description: Sends Slack notification to SecOps on detected container tag
collision.
condition: "{{ inputs.notify_slack }}"
then:
- id: notify_slack_secops
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Posts high-severity tag hijack alert to Slack.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "🚨 *Container Tag Immutability Breach Blocked* | Execution `{{ execution.id }}`\n• *Target:* `{{ inputs.destination_image }}`\n• *Existing Prod Digest:* `{{ outputs.evaluate_promotion_policies.vars.prod_digest }}`\n• *Conflicting Staging Digest:* `{{ outputs.evaluate_promotion_policies.vars.staging_digest }}`\n• *Action:* Promotion Aborted"
}
REJECTED_MISSING_PROVENANCE:
- id: log_provenance_failure
type: io.kestra.plugin.core.log.Log
description: Logs rejection notice due to missing build provenance metadata.
message: "PROVENANCE REJECTION: {{
outputs.evaluate_promotion_policies.vars.summary }}"
- id: persist_provenance_rejection
type: io.kestra.plugin.core.storage.Write
description: Records unprovenanced build metadata for developer review.
extension: .json
content: |
{
"verdict": "REJECTED_MISSING_PROVENANCE",
"sourceImage": "{{ inputs.source_image }}",
"stagingDigest": "{{ outputs.evaluate_promotion_policies.vars.staging_digest }}",
"executionId": "{{ execution.id }}"
}
- id: enforce_promotion_gate
type: io.kestra.plugin.core.flow.If
description: Fails the pipeline execution if enforce_gate is enabled and the
promotion was rejected.
condition: "{{ inputs.enforce_gate and
outputs.evaluate_promotion_policies.vars.verdict != 'APPROVED_PROMOTION'
}}"
then:
- id: fail_promotion_pipeline
type: io.kestra.plugin.core.execution.Fail
description: Halts continuous deployment pipeline execution on container
security policy rejection.
errorMessage: "Container promotion gate tripped: {{
outputs.evaluate_promotion_policies.vars.summary }}"