ReleaseSaga
ReleaseSaga is a standalone Python CLI for release automation with a Saga-style rollback pipeline: when one release step fails, every previously completed step is rolled back in reverse order.
Install
pip install release-saga
Quickstart
From the root of the project you want to release:
release-saga --mode build
Use --project-dir to point at a different target project directory when needed.
Configuration
release-saga reads configuration from the target project's pyproject.toml under [tool.release-saga].
Precedence is: CLI flag > [tool.release-saga] > built-in default.
| Config key | CLI flag | Built-in default | Notes |
|---|---|---|---|
wheel_glob |
--wheel-glob |
dist/*.whl |
Relative to the target project root |
publish_glob |
--publish-glob |
dist/* |
Relative to the target project root; used for Twine upload inputs |
s3_bucket |
--s3-bucket |
None |
Makes the S3 upload step available |
s3_prefix |
--s3-prefix |
{package_name_dash}/ |
str.format() template with package_name and package_name_dash |
git_tag_template |
--git-tag-template |
v{version} |
str.format() template with version |
git_remote |
--git-remote |
origin |
Git remote used for tag pushes |
release_notes_path |
--release-notes-path |
RELEASE_NOTES.json |
Relative to the target project root |
extra_steps |
(none) | [] |
Plugin steps to load, as "path_or_module:ClassName"; list order is priority — see Extending with custom steps |
Example target-project configuration:
[tool.release-saga]
s3_bucket = "my-bucket"
git_tag_template = "v{version}"
publish_glob = "dist/*"
Projects migrating from extract_version should explicitly set the legacy values below so existing release conventions do not change implicitly:
[tool.release-saga]
s3_bucket = "packages-s3-useast1-any"
s3_prefix = "{package_name_dash}/"
git_tag_template = "release.{version}"
git_remote = "origin"
release_notes_path = "RELEASE_NOTES.json"
publish_glob = "dist/*"
Architecture
release-saga is a tool you run from a target project's directory (or point at one with
--project-dir); it is not released by itself. cli.py:main() does two independent things:
- Local wheel lifecycle (
--mode build|install|dev|reinstall|uninstall) — builds, installs, or removes the target project's wheel via directpip/buildcalls. This has no rollback: it's a straight sequence of local, cheap-to-repeat operations. - Release pipeline (opt-in via
--upload-s3,--create-release,--publish-pypi) — a list ofReleaseStepobjects handed torun_release_pipeline(), which runs them as a Saga: steps execute in order, and if one fails or can't run, every step that already completed is undone in reverse order.
For each step, the pipeline:
- Calls
step.check(). This must returnNoneif the step can run, or a string reason if it can't (missing credentials, tool not installed, target already exists, etc.). A check failure — or a check that raises — stops the pipeline beforeexecute()runs. - Calls
step.execute(). If this raises, the pipeline rolls back that step first, then every previously completed step, in reverse order — the failing step may have partially succeeded before raising, so it gets a chance to undo whatever it already did. - Rollback is best-effort: if a step's
rollback()itself raises, the pipeline logs a warning and keeps rolling back the rest rather than aborting the rollback.
Each CLI-driven pipeline run is also recorded under
$XDG_DATA_HOME/release-saga/<package>/<version>/ (or
~/.local/share/release-saga/<package>/<version>/). If the process is interrupted after one or
more steps complete, rerun from the same target project with:
release-saga --mode clean
This finds the newest incomplete run for the target project's current version and retries rollback
for its completed steps in reverse order. Plugin steps that keep in-memory rollback state can
implement recovery_data() and prepare_rollback() to persist and restore that state; stateless
plugin rollback needs no additional methods.
Configuration flows from config.py:load_config(), which reads the target project's
pyproject.toml ([project].name/.version are required, [tool.release-saga] is optional) and
merges values in precedence order: built-in default → [tool.release-saga] → CLI flag.
Library facade: cli.py's argument parser and step-assembly logic aren't private to the CLI —
they're the public functions build_arg_parser() and build_release_steps(). release_saga/__init__.py
re-exports both, alongside ReleaseStep, ReleaseConfig, load_config, resolve_project_dir, and
run_release_pipeline, so from release_saga import ... gives a library consumer the whole public
API without reaching into submodules. release_saga.steps likewise re-exports the four concrete
step classes alongside ReleaseStep. This is what lets a custom step reuse the CLI's own flags and
built-in step wiring instead of reimplementing them — see "Extending with custom steps" below.
Release steps
The pipeline ships four built-in steps. build_release_steps() — used by cli.py:main(), and
importable directly from release_saga — wires them up in this order when their flag is passed:
| Order | Flag | Step | check() verifies |
execute() |
rollback() |
|---|---|---|---|---|---|
| 1 | --upload-s3 |
UploadS3Step |
s3_bucket configured, aws installed and credentials valid, object doesn't already exist at the target key |
aws s3 cp the wheel to s3://{bucket}/{prefix}{wheel} |
aws s3 rm the uploaded object |
| 2 | --create-release |
GitTagStep |
git installed, git_remote configured, tag doesn't already exist locally or on the remote |
creates an annotated tag, pushes it to git_remote |
deletes the remote tag (if pushed), then the local tag (if created) |
| 3 | --create-release |
GitHubReleaseStep |
gh installed and authenticated, release doesn't already exist for the tag, release_notes_path has an entry for the current version |
builds release notes from release_notes_path, runs gh release create attaching the wheel |
gh release delete (only if the release was actually created) |
| 4 | --publish-pypi |
PublishPyPiStep |
twine installed, ~/.pypirc exists |
twine check then twine upload on files matched by publish_glob |
cannot roll back — logs instructions to yank the release manually on pypi.org |
Because a PyPI upload can't be undone, keep --publish-pypi as the last step you enable for a
given release, after steps you're confident will succeed.
Steps that can only partially succeed track their own progress on self so rollback() only
undoes what actually happened — e.g. GitTagStep only pushes a tag-delete if it already pushed the
tag, and only deletes the local tag if it created one. Keep this in mind when writing your own step
(below): if execute() has more than one side effect, record which ones completed.
Extending with custom steps
A custom step is a ReleaseStep subclass with exactly three methods:
check() -> str | None— returnNoneif the step can run now, otherwise a short reason it can't. Called beforeexecute(); also called again on every step after this one before that step runs, so keep it cheap and side-effect-free.execute() -> None— do the work. Raise on failure (a normal exception is fine; subprocess calls should usecheck=TruesoCalledProcessErrorpropagates).rollback() -> None— best-effort undo. Only called for steps that actually executed (or that raised mid-execute()), never for steps that were skipped by a failedcheck().
The recommended way to add one is plugin loading — release-saga discovers and runs it
automatically, with no CLI reimplementation and no reload of the CLI tool required:
# pyproject.toml, in the target project
[tool.release-saga]
# "path_or_module:ClassName"; a relative path resolves against project_dir.
# List order is priority — steps run in this order, after the built-in S3/git/GitHub
# steps but before the (irreversible) PyPI publish step.
extra_steps = ["release_steps.py:ChangelogStep"]
# release_steps.py, next to pyproject.toml
from release_saga.steps.base import ReleaseStep
class ChangelogStep(ReleaseStep):
"""Prepend a version heading to CHANGELOG.md."""
name = "update changelog"
def __init__(self, config):
self.config = config
self._path = config.project_dir / "CHANGELOG.md"
self._original: str | None = None
def check(self) -> str | None:
if not self._path.is_file():
return f"{self._path} does not exist"
if f"## {self.config.version}" in self._path.read_text(encoding="utf-8"):
return f"CHANGELOG.md already has an entry for {self.config.version}"
return None
def execute(self):
self._original = self._path.read_text(encoding="utf-8")
heading = f"## {self.config.version}\n\n"
self._path.write_text(heading + self._original, encoding="utf-8")
def rollback(self):
if self._original is not None:
self._path.write_text(self._original, encoding="utf-8")
That's it — no reload of release-saga, no wrapper script. release-saga --create-release now
also runs ChangelogStep, with full Saga rollback across all three steps. A step meant to be
shared across projects instead of copy-pasted into each one's pyproject.toml can be published as
its own pip-installable package registering a release_saga.steps entry point; release-saga
discovers every installed package's entry points in that group automatically. Pass --no-plugins
to disable both mechanisms at once. See
Writing a custom release step for the full walkthrough
of both plugin mechanisms, their failure modes, and more examples.
There's also an older way, predating plugin loading: subclass ReleaseStep and call
run_release_pipeline() yourself. It's still supported, and still the right tool for a bespoke,
one-off release script that isn't worth an extra_steps entry — see
"Manual pipeline wiring" in the tutorial
for the full version, including how to reuse build_arg_parser() and build_release_steps() to
avoid reimplementing the CLI's own flags:
from pathlib import Path
from release_saga.config import load_config, resolve_project_dir
from release_saga.pipeline import run_release_pipeline
from release_saga.steps.git_tag import GitTagStep
from mymodule import ChangelogStep
project_dir = resolve_project_dir(Path.cwd())
config = load_config(project_dir, cli_overrides={})
run_release_pipeline(
[
ChangelogStep(config),
GitTagStep(config), # mix in a built-in step wherever it belongs in the order
]
)
Metadata
Release files for release-saga 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| release_saga-1.1.0.tar.gz | 42.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| release_saga-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 71.8 kB
Release files / release_saga-1.1.0.tar.gz
| Download URL | release_saga-1.1.0.tar.gz |
|---|---|
| Size | 42.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4723859c98e93082fd60ca4046fbf20c24d35d1a6124c136592722ced60f22f9
|
|
BLAKE2b-256 checksum How to use checksums |
513986c52189783aad8689f3dac030e21949f2c931c37f4b54843fc3205ef925
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.13
|
Release files / release_saga-1.1.0-py3-none-any.whl
| Download URL | release_saga-1.1.0-py3-none-any.whl |
|---|---|
| Size | 29.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3c8e4858c6f65203e5479fa1c576f926a1f71ad75bf6a750f0c8597ba21caf4f
|
|
BLAKE2b-256 checksum How to use checksums |
20ec44d1560efe036ef921edc0d5c075214914d57f1853860927fd2329b42472
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.13
|
Security for release-saga 1.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.