zapret-kvn/tests/test_release_windows_automation.py
loop-uh 6ea3d5aeef
Some checks failed
Windows project source guards / test (push) Failing after 26s
Выпуск: уборка хранилища Forgejo после публикации
Оставляется то, на что ссылаются этот и три прежних выпуска; ядро —
копия из Zapret 2 GUI (без лишних заплаток zstd, publish.prune).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:51:14 +03:00

584 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import hashlib
import io
import json
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import zipfile
import build
from scripts import release_windows
class ReleaseVersionTests(unittest.TestCase):
def test_next_patch_comes_from_stable_tag(self) -> None:
self.assertEqual(release_windows.next_patch("v0.4.83"), "0.4.84")
self.assertEqual(release_windows.next_patch("v1.9.99"), "1.9.100")
def test_explicit_immediate_minor_zero_is_allowed(self) -> None:
self.assertEqual(
release_windows.validate_next_stable_version("v0.4.101", "0.5.0"),
"0.5.0",
)
self.assertEqual(
release_windows.validate_next_stable_version("v0.4.101", None),
"0.4.102",
)
def test_release_archive_keeps_newest_versions_numerically(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for name in ("v0.5.0", "v0.6.9", "v0.6.10", "v0.6.11", "v0.6.12", "notes"):
(root / name).mkdir()
(root / name / "asset.zip").write_bytes(b"x")
removed = release_windows.prune_release_archive("0.6.12", root, keep=3)
self.assertEqual(sorted(removed), ["v0.5.0", "v0.6.9"])
self.assertEqual(
sorted(entry.name for entry in root.iterdir()),
["notes", "v0.6.10", "v0.6.11", "v0.6.12"],
)
def test_remote_dist_pattern_matches_only_release_assets(self) -> None:
# Python has no duplicate named groups; the shape check is the same.
pattern = release_windows.remote_dist_asset_pattern().replace("(?<v>", "(")
for name in release_windows.EXPECTED_ASSET_NAMES:
with self.subTest(name=name):
self.assertRegex(name.format(version="0.6.12"), pattern)
for name in ("ZapretKVN", "ZapretKVN-portable.zip", "ZapretKVN-v0.6.12-windows-x64.exe.bak"):
with self.subTest(name=name):
self.assertNotRegex(name, pattern)
def test_skipped_or_major_versions_are_rejected(self) -> None:
for value in ("0.4.103", "0.5.1", "0.6.0", "1.0.0"):
with self.subTest(value=value), self.assertRaises(release_windows.ReleaseError):
release_windows.validate_next_stable_version("v0.4.101", value)
def test_prerelease_or_malformed_version_is_rejected(self) -> None:
for value in ("0.4", "0.4.84-rc1", "latest", ""):
with self.subTest(value=value), self.assertRaises(release_windows.ReleaseError):
release_windows.parse_version(value)
def test_latest_stable_tag_ignores_prerelease_suffixes(self) -> None:
with patch.object(
release_windows,
"output",
return_value="v0.4.83\nv0.4.84-rc1\nv0.4.82\n",
):
self.assertEqual(release_windows.latest_stable_tag(), "v0.4.83")
class ReleaseChangelogTests(unittest.TestCase):
def test_repeated_and_semicolon_changes_are_normalized(self) -> None:
self.assertEqual(
release_windows.normalize_changes(
[" First change ; Second change ", "• Third change"]
),
["First change", "Second change", "Third change"],
)
def test_empty_resume_changes_are_allowed_only_explicitly(self) -> None:
self.assertEqual(release_windows.normalize_changes([], allow_empty=True), [])
with self.assertRaises(release_windows.ReleaseError):
release_windows.normalize_changes([])
def test_publisher_limits_are_enforced_before_external_writes(self) -> None:
with self.assertRaises(release_windows.ReleaseError):
release_windows.normalize_changes(["x"] * 7)
with self.assertRaises(release_windows.ReleaseError):
release_windows.normalize_changes(["x" * 181])
class ReleaseStateTests(unittest.TestCase):
def test_phase_order_supports_resume_without_repeating_completed_work(self) -> None:
state = {"phase": "stable_verified"}
self.assertTrue(release_windows.phase_done(state, "dev_verified"))
self.assertTrue(release_windows.phase_done(state, "stable_verified"))
self.assertFalse(release_windows.phase_done(state, "assets_verified"))
def test_atomic_json_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "state.json"
release_windows.atomic_json(path, {"phase": "dev_verified", "version": "0.4.84"})
self.assertEqual(
release_windows.read_json(path),
{"phase": "dev_verified", "version": "0.4.84"},
)
def test_execute_checks_clean_tree_before_creating_resume_state(self) -> None:
args = release_windows.build_parser().parse_args(["--change", "Ready"])
with (
patch.object(release_windows, "require_clean_main", side_effect=release_windows.ReleaseError("dirty")),
patch.object(release_windows, "load_or_create_state") as create_state,
):
with self.assertRaisesRegex(release_windows.ReleaseError, "dirty"):
release_windows.execute(args)
create_state.assert_not_called()
def test_core_refresh_uses_verified_write_or_nonmutating_current_check(self) -> None:
with patch.object(release_windows, "run") as run:
release_windows.refresh_stable_core_lock(write=True)
release_windows.refresh_stable_core_lock(write=False)
resolver = str(release_windows.CORE_RESOLVER_PATH.relative_to(release_windows.ROOT))
self.assertEqual(
run.call_args_list[0].args[0],
[release_windows.sys.executable, resolver, "--write", "--update-runtime"],
)
self.assertEqual(
run.call_args_list[1].args[0],
[release_windows.sys.executable, resolver, "--check", "--require-current"],
)
@staticmethod
def _gate_manifest(*, prerelease: bool = False) -> dict:
return {
"schema": 1,
"mode": "stable",
"version": "0.4.95",
"commit": "a" * 40,
"templates_verified": 4,
"geoip": json.loads(release_windows.GEOIP_LOCK_PATH.read_text()),
"executable": {"size": 1, "sha256": "b" * 64},
"core": {
"lock_sha256": "c" * 64,
"manifest_sha256": "d" * 64,
"sources": [
{
"id": "amnezia", "repository": "amnezia-vpn/amneziawg-go",
"channel": "official-tags", "release_prerelease": True,
"archive_sha256": "9" * 64, "asset_size": 5,
},
{
"id": "xray-core",
"channel": "stable",
"release_prerelease": prerelease,
"archive_sha256": "e" * 64,
"asset_size": 1,
},
{
"id": "sing-box-extended",
"channel": "stable",
"release_prerelease": False,
"archive_sha256": "f" * 64,
"asset_size": 1,
},
{
"id": "hysteria",
"channel": "stable",
"release_prerelease": False,
"archive_sha256": "1" * 64,
"asset_size": 1,
},
],
},
}
def test_gate_manifest_requires_exact_stable_core_proof(self) -> None:
release_windows.verify_gate_manifest(
self._gate_manifest(),
"stable",
"0.4.95",
"a" * 40,
)
with self.assertRaisesRegex(release_windows.ReleaseError, "non-stable core"):
release_windows.verify_gate_manifest(
self._gate_manifest(prerelease=True),
"stable",
"0.4.95",
"a" * 40,
)
class ReleaseAbandonTests(unittest.TestCase):
"""A cancelled unpublished release is skipped only through --abandon."""
def setUp(self) -> None:
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.state_path = root / "state.json"
self.abandoned_path = root / "abandoned.json"
for name, value in (("STATE_PATH", self.state_path), ("ABANDONED_PATH", self.abandoned_path)):
patcher = patch.object(release_windows, name, value)
patcher.start()
self.addCleanup(patcher.stop)
class _Forgejo:
def __init__(self, release=None) -> None:
self.release = release
def release_by_tag(self, _version):
return self.release
def _state(self, phase: str, version: str = "0.7.6") -> None:
release_windows.atomic_json(
self.state_path,
{"version": version, "phase": phase, "commit": "abc", "previous_tag": "v0.7.5", "changes": ["x"]},
)
def _abandon(self, version: str = "0.7.6", *, tag: bool = False, remote: str = "", release=None):
def fake_run(args, **_kwargs):
return subprocess.CompletedProcess(args, 0 if tag else 1)
with (
patch.object(release_windows.subprocess, "run", side_effect=fake_run),
patch.object(release_windows, "output", return_value=remote),
):
return release_windows.abandon(version, self._Forgejo(release))
def test_abandon_archives_state_and_records_version(self) -> None:
self._state("dev_verified")
result = self._abandon()
self.assertEqual(result["status"], "abandoned")
self.assertFalse(self.state_path.exists())
self.assertTrue(self.state_path.with_name("zapret-kvn-release-abandoned-v0.7.6.json").is_file())
self.assertEqual(release_windows.abandoned_versions()["0.7.6"]["phase"], "dev_verified")
def test_abandon_refuses_tagged_or_published_or_other_versions(self) -> None:
self._state("tag_pushed")
with self.assertRaisesRegex(release_windows.ReleaseError, "correction"):
self._abandon()
self._state("assets_verified")
with self.assertRaisesRegex(release_windows.ReleaseError, "locally"):
self._abandon(tag=True)
with self.assertRaisesRegex(release_windows.ReleaseError, "origin"):
self._abandon(remote="abc refs/tags/v0.7.6")
with self.assertRaisesRegex(release_windows.ReleaseError, "Forgejo"):
self._abandon(release={"id": 1})
with self.assertRaisesRegex(release_windows.ReleaseError, "not v0.7.7"):
self._abandon("0.7.7")
self.assertTrue(self.state_path.exists())
self.assertEqual(release_windows.abandoned_versions(), {})
def test_next_release_follows_only_abandoned_versions(self) -> None:
with patch.object(release_windows, "current_app_version", return_value="0.7.5"):
self.assertEqual(release_windows.release_base("v0.7.5"), "v0.7.5")
with patch.object(release_windows, "current_app_version", return_value="0.7.6"):
with self.assertRaisesRegex(release_windows.ReleaseError, "abandon"):
release_windows.release_base("v0.7.5")
release_windows.atomic_json(self.abandoned_path, {"versions": {"0.7.6": {}, "0.7.7": {}}})
with patch.object(release_windows, "current_app_version", return_value="0.7.7"):
base = release_windows.release_base("v0.7.5")
self.assertEqual(base, "v0.7.7")
self.assertEqual(release_windows.validate_next_stable_version(base, "0.7.8"), "0.7.8")
with self.assertRaises(release_windows.ReleaseError):
release_windows.validate_next_stable_version(base, "0.7.9")
class AppVersionTests(unittest.TestCase):
def test_version_update_changes_exactly_one_assignment(self) -> None:
with tempfile.TemporaryDirectory() as directory:
constants = Path(directory) / "constants.py"
constants.write_text('APP_NAME = "x"\nAPP_VERSION = "0.4.83"\n', encoding="utf-8")
with patch.object(release_windows, "CONSTANTS_PATH", constants):
self.assertEqual(release_windows.current_app_version(), "0.4.83")
release_windows.set_app_version("0.4.84")
self.assertEqual(release_windows.current_app_version(), "0.4.84")
class BuildPayloadTests(unittest.TestCase):
def _make_clean_payload(self, root: Path) -> Path:
app_dir = root / "dist" / "ZapretKVN"
(app_dir / "data" / "templates" / "xray").mkdir(parents=True)
(app_dir / "data" / "templates" / "xray" / "default.json").write_text(
"{}\n", encoding="utf-8"
)
for relative in build.SINGBOX_RULE_SET_RELATIVE_PATHS:
path = app_dir / "core" / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"srs")
return app_dir
def test_clean_payload_allows_only_source_owned_templates_under_data(self) -> None:
with tempfile.TemporaryDirectory() as directory:
app_dir = self._make_clean_payload(Path(directory))
build.assert_clean_payload(app_dir)
def test_clean_payload_rejects_runtime_data_and_orphan_files(self) -> None:
forbidden = ("state.enc", "configs", "runtime", "logs")
for name in forbidden:
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
app_dir = self._make_clean_payload(Path(directory))
path = app_dir / "data" / name
if "." in name:
path.write_bytes(b"runtime")
else:
path.mkdir()
with self.assertRaisesRegex(RuntimeError, "runtime data"):
build.assert_clean_payload(app_dir)
with tempfile.TemporaryDirectory() as directory:
app_dir = self._make_clean_payload(Path(directory))
(app_dir / "old-orphan.bin").write_bytes(b"stale")
with self.assertRaisesRegex(RuntimeError, "unexpected top-level"):
build.assert_clean_payload(app_dir)
def test_clean_payload_requires_all_local_singbox_rule_sets(self) -> None:
with tempfile.TemporaryDirectory() as directory:
app_dir = self._make_clean_payload(Path(directory))
(app_dir / "core" / build.SINGBOX_RULE_SET_RELATIVE_PATHS[0]).unlink()
with self.assertRaisesRegex(RuntimeError, "missing bundled sing-box rule-set"):
build.assert_clean_payload(app_dir)
@staticmethod
def _routing_archive() -> bytes:
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
archive.writestr(
"snapshot/sing-box/rule-set-geosite/geosite-ru-blocked.srs",
b"blocked-domain",
)
archive.writestr(
"snapshot/sing-box/rule-set-geoip/geoip-ru-blocked.srs",
b"blocked-ip",
)
archive.writestr(
"snapshot/sing-box/rule-set-geosite/geosite-category-ru.srs",
b"direct-domain",
)
archive.writestr(
"snapshot/sing-box/rule-set-geoip/geoip-ru.srs",
b"direct-ip",
)
return output.getvalue()
def test_build_downloads_locked_rule_sets_into_core_and_replaces_stale_data(self) -> None:
archive_bytes = self._routing_archive()
archive_sha256 = hashlib.sha256(archive_bytes).hexdigest()
class Response:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def read(self, size=-1):
nonlocal archive_bytes
if not archive_bytes:
return b""
chunk, archive_bytes = archive_bytes[:size], archive_bytes[size:]
return chunk
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
lock = json.loads(build.CORE_LOCK_PATH.read_text(encoding="utf-8"))
source = next(item for item in lock["sources"] if item["id"] == build.ROUTING_SOURCE_ID)
source["sha256"] = archive_sha256
lock_path = root / "core-lock.json"
lock_path.write_text(json.dumps(lock), encoding="utf-8")
core_dir = root / "core"
stale = core_dir / "rule-set" / "stale.srs"
stale.parent.mkdir(parents=True)
stale.write_bytes(b"stale")
with (
patch.object(build, "urlopen", return_value=Response()) as request,
patch.object(build, "DOWNLOAD_CACHE", root / "cache"),
):
destination = build.stage_singbox_rule_sets(lock_path, core_dir)
build.stage_singbox_rule_sets(lock_path, core_dir)
request.assert_called_once()
self.assertTrue((root / "cache" / source["archive"]).is_file())
self.assertEqual(destination, core_dir / "rule-set")
self.assertFalse(stale.exists())
self.assertEqual(
{path.name for path in destination.iterdir()},
{relative.name for relative in build.SINGBOX_RULE_SET_RELATIVE_PATHS},
)
self.assertTrue(all(path.stat().st_size > 0 for path in destination.iterdir()))
def test_clean_removes_the_entire_previous_payload(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
dist_dir = root / "dist"
app_dir = dist_dir / "ZapretKVN"
(app_dir / "data" / "runtime").mkdir(parents=True)
(app_dir / "data" / "runtime" / "old.json").write_text("{}")
(app_dir / "legacy-orphan.bin").write_bytes(b"stale")
(root / "build" / "old").mkdir(parents=True)
(dist_dir / "_build_tmp" / "old").mkdir(parents=True)
with (
patch.object(build, "DIST_DIR", dist_dir),
patch.object(build, "APP_DIR", app_dir),
patch.object(build, "BUILD_DIR", root / "build"),
):
build.clean()
self.assertFalse(app_dir.exists())
self.assertFalse((root / "build").exists())
self.assertFalse((dist_dir / "_build_tmp").exists())
def test_copy_tree_does_not_suppress_permission_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source"
source.mkdir()
destination = root / "destination"
with patch.object(build.shutil, "copytree", side_effect=PermissionError("locked")):
with self.assertRaisesRegex(RuntimeError, "Cannot stage build files"):
build._copy_tree_strict(source, destination)
def test_remove_path_does_not_suppress_permission_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "locked"
path.mkdir()
with patch.object(build.shutil, "rmtree", side_effect=PermissionError("locked")):
with self.assertRaisesRegex(RuntimeError, "Cannot remove build path"):
build._remove_path_strict(path)
def test_remove_previous_payload_with_readonly_module_license(self) -> None:
with tempfile.TemporaryDirectory() as directory:
payload = Path(directory) / "previous-payload"
(payload / "core").mkdir(parents=True)
license_file = payload / "core" / "LICENSE-Amnezia.txt"
license_file.write_text("test license", encoding="utf-8")
license_file.chmod(stat.S_IREAD)
try:
build._remove_path_strict(payload)
self.assertFalse(payload.exists())
finally:
if license_file.exists():
license_file.chmod(stat.S_IREAD | stat.S_IWRITE)
def test_release_gate_asserts_clean_payload(self) -> None:
source = (Path(__file__).parents[1] / "scripts" / "release_windows_gate.ps1").read_text(
encoding="utf-8"
)
self.assertIn("function Assert-CleanPayload", source)
self.assertIn("only data/templates is allowed", source)
self.assertIn("Release payload is missing bundled sing-box rule-set", source)
self.assertIn('Invoke-Native $singbox @(\"check\", \"-D\"', source)
self.assertIn("Assert-CleanPayload $RepoRoot", source)
self.assertIn("function Assert-StableCoreLock", source)
self.assertIn("release_prerelease", source)
def test_release_gate_stops_every_shipped_process(self) -> None:
source = (Path(__file__).parents[1] / "scripts" / "release_windows_gate.ps1").read_text(
encoding="utf-8"
)
names_line = next(line for line in source.splitlines() if "$names = @(" in line)
# Every executable the payload ships must be stopped before the clean
# step, otherwise an orphan locks files inside dist\ZapretKVN.
shipped = {path.stem for path in (Path(__file__).parents[1] / "zapret").rglob("*.exe")}
for name in {"ZapretKVN", "sing-box", "xray", *shipped}:
with self.subTest(name=name):
self.assertIn(f'"{name}"', names_line)
if __name__ == "__main__":
unittest.main()
def test_release_is_signed_with_a_key_the_app_knows(tmp_path, monkeypatch) -> None:
import base64 as _b64
import os as _os
release = release_windows
from zapret_update import manifest, sign
key = tmp_path / "k.key"
public = _b64.b64decode(sign.generate_key("kvn-test", key))
_os.chmod(key, 0o600)
import zipfile as _zipfile
with _zipfile.ZipFile(tmp_path / "ZapretKVN-v9.9.9-windows-x64.zip", "w") as bundle:
bundle.writestr("ZapretKVN.exe", b"exe")
bundle.writestr("core/sing-box.exe", b"core")
bundle.writestr("empty/", b"")
monkeypatch.setattr(release, "SIGNING_KEY_PATH", key)
# Сборщик хранилища (Python 3.14, сеть) проверяется своими тестами ядра.
def fake_store(tree, directory, version, keys):
files = [
{"path": path.relative_to(tree).as_posix(), "size": path.stat().st_size,
"sha256": manifest.file_sha256(path), "zsize": 1}
for path in sorted(tree.rglob("*")) if path.is_file()
]
return {"files": files, "patches": [], "names": []}
monkeypatch.setattr(release, "build_store", fake_store)
keys = release._signed_release_module()
monkeypatch.setattr(release, "_signed_release_module", lambda: type("K", (), {
"PRODUCT": keys.PRODUCT, "CHANNEL": keys.CHANNEL, "TRUSTED_KEYS": {"kvn-test": public}})())
release.sign_release(tmp_path, "9.9.9")
assets = release.signed_assets(tmp_path)
assert [item["name"] for item in assets] == ["update-manifest.json", "update-manifest.json.sig"]
loaded = manifest.load_signed(
(tmp_path / "update-manifest.json").read_bytes(), (tmp_path / "update-manifest.json.sig").read_text(),
trusted={"kvn-test": public}, product="zapret-kvn", channel="stable",
)
assert loaded.installer.name == "ZapretKVN-v9.9.9-windows-x64.zip"
# Список файлов сборки совпадает с содержимым zip: по нему идёт частичное обновление.
assert {(item.path, item.size) for item in loaded.files} == {("ZapretKVN.exe", 3), ("core/sing-box.exe", 4)}
# Ключ, которого программа не знает, выпуск останавливает.
monkeypatch.setattr(release, "_signed_release_module", lambda: keys)
import pytest as _pytest
with _pytest.raises(release.ReleaseError, match="not accepted"):
release.sign_release(tmp_path, "9.9.9")
def test_app_keys_are_the_owner_keys_when_they_are_on_this_machine() -> None:
import base64 as _b64
from pathlib import Path as _Path
import pytest as _pytest
from xray_fluent.updates.signed_release import TRUSTED_KEYS
from zapret_update import sign
folder = _Path.home() / ".config" / "zapret-signing"
for key_id in ("kvn-2026a", "kvn-reserve-1"):
path = folder / f"{key_id}.key"
if not path.is_file():
_pytest.skip("ключей владельца на этой машине нет")
assert _b64.b64decode(sign.public_key_of(path)) == TRUSTED_KEYS[key_id]
def test_easter_egg_follows_the_shared_rule() -> None:
# Само правило — общее ядро zapret_update.release_notes (свои тесты в Zapret 2 GUI).
from zapret_update import release_notes
versions = [f"0.{b}.{c}" for b in range(9, 40) for c in range(60)]
joked = next(v for v in versions if release_notes.lucky("stable", v))
plain = next(v for v in versions if not release_notes.lucky("stable", v))
assert release_windows.published_changes(joked, ["Первое"]) == ["Первое", "Удалена буква «О»"]
assert release_windows.published_changes(plain, ["Первое"]) == ["Первое"]
# Большой список — без шутки: настоящие изменения важнее.
assert release_windows.published_changes(joked, ["Первое", "Второе"]) == ["Первое", "Второе"]
# Длинный единственный пункт не влезает в предел публикатора — шутка уступает место.
assert release_windows.published_changes(joked, ["x" * 700]) == ["x" * 700]
def test_store_prune_keeps_this_and_recent_releases(tmp_path, monkeypatch) -> None:
import json as _json
from zapret_update import publish
payload = {"schema": 1, "product": "zapret-kvn", "channel": "stable", "version": "0.8.28", "created": 1, "key": "k",
"files": [{"path": "ZapretKVN.exe", "size": 1, "sha256": "a" * 64, "zsize": 1}]}
(tmp_path / "update-manifest.json").write_text(_json.dumps(payload), encoding="utf-8")
previous = tmp_path / "previous-0.8.27.json"
previous.write_text(_json.dumps({**payload, "version": "0.8.27", "files": [{**payload["files"][0], "sha256": "b" * 64}]}), encoding="utf-8")
token = tmp_path / "token"
token.write_text("secret", encoding="utf-8")
asked, kept = [], {}
monkeypatch.setattr(release_windows, "STORE_TOKEN_PATH", token)
monkeypatch.setattr(release_windows, "_signed_release_module", lambda: object())
monkeypatch.setattr(release_windows, "previous_manifests", lambda keys, version, directory, **kw: asked.append(kw) or [previous])
monkeypatch.setattr(publish, "prune", lambda keep, **kw: kept.update(keep=keep, **kw) or 0)
release_windows.prune_store(tmp_path, "0.8.28")
assert asked == [{"limit": release_windows.KEEP_RELEASES, "strict": True}]
assert [m.version for m in kept["keep"]] == ["0.8.28", "0.8.27"]
assert kept["owner"] == "zapretkvn" and kept["product"] == "zapret-kvn" and kept["token"] == "secret"