id: deploy-freeze-enforcer
namespace: company.team
description: |
Blocks production deployments while a declared freeze window is active, whether the
window covers a holiday, an incident, or a major launch. A webhook receives pull
request opened, synchronize, and labeled events alongside deployment events, a Python
task parses the window against the current UTC time, and a frozen verdict escalates
to a GitHub tracking issue and a Slack emergency-approval request while any failure
is raised in the ops channel. Each run exposes the verdict, the window, and the days
until the next opening as outputs a deploy pipeline can branch on.
triggers:
- id: pull_request_and_deployment_events
type: io.kestra.plugin.core.trigger.Webhook
description: GitHub POSTs here when a pull request opens, pushes new commits, or
gains a label, and when a deployment event lands, so every change meets
the freeze check.
key: "{{ secret('DEPLOY_FREEZE_WEBHOOK_KEY') }}"
- id: weekly_freeze_preflight
type: io.kestra.plugin.core.trigger.Schedule
description: Weekly pre-flight that recomputes the upcoming freeze windows for
the calendar digest, shipped disabled until it is switched on after a
manual run.
cron: "0 6 * * 1"
disabled: true
inputs:
- id: freeze_start
type: STRING
defaults: "2026-12-24"
description: Opening of the freeze window as an ISO date or timestamp in UTC,
for example 2026-12-24T00:00:00Z.
- id: freeze_end
type: STRING
defaults: "2026-12-26"
description: Close of the freeze window as an ISO date or timestamp in UTC,
where a bare date closes at 23:59:59Z.
- id: repo
type: STRING
defaults: kestra-io/kestra
description: Repository the freeze protects in owner/name form, which is where
the tracking issue is opened.
variables:
event_action: "{{ trigger is not defined ? '' : ((trigger.body ?? {}).action ?? '') }}"
event_label: "{{ trigger is not defined ? '' : (((trigger.body ?? {}).label ??
{}).name ?? '') }}"
tasks:
# --- 1. freeze window verdict ---
- id: evaluate_freeze_window
type: io.kestra.plugin.scripts.python.Script
description: Parse the declared freeze window against the current UTC time and
publish is_frozen, the days until the next opening, and the reason on the
repo-standard outputs line.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.11-slim
script: |
import json
from datetime import date, datetime, time, timedelta, timezone
RAW_START = "{{ inputs.freeze_start }}".strip()
RAW_END = "{{ inputs.freeze_end }}".strip()
REPO = "{{ inputs.repo }}".strip()
def parse_stamp(value, end_of_day=False):
text = str(value or "").strip()
if not text:
return None
if len(text) == 10 and text[4] == "-" and text[7] == "-":
try:
day = date.fromisoformat(text)
except ValueError:
return None
moment = time(23, 59, 59) if end_of_day else time(0, 0)
return datetime.combine(day, moment, tzinfo=timezone.utc)
if text[-1:] in ("Z", "z"):
text = text[:-1] + "+00:00"
try:
stamp = datetime.fromisoformat(text)
except ValueError:
return None
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=timezone.utc)
return stamp.astimezone(timezone.utc)
def describe(stamp):
return stamp.strftime("%Y-%m-%dT%H:%M:%SZ")
def next_opening(stamp, now):
candidate = stamp
for _ in range(8):
if candidate.date() > now.date():
return candidate
try:
candidate = candidate.replace(year=candidate.year + 1)
except ValueError:
candidate = candidate + timedelta(days=365)
return candidate
start = parse_stamp(RAW_START)
end = parse_stamp(RAW_END, end_of_day=True)
now = datetime.now(timezone.utc)
if start is None or end is None or end < start:
payload = {
"verdict": "INVALID_WINDOW",
"is_frozen": False,
"window": "%s -> %s" % (RAW_START or "unset", RAW_END or "unset"),
"days_until": 0,
"reason": "the declared range is not a valid ISO 8601 freeze window, so nothing is frozen",
}
elif start <= now <= end:
payload = {
"verdict": "BLOCKED",
"is_frozen": True,
"window": "%s -> %s" % (describe(start), describe(end)),
"days_until": 0,
"reason": "current UTC time %s falls inside the window protecting %s" % (describe(now), REPO),
}
else:
opening = start if now < start else next_opening(start, now)
payload = {
"verdict": "ALLOWED",
"is_frozen": False,
"window": "%s -> %s" % (describe(start), describe(end)),
"days_until": max(0, (opening.date() - now.date()).days),
"reason": "no active freeze for %s, the window opens again %s UTC" % (REPO, describe(opening)),
}
print("::" + json.dumps({"outputs": payload}, ensure_ascii=True) + "::")
# --- 2. freeze gate ---
- id: freeze_gate
type: io.kestra.plugin.core.flow.If
description: Branch on the is_frozen verdict so a frozen window escalates to a
ticket and a channel request while a clear window is recorded as allowed.
condition: "{{ outputs.evaluate_freeze_window.vars.is_frozen }}"
then:
- id: file_freeze_issue
type: io.kestra.plugin.github.issues.Create
description: Open a tracking issue in the protected repository whose title
carries the active window, so the block shows up in the backlog
engineers already watch.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.repo }}"
title: "Deploy freeze active for {{ inputs.repo }} window {{
outputs.evaluate_freeze_window.vars.window }}"
labels:
- deploy-freeze
- change-freeze
body: |
## Deploy freeze is active
- Repository `{{ inputs.repo }}`
- Window `{{ outputs.evaluate_freeze_window.vars.window }}`
- Verdict `{{ outputs.evaluate_freeze_window.vars.verdict }}`
- Reason {{ outputs.evaluate_freeze_window.vars.reason }}
- Days until the next opening `{{ outputs.evaluate_freeze_window.vars.days_until }}`
Deployments are held until this window closes. Apply the `emergency-approval`
label to the pull request to take the allow path, or record an approved
exception in this issue before the change ships.
Flow `{{ flow.id }}`, execution `{{ execution.id }}`.
- id: request_emergency_approval
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Ask the ops channel to apply the emergency-approval label when a
hotfix really has to ship while the freeze holds.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text": "DEPLOY FREEZE active for {{ inputs.repo }}, window {{ outputs.evaluate_freeze_window.vars.window }}. Production deploys are blocked. Apply the emergency-approval label to the pull request to take the allow path. Verdict {{ outputs.evaluate_freeze_window.vars.verdict }}, execution {{ execution.id }}."}
- id: route_event_action
type: io.kestra.plugin.core.flow.Switch
description: Evaluate the incoming event action exactly once off the event
action value, so the emergency-approval label takes the allow path
while opened, synchronize, and unrelated label events stay blocked.
value: "{{ vars.event_action }}"
cases:
opened:
- id: log_pull_request_opened
type: io.kestra.plugin.core.log.Log
description: Record that a pull request opened inside the freeze and is held
until the window closes.
message: "Blocked for {{ inputs.repo }} - pull request opened during window {{
outputs.evaluate_freeze_window.vars.window }}, hold until the
freeze lifts, {{ outputs.evaluate_freeze_window.vars.days_until
}} day(s) to the next opening."
synchronize:
- id: log_pull_request_synchronize
type: io.kestra.plugin.core.log.Log
description: Record that new commits landed on a pull request inside the freeze,
so the push is held too.
message: "Blocked for {{ inputs.repo }} - new commits pushed during window {{
outputs.evaluate_freeze_window.vars.window }}, the pull request
stays frozen, execution {{ execution.id }}."
labeled:
- id: check_label
type: io.kestra.plugin.core.flow.If
description: Branch on whether the applied label is the emergency-approval
exemption or some other label.
condition: "{{ vars.event_label == 'emergency-approval' }}"
then:
- id: log_emergency_allow
type: io.kestra.plugin.core.log.Log
description: Record the allow path taken by the emergency-approval label while
the freeze still holds.
message: "Allow path for {{ inputs.repo }} - emergency-approval label present
during window {{ outputs.evaluate_freeze_window.vars.window
}}, the deploy ships under exception, execution {{
execution.id }}."
else:
- id: log_label_not_exempt
type: io.kestra.plugin.core.log.Log
description: Record that a label other than emergency-approval was applied,
which does not lift the freeze.
message: "Blocked for {{ inputs.repo }} - label {{ vars.event_label }} applied
during window {{ outputs.evaluate_freeze_window.vars.window
}}, only emergency-approval takes the allow path."
defaults:
- id: log_preflight_digest
type: io.kestra.plugin.core.log.Log
description: Log the pre-flight digest line for deployment events and the weekly
schedule, which carry no pull request action.
message: "Freeze pre-flight for {{ inputs.repo }} - window {{
outputs.evaluate_freeze_window.vars.window }}, verdict {{
outputs.evaluate_freeze_window.vars.verdict }}, {{
outputs.evaluate_freeze_window.vars.days_until }} day(s) until the
next opening."
else:
- id: log_deploy_allowed
type: io.kestra.plugin.core.log.Log
description: Record the allow verdict so a clear window still leaves evidence
that the gate ran.
message: "Deploy allowed for {{ inputs.repo }} - no freeze window covers the
current UTC time, verdict {{
outputs.evaluate_freeze_window.vars.verdict }}, {{
outputs.evaluate_freeze_window.vars.days_until }} day(s) until the
next window opens."
errors:
- id: notify_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Escalate any failure to the ops channel with the execution id,
because a gate that dies silently reads as a clear window.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text": "FAILED: deploy freeze enforcement for {{ inputs.repo }} in flow {{ flow.id }}, execution {{ execution.id }}. No verdict was produced, verify the window inputs, GITHUB_TOKEN, and the webhook trigger key."}
outputs:
- id: verdict
type: STRING
description: BLOCKED when the current UTC time sits inside the window, ALLOWED
when it does not, INVALID_WINDOW when the declared range cannot be parsed.
value: "{{ outputs.evaluate_freeze_window.vars.verdict }}"
- id: is_frozen
type: BOOL
description: True only when the declared freeze window covers the current UTC
time, which is the value the gate branches on.
value: "{{ outputs.evaluate_freeze_window.vars.is_frozen }}"
- id: window
type: STRING
description: The normalized freeze window as an open and close timestamp in UTC,
echoed back raw when the declared range is invalid.
value: "{{ outputs.evaluate_freeze_window.vars.window }}"
- id: days_until
type: INT
description: Whole calendar days from now until the window next opens, zero
while the freeze is active or the range is invalid.
value: "{{ outputs.evaluate_freeze_window.vars.days_until }}"