id: asyncapi-contract-breaking-change-gate
namespace: company.team
description: |
Event streams ship breaking message schema changes with no gate because
REST has OpenAPI checks and event contracts have nothing. A keyed webhook
accepts the candidate AsyncAPI document from CI, a WorkingDirectory
materializes it beside the blessed baseline, and a Python comparison extracts
every channel operation payload and diffs required fields and properties
against the baseline. Removed channels or required fields alert Slack through
their own branch, an unreadable candidate escalates separately, and the
verdict, the breaking changes and the channel count are published as outputs.
triggers:
- id: post_candidate_spec
type: io.kestra.plugin.core.trigger.Webhook
description: CI POSTs the candidate AsyncAPI document here as a raw YAML body
once the event service branch is built.
key: "{{ secret('ASYNCAPI_WEBHOOK_KEY') }}"
- id: nightly_contract_check
type: io.kestra.plugin.core.trigger.Schedule
description: Fallback net, a nightly contract check at 07:00 UTC, shipped
disabled until an operator switches it on.
cron: "0 7 * * *"
disabled: true
inputs:
- id: candidate_spec
type: STRING
defaults: |
asyncapi: 2.6.0
info:
title: Orders Event API
version: 1.5.0
description: Candidate contract CI posted after the orders service refactor.
channels:
order/created:
publish:
message:
payload:
type: object
required:
- order_id
- customer_id
- total_cents
properties:
order_id:
type: string
customer_id:
type: string
total_cents:
type: integer
order/shipped:
publish:
message:
payload:
type: object
required:
- order_id
- shipped_at
properties:
order_id:
type: string
shipped_at:
type: string
order/refunded:
publish:
message:
payload:
type: object
required:
- order_id
- refund_reason
properties:
order_id:
type: string
refund_reason:
type: string
description: Candidate AsyncAPI document CI posts, the default fixture drops the
currency required field and removes the order cancelled channel.
- id: service_name
type: STRING
defaults: orders-events-api
description: Logical service the candidate contract belongs to, used in the Slack alert.
- id: ci_build_url
type: STRING
defaults: https://ci.example.com/blueprints/orders-events/1042
description: Build or pipeline URL that produced the candidate contract, linked
from the Slack alert.
tasks:
# --- 1. contract grade ---
- id: spec_workspace
type: io.kestra.plugin.core.flow.WorkingDirectory
description: One shared directory so the shell step that writes both specs and
the Python step that compares them read the same two files.
tasks:
- id: write_specs
type: io.kestra.plugin.scripts.shell.Commands
description: Write the blessed baseline with one heredoc and the posted or
default candidate with a second, then size both files for the log.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
entryPoint: []
containerImage: python:3.12-slim
commands:
- |
set -u
cat > baseline.yaml <<'BASELINE_EOF'
asyncapi: 2.6.0
info:
title: Orders Event API
version: 1.4.0
description: Blessed contract every consumer builds against.
channels:
order/created:
publish:
message:
payload:
type: object
required:
- order_id
- customer_id
- currency
- total_cents
properties:
order_id:
type: string
customer_id:
type: string
currency:
type: string
total_cents:
type: integer
order/shipped:
publish:
message:
payload:
type: object
required:
- order_id
- shipped_at
properties:
order_id:
type: string
shipped_at:
type: string
order/cancelled:
publish:
message:
payload:
type: object
required:
- order_id
- reason
properties:
order_id:
type: string
reason:
type: string
BASELINE_EOF
cat > candidate.yaml <<'CANDIDATE_EOF'
{{ trigger.body ?? inputs.candidate_spec }}
CANDIDATE_EOF
echo "baseline bytes $(wc -c < baseline.yaml) candidate bytes $(wc -c < candidate.yaml)"
- id: compare_specs
type: io.kestra.plugin.scripts.python.Script
description: Load both specs, extract every publish and subscribe payload
required field and property per channel, diff the candidate against
the baseline, and emit the compatibility verdict through the outputs
protocol.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.12-slim
beforeCommands:
- pip install --quiet pyyaml
script: |
import json
import os
import yaml
BASELINE_PATH = "baseline.yaml"
CANDIDATE_PATH = "candidate.yaml"
def load_spec(path, label):
try:
if not os.path.exists(path) or os.path.getsize(path) == 0:
return None, label + " file is missing or empty"
with open(path, "r", encoding="utf-8") as handle:
document = yaml.safe_load(handle)
if not isinstance(document, dict):
return None, label + " is not a YAML mapping"
return document, ""
except yaml.YAMLError:
return None, label + " is not valid YAML"
def operations(spec):
found = {}
channels = spec.get("channels")
if not isinstance(channels, dict):
channels = {}
for name, channel in channels.items():
if not isinstance(channel, dict):
continue
entry = {}
for direction in ("publish", "subscribe"):
operation = channel.get(direction)
if not isinstance(operation, dict):
continue
message = operation.get("message")
payload = message.get("payload") if isinstance(message, dict) else None
required, properties = [], []
if isinstance(payload, dict):
raw_required = payload.get("required")
if isinstance(raw_required, list):
required = [str(field) for field in raw_required]
raw_properties = payload.get("properties")
if isinstance(raw_properties, dict):
properties = [str(field) for field in raw_properties]
entry[direction] = {"required": required, "properties": properties}
if entry:
found[str(name)] = entry
return found
baseline, baseline_problem = load_spec(BASELINE_PATH, "baseline spec")
candidate, candidate_problem = load_spec(CANDIDATE_PATH, "candidate spec")
verdict = "compatible"
error = ""
breaking = []
additions = []
channels_checked = 0
baseline_version = ""
candidate_version = ""
if baseline is None:
verdict = "error"
error = baseline_problem
elif candidate is None:
verdict = "error"
error = candidate_problem
else:
baseline_version = str((baseline.get("info") or {}).get("version") or "")
candidate_version = str((candidate.get("info") or {}).get("version") or "")
base_ops = operations(baseline)
cand_ops = operations(candidate)
channels_checked = len(base_ops)
for name in base_ops:
if name not in cand_ops:
breaking.append("removed channel " + name)
continue
for direction, base_detail in base_ops[name].items():
if direction not in cand_ops[name]:
breaking.append("removed " + direction + " operation on " + name)
continue
cand_detail = cand_ops[name][direction]
flagged = set()
for field in base_detail["required"]:
if field not in cand_detail["required"]:
breaking.append("removed required field " + name + "." + direction + "." + field)
flagged.add(field)
for field in base_detail["properties"]:
if field not in cand_detail["properties"] and field not in flagged:
breaking.append("removed property " + name + "." + direction + "." + field)
for name in cand_ops:
if name not in base_ops:
additions.append("added channel " + name)
if breaking:
verdict = "breaking"
payload = {
"verdict": verdict,
"breaking_changes": breaking,
"breaking_count": len(breaking),
"channels_checked": channels_checked,
"compatible_additions": additions,
"baseline_version": baseline_version,
"candidate_version": candidate_version,
"error": error,
}
print("channels=%d breaking=%d additions=%d verdict=%s" % (
channels_checked, len(breaking), len(additions), verdict))
print("::" + json.dumps({"outputs": payload}) + "::")
# --- 2. verdict gate ---
- id: gate_contract_changes
type: io.kestra.plugin.core.flow.If
description: Escalate breaking contract changes before consumers see them, and
separate an unreadable candidate from a clean compatibility verdict.
condition: "{{ outputs.compare_specs.vars.verdict == 'breaking' }}"
then:
- id: alert_breaking_change
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the platform channel which channels or required fields the
candidate dropped, with the CI build to inspect.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "AsyncAPI breaking change for {{ inputs.service_name }} - {{ outputs.compare_specs.vars.breaking_count }} breaking change(s) {{ outputs.compare_specs.vars.breaking_changes | join('; ') }}. Candidate v{{ outputs.compare_specs.vars.candidate_version }} against baseline v{{ outputs.compare_specs.vars.baseline_version }}. Build {{ inputs.ci_build_url }}. Execution {{ execution.id }}."
}
else:
- id: check_parse_error
type: io.kestra.plugin.core.flow.If
description: Route an unparseable candidate into its own alert so a broken file
never reads as a compatible contract.
condition: "{{ outputs.compare_specs.vars.verdict == 'error' }}"
then:
- id: alert_unreadable_spec
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the platform channel the candidate spec could not be parsed
and name the load problem.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "AsyncAPI candidate unreadable for {{ inputs.service_name }} - {{ outputs.compare_specs.vars.error }}. Build {{ inputs.ci_build_url }}. Execution {{ execution.id }}."
}
else:
- id: log_compatible_contract
type: io.kestra.plugin.core.log.Log
description: Record the compatible verdict with the channel count so the
execution history is the contract evidence.
message: "asyncapi contract compatible for {{ inputs.service_name }} - {{
outputs.compare_specs.vars.channels_checked }} baseline channel(s)
unchanged, candidate v{{
outputs.compare_specs.vars.candidate_version }} carries {{
outputs.compare_specs.vars.compatible_additions | length }} added
channel(s)"
errors:
- id: notify_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Escalate any failure to the ops channel with the execution url.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text": "FAILED: {{ flow.id }} {{ execution.id }}"}
outputs:
- id: verdict
type: STRING
description: compatible when nothing was removed, breaking when a channel,
operation, required field or property disappeared, error when a spec could
not be parsed.
value: "{{ outputs.compare_specs.vars.verdict }}"
- id: breaking_changes
type: ARRAY
description: 'Breaking removals in baseline order, for example ["removed
required field order/created.publish.currency"].'
value: "{{ outputs.compare_specs.vars.breaking_changes | toJson }}"
- id: breaking_count
type: INT
description: Number of breaking removals, for example 2.
value: "{{ outputs.compare_specs.vars.breaking_count }}"
- id: channels_checked
type: INT
description: Number of baseline channels the candidate was compared against, for
example 3.
value: "{{ outputs.compare_specs.vars.channels_checked }}"
- id: compatible_additions
type: ARRAY
description: 'Channels the candidate added on top of the baseline, for example
["added channel order/refunded"].'
value: "{{ outputs.compare_specs.vars.compatible_additions | toJson }}"
- id: candidate_version
type: STRING
description: Version string from the candidate info block, for example 1.5.0.
value: "{{ outputs.compare_specs.vars.candidate_version }}"