id: github-actions-evidence-deploy-gate
namespace: company.engineering
description: Check exact GitHub Actions run, required jobs and artifacts, and
dispatch deployment only with passing evidence.
inputs:
- id: repository
type: STRING
description: GitHub owner/repo. The evidence run and the deploy workflow must
belong to this repository.
defaults: kestra-io/blueprints
- id: run_id
type: STRING
description: Numeric GitHub Actions run ID to verify against the live GitHub API.
defaults: "0"
- id: required_jobs
type: JSON
description: Exact job display names that must all succeed on the selected run.
defaults:
- build
- test
- id: required_artifacts
type: JSON
description: Names of non-expired uploaded artifacts that must be present and nonempty.
defaults:
- release-bundle
- id: deploy_workflow
type: STRING
description: Workflow filename/id that receives a workflow_dispatch in the same
GitHub repository.
defaults: deploy.yml
- id: dispatch_deployment
type: BOOL
description: False performs read-only verification. True dispatches a deployment
workflow, which must enforce the supplied expected_sha.
defaults: false
- id: sha_guard_confirmed
type: BOOL
description: Explicit acknowledgment that the target workflow validates
expected_sha before any deployment and checks out that exact commit.
defaults: false
triggers:
- id: periodic_release_check
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 8 * * *"
timezone: UTC
disabled: true
tasks:
- id: collect_evidence
type: io.kestra.plugin.scripts.python.Script
description: Query GitHub for the exact run, jobs and run artifacts. Write an
evidence report even if the gate fails.
containerImage: python:3.13-slim
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
timeout: PT5M
env:
GITHUB_TOKEN: "{{ secret('GITHUB_TOKEN') }}"
REPOSITORY: "{{ inputs.repository }}"
RUN_ID: "{{ inputs.run_id }}"
REQUIRED_JOBS: "{{ inputs.required_jobs | toJson }}"
REQUIRED_ARTIFACTS: "{{ inputs.required_artifacts | toJson }}"
outputFiles:
- evidence.json
script: |
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
repository = os.environ["REPOSITORY"]
run_id = os.environ["RUN_ID"]
token = os.environ["GITHUB_TOKEN"]
required_jobs = json.loads(os.environ["REQUIRED_JOBS"])
required_artifacts = json.loads(os.environ["REQUIRED_ARTIFACTS"])
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) or not re.fullmatch(r"[1-9][0-9]*", run_id):
raise ValueError("Provide an owner/repo and a positive GitHub Actions run ID")
for label, items in (("required_jobs", required_jobs), ("required_artifacts", required_artifacts)):
if not isinstance(items, list) or not items or any(not isinstance(v, str) or not v.strip() for v in items):
raise ValueError(label + " must be a nonempty list of names")
if len(set(items)) != len(items):
raise ValueError(label + " has duplicate entries")
if not token:
raise ValueError("GITHUB_TOKEN is required")
base = "https://api.github.com/repos/" + repository
def get(path):
req = urllib.request.Request(
base + path,
headers={
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kestra-release-evidence-gate",
},
)
with urllib.request.urlopen(req, timeout=25) as response:
return json.load(response)
def pages(path, field):
items = []
for page in range(1, 11):
sep = "&" if "?" in path else "?"
payload = get(path + sep + "per_page=100&page=" + str(page))
batch = payload[field]
items.extend(batch)
if len(batch) < 100:
return items
raise ValueError("Evidence exceeds 1000 entries; refusing a partial gate")
reasons = []
try:
run = get("/actions/runs/" + run_id)
jobs = pages("/actions/runs/" + run_id + "/jobs?filter=latest", "jobs")
artifacts = pages("/actions/runs/" + run_id + "/artifacts", "artifacts")
if run.get("repository", {}).get("full_name", "").lower() != repository.lower():
reasons.append("REPOSITORY_MISMATCH")
if run.get("status") != "completed" or run.get("conclusion") != "success":
reasons.append("RUN_NOT_SUCCESSFUL")
sha = run.get("head_sha", "")
branch = run.get("head_branch", "")
if not re.fullmatch(r"[0-9a-f]{40}", sha) or not branch:
reasons.append("MISSING_COMMIT_OR_BRANCH")
else:
ref = get("/branches/" + urllib.parse.quote(branch, safe=""))
if ref.get("commit", {}).get("sha") != sha:
reasons.append("BRANCH_MOVED_SINCE_CI")
for name in required_jobs:
matches = [j for j in jobs if j.get("name") == name]
if len(matches) != 1 or matches[0].get("status") != "completed" or matches[0].get("conclusion") != "success":
reasons.append("JOB_NOT_SUCCESSFUL:" + name)
for name in required_artifacts:
matches = [a for a in artifacts if a.get("name") == name and not a.get("expired") and a.get("size_in_bytes", 0) > 0]
if len(matches) != 1:
reasons.append("ARTIFACT_MISSING_EXPIRED_OR_EMPTY:" + name)
except (urllib.error.URLError, TimeoutError, ValueError, KeyError, TypeError) as exc:
reasons.append("EVIDENCE_RETRIEVAL_FAILED:" + type(exc).__name__)
run, jobs, artifacts, sha, branch = {}, [], [], None, None
approved = not reasons
report = {
"version": 1, "approved": approved, "repository": repository, "run_id": run_id,
"head_sha": sha, "head_branch": branch, "required_jobs": required_jobs,
"required_artifacts": required_artifacts, "failure_reasons": reasons,
"checked_jobs": [{"name": j.get("name"), "status": j.get("status"), "conclusion": j.get("conclusion")} for j in jobs],
"checked_artifacts": [{"name": a.get("name"), "expired": a.get("expired"), "size_in_bytes": a.get("size_in_bytes")} for a in artifacts],
}
Path("evidence.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print("::" + json.dumps({"outputs": {"approved": approved, "head_branch": branch or "", "head_sha": sha or ""}}) + "::")
- id: evidence_gate
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.collect_evidence.vars.approved == true }}"
then:
- id: dispatch_choice
type: io.kestra.plugin.core.flow.If
condition: "{{ inputs.dispatch_deployment == true }}"
then:
- id: require_sha_guard
type: io.kestra.plugin.core.flow.If
condition: "{{ inputs.sha_guard_confirmed == true }}"
then:
- id: dispatch_verified_deploy
type: io.kestra.plugin.github.actions.RunWorkflow
description: Dispatch only to a trusted deploy workflow that rejects SHA
mismatch and checks out the exact verified commit.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.repository }}"
workflowId: "{{ inputs.deploy_workflow }}"
ref: "{{ outputs.collect_evidence.vars.head_branch }}"
inputs:
expected_sha: "{{ outputs.collect_evidence.vars.head_sha }}"
evidence_run_id: "{{ inputs.run_id }}"
else:
- id: reject_unsafe_dispatch
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Deployment blocked: target workflow must verify expected_sha and
checkout that exact commit; set sha_guard_confirmed only after
auditing that workflow."
else:
- id: record_read_only_approval
type: io.kestra.plugin.core.output.OutputValues
description: Explicitly records that the evidence gate passed but no deployment
was dispatched.
values:
verdict: APPROVED_READ_ONLY
verified_sha: "{{ outputs.collect_evidence.vars.head_sha }}"
else:
- id: reject_release
type: io.kestra.plugin.core.execution.Fail
errorMessage: "GitHub CI/artifact evidence rejected. Inspect {{
outputs.collect_evidence.outputFiles['evidence.json'] }}. Deployment
was blocked."