release-saga 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- release_saga-0.9.0/LICENSE +21 -0
- release_saga-0.9.0/PKG-INFO +241 -0
- release_saga-0.9.0/README.md +223 -0
- release_saga-0.9.0/pyproject.toml +45 -0
- release_saga-0.9.0/setup.cfg +4 -0
- release_saga-0.9.0/src/release_saga/__init__.py +22 -0
- release_saga-0.9.0/src/release_saga/__main__.py +3 -0
- release_saga-0.9.0/src/release_saga/cli.py +134 -0
- release_saga-0.9.0/src/release_saga/config.py +77 -0
- release_saga-0.9.0/src/release_saga/package_ops.py +83 -0
- release_saga-0.9.0/src/release_saga/pipeline.py +57 -0
- release_saga-0.9.0/src/release_saga/steps/__init__.py +13 -0
- release_saga-0.9.0/src/release_saga/steps/base.py +21 -0
- release_saga-0.9.0/src/release_saga/steps/git_tag.py +66 -0
- release_saga-0.9.0/src/release_saga/steps/github_release.py +108 -0
- release_saga-0.9.0/src/release_saga/steps/pypi_publish.py +48 -0
- release_saga-0.9.0/src/release_saga/steps/s3.py +73 -0
- release_saga-0.9.0/src/release_saga.egg-info/PKG-INFO +241 -0
- release_saga-0.9.0/src/release_saga.egg-info/SOURCES.txt +27 -0
- release_saga-0.9.0/src/release_saga.egg-info/dependency_links.txt +1 -0
- release_saga-0.9.0/src/release_saga.egg-info/entry_points.txt +2 -0
- release_saga-0.9.0/src/release_saga.egg-info/top_level.txt +1 -0
- release_saga-0.9.0/test/test_cli.py +168 -0
- release_saga-0.9.0/test/test_config.py +131 -0
- release_saga-0.9.0/test/test_package_ops.py +196 -0
- release_saga-0.9.0/test/test_pipeline.py +143 -0
- release_saga-0.9.0/test/test_public_api.py +51 -0
- release_saga-0.9.0/test/test_release_steps_builder.py +94 -0
- release_saga-0.9.0/test/test_steps.py +533 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yurii Cherkasov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: release-saga
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Cross-platform, plugin-extensible release automation with Saga-style rollback
|
|
5
|
+
Author-email: Yurii Cherkasov <yuchdev@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yuchdev/ReleaseSaga
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/yuchdev/ReleaseSaga/issues
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# ReleaseSaga
|
|
20
|
+
|
|
21
|
+
[](./pyproject.toml)
|
|
22
|
+
[](https://github.com/yuchdev/ReleaseSaga/actions/workflows/ci.yml)
|
|
23
|
+
[](./.coveragerc)
|
|
24
|
+
[](./LICENSE)
|
|
25
|
+
|
|
26
|
+
`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.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install release-saga
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
From the root of the project you want to release:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
release-saga --mode build
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use `--project-dir` to point at a different target project directory when needed.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
`release-saga` reads configuration from the target project's `pyproject.toml` under `[tool.release-saga]`.
|
|
47
|
+
Precedence is: CLI flag > `[tool.release-saga]` > built-in default.
|
|
48
|
+
|
|
49
|
+
| Config key | CLI flag | Built-in default | Notes |
|
|
50
|
+
|----------------------|------------------------|------------------------|---------------------------------------------------------------------|
|
|
51
|
+
| `wheel_glob` | `--wheel-glob` | `dist/*.whl` | Relative to the target project root |
|
|
52
|
+
| `publish_glob` | `--publish-glob` | `dist/*` | Relative to the target project root; used for Twine upload inputs |
|
|
53
|
+
| `s3_bucket` | `--s3-bucket` | `None` | Makes the S3 upload step available |
|
|
54
|
+
| `s3_prefix` | `--s3-prefix` | `{package_name_dash}/` | `str.format()` template with `package_name` and `package_name_dash` |
|
|
55
|
+
| `git_tag_template` | `--git-tag-template` | `v{version}` | `str.format()` template with `version` |
|
|
56
|
+
| `git_remote` | `--git-remote` | `origin` | Git remote used for tag pushes |
|
|
57
|
+
| `release_notes_path` | `--release-notes-path` | `RELEASE_NOTES.json` | Relative to the target project root |
|
|
58
|
+
|
|
59
|
+
Example target-project configuration:
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[tool.release-saga]
|
|
63
|
+
s3_bucket = "my-bucket"
|
|
64
|
+
git_tag_template = "v{version}"
|
|
65
|
+
publish_glob = "dist/*"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Projects migrating from `extract_version` should explicitly set the legacy values below so existing release conventions do not change implicitly:
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[tool.release-saga]
|
|
72
|
+
s3_bucket = "packages-s3-useast1-any"
|
|
73
|
+
s3_prefix = "{package_name_dash}/"
|
|
74
|
+
git_tag_template = "release.{version}"
|
|
75
|
+
git_remote = "origin"
|
|
76
|
+
release_notes_path = "RELEASE_NOTES.json"
|
|
77
|
+
publish_glob = "dist/*"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Architecture
|
|
81
|
+
|
|
82
|
+
`release-saga` is a tool you run *from* a target project's directory (or point at one with
|
|
83
|
+
`--project-dir`); it is not released by itself. `cli.py:main()` does two independent things:
|
|
84
|
+
|
|
85
|
+
1. **Local wheel lifecycle** (`--mode build|install|dev|reinstall|uninstall`) — builds, installs,
|
|
86
|
+
or removes the target project's wheel via direct `pip`/`build` calls. This has no rollback: it's
|
|
87
|
+
a straight sequence of local, cheap-to-repeat operations.
|
|
88
|
+
2. **Release pipeline** (opt-in via `--upload-s3`, `--create-release`, `--publish-pypi`) — a list of
|
|
89
|
+
`ReleaseStep` objects handed to `run_release_pipeline()`, which runs them as a **Saga**: steps
|
|
90
|
+
execute in order, and if one fails or can't run, every step that already completed is undone in
|
|
91
|
+
reverse order.
|
|
92
|
+
|
|
93
|
+
For each step, the pipeline:
|
|
94
|
+
|
|
95
|
+
1. Calls `step.check()`. This must return `None` if the step can run, or a string reason if it
|
|
96
|
+
can't (missing credentials, tool not installed, target already exists, etc.). A check failure —
|
|
97
|
+
or a check that raises — stops the pipeline *before* `execute()` runs.
|
|
98
|
+
2. Calls `step.execute()`. If this raises, the pipeline rolls back **that step first, then every
|
|
99
|
+
previously completed step**, in reverse order — the failing step may have partially succeeded
|
|
100
|
+
before raising, so it gets a chance to undo whatever it already did.
|
|
101
|
+
3. Rollback is best-effort: if a step's `rollback()` itself raises, the pipeline logs a warning and
|
|
102
|
+
keeps rolling back the rest rather than aborting the rollback.
|
|
103
|
+
|
|
104
|
+
Configuration flows from `config.py:load_config()`, which reads the target project's
|
|
105
|
+
`pyproject.toml` (`[project].name`/`.version` are required, `[tool.release-saga]` is optional) and
|
|
106
|
+
merges values in precedence order: built-in default → `[tool.release-saga]` → CLI flag.
|
|
107
|
+
|
|
108
|
+
**Library facade**: `cli.py`'s argument parser and step-assembly logic aren't private to the CLI —
|
|
109
|
+
they're the public functions `build_arg_parser()` and `build_release_steps()`. `release_saga/__init__.py`
|
|
110
|
+
re-exports both, alongside `ReleaseStep`, `ReleaseConfig`, `load_config`, `resolve_project_dir`, and
|
|
111
|
+
`run_release_pipeline`, so `from release_saga import ...` gives a library consumer the whole public
|
|
112
|
+
API without reaching into submodules. `release_saga.steps` likewise re-exports the four concrete
|
|
113
|
+
step classes alongside `ReleaseStep`. This is what lets a custom step reuse the CLI's own flags and
|
|
114
|
+
built-in step wiring instead of reimplementing them — see "Extending with custom steps" below.
|
|
115
|
+
|
|
116
|
+
## Release steps
|
|
117
|
+
|
|
118
|
+
The pipeline ships four built-in steps. `build_release_steps()` — used by `cli.py:main()`, and
|
|
119
|
+
importable directly from `release_saga` — wires them up in this order when their flag is passed:
|
|
120
|
+
|
|
121
|
+
| Order | Flag | Step | `check()` verifies | `execute()` | `rollback()` |
|
|
122
|
+
|-------|--------------------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
|
|
123
|
+
| 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 |
|
|
124
|
+
| 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) |
|
|
125
|
+
| 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) |
|
|
126
|
+
| 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 |
|
|
127
|
+
|
|
128
|
+
Because a PyPI upload can't be undone, keep `--publish-pypi` as the last step you enable for a
|
|
129
|
+
given release, after steps you're confident will succeed.
|
|
130
|
+
|
|
131
|
+
Steps that can only *partially* succeed track their own progress on `self` so `rollback()` only
|
|
132
|
+
undoes what actually happened — e.g. `GitTagStep` only pushes a tag-delete if it already pushed the
|
|
133
|
+
tag, and only deletes the local tag if it created one. Keep this in mind when writing your own step
|
|
134
|
+
(below): if `execute()` has more than one side effect, record which ones completed.
|
|
135
|
+
|
|
136
|
+
## Extending with custom steps
|
|
137
|
+
|
|
138
|
+
There's no plugin/entry-point loading yet — `release-saga`'s CLI only wires up the four steps
|
|
139
|
+
above. To run your own steps (in the same pipeline, with the same rollback guarantees), subclass
|
|
140
|
+
`ReleaseStep` and call `run_release_pipeline()` yourself. The contract is exactly three methods:
|
|
141
|
+
|
|
142
|
+
- `check() -> str | None` — return `None` if the step can run now, otherwise a short reason it
|
|
143
|
+
can't. Called before `execute()`; also called again on every step *after* this one before that
|
|
144
|
+
step runs, so keep it cheap and side-effect-free.
|
|
145
|
+
- `execute() -> None` — do the work. Raise on failure (a normal exception is fine; subprocess
|
|
146
|
+
calls should use `check=True` so `CalledProcessError` propagates).
|
|
147
|
+
- `rollback() -> None` — best-effort undo. Only called for steps that actually executed (or that
|
|
148
|
+
raised mid-`execute()`), never for steps that were skipped by a failed `check()`.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from pathlib import Path
|
|
152
|
+
|
|
153
|
+
from release_saga.config import load_config, resolve_project_dir
|
|
154
|
+
from release_saga.pipeline import run_release_pipeline
|
|
155
|
+
from release_saga.steps.base import ReleaseStep
|
|
156
|
+
from release_saga.steps.git_tag import GitTagStep
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ChangelogStep(ReleaseStep):
|
|
160
|
+
"""Prepend a version heading to CHANGELOG.md."""
|
|
161
|
+
|
|
162
|
+
name = "update changelog"
|
|
163
|
+
|
|
164
|
+
def __init__(self, config):
|
|
165
|
+
self.config = config
|
|
166
|
+
self._path = config.project_dir / "CHANGELOG.md"
|
|
167
|
+
self._original: str | None = None
|
|
168
|
+
|
|
169
|
+
def check(self) -> str | None:
|
|
170
|
+
if not self._path.is_file():
|
|
171
|
+
return f"{self._path} does not exist"
|
|
172
|
+
if f"## {self.config.version}" in self._path.read_text(encoding="utf-8"):
|
|
173
|
+
return f"CHANGELOG.md already has an entry for {self.config.version}"
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def execute(self) -> None:
|
|
177
|
+
self._original = self._path.read_text(encoding="utf-8")
|
|
178
|
+
heading = f"## {self.config.version}\n\n"
|
|
179
|
+
self._path.write_text(heading + self._original, encoding="utf-8")
|
|
180
|
+
|
|
181
|
+
def rollback(self) -> None:
|
|
182
|
+
if self._original is not None:
|
|
183
|
+
self._path.write_text(self._original, encoding="utf-8")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SlackNotifyStep(ReleaseStep):
|
|
187
|
+
"""Post to Slack; rollback deletes the message if one was posted."""
|
|
188
|
+
|
|
189
|
+
name = "notify Slack"
|
|
190
|
+
|
|
191
|
+
def __init__(self, config, webhook_url: str):
|
|
192
|
+
self.config = config
|
|
193
|
+
self.webhook_url = webhook_url
|
|
194
|
+
self._posted = False
|
|
195
|
+
|
|
196
|
+
def check(self) -> str | None:
|
|
197
|
+
if not self.webhook_url:
|
|
198
|
+
return "no Slack webhook configured"
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def execute(self) -> None:
|
|
202
|
+
# post_to_slack(self.webhook_url, f"Released {self.config.version}")
|
|
203
|
+
self._posted = True
|
|
204
|
+
|
|
205
|
+
def rollback(self) -> None:
|
|
206
|
+
if self._posted:
|
|
207
|
+
# post_to_slack(self.webhook_url, f"Release {self.config.version} rolled back")
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
project_dir = resolve_project_dir(Path.cwd())
|
|
212
|
+
config = load_config(project_dir, cli_overrides={})
|
|
213
|
+
|
|
214
|
+
run_release_pipeline(
|
|
215
|
+
[
|
|
216
|
+
ChangelogStep(config),
|
|
217
|
+
GitTagStep(config), # mix in a built-in step wherever it belongs in the order
|
|
218
|
+
SlackNotifyStep(config, webhook_url="https://hooks.slack.example/..."),
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`ChangelogStep` shows the common pattern for a step whose rollback needs a prior state (it snapshots
|
|
224
|
+
the file before mutating it). `SlackNotifyStep` shows the common pattern for a step whose
|
|
225
|
+
`execute()` may or may not have "really" happened by the time something later fails (it tracks
|
|
226
|
+
`_posted` so `rollback()` only fires if `execute()` got far enough to matter) — the same technique
|
|
227
|
+
`GitTagStep` and `GitHubReleaseStep` use for their own partial-success tracking.
|
|
228
|
+
|
|
229
|
+
The example above hand-builds its step list from scratch. If you also want the CLI's own
|
|
230
|
+
`--upload-s3`/`--create-release`/`--publish-pypi` flags and built-in steps alongside your custom
|
|
231
|
+
one — without reimplementing that wiring yourself — see
|
|
232
|
+
[Writing a custom release step](docs/tutorials/custom-release-step.md), which reuses
|
|
233
|
+
`build_arg_parser()` and `build_release_steps()` from the library.
|
|
234
|
+
|
|
235
|
+
## Competitive comparison
|
|
236
|
+
|
|
237
|
+
<!-- Milestone 0004, Task 04.0 -->
|
|
238
|
+
|
|
239
|
+
## Migration from `release_package.py`
|
|
240
|
+
|
|
241
|
+
<!-- Milestone 0004, Task 04.0 -->
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# ReleaseSaga
|
|
2
|
+
|
|
3
|
+
[](./pyproject.toml)
|
|
4
|
+
[](https://github.com/yuchdev/ReleaseSaga/actions/workflows/ci.yml)
|
|
5
|
+
[](./.coveragerc)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
`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.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install release-saga
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
From the root of the project you want to release:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
release-saga --mode build
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use `--project-dir` to point at a different target project directory when needed.
|
|
25
|
+
|
|
26
|
+
## Configuration
|
|
27
|
+
|
|
28
|
+
`release-saga` reads configuration from the target project's `pyproject.toml` under `[tool.release-saga]`.
|
|
29
|
+
Precedence is: CLI flag > `[tool.release-saga]` > built-in default.
|
|
30
|
+
|
|
31
|
+
| Config key | CLI flag | Built-in default | Notes |
|
|
32
|
+
|----------------------|------------------------|------------------------|---------------------------------------------------------------------|
|
|
33
|
+
| `wheel_glob` | `--wheel-glob` | `dist/*.whl` | Relative to the target project root |
|
|
34
|
+
| `publish_glob` | `--publish-glob` | `dist/*` | Relative to the target project root; used for Twine upload inputs |
|
|
35
|
+
| `s3_bucket` | `--s3-bucket` | `None` | Makes the S3 upload step available |
|
|
36
|
+
| `s3_prefix` | `--s3-prefix` | `{package_name_dash}/` | `str.format()` template with `package_name` and `package_name_dash` |
|
|
37
|
+
| `git_tag_template` | `--git-tag-template` | `v{version}` | `str.format()` template with `version` |
|
|
38
|
+
| `git_remote` | `--git-remote` | `origin` | Git remote used for tag pushes |
|
|
39
|
+
| `release_notes_path` | `--release-notes-path` | `RELEASE_NOTES.json` | Relative to the target project root |
|
|
40
|
+
|
|
41
|
+
Example target-project configuration:
|
|
42
|
+
|
|
43
|
+
```toml
|
|
44
|
+
[tool.release-saga]
|
|
45
|
+
s3_bucket = "my-bucket"
|
|
46
|
+
git_tag_template = "v{version}"
|
|
47
|
+
publish_glob = "dist/*"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Projects migrating from `extract_version` should explicitly set the legacy values below so existing release conventions do not change implicitly:
|
|
51
|
+
|
|
52
|
+
```toml
|
|
53
|
+
[tool.release-saga]
|
|
54
|
+
s3_bucket = "packages-s3-useast1-any"
|
|
55
|
+
s3_prefix = "{package_name_dash}/"
|
|
56
|
+
git_tag_template = "release.{version}"
|
|
57
|
+
git_remote = "origin"
|
|
58
|
+
release_notes_path = "RELEASE_NOTES.json"
|
|
59
|
+
publish_glob = "dist/*"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Architecture
|
|
63
|
+
|
|
64
|
+
`release-saga` is a tool you run *from* a target project's directory (or point at one with
|
|
65
|
+
`--project-dir`); it is not released by itself. `cli.py:main()` does two independent things:
|
|
66
|
+
|
|
67
|
+
1. **Local wheel lifecycle** (`--mode build|install|dev|reinstall|uninstall`) — builds, installs,
|
|
68
|
+
or removes the target project's wheel via direct `pip`/`build` calls. This has no rollback: it's
|
|
69
|
+
a straight sequence of local, cheap-to-repeat operations.
|
|
70
|
+
2. **Release pipeline** (opt-in via `--upload-s3`, `--create-release`, `--publish-pypi`) — a list of
|
|
71
|
+
`ReleaseStep` objects handed to `run_release_pipeline()`, which runs them as a **Saga**: steps
|
|
72
|
+
execute in order, and if one fails or can't run, every step that already completed is undone in
|
|
73
|
+
reverse order.
|
|
74
|
+
|
|
75
|
+
For each step, the pipeline:
|
|
76
|
+
|
|
77
|
+
1. Calls `step.check()`. This must return `None` if the step can run, or a string reason if it
|
|
78
|
+
can't (missing credentials, tool not installed, target already exists, etc.). A check failure —
|
|
79
|
+
or a check that raises — stops the pipeline *before* `execute()` runs.
|
|
80
|
+
2. Calls `step.execute()`. If this raises, the pipeline rolls back **that step first, then every
|
|
81
|
+
previously completed step**, in reverse order — the failing step may have partially succeeded
|
|
82
|
+
before raising, so it gets a chance to undo whatever it already did.
|
|
83
|
+
3. Rollback is best-effort: if a step's `rollback()` itself raises, the pipeline logs a warning and
|
|
84
|
+
keeps rolling back the rest rather than aborting the rollback.
|
|
85
|
+
|
|
86
|
+
Configuration flows from `config.py:load_config()`, which reads the target project's
|
|
87
|
+
`pyproject.toml` (`[project].name`/`.version` are required, `[tool.release-saga]` is optional) and
|
|
88
|
+
merges values in precedence order: built-in default → `[tool.release-saga]` → CLI flag.
|
|
89
|
+
|
|
90
|
+
**Library facade**: `cli.py`'s argument parser and step-assembly logic aren't private to the CLI —
|
|
91
|
+
they're the public functions `build_arg_parser()` and `build_release_steps()`. `release_saga/__init__.py`
|
|
92
|
+
re-exports both, alongside `ReleaseStep`, `ReleaseConfig`, `load_config`, `resolve_project_dir`, and
|
|
93
|
+
`run_release_pipeline`, so `from release_saga import ...` gives a library consumer the whole public
|
|
94
|
+
API without reaching into submodules. `release_saga.steps` likewise re-exports the four concrete
|
|
95
|
+
step classes alongside `ReleaseStep`. This is what lets a custom step reuse the CLI's own flags and
|
|
96
|
+
built-in step wiring instead of reimplementing them — see "Extending with custom steps" below.
|
|
97
|
+
|
|
98
|
+
## Release steps
|
|
99
|
+
|
|
100
|
+
The pipeline ships four built-in steps. `build_release_steps()` — used by `cli.py:main()`, and
|
|
101
|
+
importable directly from `release_saga` — wires them up in this order when their flag is passed:
|
|
102
|
+
|
|
103
|
+
| Order | Flag | Step | `check()` verifies | `execute()` | `rollback()` |
|
|
104
|
+
|-------|--------------------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
|
|
105
|
+
| 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 |
|
|
106
|
+
| 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) |
|
|
107
|
+
| 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) |
|
|
108
|
+
| 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 |
|
|
109
|
+
|
|
110
|
+
Because a PyPI upload can't be undone, keep `--publish-pypi` as the last step you enable for a
|
|
111
|
+
given release, after steps you're confident will succeed.
|
|
112
|
+
|
|
113
|
+
Steps that can only *partially* succeed track their own progress on `self` so `rollback()` only
|
|
114
|
+
undoes what actually happened — e.g. `GitTagStep` only pushes a tag-delete if it already pushed the
|
|
115
|
+
tag, and only deletes the local tag if it created one. Keep this in mind when writing your own step
|
|
116
|
+
(below): if `execute()` has more than one side effect, record which ones completed.
|
|
117
|
+
|
|
118
|
+
## Extending with custom steps
|
|
119
|
+
|
|
120
|
+
There's no plugin/entry-point loading yet — `release-saga`'s CLI only wires up the four steps
|
|
121
|
+
above. To run your own steps (in the same pipeline, with the same rollback guarantees), subclass
|
|
122
|
+
`ReleaseStep` and call `run_release_pipeline()` yourself. The contract is exactly three methods:
|
|
123
|
+
|
|
124
|
+
- `check() -> str | None` — return `None` if the step can run now, otherwise a short reason it
|
|
125
|
+
can't. Called before `execute()`; also called again on every step *after* this one before that
|
|
126
|
+
step runs, so keep it cheap and side-effect-free.
|
|
127
|
+
- `execute() -> None` — do the work. Raise on failure (a normal exception is fine; subprocess
|
|
128
|
+
calls should use `check=True` so `CalledProcessError` propagates).
|
|
129
|
+
- `rollback() -> None` — best-effort undo. Only called for steps that actually executed (or that
|
|
130
|
+
raised mid-`execute()`), never for steps that were skipped by a failed `check()`.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from pathlib import Path
|
|
134
|
+
|
|
135
|
+
from release_saga.config import load_config, resolve_project_dir
|
|
136
|
+
from release_saga.pipeline import run_release_pipeline
|
|
137
|
+
from release_saga.steps.base import ReleaseStep
|
|
138
|
+
from release_saga.steps.git_tag import GitTagStep
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ChangelogStep(ReleaseStep):
|
|
142
|
+
"""Prepend a version heading to CHANGELOG.md."""
|
|
143
|
+
|
|
144
|
+
name = "update changelog"
|
|
145
|
+
|
|
146
|
+
def __init__(self, config):
|
|
147
|
+
self.config = config
|
|
148
|
+
self._path = config.project_dir / "CHANGELOG.md"
|
|
149
|
+
self._original: str | None = None
|
|
150
|
+
|
|
151
|
+
def check(self) -> str | None:
|
|
152
|
+
if not self._path.is_file():
|
|
153
|
+
return f"{self._path} does not exist"
|
|
154
|
+
if f"## {self.config.version}" in self._path.read_text(encoding="utf-8"):
|
|
155
|
+
return f"CHANGELOG.md already has an entry for {self.config.version}"
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def execute(self) -> None:
|
|
159
|
+
self._original = self._path.read_text(encoding="utf-8")
|
|
160
|
+
heading = f"## {self.config.version}\n\n"
|
|
161
|
+
self._path.write_text(heading + self._original, encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
def rollback(self) -> None:
|
|
164
|
+
if self._original is not None:
|
|
165
|
+
self._path.write_text(self._original, encoding="utf-8")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class SlackNotifyStep(ReleaseStep):
|
|
169
|
+
"""Post to Slack; rollback deletes the message if one was posted."""
|
|
170
|
+
|
|
171
|
+
name = "notify Slack"
|
|
172
|
+
|
|
173
|
+
def __init__(self, config, webhook_url: str):
|
|
174
|
+
self.config = config
|
|
175
|
+
self.webhook_url = webhook_url
|
|
176
|
+
self._posted = False
|
|
177
|
+
|
|
178
|
+
def check(self) -> str | None:
|
|
179
|
+
if not self.webhook_url:
|
|
180
|
+
return "no Slack webhook configured"
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def execute(self) -> None:
|
|
184
|
+
# post_to_slack(self.webhook_url, f"Released {self.config.version}")
|
|
185
|
+
self._posted = True
|
|
186
|
+
|
|
187
|
+
def rollback(self) -> None:
|
|
188
|
+
if self._posted:
|
|
189
|
+
# post_to_slack(self.webhook_url, f"Release {self.config.version} rolled back")
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
project_dir = resolve_project_dir(Path.cwd())
|
|
194
|
+
config = load_config(project_dir, cli_overrides={})
|
|
195
|
+
|
|
196
|
+
run_release_pipeline(
|
|
197
|
+
[
|
|
198
|
+
ChangelogStep(config),
|
|
199
|
+
GitTagStep(config), # mix in a built-in step wherever it belongs in the order
|
|
200
|
+
SlackNotifyStep(config, webhook_url="https://hooks.slack.example/..."),
|
|
201
|
+
]
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`ChangelogStep` shows the common pattern for a step whose rollback needs a prior state (it snapshots
|
|
206
|
+
the file before mutating it). `SlackNotifyStep` shows the common pattern for a step whose
|
|
207
|
+
`execute()` may or may not have "really" happened by the time something later fails (it tracks
|
|
208
|
+
`_posted` so `rollback()` only fires if `execute()` got far enough to matter) — the same technique
|
|
209
|
+
`GitTagStep` and `GitHubReleaseStep` use for their own partial-success tracking.
|
|
210
|
+
|
|
211
|
+
The example above hand-builds its step list from scratch. If you also want the CLI's own
|
|
212
|
+
`--upload-s3`/`--create-release`/`--publish-pypi` flags and built-in steps alongside your custom
|
|
213
|
+
one — without reimplementing that wiring yourself — see
|
|
214
|
+
[Writing a custom release step](docs/tutorials/custom-release-step.md), which reuses
|
|
215
|
+
`build_arg_parser()` and `build_release_steps()` from the library.
|
|
216
|
+
|
|
217
|
+
## Competitive comparison
|
|
218
|
+
|
|
219
|
+
<!-- Milestone 0004, Task 04.0 -->
|
|
220
|
+
|
|
221
|
+
## Migration from `release_package.py`
|
|
222
|
+
|
|
223
|
+
<!-- Milestone 0004, Task 04.0 -->
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "release-saga"
|
|
7
|
+
version = "0.9.0"
|
|
8
|
+
description = "Cross-platform, plugin-extensible release automation with Saga-style rollback"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Yurii Cherkasov", email = "yuchdev@users.noreply.github.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Software Development :: Build Tools",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/yuchdev/ReleaseSaga"
|
|
25
|
+
"Bug Tracker" = "https://github.com/yuchdev/ReleaseSaga/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
release-saga = "release_saga.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
include = ["release_saga*"]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-cov>=5.0",
|
|
38
|
+
"ruff>=0.16",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
pythonpath = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.release-saga]
|
|
45
|
+
s3_bucket = "packages-s3-useast1-any"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = version("release-saga")
|
|
5
|
+
except PackageNotFoundError:
|
|
6
|
+
__version__ = "0.9.0"
|
|
7
|
+
|
|
8
|
+
from .cli import build_arg_parser, build_release_steps
|
|
9
|
+
from .config import ReleaseConfig, load_config, resolve_project_dir
|
|
10
|
+
from .pipeline import run_release_pipeline
|
|
11
|
+
from .steps import ReleaseStep
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"ReleaseStep",
|
|
16
|
+
"ReleaseConfig",
|
|
17
|
+
"load_config",
|
|
18
|
+
"resolve_project_dir",
|
|
19
|
+
"run_release_pipeline",
|
|
20
|
+
"build_arg_parser",
|
|
21
|
+
"build_release_steps",
|
|
22
|
+
]
|