-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrelease.py
More file actions
executable file
·345 lines (268 loc) · 8.98 KB
/
release.py
File metadata and controls
executable file
·345 lines (268 loc) · 8.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
#!/usr/bin/env python3
"""Prepare and create a release.
Usage:
.. code-block:: bash
python release.py <X.Y.Z>
"""
import re
import subprocess
import sys
from collections.abc import Sequence
from datetime import date
from pathlib import Path
import yaml
def run(
cmd: Sequence[str], check: bool = True, text: bool = True, **kwargs
) -> subprocess.CompletedProcess:
"""Run a shell command and ensure it completes successfully.
Parameters
----------
cmd :
The command and its arguments to run.
Returns
-------
subprocess.CompletedProcess
The result of the completed subprocess.
"""
return subprocess.run(cmd, check=check, text=text, **kwargs)
def git_clean() -> bool:
"""Check if the Git working directory is clean.
Returns
-------
bool
True if the working directory is clean, False otherwise.
"""
result = run(["git", "status", "--porcelain"], capture_output=True)
return result.stdout.strip() == ""
def current_branch() -> str:
"""Give branch we are on."""
return run(
["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True
).stdout.strip()
def on_appropriate_branch(version: str) -> bool:
"""Verify that the version number is consistent with current branch.
If the version is ``1.2.3``, we should be on the ``1.2.x`` branch.
Parameters
----------
version :
The version string to check, in the form ``X.Y.Z``.
Returns
-------
bool
True if the current Git branch matches ``X.Y.x``, False otherwise.
"""
try:
branch = current_branch()
except subprocess.CalledProcessError:
print("Failed to get current Git branch.")
return False
major, minor, _ = version.split(".")
expected_branch = f"{major}.{minor}.x"
if branch != expected_branch:
print(
f"You are on branch '{branch}', but version {version} "
f"suggests you should be on '{expected_branch}'."
)
return False
print(f"Branch '{branch}' matches version {version}.")
return True
def has_staged_changes() -> bool:
"""Tell if there are changes to commit to avoid errors."""
result = run(["git", "diff", "--cached", "--quiet"], check=False)
return result.returncode != 0
def update_files(version: str) -> None:
"""Update :file:`CITATION.cff` and :file:`CHANGELOG.md`.
Parameters
----------
version :
The version string to set in the files.
"""
today = date.today().isoformat()
_update_citation(version, today)
_update_changelog(version, today)
def _update_citation(version: str, today: str) -> None:
"""Update the :file:`CITATION.cff` with the given version and current date.
Parameters
----------
version :
The version string to set in the CITATION.cff file.
today :
Today's date.
"""
path = Path("CITATION.cff")
if not path.exists():
print("CITATION.cff not found.")
sys.exit(1)
with path.open() as f:
data = yaml.safe_load(f)
data["version"] = version
data["date-released"] = today
with path.open("w") as f:
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
run(["git", "add", str(path)])
def _extract_changelog_section(
version: str, content: str
) -> tuple[str, int, re.Match[str]] | None:
"""Find the changelog section for a version.
Parameters
----------
version :
The version string (e.g., ``"1.8.0"``).
content :
The full text content of :file:`CHANGELOG.md`.
Returns
-------
tuple[str, int, re.Match[str]] | None
A tuple of (matched section string, line index, match object) or None
if not found.
"""
pattern = rf"^## \[{re.escape(
version
)}\](?: -- (unreleased|\d{{4}}-\d{{2}}-\d{{2}}))?$"
lines = content.splitlines()
for i, line in enumerate(lines):
if match := re.match(pattern, line, re.IGNORECASE):
section_lines = [line]
for j in range(i + 1, len(lines)):
if lines[j].startswith("## "):
break
section_lines.append(lines[j])
return ("\n".join(section_lines), i, match)
return None
def changelog_is_ok(version: str) -> bool:
"""Print the section of the changelog corresponding to the given version.
Parameters
----------
version :
The version string whose section should be printed.
Returns
-------
bool :
If the following processes can continue.
"""
path = Path("CHANGELOG.md")
if not path.exists():
print("CHANGELOG.md not found.")
return False
content = path.read_text()
result = _extract_changelog_section(version, content)
if not result:
print(
f"CHANGELOG.md does not contain '## [{version}]'. You may have "
"forgotten to update it."
)
return False
section, _, _ = result
print(f"\nThe CHANGELOG section corresponding to {version} reads:")
print(
" (note that the current date will be automatically appended to the "
"version\n number line if it is not present)"
)
print("=" * 79)
print(section)
print("=" * 79 + "\n")
return True
def _update_changelog(version: str, today: str) -> None:
"""Add current date to version section name in :file:`CHANGELOG.md`.
Date is appended to the line containing ``## [X.Y.Z]``. If the date is
already present, check that it matches ``today``.
Parameters
----------
version :
The version string to set in the :file:`CHANGELOG.md` file.
today :
Today's date.
"""
path = Path("CHANGELOG.md")
if not path.exists():
print("CHANGELOG.md not found.")
sys.exit(1)
content = path.read_text()
result = _extract_changelog_section(version, content)
if not result:
print(f"No section '## [{version}]' found in CHANGELOG.md.")
sys.exit(1)
_, index, match = result
matched_date_or_flag = match.group(1)
if matched_date_or_flag is None:
print(f"No date yet, appending today's date to version {version}.")
elif matched_date_or_flag.lower() == "unreleased":
print(f"'unreleased' found, replacing it with today's date.")
elif matched_date_or_flag != today:
print(
f"Version {version} already has date {matched_date_or_flag}, but "
f"today is {today}. I will write today's date in CHANGELOG."
)
ask_user_to_continue()
else:
print(
"CHANGELOG.md already contains correct date for version "
f"{version}."
)
return
lines = content.splitlines()
lines[index] = f"## [{version}] -- {today}"
path.write_text("\n".join(lines) + "\n")
run(["git", "add", str(path)])
def ask_user_to_continue(
question: str = "Is it ok for you? (y/n) ",
error_msg: str = "Operation aborted by user.",
count: int = 0,
) -> None:
"""Ask the user if he/she wants to continue execution of the script."""
if count >= 3:
print("Too many unsuccessful attempts.")
sys.exit(1)
answer = input(question)
if answer.lower() == "y":
return
if answer.lower() == "n":
print(error_msg)
sys.exit(1)
print(f"{answer = } was not understood. Please try again.")
return ask_user_to_continue(count=count + 1)
def main() -> None:
"""Run the release process.
This includes:
#. Checking the Git status
#. Checking the corresponding section in :file:`CHANGELOG.md`
#. Updating :file:`CITATION.cff` and :file:`CHANGELOG.md`
#. Committing changes, tagging the release, pushing to origin
#. Switching to main, merging, pushing
"""
if len(sys.argv) != 2:
print("Usage: python release.py X.Y.Z")
sys.exit(1)
version = sys.argv[1]
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
print("Version must be in format X.Y.Z (e.g. 1.8.0)")
sys.exit(1)
tag = f"v{version}"
if not git_clean():
print(
"Git working directory is not clean. Maybe there are uncommited "
"changes."
)
ask_user_to_continue()
if not on_appropriate_branch(version):
ask_user_to_continue()
if not changelog_is_ok(version):
print("Operation aborted due to a CHANGELOG.md error.")
sys.exit(1)
ask_user_to_continue()
update_files(version)
if has_staged_changes():
run(["git", "commit", "-m", f"Prepare release {tag}"])
else:
print("Nothing to commit.")
run(["git", "tag", tag])
run(["git", "push", "--set-upstream", "origin", current_branch()])
run(["git", "push", "origin", tag])
print(f"Release {tag} tagged and pushed!")
print("I will merge release branch into main and push.")
ask_user_to_continue()
run(["git", "checkout", "main"])
run(["git", "merge", "--no-ff", tag])
run(["git", "push", "origin", "main"])
if __name__ == "__main__":
main()