14
14
# See the License for the specific language governing permissions and
15
15
# limitations under the License.
16
16
17
- """An interactive script for doing a release. See `run ()` below.
17
+ """An interactive script for doing a release. See `cli ()` below.
18
18
"""
19
19
20
+ import re
20
21
import subprocess
21
22
import sys
22
- from typing import Optional
23
+ import urllib .request
24
+ from os import path
25
+ from tempfile import TemporaryDirectory
26
+ from typing import List , Optional , Tuple
23
27
28
+ import attr
24
29
import click
30
+ import commonmark
25
31
import git
32
+ import redbaron
33
+ from click .exceptions import ClickException
34
+ from github import Github
26
35
from packaging import version
27
- from redbaron import RedBaron
28
36
29
37
30
- @click .command ()
31
- def run ():
32
- """An interactive script to walk through the initial stages of creating a
33
- release, including creating release branch, updating changelog and pushing to
34
- GitHub.
38
+ @click .group ()
39
+ def cli ():
40
+ """An interactive script to walk through the parts of creating a release.
35
41
36
42
Requires the dev dependencies be installed, which can be done via:
37
43
38
44
pip install -e .[dev]
39
45
46
+ Then to use:
47
+
48
+ ./scripts-dev/release.py prepare
49
+
50
+ # ... ask others to look at the changelog ...
51
+
52
+ ./scripts-dev/release.py tag
53
+
54
+ # ... wait for asssets to build ...
55
+
56
+ ./scripts-dev/release.py publish
57
+ ./scripts-dev/release.py upload
58
+
59
+ If the env var GH_TOKEN (or GITHUB_TOKEN) is set, or passed into the
60
+ `tag`/`publish` command, then a new draft release will be created/published.
61
+ """
62
+
63
+
64
+ @cli .command ()
65
+ def prepare ():
66
+ """Do the initial stages of creating a release, including creating release
67
+ branch, updating changelog and pushing to GitHub.
40
68
"""
41
69
42
70
# Make sure we're in a git repo.
@@ -51,32 +79,8 @@ def run():
51
79
click .secho ("Updating git repo..." )
52
80
repo .remote ().fetch ()
53
81
54
- # Parse the AST and load the `__version__` node so that we can edit it
55
- # later.
56
- with open ("synapse/__init__.py" ) as f :
57
- red = RedBaron (f .read ())
58
-
59
- version_node = None
60
- for node in red :
61
- if node .type != "assignment" :
62
- continue
63
-
64
- if node .target .type != "name" :
65
- continue
66
-
67
- if node .target .value != "__version__" :
68
- continue
69
-
70
- version_node = node
71
- break
72
-
73
- if not version_node :
74
- print ("Failed to find '__version__' definition in synapse/__init__.py" )
75
- sys .exit (1 )
76
-
77
- # Parse the current version.
78
- current_version = version .parse (version_node .value .value .strip ('"' ))
79
- assert isinstance (current_version , version .Version )
82
+ # Get the current version and AST from root Synapse module.
83
+ current_version , parsed_synapse_ast , version_node = parse_version_from_module ()
80
84
81
85
# Figure out what sort of release we're doing and calcuate the new version.
82
86
rc = click .confirm ("RC" , default = True )
@@ -190,7 +194,7 @@ def run():
190
194
# Update the `__version__` variable and write it back to the file.
191
195
version_node .value = '"' + new_version + '"'
192
196
with open ("synapse/__init__.py" , "w" ) as f :
193
- f .write (red .dumps ())
197
+ f .write (parsed_synapse_ast .dumps ())
194
198
195
199
# Generate changelogs
196
200
subprocess .run ("python3 -m towncrier" , shell = True )
@@ -240,6 +244,180 @@ def run():
240
244
)
241
245
242
246
247
+ @cli .command ()
248
+ @click .option ("--gh-token" , envvar = ["GH_TOKEN" , "GITHUB_TOKEN" ])
249
+ def tag (gh_token : Optional [str ]):
250
+ """Tags the release and generates a draft GitHub release"""
251
+
252
+ # Make sure we're in a git repo.
253
+ try :
254
+ repo = git .Repo ()
255
+ except git .InvalidGitRepositoryError :
256
+ raise click .ClickException ("Not in Synapse repo." )
257
+
258
+ if repo .is_dirty ():
259
+ raise click .ClickException ("Uncommitted changes exist." )
260
+
261
+ click .secho ("Updating git repo..." )
262
+ repo .remote ().fetch ()
263
+
264
+ # Find out the version and tag name.
265
+ current_version , _ , _ = parse_version_from_module ()
266
+ tag_name = f"v{ current_version } "
267
+
268
+ # Check we haven't released this version.
269
+ if tag_name in repo .tags :
270
+ raise click .ClickException (f"Tag { tag_name } already exists!\n " )
271
+
272
+ # Get the appropriate changelogs and tag.
273
+ changes = get_changes_for_version (current_version )
274
+
275
+ click .echo_via_pager (changes )
276
+ if click .confirm ("Edit text?" , default = False ):
277
+ changes = click .edit (changes , require_save = False )
278
+
279
+ repo .create_tag (tag_name , message = changes )
280
+
281
+ if not click .confirm ("Push tag to GitHub?" , default = True ):
282
+ print ("" )
283
+ print ("Run when ready to push:" )
284
+ print ("" )
285
+ print (f"\t git push { repo .remote ().name } tag { current_version } " )
286
+ print ("" )
287
+ return
288
+
289
+ repo .git .push (repo .remote ().name , "tag" , tag_name )
290
+
291
+ # If no token was given, we bail here
292
+ if not gh_token :
293
+ click .launch (f"https://github.com/matrix-org/synapse/releases/edit/{ tag_name } " )
294
+ return
295
+
296
+ # Create a new draft release
297
+ gh = Github (gh_token )
298
+ gh_repo = gh .get_repo ("matrix-org/synapse" )
299
+ release = gh_repo .create_git_release (
300
+ tag = tag_name ,
301
+ name = tag_name ,
302
+ message = changes ,
303
+ draft = True ,
304
+ prerelease = current_version .is_prerelease ,
305
+ )
306
+
307
+ # Open the release and the actions where we are building the assets.
308
+ click .launch (release .url )
309
+ click .launch (
310
+ f"https://github.com/matrix-org/synapse/actions?query=branch%3A{ tag_name } "
311
+ )
312
+
313
+ click .echo ("Wait for release assets to be built" )
314
+
315
+
316
+ @cli .command ()
317
+ @click .option ("--gh-token" , envvar = ["GH_TOKEN" , "GITHUB_TOKEN" ], required = True )
318
+ def publish (gh_token : str ):
319
+ """Publish release."""
320
+
321
+ # Make sure we're in a git repo.
322
+ try :
323
+ repo = git .Repo ()
324
+ except git .InvalidGitRepositoryError :
325
+ raise click .ClickException ("Not in Synapse repo." )
326
+
327
+ if repo .is_dirty ():
328
+ raise click .ClickException ("Uncommitted changes exist." )
329
+
330
+ current_version , _ , _ = parse_version_from_module ()
331
+ tag_name = f"v{ current_version } "
332
+
333
+ if not click .confirm (f"Publish { tag_name } ?" , default = True ):
334
+ return
335
+
336
+ # Publish the draft release
337
+ gh = Github (gh_token )
338
+ gh_repo = gh .get_repo ("matrix-org/synapse" )
339
+ for release in gh_repo .get_releases ():
340
+ if release .title == tag_name :
341
+ break
342
+ else :
343
+ raise ClickException (f"Failed to find GitHub release for { tag_name } " )
344
+
345
+ assert release .title == tag_name
346
+
347
+ if not release .draft :
348
+ click .echo ("Release already published." )
349
+ return
350
+
351
+ release = release .update_release (
352
+ name = release .title ,
353
+ message = release .body ,
354
+ tag_name = release .tag_name ,
355
+ prerelease = release .prerelease ,
356
+ draft = False ,
357
+ )
358
+
359
+
360
+ @cli .command ()
361
+ def upload ():
362
+ """Upload release to pypi."""
363
+
364
+ current_version , _ , _ = parse_version_from_module ()
365
+ tag_name = f"v{ current_version } "
366
+
367
+ pypi_asset_names = [
368
+ f"matrix_synapse-{ current_version } -py3-none-any.whl" ,
369
+ f"matrix-synapse-{ current_version } .tar.gz" ,
370
+ ]
371
+
372
+ with TemporaryDirectory (prefix = f"synapse_upload_{ tag_name } _" ) as tmpdir :
373
+ for name in pypi_asset_names :
374
+ filename = path .join (tmpdir , name )
375
+ url = f"https://github.com/matrix-org/synapse/releases/download/{ tag_name } /{ name } "
376
+
377
+ click .echo (f"Downloading { name } into { filename } " )
378
+ urllib .request .urlretrieve (url , filename = filename )
379
+
380
+ if click .confirm ("Upload to PyPI?" , default = True ):
381
+ subprocess .run ("twine upload *" , shell = True , cwd = tmpdir )
382
+
383
+ click .echo (
384
+ f"Done! Remember to merge the tag { tag_name } into the appropriate branches"
385
+ )
386
+
387
+
388
+ def parse_version_from_module () -> Tuple [
389
+ version .Version , redbaron .RedBaron , redbaron .Node
390
+ ]:
391
+ # Parse the AST and load the `__version__` node so that we can edit it
392
+ # later.
393
+ with open ("synapse/__init__.py" ) as f :
394
+ red = redbaron .RedBaron (f .read ())
395
+
396
+ version_node = None
397
+ for node in red :
398
+ if node .type != "assignment" :
399
+ continue
400
+
401
+ if node .target .type != "name" :
402
+ continue
403
+
404
+ if node .target .value != "__version__" :
405
+ continue
406
+
407
+ version_node = node
408
+ break
409
+
410
+ if not version_node :
411
+ print ("Failed to find '__version__' definition in synapse/__init__.py" )
412
+ sys .exit (1 )
413
+
414
+ # Parse the current version.
415
+ current_version = version .parse (version_node .value .value .strip ('"' ))
416
+ assert isinstance (current_version , version .Version )
417
+
418
+ return current_version , red , version_node
419
+
420
+
243
421
def find_ref (repo : git .Repo , ref_name : str ) -> Optional [git .HEAD ]:
244
422
"""Find the branch/ref, looking first locally then in the remote."""
245
423
if ref_name in repo .refs :
@@ -256,5 +434,66 @@ def update_branch(repo: git.Repo):
256
434
repo .git .merge (repo .active_branch .tracking_branch ().name )
257
435
258
436
437
+ def get_changes_for_version (wanted_version : version .Version ) -> str :
438
+ """Get the changelogs for the given version.
439
+
440
+ If an RC then will only get the changelog for that RC version, otherwise if
441
+ its a full release will get the changelog for the release and all its RCs.
442
+ """
443
+
444
+ with open ("CHANGES.md" ) as f :
445
+ changes = f .read ()
446
+
447
+ # First we parse the changelog so that we can split it into sections based
448
+ # on the release headings.
449
+ ast = commonmark .Parser ().parse (changes )
450
+
451
+ @attr .s (auto_attribs = True )
452
+ class VersionSection :
453
+ title : str
454
+
455
+ # These are 0-based.
456
+ start_line : int
457
+ end_line : Optional [int ] = None # Is none if its the last entry
458
+
459
+ headings : List [VersionSection ] = []
460
+ for node , _ in ast .walker ():
461
+ # We look for all text nodes that are in a level 1 heading.
462
+ if node .t != "text" :
463
+ continue
464
+
465
+ if node .parent .t != "heading" or node .parent .level != 1 :
466
+ continue
467
+
468
+ # If we have a previous heading then we update its `end_line`.
469
+ if headings :
470
+ headings [- 1 ].end_line = node .parent .sourcepos [0 ][0 ] - 1
471
+
472
+ headings .append (VersionSection (node .literal , node .parent .sourcepos [0 ][0 ] - 1 ))
473
+
474
+ changes_by_line = changes .split ("\n " )
475
+
476
+ version_changelog = [] # The lines we want to include in the changelog
477
+
478
+ # Go through each section and find any that match the requested version.
479
+ regex = re .compile (r"^Synapse v?(\S+)" )
480
+ for section in headings :
481
+ groups = regex .match (section .title )
482
+ if not groups :
483
+ continue
484
+
485
+ heading_version = version .parse (groups .group (1 ))
486
+ heading_base_version = version .parse (heading_version .base_version )
487
+
488
+ # Check if heading version matches the requested version, or if its an
489
+ # RC of the requested version.
490
+ if wanted_version not in (heading_version , heading_base_version ):
491
+ continue
492
+
493
+ version_changelog .extend (changes_by_line [section .start_line : section .end_line ])
494
+
495
+ return "\n " .join (version_changelog )
496
+
497
+
259
498
if __name__ == "__main__" :
260
- run ()
499
+ cli ()
0 commit comments