Skip to content

feat: add release notes to github actions outputs #1300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/configuration/automatic-releases/github-actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
50 changes: 23 additions & 27 deletions src/semantic_release/cli/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 34 additions & 5 deletions src/semantic_release/cli/github_actions_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -64,35 +67,61 @@ 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,
"is_prerelease": str(self.is_prerelease).lower(),
"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}<<EOF{os.linesep}{value}EOF{os.linesep}"
if value
else f"{key}={os.linesep}"
for key, value in multiline_output_values.items()
],
]

return str.join("", output_lines)

def write_if_possible(self, filename: str | None = None) -> None:
output_file = filename or os.getenv(self.OUTPUT_ENV_VAR)
if not output_file:
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"))
78 changes: 64 additions & 14 deletions tests/e2e/cmd_version/test_version_github_actions.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
Expand All @@ -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())
Expand All @@ -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()
)
42 changes: 37 additions & 5 deletions tests/unit/semantic_release/cli/test_github_actions_output.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
from textwrap import dedent
from typing import TYPE_CHECKING

Expand All @@ -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<<EOF\n{release_notes}EOF{os.linesep}"
)

output = VersionGitHubActionsOutput(
released=released,
version=Version.parse(version),
commit_sha=commit_sha,
release_notes=release_notes,
)

# Evaluate (expected -> actual)
Expand Down Expand Up @@ -66,31 +79,50 @@ 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"]
assert str(True).lower() == action_outputs["released"]
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(
Expand Down
Loading
Loading