id: openapi-breaking-change-gate
namespace: company.team
description: |
Release gate for HTTP APIs. Compares the OpenAPI spec about to ship with
the one in production using oasdiff, posts the changelog, and lets a
breaking change through only when the API owner approves it. Approvals
are stored per change in the KV store, so CI retries do not ask twice and
any new breaking change is caught. The execution ends SUCCESS (ship) or
FAILED (blocked), which CI can wait for.
triggers:
- id: release_candidate
type: io.kestra.plugin.core.trigger.Webhook
description: |
Call from CI with a JSON body, for example
{"service": "orders-api", "release": "2.0.0", "base_spec_url": "...", "revision_spec_url": "..."}.
Missing fields fall back to the input defaults. The key is read from the
OPENAPI_GATE_WEBHOOK_KEY secret, so the webhook URL is not guessable.
key: "{{ secret('OPENAPI_GATE_WEBHOOK_KEY') }}"
- id: nightly_staging_check
type: io.kestra.plugin.core.trigger.Schedule
description: Compares staging with production every night, so breaking changes
are known (and approved) before release day. Shipped disabled because the
default spec URLs are placeholders; enable it once they point at your
specs.
cron: "40 6 * * 1-5"
disabled: true
inputs:
- id: service
type: STRING
defaults: orders-api
- id: base_spec_url
type: STRING
defaults: https://api.example.com/openapi.json
description: The spec in production.
- id: revision_spec_url
type: STRING
defaults: https://staging.api.example.com/openapi.json
description: The spec about to ship.
- id: release
type: STRING
defaults: staging
description: Release name, shown in messages.
- id: kestra_url
type: STRING
defaults: http://localhost:8080
description: Base URL of your Kestra UI, used for the approval link.
- id: block_on
type: SELECT
values:
- ERR
- WARN
defaults: ERR
description: ERR gates only definite breaking changes; WARN also gates potential
ones (for example a removed deprecated endpoint without a sunset date).
variables:
# Webhook bodies override inputs, so one flow serves every service and release.
service: "{{ (trigger.body ?? {}).service ?? inputs.service }}"
release: "{{ (trigger.body ?? {}).release ?? inputs.release }}"
base_url: "{{ (trigger.body ?? {}).base_spec_url ?? inputs.base_spec_url }}"
revision_url: "{{ (trigger.body ?? {}).revision_spec_url ?? inputs.revision_spec_url }}"
kv_key: "openapi_approved_{{ render(vars.service) | slugify }}"
min_level: "{{ inputs.block_on == 'WARN' ? 2 : 3 }}"
tasks:
- id: validate_request
type: io.kestra.plugin.core.flow.If
condition: "{{ not ({'urls': [render(vars.base_url), render(vars.revision_url)],
'service': render(vars.service)} | jq('(.urls | all(test(\"^https?://\")))
and (.service | length > 0)') | first) }}"
then:
- id: invalid_request
type: io.kestra.plugin.core.execution.Fail
errorMessage: "A service name and two http(s) spec URLs are required; got
service '{{ render(vars.service) }}', base '{{ render(vars.base_url)
}}', revision '{{ render(vars.revision_url) }}'"
- id: download_base
type: io.kestra.plugin.core.http.Download
uri: "{{ render(vars.base_url) }}"
retry:
type: exponential
interval: PT2S
maxInterval: PT30S
maxAttempts: 3
- id: download_revision
type: io.kestra.plugin.core.http.Download
uri: "{{ render(vars.revision_url) }}"
retry:
type: exponential
interval: PT2S
maxInterval: PT30S
maxAttempts: 3
- id: diff
type: io.kestra.plugin.scripts.shell.Commands
description: oasdiff reads JSON or YAML, resolves $refs, and gives each change a
level (3 error, 2 warning, 1 info) and a stable fingerprint.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: tufin/oasdiff:latest
inputFiles:
base.spec: "{{ read(outputs.download_base.uri) }}"
revision.spec: "{{ read(outputs.download_revision.uri) }}"
outputFiles:
- changes.json
commands:
- oasdiff changelog base.spec revision.spec --format json > changes.json
- id: summary
type: io.kestra.plugin.core.output.OutputValues
description: Split the changelog, and leave out breaking changes that were
already approved for this service.
values:
changes: "{{ read(outputs.diff.outputFiles['changes.json']) }}"
gated: >-
{{ {'changes': fromJson(read(outputs.diff.outputFiles['changes.json'])),
'approved': (kv(render(vars.kv_key), errorOnMissing=false) ?? {}).fingerprints ?? []}
| jq('. as $in | [.changes[] | select(.level >= ' ~
render(vars.min_level) ~ ' and (.fingerprint as $f | $in.approved |
index($f) | not))]') | first | toJson }}
already_approved: >-
{{ {'changes': fromJson(read(outputs.diff.outputFiles['changes.json'])),
'approved': (kv(render(vars.kv_key), errorOnMissing=false) ?? {}).fingerprints ?? []}
| jq('. as $in | [.changes[] | select(.level >= ' ~
render(vars.min_level) ~ ' and (.fingerprint as $f | $in.approved |
index($f)))] | length') | first }}
- id: report
type: io.kestra.plugin.core.flow.If
condition: "{{ fromJson(outputs.summary.values.changes) | length > 0 }}"
then:
- id: post_changelog
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{% set changes = fromJson(outputs.summary.values.changes) %}{{ render(vars.service) }} {{ render(vars.release) }}: {{ changes | jq('[.[] | select(.level == 3)] | length') | first }} breaking, {{ changes | jq('[.[] | select(.level == 2)] | length') | first }} warnings, {{ changes | jq('[.[] | select(.level == 1)] | length') | first }} compatible changes{% if (outputs.summary.values.already_approved | trim) != '0' %} ({{ outputs.summary.values.already_approved }} breaking already approved){% endif %}
{% for c in changes %}- {{ c.level == 3 ? 'BREAKING' : (c.level == 2 ? 'WARNING' : 'change') }} {{ c.operation }} {{ c.path }}: {{ c.text | replace({'`': ''}) }}
{% endfor %}
- id: gate
type: io.kestra.plugin.core.flow.If
condition: "{{ fromJson(outputs.summary.values.gated) | length > 0 }}"
then:
- id: ask_owner
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{{ render(vars.service) }} {{ render(vars.release) }} is waiting for the API owner: {{ fromJson(outputs.summary.values.gated) | length }} breaking change(s) need approval within 2 hours, otherwise the release is blocked.
{{ inputs.kestra_url }}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{ execution.id }}
- id: wait_for_owner
type: io.kestra.plugin.core.flow.Pause
description: No answer within 2 hours blocks the release.
pauseDuration: PT2H
behavior: RESUME
onResume:
- id: decision
type: SELECT
values:
- Block release
- Approve these breaking changes
defaults: Block release
- id: note
type: STRING
required: false
description: Why the change is acceptable (consumers informed, versioned path,
...). Stored with the approval.
- id: approved
type: io.kestra.plugin.core.flow.If
condition: "{{ (outputs.wait_for_owner.onResume.decision ?? 'Block release') ==
'Approve these breaking changes' }}"
then:
- id: record_approval
type: io.kestra.plugin.core.kv.Set
description: Approvals are kept per change (oasdiff fingerprint), so a retry of
the same release passes and a new breaking change is still caught.
key: "{{ render(vars.kv_key) }}"
kvType: JSON
value: >-
{{ {'old': kv(render(vars.kv_key), errorOnMissing=false) ?? {},
'new': fromJson(outputs.summary.values.gated),
'release': render(vars.release), 'note': outputs.wait_for_owner.onResume.note ?? '', 'execution': execution.id}
| toJson | jq('{fingerprints: (((.old.fingerprints // []) +
[.new[].fingerprint]) | unique),
history: (((.old.history // []) + [{release, note, execution, changes: [.new[] | .operation + " " + .path + ": " + .text]}]) as $h
| [range([0, ($h | length) - 50] | max; $h | length) as $i | $h[$i]])}')
| first | toJson }}
- id: notify_approved
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "{{ render(vars.service) }} {{ render(vars.release) }}: breaking
changes approved{% if (outputs.wait_for_owner.onResume.note ?? '')
!= '' %} (\"{{ outputs.wait_for_owner.onResume.note }}\"){% endif
%}. The release can ship."
else:
- id: blocked
type: io.kestra.plugin.core.execution.Fail
errorMessage: "{{ render(vars.service) }} {{ render(vars.release) }} blocked: {{
fromJson(outputs.summary.values.gated) | length }} unapproved
breaking change(s), for example {{
fromJson(outputs.summary.values.gated)[0].operation }} {{
fromJson(outputs.summary.values.gated)[0].path }}: {{
fromJson(outputs.summary.values.gated)[0].text | replace({'`':
''}) }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: >-
API release gate did not pass for {{ render(vars.service) }} {{
render(vars.release) }} {% if errorLogs() | length > 0 %}at `{{
errorLogs()[0]['taskId'] }}`: {{ errorLogs()[0]['message'] | split('
\[\[') | first | split(' and body:') | first }}{% else %}without an error
message{% endif %}. The release should not ship until this execution
succeeds: {{ execution.id }}
outputs:
- id: breaking_changes
type: JSON
description: Breaking changes that needed approval in this run.
value: "{{ outputs.summary is defined ? fromJson(outputs.summary.values.gated) :
[] }}"
- id: changes
type: JSON
description: The full oasdiff changelog.
value: "{{ outputs.summary is defined ? fromJson(outputs.summary.values.changes)
: [] }}"