From 67fd10a830a635a66c551b52d10993865c9d81d1 Mon Sep 17 00:00:00 2001 From: Hind-M <70631848+Hind-M@users.noreply.github.com> Date: Thu, 8 Feb 2024 09:35:18 +0100 Subject: [PATCH] Automate releases (`CHANGELOG.md` updating) (#3179) * Add script to automate updating CHANGELOG.md for releasing * Add PRs label check workflow --- .github/workflows/label_check.yml | 43 ++++++++ releaser.py | 4 +- update_changelog.py | 161 ++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/label_check.yml create mode 100644 update_changelog.py diff --git a/.github/workflows/label_check.yml b/.github/workflows/label_check.yml new file mode 100644 index 0000000000..e298da71a5 --- /dev/null +++ b/.github/workflows/label_check.yml @@ -0,0 +1,43 @@ +name: Check release label + +on: + pull_request: + types: + - synchronize + - opened + - reopened + - labeled + - unlabeled + +jobs: + label_check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Check labels + run: | + NUMBER_OF_LABELS=$(jq '.pull_request.labels | length' "$GITHUB_EVENT_PATH") + if [ $NUMBER_OF_LABELS -eq 0 ]; then + echo "PR has no labels. Please add at least one label of release type." + exit 1 + fi + + RELEASE_LABELS=("release::enhancements" "release::bug_fixes" "release::ci_docs") + PR_LABELS=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH") + NB_RELEASE_LABELS=0 + + for LABEL in $PR_LABELS; do + if [[ " ${RELEASE_LABELS[@]} " =~ " ${LABEL} " ]]; then + NB_RELEASE_LABELS=$((NB_RELEASE_LABELS+1)) + fi + done + + if [ $NB_RELEASE_LABELS -eq 0 ]; then + echo "PR has no release labels. Please add a label of release type." + exit 1 + elif [ $NB_RELEASE_LABELS -gt 1 ]; then + echo "PR has multiple release labels. Please remove all but one label." + exit 1 + fi diff --git a/releaser.py b/releaser.py index 93fb7e09c3..273ba8c674 100644 --- a/releaser.py +++ b/releaser.py @@ -1,4 +1,6 @@ -# script to release any of the mamba packages +# Script to release any of the mamba packages +# Please refer to `update_changelog.py` for more info about the release process + import copy import datetime import re diff --git a/update_changelog.py b/update_changelog.py new file mode 100644 index 0000000000..57a3c89355 --- /dev/null +++ b/update_changelog.py @@ -0,0 +1,161 @@ +# Script to update `CHANGELOG.md` in order to release any of the mamba packages + +# Steps: + +# 1. Run this script to update the root `CHANGELOG.md` file by giving the date of +# the last release as input (cf. last date shown at the top of the file for reference) +# or any other starting date that may be relevant for the release, +# and the release version to be made. + +# 2. If you are happy with the changes, run `releaser.py` to update the versions and +# corresponding nested `CHANGELOG.md` files. + +# N.B If the release is to be a pre-release (alpha,...), the versions should not be updated in `.py` and `.h` files. +# Only the `CHANGELOG.md` files should be modified. +# If otherwise, please revert the corresponding files if modified by the script. + +# 3. Follow the steps described in the `releaser.py` output. + +from datetime import date + +import json +import re +import subprocess + + +def validate_date(date_str): + try: + date.fromisoformat(date_str) + except ValueError: + raise ValueError("Incorrect date format, should be YYYY-MM-DD") + + +def subprocess_run(*args: str, **kwargs) -> str: + """Execute a command in a subprocess while properly capturing stderr in exceptions.""" + try: + p = subprocess.run( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, **kwargs + ) + except subprocess.CalledProcessError as e: + print(f"Command {args} failed with stderr: {e.stderr.decode()}") + print(f"Command {args} failed with stdout: {e.stdout.decode()}") + raise e + return p.stdout + + +def append_to_file(ctgr_name, prs, out_file): + out_file.write("\n{}:\n\n".format(ctgr_name)) + for pr in prs: + # Author + pr_author_cmd = "gh pr view {} --json author".format(pr) + author_login = dict(json.loads(subprocess_run(*pr_author_cmd.split()).decode("utf-8")))[ + "author" + ]["login"] + # Title + pr_title_cmd = "gh pr view {} --json title".format(pr) + title = dict(json.loads(subprocess_run(*pr_title_cmd.split()).decode("utf-8")))["title"] + # URL + pr_url_cmd = "gh pr view {} --json url".format(pr) + url = dict(json.loads(subprocess_run(*pr_url_cmd.split()).decode("utf-8")))["url"] + # Files + pr_files_cmd = "gh pr view {} --json files".format(pr) + files = dict(json.loads(subprocess_run(*pr_files_cmd.split()).decode("utf-8")))["files"] + ref_mamba_pkgs = ["libmamba/", "libmambapy/", "micromamba/"] + concerned_pkgs = set() + for f in files: + for ref_pkg in ref_mamba_pkgs: + if f["path"].startswith(ref_pkg): + concerned_pkgs.add(ref_pkg) + + if (sorted(ref_mamba_pkgs) == sorted(concerned_pkgs)) or (len(concerned_pkgs) == 0): + concerned_pkgs = ["all/"] + # Write in file + out_file.write( + "- [{}] {} by @{} in {}\n".format( + (", ".join([pkg[:-1] for pkg in concerned_pkgs])), title, author_login, url + ) + ) + + +def main(): + commits_starting_date = input( + "Enter the starting date of commits to be included in the release in the format YYYY-MM-DD: " + ) + validate_date(commits_starting_date) + release_version = input("Enter the version to be released: ") + + # Get commits to include in the release + log_cmd = "git log --since=" + commits_starting_date + commits = subprocess_run(*log_cmd.split()).decode("utf-8") + + # Create the regular expression pattern + opening_char = "(#" + closing_char = ")" + pattern = re.compile(re.escape(opening_char) + "(.*?)" + re.escape(closing_char)) + + # Get the PRs numbers + prs_nbrs = re.findall(pattern, commits) + + # Make three lists to categorize PRs: "Enhancements", "Bug fixes" and "CI fixes and doc" + enhancements_prs = [] # release::enhancements + bug_fixes_prs = [] # release::bug_fixes + ci_docs_prs = [] # release::ci_docs + + for pr in prs_nbrs: + # Get labels + pr_labels_cmd = "gh pr view {} --json labels".format(pr) + labels = dict(json.loads(subprocess_run(*pr_labels_cmd.split()).decode("utf-8")))["labels"] + nb_rls_lbls_types = 0 + label = "" + for lab in labels: + if lab["name"].startswith("release::"): + nb_rls_lbls_types = nb_rls_lbls_types + 1 + label = lab["name"] + + # Only one release label should be set + if nb_rls_lbls_types == 0: + raise ValueError("No release label is set for PR #{}".format(pr)) + elif nb_rls_lbls_types > 1: + raise ValueError( + "Only one release label should be set. PR #{} has {} labels.".format( + pr, nb_rls_lbls_types + ) + ) + + # Dispatch PRs with their corresponding label + if label == "release::enhancements": + enhancements_prs.append(pr) + elif label == "release::bug_fixes": + bug_fixes_prs.append(pr) + elif label == "release::ci_docs": + ci_docs_prs.append(pr) + else: + raise ValueError("Unknown release label {} for PR #{}".format(label, pr)) + + with open("CHANGELOG.md", "r+") as changelog_file: + # Make sure we're appending at the beginning of the file + content_to_restore = changelog_file.read() + changelog_file.seek(0) + + # Append new info + # Release date and version + changelog_file.write("{}\n".format(date.today().strftime("%Y.%m.%d"))) + changelog_file.write("==========\n") + changelog_file.write( + "\nReleases: libmamba {0}, libmambapy {0}, micromamba {0}\n".format(release_version) + ) + # PRs info + append_to_file("Enhancements", enhancements_prs, changelog_file) + append_to_file("Bug fixes", bug_fixes_prs, changelog_file) + append_to_file("CI fixes and doc", ci_docs_prs, changelog_file) + + # Write back old content of CHANGELOG file + changelog_file.write("\n" + content_to_restore) + + print( + "'CHANGELOG.md' was successfully updated.\nPlease run 'releaser.py' if you agree with the changes applied." + ) + + +if __name__ == "__main__": + main()