diff --git a/action.yml b/action.yml index 0b9137bdf..0d3ad4dd1 100644 --- a/action.yml +++ b/action.yml @@ -126,6 +126,11 @@ outputs: description: | The commit SHA of the release if a release was made, otherwise an empty string + id: + description: | + The release ID from the remote VCS, if a release was made. If no release was made, + this will be an empty string. + is_prerelease: description: | "true" if the version is a prerelease, "false" otherwise @@ -153,6 +158,25 @@ outputs: description: | The Git tag corresponding to the version output + upload_url: + description: | + The URL for uploading additional assets to the release, if a release was made. + If no release was made, this will be an empty string. This can be used with + actions like actions/upload-release-asset to add more files to the release. + + assets: + description: | + A JSON array containing information about each uploaded asset. Each asset object + includes metadata such as name, size, content_type, browser_download_url, etc. + If no release was made, this will be an empty JSON array ([]). + + assets_dist: + description: | + A JSON object containing information about Python distribution assets (wheel and sdist), + organized by type. Keys are 'wheel' for .whl files and 'sdist' for .tar.gz files. + Each value is an asset object with metadata. If no release was made or no dist assets + were uploaded, this will be an empty JSON object ({}). + version: description: | The newly released version if one was made, otherwise the current version diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index 683cf1bfc..da99fa121 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -623,6 +623,107 @@ Example: ``v1.2.3`` ---- +.. _gh_actions-psr-outputs-id: + +``id`` +"""""" + +**Type:** ``string`` + +The release ID from the GitHub API if a release was made, otherwise an empty string. +This can be used in subsequent workflow steps to interact with the release via the +GitHub API. + +Example upon release: ``123456789`` +Example when no release was made: ``""`` + +---- + +.. _gh_actions-psr-outputs-upload_url: + +``upload_url`` +"""""""""""""" + +**Type:** ``string`` + +The upload URL for the release from the GitHub API if a release was made, otherwise +an empty string. This URL can be used to upload additional assets to the release. + +Example upon release: ``https://uploads.github.com/repos/user/repo/releases/123456789/assets{?name,label}`` +Example when no release was made: ``""`` + +---- + +.. _gh_actions-psr-outputs-assets: + +``assets`` +"""""""""" + +**Type:** ``string`` (JSON array) + +A JSON array of asset metadata objects that were uploaded to the release. Each object +contains information about an uploaded asset including its name, browser_download_url, +and other GitHub API metadata. If no release was made or no assets were uploaded, this +will be an empty JSON array ``[]``. + +Example upon release with assets: + +.. code-block:: json + + [ + { + "name": "package-1.2.3-py3-none-any.whl", + "browser_download_url": "https://github.com/user/repo/releases/download/v1.2.3/package-1.2.3-py3-none-any.whl", + "size": 123456, + ... + }, + { + "name": "package-1.2.3.tar.gz", + "browser_download_url": "https://github.com/user/repo/releases/download/v1.2.3/package-1.2.3.tar.gz", + "size": 234567, + ... + } + ] + +Example when no release was made: ``[]`` + +---- + +.. _gh_actions-psr-outputs-assets_dist: + +``assets_dist`` +""""""""""""""" + +**Type:** ``string`` (JSON object) + +A JSON object containing Python distribution assets organized by type (``wheel`` and ``sdist``). +This is a convenience output that categorizes the assets from the ``assets`` output into +their distribution types. If no distribution assets were uploaded, this will be an empty +JSON object ``{}``. + +Example upon release with distribution assets: + +.. code-block:: json + + { + "wheel": { + "name": "package-1.2.3-py3-none-any.whl", + "browser_download_url": "https://github.com/user/repo/releases/download/v1.2.3/package-1.2.3-py3-none-any.whl", + "size": 123456, + ... + }, + "sdist": { + "name": "package-1.2.3.tar.gz", + "browser_download_url": "https://github.com/user/repo/releases/download/v1.2.3/package-1.2.3.tar.gz", + "size": 234567, + ... + } + } + +Example when no release was made: ``{}`` + +---- + .. _gh_actions-publish: Python Semantic Release Publish Action @@ -1021,6 +1122,60 @@ The equivalent GitHub Action configuration would be: .. _Publish Action Manual Release Workflow: https://github.com/python-semantic-release/publish-action/blob/main/.github/workflows/release.yml +Using Release Assets Outputs +----------------------------- + +The Python Semantic Release Action provides outputs for release assets that can be used +in subsequent workflow steps. This example demonstrates how to access asset information +and download specific distribution files for further processing. + +.. code:: yaml + + # snippet + + - name: Action | Semantic Version Release + id: release + uses: python-semantic-release/python-semantic-release@v10.5.3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish | Upload to GitHub Release Assets + uses: python-semantic-release/publish-action@v10.5.3 + if: steps.release.outputs.released == 'true' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release.outputs.tag }} + + # Example: Download wheel from the release assets + - name: Process | Download wheel asset + if: steps.release.outputs.released == 'true' + run: | + # Parse assets_dist JSON output to get wheel download URL + WHEEL_URL=$(echo '${{ steps.release.outputs.assets_dist }}' | jq -r '.wheel.browser_download_url') + if [ -n "$WHEEL_URL" ]; then + echo "Downloading wheel from: $WHEEL_URL" + curl -L -o wheel.whl "$WHEEL_URL" + fi + + # Example: List all uploaded assets + - name: Display | Show all release assets + if: steps.release.outputs.released == 'true' + run: | + echo "Release ID: ${{ steps.release.outputs.id }}" + echo "Upload URL: ${{ steps.release.outputs.upload_url }}" + echo "All assets:" + echo '${{ steps.release.outputs.assets }}' | jq -r '.[] | " - \(.name) (\(.size) bytes)"' + + # Example: Use release ID to add a comment via GitHub API + - name: Comment | Add release comment + if: steps.release.outputs.released == 'true' + run: | + curl -X POST \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.release.outputs.id }}/assets \ + -d '{"body": "Release artifacts are now available!"}' + .. _gh_actions-monorepo: Actions with Monorepos diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 7a6fa26ef..a2368d298 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -302,7 +302,9 @@ def build_distributions( rprint("[bold green]Build completed successfully!") except subprocess.CalledProcessError as exc: logger.exception(exc) - logger.error("Build command failed with exit code %s", exc.returncode) # noqa: TRY400 + logger.error( + "Build command failed with exit code %s", exc.returncode + ) # noqa: TRY400 raise BuildDistributionsError from exc @@ -826,13 +828,24 @@ def version( # noqa: C901 exception: Exception | None = None help_message = "" try: - hvcs_client.create_release( + release_result = hvcs_client.create_release( tag=new_version.as_tag(), release_notes=release_notes, prerelease=new_version.is_prerelease, assets=assets, noop=opts.noop, ) + # Update GitHub Actions output with release information + # Only Github returns ReleaseInfo with asset details + if isinstance(hvcs_client, Github): + from semantic_release.hvcs.github import ReleaseInfo + + if isinstance(release_result, ReleaseInfo): + gha_output.release_id = release_result.id + gha_output.upload_url = release_result.upload_url + gha_output.assets = release_result.assets + elif isinstance(release_result, int): + gha_output.release_id = release_result except HTTPError as err: exception = err except UnexpectedResponse as err: diff --git a/src/semantic_release/cli/github_actions_output.py b/src/semantic_release/cli/github_actions_output.py index 8ff97016f..4818b7045 100644 --- a/src/semantic_release/cli/github_actions_output.py +++ b/src/semantic_release/cli/github_actions_output.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os from enum import Enum from re import compile as regexp @@ -31,6 +32,9 @@ def __init__( commit_sha: str | None = None, release_notes: str | None = None, prev_version: Version | None = None, + release_id: int | None = None, + upload_url: str | None = None, + assets: list[dict[str, Any]] | None = None, ) -> None: self._gh_client = gh_client self._mode = mode @@ -39,6 +43,9 @@ def __init__( self._commit_sha = commit_sha self._release_notes = release_notes self._prev_version = prev_version + self._release_id = release_id + self._upload_url = upload_url + self._assets = assets or [] @property def released(self) -> bool | None: @@ -112,6 +119,59 @@ def gh_client(self) -> Github: raise ValueError("GitHub client not set, cannot create links") return self._gh_client + @property + def release_id(self) -> int | None: + return self._release_id if self._release_id else None + + @release_id.setter + def release_id(self, value: int) -> None: + if not isinstance(value, int): + raise TypeError("output 'release_id' should be an integer") + self._release_id = value + + @property + def upload_url(self) -> str | None: + return self._upload_url if self._upload_url else None + + @upload_url.setter + def upload_url(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError("output 'upload_url' should be a string") + self._upload_url = value + + @property + def assets(self) -> list[dict[str, Any]]: + return self._assets + + @assets.setter + def assets(self, value: list[dict[str, Any]]) -> None: + if not isinstance(value, list): + raise TypeError("output 'assets' should be a list") + self._assets = value + + @property + def assets_dist(self) -> dict[str, dict[str, Any]]: + """ + Returns a dictionary of dist assets organized by type (wheel, sdist, etc.) + + Identifies Python distribution files by extension: + - .whl files are categorized as 'wheel' + - .tar.gz files are categorized as 'sdist' + - Other files retain their extension as the key + """ + dist_assets: dict[str, dict[str, Any]] = {} + for asset in self._assets: + name = asset.get("name", "") + if name.endswith(".whl"): + dist_assets["wheel"] = asset + elif name.endswith(".tar.gz"): + dist_assets["sdist"] = asset + else: + # Use file extension as key for other files + ext = name.split(".")[-1] if "." in name else "unknown" + dist_assets[ext] = asset + return dist_assets + def to_output_text(self) -> str: missing: set[str] = set() if self.version is None: @@ -137,6 +197,10 @@ def to_output_text(self) -> str: "link": self.gh_client.create_release_url(self.tag) if self.tag else "", "previous_version": str(self.prev_version) if self.prev_version else "", "commit_sha": self.commit_sha if self.commit_sha else "", + "id": str(self.release_id) if self.release_id else "", + "upload_url": self.upload_url if self.upload_url else "", + "assets": json.dumps(self.assets) if self.assets else "[]", + "assets_dist": json.dumps(self.assets_dist) if self.assets_dist else "{}", } multiline_output_values: dict[str, str] = { @@ -146,9 +210,11 @@ def to_output_text(self) -> str: output_lines = [ *[f"{key}={value!s}{os.linesep}" for key, value in output_values.items()], *[ - f"{key}< int: def create_or_update_release( self, tag: str, release_notes: str, prerelease: bool = False - ) -> int | str: + ) -> int | str | ReleaseInfo: return super().create_or_update_release(tag, release_notes, prerelease) def create_release( @@ -251,7 +253,7 @@ def create_release( prerelease: bool = False, assets: list[str] | None = None, noop: bool = False, - ) -> int | str: + ) -> int | str | ReleaseInfo: return super().create_release(tag, release_notes, prerelease, assets, noop) diff --git a/src/semantic_release/hvcs/github.py b/src/semantic_release/hvcs/github.py index 5ccc9004b..6cd07520b 100644 --- a/src/semantic_release/hvcs/github.py +++ b/src/semantic_release/hvcs/github.py @@ -8,7 +8,7 @@ from functools import lru_cache from pathlib import PurePosixPath from re import compile as regexp -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple from requests import HTTPError, JSONDecodeError from urllib3.util.url import Url, parse_url @@ -29,6 +29,14 @@ from typing import Any, Callable +class ReleaseInfo(NamedTuple): + """Information about a created release""" + + id: int + upload_url: str + assets: list[dict[str, Any]] + + # Add a mime type for wheels # Fix incorrect entries in the `mimetypes` registry. # On Windows, the Python standard library's `mimetypes` reads in @@ -210,7 +218,7 @@ def create_release( prerelease: bool = False, assets: list[str] | None = None, noop: bool = False, - ) -> int: + ) -> ReleaseInfo: """ Create a new release @@ -224,7 +232,7 @@ def create_release( :param assets: a list of artifacts to upload to the release - :return: the ID of the release + :return: ReleaseInfo containing the release ID, upload URL, and asset information """ if noop: noop_report( @@ -247,7 +255,7 @@ def create_release( ], ) ) - return -1 + return ReleaseInfo(id=-1, upload_url="", assets=[]) logger.info("Creating release for tag %s", tag) releases_endpoint = self.create_api_url( @@ -268,18 +276,22 @@ def create_release( response.raise_for_status() try: - release_id: int = response.json()["id"] + release_data = response.json() + release_id: int = release_data["id"] + upload_url: str = release_data["upload_url"].replace("{?name,label}", "") logger.info("Successfully created release with ID: %s", release_id) except JSONDecodeError as err: raise UnexpectedResponse("Unreadable json response") from err except KeyError as err: - raise UnexpectedResponse("JSON response is missing an id") from err + raise UnexpectedResponse("JSON response is missing required keys") from err + asset_info: list[dict[str, Any]] = [] errors = [] for asset in assets or []: logger.info("Uploading asset %s", asset) try: - self.upload_release_asset(release_id, asset) + asset_data = self.upload_release_asset(release_id, asset) + asset_info.append(asset_data) except HTTPError as err: errors.append( AssetUploadError(f"Failed asset upload for {asset}").with_traceback( @@ -288,7 +300,7 @@ def create_release( ) if len(errors) < 1: - return release_id + return ReleaseInfo(id=release_id, upload_url=upload_url, assets=asset_info) for error in errors: logger.exception(error) @@ -349,13 +361,13 @@ def edit_release_notes(self, release_id: int, release_notes: str) -> int: @logged_function(logger) def create_or_update_release( self, tag: str, release_notes: str, prerelease: bool = False - ) -> int: + ) -> ReleaseInfo: """ Post release changelog :param tag: The version number :param release_notes: The release notes for this version :param prerelease: Whether or not this release should be created as a prerelease - :return: The status of the request + :return: ReleaseInfo containing the release ID, upload URL, and asset information """ logger.info("Creating release for %s", tag) try: @@ -372,7 +384,14 @@ def create_or_update_release( logger.debug("Found existing release %s, updating", release_id) # If this errors we let it die - return self.edit_release_notes(release_id, release_notes) + updated_id = self.edit_release_notes(release_id, release_notes) + + # Get the upload_url for the existing release + upload_url = self.asset_upload_url(release_id) + if upload_url is None: + upload_url = "" + + return ReleaseInfo(id=updated_id, upload_url=upload_url, assets=[]) @logged_function(logger) @suppress_not_found @@ -404,14 +423,14 @@ def asset_upload_url(self, release_id: str) -> str | None: @logged_function(logger) def upload_release_asset( self, release_id: int, file: str, label: str | None = None - ) -> bool: + ) -> dict[str, Any]: """ Upload an asset to an existing release https://docs.github.com/rest/reference/repos#upload-a-release-asset :param release_id: ID of the release to upload to :param file: Path of the file to upload :param label: Optional custom label for this file - :return: The status of the request + :return: The asset information from the API response """ url = self.asset_upload_url(release_id) if url is None: @@ -445,7 +464,16 @@ def upload_release_asset( response.status_code, ) - return True + try: + asset_data = response.json() + # Remove the uploader field as specified in issue #401 + asset_data.pop("uploader", None) + except JSONDecodeError as err: + raise UnexpectedResponse( + "Unreadable json response from asset upload" + ) from err + else: + return asset_data @logged_function(logger) def upload_dists(self, tag: str, dist_glob: str) -> int: diff --git a/src/semantic_release/hvcs/remote_hvcs_base.py b/src/semantic_release/hvcs/remote_hvcs_base.py index 14e7a5e2f..093c5ce7d 100644 --- a/src/semantic_release/hvcs/remote_hvcs_base.py +++ b/src/semantic_release/hvcs/remote_hvcs_base.py @@ -1,4 +1,4 @@ -"""Common functionality and interface for interacting with Git remote VCS""" +"""Base class for remote version control system (HVCS) support""" from __future__ import annotations @@ -8,11 +8,13 @@ from urllib3.util.url import Url, parse_url -from semantic_release.hvcs import HvcsBase +from semantic_release.hvcs._base import HvcsBase if TYPE_CHECKING: # pragma: no cover from typing import Any + from semantic_release.hvcs.github import ReleaseInfo + class RemoteHvcsBase(HvcsBase, metaclass=ABCMeta): """ @@ -63,11 +65,14 @@ def create_release( prerelease: bool = False, assets: list[str] | None = None, noop: bool = False, - ) -> int | str: + ) -> int | str | ReleaseInfo: """ Create a release in a remote VCS, if supported Which includes uploading any assets as part of the release + + :return: a release identifier (int for GitHub, str for other platforms) + GitHub implementations may return ReleaseInfo instead of int """ self._not_supported(self.create_release.__name__) return -1 @@ -75,10 +80,13 @@ def create_release( @abstractmethod def create_or_update_release( self, tag: str, release_notes: str, prerelease: bool = False - ) -> int | str: + ) -> int | str | ReleaseInfo: """ Create or update a release for the given tag in a remote VCS, attaching the given changelog, if supported + + :return: a release identifier (int for GitHub, str for other platforms) + GitHub implementations may return ReleaseInfo instead of int """ self._not_supported(self.create_or_update_release.__name__) return -1 diff --git a/tests/e2e/cmd_version/test_version_github_actions.py b/tests/e2e/cmd_version/test_version_github_actions.py index b3953f43e..20dc60a18 100644 --- a/tests/e2e/cmd_version/test_version_github_actions.py +++ b/tests/e2e/cmd_version/test_version_github_actions.py @@ -63,6 +63,10 @@ def test_version_writes_github_actions_output( "commit_sha": "0" * 40, "is_prerelease": str(latest_release_version.is_prerelease).lower(), "previous_version": str(previous_version) if previous_version else "", + "id": "", # Empty because --no-push means no GitHub release created + "upload_url": "", # Empty because --no-push means no GitHub release created + "assets": "[]", # Empty JSON array because no assets uploaded + "assets_dist": "{}", # Empty JSON object because no assets uploaded "release_notes": generate_default_release_notes_from_def( version_actions=repo_actions_per_version[latest_release_version], hvcs=hvcs_client, @@ -113,4 +117,8 @@ def test_version_writes_github_actions_output( assert expected_gha_output["link"] == action_outputs["link"] assert expected_gha_output["previous_version"] == action_outputs["previous_version"] assert expected_gha_output["commit_sha"] == action_outputs["commit_sha"] + assert expected_gha_output["id"] == action_outputs["id"] + assert expected_gha_output["upload_url"] == action_outputs["upload_url"] + assert expected_gha_output["assets"] == action_outputs["assets"] + assert expected_gha_output["assets_dist"] == action_outputs["assets_dist"] assert expected_gha_output["release_notes"] == action_outputs["release_notes"] 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 7c4761d14..c8249580a 100644 --- a/tests/unit/semantic_release/cli/test_github_actions_output.py +++ b/tests/unit/semantic_release/cli/test_github_actions_output.py @@ -52,8 +52,12 @@ def test_version_github_actions_output_format( link={BASE_VCS_URL}/releases/tag/v{version} previous_version={prev_version or ""} commit_sha={commit_sha} + id= + upload_url= + assets=[] + assets_dist={{}} """ - ) + ).replace("\n", os.linesep) + f"release_notes<