diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml new file mode 100644 index 0000000..1bb1538 --- /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.9" + - "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/.github/workflows/wf-finalize-release.yaml b/.github/workflows/wf-finalize-release.yaml new file mode 100644 index 0000000..4fda56d --- /dev/null +++ b/.github/workflows/wf-finalize-release.yaml @@ -0,0 +1,43 @@ +--- +on: + workflow_call: + inputs: + draft: + description: If true (the default), draft the release for later manual approval. + type: boolean + default: true + +jobs: + finalize-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 }} + 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 + env: + INPUT_DRAFT: ${{ inputs.draft }} + with: + script: | + const script = require('./scripts/finalize-release.js') + await script({github, context, core}) diff --git a/.github/workflows/wf-prepare-release.yaml b/.github/workflows/wf-prepare-release.yaml new file mode 100644 index 0000000..03912a8 --- /dev/null +++ b/.github/workflows/wf-prepare-release.yaml @@ -0,0 +1,96 @@ +--- +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: "" + timezone: + type: string + description: IANA timezone to use when computing the current date + default: "America/Los_Angeles" + required: false + +jobs: + prepare-release: + runs-on: ubuntu-latest + + 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 + # sidecar scripts + - id: workflow-parsing + name: Get SHA of reusuable workflow + env: + 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 + path: reusable + ref: ${{ steps.workflow-parsing.outputs.SHA }} + token: ${{ secrets.UCLAHS_CDS_REPO_READ_TOKEN }} + + - name: Checkout calling repository + uses: actions/checkout@v4 + with: + path: caller + fetch-depth: 0 + fetch-tags: true + + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + # Install the bundled package + - run: pip install ./reusable + + # Get the next version using the package's script + - id: get-next-version + run: get-next-version "$REPO_DIR" "$BUMP_TYPE" "$EXACT_VERSION" + env: + REPO_DIR: caller + + # Update the CHANGELOG + - id: bump-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 }} + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + path: caller + add-paths: ${{ inputs.changelog }} + 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 }} 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/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 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. diff --git a/bumpchanges/__init__.py b/bumpchanges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bumpchanges/bump.py b/bumpchanges/bump.py new file mode 100644 index 0000000..4aef2bd --- /dev/null +++ b/bumpchanges/bump.py @@ -0,0 +1,112 @@ +"""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, date: datetime.date +): + "Rewrite a CHANGELOG file for a new release." + + try: + changelog = Changelog(changelog_file, repo_url) + except ChangelogError: + getLogger(__name__).exception("Could not parse changelog") + raise + + changelog.update_version(version, date) + + 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 entrypoint(): + """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() + setup_logging() + + 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 new file mode 100644 index 0000000..32bd596 --- /dev/null +++ b/bumpchanges/changelog.py @@ -0,0 +1,366 @@ +"""Classes to handle parsing and updating CHANGELOG.md files.""" + +import datetime +import itertools +import logging +import re + +from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar, Optional + +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 + + 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 + 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: Optional[str] = None + link: Optional[str] = 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): + """Parse a Version from a token stream.""" + # pylint: disable=too-many-branches + # 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}") + + 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"] + + 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: + raise ChangelogError("Don't know how to handle these tokens") + + if tokens: + raise ChangelogError("Leftover 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 + + logger = logging.getLogger(__name__) + + 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, + ], + ) + ): + # 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. + 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) + + 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. + if nexttoken is None: + raise ChangelogError() + + if re.match( + 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([]) + + 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, 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( + "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 = 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/bumpchanges/getversion.py b/bumpchanges/getversion.py new file mode 100644 index 0000000..4680664 --- /dev/null +++ b/bumpchanges/getversion.py @@ -0,0 +1,87 @@ +"""Get the next tag version.""" + +import argparse +import re +import os +import subprocess + +from logging import getLogger +from pathlib import Path + +import semver + +from .logging import setup_logging + + +def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str: + """Return the next tag after the appropriate bump type.""" + logger = getLogger(__name__) + + 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: + 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 + 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)) + + 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 + 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_proc.stdout.decode("utf-8") + ) + raise RuntimeError() + + 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/bumpchanges/logging.py b/bumpchanges/logging.py new file mode 100644 index 0000000..70da98a --- /dev/null +++ b/bumpchanges/logging.py @@ -0,0 +1,54 @@ +"""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.""" + + # pylint: disable=too-few-public-methods + + prefixes = { + logging.DEBUG: "::debug::", + logging.INFO: "", + NOTICE: "::notice::", + logging.WARNING: "::warning::", + logging.ERROR: "::error::", + logging.CRITICAL: "::error::", + } + + def filter(self, record): + record.ghaprefix = self.prefixes[record.levelno] + return True + + +def setup_logging(): + """Set up logging to GitHub Actions.logger.""" + # Does this need to be re-entrant like this? + if logging.getLevelName("NOTICE") == NOTICE: + return + + logging.addLevelName(NOTICE, "NOTICE") + + logging.setLoggerClass(NoticeLogger) + + 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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8101172 --- /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.9" + +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.entrypoint" +bump-changelog = "bumpchanges:bump.entrypoint" + +[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.9 + py3.12 + +[testenv] +deps = pytest +commands = pytest tests --doctest-modules --junitxml=junit/test-results.xml +""" diff --git a/scripts/finalize-release.js b/scripts/finalize-release.js new file mode 100644 index 0000000..7f7b3b7 --- /dev/null +++ b/scripts/finalize-release.js @@ -0,0 +1,59 @@ +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] + + const isDraft = core.getBooleanInput('draft', { required: false }) + + 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: isDraft, + generate_release_notes: true, + body: `Automatically generated after merging #${context.payload.number}.` + }) + + 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.data.html_url}. Please review the details for accuracy. +` + }) +} 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