Webhook icon
Schedule icon
Script icon
Process icon
Request icon
If icon
Create icon
SlackIncomingWebhook icon
Fail icon
Log icon

Gate PyPI Publishing on Release Artifact Provenance and Uploads

Verify GitHub release tags against PyPI: sdist and wheel present, uploads not yanked, attestations checked, GitHub issue and red run on mismatch.

Categories
CoreInfrastructure

A release that looks green in GitHub can still be wrong on PyPI. The tag can name a version that was never served, the wheel can be missing, an upload can be yanked an hour after the announcement, or the attestation metadata a supply chain review expects never appeared. This blueprint turns those facts into a gate that reads PyPI itself instead of trusting the pipeline log that claims to have uploaded. A GitHub release webhook starts the run, a Python step reads the payload, an unauthenticated request pulls the public project record, and a second Python step compares the tag with the version PyPI serves, requires an sdist and a wheel, rejects yanked uploads, and records uploader or attestation metadata whenever the API exposes it. Any failed check opens an issue on the repository, posts the same reasons to Slack, and fails the execution, so a waiting deploy pipeline stops instead of shipping.

How it works

  1. The release_published trigger (io.kestra.plugin.core.trigger.Webhook) exposes a keyed URL for GitHub release events, and daily_reverify (io.kestra.plugin.core.trigger.Schedule, cron 0 6 * * *, shipped disabled: true) is there for a re-check after the fact.
  2. parse_release (io.kestra.plugin.scripts.python.Script on the io.kestra.plugin.core.runner.Process runner) reads tag_name, repository.full_name, and the package name out of the payload with input fallbacks, normalizes the package to its canonical PyPI form, and emits tag, package, repository, source, and release_url through Kestra's outputs protocol. A payload that is missing or malformed falls back to the inputs instead of raising.
  3. fetch_project_json (io.kestra.plugin.core.http.Request) issues an unauthenticated GET https://pypi.org/pypi/<package>/json with options.allowFailed: true, so an unknown project becomes a status code the verdict can reason about instead of an infrastructure error.
  4. assess_provenance (io.kestra.plugin.scripts.python.Script) reads the project record and compares the release tag with the served version, stripping a leading v on both sides, then requires an sdist and a wheel, rejects any yanked upload, and records uploader or attestation metadata when PyPI publishes it. It emits verdict, reasons, the version, and the file counts through the outputs protocol.
  5. gate (io.kestra.plugin.core.flow.If) branches on verdict != pass. The failing path opens an issue with io.kestra.plugin.github.issues.Create, posts the same reasons through io.kestra.plugin.slack.notifications.SlackIncomingWebhook, and ends with io.kestra.plugin.core.execution.Fail; the passing path logs the evidence for the execution history.
  6. The errors block alerts Slack when the gate itself cannot run, since a broken gate must never be mistaken for a verified release.

What you get

  • A verdict that answers one question with PyPI's own record: does the published tag, the served version, and the attached file set agree.
  • Typed flow outputs (verdict, reasons, release_tag, pypi_version, file_count) that a parent release pipeline can branch on without scraping logs.
  • Escalation in three places at once, a GitHub issue on the repository, a Slack message with the failed checks, and a red execution for anything watching run status.
  • A dated record of every check in Kestra's execution history, so you can open last month's release and see exactly what the gate decided and why.
  • A disabled daily schedule you can enable to re-verify artifacts after the fact, which is when yanks and replaced files usually show up.
  • No PyPI credentials anywhere in the flow, because the project API it reads is public.

Who it's for

  • Release engineers who publish to PyPI from GitHub releases and want a check that does not trust their own pipeline.
  • Security and platform teams answering supply chain questions about what actually serves for a given tag.
  • Maintainers of shared packages who need a red run and a tracked issue the moment an upload goes missing, gets yanked, or loses its provenance metadata.

Why orchestrate this with Kestra

A webhook in CI can call a PyPI API and post a message, but the hard part is what happens next: who records the verdict, who re-checks tomorrow, and what turns the deploy red when the record changes after the fact. Kestra makes the gate a first class workflow. The event trigger and the schedule live in the same file, every check is a logged step whose inputs and outputs are preserved, the branch that blocks the release is declarative rather than buried in a shell script, and the error handler keeps alerting honest when the gate itself cannot run. The execution history doubles as the audit trail: for any past release you can open the run and see the tag, the version PyPI served, the file counts, and the reasons behind the decision. Replaying a webhook or widening the schedule to hourly is configuration, not another script to deploy somewhere.

Prerequisites

  • A Kestra worker with Python 3 available to the process runner, or switch the two Python tasks to a container task runner.
  • A GitHub repository where the provided token can open issues, classic repo scope or fine-grained Issues write permission.
  • A Slack incoming webhook for block notices and failure alerts.
  • A GitHub repository webhook pointing at the trigger URL and configured to send release events.
  • The PyPI project name to verify. No PyPI account or token is needed, since the project API used here is public.

Secrets

  • GITHUB_TOKEN: token used to open the gate issue when a check fails.
  • SLACK_WEBHOOK_URL: Slack incoming webhook URL used for block notices and error alerts.
  • RELEASE_WEBHOOK_KEY: key segment guarding the webhook URL. Anyone holding it can start a run, so treat it as a secret.

Quick start

  1. Add the three secrets to the company.team namespace.
  2. Set package to your PyPI project name and repository to the GitHub repository that should receive gate issues.
  3. Point a GitHub webhook at /api/v1/{tenant}/executions/webhook/company.team/pypi-publish-provenance-gate/{RELEASE_WEBHOOK_KEY} and restrict it to release events. A payload without a release tag simply asks the gate to verify the latest version PyPI serves.
  4. Publish a GitHub release whose tag matches PyPI and confirm the run ends with verdict: pass. Then push a tag PyPI does not know, or yank an upload, and confirm the issue, the Slack alert, and the red execution.
  5. For the daily re-check, set daily_reverify.disabled to false and leave release_tag at latest.

How to extend

  • Cover several packages by calling this flow once per package from a parent flow with io.kestra.plugin.core.flow.Subflow and collecting each verdict output.
  • Make the gate blocking in your pipeline by branching on the verdict output of the parent call instead of parsing logs.
  • Check a second index, such as an internal mirror, with another io.kestra.plugin.core.http.Request and a third verdict task that compares the two records.
  • Persist the full project record next to the verdict with io.kestra.plugin.core.storage.Write, then link the storage URI from the GitHub issue so auditors can inspect the raw evidence.
  • Add a PEP 440 aware comparison (for example local version segments) by extending the version normalization helper in assess_provenance.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.