Files
nyaterm/.github/scripts/test_web_release.py
T
Kang 29be430b6a feat(web-release): implement web release workflow and testing scripts
- Added a GitHub Actions workflow for building and releasing the web application on version tag pushes.
- Introduced Python and JavaScript scripts for release metadata generation, validation, and smoke testing of the web release image.
- Updated .gitignore to include new artifacts and Python cache directories.
- Created Docker Compose configuration for deploying the web application with environment variable management.
- Enhanced documentation for deployment instructions and usage of the official web release image.
2026-10-07 20:56:55 +08:00

277 lines
11 KiB
Python

import importlib.util
import io
import json
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest.mock import Mock, patch
spec = importlib.util.spec_from_file_location(
"web_release", Path(__file__).with_name("web-release.py")
)
release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release)
class VersionTests(unittest.TestCase):
def test_metadata_for_stable_and_prerelease(self):
for version, stable in [("1.2.12", "true"), ("1.3.0-beta.1", "false")]:
with self.subTest(version=version):
metadata = release.release_metadata(
f"refs/tags/v{version}", version, "NyaKang/nyaterm"
)
self.assertEqual(metadata["version"], version)
self.assertEqual(metadata["image_tag"], version)
self.assertEqual(metadata["stable"], stable)
self.assertEqual(metadata["image"], "ghcr.io/nyakang/nyaterm-web")
def test_build_metadata_is_preserved_in_version_and_encoded_in_tag(self):
metadata = release.release_metadata(
"refs/tags/v1.2.3+build.4", "1.2.3+build.4", "nyakang/nyaterm"
)
self.assertEqual(metadata["version"], "1.2.3+build.4")
self.assertEqual(metadata["image_tag"], "1.2.3_build.4")
def test_branch_dispatch_and_mismatched_version_fail(self):
for ref in ["refs/heads/main", "refs/tags/1.2.12", "refs/tags/v1.2.11"]:
with self.subTest(ref=ref), self.assertRaises(ValueError):
release.release_metadata(ref, "1.2.12", "nyakang/nyaterm")
def test_invalid_semver_fails(self):
for version in [
"1.2",
"01.2.3",
"1.2.3-01",
"1.2.3-alpha.01",
"1.2.3-",
"1.2.3+",
"1.2.3\n",
]:
with self.subTest(version=version), self.assertRaises(ValueError):
release.version_parts(version)
def test_docker_tag_length_limit(self):
version = "1.2.3-" + "a" * 123
with self.assertRaises(ValueError):
release.release_metadata(
f"refs/tags/v{version}", version, "nyakang/nyaterm"
)
def test_latest_creation_and_numeric_upgrade(self):
for candidate, current in [
("1.2.12", None),
("1.2.12", "1.2.9"),
("1.10.0", "1.9.99"),
("2.0.0", "1.99.99"),
]:
with self.subTest(candidate=candidate, current=current):
self.assertTrue(release.should_promote(candidate, current))
def test_latest_never_regresses_or_accepts_prerelease(self):
for candidate, current in [
("1.2.11", "1.2.12"),
("1.2.12", "1.2.12"),
("1.2.12+rebuild", "1.2.12"),
("2.0.0-beta.1", "1.2.12"),
("2.0.0-beta.1", None),
]:
with self.subTest(candidate=candidate, current=current):
self.assertFalse(release.should_promote(candidate, current))
def test_invalid_existing_latest_fails_closed(self):
for current in ["unknown", "1.2.12-beta.1"]:
with self.subTest(current=current), self.assertRaises(ValueError):
release.should_promote("1.3.0", current)
class ManifestTests(unittest.TestCase):
def setUp(self):
self.digests = {
("linux", "amd64"): "sha256:" + "a" * 64,
("linux", "arm64"): "sha256:" + "b" * 64,
}
self.manifests = [
{"platform": {"os": os, "architecture": arch}, "digest": digest}
for (os, arch), digest in self.digests.items()
]
def test_both_tested_platforms_required(self):
raw = json.dumps({"manifests": self.manifests}).encode()
self.assertRegex(
release.verify_manifest(raw, self.digests), r"^sha256:[0-9a-f]{64}$"
)
for manifests in [self.manifests[:1], self.manifests + self.manifests[:1]]:
with self.subTest(manifests=manifests), self.assertRaises(ValueError):
release.verify_manifest(
json.dumps({"manifests": manifests}).encode(), self.digests
)
def test_untested_digest_rejected(self):
self.manifests[0]["digest"] = "sha256:" + "c" * 64
with self.assertRaises(ValueError):
release.verify_manifest(
json.dumps({"manifests": self.manifests}).encode(), self.digests
)
class PromotionTests(unittest.TestCase):
def setUp(self):
self.image = "ghcr.io/nyakang/nyaterm-web"
self.digest = "sha256:" + "a" * 64
self.registry = Mock()
def test_first_release_and_upgrade_publish_verified_digest(self):
for current in [None, "1.2.9"]:
with self.subTest(current=current), patch.object(
release.subprocess, "run"
) as run:
self.registry.image_version.side_effect = ["1.2.12", current, "1.2.12"]
release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
run.assert_called_once_with(
[
"docker",
"buildx",
"imagetools",
"create",
"--tag",
f"{self.image}:latest",
f"{self.image}@{self.digest}",
],
check=True,
)
def test_old_equal_and_prerelease_do_not_write_registry(self):
for candidate, current in [
("1.2.11", "1.2.12"),
("1.2.12", "1.2.12"),
("1.3.0-beta.1", "1.2.12"),
]:
with self.subTest(candidate=candidate), patch.object(
release.subprocess, "run"
) as run:
self.registry.image_version.side_effect = [candidate, current]
release.promote_latest(
self.registry, self.image, candidate, self.digest
)
run.assert_not_called()
def test_latest_read_failure_cannot_trigger_publication(self):
self.registry.image_version.side_effect = [
"1.2.12",
urllib.error.URLError("fixture"),
]
with patch.object(release.subprocess, "run") as run, self.assertRaises(
urllib.error.URLError
):
release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
run.assert_not_called()
def test_wrong_candidate_version_cannot_trigger_publication(self):
self.registry.image_version.return_value = "1.2.11"
with patch.object(release.subprocess, "run") as run, self.assertRaises(
ValueError
):
release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
run.assert_not_called()
def test_publication_error_and_failed_verification_fail_job(self):
self.registry.image_version.side_effect = ["1.2.12", None]
with patch.object(
release.subprocess, "run", side_effect=RuntimeError("fixture")
), self.assertRaises(RuntimeError):
release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
self.registry.image_version.side_effect = ["1.2.12", "1.2.11", "1.2.11"]
with patch.object(release.subprocess, "run"), self.assertRaises(ValueError):
release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
class RegistryTests(unittest.TestCase):
def setUp(self):
self.registry = release.Registry.__new__(release.Registry)
self.registry.opener = Mock()
self.registry.repository = "nyakang/nyaterm-web"
self.registry.headers = {"Authorization": "Bearer disposable-test-token"}
def http_error(self, status, code):
self.registry.opener.open.side_effect = urllib.error.HTTPError(
"https://ghcr.io/v2/nyakang/nyaterm-web/manifests/latest",
status,
"fixture",
{},
io.BytesIO(json.dumps({"errors": [{"code": code}]}).encode()),
)
def test_only_missing_latest_can_be_treated_as_first_release(self):
self.http_error(404, "MANIFEST_UNKNOWN")
self.assertIsNone(self.registry.manifest("latest", missing=True))
self.http_error(404, "MANIFEST_UNKNOWN")
with self.assertRaises(urllib.error.HTTPError):
self.registry.manifest("sha256:" + "a" * 64)
def test_auth_server_and_repository_errors_fail_closed(self):
for status, code in [
(401, "UNAUTHORIZED"),
(403, "DENIED"),
(429, "TOOMANYREQUESTS"),
(500, "UNKNOWN"),
(404, "NAME_UNKNOWN"),
]:
with self.subTest(status=status, code=code):
self.http_error(status, code)
with self.assertRaises(urllib.error.HTTPError):
self.registry.manifest("latest", missing=True)
def test_network_failure_propagates(self):
self.registry.opener.open.side_effect = urllib.error.URLError("fixture timeout")
with self.assertRaises(urllib.error.URLError):
self.registry.manifest("latest", missing=True)
def test_version_is_read_from_amd64_config_in_index(self):
digest = "sha256:" + "a" * 64
self.registry.manifest = Mock(
side_effect=[
{
"manifests": [
{
"platform": {"os": "linux", "architecture": "arm64"},
"digest": "sha256:" + "b" * 64,
},
{
"platform": {"os": "linux", "architecture": "amd64"},
"digest": digest,
},
]
},
{"config": {"digest": digest}},
]
)
self.registry.get_json = Mock(
return_value={
"config": {"Labels": {"org.opencontainers.image.version": "1.2.12"}}
}
)
self.assertEqual(self.registry.image_version("latest"), "1.2.12")
self.registry.manifest.assert_called_with(digest)
def test_missing_version_label_is_an_error(self):
self.registry.manifest = Mock(
return_value={"config": {"digest": "sha256:" + "a" * 64}}
)
self.registry.get_json = Mock(return_value={"config": {"Labels": {}}})
with self.assertRaises(KeyError):
self.registry.image_version("latest")
def test_registry_redirect_does_not_forward_token_to_blob_storage(self):
request = urllib.request.Request(
"https://ghcr.io/v2/example/blobs/fixture", headers=self.registry.headers
)
redirect = release.RegistryRedirect().redirect_request(
request, None, 307, "redirect", {}, "https://storage.example/blob"
)
self.assertFalse(redirect.has_header("Authorization"))
if __name__ == "__main__":
unittest.main()