1414# See the License for the specific language governing permissions and
1515# limitations under the License.
1616
17- """An interactive script for doing a release. See `run ()` below.
17+ """An interactive script for doing a release. See `cli ()` below.
1818"""
1919
20+ import re
2021import subprocess
2122import 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
2327
28+ import attr
2429import click
30+ import commonmark
2531import git
32+ import redbaron
33+ from click .exceptions import ClickException
34+ from github import Github
2635from packaging import version
27- from redbaron import RedBaron
2836
2937
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.
3541
3642 Requires the dev dependencies be installed, which can be done via:
3743
3844 pip install -e .[dev]
3945
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.
4068 """
4169
4270 # Make sure we're in a git repo.
@@ -51,32 +79,8 @@ def run():
5179 click .secho ("Updating git repo..." )
5280 repo .remote ().fetch ()
5381
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 ()
8084
8185 # Figure out what sort of release we're doing and calcuate the new version.
8286 rc = click .confirm ("RC" , default = True )
@@ -190,7 +194,7 @@ def run():
190194 # Update the `__version__` variable and write it back to the file.
191195 version_node .value = '"' + new_version + '"'
192196 with open ("synapse/__init__.py" , "w" ) as f :
193- f .write (red .dumps ())
197+ f .write (parsed_synapse_ast .dumps ())
194198
195199 # Generate changelogs
196200 subprocess .run ("python3 -m towncrier" , shell = True )
@@ -240,6 +244,180 @@ def run():
240244 )
241245
242246
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+
243421def find_ref (repo : git .Repo , ref_name : str ) -> Optional [git .HEAD ]:
244422 """Find the branch/ref, looking first locally then in the remote."""
245423 if ref_name in repo .refs :
@@ -256,5 +434,66 @@ def update_branch(repo: git.Repo):
256434 repo .git .merge (repo .active_branch .tracking_branch ().name )
257435
258436
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+
259498if __name__ == "__main__" :
260- run ()
499+ cli ()
0 commit comments