diff --git a/.github/renovate.json b/.github/renovate.json index 4ca8c2ff..caa22da4 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -3,7 +3,8 @@ "dependencyDashboard": true, "extends": [ "config:best-practices", - "group:githubArtifactActions" + "group:githubArtifactActions", + ":separatePatchReleases" ], "ignoreDeps": [ "tektronix/python-package-ci-cd" @@ -75,6 +76,19 @@ "patch" ] }, + { + "description": "Allow automerge for minor updates of certain packages", + "matchPackageNames": [ + "certifi" + ] + }, + { + "description": "Group together all pydantic dependencies", + "groupName": "pydantic dependencies", + "matchPackageNames": [ + "/^pydantic/" + ] + }, { "automerge": false, "description": "Group together all python-semantic-release dependencies", diff --git a/.github/workflows/_reusable-package-release.yml b/.github/workflows/_reusable-package-release.yml index f3a8327f..f8e296bb 100644 --- a/.github/workflows/_reusable-package-release.yml +++ b/.github/workflows/_reusable-package-release.yml @@ -92,6 +92,9 @@ jobs: contents: read steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + fetch-depth: 0 + fetch-tags: true - name: python-versions-array input missing if: ${{ inputs.build-and-publish-python-package == true && (inputs.python-versions-array == null || inputs.python-versions-array == '') }} run: | @@ -127,6 +130,7 @@ jobs: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: fetch-depth: 0 + fetch-tags: true token: ${{ secrets.checkout-token }} - if: ${{ endsWith(github.repository, '/python-package-ci-cd') }} # Run the local action when this is run in the python-package-ci-cd repository uses: ./actions/find_unreleased_changelog_items diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 7382b45a..881da477 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -12,6 +12,8 @@ on: minor for backward compatible larger changes, major for non-backward compatible changes. options: [patch, minor, major] + schedule: + - cron: 0 16 * * 2 concurrency: group: pypi jobs: @@ -23,7 +25,7 @@ jobs: build-and-publish-python-package: false commit-user-name: ${{ vars.TEK_OPENSOURCE_NAME }} commit-user-email: ${{ vars.TEK_OPENSOURCE_EMAIL }} - release-level: ${{ inputs.release-level }} + release-level: ${{ inputs.release-level || 'patch' }} previous-changelog-filepath: python_semantic_release_templates/.previous_changelog_for_template.md previous-release-notes-filepath: python_semantic_release_templates/.previous_release_notes_for_template.md permissions: diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 63132fd7..23f006bb 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -101,6 +101,8 @@ jobs: MULTILINE_STRING=$(cat <<'EOF' ## Workflow Inputs - release-level: patch + ## PRs Merged Since Last Release + ## Incoming Changes Things to be included in the next release go here. ### Added diff --git a/CHANGELOG.md b/CHANGELOG.md index f44d77b7..3d15e951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,11 @@ Things to be included in the next release go here. ### Added - Added the ability for the `update_development_dependencies` action to accept a comma-separated, multiline string +- Added all PRs merged since the last release to the job summary for the release workflow ### Changed +- Bumped dependency versions. - Changed the `_reusable-update-python-and-pre-commit-dependencies.yml` workflow to no longer only work on PRs from Dependabot, users will now need to apply any conditional login in the calling workflow. - Updated the `_reusable-update-python-and-pre-commit-dependencies.yml` workflow to allow using [`renovate`](https://docs.renovatebot.com/) instead of Dependabot to update dependencies. diff --git a/actions/find_unreleased_changelog_items/Dockerfile b/actions/find_unreleased_changelog_items/Dockerfile index ed2983b6..e29b591a 100644 --- a/actions/find_unreleased_changelog_items/Dockerfile +++ b/actions/find_unreleased_changelog_items/Dockerfile @@ -5,6 +5,9 @@ COPY requirements.txt /requirements.txt COPY main.py /main.py # Install dependencies +RUN apk update && \ + apk add --no-cache git && \ + rm -rf /var/cache/apk/* RUN python -m pip install --no-cache-dir --requirement /requirements.txt # Run the Python script as the entrypoint diff --git a/actions/find_unreleased_changelog_items/main.py b/actions/find_unreleased_changelog_items/main.py index 77c4f599..9d48c27c 100644 --- a/actions/find_unreleased_changelog_items/main.py +++ b/actions/find_unreleased_changelog_items/main.py @@ -17,11 +17,52 @@ import os import pathlib import re +import shlex import shutil +import subprocess CHANGELOG_FILE = pathlib.Path("./CHANGELOG.md") +def run_cmd_in_subprocess(command: str) -> str: + """Run the given command in a subprocess and return the result. + + Args: + command: The command string to send. + + Returns: + The output from the command. + """ + command = command.replace("\\", "/") + print(f"\nExecuting command: {command}") + return subprocess.check_output(shlex.split(command)).decode() # noqa: S603 + + +def get_latest_tag() -> str | None: + """Retrieve the latest tag in the Git repository. + + Returns: + The latest tag as a string if it exists, otherwise None. + """ + try: + return run_cmd_in_subprocess("git describe --tags --abbrev=0").strip() + except subprocess.CalledProcessError: + return None + + +def get_commit_messages(since_tag: str | None = None) -> list[str]: + """Retrieve commit messages from the Git repository. + + Args: + since_tag: The tag from which to start listing commits. If None, lists all commits. + + Returns: + A list of commit messages as strings. + """ + range_spec = f"{since_tag}..HEAD" if since_tag else "HEAD" + return run_cmd_in_subprocess(f"git log {range_spec} --pretty=format:%s").splitlines() + + def main() -> None: """Check for entries in the Unreleased section of the CHANGELOG.md file. @@ -76,12 +117,21 @@ def main() -> None: # If running in GitHub Actions, and the release_level is set, send the release level and # incoming changes to the GitHub Summary if release_level: + root_dir = pathlib.Path.cwd() + run_cmd_in_subprocess( + f'git config --global --add safe.directory "{root_dir.resolve().as_posix()}"' + ) + commit_messages = get_commit_messages(since_tag=get_latest_tag()) + + pr_regex = re.compile(r"\(#\d+\)$") + pr_descriptions = "\n".join([f"- {msg}" for msg in commit_messages if pr_regex.search(msg)]) summary_contents = ( f"## Workflow Inputs\n- release-level: {release_level}\n" + f"## PRs Merged Since Last Release\n{pr_descriptions}\n" f"## Incoming Changes\n{release_notes_content.replace('## Unreleased', '').strip()}\n" ) print( - f"Adding the following contents to the GitHub Workflow Summary:\n\n{summary_contents}" + f"\nAdding the following contents to the GitHub Workflow Summary:\n\n{summary_contents}" ) with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary_file: # noqa: PTH123 summary_file.write(summary_contents) diff --git a/pyproject.toml b/pyproject.toml index 2ed5c936..6b583c1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ pytest-env = "^1.1.3" pytest-github-report = "^0.0.1" pytest-html = "^4.1.1" pytest-order = "^1.2.1" +pytest-subprocess = "^1.5.2" [tool.pyright] ignore = [ @@ -109,7 +110,8 @@ junit_family = "xunit2" junit_logging = "all" markers = [ 'docs', - 'order' + 'order', + 'slow' ] pythonpath = "." xfail_strict = true diff --git a/python_semantic_release_templates/CHANGELOG.md.j2 b/python_semantic_release_templates/CHANGELOG.md.j2 index 73bf2c1e..feca00e2 100644 --- a/python_semantic_release_templates/CHANGELOG.md.j2 +++ b/python_semantic_release_templates/CHANGELOG.md.j2 @@ -1,7 +1,7 @@ {%- import ".macros.j2" as macros %} {%- call(output) macros.populate_variables() %} - {%- filter replace("## Unreleased", "## Unreleased\n\nThings to be included in the next release go here.\n\n---\n\n## " + output[0] + " (" + output[1] + ")" + output[2]) %} + {%- filter replace("## Unreleased", "## Unreleased\n\nThings to be included in the next release go here.\n\n### Changed\n\n- Bumped dependency versions.\n\n---\n\n## " + output[0] + " (" + output[1] + ")" + output[2]) %} {%- filter replace("Things to be included in the next release go here.\n\n", "") %} {%- include ".previous_changelog_for_template.md" %} {%- endfilter %} diff --git a/tests/test_find_unreleased_changelog_items.py b/tests/test_find_unreleased_changelog_items.py index 6ccba91e..b6b6f103 100644 --- a/tests/test_find_unreleased_changelog_items.py +++ b/tests/test_find_unreleased_changelog_items.py @@ -2,15 +2,21 @@ from __future__ import annotations +import shlex +import subprocess + from typing import TYPE_CHECKING +from unittest import mock import pytest -from actions.find_unreleased_changelog_items.main import main +from actions.find_unreleased_changelog_items.main import get_commit_messages, get_latest_tag, main if TYPE_CHECKING: from pathlib import Path + from pytest_subprocess import FakeProcess + PREVIOUS_CHANGELOG_FILEPATH = "previous_changelog.md" PREVIOUS_RELEASE_NOTES_FILEPATH = "previous_release_notes.md" MOCK_TEMPLATES_FOLDER = "mock_templates" @@ -76,15 +82,28 @@ def mock_env_vars( monkeypatch: pytest.MonkeyPatch, summary_file: Path, mock_previous_files: tuple[Path, Path], + fake_process: FakeProcess, ) -> None: """Mock the environment variables to simulate GitHub Actions inputs. + This fixture also mocks subprocess.check_output to enable testing to function without running + git commands. + Args: tmp_path: The temporary path fixture. monkeypatch: The monkeypatch fixture. summary_file: The path to the job summary file. mock_previous_files: Paths to the previous changelog file and previous release notes file. + fake_process: The fake_process fixture, used to register commands that will be mocked. """ + fake_process.register( # pyright: ignore[reportUnknownMemberType] + shlex.split(f"git config --global --add safe.directory {tmp_path.resolve().as_posix()}") + ) + fake_process.register(shlex.split("git describe --tags --abbrev=0"), stdout=b"v1.0.0\n") # pyright: ignore[reportUnknownMemberType] + fake_process.register( # pyright: ignore[reportUnknownMemberType] + shlex.split("git log v1.0.0..HEAD --pretty=format:%s"), + stdout=b"Initial commit\nAdd new feature (#123)\n", + ) # Change the working directory monkeypatch.chdir(tmp_path) monkeypatch.setenv("INPUT_PREVIOUS-CHANGELOG-FILEPATH", mock_previous_files[0].as_posix()) @@ -139,8 +158,17 @@ def test_main_with_unreleased_entries( with summary_file.open("r") as summary_file_handle: summary_contents = summary_file_handle.read() - assert "## Workflow Inputs\n- release-level: minor\n" in summary_contents - assert "## Incoming Changes\n### Added\n- New feature" in summary_contents + assert ( + summary_contents + == """## Workflow Inputs +- release-level: minor +## PRs Merged Since Last Release +- Add new feature (#123) +## Incoming Changes +### Added +- New feature +""" + ) def test_main_with_no_release_level( @@ -166,3 +194,20 @@ def test_main_with_no_release_level( assert mock_previous_files[0].read_text() == mock_changelog_file.read_text() assert mock_previous_files[1].read_text().strip() == "## Unreleased\n### Added\n- New feature" assert not summary_file.exists() + + +def test_get_latest_tag() -> None: + """Test the get_latest_tag function.""" + with mock.patch("subprocess.check_output") as mock_check_output: + mock_check_output.return_value = b"v1.0.0\n" + assert get_latest_tag() == "v1.0.0" + + mock_check_output.side_effect = subprocess.CalledProcessError(1, "git") + assert get_latest_tag() is None + + +def test_get_commit_messages() -> None: + """Test the get_commit_messages function.""" + with mock.patch("subprocess.check_output") as mock_check_output: + mock_check_output.return_value = b"Initial commit\nAdd new feature\n" + assert get_commit_messages() == ["Initial commit", "Add new feature"]