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: pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy