reflex-release 0.1.0__py3-none-any.whl
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.
- reflex_release/__init__.py +15 -0
- reflex_release/__main__.py +6 -0
- reflex_release/actions.py +125 -0
- reflex_release/changelog.py +251 -0
- reflex_release/cli.py +405 -0
- reflex_release/commands.py +1215 -0
- reflex_release/config.py +1026 -0
- reflex_release/devpins.py +685 -0
- reflex_release/discovery.py +427 -0
- reflex_release/dist.py +176 -0
- reflex_release/gitutil.py +352 -0
- reflex_release/scaffold.py +1024 -0
- reflex_release/templates/workflows/auto_release_internal.yml +69 -0
- reflex_release/templates/workflows/changelog.yml +107 -0
- reflex_release/templates/workflows/dispatch_release.yml +127 -0
- reflex_release/templates/workflows/publish.yml +431 -0
- reflex_release/templates/workflows/release_from_changelog.yml +166 -0
- reflex_release/versions.py +125 -0
- reflex_release-0.1.0.dist-info/METADATA +962 -0
- reflex_release-0.1.0.dist-info/RECORD +22 -0
- reflex_release-0.1.0.dist-info/WHEEL +4 -0
- reflex_release-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Changelog-driven release automation for Python packages and monorepos.
|
|
2
|
+
|
|
3
|
+
The ``CHANGELOG.md`` files are the source of truth for publishing: a package is
|
|
4
|
+
published exactly when the newest version heading in its changelog has no
|
|
5
|
+
corresponding git tag. Tags are created only *after* a successful upload, so a
|
|
6
|
+
failed build or publish is retried by pushing a fix on top of the changelog
|
|
7
|
+
bump — no tag or release ever needs to be deleted.
|
|
8
|
+
|
|
9
|
+
The public entry point is the ``reflex-release`` command line interface; see
|
|
10
|
+
:mod:`reflex_release.cli` and the package README for the workflows it drives.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .config import Config, LockstepGroup, load_config
|
|
14
|
+
|
|
15
|
+
__all__ = ["Config", "LockstepGroup", "load_config"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""GitHub Actions plumbing: annotations, step outputs and job summaries.
|
|
2
|
+
|
|
3
|
+
Every helper degrades gracefully outside Actions, so the commands can be run
|
|
4
|
+
locally: annotations print as plain lines and outputs/summaries are written only
|
|
5
|
+
when the corresponding environment variable is set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import NoReturn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReleaseError(Exception):
|
|
17
|
+
"""A user-facing failure that should end the command with exit code 1."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fail(message: str) -> NoReturn:
|
|
21
|
+
"""Raise a :class:`ReleaseError` carrying a user-facing message.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
message: Human-readable description of the failure.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ReleaseError: Always.
|
|
28
|
+
"""
|
|
29
|
+
raise ReleaseError(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def echo(message: str) -> None:
|
|
33
|
+
"""Write a line of human-readable output to stdout.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
message: The text to write.
|
|
37
|
+
"""
|
|
38
|
+
sys.stdout.write(f"{message}\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def notice(message: str) -> None:
|
|
42
|
+
"""Emit a GitHub Actions notice annotation (plain line outside Actions).
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
message: The notice text.
|
|
46
|
+
"""
|
|
47
|
+
sys.stdout.write(f"::notice::{message}\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def warning(message: str) -> None:
|
|
51
|
+
"""Emit a GitHub Actions warning annotation (plain line outside Actions).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
message: The warning text.
|
|
55
|
+
"""
|
|
56
|
+
sys.stdout.write(f"::warning::{message}\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def error(message: str) -> None:
|
|
60
|
+
"""Emit a GitHub Actions error annotation (plain line outside Actions).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
message: The error text.
|
|
64
|
+
"""
|
|
65
|
+
sys.stdout.write(f"::error::{message}\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def repo_url() -> str:
|
|
69
|
+
"""Return the web URL of the repository the workflow is running against.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
``https://<server>/<owner>/<repo>``, or an empty string outside GitHub
|
|
73
|
+
Actions — callers fall back to plain text when there is no URL to link.
|
|
74
|
+
"""
|
|
75
|
+
repository = os.environ.get("GITHUB_REPOSITORY")
|
|
76
|
+
if not repository:
|
|
77
|
+
return ""
|
|
78
|
+
server = os.environ.get("GITHUB_SERVER_URL") or "https://github.com"
|
|
79
|
+
return f"{server.rstrip('/')}/{repository}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def link(label: str, url: str) -> str:
|
|
83
|
+
"""Render a markdown link, degrading to the label alone without a URL.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
label: The link text (already escaped/quoted as it should appear).
|
|
87
|
+
url: The target, or an empty string.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
``[label](url)``, or just ``label``.
|
|
91
|
+
"""
|
|
92
|
+
return f"[{label}]({url})" if url else label
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _append_lines(env_var: str, lines: list[str]) -> None:
|
|
96
|
+
"""Append lines to the file named by an environment variable, if it is set.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
env_var: ``GITHUB_OUTPUT`` or ``GITHUB_STEP_SUMMARY``.
|
|
100
|
+
lines: The lines to append.
|
|
101
|
+
"""
|
|
102
|
+
path = os.environ.get(env_var)
|
|
103
|
+
if not path:
|
|
104
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
105
|
+
return
|
|
106
|
+
with Path(path).open("a", encoding="utf-8") as f:
|
|
107
|
+
f.write("\n".join(lines) + "\n")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_outputs(**outputs: str) -> None:
|
|
111
|
+
"""Append ``key=value`` pairs to ``$GITHUB_OUTPUT``.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
**outputs: Output names and single-line values.
|
|
115
|
+
"""
|
|
116
|
+
_append_lines("GITHUB_OUTPUT", [f"{key}={value}" for key, value in outputs.items()])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def write_summary(lines: list[str]) -> None:
|
|
120
|
+
"""Append markdown lines to ``$GITHUB_STEP_SUMMARY``.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
lines: The markdown lines.
|
|
124
|
+
"""
|
|
125
|
+
_append_lines("GITHUB_STEP_SUMMARY", lines)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Parsing and rewriting of towncrier-generated ``CHANGELOG.md`` files.
|
|
2
|
+
|
|
3
|
+
The changelogs are the source of truth for publishing, so exactly one parser
|
|
4
|
+
recognizes version headings: the pull-request guard, the publish validation and
|
|
5
|
+
the release-notes extraction all go through this module and therefore cannot
|
|
6
|
+
disagree about what counts as a release.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from packaging.version import InvalidVersion, Version
|
|
16
|
+
|
|
17
|
+
from .actions import fail
|
|
18
|
+
|
|
19
|
+
NO_SIGNIFICANT_CHANGES = "No significant changes"
|
|
20
|
+
|
|
21
|
+
DEFAULT_TITLE_FORMAT = "## {version} ({project_date})"
|
|
22
|
+
|
|
23
|
+
_HEADING_RE = re.compile(r"^(?P<label>.*?)(?:\s+\((?P<date>[^()]*)\))?\s*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(frozen=True)
|
|
27
|
+
class Section:
|
|
28
|
+
"""One ``## <version> (<date>)`` section of a towncrier changelog.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
label: The heading text without the ``## `` prefix or date suffix.
|
|
32
|
+
date: The parenthesized date portion of the heading, if any.
|
|
33
|
+
version: The parsed version, or None for unversioned headings.
|
|
34
|
+
body: Everything below the heading, up to the next one.
|
|
35
|
+
raw: The heading and body verbatim.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
label: str
|
|
39
|
+
date: str | None
|
|
40
|
+
version: Version | None
|
|
41
|
+
body: str
|
|
42
|
+
raw: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def heading_version(label: str, date: str | None) -> Version | None:
|
|
46
|
+
"""Parse the version out of a section heading label.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
label: The heading text without the ``## `` prefix or date suffix.
|
|
50
|
+
date: The parenthesized date portion of the heading, if any.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The parsed version, or None for unparsable or "Unreleased" headings.
|
|
54
|
+
"""
|
|
55
|
+
if "unreleased" in label.lower() or "unreleased" in (date or "").lower():
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return Version(label.removeprefix("v"))
|
|
59
|
+
except InvalidVersion:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_sections(text: str) -> tuple[str, list[Section]]:
|
|
64
|
+
"""Split changelog markdown into a preamble and its ``## `` sections.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
text: The changelog markdown.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A ``(preamble, sections)`` tuple; the preamble is everything before the
|
|
71
|
+
first ``## `` heading.
|
|
72
|
+
"""
|
|
73
|
+
lines = text.splitlines(keepends=True)
|
|
74
|
+
starts = [i for i, line in enumerate(lines) if line.startswith("## ")]
|
|
75
|
+
if not starts:
|
|
76
|
+
return text, []
|
|
77
|
+
preamble = "".join(lines[: starts[0]])
|
|
78
|
+
sections: list[Section] = []
|
|
79
|
+
for pos, start in enumerate(starts):
|
|
80
|
+
end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
|
|
81
|
+
match = _HEADING_RE.match(lines[start][3:].strip())
|
|
82
|
+
label = match["label"] if match else ""
|
|
83
|
+
date = match["date"] if match else None
|
|
84
|
+
sections.append(
|
|
85
|
+
Section(
|
|
86
|
+
label=label,
|
|
87
|
+
date=date,
|
|
88
|
+
version=heading_version(label, date),
|
|
89
|
+
body="".join(lines[start + 1 : end]),
|
|
90
|
+
raw="".join(lines[start:end]),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return preamble, sections
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def latest_version(text: str) -> Version | None:
|
|
97
|
+
"""Return the newest version in a changelog, skipping "Unreleased".
|
|
98
|
+
|
|
99
|
+
The newest version is the first section heading that parses as a version;
|
|
100
|
+
towncrier prepends new sections, so document order is version order.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
text: The changelog markdown.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The version of the first versioned section, or None.
|
|
107
|
+
"""
|
|
108
|
+
_, sections = parse_sections(text)
|
|
109
|
+
for section in sections:
|
|
110
|
+
if section.version is not None:
|
|
111
|
+
return section.version
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def extract_notes(text: str, version: Version) -> str | None:
|
|
116
|
+
"""Return the body of the changelog section for a version.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
text: The changelog markdown.
|
|
120
|
+
version: The version whose section to extract.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The section body without the heading, stripped, or None if the version
|
|
124
|
+
has no section.
|
|
125
|
+
"""
|
|
126
|
+
_, sections = parse_sections(text)
|
|
127
|
+
for section in sections:
|
|
128
|
+
if section.version == version:
|
|
129
|
+
return section.body.strip()
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def split_categories(body: str) -> tuple[str, list[tuple[str, str]]]:
|
|
134
|
+
"""Split a section body into leading text and its ``### `` category blocks.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
body: The section body (text below a ``## `` heading).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A ``(lead, categories)`` tuple where lead is the text before the first
|
|
141
|
+
category heading and categories is a list of ``(name, block)`` pairs.
|
|
142
|
+
"""
|
|
143
|
+
lines = body.splitlines(keepends=True)
|
|
144
|
+
starts = [i for i, line in enumerate(lines) if line.startswith("### ")]
|
|
145
|
+
if not starts:
|
|
146
|
+
return body, []
|
|
147
|
+
lead = "".join(lines[: starts[0]])
|
|
148
|
+
categories: list[tuple[str, str]] = []
|
|
149
|
+
for pos, start in enumerate(starts):
|
|
150
|
+
end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
|
|
151
|
+
categories.append((lines[start][4:].strip(), "".join(lines[start + 1 : end])))
|
|
152
|
+
return lead, categories
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_heading(title_format: str, version: str, date_str: str) -> str:
|
|
156
|
+
"""Render a section heading the way towncrier would.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
title_format: The configured ``title_format``.
|
|
160
|
+
version: The version as it appears in headings (``v1.2.3``).
|
|
161
|
+
date_str: The release date (YYYY-MM-DD).
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The heading line, without a trailing newline.
|
|
165
|
+
"""
|
|
166
|
+
return title_format.format(name="", version=version, project_date=date_str).strip()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def collapse_prereleases(
|
|
170
|
+
text: str,
|
|
171
|
+
final_version: Version,
|
|
172
|
+
date_str: str,
|
|
173
|
+
category_order: list[str],
|
|
174
|
+
title_format: str = DEFAULT_TITLE_FORMAT,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Collapse the top prerelease sections into a single final section.
|
|
177
|
+
|
|
178
|
+
Merges the section for ``final_version`` (freshly written by towncrier from
|
|
179
|
+
any remaining fragments) together with every consecutive prerelease section
|
|
180
|
+
at the top of the changelog into one section, concatenating each category's
|
|
181
|
+
entries oldest-first. "No significant changes." placeholders are dropped
|
|
182
|
+
unless nothing else remains.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
text: The changelog markdown.
|
|
186
|
+
final_version: The final version being released.
|
|
187
|
+
date_str: The date for the new section heading (YYYY-MM-DD).
|
|
188
|
+
category_order: Category names in canonical display order.
|
|
189
|
+
title_format: The towncrier ``title_format`` to render the heading with.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The rewritten changelog markdown.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ReleaseError: When the changelog has no prerelease sections to collapse.
|
|
196
|
+
"""
|
|
197
|
+
preamble, sections = parse_sections(text)
|
|
198
|
+
run: list[Section] = []
|
|
199
|
+
for section in sections:
|
|
200
|
+
if section.version is not None and (
|
|
201
|
+
section.version == final_version or section.version.is_prerelease
|
|
202
|
+
):
|
|
203
|
+
run.append(section)
|
|
204
|
+
else:
|
|
205
|
+
break
|
|
206
|
+
if not run:
|
|
207
|
+
fail(f"no prerelease sections to collapse into v{final_version}")
|
|
208
|
+
|
|
209
|
+
strays = [
|
|
210
|
+
section
|
|
211
|
+
for section in sections[len(run) :]
|
|
212
|
+
if section.version is not None and section.version.is_prerelease
|
|
213
|
+
]
|
|
214
|
+
for stray in strays:
|
|
215
|
+
sys.stderr.write(
|
|
216
|
+
f"Warning: prerelease section '{stray.label}' is not contiguous "
|
|
217
|
+
"with the top of the changelog and was left in place.\n"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
merged: dict[str, list[str]] = {}
|
|
221
|
+
extra_categories: list[str] = []
|
|
222
|
+
leads: list[str] = []
|
|
223
|
+
for section in reversed(run):
|
|
224
|
+
lead, categories = split_categories(section.body)
|
|
225
|
+
lead = lead.strip()
|
|
226
|
+
if lead and not lead.startswith(NO_SIGNIFICANT_CHANGES):
|
|
227
|
+
leads.append(lead)
|
|
228
|
+
for name, block in categories:
|
|
229
|
+
block = block.strip()
|
|
230
|
+
if not block:
|
|
231
|
+
continue
|
|
232
|
+
if name not in merged:
|
|
233
|
+
merged[name] = []
|
|
234
|
+
if name not in category_order:
|
|
235
|
+
extra_categories.append(name)
|
|
236
|
+
merged[name].append(block)
|
|
237
|
+
|
|
238
|
+
parts: list[str] = [render_heading(title_format, f"v{final_version}", date_str), ""]
|
|
239
|
+
if leads:
|
|
240
|
+
parts.extend(["\n\n".join(leads), ""])
|
|
241
|
+
for name in [*category_order, *extra_categories]:
|
|
242
|
+
if name in merged:
|
|
243
|
+
parts.extend([f"### {name}", "", "\n".join(merged[name]), ""])
|
|
244
|
+
if not leads and not merged:
|
|
245
|
+
parts.extend([f"{NO_SIGNIFICANT_CHANGES}.", ""])
|
|
246
|
+
|
|
247
|
+
new_section = "\n".join(parts).rstrip("\n") + "\n"
|
|
248
|
+
remainder = "".join(section.raw for section in sections[len(run) :])
|
|
249
|
+
if remainder:
|
|
250
|
+
return f"{preamble}{new_section}\n\n{remainder}"
|
|
251
|
+
return preamble + new_section
|