From a0df3d631ecf87b0cbc034d1f5ab51541938d3ab Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 14:53:32 -0700 Subject: [PATCH 01/59] Add initial reusable workflow --- .github/workflows/create-release-pr.yaml | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/create-release-pr.yaml diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml new file mode 100644 index 0000000..aa880b7 --- /dev/null +++ b/.github/workflows/create-release-pr.yaml @@ -0,0 +1,58 @@ +--- +name: Manage new releases + +run-name: Prepare for new release via pull request + +on: + workflow_call: + inputs: + changelog: + type: string + description: Relative path to the CHANGELOG file + required: false + default: CHANGELOG.md + bump_type: + type: string + description: Semantic version bump type. Must be one of `major`, `minor`, `patch`, `prerelease`, or `exact`. Using the first four options will compute the next appropriate semantic version tag based on the most recent tag available from the main branch. Using `exact` is required for repositories without semantic version tags and allows specifying the exact next tag to use with the `exact_version` argument. + required: true + exact_version: + type: string + description: Exact version number to target. Only used if bump_type is set to `exact`. + required: false + default: "" + +jobs: + prepare-release: + runs-on: ubuntu-latest + + steps: + # Get the version of _this_ repository that is in use so that we can use + # sidecar scripts + - id: workflow-parsing + name: Get SHA of reusuable workflow + env: + REPO: github.repository + RUN_ID: github.run_id + run: | + SHA=$(gh api "repos/$REPO/actions/runs/$RUN_ID" | jq -r '.referenced_workflows.[] | select(.path|startswith("uclahs-cds/tool-create-release")).sha') + echo "SHA=$SHA" >> "$GITHUB_OUTPUT" + + - name: Checkout reusable repository + uses: actions/checkout@v4 + with: + repository: uclahs-cds/tool-create-release + ref: ${{ steps.workflow-parsing.outputs.SHA }} + path: reusable + + - name: Checkout calling repository + uses: actions/checkout@v4 + with: + path: caller + + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + # Install the semver package + - run: pip install semver==3.0.2 From 449e2aea14f716546429bd35ff841027e25f8309 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:13:40 -0700 Subject: [PATCH 02/59] Add token to environment --- .github/workflows/create-release-pr.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index aa880b7..5a3b312 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -33,6 +33,7 @@ jobs: env: REPO: github.repository RUN_ID: github.run_id + GH_TOKEN: ${{ github.token }} run: | SHA=$(gh api "repos/$REPO/actions/runs/$RUN_ID" | jq -r '.referenced_workflows.[] | select(.path|startswith("uclahs-cds/tool-create-release")).sha') echo "SHA=$SHA" >> "$GITHUB_OUTPUT" From 83daec50bf4dc0cbedbd03ce85630f44b56737ac Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:15:25 -0700 Subject: [PATCH 03/59] Debug --- .github/workflows/create-release-pr.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 5a3b312..b7c9893 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -35,7 +35,9 @@ jobs: RUN_ID: github.run_id GH_TOKEN: ${{ github.token }} run: | - SHA=$(gh api "repos/$REPO/actions/runs/$RUN_ID" | jq -r '.referenced_workflows.[] | select(.path|startswith("uclahs-cds/tool-create-release")).sha') + DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") + echo "$DATA" + SHA=$(echo "$DATA" | jq -r '.referenced_workflows.[] | select(.path|startswith("uclahs-cds/tool-create-release")).sha') echo "SHA=$SHA" >> "$GITHUB_OUTPUT" - name: Checkout reusable repository From 7d40caf6b42fef20bfb38c6e4375392bf6b67638 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:16:24 -0700 Subject: [PATCH 04/59] Actually reference the variables --- .github/workflows/create-release-pr.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index b7c9893..5cff6d5 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -31,8 +31,8 @@ jobs: - id: workflow-parsing name: Get SHA of reusuable workflow env: - REPO: github.repository - RUN_ID: github.run_id + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} GH_TOKEN: ${{ github.token }} run: | DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") From 4fb302934fc12e709382adb1a4789b9447131b10 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:20:49 -0700 Subject: [PATCH 05/59] Do it in one step again --- .github/workflows/create-release-pr.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 5cff6d5..ee3c081 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -35,9 +35,7 @@ jobs: RUN_ID: ${{ github.run_id }} GH_TOKEN: ${{ github.token }} run: | - DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") - echo "$DATA" - SHA=$(echo "$DATA" | jq -r '.referenced_workflows.[] | select(.path|startswith("uclahs-cds/tool-create-release")).sha') + SHA=$(gh api "repos/$REPO/actions/runs/$RUN_ID" | jq -r '.referenced_workflows.[] | select(.path|startswith("uclahs-cds/tool-create-release")).sha') echo "SHA=$SHA" >> "$GITHUB_OUTPUT" - name: Checkout reusable repository From 851d758f3fb8c87857820601f7cd7bc7058fb70f Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:25:51 -0700 Subject: [PATCH 06/59] Debug jq syntax --- .github/workflows/create-release-pr.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index ee3c081..895f8ac 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -35,7 +35,8 @@ jobs: RUN_ID: ${{ github.run_id }} GH_TOKEN: ${{ github.token }} run: | - SHA=$(gh api "repos/$REPO/actions/runs/$RUN_ID" | jq -r '.referenced_workflows.[] | select(.path|startswith("uclahs-cds/tool-create-release")).sha') + jq --version + SHA=$(gh api "repos/$REPO/actions/runs/$RUN_ID" | jq -r '.["referenced_workflows"].[] | select(.["path"]|startswith("uclahs-cds/tool-create-release")).["sha"]') echo "SHA=$SHA" >> "$GITHUB_OUTPUT" - name: Checkout reusable repository From 1f24b4c5f21a9cff63f59c3a41fc298d4003938e Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:31:56 -0700 Subject: [PATCH 07/59] More debugging --- .github/workflows/create-release-pr.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 895f8ac..ce64a39 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -30,13 +30,16 @@ jobs: # sidecar scripts - id: workflow-parsing name: Get SHA of reusuable workflow + shell: bash env: REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} GH_TOKEN: ${{ github.token }} run: | jq --version - SHA=$(gh api "repos/$REPO/actions/runs/$RUN_ID" | jq -r '.["referenced_workflows"].[] | select(.["path"]|startswith("uclahs-cds/tool-create-release")).["sha"]') + ACTION_DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") + echo "$ACTION_DATA" + SHA=$(echo "$ACTION_DATA" | jq -r '.["referenced_workflows"].[] | select(.["path"]|startswith("uclahs-cds/tool-create-release")).["sha"]') echo "SHA=$SHA" >> "$GITHUB_OUTPUT" - name: Checkout reusable repository From b509d5f76d7ef8a242faba11f69ce44de6468357 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:38:40 -0700 Subject: [PATCH 08/59] Fix bug in jq filter --- .github/workflows/create-release-pr.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index ce64a39..7b14688 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -36,10 +36,9 @@ jobs: RUN_ID: ${{ github.run_id }} GH_TOKEN: ${{ github.token }} run: | - jq --version ACTION_DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") echo "$ACTION_DATA" - SHA=$(echo "$ACTION_DATA" | jq -r '.["referenced_workflows"].[] | select(.["path"]|startswith("uclahs-cds/tool-create-release")).["sha"]') + SHA=$(echo "$ACTION_DATA" | jq -r '.referenced_workflows | .[] | select(.path | startswith("uclahs-cds/tool-create-release")).sha') echo "SHA=$SHA" >> "$GITHUB_OUTPUT" - name: Checkout reusable repository From 75ae17d5695b045915ac38b8e3223403002e3ed5 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:41:46 -0700 Subject: [PATCH 09/59] Use the PAT to get the reusable repository --- .github/workflows/create-release-pr.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 7b14688..fca931b 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -45,8 +45,9 @@ jobs: uses: actions/checkout@v4 with: repository: uclahs-cds/tool-create-release - ref: ${{ steps.workflow-parsing.outputs.SHA }} path: reusable + ref: ${{ steps.workflow-parsing.outputs.SHA }} + token: ${{ secrets.UCLAHS_CDS_REPO_READ_TOKEN }} - name: Checkout calling repository uses: actions/checkout@v4 From e255274a4d42bc2ec486d6bbd308c8422114b73e Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 15:47:04 -0700 Subject: [PATCH 10/59] Mark the full JSON as debug output --- .github/workflows/create-release-pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index fca931b..909cac5 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -37,7 +37,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | ACTION_DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") - echo "$ACTION_DATA" + echo "::debug::$ACTION_DATA" SHA=$(echo "$ACTION_DATA" | jq -r '.referenced_workflows | .[] | select(.path | startswith("uclahs-cds/tool-create-release")).sha') echo "SHA=$SHA" >> "$GITHUB_OUTPUT" From f25cbd633bf3f06b33c3a0be893503cfdbcd58c0 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 16:06:17 -0700 Subject: [PATCH 11/59] Get the next version --- .github/workflows/create-release-pr.yaml | 8 +++++- get_next_version.py | 34 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 get_next_version.py diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 909cac5..5f0fcb6 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -30,7 +30,6 @@ jobs: # sidecar scripts - id: workflow-parsing name: Get SHA of reusuable workflow - shell: bash env: REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} @@ -53,6 +52,7 @@ jobs: uses: actions/checkout@v4 with: path: caller + fetch-tags: true - name: Set up python uses: actions/setup-python@v5 @@ -61,3 +61,9 @@ jobs: # Install the semver package - run: pip install semver==3.0.2 + + - run: python reusable/get_next_version.py + env: + REPO_DIR: caller + BUMP_TYPE: ${{ inputs.bump_type }} + EXACT_VERSION: ${{ inputs.exact_version }} diff --git a/get_next_version.py b/get_next_version.py new file mode 100644 index 0000000..c34e66b --- /dev/null +++ b/get_next_version.py @@ -0,0 +1,34 @@ +"Get the next tag version." + +import os +import subprocess +from pathlib import Path + +import semver + + +def get_next_tag(): + "Return the next tag after the appropriate bump type." + repo_dir = os.environ["REPO_DIR"] + bump_type = os.environ["BUMP_TYPE"] + exact_version = os.environ["EXACT_VERSION"] + output_file = Path(os.environ["GITHUB_OUTPUT"]) + + if bump_type == "exact": + return exact_version + + # Get the most recent ancestor tag + last_tag = subprocess.check_output( + ["git", "describe", "--tags", "--abbrev=0"], + cwd=repo_dir + ).decode("utf-8") + + last_version = semver.Version.parse(last_tag) + next_version = last_version.next_version(part=bump_type) + print(f"{last_version} -> {bump_type} -> {next_version}") + + with output_file.open(mode="w", encoding="utf-8") as outfile: + outfile.write(f"next_version={next_version}\n") + +if __name__ == "__main__": + get_next_tag() From 3187e7ffcbb12eca72ccd713e9f58d9af92073ee Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 16:09:04 -0700 Subject: [PATCH 12/59] fetch-tags does not work correctly https://github.com/actions/checkout/issues/1781 --- .github/workflows/create-release-pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 5f0fcb6..a0e08c2 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -52,7 +52,7 @@ jobs: uses: actions/checkout@v4 with: path: caller - fetch-tags: true + fetch-depth: 0 - name: Set up python uses: actions/setup-python@v5 From 29acc5c425caf58761c1c7f696dc9404b1f69e59 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 16:10:48 -0700 Subject: [PATCH 13/59] Apparently both are needed --- .github/workflows/create-release-pr.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index a0e08c2..77e3a37 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -53,6 +53,7 @@ jobs: with: path: caller fetch-depth: 0 + fetch-tags: true - name: Set up python uses: actions/setup-python@v5 From 7ecdb6c91a4d9e9294410811d6315c695b049296 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 16:34:00 -0700 Subject: [PATCH 14/59] Properly handle the leading v --- get_next_version.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/get_next_version.py b/get_next_version.py index c34e66b..9139f2d 100644 --- a/get_next_version.py +++ b/get_next_version.py @@ -17,13 +17,14 @@ def get_next_tag(): if bump_type == "exact": return exact_version - # Get the most recent ancestor tag + # Get the most recent ancestor tag that matches r"v\d.*" last_tag = subprocess.check_output( - ["git", "describe", "--tags", "--abbrev=0"], + ["git", "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], cwd=repo_dir ).decode("utf-8") - last_version = semver.Version.parse(last_tag) + # Strip off the leading v when parsing the version + last_version = semver.Version.parse(last_tag[1:]) next_version = last_version.next_version(part=bump_type) print(f"{last_version} -> {bump_type} -> {next_version}") From 546e8651bad22c9538fd8cc2c5b202a10f5d9acb Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 16:37:44 -0700 Subject: [PATCH 15/59] Include exact in the same workflow --- get_next_version.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/get_next_version.py b/get_next_version.py index 9139f2d..bac20ae 100644 --- a/get_next_version.py +++ b/get_next_version.py @@ -15,17 +15,19 @@ def get_next_tag(): output_file = Path(os.environ["GITHUB_OUTPUT"]) if bump_type == "exact": - return exact_version + last_version = "" + next_version = exact_version + else: + # Get the most recent ancestor tag that matches r"v\d.*" + last_tag = subprocess.check_output( + ["git", "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], + cwd=repo_dir + ).decode("utf-8") + + # Strip off the leading v when parsing the version + last_version = semver.Version.parse(last_tag[1:]) + next_version = str(last_version.next_version(part=bump_type)) - # Get the most recent ancestor tag that matches r"v\d.*" - last_tag = subprocess.check_output( - ["git", "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], - cwd=repo_dir - ).decode("utf-8") - - # Strip off the leading v when parsing the version - last_version = semver.Version.parse(last_tag[1:]) - next_version = last_version.next_version(part=bump_type) print(f"{last_version} -> {bump_type} -> {next_version}") with output_file.open(mode="w", encoding="utf-8") as outfile: From cb34d700e06a0308887c28772c8e23f344f98ef3 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 17:05:01 -0700 Subject: [PATCH 16/59] Handle the first release --- get_next_version.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/get_next_version.py b/get_next_version.py index bac20ae..200a86d 100644 --- a/get_next_version.py +++ b/get_next_version.py @@ -19,10 +19,15 @@ def get_next_tag(): next_version = exact_version else: # Get the most recent ancestor tag that matches r"v\d.*" - last_tag = subprocess.check_output( - ["git", "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], - cwd=repo_dir - ).decode("utf-8") + try: + last_tag = subprocess.check_output( + ["git", "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], + cwd=repo_dir, + ).decode("utf-8") + except subprocess.CalledProcessError: + # It seems that this is the first release + print("WARNING: No prior tag found!") + last_tag = "v0.0.0" # Strip off the leading v when parsing the version last_version = semver.Version.parse(last_tag[1:]) @@ -33,5 +38,6 @@ def get_next_tag(): with output_file.open(mode="w", encoding="utf-8") as outfile: outfile.write(f"next_version={next_version}\n") + if __name__ == "__main__": get_next_tag() From ceb9aa747193569e2def0b876a261f46b2025e7e Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 17:09:24 -0700 Subject: [PATCH 17/59] Add in python code --- .github/workflows/pytest.yaml | 42 +++ bumpchanges/__init__.py | 32 ++ bumpchanges/changelog.py | 349 ++++++++++++++++++ .../getversion.py | 4 - pyproject.toml | 47 +++ tests/test_bumpchanges.py | 6 + 6 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pytest.yaml create mode 100644 bumpchanges/__init__.py create mode 100644 bumpchanges/changelog.py rename get_next_version.py => bumpchanges/getversion.py (96%) create mode 100644 pyproject.toml create mode 100644 tests/test_bumpchanges.py diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml new file mode 100644 index 0000000..8b0aa40 --- /dev/null +++ b/.github/workflows/pytest.yaml @@ -0,0 +1,42 @@ +--- +name: Run PyTest + +on: + pull_request: + branches: + - main + push: + branches: + - main + - nwiltsie-reusable-workflows + +jobs: + pytest: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: + - "3.7" + - "3.12" + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Run tests + run: | + pip install tox + tox -e py${{matrix.python-version}} + + - name: Upload pytest test results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: "pytest-${{ matrix.python-version }}.xml" + path: junit/test-results.xml + if-no-files-found: error diff --git a/bumpchanges/__init__.py b/bumpchanges/__init__.py new file mode 100644 index 0000000..d616280 --- /dev/null +++ b/bumpchanges/__init__.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"Work with CHANGELOG.md files." + +import argparse + +from pathlib import Path + +from .changelog import Changelog + + +def update_changelog( + changelog_file: Path, repo_url: str, version: str +) -> str: + "Rewrite a CHANGELOG file for a new release." + changelog = Changelog(changelog_file, repo_url) + changelog.update_version(version) + return changelog.render() + + +def main(): + "Main entrypoint." + parser = argparse.ArgumentParser() + parser.add_argument("changelog", type=Path) + parser.add_argument("repo_url", type=str) + parser.add_argument("version", type=str) + + args = parser.parse_args() + + print(update_changelog(args.changelog, args.repo_url, args.version)) + +if __name__ == "__main__": + main() diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py new file mode 100644 index 0000000..3c785c1 --- /dev/null +++ b/bumpchanges/changelog.py @@ -0,0 +1,349 @@ +"Classes to handle parsing and updating CHANGELOG.md files." + +import datetime +import itertools +import re + +from pathlib import Path +from typing import ClassVar, Self +from dataclasses import dataclass, field + +import mdformat +from markdown_it import MarkdownIt +from markdown_it.token import Token + + +class ChangelogError(Exception): + "Indicate a fundamental problem with the CHANGELOG structure." + + +class EmptyListError(Exception): + "Indicate that a section is empty and should be stripped." + + +def parse_heading(tokens: list[Token]) -> tuple[str, Token]: + "Parse the `inline` element from the heading." + if ( + len(tokens) < 3 + or tokens[0].type != "heading_open" + or tokens[1].type != "inline" + ): + raise ChangelogError(f"Invalid header section (line {tokens[0].map})") + + tag = tokens.pop(0).tag + inline = tokens.pop(0) + tokens.pop(0) + + return (tag, inline) + + +def parse_bullet_list(tokens: list[Token]) -> list[Token]: + "Consume tokens and return all of the child list_items." + # Parse the heading + if not tokens or tokens[0].type != "bullet_list_open": + raise EmptyListError() + + nesting = 0 + list_tokens = [] + + while tokens: + list_tokens.append(tokens.pop(0)) + nesting += list_tokens[-1].nesting + + if nesting == 0: + break + + assert list_tokens[0].type == "bullet_list_open" + assert list_tokens[-1].type == "bullet_list_close" + + # Strip off the bullet list so that we can assert our own style and merge + # lists + return list_tokens[1:-1] + + +def heading(level: int, children: list): + "Return a heading of the appropriate level." + + markup = "#" * level + tag = f"h{level}" + + return [ + Token("heading_open", tag=tag, markup=markup, nesting=1), + Token("inline", tag="", nesting=0, children=children), + Token("heading_close", tag=tag, markup=markup, nesting=-1), + ] + + +HEADING_REPLACEMENTS = { + "updated": "changed", + "add": "added", +} + + +@dataclass +class Version: + "Class to help manage individual releases within CHANGELOG.md files." + + link_heading_re: ClassVar = re.compile( + r"^\[(?P.+?)\]\((?P.+?)\)(?:\s+-\s+(?P.*))?$" + ) + heading_re: ClassVar = re.compile( + r"^\[?(?P.+?)\]?(?:\s+-\s+(?P.*))?$" + ) + + version: str + date: str | None = None + link: str | None = None + + # This is a CommonChangelog modification + notices: list = field(default_factory=list) + + added: list = field(default_factory=list) + changed: list = field(default_factory=list) + deprecated: list = field(default_factory=list) + removed: list = field(default_factory=list) + fixed: list = field(default_factory=list) + security: list = field(default_factory=list) + + @classmethod + def blank_unreleased(cls): + "Create a new empty Unreleased version." + return cls(version="Unreleased") + + @classmethod + def from_tokens(cls, tokens) -> Self: + "Parse a Version from a token stream." + # Open, content, close + if ( + len(tokens) < 3 + or tokens[0].type != "heading_open" + or tokens[0].tag != "h2" + or tokens[1].type != "inline" + ): + raise ChangelogError("Invalid version section") + + kwargs = {} + + for regex in (cls.link_heading_re, cls.heading_re): + match = regex.match(tokens[1].content) + if match: + kwargs.update(match.groupdict()) + break + else: + raise ChangelogError(f"Invalid section heading: {tokens[1].content}") + + # The rest of the tokens should be the lists. Strip any rulers now. + tokens = [token for token in tokens[3:] if token.type != "hr"] + + while tokens: + if tokens[0].type == "heading_open": + _, inline_heading = parse_heading(tokens) + + # For these headings, all we care about is the raw content + heading_name = inline_heading.content + + # Strip off any stray brackets and trailing colons + heading_name = re.sub(r"^\[?(.*?)\]?:?$", r"\1", heading_name).lower() + heading_name = HEADING_REPLACEMENTS.get(heading_name, heading_name) + + try: + items = parse_bullet_list(tokens) + except EmptyListError: + # Empty section - ignore it + continue + + # Merge multiple identical sections together + kwargs.setdefault(heading_name, []).extend(items) + + elif tokens[0].type == "paragraph_open": + nesting = 0 + notice = [] + while tokens: + notice.append(tokens.pop(0)) + nesting += notice[-1].nesting + + if nesting == 0: + break + + kwargs.setdefault("notices", []).append(notice) + + elif tokens[0].type == "bullet_list_open": + # Un-headered section - add these to "Changed" + + items = parse_bullet_list(tokens) + + # Merge multiple identical sections together + kwargs.setdefault("changed", []).extend(items) + + else: + print(tokens) + raise ChangelogError("Don't know how to handle these tokens") + + assert not tokens + + return cls(**kwargs) + + def serialize(self): + "Yield a stream of markdown tokens describing this Version." + + link_kwargs = {} + if self.link: + link_kwargs["attrs"] = {"href": self.link} + else: + link_kwargs["meta"] = {"label": self.version} + + heading_children = [ + Token("link_open", tag="a", nesting=1, **link_kwargs), + Token("text", tag="", nesting=0, level=1, content=self.version), + Token("link_close", tag="a", nesting=-1), + ] + + if self.date: + heading_children.append( + Token("text", tag="", nesting=0, content=f" - {self.date}") + ) + + yield from heading(2, heading_children) + + for notice in self.notices: + yield from notice + + section_order = ( + "added", + "changed", + "deprecated", + "removed", + "fixed", + "security", + ) + + for section in section_order: + section_items = getattr(self, section) + + if section_items: + yield from heading( + 3, [Token("text", tag="", nesting=0, content=section.title())] + ) + + yield Token( + "bullet_list_open", + tag="ul", + markup="-", + nesting=1, + block=True, + hidden=True, + ) + yield from section_items + yield Token( + "bullet_list_close", + tag="ul", + markup="-", + nesting=-1, + block=True, + hidden=True, + ) + + +class Changelog: + "Class to help manage CHANGELOG.md files." + + def __init__(self, changelog_file: Path, repo_url: str): + self.changelog_file = changelog_file + self.repo_url = repo_url + + groups = [[]] + + all_tokens = MarkdownIt("gfm-like").parse( + changelog_file.read_text(encoding="utf-8") + ) + + for token, nexttoken in itertools.pairwise( + itertools.chain( + all_tokens, + [ + None, + ], + ) + ): + assert token is not None + + if token.type == "heading_open": + if token.tag == "h1": + # Several of our repositories have errors where versions + # are mistakenly H1s rather than H2s. Catch those cases and + # fix them up. + assert nexttoken is not None + if re.match(r"^\[\d", nexttoken.content): + token.tag = "h2" + + if token.tag == "h2": + # A lot of our repositories have an issue where "Added", + # "Fixed", etc. are mistakenly H2s rather than H3s. Catch those + # cases and fix them up. + assert nexttoken is not None + if re.match( + r"Add|Fix|Change|Remove", nexttoken.content, flags=re.IGNORECASE + ): + token.tag = "h3" + else: + # Split split these tokens off into a new Version + groups.append([]) + + groups[-1].append(token) + + self.header = [token for token in groups.pop(0) if token.tag != "hr"] + + self.versions = [Version.from_tokens(group) for group in groups] + + if not self.versions: + raise ChangelogError("No versions!") + + def update_version(self, next_version: str): + "Move all unreleased changes under the new version." + if not self.versions or self.versions[0].version != "Unreleased": + print("WARNING: No Unreleased section - adding a new empty section") + self.versions.insert(0, Version.blank_unreleased()) + + # Change the version and date of the unreleased section. For now + # explicitly assume UTC, but that should probably be an input. + self.versions[0].version = next_version + self.versions[0].date = datetime.datetime.now(datetime.timezone.utc).date().isoformat() + + def render(self) -> str: + "Render the CHANGELOG to markdown." + renderer = mdformat.renderer.MDRenderer() + + options = {} + + all_tokens = list( + itertools.chain( + self.header, + itertools.chain.from_iterable( + version.serialize() for version in self.versions + ), + ) + ) + + refs = {} + + # Linkify all of the versions + prior_tag = None + + for version in reversed(self.versions): + if version.version == "Unreleased": + this_tag = None + else: + this_tag = f"v{version.version}" + + if prior_tag: + href = f"{self.repo_url}/compare/{prior_tag}...{this_tag if this_tag else 'HEAD'}" + elif this_tag: + href = f"{self.repo_url}/releases/tag/{this_tag}" + else: + href = f"{self.repo_url}/commits/HEAD" + + refs[version.version] = {"href": href, "title": ""} + + prior_tag = this_tag + + return renderer.render(all_tokens, options, {"references": refs}) diff --git a/get_next_version.py b/bumpchanges/getversion.py similarity index 96% rename from get_next_version.py rename to bumpchanges/getversion.py index 200a86d..0fb68ab 100644 --- a/get_next_version.py +++ b/bumpchanges/getversion.py @@ -37,7 +37,3 @@ def get_next_tag(): with output_file.open(mode="w", encoding="utf-8") as outfile: outfile.write(f"next_version={next_version}\n") - - -if __name__ == "__main__": - get_next_tag() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7643e4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "bumpchanges" +description = "Tools to normalize and version-bump CHANGELOG.md files." +readme = "README.md" +dynamic = ["version"] + +keywords = ["changelog", "ci"] + +requires-python = ">=3.7" + +dependencies = [ + "linkify-it-py>=2.0.3", + "markdown-it-py>=3.0.0", + "mdformat-gfm>=0.3.6", + "mdformat>=0.7.17", + "semver>=3.0.2" +] + +maintainers = [ + {name = "Nicholas Wiltsie", email = "nwiltsie@mednet.ucla.edu"} +] + +[project.scripts] +get-next-version = "bumpchanges:getversion.get_next_version" +bump-changelog = "bumpchanges:main" + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "bumpchanges/_version.py" + +[tool.tox] +legacy_tox_ini = """ +[tox] +env_list = + py3.7 + py3.12 + +[testenv] +deps = pytest +commands = pytest tests --doctest-modules --junitxml=junit/test-results.xml +""" diff --git a/tests/test_bumpchanges.py b/tests/test_bumpchanges.py new file mode 100644 index 0000000..5a35eab --- /dev/null +++ b/tests/test_bumpchanges.py @@ -0,0 +1,6 @@ +"Tests for the bumpchanges module." + + +def test_something(): + "An empty test. Should be filled with something." + assert True From 1e3461f286c3a850f710431122d046f534e2325c Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 17:11:15 -0700 Subject: [PATCH 18/59] Update the workflow to install the python package --- .github/workflows/create-release-pr.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 77e3a37..3f19d5d 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -60,10 +60,12 @@ jobs: with: python-version: '3.10' - # Install the semver package - - run: pip install semver==3.0.2 + # Install the bundled package + - run: pip install ./reusable - - run: python reusable/get_next_version.py + # Get the next version using the package's script + - id: get-next-version + run: get-next-version env: REPO_DIR: caller BUMP_TYPE: ${{ inputs.bump_type }} From 21ce1c33202aa94f9fba4f3f7a5165a6a9cfa51f Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 17:12:42 -0700 Subject: [PATCH 19/59] Remove python3.11 feature --- bumpchanges/changelog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 3c785c1..0fd73b5 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -5,7 +5,7 @@ import re from pathlib import Path -from typing import ClassVar, Self +from typing import ClassVar from dataclasses import dataclass, field import mdformat @@ -111,7 +111,7 @@ def blank_unreleased(cls): return cls(version="Unreleased") @classmethod - def from_tokens(cls, tokens) -> Self: + def from_tokens(cls, tokens): "Parse a Version from a token stream." # Open, content, close if ( From aaf608b7b71ba9df977f0ff7422529a71a10544c Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 17:13:52 -0700 Subject: [PATCH 20/59] Bugfix --- bumpchanges/getversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index 0fb68ab..1ba7523 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -7,7 +7,7 @@ import semver -def get_next_tag(): +def get_next_version(): "Return the next tag after the appropriate bump type." repo_dir = os.environ["REPO_DIR"] bump_type = os.environ["BUMP_TYPE"] From ee9de626d8fe9c5fcae648563c7adc3d7c99fc60 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 17:16:07 -0700 Subject: [PATCH 21/59] Bump to requiring 3.8 --- .github/workflows/pytest.yaml | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 8b0aa40..0761722 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.7" + - "3.8" - "3.12" steps: diff --git a/pyproject.toml b/pyproject.toml index a7643e4..92151d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ dynamic = ["version"] keywords = ["changelog", "ci"] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [ "linkify-it-py>=2.0.3", @@ -38,7 +38,7 @@ version-file = "bumpchanges/_version.py" legacy_tox_ini = """ [tox] env_list = - py3.7 + py3.8 py3.12 [testenv] From b78a93da758507671de30fc67374693ed337e912 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Mon, 29 Jul 2024 17:20:48 -0700 Subject: [PATCH 22/59] Update the CHANGELOG --- .github/workflows/create-release-pr.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 3f19d5d..0c3e3be 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -70,3 +70,10 @@ jobs: REPO_DIR: caller BUMP_TYPE: ${{ inputs.bump_type }} EXACT_VERSION: ${{ inputs.exact_version }} + + # Update the CHANGELOG + - run: bump-changelog "$CHANGELOG" "$URL" "$VERSION" + env: + CHANGELOG: caller/${{ inputs.changelog }} + URL: ${{ github.server_url }}/${{ github.repository }} + VERSION: ${{ steps.get-next-version.outputs.next_version }} From a572a6eb97993c07eb5e85ef7ade48750c530d5b Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 08:37:07 -0700 Subject: [PATCH 23/59] Actually re-write the file --- bumpchanges/__init__.py | 9 ++++----- bumpchanges/changelog.py | 4 +++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bumpchanges/__init__.py b/bumpchanges/__init__.py index d616280..ee3d74a 100644 --- a/bumpchanges/__init__.py +++ b/bumpchanges/__init__.py @@ -8,13 +8,12 @@ from .changelog import Changelog -def update_changelog( - changelog_file: Path, repo_url: str, version: str -) -> str: +def update_changelog(changelog_file: Path, repo_url: str, version: str): "Rewrite a CHANGELOG file for a new release." changelog = Changelog(changelog_file, repo_url) changelog.update_version(version) - return changelog.render() + + changelog_file.write_text(changelog.render(), encoding="utf-8") def main(): @@ -25,8 +24,8 @@ def main(): parser.add_argument("version", type=str) args = parser.parse_args() + update_changelog(args.changelog, args.repo_url, args.version) - print(update_changelog(args.changelog, args.repo_url, args.version)) if __name__ == "__main__": main() diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 0fd73b5..a422966 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -307,7 +307,9 @@ def update_version(self, next_version: str): # Change the version and date of the unreleased section. For now # explicitly assume UTC, but that should probably be an input. self.versions[0].version = next_version - self.versions[0].date = datetime.datetime.now(datetime.timezone.utc).date().isoformat() + self.versions[0].date = ( + datetime.datetime.now(datetime.timezone.utc).date().isoformat() + ) def render(self) -> str: "Render the CHANGELOG to markdown." From 829dfbc3b3db32ea5c7bf8d841a30f1d91e54fce Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 08:41:12 -0700 Subject: [PATCH 24/59] Create a pull request with the changes --- .github/workflows/create-release-pr.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 0c3e3be..b1e0ece 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -77,3 +77,10 @@ jobs: CHANGELOG: caller/${{ inputs.changelog }} URL: ${{ github.server_url }}/${{ github.repository }} VERSION: ${{ steps.get-next-version.outputs.next_version }} + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + path: caller + add-paths: ${{ inputs.changelog }} + branch: automation-create-release-${{ steps.get-next-version.outputs.next_version }} From 8d7e43e46ff4d5ba64bffaa9f599b91d0afaf919 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 09:52:52 -0700 Subject: [PATCH 25/59] Add logging levels for GitHub Actions --- bumpchanges/getversion.py | 25 +++++++++++++++--- bumpchanges/logging.py | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 bumpchanges/logging.py diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index 1ba7523..be0f3d8 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -1,14 +1,17 @@ "Get the next tag version." +from pathlib import Path import os import subprocess -from pathlib import Path import semver +from .logging import setup_logging + def get_next_version(): "Return the next tag after the appropriate bump type." + logger = setup_logging() repo_dir = os.environ["REPO_DIR"] bump_type = os.environ["BUMP_TYPE"] exact_version = os.environ["EXACT_VERSION"] @@ -26,14 +29,30 @@ def get_next_version(): ).decode("utf-8") except subprocess.CalledProcessError: # It seems that this is the first release - print("WARNING: No prior tag found!") last_tag = "v0.0.0" + logger.warning("No prior tag found! Defaulting to %s", last_tag) # Strip off the leading v when parsing the version last_version = semver.Version.parse(last_tag[1:]) next_version = str(last_version.next_version(part=bump_type)) - print(f"{last_version} -> {bump_type} -> {next_version}") + logger.info("%s -> %s -> %s", last_version, bump_type, next_version) + next_tag = f"v{next_version}" + logger.notice("New version (tag): %s (%s)", next_version, next_tag) + + # Confirm that the corresponding git tag does not exist + try: + tag_ref = subprocess.check_output( + ["git", "rev-parse", "--verify", f"refs/tags/{next_tag}"], + cwd=repo_dir + ) + # Oops, that tag does exist + logger.error("Tag %s already exists! %s", next_tag, tag_ref) + raise RuntimeError() + + except subprocess.CalledProcessError: + # Tag doesn't exist yet - everything is good! + pass with output_file.open(mode="w", encoding="utf-8") as outfile: outfile.write(f"next_version={next_version}\n") diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py new file mode 100644 index 0000000..8f9dc9a --- /dev/null +++ b/bumpchanges/logging.py @@ -0,0 +1,55 @@ +"Module to handle logging to GitHub Actions." +import logging + + +NOTICE = 25 + + +class NoticeLogger(logging.getLoggerClass()): + "A logger subclass that has an additional NOTICE level." + def notice(self, msg, *args, **kwargs): + "Log the message at NOTICE level." + self.log(NOTICE, msg, *args, **kwargs) + + +class GHAFilter(logging.Filter): + "A logging filter that plays nice with GitHub Actions output." + prefixes = { + logging.DEBUG: "::debug::", + logging.INFO: "", + NOTICE: "::notice::", + logging.WARNING: "::warning::", + logging.ERROR: "::error::", + logging.CRITICAL: "::error::", + + } + + def filter(self, record): + record.gha_prefix = self.prefixes[record.levelno] + return True + + +def setup_logging() -> logging.Logger: + "Set up logging to GitHub Actions and return the configured logger." + # Does this need to be re-entrant like this? + logger_name = "bumpchanges" + + if logging.getLevelName("NOTICE") == NOTICE: + return logging.getLogger(logger_name) + + logging.addLevelName(NOTICE, "NOTICE") + + logging.setLoggerClass(NoticeLogger) + + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + handler.addFilter(GHAFilter()) + handler.setFormatter(logging.Formatter( + "%(ghaprefix)s%(message)s", + defaults={"ghaprefix": ""} + )) + + root_logger = logging.getLogger(logger_name) + root_logger.addHandler(handler) + + return root_logger From 21178b8c53d62b3f558b99f6996f12e02586500f Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 10:07:58 -0700 Subject: [PATCH 26/59] Log more things --- bumpchanges/__init__.py | 14 ++++++++++++-- bumpchanges/changelog.py | 12 ++++++++++-- bumpchanges/logging.py | 13 +++++-------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/bumpchanges/__init__.py b/bumpchanges/__init__.py index ee3d74a..525b0b9 100644 --- a/bumpchanges/__init__.py +++ b/bumpchanges/__init__.py @@ -2,15 +2,23 @@ "Work with CHANGELOG.md files." import argparse +from logging import getLogger from pathlib import Path -from .changelog import Changelog +from .changelog import Changelog, ChangelogError +from .logging import setup_logging def update_changelog(changelog_file: Path, repo_url: str, version: str): "Rewrite a CHANGELOG file for a new release." - changelog = Changelog(changelog_file, repo_url) + + try: + changelog = Changelog(changelog_file, repo_url) + except ChangelogError: + getLogger(__name__).exception("Could not parse changelog") + raise + changelog.update_version(version) changelog_file.write_text(changelog.render(), encoding="utf-8") @@ -24,6 +32,8 @@ def main(): parser.add_argument("version", type=str) args = parser.parse_args() + setup_logging() + update_changelog(args.changelog, args.repo_url, args.version) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index a422966..8e535d6 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -2,6 +2,7 @@ import datetime import itertools +import logging import re from pathlib import Path @@ -132,6 +133,8 @@ def from_tokens(cls, tokens): else: raise ChangelogError(f"Invalid section heading: {tokens[1].content}") + logging.getLogger(__name__).info("Parsed new version %s", kwargs.get("version")) + # The rest of the tokens should be the lists. Strip any rulers now. tokens = [token for token in tokens[3:] if token.type != "hr"] @@ -176,7 +179,6 @@ def from_tokens(cls, tokens): kwargs.setdefault("changed", []).extend(items) else: - print(tokens) raise ChangelogError("Don't know how to handle these tokens") assert not tokens @@ -251,6 +253,8 @@ def __init__(self, changelog_file: Path, repo_url: str): self.changelog_file = changelog_file self.repo_url = repo_url + logger = logging.getLogger(__name__) + groups = [[]] all_tokens = MarkdownIt("gfm-like").parse( @@ -275,6 +279,7 @@ def __init__(self, changelog_file: Path, repo_url: str): assert nexttoken is not None if re.match(r"^\[\d", nexttoken.content): token.tag = "h2" + logger.notice("Changing `%s` from h1 to h2", nexttoken.content) if token.tag == "h2": # A lot of our repositories have an issue where "Added", @@ -285,6 +290,7 @@ def __init__(self, changelog_file: Path, repo_url: str): r"Add|Fix|Change|Remove", nexttoken.content, flags=re.IGNORECASE ): token.tag = "h3" + logger.notice("Changing `%s` from h2 to h3", nexttoken.content) else: # Split split these tokens off into a new Version groups.append([]) @@ -301,7 +307,9 @@ def __init__(self, changelog_file: Path, repo_url: str): def update_version(self, next_version: str): "Move all unreleased changes under the new version." if not self.versions or self.versions[0].version != "Unreleased": - print("WARNING: No Unreleased section - adding a new empty section") + logging.getLogger(__name__).warning( + "No Unreleased section - adding a new empty section" + ) self.versions.insert(0, Version.blank_unreleased()) # Change the version and date of the unreleased section. For now diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index 8f9dc9a..cdb50da 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -29,13 +29,11 @@ def filter(self, record): return True -def setup_logging() -> logging.Logger: - "Set up logging to GitHub Actions and return the configured logger." +def setup_logging(): + "Set up logging to GitHub Actions.logger." # Does this need to be re-entrant like this? - logger_name = "bumpchanges" - if logging.getLevelName("NOTICE") == NOTICE: - return logging.getLogger(logger_name) + return logging.addLevelName(NOTICE, "NOTICE") @@ -49,7 +47,6 @@ def setup_logging() -> logging.Logger: defaults={"ghaprefix": ""} )) - root_logger = logging.getLogger(logger_name) + # Set these handlers on the root logger of this module + root_logger = logging.getLogger(__name__.rpartition('.')[0]) root_logger.addHandler(handler) - - return root_logger From beb5ca09359f63ae948cfda3706dfb49175b9fa3 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 10:09:53 -0700 Subject: [PATCH 27/59] Bugfix --- bumpchanges/getversion.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index be0f3d8..260c7ac 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -1,9 +1,11 @@ "Get the next tag version." -from pathlib import Path import os import subprocess +from logging import getLogger +from pathlib import Path + import semver from .logging import setup_logging @@ -11,7 +13,8 @@ def get_next_version(): "Return the next tag after the appropriate bump type." - logger = setup_logging() + setup_logging() + logger = getLogger(__name__) repo_dir = os.environ["REPO_DIR"] bump_type = os.environ["BUMP_TYPE"] exact_version = os.environ["EXACT_VERSION"] From 9e2335af71a265ff466b2af73d85afd8d3d72708 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 10:19:22 -0700 Subject: [PATCH 28/59] Logging bugfixes --- bumpchanges/logging.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index cdb50da..0ba0bca 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -25,7 +25,7 @@ class GHAFilter(logging.Filter): } def filter(self, record): - record.gha_prefix = self.prefixes[record.levelno] + record.ghaprefix = self.prefixes[record.levelno] return True @@ -41,12 +41,10 @@ def setup_logging(): handler = logging.StreamHandler() handler.setLevel(logging.DEBUG) - handler.addFilter(GHAFilter()) - handler.setFormatter(logging.Formatter( - "%(ghaprefix)s%(message)s", - defaults={"ghaprefix": ""} - )) + handler.setFormatter(logging.Formatter("%(ghaprefix)s%(message)s")) # Set these handlers on the root logger of this module root_logger = logging.getLogger(__name__.rpartition('.')[0]) root_logger.addHandler(handler) + root_logger.setLevel(logging.DEBUG) + root_logger.addFilter(GHAFilter()) From bfc0078dea589f6395e4ba89a58e5c8299f06901 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 10:30:05 -0700 Subject: [PATCH 29/59] The filter has to be set on the handler --- bumpchanges/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index 0ba0bca..52d361a 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -42,9 +42,9 @@ def setup_logging(): handler = logging.StreamHandler() handler.setLevel(logging.DEBUG) handler.setFormatter(logging.Formatter("%(ghaprefix)s%(message)s")) + handler.addFilter(GHAFilter()) # Set these handlers on the root logger of this module root_logger = logging.getLogger(__name__.rpartition('.')[0]) root_logger.addHandler(handler) root_logger.setLevel(logging.DEBUG) - root_logger.addFilter(GHAFilter()) From 10362ba06ccd57b9a6b8201ef18bf859e211e64f Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 10:33:43 -0700 Subject: [PATCH 30/59] Capture expected 'FATAL' output from tag check --- bumpchanges/getversion.py | 20 ++++++++++---------- bumpchanges/logging.py | 6 ++++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index 260c7ac..d6673b5 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -44,18 +44,18 @@ def get_next_version(): logger.notice("New version (tag): %s (%s)", next_version, next_tag) # Confirm that the corresponding git tag does not exist - try: - tag_ref = subprocess.check_output( - ["git", "rev-parse", "--verify", f"refs/tags/{next_tag}"], - cwd=repo_dir - ) + tag_ref_proc = subprocess.run( + ["git", "rev-parse", "--verify", f"refs/tags/{next_tag}"], + cwd=repo_dir, + capture_output=True, + check=False, + ) + if tag_ref_proc.returncode == 0: # Oops, that tag does exist - logger.error("Tag %s already exists! %s", next_tag, tag_ref) + logger.error( + "Tag %s already exists! %s", next_tag, tag_ref_proc.stdout.decode("utf-8") + ) raise RuntimeError() - except subprocess.CalledProcessError: - # Tag doesn't exist yet - everything is good! - pass - with output_file.open(mode="w", encoding="utf-8") as outfile: outfile.write(f"next_version={next_version}\n") diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index 52d361a..4273516 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -1,4 +1,5 @@ "Module to handle logging to GitHub Actions." + import logging @@ -7,6 +8,7 @@ class NoticeLogger(logging.getLoggerClass()): "A logger subclass that has an additional NOTICE level." + def notice(self, msg, *args, **kwargs): "Log the message at NOTICE level." self.log(NOTICE, msg, *args, **kwargs) @@ -14,6 +16,7 @@ def notice(self, msg, *args, **kwargs): class GHAFilter(logging.Filter): "A logging filter that plays nice with GitHub Actions output." + prefixes = { logging.DEBUG: "::debug::", logging.INFO: "", @@ -21,7 +24,6 @@ class GHAFilter(logging.Filter): logging.WARNING: "::warning::", logging.ERROR: "::error::", logging.CRITICAL: "::error::", - } def filter(self, record): @@ -45,6 +47,6 @@ def setup_logging(): handler.addFilter(GHAFilter()) # Set these handlers on the root logger of this module - root_logger = logging.getLogger(__name__.rpartition('.')[0]) + root_logger = logging.getLogger(__name__.rpartition(".")[0]) root_logger.addHandler(handler) root_logger.setLevel(logging.DEBUG) From 91f24b4233dfc3cf5e95f327362f1c6f906ab4c6 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 10:35:17 -0700 Subject: [PATCH 31/59] Minor output tweak --- bumpchanges/changelog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 8e535d6..38e1fdc 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -133,7 +133,7 @@ def from_tokens(cls, tokens): else: raise ChangelogError(f"Invalid section heading: {tokens[1].content}") - logging.getLogger(__name__).info("Parsed new version %s", kwargs.get("version")) + logging.getLogger(__name__).info("Parsed version: %s", kwargs.get("version")) # The rest of the tokens should be the lists. Strip any rulers now. tokens = [token for token in tokens[3:] if token.type != "hr"] From 0a9882f274e030d606e3637765d8eb731aaae38c Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 10:51:56 -0700 Subject: [PATCH 32/59] Refactor code --- .github/workflows/create-release-pr.yaml | 11 +++++---- bumpchanges/{__init__.py => bump.py} | 3 ++- bumpchanges/getversion.py | 30 +++++++++++++++++------- pyproject.toml | 4 ++-- 4 files changed, 33 insertions(+), 15 deletions(-) rename bumpchanges/{__init__.py => bump.py} (97%) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index b1e0ece..4d9ca1f 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -25,6 +25,10 @@ jobs: prepare-release: runs-on: ubuntu-latest + env: + BUMP_TYPE: ${{ inputs.bump_type }} + EXACT_VERSION: ${{ inputs.exact_version }} + steps: # Get the version of _this_ repository that is in use so that we can use # sidecar scripts @@ -65,14 +69,13 @@ jobs: # Get the next version using the package's script - id: get-next-version - run: get-next-version + run: get-next-version "$REPO_DIR" "$BUMP_TYPE" "$EXACT_VERSION" env: REPO_DIR: caller - BUMP_TYPE: ${{ inputs.bump_type }} - EXACT_VERSION: ${{ inputs.exact_version }} # Update the CHANGELOG - - run: bump-changelog "$CHANGELOG" "$URL" "$VERSION" + - id: bump-changelog + run: bump-changelog "$CHANGELOG" "$URL" "$VERSION" env: CHANGELOG: caller/${{ inputs.changelog }} URL: ${{ github.server_url }}/${{ github.repository }} diff --git a/bumpchanges/__init__.py b/bumpchanges/bump.py similarity index 97% rename from bumpchanges/__init__.py rename to bumpchanges/bump.py index 525b0b9..e0c5b52 100644 --- a/bumpchanges/__init__.py +++ b/bumpchanges/bump.py @@ -1,7 +1,8 @@ -#!/usr/bin/env python3 "Work with CHANGELOG.md files." import argparse +import os + from logging import getLogger from pathlib import Path diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index d6673b5..78fd9a7 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -1,5 +1,6 @@ "Get the next tag version." +import argparse import os import subprocess @@ -11,14 +12,9 @@ from .logging import setup_logging -def get_next_version(): +def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str: "Return the next tag after the appropriate bump type." - setup_logging() logger = getLogger(__name__) - repo_dir = os.environ["REPO_DIR"] - bump_type = os.environ["BUMP_TYPE"] - exact_version = os.environ["EXACT_VERSION"] - output_file = Path(os.environ["GITHUB_OUTPUT"]) if bump_type == "exact": last_version = "" @@ -57,5 +53,23 @@ def get_next_version(): ) raise RuntimeError() - with output_file.open(mode="w", encoding="utf-8") as outfile: - outfile.write(f"next_version={next_version}\n") + return next_version + + +def entrypoint(): + "Main entrypoint for this module." + setup_logging() + + parser = argparse.ArgumentParser() + parser.add_argument("repo_dir", type=Path) + parser.add_argument("bump_type", type=str) + parser.add_argument("exact_version", type=str) + + args = parser.parse_args() + setup_logging() + + next_version = get_next_version(args.repo_dir, args.bump_type, args.exact_version) + + Path(os.environ["GITHUB_OUTPUT"]).write_text( + f"next_version={next_version}\n", encoding="utf-8" + ) diff --git a/pyproject.toml b/pyproject.toml index 92151d7..3b100e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ maintainers = [ ] [project.scripts] -get-next-version = "bumpchanges:getversion.get_next_version" -bump-changelog = "bumpchanges:main" +get-next-version = "bumpchanges:getversion.main" +bump-changelog = "bumpchanges:bump.main" [build-system] requires = ["hatchling", "hatch-vcs"] From b0b2e77adc7c8139ac21b4efb94e334a702e15a4 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 11:29:30 -0700 Subject: [PATCH 33/59] Output PR and commit details --- bumpchanges/bump.py | 53 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/bumpchanges/bump.py b/bumpchanges/bump.py index e0c5b52..c1f5a68 100644 --- a/bumpchanges/bump.py +++ b/bumpchanges/bump.py @@ -2,6 +2,7 @@ import argparse import os +import tempfile from logging import getLogger @@ -25,6 +26,57 @@ def update_changelog(changelog_file: Path, repo_url: str, version: str): changelog_file.write_text(changelog.render(), encoding="utf-8") +def write_commit_details(version: str): + "Write text snippets for the eventual commit and pull request." + outputs = {} + + actor = os.environ["GITHUB_ACTOR"] + trigger_actor = os.environ["GITHUB_TRIGGERING_ACTOR"] + ref_name = os.environ["GITHUB_REF_NAME"] + bump_type = os.environ["BUMP_TYPE"] + exact_version = os.environ["EXACT_VERSION"] + + body_values = {} + body_values = {"Actor": f"@{actor}"} + + if trigger_actor != actor: + body_values["Triggering Actor"] = f"@{trigger_actor}" + + body_values.update({ + "Branch": f"`{ref_name}`", + "Bump Type": f"`{bump_type}`", + }) + + if bump_type == "exact": + body_values["Exact version"] = exact_version + + # Write the PR body into a temporary file + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=os.environ["GITHUB_WORKSPACE"], delete=False + ) as bodyfile: + bodyfile.write(f"""\ +Update CHANGELOG in preparation for release **{version}**. + +Merging this PR will trigger another workflow to create the release tag **v{version}**." + +| Input | Value | +| ----- | ----- | +""") + + for key, value in body_values.items(): + bodyfile.write(f"| {key} | {value} |\n") + + outputs["pr_bodyfile"] = bodyfile.name + + outputs["pr_title"] = f"Prepare for version **`{version}`**" + outputs["commit_message"] = f"Update CHANGELOG for version `{version}`" + + Path(os.environ["GITHUB_OUTPUT"]).write_text( + "\n".join(f"{key}={value}" for key, value in outputs.items()) + "\n", + encoding="utf-8", + ) + + def main(): "Main entrypoint." parser = argparse.ArgumentParser() @@ -36,6 +88,7 @@ def main(): setup_logging() update_changelog(args.changelog, args.repo_url, args.version) + write_commit_details(args.version) if __name__ == "__main__": From 68489ae003028f2a3fbfe52e287c7bf9aefb1dab Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 11:31:40 -0700 Subject: [PATCH 34/59] Add those details to the PR --- .github/workflows/create-release-pr.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 4d9ca1f..9bec869 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -87,3 +87,6 @@ jobs: path: caller add-paths: ${{ inputs.changelog }} branch: automation-create-release-${{ steps.get-next-version.outputs.next_version }} + commit-message: ${{ steps.bump-changelog.outputs.commit_message }} + title: ${{ steps.bump-changelog.outputs.pr_title }} + body-path: ${{ steps.bump-changelog.outputs.pr_bodyfile }} From bb02766e44445ee61a5ba2e2e4149613e6e197fa Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 11:36:26 -0700 Subject: [PATCH 35/59] Fix lints, add missing __init__.py file --- .gitignore | 1 + bumpchanges/__init__.py | 0 bumpchanges/changelog.py | 8 ++++---- bumpchanges/logging.py | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 bumpchanges/__init__.py diff --git a/.gitignore b/.gitignore index 857bf39..725cd55 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.rd[as] # Python +._version.py __pycache__/ .pytest_cache/ .Python diff --git a/bumpchanges/__init__.py b/bumpchanges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 38e1fdc..99bd20a 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -5,9 +5,9 @@ import logging import re -from pathlib import Path -from typing import ClassVar from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar, Optional import mdformat from markdown_it import MarkdownIt @@ -93,8 +93,8 @@ class Version: ) version: str - date: str | None = None - link: str | None = None + date: Optional[str] = None + link: Optional[str] = None # This is a CommonChangelog modification notices: list = field(default_factory=list) diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index 4273516..91b7c75 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -16,6 +16,7 @@ def notice(self, msg, *args, **kwargs): class GHAFilter(logging.Filter): "A logging filter that plays nice with GitHub Actions output." + # pylint: disable=too-few-public-methods prefixes = { logging.DEBUG: "::debug::", From 718967bfcd479e1b2d7df524f91ac9edc7950afe Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 11:39:35 -0700 Subject: [PATCH 36/59] Bugfixes --- bumpchanges/bump.py | 6 +----- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/bumpchanges/bump.py b/bumpchanges/bump.py index c1f5a68..43adefe 100644 --- a/bumpchanges/bump.py +++ b/bumpchanges/bump.py @@ -77,7 +77,7 @@ def write_commit_details(version: str): ) -def main(): +def entrypoint(): "Main entrypoint." parser = argparse.ArgumentParser() parser.add_argument("changelog", type=Path) @@ -89,7 +89,3 @@ def main(): update_changelog(args.changelog, args.repo_url, args.version) write_commit_details(args.version) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 3b100e0..ffb735c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ maintainers = [ ] [project.scripts] -get-next-version = "bumpchanges:getversion.main" -bump-changelog = "bumpchanges:bump.main" +get-next-version = "bumpchanges:getversion.entrypoint" +bump-changelog = "bumpchanges:bump.entrypoint" [build-system] requires = ["hatchling", "hatch-vcs"] From 29c431900a212203406f0411698d330a6d01dce8 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 11:59:01 -0700 Subject: [PATCH 37/59] Add timezone as an input --- .github/workflows/create-release-pr.yaml | 6 +++++ .github/workflows/pytest.yaml | 2 +- bumpchanges/bump.py | 29 +++++++++++++++++++----- bumpchanges/changelog.py | 8 +++---- pyproject.toml | 4 ++-- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index 9bec869..d179809 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -20,6 +20,11 @@ on: description: Exact version number to target. Only used if bump_type is set to `exact`. required: false default: "" + timezone: + type: string + description: IANA timezone to use when computing the current date + default: "America/Los_Angeles" + required: false jobs: prepare-release: @@ -28,6 +33,7 @@ jobs: env: BUMP_TYPE: ${{ inputs.bump_type }} EXACT_VERSION: ${{ inputs.exact_version }} + CHANGELOG_TIMEZONE: ${{ inputs.timezone }} steps: # Get the version of _this_ repository that is in use so that we can use diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 0761722..1bb1538 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.8" + - "3.9" - "3.12" steps: diff --git a/bumpchanges/bump.py b/bumpchanges/bump.py index 43adefe..d67b75c 100644 --- a/bumpchanges/bump.py +++ b/bumpchanges/bump.py @@ -1,18 +1,20 @@ "Work with CHANGELOG.md files." import argparse +import datetime +import logging import os import tempfile +import zoneinfo from logging import getLogger - from pathlib import Path from .changelog import Changelog, ChangelogError from .logging import setup_logging -def update_changelog(changelog_file: Path, repo_url: str, version: str): +def update_changelog(changelog_file: Path, repo_url: str, version: str, date: datetime.date): "Rewrite a CHANGELOG file for a new release." try: @@ -21,7 +23,7 @@ def update_changelog(changelog_file: Path, repo_url: str, version: str): getLogger(__name__).exception("Could not parse changelog") raise - changelog.update_version(version) + changelog.update_version(version, date) changelog_file.write_text(changelog.render(), encoding="utf-8") @@ -57,7 +59,7 @@ def write_commit_details(version: str): bodyfile.write(f"""\ Update CHANGELOG in preparation for release **{version}**. -Merging this PR will trigger another workflow to create the release tag **v{version}**." +Merging this PR will trigger another workflow to create the release tag **v{version}**. | Input | Value | | ----- | ----- | @@ -68,7 +70,7 @@ def write_commit_details(version: str): outputs["pr_bodyfile"] = bodyfile.name - outputs["pr_title"] = f"Prepare for version **`{version}`**" + outputs["pr_title"] = f"Prepare for version `{version}`" outputs["commit_message"] = f"Update CHANGELOG for version `{version}`" Path(os.environ["GITHUB_OUTPUT"]).write_text( @@ -87,5 +89,20 @@ def entrypoint(): args = parser.parse_args() setup_logging() - update_changelog(args.changelog, args.repo_url, args.version) + try: + input_timezone = os.environ["CHANGELOG_TIMEZONE"] + try: + tzinfo = zoneinfo.ZoneInfo(input_timezone) + except zoneinfo.ZoneInfoNotFoundError: + logging.getLogger(__name__).warning( + "Time zone `%s` not found! Defaulting to UTC", input_timezone + ) + tzinfo = datetime.timezone.utc + except KeyError: + logging.getLogger(__name__).notice("No time zone provided, defaulting to UTC") + tzinfo = datetime.timezone.utc + + now_date = datetime.datetime.now(tzinfo).date() + + update_changelog(args.changelog, args.repo_url, args.version, now_date) write_commit_details(args.version) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 99bd20a..62cf5b5 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -3,6 +3,7 @@ import datetime import itertools import logging +import os import re from dataclasses import dataclass, field @@ -304,7 +305,7 @@ def __init__(self, changelog_file: Path, repo_url: str): if not self.versions: raise ChangelogError("No versions!") - def update_version(self, next_version: str): + def update_version(self, next_version: str, date: datetime.date): "Move all unreleased changes under the new version." if not self.versions or self.versions[0].version != "Unreleased": logging.getLogger(__name__).warning( @@ -315,9 +316,8 @@ def update_version(self, next_version: str): # Change the version and date of the unreleased section. For now # explicitly assume UTC, but that should probably be an input. self.versions[0].version = next_version - self.versions[0].date = ( - datetime.datetime.now(datetime.timezone.utc).date().isoformat() - ) + self.versions[0].date = date.isoformat() + def render(self) -> str: "Render the CHANGELOG to markdown." diff --git a/pyproject.toml b/pyproject.toml index ffb735c..8101172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ dynamic = ["version"] keywords = ["changelog", "ci"] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "linkify-it-py>=2.0.3", @@ -38,7 +38,7 @@ version-file = "bumpchanges/_version.py" legacy_tox_ini = """ [tox] env_list = - py3.8 + py3.9 py3.12 [testenv] From 6f118a6d01a1a74ab4d3261c43b09cdaffde7ba8 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 12:17:16 -0700 Subject: [PATCH 38/59] Make sure the exact version doesn't have a leading `v` --- .github/workflows/create-release-pr.yaml | 4 ---- bumpchanges/getversion.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index d179809..c0518cb 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -1,8 +1,4 @@ --- -name: Manage new releases - -run-name: Prepare for new release via pull request - on: workflow_call: inputs: diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index 78fd9a7..2fc271c 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -1,6 +1,7 @@ "Get the next tag version." import argparse +import re import os import subprocess @@ -18,7 +19,16 @@ def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str: if bump_type == "exact": last_version = "" + if not exact_version: + logger.error("Exact version requested, but no version supplied!") + raise RuntimeError() + + if re.match(r"^v\d", exact_version): + logger.error("Input version `{exact_version}` should not have a leading `v`") + raise RuntimeError() + next_version = exact_version + else: # Get the most recent ancestor tag that matches r"v\d.*" try: From c8589059124a90192597ea3faf790d281f90d46f Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 15:40:34 -0700 Subject: [PATCH 39/59] Add workflow to create new tag after merge --- .github/workflows/create-new-tag.yaml | 49 ++++++++++++++++++++++++ .github/workflows/create-release-pr.yaml | 4 +- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/create-new-tag.yaml diff --git a/.github/workflows/create-new-tag.yaml b/.github/workflows/create-new-tag.yaml new file mode 100644 index 0000000..4a6a271 --- /dev/null +++ b/.github/workflows/create-new-tag.yaml @@ -0,0 +1,49 @@ +--- +on: + workflow_call: + +jobs: + release-tag: + runs-on: ubuntu-latest + + steps: + - id: parse-version + uses: actions/github-script@v7 + with: + script: | + // Sanity-check that this was called from an appropriate merge event and + // extract the version number embedded in the branch name. + if (context.eventName !== 'pull_request') { + core.setFailed('Workflow requires pull_request events') + process.exit() + } + + if (!context.payload.pull_request.merged || context.payload.pull_request.state !== 'closed') { + core.setFailed('Workflow should only be called on merged and closed PRs') + process.exit() + } + + if (context.payload.pull_request.user.type !== 'bot') { + core.setFailed('Workflow should only be called for bot-generated release PRs') + process.exit() + } + + // This regex needs to kept in-sync with the pattern in create-release-pr.yaml + const regex = /^automation-create-release-(.*)$/i + const parsed_version = context.payload.pull_request.head.label.match(regex) + + if (!parsed_version || !parsed_version[1].length) { + core.setFailed('Workflow not called from an appropriate branch name') + process.exit() + } + + github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: `v${new_version}`, + target_commitish: context.payload.pull_request.merge_commit_sha, + name: `Release ${new_version}`, + draft: true, + generate_release_notes: true, + body: `Automatically generated after merging #${context.payload.number}.` + }) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml index c0518cb..03912a8 100644 --- a/.github/workflows/create-release-pr.yaml +++ b/.github/workflows/create-release-pr.yaml @@ -88,7 +88,9 @@ jobs: with: path: caller add-paths: ${{ inputs.changelog }} - branch: automation-create-release-${{ steps.get-next-version.outputs.next_version }} commit-message: ${{ steps.bump-changelog.outputs.commit_message }} title: ${{ steps.bump-changelog.outputs.pr_title }} body-path: ${{ steps.bump-changelog.outputs.pr_bodyfile }} + # This branch name format needs to be kept in-sync with the parser in + # create-new-tag.yaml + branch: automation-create-release-${{ steps.get-next-version.outputs.next_version }} From 5c016adf217e3067fcab2e3618e711a9e0e8972e Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 15:59:56 -0700 Subject: [PATCH 40/59] Dump the GitHub context to log --- .github/workflows/create-new-tag.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/create-new-tag.yaml b/.github/workflows/create-new-tag.yaml index 4a6a271..d58c400 100644 --- a/.github/workflows/create-new-tag.yaml +++ b/.github/workflows/create-new-tag.yaml @@ -7,12 +7,18 @@ jobs: runs-on: ubuntu-latest steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - id: parse-version uses: actions/github-script@v7 with: script: | // Sanity-check that this was called from an appropriate merge event and // extract the version number embedded in the branch name. + if (context.eventName !== 'pull_request') { core.setFailed('Workflow requires pull_request events') process.exit() From 2c13cdcad1ed10300e079037e6212cf2b0142153 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 16:01:55 -0700 Subject: [PATCH 41/59] Use ref, not label --- .github/workflows/create-new-tag.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create-new-tag.yaml b/.github/workflows/create-new-tag.yaml index d58c400..2740e6b 100644 --- a/.github/workflows/create-new-tag.yaml +++ b/.github/workflows/create-new-tag.yaml @@ -36,7 +36,7 @@ jobs: // This regex needs to kept in-sync with the pattern in create-release-pr.yaml const regex = /^automation-create-release-(.*)$/i - const parsed_version = context.payload.pull_request.head.label.match(regex) + const parsed_version = context.payload.pull_request.head.ref.match(regex) if (!parsed_version || !parsed_version[1].length) { core.setFailed('Workflow not called from an appropriate branch name') From 6251c32b81ae29beb9130983c2475ae392aefa4f Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 16:06:30 -0700 Subject: [PATCH 42/59] Apparently the user is an Organization --- .github/workflows/create-new-tag.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-new-tag.yaml b/.github/workflows/create-new-tag.yaml index 2740e6b..ffc4473 100644 --- a/.github/workflows/create-new-tag.yaml +++ b/.github/workflows/create-new-tag.yaml @@ -29,8 +29,8 @@ jobs: process.exit() } - if (context.payload.pull_request.user.type !== 'bot') { - core.setFailed('Workflow should only be called for bot-generated release PRs') + if (!['Bot', 'Organization'].includes(context.payload.pull_request.user.type)) { + core.setFailed('Workflow should only be called for Bot- or Organization-generated release PRs') process.exit() } From a1adc7a72ef1cebc9c2ed850562448b813f0b2ce Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 16:07:26 -0700 Subject: [PATCH 43/59] Bugfix --- .github/workflows/create-new-tag.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/create-new-tag.yaml b/.github/workflows/create-new-tag.yaml index ffc4473..22f960f 100644 --- a/.github/workflows/create-new-tag.yaml +++ b/.github/workflows/create-new-tag.yaml @@ -43,6 +43,8 @@ jobs: process.exit() } + const new_version = parsed_version[1] + github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, From de8e7ff2640df225ca7c77004b945e9a7914c0f0 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 17:00:15 -0700 Subject: [PATCH 44/59] Move release logic into sidecar script --- .github/workflows/create-new-tag.yaml | 63 +++++++++------------------ scripts/create-tag.js | 54 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 42 deletions(-) create mode 100644 scripts/create-tag.js diff --git a/.github/workflows/create-new-tag.yaml b/.github/workflows/create-new-tag.yaml index 22f960f..d7019fb 100644 --- a/.github/workflows/create-new-tag.yaml +++ b/.github/workflows/create-new-tag.yaml @@ -7,51 +7,30 @@ jobs: runs-on: ubuntu-latest steps: - - name: Dump GitHub context + # Get the version of _this_ repository that is in use so that we can use + # sidecar scripts + - id: workflow-parsing + name: Get SHA of reusuable workflow env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + GH_TOKEN: ${{ github.token }} + run: | + ACTION_DATA=$(gh api "repos/$REPO/actions/runs/$RUN_ID") + echo "::debug::$ACTION_DATA" + SHA=$(echo "$ACTION_DATA" | jq -r '.referenced_workflows | .[] | select(.path | startswith("uclahs-cds/tool-create-release")).sha') + echo "SHA=$SHA" >> "$GITHUB_OUTPUT" + + - name: Checkout reusable repository + uses: actions/checkout@v4 + with: + repository: uclahs-cds/tool-create-release + ref: ${{ steps.workflow-parsing.outputs.SHA }} + token: ${{ secrets.UCLAHS_CDS_REPO_READ_TOKEN }} - id: parse-version uses: actions/github-script@v7 with: script: | - // Sanity-check that this was called from an appropriate merge event and - // extract the version number embedded in the branch name. - - if (context.eventName !== 'pull_request') { - core.setFailed('Workflow requires pull_request events') - process.exit() - } - - if (!context.payload.pull_request.merged || context.payload.pull_request.state !== 'closed') { - core.setFailed('Workflow should only be called on merged and closed PRs') - process.exit() - } - - if (!['Bot', 'Organization'].includes(context.payload.pull_request.user.type)) { - core.setFailed('Workflow should only be called for Bot- or Organization-generated release PRs') - process.exit() - } - - // This regex needs to kept in-sync with the pattern in create-release-pr.yaml - const regex = /^automation-create-release-(.*)$/i - const parsed_version = context.payload.pull_request.head.ref.match(regex) - - if (!parsed_version || !parsed_version[1].length) { - core.setFailed('Workflow not called from an appropriate branch name') - process.exit() - } - - const new_version = parsed_version[1] - - github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: `v${new_version}`, - target_commitish: context.payload.pull_request.merge_commit_sha, - name: `Release ${new_version}`, - draft: true, - generate_release_notes: true, - body: `Automatically generated after merging #${context.payload.number}.` - }) + const script = require('./scripts/create-tag.js') + await script({github, context, core}) diff --git a/scripts/create-tag.js b/scripts/create-tag.js new file mode 100644 index 0000000..58920d7 --- /dev/null +++ b/scripts/create-tag.js @@ -0,0 +1,54 @@ +module.exports = async ({ github, context, core }) => { + // Sanity-check that this was called from an appropriate merge event and + // extract the version number embedded in the branch name. + if (context.eventName !== 'pull_request') { + core.setFailed('Workflow requires pull_request events') + process.exit() + } + + if ( + !context.payload.pull_request.merged || + context.payload.pull_request.state !== 'closed' + ) { + core.setFailed('Workflow should only be called on merged and closed PRs') + process.exit() + } + + if ( + !['Bot', 'Organization'].includes(context.payload.pull_request.user.type) + ) { + core.setFailed( + 'Workflow should only be called for Bot- or Organization-generated release PRs' + ) + process.exit() + } + + // This regex needs to kept in-sync with the pattern in create-release-pr.yaml + const regex = /^automation-create-release-(.*)$/i + const parsedVersion = context.payload.pull_request.head.ref.match(regex) + + if (!parsedVersion || !parsedVersion[1].length) { + core.setFailed('Workflow not called from an appropriate branch name') + process.exit() + } + + const newVersion = parsedVersion[1] + + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: `v${newVersion}`, + target_commitish: context.payload.pull_request.merge_commit_sha, + name: `Release ${newVersion}`, + draft: true, + generate_release_notes: true, + body: `Automatically generated after merging #${context.payload.number}.` + }) + + // Attempt to delete the release branch + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${context.payload.pull_request.head.ref}` + }) +} From 3b9058a7d70aebea75a3d4f8d5d875b1a072b50b Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Tue, 30 Jul 2024 17:09:19 -0700 Subject: [PATCH 45/59] Rename scripts and workflows --- .../{create-new-tag.yaml => wf-finalize-release.yaml} | 4 ++-- .../{create-release-pr.yaml => wf-prepare-release.yaml} | 0 scripts/{create-tag.js => finalize-release.js} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename .github/workflows/{create-new-tag.yaml => wf-finalize-release.yaml} (92%) rename .github/workflows/{create-release-pr.yaml => wf-prepare-release.yaml} (100%) rename scripts/{create-tag.js => finalize-release.js} (100%) diff --git a/.github/workflows/create-new-tag.yaml b/.github/workflows/wf-finalize-release.yaml similarity index 92% rename from .github/workflows/create-new-tag.yaml rename to .github/workflows/wf-finalize-release.yaml index d7019fb..190760d 100644 --- a/.github/workflows/create-new-tag.yaml +++ b/.github/workflows/wf-finalize-release.yaml @@ -3,7 +3,7 @@ on: workflow_call: jobs: - release-tag: + finalize-release: runs-on: ubuntu-latest steps: @@ -32,5 +32,5 @@ jobs: uses: actions/github-script@v7 with: script: | - const script = require('./scripts/create-tag.js') + const script = require('./scripts/finalize-release.js') await script({github, context, core}) diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/wf-prepare-release.yaml similarity index 100% rename from .github/workflows/create-release-pr.yaml rename to .github/workflows/wf-prepare-release.yaml diff --git a/scripts/create-tag.js b/scripts/finalize-release.js similarity index 100% rename from scripts/create-tag.js rename to scripts/finalize-release.js From ac07fffad743fce77c5e04d7d1288e05055163d7 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 13:23:34 -0700 Subject: [PATCH 46/59] Add create-draft argument, post comment with link --- .github/workflows/wf-finalize-release.yaml | 7 +++++++ scripts/finalize-release.js | 15 ++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wf-finalize-release.yaml b/.github/workflows/wf-finalize-release.yaml index 190760d..5b7023e 100644 --- a/.github/workflows/wf-finalize-release.yaml +++ b/.github/workflows/wf-finalize-release.yaml @@ -1,6 +1,11 @@ --- on: workflow_call: + inputs: + create-draft: + description: If true (the default), draft the release for later manual approval. + type: boolean + default: true jobs: finalize-release: @@ -30,6 +35,8 @@ jobs: - id: parse-version uses: actions/github-script@v7 + env: + INPUT_CREATE_DRAFT: ${{ inputs.create-draft }} with: script: | const script = require('./scripts/finalize-release.js') diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index 58920d7..bdb37c7 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -34,21 +34,26 @@ module.exports = async ({ github, context, core }) => { const newVersion = parsedVersion[1] - await github.rest.repos.createRelease({ + const isDraft = core.getBooleanInput('create-draft') + + const releaseData = await github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, tag_name: `v${newVersion}`, target_commitish: context.payload.pull_request.merge_commit_sha, name: `Release ${newVersion}`, - draft: true, + draft: isDraft, generate_release_notes: true, body: `Automatically generated after merging #${context.payload.number}.` }) - // Attempt to delete the release branch - await github.rest.git.deleteRef({ + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - ref: `heads/${context.payload.pull_request.head.ref}` + issue_number: context.payload.number, + body: `*Bleep bloop, I am a robot.* + +A new release has been ${isDraft ? 'drafted' : 'created'} as ${releaseData.url}. Please review the details for accuracy. +` }) } From 76c59f5c40f30b2e0d76f5925e5ddc47d797718c Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 13:27:29 -0700 Subject: [PATCH 47/59] Bugfix --- scripts/finalize-release.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index bdb37c7..07ad8dd 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -34,7 +34,7 @@ module.exports = async ({ github, context, core }) => { const newVersion = parsedVersion[1] - const isDraft = core.getBooleanInput('create-draft') + const isDraft = core.getBooleanInput('create-draft', { required: false }) const releaseData = await github.rest.repos.createRelease({ owner: context.repo.owner, From a80435611ef1045bf3fa46ec8f4689b73ba150a1 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 13:30:39 -0700 Subject: [PATCH 48/59] See if the dash is the problem --- .github/workflows/wf-finalize-release.yaml | 4 ++-- scripts/finalize-release.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wf-finalize-release.yaml b/.github/workflows/wf-finalize-release.yaml index 5b7023e..4fda56d 100644 --- a/.github/workflows/wf-finalize-release.yaml +++ b/.github/workflows/wf-finalize-release.yaml @@ -2,7 +2,7 @@ on: workflow_call: inputs: - create-draft: + draft: description: If true (the default), draft the release for later manual approval. type: boolean default: true @@ -36,7 +36,7 @@ jobs: - id: parse-version uses: actions/github-script@v7 env: - INPUT_CREATE_DRAFT: ${{ inputs.create-draft }} + INPUT_DRAFT: ${{ inputs.draft }} with: script: | const script = require('./scripts/finalize-release.js') diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index 07ad8dd..a72d7fc 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -34,7 +34,7 @@ module.exports = async ({ github, context, core }) => { const newVersion = parsedVersion[1] - const isDraft = core.getBooleanInput('create-draft', { required: false }) + const isDraft = core.getBooleanInput('draft', { required: false }) const releaseData = await github.rest.repos.createRelease({ owner: context.repo.owner, From ec0bb309fa4cd97e40df03852e1e50a9fe4f3ea5 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 13:39:58 -0700 Subject: [PATCH 49/59] Use correct URL --- scripts/finalize-release.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index a72d7fc..4970956 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -53,7 +53,7 @@ module.exports = async ({ github, context, core }) => { issue_number: context.payload.number, body: `*Bleep bloop, I am a robot.* -A new release has been ${isDraft ? 'drafted' : 'created'} as ${releaseData.url}. Please review the details for accuracy. +A new release has been ${isDraft ? 'drafted' : 'created'} as ${releaseData.html_url}. Please review the details for accuracy. ` }) } From 29cccce1a8a25609d2e2b30d64baa720ccf77f8f Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 13:48:35 -0700 Subject: [PATCH 50/59] Log the release data --- scripts/finalize-release.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index 4970956..d7f5810 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -47,6 +47,9 @@ module.exports = async ({ github, context, core }) => { body: `Automatically generated after merging #${context.payload.number}.` }) + console.log(releaseData) + console.log(JSON.dumps(releaseData)) + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, From 73e602bb44e96c18287835a623202ceae5d2ff73 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 13:51:19 -0700 Subject: [PATCH 51/59] Bugfix --- scripts/finalize-release.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index d7f5810..279d00a 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -48,7 +48,6 @@ module.exports = async ({ github, context, core }) => { }) console.log(releaseData) - console.log(JSON.dumps(releaseData)) await github.rest.issues.createComment({ owner: context.repo.owner, From 6b6aa0c36ff6a685d507223f41c7001efb85f294 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 13:53:37 -0700 Subject: [PATCH 52/59] Get the proper URL --- scripts/finalize-release.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js index 279d00a..7f7b3b7 100644 --- a/scripts/finalize-release.js +++ b/scripts/finalize-release.js @@ -47,15 +47,13 @@ module.exports = async ({ github, context, core }) => { body: `Automatically generated after merging #${context.payload.number}.` }) - console.log(releaseData) - await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.number, body: `*Bleep bloop, I am a robot.* -A new release has been ${isDraft ? 'drafted' : 'created'} as ${releaseData.html_url}. Please review the details for accuracy. +A new release has been ${isDraft ? 'drafted' : 'created'} as ${releaseData.data.html_url}. Please review the details for accuracy. ` }) } From 85feb1c40557f1a981c0e137c7ea7a5abb0199d9 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 14:04:14 -0700 Subject: [PATCH 53/59] Update CHANGELOG --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 334a89c..fe6acd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,4 +11,5 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added -- Initial commit +- Workflow to update CHANGELOG and open pre-release PRs +- Workflow to create/draft releases after pre-release PR merge From 7719e915d33c6163990b4f4000ffc35904ede01e Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 14:30:13 -0700 Subject: [PATCH 54/59] Update README --- README.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/README.md b/README.md index 4b9aa17..0c15cab 100644 --- a/README.md +++ b/README.md @@ -1 +1,118 @@ # Automations for GitHub Releases + +This pair of reusable workflows manage the complexity of creating and tagging new software releases on GitHub. + +`wf-prepare-release.yaml` is triggered manually (via a `workflow_dispatch`) and takes the following actions: + +1. Compute the target version number based on existing tags and user input for `major`/`minor`/`patch`/`prerelease`. +1. Re-write the `CHANGELOG.md` file to move unreleased changes into a new dated release section. +1. Open a PR listing the target version number and release tag. + +`wf-finalize-release.yaml`, triggered when a release PR is merged, takes the following actions: + +1. Create a new release with auto-generated notes and the target tag. + * By default the new release is a draft, so no public release or tag are created without user intervention. +1. Comment on the release PR with a link to the new release. + +## Example Usage + +Usage of this tool requires adding two workflows to each calling repository: + +**`prepare-release.yaml`** + +```yaml +--- +name: Prepare new release + +run-name: Open PR for new ${{ inputs.bump_type }} release + +on: + workflow_dispatch: + inputs: + bump_type: + type: choice + description: >- + Semantic version bump type. Using `exact` is required for repositories + without semantic version tags and allows specifying the exact next tag + to use with the `exact_version` argument. + required: true + options: + - major + - minor + - patch + - prerelease + - exact + exact_version: + type: string + description: >- + Exact version number to target. Only used if bump_type is set to + `exact`. Do not include a leading `v`. + required: false + default: '' + +permissions: + actions: read + contents: write + pull-requests: write + +jobs: + prepare-release: + uses: uclahs-cds/tool-create-release/.github/workflows/wf-prepare-release.yaml@v1 + with: + bump_type: ${{ inputs.bump_type }} + exact_version: ${{ inputs.exact_version }} + # Secrets are only required until tool-create-release is made public + secrets: inherit +``` + +**`finalize-release.yaml`** + +```yaml +--- +name: Finalize release + +on: + pull_request: + branches: + - main + types: + - closed + +permissions: + actions: read + contents: write + pull-requests: write + +jobs: + finalize-release: + # This conditional ensures that the reusable workflow is only run for + # release pull requests. The called workflow repeats these checks. + if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'automation-create-release') }} + uses: uclahs-cds/tool-create-release/.github/workflows/wf-finalize-release.yaml@v1 + with: + draft: true + # Secrets are only required until tool-create-release is made public + secrets: inherit +``` + +## Parameters + +Parameters can be specified using the [`with`](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runsstepswith) option. + +| Workflow | Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | ---- | +| `wf-prepare-release.yaml` | `bump_type` | string | yes | Kind of semantic release version to target. Must be one of `major`, `minor`, `patch`, `prerelease`, or `exact`. Using `exact` requires `exact_version`. | +| `wf-prepare-release.yaml` | `exact_version` | string | no | The exact version to assign to the next release (only used if `bump_type` is `exact`). Must not include a leading `v` - use `1XXXX`, not `v1XXXX`. | +| `wf-prepare-release.yaml` | `changelog` | string | no | Relative path to the CHANGELOG file. Defaults to `./CHANGELOG.md`. | +| `wf-prepare-release.yaml` | `timezone` | string | no | IANA timezone to use when calculating the current date for the CHANGELOG. Defaults to `America/Los_Angeles`. | +| `wf-finalize-release.yaml` | `draft` | boolean | no | If true (the default), mark the new release as a draft and require manual intervention to continue. | + +## License + +tool-generate-docs is licensed under the GNU General Public License version 2. See the file LICENSE.md for the terms of the GNU GPL license. + +Copyright (C) 2024 University of California Los Angeles ("Boutros Lab") All rights reserved. + +This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. From 7bd303206efcd0a49253b6d9c14ffb03d09a98b2 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Thu, 1 Aug 2024 15:27:19 -0700 Subject: [PATCH 55/59] Fix lint --- bumpchanges/changelog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 62cf5b5..19ae44b 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -3,7 +3,6 @@ import datetime import itertools import logging -import os import re from dataclasses import dataclass, field From 644ce8eb236687d95aa75ed405ec2d43fda2a688 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Fri, 2 Aug 2024 09:32:44 -0700 Subject: [PATCH 56/59] Run code through ruff --- bumpchanges/bump.py | 10 +++++++--- bumpchanges/changelog.py | 1 - bumpchanges/getversion.py | 4 +++- bumpchanges/logging.py | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/bumpchanges/bump.py b/bumpchanges/bump.py index d67b75c..61597d8 100644 --- a/bumpchanges/bump.py +++ b/bumpchanges/bump.py @@ -14,7 +14,9 @@ from .logging import setup_logging -def update_changelog(changelog_file: Path, repo_url: str, version: str, date: datetime.date): +def update_changelog( + changelog_file: Path, repo_url: str, version: str, date: datetime.date +): "Rewrite a CHANGELOG file for a new release." try: @@ -56,14 +58,16 @@ def write_commit_details(version: str): with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=os.environ["GITHUB_WORKSPACE"], delete=False ) as bodyfile: - bodyfile.write(f"""\ + bodyfile.write( + f"""\ Update CHANGELOG in preparation for release **{version}**. Merging this PR will trigger another workflow to create the release tag **v{version}**. | Input | Value | | ----- | ----- | -""") +""" + ) for key, value in body_values.items(): bodyfile.write(f"| {key} | {value} |\n") diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 19ae44b..4e8ad78 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -317,7 +317,6 @@ def update_version(self, next_version: str, date: datetime.date): self.versions[0].version = next_version self.versions[0].date = date.isoformat() - def render(self) -> str: "Render the CHANGELOG to markdown." renderer = mdformat.renderer.MDRenderer() diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index 2fc271c..fcb2c8f 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -24,7 +24,9 @@ def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str: raise RuntimeError() if re.match(r"^v\d", exact_version): - logger.error("Input version `{exact_version}` should not have a leading `v`") + logger.error( + "Input version `{exact_version}` should not have a leading `v`" + ) raise RuntimeError() next_version = exact_version diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index 91b7c75..196a4dd 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -16,6 +16,7 @@ def notice(self, msg, *args, **kwargs): class GHAFilter(logging.Filter): "A logging filter that plays nice with GitHub Actions output." + # pylint: disable=too-few-public-methods prefixes = { From f66c0b306483a2d05a613379f38dd9b700b89ec0 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Fri, 2 Aug 2024 09:48:50 -0700 Subject: [PATCH 57/59] Format docstrings to use triple-quotes --- bumpchanges/bump.py | 6 +++--- bumpchanges/changelog.py | 26 +++++++++++++------------- bumpchanges/getversion.py | 6 +++--- bumpchanges/logging.py | 10 +++++----- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/bumpchanges/bump.py b/bumpchanges/bump.py index 61597d8..4aef2bd 100644 --- a/bumpchanges/bump.py +++ b/bumpchanges/bump.py @@ -1,4 +1,4 @@ -"Work with CHANGELOG.md files." +"""Work with CHANGELOG.md files.""" import argparse import datetime @@ -31,7 +31,7 @@ def update_changelog( def write_commit_details(version: str): - "Write text snippets for the eventual commit and pull request." + """Write text snippets for the eventual commit and pull request.""" outputs = {} actor = os.environ["GITHUB_ACTOR"] @@ -84,7 +84,7 @@ def write_commit_details(version: str): def entrypoint(): - "Main entrypoint." + """Main entrypoint.""" parser = argparse.ArgumentParser() parser.add_argument("changelog", type=Path) parser.add_argument("repo_url", type=str) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 4e8ad78..59dff15 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -1,4 +1,4 @@ -"Classes to handle parsing and updating CHANGELOG.md files." +"""Classes to handle parsing and updating CHANGELOG.md files.""" import datetime import itertools @@ -15,15 +15,15 @@ class ChangelogError(Exception): - "Indicate a fundamental problem with the CHANGELOG structure." + """Indicate a fundamental problem with the CHANGELOG structure.""" class EmptyListError(Exception): - "Indicate that a section is empty and should be stripped." + """Indicate that a section is empty and should be stripped.""" def parse_heading(tokens: list[Token]) -> tuple[str, Token]: - "Parse the `inline` element from the heading." + """Parse the `inline` element from the heading.""" if ( len(tokens) < 3 or tokens[0].type != "heading_open" @@ -39,7 +39,7 @@ def parse_heading(tokens: list[Token]) -> tuple[str, Token]: def parse_bullet_list(tokens: list[Token]) -> list[Token]: - "Consume tokens and return all of the child list_items." + """Consume tokens and return all of the child list_items.""" # Parse the heading if not tokens or tokens[0].type != "bullet_list_open": raise EmptyListError() @@ -63,7 +63,7 @@ def parse_bullet_list(tokens: list[Token]) -> list[Token]: def heading(level: int, children: list): - "Return a heading of the appropriate level." + """Return a heading of the appropriate level.""" markup = "#" * level tag = f"h{level}" @@ -83,7 +83,7 @@ def heading(level: int, children: list): @dataclass class Version: - "Class to help manage individual releases within CHANGELOG.md files." + """Class to help manage individual releases within CHANGELOG.md files.""" link_heading_re: ClassVar = re.compile( r"^\[(?P.+?)\]\((?P.+?)\)(?:\s+-\s+(?P.*))?$" @@ -108,12 +108,12 @@ class Version: @classmethod def blank_unreleased(cls): - "Create a new empty Unreleased version." + """Create a new empty Unreleased version.""" return cls(version="Unreleased") @classmethod def from_tokens(cls, tokens): - "Parse a Version from a token stream." + """Parse a Version from a token stream.""" # Open, content, close if ( len(tokens) < 3 @@ -186,7 +186,7 @@ def from_tokens(cls, tokens): return cls(**kwargs) def serialize(self): - "Yield a stream of markdown tokens describing this Version." + """Yield a stream of markdown tokens describing this Version.""" link_kwargs = {} if self.link: @@ -247,7 +247,7 @@ def serialize(self): class Changelog: - "Class to help manage CHANGELOG.md files." + """Class to help manage CHANGELOG.md files.""" def __init__(self, changelog_file: Path, repo_url: str): self.changelog_file = changelog_file @@ -305,7 +305,7 @@ def __init__(self, changelog_file: Path, repo_url: str): raise ChangelogError("No versions!") def update_version(self, next_version: str, date: datetime.date): - "Move all unreleased changes under the new version." + """Move all unreleased changes under the new version.""" if not self.versions or self.versions[0].version != "Unreleased": logging.getLogger(__name__).warning( "No Unreleased section - adding a new empty section" @@ -318,7 +318,7 @@ def update_version(self, next_version: str, date: datetime.date): self.versions[0].date = date.isoformat() def render(self) -> str: - "Render the CHANGELOG to markdown." + """Render the CHANGELOG to markdown.""" renderer = mdformat.renderer.MDRenderer() options = {} diff --git a/bumpchanges/getversion.py b/bumpchanges/getversion.py index fcb2c8f..4680664 100644 --- a/bumpchanges/getversion.py +++ b/bumpchanges/getversion.py @@ -1,4 +1,4 @@ -"Get the next tag version." +"""Get the next tag version.""" import argparse import re @@ -14,7 +14,7 @@ def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str: - "Return the next tag after the appropriate bump type." + """Return the next tag after the appropriate bump type.""" logger = getLogger(__name__) if bump_type == "exact": @@ -69,7 +69,7 @@ def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str: def entrypoint(): - "Main entrypoint for this module." + """Main entrypoint for this module.""" setup_logging() parser = argparse.ArgumentParser() diff --git a/bumpchanges/logging.py b/bumpchanges/logging.py index 196a4dd..70da98a 100644 --- a/bumpchanges/logging.py +++ b/bumpchanges/logging.py @@ -1,4 +1,4 @@ -"Module to handle logging to GitHub Actions." +"""Module to handle logging to GitHub Actions.""" import logging @@ -7,15 +7,15 @@ class NoticeLogger(logging.getLoggerClass()): - "A logger subclass that has an additional NOTICE level." + """A logger subclass that has an additional NOTICE level.""" def notice(self, msg, *args, **kwargs): - "Log the message at NOTICE level." + """Log the message at NOTICE level.""" self.log(NOTICE, msg, *args, **kwargs) class GHAFilter(logging.Filter): - "A logging filter that plays nice with GitHub Actions output." + """A logging filter that plays nice with GitHub Actions output.""" # pylint: disable=too-few-public-methods @@ -34,7 +34,7 @@ def filter(self, record): def setup_logging(): - "Set up logging to GitHub Actions.logger." + """Set up logging to GitHub Actions.logger.""" # Does this need to be re-entrant like this? if logging.getLevelName("NOTICE") == NOTICE: return From 8fd9cc9f6bbec066dfa46233c4e4b387e73be6c5 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Fri, 2 Aug 2024 12:06:02 -0700 Subject: [PATCH 58/59] Replace asserts with exceptions --- bumpchanges/changelog.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 59dff15..6487757 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -54,8 +54,9 @@ def parse_bullet_list(tokens: list[Token]) -> list[Token]: if nesting == 0: break - assert list_tokens[0].type == "bullet_list_open" - assert list_tokens[-1].type == "bullet_list_close" + if list_tokens[0].type != "bullet_list_open" or \ + list_tokens[-1].type != "bullet_list_close": + raise ChangelogError("Bullet list is malformed!") # Strip off the bullet list so that we can assert our own style and merge # lists @@ -181,7 +182,8 @@ def from_tokens(cls, tokens): else: raise ChangelogError("Don't know how to handle these tokens") - assert not tokens + if tokens: + raise ChangelogError("Leftover tokens!") return cls(**kwargs) @@ -269,14 +271,18 @@ def __init__(self, changelog_file: Path, repo_url: str): ], ) ): - assert token is not None + # This check is mostly to make pyright happy + if token is None: + raise RuntimeError("This should never happen") if token.type == "heading_open": if token.tag == "h1": # Several of our repositories have errors where versions # are mistakenly H1s rather than H2s. Catch those cases and # fix them up. - assert nexttoken is not None + if nexttoken is None: + raise ChangelogError() + if re.match(r"^\[\d", nexttoken.content): token.tag = "h2" logger.notice("Changing `%s` from h1 to h2", nexttoken.content) @@ -285,7 +291,9 @@ def __init__(self, changelog_file: Path, repo_url: str): # A lot of our repositories have an issue where "Added", # "Fixed", etc. are mistakenly H2s rather than H3s. Catch those # cases and fix them up. - assert nexttoken is not None + if nexttoken is None: + raise ChangelogError() + if re.match( r"Add|Fix|Change|Remove", nexttoken.content, flags=re.IGNORECASE ): From eca4a83de87202e7a02e3af8b7b5f6a71ee01552 Mon Sep 17 00:00:00 2001 From: Nicholas Wiltsie Date: Fri, 2 Aug 2024 12:08:03 -0700 Subject: [PATCH 59/59] Silence warning about extra branches from asserts --- bumpchanges/changelog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bumpchanges/changelog.py b/bumpchanges/changelog.py index 6487757..32bd596 100644 --- a/bumpchanges/changelog.py +++ b/bumpchanges/changelog.py @@ -115,6 +115,7 @@ def blank_unreleased(cls): @classmethod def from_tokens(cls, tokens): """Parse a Version from a token stream.""" + # pylint: disable=too-many-branches # Open, content, close if ( len(tokens) < 3