From ad2e704f98c3bc2fb847d5b58b77aa6d15f09657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinbei=C3=9F?= Date: Mon, 21 Mar 2022 13:56:18 +0100 Subject: [PATCH] Add composite action to create upstream tag (#5) Add composite action to create upstream tag The release_bot.py contains several steps, which were previously part of our release script in osbuild/maintainer-tools. The script here is much shorter though. Things that were dropped were the sanity checks, because we can presume that the git repository at hand is sane, that the git remote is well configured etc. Furthermore all interactivity of the original script was removed. Even though I had recently added a --no-interactive mode to the original script, I just felt this would make the code harder to read. The rationale for making this a composite action instead of a standalone repository was that this way each component can define it's own automated schedule and that there would be some representation in the GitHub Actions for the component of the tag being created. Getting the component/repository name as a separate step vs. just using `github.event.repository.name` is done so it also works when being triggered from schedule (the event payload is missing in this case). --- README.md | 9 ++- action.yml | 62 ++++++--------- bump-version.sh | 9 --- create_tag.py | 199 +++++++++++++++++++++++++++++++++++++++++++++++ release-info.sh | 6 -- requirements.txt | 1 + 6 files changed, 230 insertions(+), 56 deletions(-) delete mode 100755 bump-version.sh create mode 100755 create_tag.py delete mode 100755 release-info.sh create mode 100644 requirements.txt diff --git a/README.md b/README.md index 075f58e..436d86f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ ## release-action -This GitHub composite action is used for creating upstream releases in GitHub. +This GitHub composite action is used for creating upstream git tags. It does the following: - * Extracts the release note text from the git tag - * Creates a GitHub release based on the tag information - * Automatically bumps the version (where applicable) and commits this change + * Bump the version number (based on the latest existing tag) + * Extract the release note text from the pull requests associated with commits since the latest tag + * Create a git tag with the release note text being the tag's body + * Push the tag to the upstream repository diff --git a/action.yml b/action.yml index 0ad50ad..90428f5 100644 --- a/action.yml +++ b/action.yml @@ -1,57 +1,45 @@ -name: "Upstream release" -description: "Perform an upstream GitHub release" +name: "Release bot" +description: "Create a tag and push it" inputs: token: - description: "A GitHub token for creating a release" + description: "A GitHub token for creating a tag" required: true - slack_webhook_url: - description: "A Slack incoming Webhook URL" + username: + description: "A GitHub user name" + required: true + email: + description: "The GitHub user's email address" required: true runs: using: "composite" steps: - name: Checkout current repo - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: token: "${{ inputs.TOKEN }}" fetch-depth: 0 - - name: Fetch all tags - # GitHub's checkout action doesn't properly fetch the current tag - run: git fetch --tags --prune --force + - name: Install Python and depends + run: | + sudo apt install python3 + pip install -r ${{ github.action_path }}/requirements.txt shell: bash - - name: Generate release information - run: ${{ github.action_path }}/release-info.sh + - name: Get component name + run: echo "REPOSITORY_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV shell: bash - - name: Create GitHub release - # https://github.com/marketplace/actions/create-release - uses: ncipollo/release-action@v1.8.10 - with: - name: "${{ env.release_version }}" - token: "${{ inputs.TOKEN }}" - bodyFile: "release.md" - - - name: Bump version - run: ${{ github.action_path }}/bump-version.sh "${{ env.release_version }}" + - name: Set up git + run: | + git clone https://"${{ inputs.TOKEN }}"@github.com/"${{ github.repository }}".git ${{ github.action_path }}/${{ env.REPOSITORY_NAME }} + git config --global user.email "${{ inputs.EMAIL }}" + git config --global user.name "${{ inputs.USERNAME }}" shell: bash - - name: Commit new version - # https://github.com/marketplace/actions/add-commit - uses: EndBug/add-and-commit@v7 - with: - branch: main - message: "Post release version bump\n\n[skip ci]" - add: "-u *" - - - name: Send release announcement to osbuild Slack channel - id: slack - uses: slackapi/slack-github-action@v1.16.0 - with: - payload: "{\"blocks\":[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"🚀 ** just got released upstream! 🚀\"}}]}" - env: - SLACK_WEBHOOK_URL: ${{ inputs.SLACK_WEBHOOK_URL }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK \ No newline at end of file + - name: Create and push tag + run: | + cd ${{ github.action_path }}/${{ env.REPOSITORY_NAME }}/ + ${{ github.action_path }}/create_tag.py --token "${{ inputs.TOKEN }}" + shell: bash \ No newline at end of file diff --git a/bump-version.sh b/bump-version.sh deleted file mode 100755 index 24d24df..0000000 --- a/bump-version.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -VERSION=$(( $1 + 1 )) - -sed -i -E "s/(Version:\\s+)[0-9]+/\1$VERSION/" *osbuild*.spec - -if [ -f "setup.py" ]; then - sed -i -E "s/(version=\")[0-9]+/\1$VERSION/" setup.py -fi diff --git a/create_tag.py b/create_tag.py new file mode 100755 index 0000000..a9a5763 --- /dev/null +++ b/create_tag.py @@ -0,0 +1,199 @@ +#!/usr/bin/python3 + +"""Release bot""" + +import argparse +import subprocess +import sys +import os +import time +import logging +from datetime import date +from ghapi.all import GhApi + + +class fg: # pylint: disable=too-few-public-methods + """Set of constants to print colored output in the terminal""" + BOLD = '\033[1m' # bold + OK = '\033[32m' # green + INFO = '\033[33m' # yellow + ERROR = '\033[31m' # red + RESET = '\033[0m' # reset + + +def msg_error(body): + """Print error and exit""" + print(f"{fg.ERROR}{fg.BOLD}Error:{fg.RESET} {body}") + sys.exit(1) + + +def msg_info(body): + """Print info message""" + print(f"{fg.INFO}{fg.BOLD}Info:{fg.RESET} {body}") + + +def msg_ok(body): + """Print ok status message""" + print(f"{fg.OK}{fg.BOLD}OK:{fg.RESET} {body}") + + +def run_command(argv): + """Run a shellcommand and return stdout""" + result = subprocess.run( # pylint: disable=subprocess-run-check + argv, + capture_output=True, + text=True, + encoding='utf-8') + + if result.returncode == 0: + ret = result.stdout.strip() + else: + ret = result.stderr.strip() + + return ret + + +def autoincrement_version(latest_tag): + """Bump the version of the latest git tag by 1""" + if latest_tag == "": + msg_info("There are no tags yet in this repository.") + version = "1" + elif "." in latest_tag: + version = latest_tag.replace("v", "").split(".")[0] + "." + str(int(latest_tag[-1]) + 1) + else: + version = int(latest_tag.replace("v", "")) + 1 + return version + + +def list_prs_for_hash(args, api, repo, commit_hash): + """Get pull request for a given commit hash""" + query = f'{commit_hash} type:pr is:merged base:{args.base} repo:osbuild/{repo}' + try: + res = api.search.issues_and_pull_requests(q=query, per_page=20) + except: + msg_info(f"Couldn't get PR infos for {commit_hash}.") + res = None + + if res is not None: + items = res["items"] + + if len(items) == 1: + ret = items[0] + else: + msg_info(f"There are {len(items)} pull requests associated with {commit_hash} - skipping...") + for item in items: + msg_info(f"{item.html_url}") + ret = None + else: + ret = None + + return ret + + +def get_pullrequest_infos(args, repo, hashes): + """Fetch the titles of all related pull requests""" + logging.debug("Collect pull request titles...") + api = GhApi(repo=repo, owner='osbuild', token=args.token) + + for i, commit_hash in enumerate(hashes): + print(f"Fetching PR for commit {i}/{len(hashes)} ({commit_hash})") + time.sleep(2) + pull_request = list_prs_for_hash(args, api, repo, commit_hash) + if pull_request is not None: + if repo == "cockpit-composer": + msg = f"- {pull_request.title} (#{pull_request.number})" + else: + msg = f" * {pull_request.title} (#{pull_request.number})" + summaries.append(msg) + + summaries = list(dict.fromkeys(summaries)) + msg_ok(f"Collected summaries from {len(summaries)} pull requests ({i} commits).") + return "\n".join(summaries) + + +def get_contributors(latest_tag): + """Collect all contributors to a release based on the git history""" + logging.debug("Collect contributors...") + contributors = run_command(["git", "log", '--format="%an"', f"{latest_tag}..HEAD"]) + contributor_list = contributors.replace('"', '').split("\n") + names = "" + for name in sorted(set(contributor_list)): + if name != "": + names += f"{name}, " + + logging.debug(f"List of contributors:\n{names[:-2]}") + + return names[:-2] + + +def create_release_tag(args, repo, tag, latest_tag): + """Create a release tag""" + logging.debug("Preparing tag...") + today = date.today() + contributors = get_contributors(latest_tag) + + summaries = "" + hashes = run_command(['git', 'log', '--format=%H', f'{latest_tag}..HEAD']).split("\n") + msg_info(f"Found {len(hashes)} commits since {latest_tag} in {args.base}:") + logging.debug("\n".join(hashes)) + summaries = get_pullrequest_infos(args, repo, hashes) + + tag = f'v{args.version}' + message = (f"CHANGES WITH {args.version}:\n\n" + f"----------------\n" + f"{summaries}\n\n" + f"Contributions from: {contributors}\n\n" + f"— Somewhere on the Internet, {today.strftime('%Y-%m-%d')}") + + res = run_command(['git', 'tag', '-m', message, tag, 'HEAD']) + logging.debug(res) + msg_ok(f"Created tag '{tag}' with message:\n{message}") + + +def print_config(args, repo): + """Print the values used for the release playbook""" + print("\n--------------------------------\n" + f"{fg.BOLD}Release:{fg.RESET}\n" + f" Component: {repo}\n" + f" Version: {args.version}\n" + f" Base branch: {args.base}\n" + f"--------------------------------\n") + + +def main(): + """Main function""" + # Get some basic fallback/default values + repo = os.path.basename(os.getcwd()) + latest_tag = run_command(['git', 'describe', '--tags', '--abbrev=0']) + version = autoincrement_version(latest_tag) + + parser = argparse.ArgumentParser() + parser.add_argument("-v", "--version", + help=f"Set the version for the release (Default: {version})", + default=version) + parser.add_argument("-t", "--token", help=f"Set the GitHub token") + parser.add_argument("-b", "--base", + help=f"Set the release branch (Default: 'main')", + default='main') + parser.add_argument("-d", "--debug", help="Print lots of debugging statements", action="store_const", + dest="loglevel", const=logging.DEBUG, default=logging.INFO) + args = parser.parse_args() + + logging.basicConfig(level=args.loglevel, format='%(asctime)s %(message)s', datefmt='%Y/%m/%d/ %H:%M:%S') + + print_config(args, repo) + + tag = f'v{args.version}' + logging.debug(f"Current release: {latest_tag}\nNew release: {args.version}\nTag name: {tag}") + + # Create a release tag + create_release_tag(args, repo, tag, latest_tag) + + # Push the tag + res = run_command(['git', 'push', 'origin', tag]) + logging.debug(res) + msg_ok(f"Pushed tag '{tag}' to branch '{args.base}'") + + +if __name__ == "__main__": + main() diff --git a/release-info.sh b/release-info.sh deleted file mode 100755 index 308d171..0000000 --- a/release-info.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -TAG=$(git describe "${GITHUB_REF}") -git tag --list --format="%(subject)%0a%(body)" $TAG | sed '/^-----BEGIN PGP SIGNATURE-----$/,$d' > release.md -echo "release_version=${TAG//v}" >> $GITHUB_ENV -echo "component=$(basename `git rev-parse --show-toplevel`)" >> $GITHUB_ENV \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d424301 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +ghapi \ No newline at end of file