diff --git a/action.yml b/action.yml index 023aec9b7..b3ae0c213 100644 --- a/action.yml +++ b/action.yml @@ -134,6 +134,11 @@ outputs: description: | The commit SHA of the release if a release was made, otherwise an empty string + release_notes: + description: | + The release notes generated by the release, if any. If no release was made, + this will be an empty string. + tag: description: | The Git tag corresponding to the version output diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index 07d29bce4..79aef1066 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -549,6 +549,18 @@ Example when no release was made: ``""`` ---- +.. _gh_actions-psr-outputs-release_notes: + +``release_notes`` +""""""""""""""""""" + +**Type:** ``string`` + +The release notes generated by the release, if any. If no release was made, +this will be an empty string. + +---- + .. _gh_actions-psr-outputs-version: ``version`` diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 45d9178b3..77a52afb4 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -641,6 +641,29 @@ def version( # noqa: C901 click.echo("Build failed, aborting release", err=True) ctx.exit(1) + license_cfg = runtime.project_metadata.get( + "license-expression", + runtime.project_metadata.get( + "license", + "", + ), + ) + + license_cfg = "" if not isinstance(license_cfg, (str, dict)) else license_cfg + license_cfg = ( + license_cfg.get("text", "") if isinstance(license_cfg, dict) else license_cfg + ) + + gha_output.release_notes = release_notes = generate_release_notes( + hvcs_client, + release=release_history.released[new_version], + template_dir=runtime.template_dir, + history=release_history, + style=runtime.changelog_style, + mask_initial_release=runtime.changelog_mask_initial_release, + license_name="" if not isinstance(license_cfg, str) else license_cfg, + ) + project = GitProject( directory=runtime.repo_dir, commit_author=runtime.commit_author, @@ -713,33 +736,6 @@ def version( # noqa: C901 logger.info("Remote does not support releases. Skipping release creation...") return - license_cfg = runtime.project_metadata.get( - "license-expression", - runtime.project_metadata.get( - "license", - "", - ), - ) - - if not isinstance(license_cfg, (str, dict)) or license_cfg is None: - license_cfg = "" - - license_name = ( - license_cfg.get("text", "") - if isinstance(license_cfg, dict) - else license_cfg or "" - ) - - release_notes = generate_release_notes( - hvcs_client, - release=release_history.released[new_version], - template_dir=runtime.template_dir, - history=release_history, - style=runtime.changelog_style, - mask_initial_release=runtime.changelog_mask_initial_release, - license_name=license_name, - ) - exception: Exception | None = None help_message = "" try: diff --git a/src/semantic_release/cli/github_actions_output.py b/src/semantic_release/cli/github_actions_output.py index 9c4090d31..c10f927e1 100644 --- a/src/semantic_release/cli/github_actions_output.py +++ b/src/semantic_release/cli/github_actions_output.py @@ -2,6 +2,7 @@ import os from re import compile as regexp +from typing import Any from semantic_release.globals import logger from semantic_release.version.version import Version @@ -15,10 +16,12 @@ def __init__( released: bool | None = None, version: Version | None = None, commit_sha: str | None = None, + release_notes: str | None = None, ) -> None: self._released = released self._version = version self._commit_sha = commit_sha + self._release_notes = release_notes @property def released(self) -> bool | None: @@ -64,21 +67,33 @@ def commit_sha(self, value: str) -> None: self._commit_sha = value + @property + def release_notes(self) -> str | None: + return self._release_notes if self._release_notes else None + + @release_notes.setter + def release_notes(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError("output 'release_notes' should be a string") + self._release_notes = value + def to_output_text(self) -> str: - missing = set() + missing: set[str] = set() if self.version is None: missing.add("version") if self.released is None: missing.add("released") if self.released and self.commit_sha is None: missing.add("commit_sha") + if self.released and self.release_notes is None: + missing.add("release_notes") if missing: raise ValueError( f"some required outputs were not set: {', '.join(missing)}" ) - outputs = { + output_values: dict[str, Any] = { "released": str(self.released).lower(), "version": str(self.version), "tag": self.tag, @@ -86,7 +101,21 @@ def to_output_text(self) -> str: "commit_sha": self.commit_sha if self.commit_sha else "", } - return str.join("", [f"{key}={value!s}\n" for key, value in outputs.items()]) + multiline_output_values: dict[str, str] = { + "release_notes": self.release_notes if self.release_notes else "", + } + + output_lines = [ + *[f"{key}={value!s}{os.linesep}" for key, value in output_values.items()], + *[ + f"{key}< None: output_file = filename or os.getenv(self.OUTPUT_ENV_VAR) @@ -94,5 +123,5 @@ def write_if_possible(self, filename: str | None = None) -> None: logger.info("not writing GitHub Actions output, as no file specified") return - with open(output_file, "a", encoding="utf-8") as f: - f.write(self.to_output_text()) + with open(output_file, "ab") as f: + f.write(self.to_output_text().encode("utf-8")) diff --git a/tests/e2e/cmd_version/test_version_github_actions.py b/tests/e2e/cmd_version/test_version_github_actions.py index 11f13b90f..a6c724a03 100644 --- a/tests/e2e/cmd_version/test_version_github_actions.py +++ b/tests/e2e/cmd_version/test_version_github_actions.py @@ -1,20 +1,32 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import os +from datetime import timezone +from typing import TYPE_CHECKING, cast import pytest +from freezegun import freeze_time from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from semantic_release.version.version import Version + +from tests.const import EXAMPLE_PROJECT_LICENSE, MAIN_PROG_NAME, VERSION_SUBCMD from tests.fixtures.repos import ( repo_w_git_flow_w_alpha_prereleases_n_conventional_commits, ) from tests.util import actions_output_to_dict, assert_successful_exit_code if TYPE_CHECKING: - from tests.conftest import RunCliFn + from tests.conftest import GetStableDateNowFn, RunCliFn from tests.fixtures.example_project import ExProjectDir - from tests.fixtures.git_repo import BuiltRepoResult + from tests.fixtures.git_repo import ( + BuiltRepoResult, + GenerateDefaultReleaseNotesFromDefFn, + GetCfgValueFromDefFn, + GetHvcsClientFromRepoDefFn, + GetVersionsFromRepoBuildDefFn, + SplitRepoActionsByReleaseTagsFn, + ) @pytest.mark.parametrize( @@ -25,21 +37,56 @@ def test_version_writes_github_actions_output( repo_result: BuiltRepoResult, run_cli: RunCliFn, example_project_dir: ExProjectDir, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_hvcs_client_from_repo_def: GetHvcsClientFromRepoDefFn, + generate_default_release_notes_from_def: GenerateDefaultReleaseNotesFromDefFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + stable_now_date: GetStableDateNowFn, ): mock_output_file = example_project_dir / "action.out" + repo_def = repo_result["definition"] + tag_format_str = cast(str, get_cfg_value_from_def(repo_def, "tag_format_str")) + all_versions = get_versions_from_repo_build_def(repo_def) + latest_release_version = all_versions[-1] + release_tag = tag_format_str.format(version=latest_release_version) + + repo_actions_per_version = split_repo_actions_by_release_tags( + repo_definition=repo_def, + tag_format_str=tag_format_str, + ) expected_gha_output = { "released": str(True).lower(), - "version": "1.2.1", - "tag": "v1.2.1", + "version": latest_release_version, + "tag": release_tag, "commit_sha": "0" * 40, - "is_prerelease": str(False).lower(), + "is_prerelease": str( + Version.parse(latest_release_version).is_prerelease + ).lower(), + "release_notes": generate_default_release_notes_from_def( + version_actions=repo_actions_per_version[release_tag], + hvcs=get_hvcs_client_from_repo_def(repo_def), + previous_version=( + Version.parse(all_versions[-2]) if len(all_versions) > 1 else None + ), + license_name=EXAMPLE_PROJECT_LICENSE, + mask_initial_release=get_cfg_value_from_def( + repo_def, "mask_initial_release" + ), + ), } + # Remove the previous tag & version commit + repo_result["repo"].git.tag(release_tag, delete=True) + repo_result["repo"].git.reset("HEAD~1", hard=True) + # Act - cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--patch", "--no-push"] - result = run_cli( - cli_cmd[1:], env={"GITHUB_OUTPUT": str(mock_output_file.resolve())} - ) + with freeze_time(stable_now_date().astimezone(timezone.utc)): + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push"] + result = run_cli( + cli_cmd[1:], env={"GITHUB_OUTPUT": str(mock_output_file.resolve())} + ) + assert_successful_exit_code(result, cli_cmd) # Update the expected output with the commit SHA @@ -51,9 +98,8 @@ def test_version_writes_github_actions_output( ) # Extract the output - action_outputs = actions_output_to_dict( - mock_output_file.read_text(encoding="utf-8") - ) + with open(mock_output_file, encoding="utf-8", newline=os.linesep) as rfd: + action_outputs = actions_output_to_dict(rfd.read()) # Evaluate expected_keys = set(expected_gha_output.keys()) @@ -67,3 +113,7 @@ def test_version_writes_github_actions_output( assert expected_gha_output["tag"] == action_outputs["tag"] assert expected_gha_output["is_prerelease"] == action_outputs["is_prerelease"] assert expected_gha_output["commit_sha"] == action_outputs["commit_sha"] + assert ( + expected_gha_output["release_notes"].encode() + == action_outputs["release_notes"].encode() + ) diff --git a/tests/unit/semantic_release/cli/test_github_actions_output.py b/tests/unit/semantic_release/cli/test_github_actions_output.py index 7b5cc2861..91b7e1605 100644 --- a/tests/unit/semantic_release/cli/test_github_actions_output.py +++ b/tests/unit/semantic_release/cli/test_github_actions_output.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from textwrap import dedent from typing import TYPE_CHECKING @@ -26,19 +27,31 @@ def test_version_github_actions_output_format( released: bool, version: str, is_prerelease: bool ): commit_sha = "0" * 40 # 40 zeroes to simulate a SHA-1 hash - expected_output = dedent( - f"""\ + release_notes = dedent( + """\ + ## Changes + - Added new feature + - Fixed bug + """ + ) + expected_output = ( + dedent( + f"""\ released={'true' if released else 'false'} version={version} tag=v{version} is_prerelease={'true' if is_prerelease else 'false'} commit_sha={commit_sha} """ + ) + + f"release_notes< actual) @@ -66,24 +79,42 @@ def test_version_github_actions_output_fails_if_missing_commit_sha_param(): output.to_output_text() +def test_version_github_actions_output_fails_if_missing_release_notes_param(): + output = VersionGitHubActionsOutput( + released=True, + version=Version.parse("1.2.3"), + ) + + # Execute with expected failure + with pytest.raises(ValueError, match="required outputs were not set"): + output.to_output_text() + + def test_version_github_actions_output_writes_to_github_output_if_available( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ): mock_output_file = tmp_path / "action.out" version_str = "1.2.3" commit_sha = "0" * 40 # 40 zeroes to simulate a SHA-1 hash + release_notes = dedent( + """\ + ## Changes + - Added new feature + - Fixed bug + """ + ) monkeypatch.setenv("GITHUB_OUTPUT", str(mock_output_file.resolve())) output = VersionGitHubActionsOutput( version=Version.parse(version_str), released=True, commit_sha=commit_sha, + release_notes=release_notes, ) output.write_if_possible() - action_outputs = actions_output_to_dict( - mock_output_file.read_text(encoding="utf-8") - ) + with open(mock_output_file, encoding="utf-8", newline=os.linesep) as rfd: + action_outputs = actions_output_to_dict(rfd.read()) # Evaluate (expected -> actual) assert version_str == action_outputs["version"] @@ -91,6 +122,7 @@ def test_version_github_actions_output_writes_to_github_output_if_available( assert str(False).lower() == action_outputs["is_prerelease"] assert f"v{version_str}" == action_outputs["tag"] assert commit_sha == action_outputs["commit_sha"] + assert release_notes == action_outputs["release_notes"] def test_version_github_actions_output_no_error_if_not_in_gha( diff --git a/tests/util.py b/tests/util.py index 3d4815064..9c884c50b 100644 --- a/tests/util.py +++ b/tests/util.py @@ -8,6 +8,7 @@ import string from contextlib import contextmanager, suppress from pathlib import Path +from re import compile as regexp from textwrap import indent from typing import TYPE_CHECKING, Tuple @@ -190,7 +191,38 @@ def xdist_sort_hack(it: Iterable[_R]) -> Iterable[_R]: def actions_output_to_dict(output: str) -> dict[str, str]: - return {line.split("=")[0]: line.split("=")[1] for line in output.splitlines()} + single_line_var_pattern = regexp(r"^(?P\w+)=(?P.*?)\r?$") + multiline_var_pattern = regexp(r"^(?P\w+?)< ReleaseHistory: