WorkingDirectory icon
Commands icon
Docker icon
Clone icon
Process icon
If icon
Comment icon
Switch icon
Fail icon
AllowFailure icon
Log icon
Webhook icon

Check that a Python release version matches its public API changes before it is published

Block Python releases whose version bump hides breaking API changes. Kestra compares two commits statically with Griffe and applies a SemVer policy.

Categories
Infrastructure

A strict, API-aware Semantic Versioning policy gate for Python releases. It answers one question before a release is published: does the proposed version match the public API changes since the last release?

A patch release that removes a function, or a minor release that adds a required parameter, breaks the users who trust Semantic Versioning. This blueprint compares the public API of a package at two exact commits (the last release and the release candidate), classifies every change as breaking, added or informational, and checks the proposed version bump against a strict SemVer policy. It returns PASS, WARN, BLOCK or INCONCLUSIVE with deterministic reason codes, and can post the result on the release pull request.

Both commits are analysed statically with Griffe: the source is parsed, never imported. Repository code is not installed, built or executed, and no build backend or setup.py runs.

How it works

  1. fetch_tools downloads two pinned wheels (griffelib 2.3.0 and packaging 25.0) with pip download --require-hashes --only-binary=:all:. It runs before anything is cloned, so the only container with network access never sees repository content.
  2. clone_base and clone_candidate (io.kestra.plugin.git.Clone) check out the two exact commits, without submodules.
  3. verify runs on the Kestra worker, before any container sees the checkouts, and first drops every environment variable it does not need. It checks that each checkout is the requested commit and keeps project_path inside each clone. It refuses symbolic links and .pth files where the analysis reads (the package, the folder it is found in, pyproject.toml), deletes symbolic links anywhere else (documentation, tests) so no later task can follow them out of the clone, and removes the .git folders. A refused repository is deleted and reported as INCONCLUSIVE.
  4. analyze runs in one container with networkMode: none, 2 CPUs, 1 GB of memory, a time limit and no secrets. It installs the wheels offline with their hashes checked, then loads both commits as the unprivileged nobody user with allow_inspection=False, so Griffe can never fall back to importing a module. Every loaded file must resolve inside its clone. It compares the two public APIs, applies the policy, and writes report.json and the pull request comment.
  5. pr_comment (io.kestra.plugin.github.issues.Comment) posts the verdict when pr_number is set. updateTag keeps one comment per pull request, project and package, updated on every run.
  6. verdict (Switch) fails the execution on BLOCK and ends INCONCLUSIVE runs in WARNING, so a release pipeline waiting on the gate can tell them apart.

What counts as public API

  • Public modules, classes, functions, methods (including special methods such as __init__) and attributes whose names do not start with _.
  • When a module defines __all__, only the names it lists are public.
  • A name imported into a package's __init__.py that points to a class, function or attribute inside the package (for example from ._core import Client) is a public re-export, even without __all__. Its signature is compared through the object it points to. Imports of other packages (import os), imports of modules (import mypackage, from mypackage import utils) and imports inside ordinary modules are not part of the API.
  • An object reachable under two public names (for example mypackage.Client and mypackage.client.Client) is reported under both.

Change classification

  • Breaking: everything Griffe's find_breaking_changes reports (removed objects, removed or reordered parameters, new required parameters, changed parameter kinds or defaults, incompatible return or attribute types, removed base classes), plus removed re-exports. Results are Griffe's, so its conventions apply: for example, rewriting def f(__self, __x) as def f(self, x, /) is reported as new required parameters.
  • Added: new public objects and new optional parameters.
  • Informational: changed values of module or class attributes, such as __version__, VERSION or DEFAULT_TIMEOUT. They are reported but never force a bump.

Policy

Versions must be final X.Y.Z releases. They are compared with packaging; pre-releases, post-releases, local versions and prefixes such as v1.2.3 are rejected rather than normalised.

Base version Change Patch bump Minor bump Major bump
>= 1.0.0 Breaking BLOCK BLOCK PASS
>= 1.0.0 Additions only WARN PASS PASS
>= 1.0.0 No API change PASS PASS WARN
0.y.z Breaking BLOCK PASS PASS
0.y.z Additions or none PASS PASS PASS

A version that does not increase is always BLOCK.

This is a policy gate, not a universal truth oracle. Many well-run projects intentionally ship breaking changes without a major version, for example removing APIs after a deprecation period in a minor release (Click 8.2.0 and Django 5.1 both do). This gate reports such releases as BLOCK and lists the exact changes, so maintainers can decide whether the policy applies to them.

Verdicts

The first group that matches wins.

Verdict Execution state Reason codes
INCONCLUSIVE WARNING UNSAFE_REPOSITORY (symbolic links or .pth files where the analysis reads, or a loaded file outside the clone), PACKAGE_NOT_FOUND, ANALYSIS_FAILED (for example a syntax error or the memory limit), ANALYSIS_TIMEOUT, VERSION_UNKNOWN (no static [project].version and no version input)
BLOCK FAILED VERSION_MISMATCH (a version input disagrees with pyproject.toml), VERSION_NOT_INCREASING, BREAKING_CHANGE_REQUIRES_MAJOR, BREAKING_CHANGE_REQUIRES_MINOR_0X
WARN SUCCESS NEW_API_REQUIRES_MINOR, UNNECESSARY_MAJOR
PASS SUCCESS none

Inputs

Name Type Default Description
repo_url STRING demo repository Public GitHub repository, https://github.com/<owner>/<repo>. No other host, scheme, port, credentials or query.
base_commit STRING demo commit Full 40-character SHA of the last release. Branch and tag names are rejected.
candidate_commit STRING demo commit Full 40-character SHA of the release candidate.
package STRING semver_demo Top-level import name, for example requests.
project_path STRING . Folder holding pyproject.toml. The package is looked up in <project_path>/src/<package> and <project_path>/<package>, separately for each commit, so a move to a src/ layout is handled. No absolute paths or ...
base_version STRING none Optional X.Y.Z. When empty or not set, [project].version of the base commit is used.
proposed_version STRING none Optional X.Y.Z. When empty or not set, [project].version of the candidate commit is used. Needed for projects with a dynamic version (for example setuptools-scm).
pr_number INT none Optional. When set, the verdict is posted on that pull request of the same repository.

The defaults compare 1.0.0 and 1.1.0 of a small public demo package, where 1.1.0 only adds API: the result is PASS, and nothing is written to GitHub.

Outputs

  • verdict: PASS, WARN, BLOCK or INCONCLUSIVE.
  • reason_codes: the codes behind the verdict, for example ["BREAKING_CHANGE_REQUIRES_MAJOR"].
  • required_bump: the smallest bump the detected changes require.
  • report: report.json (schema kestra-python-release-semver-gate/1.0) with the repository and commits, versions and where they came from, required and actual bump, every breaking change with its file and line, additions, informational changes, the detected layout per commit, and analysis and security metadata. Lists are sorted and the file contains no timestamps or execution IDs, so the same inputs always produce a byte-identical report.

Prerequisites

  • A Kestra worker that can run Docker containers, with outbound access to github.com and pypi.org / files.pythonhosted.org.
  • A pure-Python package. Compiled extension modules (.so, .pyd) are never imported, so their API is not visible.

Secrets

  • GITHUB_TOKEN: only when pr_number is set. A fine-grained token for the repository with Pull requests: read and write (or Issues: read and write). It is used by the comment task only and never reaches the analysis container.
  • SEMVER_GATE_WEBHOOK_KEY: random key that forms the webhook URL, only if you use the webhook.

Quick start

  1. Run the flow with the defaults to see a PASS. The defaults use the tiny package in kestra-python-semver-gate-demo, whose README lists every commit. Keep repo_url and package and try these pairs:

    base_commit candidate_commit Result
    cbe742eeb13641ad1bf66a5403b60a6e7d8e94b4 (1.0.0) f3359619076dc692e700e1f0a504e8282e644012 (1.1.0, adds farewell()) PASS (the defaults)
    f3359619076dc692e700e1f0a504e8282e644012 (1.1.0) a941ede3257832f02b8c692b603b9465203b0fa1 (2.0.0, greet() requires language) PASS
    f3359619076dc692e700e1f0a504e8282e644012 (1.1.0) 656f53ff6ceb99af2e3d9d570e64c03830febfbd (the same change released as 1.2.0) BLOCK: BREAKING_CHANGE_REQUIRES_MAJOR
  2. Point the inputs at your own repository, the commit of your last release and the release candidate.

  3. To gate release pull requests, call the webhook from CI and pass pr_number:

curl -fsS -X POST -H 'Content-Type: application/json' \
  -d "{\"repo_url\": \"https://github.com/${GITHUB_REPOSITORY}\", \"base_commit\": \"${LAST_RELEASE_SHA}\", \"candidate_commit\": \"${PR_HEAD_SHA}\", \"package\": \"mypackage\", \"pr_number\": ${PR_NUMBER}}" \
  "https://<your-kestra-host>/api/v1/<tenant>/executions/webhook/company.team/python-release-semver-gate/${SEMVER_GATE_WEBHOOK_KEY}"

Webhook values go through the same validators as a manual run: an invalid request gets HTTP 422 and no execution. Omitted or empty version fields fall back to pyproject.toml, and project_path defaults to ..

Security model

  • Repository code is untrusted and is only ever read as text. Griffe runs with allow_inspection=False and force_inspection=False, aliases are resolved only inside the already parsed tree, and no repository configuration (including any Griffe configuration) is read; only [project].version is taken from pyproject.toml, as data.
  • Symbolic links and .pth files where the analysis reads are refused, all other symbolic links are deleted before any container starts, and every file Griffe loads must resolve inside its clone. Files Python cannot parse make the run ANALYSIS_FAILED instead of looking like removed API.
  • verify runs on the worker and drops every environment variable except its four inputs before it starts. It only runs fixed checks and never executes repository content.
  • The analysis container has no network, no secrets, CPU, memory and time limits, and runs the parser as nobody with an empty environment. Tools come from two hash-pinned wheels; nothing is installed from the repository.
  • GITHUB_TOKEN is referenced only by the comment task.

Limitations

This gate checks the statically detectable public API surface. It does not prove:

  • runtime behaviour, or that the code still works;
  • semantic changes behind an unchanged signature (a function that now returns different results);
  • the API of C extensions and other compiled modules (.so, .pyd), which are never imported;
  • dynamic APIs created at runtime (module __getattr__, generated classes, decorators that replace signatures);
  • undocumented runtime effects such as changed exceptions, warnings, logging or performance.

Also: only final X.Y.Z versions, one top-level package per run, no namespace-package auto-detection, and type annotations are compared the way Griffe compares them.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.