id: pypi-publish-provenance-gate
namespace: company.team
description: |
Verify a published GitHub release against the PyPI project record: the tag
matches the served version, the sdist and the wheel are attached, no upload
is yanked, and attestation metadata is checked when PyPI exposes it. A failed
check opens a GitHub issue, alerts Slack, and fails the run so the gate is
visibly red.
triggers:
- id: release_published
type: io.kestra.plugin.core.trigger.Webhook
description: GitHub posts the release payload here. The key segment guards the
URL, so treat it as a secret and not as the only thing standing between a
stranger and a run.
key: "{{ secret('RELEASE_WEBHOOK_KEY') }}"
- id: daily_reverify
type: io.kestra.plugin.core.trigger.Schedule
description: Shipped disabled. Enable it for a daily re-check of the same
package, which is when yanked or quietly replaced uploads usually surface.
cron: "0 6 * * *"
disabled: true
inputs:
- id: package
type: STRING
defaults: your-package
description: PyPI project name to verify, normalized to its canonical form
before it is used in the project API request.
- id: repository
type: STRING
defaults: your-org/your-repo
description: GitHub repository that receives the gate issue when a webhook
payload does not carry one, which is the case for scheduled and manual
runs.
- id: release_tag
type: STRING
defaults: latest
description: Tag verified on scheduled and manual runs. Keep latest to check
whatever PyPI currently serves, while webhook runs always use the tag from
the payload.
tasks:
# --- 1. parse the release payload ---
- id: parse_release
type: io.kestra.plugin.scripts.python.Script
description: Pull the release tag, the repository, and the package name out of
the webhook payload, falling back to the flow inputs so scheduled and
manual runs take the same path.
taskRunner:
type: io.kestra.plugin.core.runner.Process
env:
RELEASE_PAYLOAD: "{{ trigger.body ?? '' }}"
INPUT_TAG: "{{ inputs.release_tag }}"
INPUT_REPOSITORY: "{{ inputs.repository }}"
PACKAGE: "{{ inputs.package }}"
script: |
import json
import os
import re
def as_object(value):
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
decoded = json.loads(value)
except ValueError:
return {}
if isinstance(decoded, str):
try:
decoded = json.loads(decoded)
except ValueError:
return {}
return decoded if isinstance(decoded, dict) else {}
return {}
data = as_object(os.environ.get("RELEASE_PAYLOAD", "") or "")
release = as_object(data.get("release"))
payload_repo = as_object(data.get("repository"))
payload_tag = str(release.get("tag_name") or "").strip()
input_tag = (os.environ.get("INPUT_TAG", "") or "").strip()
tag = payload_tag or ("" if input_tag.lower() == "latest" else input_tag)
if payload_tag:
source = "webhook"
elif tag:
source = "input"
else:
source = "latest"
repository = str(payload_repo.get("full_name") or "").strip()
if not repository:
repository = (os.environ.get("INPUT_REPOSITORY", "") or "").strip()
package_raw = (os.environ.get("PACKAGE", "") or "").strip()
if not package_raw and repository:
package_raw = repository.split("/")[-1]
package = re.sub(r"[-_.]+", "-", package_raw).lower()
payload = {
"tag": tag,
"package": package,
"repository": repository,
"source": source,
"release_url": str(release.get("html_url") or "").strip(),
"prerelease": bool(release.get("prerelease") or False),
"payload_parsed": bool(data),
}
print("parsed tag=%s package=%s repository=%s source=%s" % (
payload["tag"] or "<latest>", payload["package"], payload["repository"], payload["source"]))
print("::" + json.dumps({"outputs": payload}) + "::")
# --- 2. read what PyPI actually serves ---
- id: fetch_project_json
type: io.kestra.plugin.core.http.Request
description: GET the public PyPI project API without any credentials, so the
gate compares against the record PyPI serves rather than against the
pipeline log that claims to have uploaded.
uri: "https://pypi.org/pypi/{{ outputs.parse_release.vars.package }}/json"
method: GET
headers:
Accept: "application/json"
options:
allowFailed: true
timeout:
connectTimeout: PT10S
readIdleTimeout: PT30S
# --- 3. provenance verdict ---
- id: assess_provenance
type: io.kestra.plugin.scripts.python.Script
description: Compare the release tag with the served version, require an sdist
and a wheel, reject yanked uploads, and record uploader or attestation
metadata whenever PyPI exposes it. Emits verdict and reasons through the
outputs protocol.
taskRunner:
type: io.kestra.plugin.core.runner.Process
env:
RELEASE_TAG: "{{ outputs.parse_release.vars.tag }}"
PACKAGE: "{{ outputs.parse_release.vars.package }}"
REPOSITORY: "{{ outputs.parse_release.vars.repository }}"
PYPI_CODE: "{{ outputs.fetch_project_json.code ?? 0 }}"
inputFiles:
project.json: "{{ outputs.fetch_project_json.body ?? '' }}"
script: |
import json
import os
def norm_version(value):
text = (value or "").strip()
if len(text) > 1 and text[0] in "vV" and text[1].isdigit():
text = text[1:]
return text.strip().lower()
tag = (os.environ.get("RELEASE_TAG", "") or "").strip()
package = (os.environ.get("PACKAGE", "") or "").strip()
repository = (os.environ.get("REPOSITORY", "") or "").strip()
try:
http_code = int(os.environ.get("PYPI_CODE", "0") or 0)
except ValueError:
http_code = 0
raw = ""
try:
with open("project.json") as handle:
raw = handle.read()
except OSError:
raw = ""
project = None
if raw.strip():
try:
project = json.loads(raw)
except ValueError:
project = None
checks = {}
reasons = []
latest = ""
files = []
sdist_files = []
wheel_files = []
yanked_files = []
attestation_files = []
uploader_files = []
if http_code != 200 or not isinstance(project, dict):
checks["pypi_record"] = False
reasons.append("PyPI project API returned HTTP %s for %s instead of a JSON project record" % (
http_code, package or "the configured package"))
else:
checks["pypi_record"] = True
info = project.get("info") or {}
latest = str(info.get("version") or "").strip()
urls = project.get("urls")
if not isinstance(urls, list):
urls = []
files = [f for f in urls if isinstance(f, dict)]
if not files and latest and isinstance(project.get("releases"), dict):
fallback = project["releases"].get(latest)
if isinstance(fallback, list):
files = [f for f in fallback if isinstance(f, dict)]
label = latest or "the latest version"
if not tag:
checks["tag_matches"] = bool(latest)
tag = latest
reasons.append("no release tag was supplied, so the gate verified the latest published version %s" % label)
else:
checks["tag_matches"] = bool(latest) and norm_version(tag) == norm_version(latest)
if checks["tag_matches"]:
reasons.append("release tag %s matches the PyPI version %s" % (tag, latest))
else:
reasons.append("release tag %s does not match the latest PyPI version %s" % (
tag, latest or "<unknown>"))
sdist_files = [f for f in files
if f.get("packagetype") == "sdist"
or str(f.get("filename") or "").endswith((".tar.gz", ".tar.bz2", ".zip"))]
checks["sdist_present"] = bool(sdist_files)
if sdist_files:
reasons.append("sdist archive present for %s" % label)
else:
reasons.append("no sdist archive is attached to %s" % label)
wheel_files = [f for f in files if str(f.get("filename") or "").lower().endswith(".whl")]
checks["wheel_present"] = bool(wheel_files)
if wheel_files:
reasons.append("wheel present for %s" % label)
else:
reasons.append("no wheel is attached to %s" % label)
yanked_files = [f for f in files if f.get("yanked")]
checks["uploads_not_yanked"] = not yanked_files
if yanked_files:
names = ", ".join(str(f.get("filename") or "unnamed") for f in yanked_files[:3])
reasons.append("%d upload(s) yanked from %s (%s)" % (len(yanked_files), label, names))
else:
reasons.append("no upload on %s is yanked" % label)
attestation_files = [f for f in files
if isinstance(f.get("attestations"), list) and f.get("attestations")]
uploader_files = [f for f in files if f.get("uploader")]
if attestation_files:
checks["provenance_metadata"] = True
reasons.append("attestation metadata present on %d of %d file(s) for %s" % (
len(attestation_files), len(files), label))
elif uploader_files:
checks["provenance_metadata"] = False
reasons.append("uploader metadata is published but no attestation is attached to any file of %s" % label)
else:
checks["provenance_metadata"] = True
reasons.append("PyPI exposes no uploader or attestation metadata for %s, so the check is recorded without blocking" % label)
if not files:
reasons.append("the project record lists no files for %s" % label)
verdict = "pass" if all(checks.values()) else "fail"
reasons_text = "; ".join(reasons) if reasons else "all provenance checks passed"
outputs = {
"verdict": verdict,
"reasons": reasons,
"reasons_text": reasons_text,
"package": package,
"repository": repository,
"tag": tag,
"pypi_version": latest,
"file_count": len(files),
"sdist_count": len(sdist_files),
"wheel_count": len(wheel_files),
"yanked_count": len(yanked_files),
"attestation_count": len(attestation_files),
"checks_passed": sum(1 for value in checks.values() if value),
"checks_total": len(checks),
}
print("%s: %s" % (verdict.upper(), reasons_text))
print("::" + json.dumps({"outputs": outputs}) + "::")
# --- 4. gate the release ---
- id: gate
type: io.kestra.plugin.core.flow.If
description: Any failed check blocks the release. The failing path opens a
GitHub issue, posts the same reasons to Slack, and fails the execution so
a waiting pipeline turns red.
condition: "{{ outputs.assess_provenance.vars.verdict != 'pass' }}"
then:
- id: open_release_issue
type: io.kestra.plugin.github.issues.Create
description: Open an issue carrying the tag, the PyPI version, and every reason
the gate recorded, so the mismatch is tracked where the release is
managed.
jwtToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ outputs.parse_release.vars.repository }}"
title: "PyPI provenance gate failed for {{ outputs.parse_release.vars.package }}
{{ outputs.assess_provenance.vars.tag }}"
body: |
## PyPI provenance gate failed
**Package:** {{ outputs.parse_release.vars.package }}
**Release tag:** {{ outputs.assess_provenance.vars.tag }}
**PyPI latest version:** {{ outputs.assess_provenance.vars.pypi_version }}
**GitHub prerelease:** {{ outputs.parse_release.vars.prerelease }}
**Checks passed:** {{ outputs.assess_provenance.vars.checks_passed }} of {{ outputs.assess_provenance.vars.checks_total }}
**Release:** {{ outputs.parse_release.vars.release_url }}
{{ outputs.assess_provenance.vars.reasons_text }}
Execution {{ execution.id }}. Fix the publish or the tag, then re-run the gate.
- id: alert_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the failed checks to Slack so release owners see the block
without opening Kestra.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "PyPI provenance gate failed for {{ outputs.parse_release.vars.package }} (tag {{ outputs.assess_provenance.vars.tag }}, PyPI {{ outputs.assess_provenance.vars.pypi_version }}): {{ outputs.assess_provenance.vars.reasons_text }}. Execution {{ execution.id }}."
}
- id: fail_gate
type: io.kestra.plugin.core.execution.Fail
description: Fail the execution with the recorded reasons as the error message,
so the gate surfaces red instead of quietly completing.
errorMessage: "Provenance gate failed for {{ outputs.parse_release.vars.package
}} tag {{ outputs.assess_provenance.vars.tag }}: {{
outputs.assess_provenance.vars.reasons_text }}"
else:
- id: log_pass
type: io.kestra.plugin.core.log.Log
description: Record the passing verdict so the execution history doubles as
evidence that the published artifacts were checked.
message: "Provenance gate passed for {{ outputs.parse_release.vars.package }}
tag {{ outputs.assess_provenance.vars.tag }} (PyPI {{
outputs.assess_provenance.vars.pypi_version }}, {{
outputs.assess_provenance.vars.file_count }} file(s), source {{
outputs.parse_release.vars.source }})."
errors:
- id: slack_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert when the gate itself cannot run, because an errored check and
a verified release look identical unless they are worded apart.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "FAILED {{ flow.id }} run {{ execution.id }}: the PyPI provenance gate could not complete, so the release stays unverified. Check GITHUB_TOKEN, SLACK_WEBHOOK_URL, and worker network access."
}
outputs:
- id: verdict
type: STRING
description: 'Gate result, for example pass or fail.'
value: "{{ outputs.assess_provenance.vars.verdict }}"
- id: reasons
type: STRING
description: Every check the gate ran, joined with semicolons.
value: "{{ outputs.assess_provenance.vars.reasons_text }}"
- id: release_tag
type: STRING
description: Release tag that was verified, filled with the served PyPI version
when the run supplied no tag.
value: "{{ outputs.assess_provenance.vars.tag }}"
- id: pypi_version
type: STRING
description: Version PyPI served for the package at check time.
value: "{{ outputs.assess_provenance.vars.pypi_version }}"
- id: file_count
type: INT
description: Number of files attached to that version on PyPI, for example 3.
value: "{{ outputs.assess_provenance.vars.file_count }}"