|
| 1 | +# usage: ./make-a-release.py [previous_version] |
| 2 | +# create a new release tag and update CHANGELOG.md. If previous-version is not |
| 3 | +# provided, it will be inferred from the latest git tag. |
| 4 | + |
| 5 | +# XXX: I set out to use pygit2, but turns out certain things I need aren't available there, |
| 6 | +# so this script uses git command line also. ¯\_(ツ)_/¯ |
| 7 | +import pygit2 |
| 8 | +import datetime |
| 9 | +import sys |
| 10 | +import tempfile |
| 11 | +import subprocess |
| 12 | +import re |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | +repo = pygit2.Repository('.') |
| 16 | + |
| 17 | +status = repo.status(untracked_files='no') |
| 18 | +for filepath, flags in status.items(): |
| 19 | + if flags != pygit2.enums.FileStatus.CURRENT: |
| 20 | + print(f"Filepath {filepath} isn't clean") |
| 21 | + print("Repository is dirty, please commit or stash your changes") |
| 22 | + sys.exit(1) |
| 23 | + |
| 24 | +all_tags = {tag.peel(pygit2.Commit).id: tag.shorthand for tag in repo.references.iterator(pygit2.enums.ReferenceFilter.TAGS)} |
| 25 | +previous_version = sys.argv[1] if len(sys.argv) > 1 else None |
| 26 | +merge_base = None |
| 27 | + |
| 28 | +# now, since stable releases are branched off from next, and some commits in next might have already been backported/cherry-picked to stable, |
| 29 | +# so we need to go through unique commits in the previous stable release, and filter out the ones that were already released. |
| 30 | + |
| 31 | +if previous_version is None: |
| 32 | + queue = [repo.head.peel(pygit2.Commit)] |
| 33 | + h = 0 |
| 34 | + while h < len(queue) and queue[h].id not in all_tags: |
| 35 | + queue.extend(queue[h].parents) |
| 36 | + h += 1 |
| 37 | + print(f'Previous release is {all_tags[queue[h].id]}, commit {queue[h].id}') |
| 38 | + previous_version = merge_base = queue[h].id |
| 39 | +else: |
| 40 | + try: |
| 41 | + previous_version = repo.references[f'refs/tags/{previous_version}'].peel(pygit2.Commit).id |
| 42 | + except KeyError: |
| 43 | + print(f'Tag {previous_version} does not exist') |
| 44 | + sys.exit(1) |
| 45 | + merge_base = repo.merge_base(repo.head.peel(pygit2.Commit).id, previous_version) |
| 46 | + print(f'Merge base between HEAD and {sys.argv[1]} is {merge_base}') |
| 47 | + |
| 48 | +# collect commits: |
| 49 | +# - from merge_base to HEAD |
| 50 | +# - from merge_base to previous_version |
| 51 | +our_commits = set(subprocess.run(['git', 'log', '--format=%H', f'{merge_base}..HEAD'], check=True, capture_output=True) \ |
| 52 | + .stdout.decode().strip().split('\n')) |
| 53 | +if merge_base != previous_version: |
| 54 | + their_commits = subprocess.run(['git', 'log', '--format=%H', f'{merge_base}..{previous_version}'], check=True, capture_output=True) \ |
| 55 | + .stdout.decode().strip().split('\n') |
| 56 | +else: |
| 57 | + their_commits = [] |
| 58 | + |
| 59 | +for commit in their_commits: |
| 60 | + if commit in our_commits: |
| 61 | + our_commits.remove(commit) |
| 62 | + commit = repo.get(commit) |
| 63 | + lines = commit.message.split('\n') |
| 64 | + r = re.compile(r'(?:cherry picked|backported) from commit ([0-9a-f]+)') |
| 65 | + for line in lines: |
| 66 | + m = r.search(line) |
| 67 | + if m: |
| 68 | + if m.group(1) not in our_commits: |
| 69 | + print(f'WARN: Cherry pick {m.group(1)} not found in our commits') |
| 70 | + else: |
| 71 | + print(f'Cherry pick: {m.group(1)}') |
| 72 | + our_commits.remove(m.group(1)) |
| 73 | + |
| 74 | +our_commits = [repo.get(commit) for commit in our_commits] |
| 75 | +our_commits.sort(key=lambda x: x.commit_time) |
| 76 | + |
| 77 | +changelog_categories = { |
| 78 | + 'BugFix': 'Bug fixes', |
| 79 | + 'BuildFix': 'Build fixes', |
| 80 | + 'BuildChange': 'Build changes', |
| 81 | + 'NewFeature': 'New features', |
| 82 | + 'Deprecation': 'Deprecations', |
| 83 | + 'Uncategorized': 'Other changes', |
| 84 | +} |
| 85 | + |
| 86 | +changelogs = {category: [] for category in changelog_categories} |
| 87 | + |
| 88 | +for commit in our_commits: |
| 89 | + lines = commit.message.split('\n') |
| 90 | + related_issues = [] |
| 91 | + r_related = re.compile(r'(?:Fixes|Related|Related-to):?\s+(.+)') |
| 92 | + for line in lines: |
| 93 | + m = r_related.fullmatch(line) |
| 94 | + if m: |
| 95 | + for issue in m.group(1).split(' '): |
| 96 | + related_issues.append(int(issue[1:])) |
| 97 | + elif line.startswith('Fixes') or line.startswith('Related'): |
| 98 | + print(f'WARN: Possibly invalid issue reference: {line}') |
| 99 | + elif line.startswith('Merge pull request #'): |
| 100 | + related_issues.append(int(line.split()[3][1:])) |
| 101 | + |
| 102 | + changelog = [i for i, line in enumerate(lines) if line.startswith('ChangeLog:')] |
| 103 | + if len(changelog) > 1: |
| 104 | + print('WARN: Multiple Changelog lines') |
| 105 | + if not changelog: |
| 106 | + continue |
| 107 | + line = changelog[0] + 1 |
| 108 | + changelog = lines[changelog[0]].removeprefix('ChangeLog:') |
| 109 | + while line < len(lines) and lines[line].strip() != '': |
| 110 | + changelog += ' ' + lines[line] |
| 111 | + line += 1 |
| 112 | + cat, changelog = changelog.split(':', 1) |
| 113 | + cat = cat.strip() |
| 114 | + changelog = changelog.strip() |
| 115 | + if cat not in changelog_categories: |
| 116 | + print(f'WARN: Unknown category: {cat}') |
| 117 | + changelog = f'({cat}) {changelog}' |
| 118 | + cat = 'Uncategorized' |
| 119 | + if related_issues: |
| 120 | + changelog += f' ({" ".join(f"#{issue}" for issue in related_issues)})' |
| 121 | + |
| 122 | + changelogs[cat].append(changelog) |
| 123 | + print(f'Commit {commit.id}: {changelog} [{cat}]') |
| 124 | + |
| 125 | +# are we already on a tag? |
| 126 | +for ref in repo.references.iterator(pygit2.enums.ReferenceFilter.TAGS): |
| 127 | + if ref.shorthand.startswith('v') and ref.peel(pygit2.Commit) == repo.head.peel(pygit2.Commit): |
| 128 | + print(f'HEAD is already a release tag: {ref.shorthand}') |
| 129 | + sys.exit(1) |
| 130 | + |
| 131 | +with tempfile.TemporaryDirectory() as tempdir: |
| 132 | + # can this commit be built? |
| 133 | + try: |
| 134 | + subprocess.run(['meson', 'setup', tempdir], check=True) |
| 135 | + subprocess.run(['ninja', '-C', tempdir], check=True) |
| 136 | + except subprocess.CalledProcessError: |
| 137 | + print('This commit cannot be built') |
| 138 | + sys.exit(1) |
| 139 | + |
| 140 | + version = subprocess.run([Path(tempdir) / 'src' / 'picom', '--version'], capture_output=True, check=True).stdout.decode().strip() |
| 141 | + version = version.split(' ')[0] |
| 142 | + |
| 143 | + if not version.startswith('v'): |
| 144 | + print('Version should start with "v"') |
| 145 | + sys.exit(1) |
| 146 | + |
| 147 | +print(f'Version to be released is {version}') |
| 148 | +try: |
| 149 | + existing_tag = repo.references[f'refs/tags/{version}'] |
| 150 | + print(f'Tag {version} already exists') |
| 151 | + sys.exit(1) |
| 152 | +except KeyError: |
| 153 | + pass |
| 154 | + |
| 155 | +today = datetime.date.today() |
| 156 | +changelog = f'# {version} ({today.strftime("%Y-%b-%d")})\n' |
| 157 | +for category, changes in changelogs.items(): |
| 158 | + if changes: |
| 159 | + changelog += f'\n## {changelog_categories[category]}\n\n' |
| 160 | + for change in changes: |
| 161 | + changelog += f'* {change}\n' |
| 162 | +changelog += '\n' |
| 163 | + |
| 164 | +with open('CHANGELOG.md', 'r') as f: |
| 165 | + old = f.read() |
| 166 | + |
| 167 | +with open('CHANGELOG.md', 'w') as f: |
| 168 | + f.write(changelog + old) |
| 169 | + |
| 170 | +subprocess.run(['git', 'add', 'CHANGELOG.md'], check=True) |
| 171 | +subprocess.run(['git', 'commit', '-s', '-m', f'release: {version}'], check=True) |
| 172 | +subprocess.run(['git', 'tag', '-a', '-s', '-m', version, version], check=True) |
0 commit comments