test(release): cover publication failure paths
prd-number-check / require-numbered-prds (pull_request) Successful in 8s
lint / lint (push) Successful in 58s
test / image-input-builds (pull_request) Successful in 42s
test / integration-docker (pull_request) Successful in 1m5s
test / unit (pull_request) Successful in 2m50s
test / coverage (pull_request) Successful in 42s
tracker-policy-pr / check-pr (pull_request) Successful in 5s

This commit is contained in:
2026-07-27 17:29:06 +00:00
parent a750a242d1
commit 47cb50db4b
2 changed files with 235 additions and 5 deletions
+67 -2
View File
@@ -2,12 +2,20 @@ from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
import urllib.error
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import MagicMock, patch
from bot_bottle.release_bundle import build_bundle_index, canonical_bytes from bot_bottle.release_bundle import build_bundle_index, canonical_bytes
from bot_bottle.release_manifest import ReleaseManifestError from bot_bottle.release_manifest import ReleaseManifestError
from bot_bottle.release_publish import bundle_url, publish_bundle from bot_bottle.release_publish import (
bundle_url,
remote_bytes,
remote_sha256,
request,
upload as publish_artifact,
publish_bundle,
)
from tests.unit.test_release_manifest import manifest from tests.unit.test_release_manifest import manifest
@@ -96,3 +104,60 @@ class TestReleasePublish(unittest.TestCase):
ReleaseManifestError, "already differs", ReleaseManifestError, "already differs",
): ):
publish_bundle(index, wheel) publish_bundle(index, wheel)
def test_request_applies_auth_and_length(self) -> None:
result = request(
"https://packages.example/file",
method="PUT",
data=b"body",
length=4,
)
self.assertEqual(result.get_method(), "PUT")
self.assertEqual(result.get_header("Authorization"), "token token")
self.assertEqual(result.get_header("Content-length"), "4")
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_remote_helpers_read_response(self, urlopen: MagicMock) -> None:
response = urlopen.return_value.__enter__.return_value
response.read.side_effect = [b"body"]
self.assertEqual(
remote_bytes("https://packages.example/file"), b"body")
response.read.side_effect = [b"body", b""]
self.assertEqual(
remote_sha256("https://packages.example/file"),
"230d8358dc8e8890b4c58deeb62912ee2"
"f20357ae92a5cc861b98e68fe31acb5",
)
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_remote_helpers_handle_not_found(self, urlopen: MagicMock) -> None:
urlopen.side_effect = urllib.error.HTTPError(
"url", 404, "missing", MagicMock(), None)
self.assertIsNone(remote_bytes("https://packages.example/file"))
self.assertIsNone(remote_sha256("https://packages.example/file"))
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_remote_helpers_report_registry_errors(
self, urlopen: MagicMock,
) -> None:
urlopen.side_effect = urllib.error.URLError("offline")
with self.assertRaisesRegex(ReleaseManifestError, "offline"):
remote_bytes("https://packages.example/file")
with self.assertRaisesRegex(ReleaseManifestError, "offline"):
remote_sha256("https://packages.example/file")
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_upload_supports_bytes_and_files(self, urlopen: MagicMock) -> None:
urlopen.return_value.__enter__.return_value = MagicMock()
publish_artifact("https://packages.example/bytes", b"body")
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "artifact"
source.write_bytes(b"artifact")
publish_artifact("https://packages.example/file", source)
self.assertEqual(urlopen.call_count, 2)
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_upload_reports_failure(self, urlopen: MagicMock) -> None:
urlopen.side_effect = urllib.error.URLError("offline")
with self.assertRaisesRegex(ReleaseManifestError, "publishing"):
publish_artifact("https://packages.example/file", b"body")
+168 -3
View File
@@ -4,27 +4,33 @@ from __future__ import annotations
import json import json
import unittest import unittest
import urllib.error
from email.message import Message
from unittest import mock from unittest import mock
from bot_bottle.release_manifest import ReleaseManifestError from bot_bottle.release_manifest import ReleaseManifestError
from bot_bottle.release_qualification import ( from bot_bottle.release_qualification import (
build_release_pointer, build_release_pointer,
canonical_pointer,
delete,
parse_release_pointer,
publish_qualification, publish_qualification,
release_channel, release_channel,
release_version,
) )
COMMIT = "a" * 40 COMMIT = "a" * 40
def bundle() -> bytes: def bundle(source_commit: str = COMMIT) -> bytes:
return json.dumps({ return json.dumps({
"schema": 1, "schema": 1,
"source_commit": COMMIT, "source_commit": source_commit,
"wheel": { "wheel": {
"filename": "bot_bottle.whl", "filename": "bot_bottle.whl",
"url": ( "url": (
"https://gitea.dideric.is/api/packages/didericis/generic/" "https://gitea.dideric.is/api/packages/didericis/generic/"
f"bot-bottle-builds/{COMMIT}/bot_bottle.whl" f"bot-bottle-builds/{source_commit}/bot_bottle.whl"
), ),
"sha256": "b" * 64, "sha256": "b" * 64,
}, },
@@ -54,6 +60,14 @@ class ReleaseQualificationTest(unittest.TestCase):
with self.assertRaises(ReleaseManifestError): with self.assertRaises(ReleaseManifestError):
release_channel("latest") release_channel("latest")
def test_release_versions_are_monotonic(self) -> None:
self.assertLess(
release_version("v1.2.3-rc.4"), release_version("v1.2.3"))
self.assertLess(
release_version("v1.2.3"), release_version("v2.0.0"))
with self.assertRaises(ReleaseManifestError):
release_version("v1")
def test_builds_qualified_pointer(self) -> None: def test_builds_qualified_pointer(self) -> None:
pointer = build_release_pointer( pointer = build_release_pointer(
tag="v1.2.3", tag="v1.2.3",
@@ -63,6 +77,42 @@ class ReleaseQualificationTest(unittest.TestCase):
) )
self.assertEqual(pointer["channel"], "production") self.assertEqual(pointer["channel"], "production")
self.assertTrue(pointer["qualified"]) self.assertTrue(pointer["qualified"])
self.assertTrue(canonical_pointer(pointer).endswith(b"\n"))
def test_rejects_invalid_pointer_fields(self) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
changes = (
("schema", 2),
("qualified", False),
("channel", "staging"),
("source_commit", "short"),
("bundle_index_url", "https://wrong.example/index.json"),
("qualification", {}),
)
for key, value in changes:
with self.subTest(key=key), self.assertRaises(ReleaseManifestError):
parse_release_pointer({**pointer, key: value})
def test_rejects_invalid_build_inputs(self) -> None:
with self.assertRaises(ReleaseManifestError):
build_release_pointer(
tag="v1.2.3",
source_commit="short",
workflow_run="run",
qualified_at="now",
)
with self.assertRaises(ReleaseManifestError):
build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="",
qualified_at="now",
)
@mock.patch("bot_bottle.release_qualification.upload") @mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes") @mock.patch("bot_bottle.release_qualification.remote_bytes")
@@ -79,6 +129,45 @@ class ReleaseQualificationTest(unittest.TestCase):
publish_qualification(pointer) publish_qualification(pointer)
self.assertEqual(upload.call_count, 2) self.assertEqual(upload.call_count, 2)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_exact_existing_publication_is_noop(
self, remote: mock.Mock, upload: mock.Mock,
) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
wanted = canonical_pointer(pointer)
remote.side_effect = [bundle(), wanted, wanted]
publish_qualification(pointer)
upload.assert_not_called()
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.delete")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_advances_to_newer_channel(
self, remote: mock.Mock, remove: mock.Mock, upload: mock.Mock,
) -> None:
old = build_release_pointer(
tag="v1.0.0",
source_commit="b" * 40,
workflow_run="old",
qualified_at="then",
)
pointer = build_release_pointer(
tag="v2.0.0",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.side_effect = [bundle(), None, canonical_pointer(old)]
publish_qualification(pointer)
remove.assert_called_once()
self.assertEqual(upload.call_count, 2)
@mock.patch("bot_bottle.release_qualification.upload") @mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes") @mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_rejects_non_monotonic_channel( def test_rejects_non_monotonic_channel(
@@ -100,6 +189,82 @@ class ReleaseQualificationTest(unittest.TestCase):
with self.assertRaisesRegex(ReleaseManifestError, "not monotonic"): with self.assertRaisesRegex(ReleaseManifestError, "not monotonic"):
publish_qualification(pointer) publish_qualification(pointer)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.delete")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_explicit_rollback_allows_older_channel(
self, remote: mock.Mock, remove: mock.Mock, _upload: mock.Mock,
) -> None:
old = build_release_pointer(
tag="v2.0.0",
source_commit="b" * 40,
workflow_run="old",
qualified_at="then",
)
pointer = build_release_pointer(
tag="v1.0.0",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.side_effect = [bundle(), None, canonical_pointer(old)]
publish_qualification(pointer, allow_rollback=True)
remove.assert_called_once()
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_rejects_missing_or_malformed_bundle(self, remote: mock.Mock) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.return_value = None
with self.assertRaisesRegex(ReleaseManifestError, "does not exist"):
publish_qualification(pointer)
remote.return_value = b"not-json"
with self.assertRaisesRegex(ReleaseManifestError, "malformed"):
publish_qualification(pointer)
remote.return_value = bundle("b" * 40)
with self.assertRaisesRegex(ReleaseManifestError, "does not match"):
publish_qualification(pointer)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_rejects_changed_release_or_malformed_channel(
self, remote: mock.Mock, _upload: mock.Mock,
) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.side_effect = [bundle(), b"different"]
with self.assertRaisesRegex(ReleaseManifestError, "already differs"):
publish_qualification(pointer)
remote.side_effect = [bundle(), None, b"not-json"]
with self.assertRaisesRegex(ReleaseManifestError, "malformed"):
publish_qualification(pointer)
@mock.patch("bot_bottle.release_qualification.urllib.request.urlopen")
def test_delete_handles_success_and_not_found(self, urlopen: mock.Mock) -> None:
urlopen.return_value.__enter__.return_value = mock.Mock()
delete("https://example.test/channel")
urlopen.side_effect = urllib.error.HTTPError(
"url", 404, "missing", Message(), None)
delete("https://example.test/channel")
@mock.patch("bot_bottle.release_qualification.urllib.request.urlopen")
def test_delete_reports_registry_errors(self, urlopen: mock.Mock) -> None:
urlopen.side_effect = urllib.error.HTTPError(
"url", 500, "broken", Message(), None)
with self.assertRaisesRegex(ReleaseManifestError, "HTTP 500"):
delete("https://example.test/channel")
urlopen.side_effect = urllib.error.URLError("offline")
with self.assertRaisesRegex(ReleaseManifestError, "offline"):
delete("https://example.test/channel")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()