Skip to content

Commit b7f289e

Browse files
committed
update release process to support multiple version
1 parent a16ac98 commit b7f289e

File tree

7 files changed

+383
-60
lines changed

7 files changed

+383
-60
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: 'Release branches'
2+
description: 'Determine branches for release & backport'
3+
inputs:
4+
major_version:
5+
description: 'The version as extracted from the package.json file'
6+
required: true
7+
latest_tag:
8+
description: 'The most recent tag published to the repository'
9+
required: true
10+
outputs:
11+
backport_source_branch:
12+
description: "The release branch for the given tag"
13+
value: ${{ steps.branches.outputs.backport_source_branch }}
14+
backport_target_branches:
15+
description: "JSON encoded list of branches to target with backports"
16+
value: ${{ steps.branches.outputs.backport_target_branches }}
17+
runs:
18+
using: "composite"
19+
steps:
20+
- id: branches
21+
run: |
22+
python ${{ github.action_path }}/release-branches.py \
23+
--major-version ${{ inputs.major_version }} \
24+
--latest-tag ${{ inputs.latest_tag }}
25+
shell: bash
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import argparse
2+
import os, json
3+
import subprocess
4+
5+
# Name of the remote
6+
ORIGIN = 'origin'
7+
8+
OLDEST_SUPPORTED_MAJOR_VERSION = 2
9+
10+
# Runs git with the given args and returns the stdout.
11+
# Raises an error if git does not exit successfully (unless passed
12+
# allow_non_zero_exit_code=True).
13+
def run_git(*args, allow_non_zero_exit_code=False):
14+
cmd = ['git', *args]
15+
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
16+
if not allow_non_zero_exit_code and p.returncode != 0:
17+
raise Exception(f'Call to {" ".join(cmd)} exited with code {p.returncode} stderr: {p.stderr.decode("ascii")}.')
18+
return p.stdout.decode('ascii')
19+
20+
def main():
21+
22+
parser = argparse.ArgumentParser()
23+
parser.add_argument("--major-version", required=True, type=str, help="The major version of the release")
24+
parser.add_argument("--latest-tag", required=True, type=str, help="The most recent tag published to the repository")
25+
args = parser.parse_args()
26+
27+
major_version = args.major_version
28+
latest_tag = args.latest_tag
29+
30+
print("major_version: " + major_version)
31+
print("latest_tag: " + latest_tag)
32+
33+
# If this is a primary release, we backport to all supported branches,
34+
# so we check whether the major_version taken from the package.json
35+
# is greater than or equal to the latest tag pulled from the repo.
36+
# For example...
37+
# 'v1' >= 'v2' is False # we're operating from an older release branch and should not backport
38+
# 'v2' >= 'v2' is True # the normal case where we're updating the current version
39+
# 'v3' >= 'v2' is True # in this case we are making the first release of a new major version
40+
consider_backports = ( major_version >= latest_tag.split(".")[0] )
41+
42+
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
43+
44+
f.write(f"backport_source_branch=releases/{major_version}\n")
45+
46+
backport_target_branches = []
47+
48+
if consider_backports:
49+
for i in range(int(major_version.strip("v"))-1, 0, -1):
50+
branch_name = f"releases/v{i}"
51+
if i >= OLDEST_SUPPORTED_MAJOR_VERSION:
52+
backport_target_branches.append(branch_name)
53+
54+
f.write("backport_target_branches="+json.dumps(backport_target_branches)+"\n")
55+
56+
if __name__ == "__main__":
57+
main()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: 'Prepare release job'
2+
description: 'Executed preparatory steps before update a release branch'
3+
4+
runs:
5+
using: "composite"
6+
steps:
7+
8+
- name: Dump environment
9+
run: env
10+
shell: bash
11+
12+
- name: Dump GitHub context
13+
env:
14+
GITHUB_CONTEXT: '${{ toJson(github) }}'
15+
run: echo "$GITHUB_CONTEXT"
16+
shell: bash
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v4
20+
with:
21+
python-version: 3.8
22+
23+
- name: Install dependencies
24+
run: |
25+
python -m pip install --upgrade pip
26+
pip install PyGithub==1.55 requests
27+
shell: bash
28+
29+
- name: Update git config
30+
run: |
31+
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
32+
git config --global user.name "github-actions[bot]"
33+
shell: bash

.github/update-release-branch.py

Lines changed: 141 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
1414
"""
1515

16-
SOURCE_BRANCH = 'main'
17-
TARGET_BRANCH = 'releases/v2'
1816

1917
# Name of the remote
2018
ORIGIN = 'origin'
@@ -34,7 +32,9 @@ def branch_exists_on_remote(branch_name):
3432
return run_git('ls-remote', '--heads', ORIGIN, branch_name).strip() != ''
3533

3634
# Opens a PR from the given branch to the target branch
37-
def open_pr(repo, all_commits, source_branch_short_sha, new_branch_name, conductor):
35+
def open_pr(
36+
repo, all_commits, source_branch_short_sha, new_branch_name, source_branch, target_branch,
37+
conductor, is_primary_release, conflicted_files):
3838
# Sort the commits into the pull requests that introduced them,
3939
# and any commits that don't have a pull request
4040
pull_requests = []
@@ -56,7 +56,7 @@ def open_pr(repo, all_commits, source_branch_short_sha, new_branch_name, conduct
5656

5757
# Start constructing the body text
5858
body = []
59-
body.append(f'Merging {source_branch_short_sha} into {TARGET_BRANCH}.')
59+
body.append(f'Merging {source_branch_short_sha} into {target_branch}.')
6060

6161
body.append('')
6262
body.append(f'Conductor for this PR is @{conductor}.')
@@ -79,20 +79,38 @@ def open_pr(repo, all_commits, source_branch_short_sha, new_branch_name, conduct
7979

8080
body.append('')
8181
body.append('Please do the following:')
82+
if len(conflicted_files) > 0:
83+
body.append(' - [ ] Ensure `package.json` file contains the correct version.')
84+
body.append(' - [ ] Add commits to this branch to resolve the merge conflicts ' +
85+
'in the following files:')
86+
body.extend([f' - [ ] `{file}`' for file in conflicted_files])
87+
body.append(' - [ ] Ensure another maintainer has reviewed the additional commits you added to this ' +
88+
'branch to resolve the merge conflicts.')
8289
body.append(' - [ ] Ensure the CHANGELOG displays the correct version and date.')
8390
body.append(' - [ ] Ensure the CHANGELOG includes all relevant, user-facing changes since the last release.')
84-
body.append(f' - [ ] Check that there are not any unexpected commits being merged into the {TARGET_BRANCH} branch.')
91+
body.append(f' - [ ] Check that there are not any unexpected commits being merged into the {target_branch} branch.')
8592
body.append(' - [ ] Ensure the docs team is aware of any documentation changes that need to be released.')
93+
94+
if not is_primary_release:
95+
body.append(' - [ ] Remove and re-add the "Update dependencies" label to the PR to trigger just this workflow.')
96+
body.append(' - [ ] Wait for the "Update dependencies" workflow to push a commit updating the dependencies.')
97+
body.append(' - [ ] Mark the PR as ready for review to trigger the full set of PR checks.')
98+
8699
body.append(' - [ ] Approve and merge this PR. Make sure `Create a merge commit` is selected rather than `Squash and merge` or `Rebase and merge`.')
87-
body.append(' - [ ] Merge the mergeback PR that will automatically be created once this PR is merged.')
88100

89-
title = f'Merge {SOURCE_BRANCH} into {TARGET_BRANCH}'
101+
if is_primary_release:
102+
body.append(' - [ ] Merge the mergeback PR that will automatically be created once this PR is merged.')
103+
body.append(' - [ ] Merge the v1 release PR that will automatically be created once this PR is merged.')
104+
105+
title = f'Merge {source_branch} into {target_branch}'
106+
labels = ['Update dependencies'] if not is_primary_release else []
90107

91108
# Create the pull request
92109
# PR checks won't be triggered on PRs created by Actions. Therefore mark the PR as draft so that
93110
# a maintainer can take the PR out of draft, thereby triggering the PR checks.
94-
pr = repo.create_pull(title=title, body='\n'.join(body), head=new_branch_name, base=TARGET_BRANCH, draft=True)
95-
print(f'Created PR #{pr.number}')
111+
pr = repo.create_pull(title=title, body='\n'.join(body), head=new_branch_name, base=target_branch, draft=True)
112+
pr.add_to_labels(*labels)
113+
print(f'Created PR #{str(pr.number)}')
96114

97115
# Assign the conductor
98116
pr.add_to_assignees(conductor)
@@ -102,10 +120,10 @@ def open_pr(repo, all_commits, source_branch_short_sha, new_branch_name, conduct
102120
# since the last release to the target branch.
103121
# This will not include any commits that exist on the target branch
104122
# that aren't on the source branch.
105-
def get_commit_difference(repo):
123+
def get_commit_difference(repo, source_branch, target_branch):
106124
# Passing split nothing means that the empty string splits to nothing: compare `''.split() == []`
107125
# to `''.split('\n') == ['']`.
108-
commits = run_git('log', '--pretty=format:%H', f'{ORIGIN}/{TARGET_BRANCH}..{ORIGIN}/{SOURCE_BRANCH}').strip().split()
126+
commits = run_git('log', '--pretty=format:%H', f'{ORIGIN}/{target_branch}..{ORIGIN}/{source_branch}').strip().split()
109127

110128
# Convert to full-fledged commit objects
111129
commits = [repo.get_commit(c) for c in commits]
@@ -182,6 +200,24 @@ def main():
182200
required=True,
183201
help='The nwo of the repository, for example github/codeql-action.'
184202
)
203+
parser.add_argument(
204+
'--source-branch',
205+
type=str,
206+
required=True,
207+
help='Source branch for release branch update.'
208+
)
209+
parser.add_argument(
210+
'--target-branch',
211+
type=str,
212+
required=True,
213+
help='Target branch for release branch update.'
214+
)
215+
parser.add_argument(
216+
'--is-primary-release',
217+
action='store_true',
218+
default=False,
219+
help='Whether this update is the primary release for the current major version.'
220+
)
185221
parser.add_argument(
186222
'--conductor',
187223
type=str,
@@ -191,18 +227,29 @@ def main():
191227

192228
args = parser.parse_args()
193229

230+
source_branch = args.source_branch
231+
target_branch = args.target_branch
232+
is_primary_release = args.is_primary_release
233+
194234
repo = Github(args.github_token).get_repo(args.repository_nwo)
195-
version = get_current_version()
235+
236+
# the target branch will be of the form releases/vN, where N is the major version number
237+
target_branch_major_version = target_branch.strip('releases/v')
238+
239+
# split version into major, minor, patch
240+
_, v_minor, v_patch = get_current_version().split('.')
241+
242+
version = f"{target_branch_major_version}.{v_minor}.{v_patch}"
196243

197244
# Print what we intend to go
198-
print(f'Considering difference between {SOURCE_BRANCH} and {TARGET_BRANCH}...')
199-
source_branch_short_sha = run_git('rev-parse', '--short', f'{ORIGIN}/{SOURCE_BRANCH}').strip()
200-
print(f'Current head of {SOURCE_BRANCH} is {source_branch_short_sha}.')
245+
print(f'Considering difference between {source_branch} and {target_branch}...')
246+
source_branch_short_sha = run_git('rev-parse', '--short', f'{ORIGIN}/{source_branch}').strip()
247+
print(f'Current head of {source_branch} is {source_branch_short_sha}.')
201248

202249
# See if there are any commits to merge in
203-
commits = get_commit_difference(repo=repo)
250+
commits = get_commit_difference(repo=repo, source_branch=source_branch, target_branch=target_branch)
204251
if len(commits) == 0:
205-
print(f'No commits to merge from {SOURCE_BRANCH} to {TARGET_BRANCH}.')
252+
print(f'No commits to merge from {source_branch} to {target_branch}.')
206253
return
207254

208255
# The branch name is based off of the name of branch being merged into
@@ -220,17 +267,81 @@ def main():
220267
# Create the new branch and push it to the remote
221268
print(f'Creating branch {new_branch_name}.')
222269

223-
# If we're performing a standard release, there won't be any new commits on the target branch,
224-
# as these will have already been merged back into the source branch. Therefore we can just
225-
# start from the source branch.
226-
run_git('checkout', '-b', new_branch_name, f'{ORIGIN}/{SOURCE_BRANCH}')
270+
# The process of creating the v{Older} release can run into merge conflicts. We commit the unresolved
271+
# conflicts so a maintainer can easily resolve them (vs erroring and requiring maintainers to
272+
# reconstruct the release manually)
273+
conflicted_files = []
274+
275+
if not is_primary_release:
276+
277+
# the source branch will be of the form releases/vN, where N is the major version number
278+
source_branch_major_version = source_branch.strip('releases/v')
279+
280+
# If we're performing a backport, start from the target branch
281+
print(f'Creating {new_branch_name} from the {ORIGIN}/{target_branch} branch')
282+
run_git('checkout', '-b', new_branch_name, f'{ORIGIN}/{target_branch}')
283+
284+
# Revert the commit that we made as part of the last release that updated the version number and
285+
# changelog to refer to {older}.x.x variants. This avoids merge conflicts in the changelog and
286+
# package.json files when we merge in the v{latest} branch.
287+
# This commit will not exist the first time we release the v{N-1} branch from the v{N} branch, so we
288+
# use `git log --grep` to conditionally revert the commit.
289+
print('Reverting the version number and changelog updates from the last release to avoid conflicts')
290+
vOlder_update_commits = run_git('log', '--grep', '^Update version and changelog for v', '--format=%H').split()
291+
292+
if len(vOlder_update_commits) > 0:
293+
print(f' Reverting {vOlder_update_commits[0]}')
294+
# Only revert the newest commit as older ones will already have been reverted in previous
295+
# releases.
296+
run_git('revert', vOlder_update_commits[0], '--no-edit')
297+
298+
# Also revert the "Update checked-in dependencies" commit created by Actions.
299+
update_dependencies_commit = run_git('log', '--grep', '^Update checked-in dependencies', '--format=%H').split()[0]
300+
# TODO: why is this failing for the v2 branch currently...?
301+
print(f' Reverting {update_dependencies_commit}')
302+
run_git('revert', update_dependencies_commit, '--no-edit')
303+
304+
else:
305+
print(' Nothing to revert.')
306+
307+
print(f'Merging {ORIGIN}/{source_branch} into the release prep branch')
308+
# Commit any conflicts (see the comment for `conflicted_files`)
309+
run_git('merge', f'{ORIGIN}/{source_branch}', allow_non_zero_exit_code=True)
310+
conflicted_files = run_git('diff', '--name-only', '--diff-filter', 'U').splitlines()
311+
if len(conflicted_files) > 0:
312+
run_git('add', '.')
313+
run_git('commit', '--no-edit')
314+
315+
# Migrate the package version number from a vLatest version number to a vOlder version number
316+
print(f'Setting version number to {version}')
317+
subprocess.check_output(['npm', 'version', version, '--no-git-tag-version'])
318+
run_git('add', 'package.json', 'package-lock.json')
319+
320+
# Migrate the changelog notes from v2 version numbers to v1 version numbers
321+
print(f'Migrating changelog notes from v{source_branch_major_version} to v{target_branch_major_version}')
322+
subprocess.check_output(['sed', '-i', f's/^## {source_branch_major_version}\./## {target_branch_major_version}./g', 'CHANGELOG.md'])
323+
324+
# Remove changelog notes from all versions that do not apply to the vOlder branch
325+
print(f'Removing changelog notes that do not apply to v{target_branch_major_version}')
326+
for v in range(int(target_branch_major_version)+1, int(source_branch_major_version)+1):
327+
print(f'Removing changelog notes that are tagged [v{v}+ only\]')
328+
subprocess.check_output(['sed', '-i', f'/^- \[v{v}+ only\]/d', 'CHANGELOG.md'])
329+
330+
# Amend the commit generated by `npm version` to update the CHANGELOG
331+
run_git('add', 'CHANGELOG.md')
332+
run_git('commit', '-m', f'Update version and changelog for v{version}')
333+
else:
334+
# If we're performing a standard release, there won't be any new commits on the target branch,
335+
# as these will have already been merged back into the source branch. Therefore we can just
336+
# start from the source branch.
337+
run_git('checkout', '-b', new_branch_name, f'{ORIGIN}/{source_branch}')
227338

228-
print('Updating changelog')
229-
update_changelog(version)
339+
print('Updating changelog')
340+
update_changelog(version)
230341

231-
# Create a commit that updates the CHANGELOG
232-
run_git('add', 'CHANGELOG.md')
233-
run_git('commit', '-m', f'Update changelog for v{version}')
342+
# Create a commit that updates the CHANGELOG
343+
run_git('add', 'CHANGELOG.md')
344+
run_git('commit', '-m', f'Update changelog for v{version}')
234345

235346
run_git('push', ORIGIN, new_branch_name)
236347

@@ -240,7 +351,11 @@ def main():
240351
commits,
241352
source_branch_short_sha,
242353
new_branch_name,
354+
source_branch=source_branch,
355+
target_branch=target_branch,
243356
conductor=args.conductor,
357+
is_primary_release=is_primary_release,
358+
conflicted_files=conflicted_files
244359
)
245360

246361
if __name__ == '__main__':

0 commit comments

Comments
 (0)