mirror of
https://github.com/signalapp/libsignal.git
synced 2026-04-26 01:35:22 +02:00
507 lines
22 KiB
Python
Executable File
507 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""
|
|
This script automates the rote work to prepare for a release, as specified in RELEASE.md:
|
|
|
|
1) Checks that "Slow Tests" and "Build and Test" (CI) have succeeded on the current commit.
|
|
2) Creates a new annotated tag for the current commit, based on the release version found in RELEASE_NOTES.md.
|
|
3) Attempts to parse the Java code size from the GitHub Actions logs and appends that value (along with the version)
|
|
to java/code_size.json.
|
|
4) Resets RELEASE_NOTES.md to the next presumed version (e.g., incrementing PATCH).
|
|
5) Updates the version throughout the repository to that new version.
|
|
6) Commits these changes in a single "Reset for version X" commit.
|
|
|
|
Usage:
|
|
1) Ensure you are on the commit you wish to mark as a release
|
|
and that both Build and Test and Slow Tests have passed on that commit.
|
|
2) Run this script: ./prepare_release.py
|
|
3) Push the tag, the tag's commit, and the version reset/update commit to the proper remotes.
|
|
|
|
Optional arguments:
|
|
--skip-main-branch-check Skip the check that ensures we are on 'main' branch.
|
|
--skip-ci-tests-pass-check Skip the check that continous integration tests have passed on this commit.
|
|
--skip-worktree-clean-check Skip the check that the working tree is clean before running this script. Not recommended.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from pathlib import Path
|
|
from shutil import which
|
|
|
|
|
|
class ReleaseFailedException(Exception):
|
|
pass
|
|
|
|
|
|
# Before we make any changes to the working tree/repository state, we add the command to rollback that change
|
|
# to this list. If we encounter an error, we execute these commands in order to return the repository to its
|
|
# original state.
|
|
# Each of these commands should be independent of each other, as if one of them fails, we will still try to
|
|
# execute the rest while performing a rollback.
|
|
on_failure_rollback_commands: list[list[str]] = []
|
|
|
|
# The following ids can be obtained by running:
|
|
# gh workflow list
|
|
# or, more programmatically friendly,
|
|
# gh workflow list --json id,name
|
|
BUILD_AND_TEST_WORKFLOW_ID = 6587503
|
|
SLOW_TEST_WORKFLOW_ID = 30989402
|
|
RELEASE_WORKFLOW_IDS = [
|
|
10143338, # Node
|
|
15104239, # Android
|
|
46287777, # iOS
|
|
]
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description='Automates the release preparation workflow.'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--skip-main-branch-check',
|
|
action='store_true',
|
|
help="Skip the check to ensure the current branch is 'main'."
|
|
)
|
|
parser.add_argument(
|
|
'--skip-ci-tests-pass-check',
|
|
action='store_true',
|
|
help='Skip the check that continous integration tests have passed on this commit.'
|
|
)
|
|
parser.add_argument(
|
|
'--skip-worktree-clean-check',
|
|
action='store_true',
|
|
help='Skip the check that the working tree is clean before running this script. Not recommended.'
|
|
)
|
|
parser.add_argument(
|
|
'-n', '--dry-run',
|
|
action='store_true',
|
|
help='Skip any steps that would actually mutate the repository, for testing purposes.'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
prepare_release(skip_main_check=args.skip_main_branch_check, skip_tests_pass_check=args.skip_ci_tests_pass_check, skip_worktree_clean_check=args.skip_worktree_clean_check, dry_run=args.dry_run)
|
|
exit_code = 0
|
|
except subprocess.CalledProcessError as e:
|
|
print(f'Error: command {e.cmd} exited with status {e.returncode}.')
|
|
exit_code = e.returncode
|
|
except ReleaseFailedException:
|
|
# We printed out the user friendly error before we threw the exception.
|
|
exit_code = 1
|
|
except KeyboardInterrupt:
|
|
print('User interrupted execution! Aborting...')
|
|
exit_code = 1
|
|
except Exception as ex:
|
|
traceback.print_exception(None, value=ex, tb=ex.__traceback__)
|
|
exit_code = 1
|
|
|
|
if exit_code != 0:
|
|
for rollback_command in on_failure_rollback_commands:
|
|
try:
|
|
run_command(rollback_command)
|
|
except subprocess.CalledProcessError:
|
|
rollback_command_str = ' '.join(rollback_command)
|
|
print(f'Unable to execute `{rollback_command_str}` after failure, working tree or repository may still be in dirty state.')
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
def get_workflow_name_mapping(repo_name: str) -> dict[int, str]:
|
|
"""Gets a mapping of workflow ids to their names from github."""
|
|
list_workflows_cmd = [
|
|
'gh', 'workflow', 'list',
|
|
'--repo', f'signalapp/{repo_name}',
|
|
'--json', 'name,id'
|
|
]
|
|
|
|
raw_json = run_command(list_workflows_cmd)
|
|
data = json.loads(raw_json)
|
|
return {d['id']: d['name'] for d in data}
|
|
|
|
|
|
def prepare_release(*, skip_main_check: bool = False, skip_tests_pass_check: bool = False, skip_worktree_clean_check: bool = False, dry_run: bool = False) -> None:
|
|
setup_and_check_env(skip_main_check, skip_worktree_clean_check)
|
|
REPO_NAME = get_repo_name()
|
|
RELEASE_NOTES_FILE_PATH = Path('RELEASE_NOTES.md')
|
|
|
|
# Obtain the workflow ids once
|
|
workflows = get_workflow_name_mapping(REPO_NAME)
|
|
|
|
# Get the commit sha of the commit we intend to mark as the release.
|
|
head_sha = run_command(['git', 'rev-parse', 'HEAD']).strip()
|
|
short_sha = head_sha[:9]
|
|
print(f'Searching for GitHub Actions runs for commit {short_sha}...')
|
|
|
|
# Release Step 1: Ensure that CI tests pass!
|
|
# - Check GitHub to see if the latest commit has all tests passing, including the nightly "Slow Tests".
|
|
# - If not, fix the tests before releasing!
|
|
# If needed, you can run the Slow Tests manually under the repository Actions tab on GitHub.
|
|
# You should run the Slow Tests before running this script.
|
|
if not skip_tests_pass_check:
|
|
build_and_test_run_id = check_workflow_success(REPO_NAME, workflows[BUILD_AND_TEST_WORKFLOW_ID], head_sha)
|
|
slow_test_run_id = check_workflow_success(REPO_NAME, workflows[SLOW_TEST_WORKFLOW_ID], head_sha)
|
|
|
|
print('Found GitHub Actions runs! They look good, but please double check manually as well.')
|
|
print(f'Build and Test: https://github.com/signalapp/{REPO_NAME}/actions/runs/{build_and_test_run_id}')
|
|
print(f'Slow Tests: https://github.com/signalapp/{REPO_NAME}/actions/runs/{slow_test_run_id}')
|
|
else:
|
|
print('Skipping checking that tests pass!')
|
|
print('Be sure to manually check for passing test runs at:')
|
|
print(f' https://github.com/signalapp/{REPO_NAME}/actions/workflows/build_and_test.yml')
|
|
print(f' https://github.com/signalapp/{REPO_NAME}/actions/workflows/slow_tests.yml')
|
|
|
|
# Release Step 2: Tag the release commit.
|
|
# - Look up the next version number vX.Y.Z according to our semantic versioning scheme, which
|
|
# is manually adjusted as needed in RELEASE_NOTES.md
|
|
# - Tag the release commit with an annotated tag titled with that version number and a message
|
|
# containing the release notes summarizing the notable changes since the last release from
|
|
# RELEASE_NOTES.md
|
|
# - Prompt the user to give the Release Notes a final human review. The expected format of the
|
|
# release notes is specified in RELEASE.md
|
|
head_release_version = tag_new_release(RELEASE_NOTES_FILE_PATH, dry_run=dry_run)
|
|
|
|
# Release Step 3: Prepare the repository for the next version
|
|
#
|
|
# Step 3, Stage 1: Update the version number throughout the repository to match the next presumed version
|
|
#
|
|
# We already have a script that does most of this, update_versions.py. We run it and pass the presumed next version
|
|
# number as an argument.
|
|
#
|
|
# We also run cargo check to make sure the version number in Cargo.lock is updated.
|
|
|
|
# We always start a release by presuming the next release will not be a breaking one. So, if the last release was v0.x.y, the next release
|
|
# is always presumed to be v0.x.(y+1) until a breaking change is merged.
|
|
major, minor, patch = parse_version(head_release_version)
|
|
next_patch = patch + 1
|
|
presumptive_next_version = f'v{major}.{minor}.{next_patch}'
|
|
|
|
if not skip_worktree_clean_check:
|
|
# Check again that the worktree is clean, just to be doubly sure we don't lose data.
|
|
run_command(['git', 'diff-index', '--quiet', 'HEAD', '--'])
|
|
on_failure_rollback_commands.append(['git', 'reset', '--hard'])
|
|
|
|
if not dry_run:
|
|
run_command(['./bin/update_versions.py', presumptive_next_version])
|
|
# Use subprocess.run() directly here to pass through `cargo check` output, because it may take a while.
|
|
subprocess.run(['cargo', 'check', '--workspace', '--all-features'], check=True)
|
|
|
|
# Step 3, Stage 2: Record the code size of the just cut release in code_size.json
|
|
# Get the cannonical computed code size for the Java library on the commit for the tagged release from GitHub
|
|
# Actions, and then add it to a new entry in java/code_size.json.
|
|
#
|
|
# The version for the new entry is the same as the version for the release that was just tagged, i.e. v0.x.y, not v0.x.(y+1).
|
|
|
|
# The "Build and Test" log contains the output of the 'java/check_code_size.py', which records the code size.
|
|
# So, we try to find the "Build and Test" log for this commit, but one may not exist.
|
|
# If it doesn't exist, we prompt the user to look it up manually.
|
|
if not skip_tests_pass_check:
|
|
print(f'Extracting Java library size from GitHub Actions run (ID: {build_and_test_run_id})...')
|
|
build_and_test_log = run_command([
|
|
'gh', 'run', 'view', str(build_and_test_run_id),
|
|
'--repo', f'signalapp/{REPO_NAME}',
|
|
'--log'
|
|
])
|
|
else:
|
|
build_and_test_log = ''
|
|
|
|
pattern = r'update code_size\.json with (\d+)' # Matches output of print_size_for_release in check_code_size.py
|
|
match = re.search(pattern, build_and_test_log)
|
|
if match:
|
|
java_code_size_int = int(match.group(1))
|
|
else:
|
|
print('Could not get logs to find Java code size automatically.')
|
|
print('This might be due to a known gh cli bug: https://github.com/cli/cli/issues/5011')
|
|
print(f"You'll have to find it manually in the list of runs: https://github.com/signalapp/{REPO_NAME}/actions/workflows/build_and_test.yml")
|
|
input_str = input('Please lookup the code size manually and enter it: ')
|
|
java_code_size_int = int(input_str)
|
|
|
|
code_size_file = Path('java/code_size.json')
|
|
append_code_size(code_size_file, head_release_version, java_code_size_int, dry_run=dry_run)
|
|
|
|
# Step 3, Stage 3: Clear RELEASE_NOTES.md, and update it with the presumptive next version number
|
|
#
|
|
# As we work, we keep updated running release notes for *just* the next release in RELEASE_NOTES.md. Because we just made a release that
|
|
# included all the changes previously in RELEASE_NOTES.md, it's now time to reset RELEASE_NOTES.md
|
|
#
|
|
# Thus, we edit RELEASE_NOTES.md so that it just contains the next version number on its own line, followed by one newline.
|
|
if not dry_run:
|
|
with RELEASE_NOTES_FILE_PATH.open('w', encoding='utf-8') as f:
|
|
f.write(presumptive_next_version + '\n\n')
|
|
|
|
# Step 3, Stage 4: Commit all changes in a single commit!
|
|
if not dry_run:
|
|
new_release_version = get_first_line_of_file(RELEASE_NOTES_FILE_PATH)
|
|
run_command([
|
|
'git', 'commit', '-am', f'Reset for version {new_release_version}'
|
|
])
|
|
|
|
upstream = os.environ.get('LIBSIGNAL_UPSTREAM_REMOTE') or '<remote>'
|
|
origin = os.environ.get('LIBSIGNAL_ORIGIN_REMOTE') or '<working-remote>'
|
|
|
|
print('\nRelease process complete!')
|
|
print('Next steps:')
|
|
print('1) Verify the GitHub Actions runs above passed.')
|
|
print('2) If they passed, push to the proper remote(s), e.g.:')
|
|
print(f'\tgit push {upstream} HEAD~1:main {head_release_version} && git push {origin} HEAD:main {head_release_version}')
|
|
print('3) To review the reset commit, you can run:')
|
|
print('\tgit show')
|
|
print('4) To run post-release actions, you can run:')
|
|
for id in RELEASE_WORKFLOW_IDS:
|
|
name = workflows[id]
|
|
raw_field = ' --raw-field dry_run=true' if dry_run else ''
|
|
print(f'\tgh workflow run "{name}" --repo signalapp/libsignal --ref {head_release_version}{raw_field}')
|
|
|
|
|
|
def setup_and_check_env(skip_main_check: bool = False, skip_worktree_clean_check: bool = False) -> None:
|
|
"""
|
|
Checks release environment pre-conditions.
|
|
Throws on failure.
|
|
"""
|
|
# We change into the repo root dir so we can use root-relative paths throughout
|
|
# the script. This matches the convention in other scripts, like update_versions.py.
|
|
repo_dir_path = run_command(['git', 'rev-parse', '--show-toplevel'])
|
|
os.chdir(repo_dir_path)
|
|
|
|
# We need to be authenticated with GitHub to fetch Actions run results from
|
|
# the API. We use these results to check that tests are passing, and to fetch
|
|
# the Java library code size from the Java test run logs.
|
|
# We opt to check this up front now and fail early, to try to minimize failures
|
|
# part way through the script that may leave the repository in a weird state.
|
|
check_gh_installed_and_authed()
|
|
|
|
# Optionally, we check to make sure we are on main as a convenience.
|
|
# Some people prefer instead to make this commit on a different branch, and
|
|
# then to 'git push <origin> HEAD:main', so we accomodate that with an opt-out.
|
|
if not skip_main_check:
|
|
current_branch = run_command(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).strip()
|
|
if current_branch != 'main':
|
|
print(f"Error: You are on branch '{current_branch}'.")
|
|
print("Please switch to 'main' or add the '--skip-main-branch-check' flag and then try again.")
|
|
raise ReleaseFailedException
|
|
|
|
if not skip_worktree_clean_check:
|
|
try:
|
|
run_command(['git', 'diff-index', '--quiet', 'HEAD', '--'])
|
|
except subprocess.CalledProcessError:
|
|
print('Error: Git working tree is not clean! This can cause unexpected behavior, as this script commits to Git.')
|
|
print('Please stash or commit your changes.')
|
|
print('You can also pass `--skip-worktree-clean-check` and try again to bypass this check, but this will result in')
|
|
print('any changes in your worktree being comitted to Git as part of the release, and is thus not recommended.')
|
|
raise ReleaseFailedException
|
|
|
|
if not skip_worktree_clean_check:
|
|
try:
|
|
run_command(['git', 'diff-index', '--quiet', 'HEAD', '--'])
|
|
except subprocess.CalledProcessError:
|
|
print('Error: Git working tree is not clean! This can cause unexpected behavior, as this script commits to Git.')
|
|
print('Please stash or commit your changes.')
|
|
print('You can also pass `--skip-worktree-clean-check` and try again to bypass this check, but this will result in')
|
|
print('any changes in your worktree being comitted to Git as part of the release, and is thus not recommended.')
|
|
sys.exit(1)
|
|
|
|
|
|
def tag_new_release(release_notes_file_path: Path, *, dry_run: bool) -> str:
|
|
if not release_notes_file_path.is_file():
|
|
print(f'Error: {release_notes_file_path} not found. Cannot proceed with release.')
|
|
raise ReleaseFailedException
|
|
|
|
# Read the top line of RELEASE_NOTES.md for the release version
|
|
head_release_version = get_first_line_of_file(release_notes_file_path)
|
|
|
|
if dry_run:
|
|
print(f'The release version is: {head_release_version}. [Normally the tag step would happen here.]')
|
|
return head_release_version
|
|
|
|
print('Opening an editor to create an annotated tag for this release.')
|
|
print('Please review and edit the release notes as needed.')
|
|
print('Once they look good, save and exit the editor to finalize the tag.\n')
|
|
time.sleep(5)
|
|
|
|
# Tag the release (and open an editor for the user)
|
|
# NB: We call subprocess.run() directly rather than run_command so we don't redirect stdin/stdout.
|
|
subprocess.run(
|
|
['git', 'tag', '--annotate', '--force', '--edit', head_release_version, '-F', str(release_notes_file_path)],
|
|
check=True
|
|
)
|
|
on_failure_rollback_commands.append(['git', 'tag', '-d', head_release_version])
|
|
print(f'Tagged new release: {head_release_version}')
|
|
return head_release_version
|
|
|
|
|
|
def get_repo_name() -> str:
|
|
# Some devs store the repo as "origin" remote, others store it as "private"
|
|
for remote in ('private', 'origin'):
|
|
try:
|
|
remote_url = run_command(['git', 'remote', 'get-url', remote], print_error=False).strip()
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
else:
|
|
break
|
|
else:
|
|
raise RuntimeError('Could not find a valid remote (origin or private).')
|
|
|
|
repo = remote_url.rsplit('/', 1)[-1]
|
|
if repo.endswith('.git'):
|
|
repo = repo[:-4]
|
|
return repo
|
|
|
|
|
|
def check_gh_installed_and_authed() -> None:
|
|
"""
|
|
Checks that the GitHub CLI ('gh') is installed and the user is authenticated.
|
|
Throws ReleaseFailedException if gh is not installed or authenticated.
|
|
"""
|
|
if which('gh') is None:
|
|
print('Error: GitHub CLI (gh) is not installed. Please install it and re-run.')
|
|
raise ReleaseFailedException
|
|
|
|
auth_status = subprocess.run(
|
|
['gh', 'auth', 'status'],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL
|
|
)
|
|
if auth_status.returncode != 0:
|
|
print("You are not logged into GitHub CLI. Please run 'gh auth login' and re-run this script.")
|
|
raise ReleaseFailedException
|
|
|
|
|
|
def run_command(cmd: list[str], print_error: bool = True) -> str:
|
|
"""
|
|
Runs a shell command and returns its stdout as a string.
|
|
If check=True, raises a CalledProcessError for non-zero exit codes.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=True
|
|
)
|
|
return result.stdout.strip()
|
|
except subprocess.CalledProcessError as e:
|
|
if print_error:
|
|
print(f'Error while running command: {cmd}')
|
|
if e.stdout:
|
|
print('STDOUT:', e.stdout)
|
|
if e.stderr:
|
|
print('STDERR:', e.stderr)
|
|
raise
|
|
|
|
|
|
def check_workflow_success(repo_name: str, workflow_name: str, head_sha: str) -> int:
|
|
"""
|
|
Checks if a GitHub Actions workflow (workflow_name) has a run on HEAD (head_sha)
|
|
that completed successfully. Returns the run ID if found and successful;
|
|
otherwise prints an error and throws an exception.
|
|
"""
|
|
run_search_limit = '100'
|
|
list_cmd = [
|
|
'gh', 'run', 'list',
|
|
'--repo', f'signalapp/{repo_name}',
|
|
'--workflow', workflow_name,
|
|
'--limit', run_search_limit,
|
|
'--json', 'databaseId,headSha,status,conclusion'
|
|
]
|
|
|
|
raw_json = run_command(list_cmd)
|
|
runs_data = json.loads(raw_json)
|
|
|
|
matching_runs = [rd for rd in runs_data if rd['headSha'] == head_sha]
|
|
if not matching_runs:
|
|
print(f"Error: Could not find a matching '{workflow_name}' run for commit {head_sha}.")
|
|
print('Make sure CI has run successfully on the current commit before releasing.')
|
|
if workflow_name == 'Slow Tests':
|
|
print('Note that Slow Tests do not run automatically.')
|
|
print(f'You must kick them off automatically at: https://github.com/signalapp/{repo_name}/actions/workflows/slow_tests.yml')
|
|
print('Or by running')
|
|
print(f'\tgh workflow run "{workflow_name}" --repo signalapp/{repo_name} --ref main')
|
|
print('If tests have actually passed, you can skip this check by re-running with --skip-ci-tests-pass-check')
|
|
raise ReleaseFailedException
|
|
|
|
# Sort by run ID and pick the lowest
|
|
# NB: I opted to pick the lowest one, because as the first, it is less likely to be a re-run.
|
|
matching_runs.sort(key=lambda x: x['databaseId'])
|
|
selected_run_id = int(matching_runs[0]['databaseId'])
|
|
|
|
run_view_cmd = [
|
|
'gh', 'run', 'view', str(selected_run_id),
|
|
'--repo', f'signalapp/{repo_name}',
|
|
'--json', 'status,conclusion'
|
|
]
|
|
run_view_json = run_command(run_view_cmd)
|
|
try:
|
|
view_data = json.loads(run_view_json)
|
|
except json.JSONDecodeError:
|
|
print(f'Error: Could not parse JSON for run {selected_run_id}.')
|
|
raise ReleaseFailedException
|
|
|
|
status = view_data.get('status')
|
|
conclusion = view_data.get('conclusion')
|
|
if status != 'completed' or conclusion != 'success':
|
|
print(f"Error: '{workflow_name}' did not succeed (status={status}, conclusion={conclusion}).")
|
|
print('Please ensure all CI checks have passed before releasing.')
|
|
print('You can watch the run using:')
|
|
print(f'\tgh run watch {selected_run_id}')
|
|
raise ReleaseFailedException
|
|
|
|
return selected_run_id
|
|
|
|
|
|
def parse_version(version_str: str) -> tuple[int, int, int]:
|
|
"""
|
|
Given a string in the form 'vMAJOR.MINOR.PATCH',
|
|
returns (MAJOR, MINOR, PATCH) as integers.
|
|
"""
|
|
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)$', version_str.strip())
|
|
if not match:
|
|
print(f"Error: version string '{version_str}' is not in 'vMAJOR.MINOR.PATCH' format.")
|
|
raise ValueError
|
|
major, minor, patch = match.groups()
|
|
assert int(major) == 0, 'Major version should always be zero, because we never promise stability to external users'
|
|
return int(major), int(minor), int(patch)
|
|
|
|
|
|
def get_first_line_of_file(filepath: Path) -> str:
|
|
"""
|
|
Returns the first line of the given file (stripped).
|
|
Throws on failure
|
|
"""
|
|
if not filepath.is_file():
|
|
print(f'Error: {filepath} not found.')
|
|
raise FileNotFoundError
|
|
with filepath.open('r', encoding='utf-8') as f:
|
|
return f.readline().strip()
|
|
|
|
|
|
def append_code_size(code_size_file: Path, version: str, code_size: int, *, dry_run: bool) -> None:
|
|
"""
|
|
Appends an object of the form { 'version': <version>, 'size': <code_size> }
|
|
to an existing JSON array in code_size_file.
|
|
Throws an exception if file not found or unable to load JSON.
|
|
"""
|
|
with code_size_file.open('r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
new_entry = {'version': version, 'size': code_size}
|
|
data.append(new_entry)
|
|
|
|
if dry_run:
|
|
print(json.dumps(new_entry))
|
|
return
|
|
|
|
with code_size_file.open('w', encoding='utf-8') as f:
|
|
json.dump(data, f, indent=2)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|