Skip to content

Commit 81a0f98

Browse files
authored
fix(cmd-publish): fix handling of asset uploading errors on publish (python-semantic-release#1397)
Resolves: python-semantic-release#1395 * fix(github): fix bubble up errors of asset uploads for GitHub * test(cmd-publish): add e2e test for handling GitHub authentication errors on upload * test(github): add comprehensive error tests for `upload_dists()`
1 parent 2833aa9 commit 81a0f98

4 files changed

Lines changed: 328 additions & 10 deletions

File tree

src/semantic_release/cli/commands/publish.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from git import Repo
77

88
from semantic_release.cli.util import noop_report
9+
from semantic_release.errors import AssetUploadError
910
from semantic_release.globals import logger
1011
from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase
1112
from semantic_release.version.algorithm import tags_and_versions
@@ -90,9 +91,13 @@ def publish(cli_ctx: CliContextObj, tag: str) -> None:
9091
)
9192
return
9293

93-
publish_distributions(
94-
tag=tag,
95-
hvcs_client=hvcs_client,
96-
dist_glob_patterns=dist_glob_patterns,
97-
noop=runtime.global_cli_options.noop,
98-
)
94+
try:
95+
publish_distributions(
96+
tag=tag,
97+
hvcs_client=hvcs_client,
98+
dist_glob_patterns=dist_glob_patterns,
99+
noop=runtime.global_cli_options.noop,
100+
)
101+
except AssetUploadError as err:
102+
click.echo(err, err=True)
103+
ctx.exit(1)

src/semantic_release/hvcs/github.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,14 +463,25 @@ def upload_dists(self, tag: str, dist_glob: str) -> int:
463463

464464
# Upload assets
465465
n_succeeded = 0
466+
errors = []
466467
for file_path in (
467468
f for f in glob.glob(dist_glob, recursive=True) if os.path.isfile(f)
468469
):
469470
try:
470471
self.upload_release_asset(release_id, file_path)
471472
n_succeeded += 1
472-
except HTTPError: # noqa: PERF203
473+
except HTTPError as err: # noqa: PERF203
473474
logger.exception("error uploading asset %s", file_path)
475+
status_code = (
476+
err.response.status_code if err.response is not None else "unknown"
477+
)
478+
error_msg = f"Failed to upload asset '{file_path}' to release"
479+
if status_code != "unknown":
480+
error_msg += f" (HTTP {status_code})"
481+
errors.append(error_msg)
482+
483+
if errors:
484+
raise AssetUploadError("\n".join(errors))
474485

475486
return n_succeeded
476487

tests/e2e/cmd_publish/test_publish.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING, cast
45
from unittest import mock
56

67
import pytest
@@ -15,8 +16,15 @@
1516
if TYPE_CHECKING:
1617
from typing import Sequence
1718

19+
from requests_mock import Mocker
20+
1821
from tests.conftest import RunCliFn
19-
from tests.fixtures.git_repo import BuiltRepoResult, GetVersionsFromRepoBuildDefFn
22+
from tests.fixtures.git_repo import (
23+
BuiltRepoResult,
24+
GetCfgValueFromDefFn,
25+
GetHvcsClientFromRepoDefFn,
26+
GetVersionsFromRepoBuildDefFn,
27+
)
2028

2129

2230
@pytest.mark.parametrize("cmd_args", [(), ("--tag", "latest")])
@@ -87,3 +95,76 @@ def test_publish_fails_on_nonexistant_tag(run_cli: RunCliFn):
8795
f"Tag '{non_existant_tag}' not found in local repository!" in result.stderr
8896
)
8997
mocked_upload_dists.assert_not_called()
98+
99+
100+
@pytest.mark.parametrize(
101+
"repo_result",
102+
[
103+
lazy_fixture(repo_fixture_name)
104+
for repo_fixture_name in [
105+
repo_w_trunk_only_conventional_commits.__name__,
106+
]
107+
],
108+
)
109+
def test_publish_fails_on_github_upload_dists(
110+
repo_result: BuiltRepoResult,
111+
get_hvcs_client_from_repo_def: GetHvcsClientFromRepoDefFn,
112+
get_cfg_value_from_def: GetCfgValueFromDefFn,
113+
get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn,
114+
run_cli: RunCliFn,
115+
requests_mock: Mocker,
116+
):
117+
"""
118+
Given a repo with conventional commits and at least one tag
119+
When publishing to a valid tag but upload dists authentication fails
120+
Then the command fails with exit code 1
121+
122+
Reference: python-semantic-release/publish-action#77
123+
"""
124+
repo_def = repo_result["definition"]
125+
tag_format_str = cast("str", get_cfg_value_from_def(repo_def, "tag_format_str"))
126+
all_versions = get_versions_from_repo_build_def(repo_def)
127+
latest_release_version = all_versions[-1]
128+
release_tag = tag_format_str.format(version=latest_release_version)
129+
hvcs_client = get_hvcs_client_from_repo_def(repo_def)
130+
if not isinstance(hvcs_client, Github):
131+
pytest.fail("Test setup error: HvcsClient is not a Github instance")
132+
133+
release_id = 12
134+
files = [
135+
Path(f"dist/package-{latest_release_version}.whl"),
136+
Path(f"dist/package-{latest_release_version}.tar.gz"),
137+
]
138+
tag_endpoint = hvcs_client.create_api_url(
139+
endpoint=f"/repos/{hvcs_client.owner}/{hvcs_client.repo_name}/releases/tags/{release_tag}",
140+
)
141+
release_endpoint = hvcs_client.create_api_url(
142+
endpoint=f"/repos/{hvcs_client.owner}/{hvcs_client.repo_name}/releases/{release_id}"
143+
)
144+
upload_url = release_endpoint + "/assets"
145+
expected_num_upload_attempts = len(files)
146+
147+
# Setup: Create distribution files before upload
148+
for file in files:
149+
file.parent.mkdir(parents=True, exist_ok=True)
150+
file.touch()
151+
152+
# Setup: Mock upload url retrieval
153+
requests_mock.register_uri("GET", tag_endpoint, json={"id": release_id})
154+
requests_mock.register_uri(
155+
"GET", release_endpoint, json={"upload_url": f"{upload_url}{{?name,label}}"}
156+
)
157+
158+
# Setup: Mock upload failure
159+
uploader_mock = requests_mock.register_uri("POST", upload_url, status_code=403)
160+
161+
# Act
162+
cli_cmd = [MAIN_PROG_NAME, PUBLISH_SUBCMD, "--tag", "latest"]
163+
result = run_cli(cli_cmd[1:])
164+
165+
# Evaluate
166+
assert_exit_code(1, result, cli_cmd)
167+
assert isinstance(result.exception, SystemExit)
168+
assert expected_num_upload_attempts == uploader_mock.call_count
169+
for file in files:
170+
assert f"Failed to upload asset '{file}'" in result.stderr

tests/unit/semantic_release/hvcs/test_github.py

Lines changed: 222 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from requests import HTTPError, Response, Session
1414
from requests.auth import _basic_auth_str
1515

16+
from semantic_release.errors import AssetUploadError
1617
from semantic_release.hvcs.github import Github
1718
from semantic_release.hvcs.token_auth import TokenAuth
1819

@@ -1026,7 +1027,7 @@ def test_upload_release_asset_fails(
10261027

10271028
# Note - mocking as the logic for uploading an asset
10281029
# is covered by testing above, no point re-testing.
1029-
def test_upload_dists_when_release_id_not_found(default_gh_client):
1030+
def test_upload_dists_when_release_id_not_found(default_gh_client: Github):
10301031
tag = "v1.0.0"
10311032
path = "doesn't matter"
10321033
expected_num_uploads = 0
@@ -1093,3 +1094,223 @@ def test_upload_dists_when_release_id_found(
10931094
assert expected_num_uploads == num_uploads
10941095
mock_get_release_id_by_tag.assert_called_once_with(tag=tag)
10951096
assert expected_files_uploaded == mock_upload_release_asset.call_args_list
1097+
1098+
1099+
@pytest.mark.parametrize(
1100+
"status_code, error_message",
1101+
[
1102+
(401, "Unauthorized"),
1103+
(403, "Forbidden"),
1104+
(400, "Bad Request"),
1105+
(404, "Not Found"),
1106+
(429, "Too Many Requests"),
1107+
(500, "Internal Server Error"),
1108+
(503, "Service Unavailable"),
1109+
],
1110+
)
1111+
def test_upload_dists_fails_with_http_error(
1112+
default_gh_client: Github,
1113+
status_code: int,
1114+
error_message: str,
1115+
):
1116+
"""Given a release exists, when upload_release_asset raises HTTPError, then AssetUploadError is raised."""
1117+
# Setup
1118+
release_id = 123
1119+
tag = "v1.0.0"
1120+
files = ["dist/package-1.0.0.whl", "dist/package-1.0.0.tar.gz"]
1121+
glob_pattern = "dist/*"
1122+
expected_num_upload_attempts = len(files)
1123+
1124+
# Create mock HTTPError with proper response
1125+
http_error = HTTPError(error_message)
1126+
http_error.response = Response()
1127+
http_error.response.status_code = status_code
1128+
http_error.response._content = error_message.encode()
1129+
1130+
# Skip filesystem checks
1131+
mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True)
1132+
mocked_globber = mock.patch.object(glob, "glob", return_value=files)
1133+
1134+
# Set up mock environment
1135+
with mocked_globber, mocked_isfile, mock.patch.object(
1136+
default_gh_client,
1137+
default_gh_client.get_release_id_by_tag.__name__,
1138+
return_value=release_id,
1139+
) as mock_get_release_id_by_tag, mock.patch.object(
1140+
default_gh_client,
1141+
default_gh_client.upload_release_asset.__name__,
1142+
side_effect=http_error,
1143+
) as mock_upload_release_asset:
1144+
# Execute method under test expecting an exception to be raised
1145+
with pytest.raises(AssetUploadError) as exc_info:
1146+
default_gh_client.upload_dists(tag, glob_pattern)
1147+
1148+
# Evaluate (expected -> actual)
1149+
mock_get_release_id_by_tag.assert_called_once_with(tag=tag)
1150+
1151+
# Should have attempted to upload all files even though they fail
1152+
assert expected_num_upload_attempts == mock_upload_release_asset.call_count
1153+
1154+
# Verify the error message contains useful information about failed uploads
1155+
error_msg = str(exc_info.value)
1156+
1157+
# Each file should be mentioned in the error message with status code
1158+
for file in files:
1159+
assert f"Failed to upload asset '{file}'" in error_msg
1160+
assert f"(HTTP {status_code})" in error_msg
1161+
1162+
1163+
def test_upload_dists_fails_authentication_error_401(default_gh_client: Github):
1164+
"""Given a release exists, when upload fails with 401, then AssetUploadError is raised with auth context."""
1165+
# Setup
1166+
release_id = 456
1167+
tag = "v2.0.0"
1168+
files = ["dist/package-2.0.0.whl"]
1169+
glob_pattern = "dist/*.whl"
1170+
1171+
# Create mock HTTPError for authentication failure
1172+
http_error = HTTPError("401 Client Error: Unauthorized")
1173+
http_error.response = Response()
1174+
http_error.response.status_code = 401
1175+
http_error.response._content = b'{"message": "Bad credentials"}'
1176+
1177+
# Skip filesystem checks
1178+
mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True)
1179+
mocked_globber = mock.patch.object(glob, "glob", return_value=files)
1180+
1181+
# Set up mock environment
1182+
with mocked_globber, mocked_isfile, mock.patch.object(
1183+
default_gh_client,
1184+
default_gh_client.get_release_id_by_tag.__name__,
1185+
return_value=release_id,
1186+
), mock.patch.object(
1187+
default_gh_client,
1188+
default_gh_client.upload_release_asset.__name__,
1189+
side_effect=http_error,
1190+
):
1191+
# Execute method under test expecting an exception to be raised
1192+
with pytest.raises(AssetUploadError) as exc_info:
1193+
default_gh_client.upload_dists(tag, glob_pattern)
1194+
1195+
# Verify the error message contains file, release information and status code
1196+
error_msg = str(exc_info.value)
1197+
assert "Failed to upload asset" in error_msg
1198+
assert files[0] in error_msg
1199+
assert "(HTTP 401)" in error_msg
1200+
1201+
1202+
def test_upload_dists_fails_forbidden_error_403(default_gh_client: Github):
1203+
"""Given a release exists, when upload fails with 403, then AssetUploadError is raised with permission context."""
1204+
# Setup
1205+
release_id = 789
1206+
tag = "v3.0.0"
1207+
files = ["dist/package-3.0.0.tar.gz"]
1208+
glob_pattern = "dist/*.tar.gz"
1209+
1210+
# Create mock HTTPError for forbidden access
1211+
http_error = HTTPError("403 Client Error: Forbidden")
1212+
http_error.response = Response()
1213+
http_error.response.status_code = 403
1214+
1215+
# Skip filesystem checks
1216+
mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True)
1217+
mocked_globber = mock.patch.object(glob, "glob", return_value=files)
1218+
1219+
# Set up mock environment
1220+
with mocked_globber, mocked_isfile, mock.patch.object(
1221+
default_gh_client,
1222+
default_gh_client.get_release_id_by_tag.__name__,
1223+
return_value=release_id,
1224+
), mock.patch.object(
1225+
default_gh_client,
1226+
default_gh_client.upload_release_asset.__name__,
1227+
side_effect=http_error,
1228+
):
1229+
# Execute method under test expecting an exception to be raised
1230+
with pytest.raises(AssetUploadError) as exc_info:
1231+
default_gh_client.upload_dists(tag, glob_pattern)
1232+
1233+
# Verify the error message contains file, release information and status code
1234+
error_msg = str(exc_info.value)
1235+
assert "Failed to upload asset" in error_msg
1236+
assert f"Failed to upload asset '{files[0]}'" in error_msg
1237+
assert "(HTTP 403)" in error_msg
1238+
1239+
1240+
def test_upload_dists_partial_failure(default_gh_client: Github):
1241+
"""Given multiple files to upload, when some succeed and some fail, then AssetUploadError is raised."""
1242+
# Setup
1243+
release_id = 999
1244+
tag = "v4.0.0"
1245+
files = [
1246+
"dist/package-4.0.0.whl",
1247+
"dist/package-4.0.0.tar.gz",
1248+
"dist/package-4.0.0-py3-none-any.whl",
1249+
]
1250+
glob_pattern = "dist/*"
1251+
expected_num_upload_attempts = len(files)
1252+
1253+
# Create mock HTTPError for the second file
1254+
http_error = HTTPError("500 Server Error: Internal Server Error")
1255+
http_error.response = Response()
1256+
http_error.response.status_code = 500
1257+
1258+
# Skip filesystem checks
1259+
mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True)
1260+
mocked_globber = mock.patch.object(glob, "glob", return_value=files)
1261+
1262+
# Set up mock environment - first upload succeeds, second fails, third fails
1263+
upload_results = [True, http_error, http_error]
1264+
1265+
with mocked_globber, mocked_isfile, mock.patch.object(
1266+
default_gh_client,
1267+
default_gh_client.get_release_id_by_tag.__name__,
1268+
return_value=release_id,
1269+
), mock.patch.object(
1270+
default_gh_client,
1271+
default_gh_client.upload_release_asset.__name__,
1272+
side_effect=upload_results,
1273+
) as mock_upload_release_asset:
1274+
# Execute method under test expecting an exception to be raised
1275+
with pytest.raises(AssetUploadError) as exc_info:
1276+
default_gh_client.upload_dists(tag, glob_pattern)
1277+
1278+
# Verify all uploads were attempted
1279+
assert expected_num_upload_attempts == mock_upload_release_asset.call_count
1280+
1281+
# Verify the error message mentions the failed files with status code
1282+
error_msg = str(exc_info.value)
1283+
assert f"Failed to upload asset '{files[1]}'" in error_msg
1284+
assert f"Failed to upload asset '{files[2]}'" in error_msg
1285+
assert "(HTTP 500)" in error_msg
1286+
1287+
1288+
def test_upload_dists_all_succeed(default_gh_client: Github):
1289+
"""Given multiple files to upload, when all succeed, then return count of successful uploads."""
1290+
# Setup
1291+
release_id = 111
1292+
tag = "v5.0.0"
1293+
files = ["dist/package-5.0.0.whl", "dist/package-5.0.0.tar.gz"]
1294+
glob_pattern = "dist/*"
1295+
expected_num_uploads = len(files)
1296+
1297+
# Skip filesystem checks
1298+
mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True)
1299+
mocked_globber = mock.patch.object(glob, "glob", return_value=files)
1300+
1301+
# Set up mock environment - all uploads succeed
1302+
with mocked_globber, mocked_isfile, mock.patch.object(
1303+
default_gh_client,
1304+
default_gh_client.get_release_id_by_tag.__name__,
1305+
return_value=release_id,
1306+
), mock.patch.object(
1307+
default_gh_client,
1308+
default_gh_client.upload_release_asset.__name__,
1309+
return_value=True,
1310+
) as mock_upload_release_asset:
1311+
# Execute method under test
1312+
num_uploads = default_gh_client.upload_dists(tag, glob_pattern)
1313+
1314+
# Evaluate (expected -> actual)
1315+
assert expected_num_uploads == num_uploads
1316+
assert expected_num_uploads == mock_upload_release_asset.call_count

0 commit comments

Comments
 (0)