[ci] sync desktop and mobile release downloads to version.json

This commit is contained in:
dijunkun
2026-10-02 03:14:10 +08:00
parent bb3949a28e
commit 13f4116825
7 changed files with 374 additions and 190 deletions
@@ -0,0 +1,114 @@
"""Regression coverage for independent and combined release metadata."""
import copy
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import update_version_manifest as manifest
class ManifestTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="crossdesk-manifest-")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.artifacts = self.root / "release"
self.artifacts.mkdir()
self.current = {
"version": "1.6.0-20260930", "latest_version": "1.6.0-20260930",
"releaseDate": "2026-09-30", "releaseNotes": "Desktop notes", "patch": 0,
"downloads": {"windows-x64": {"url": "https://downloads.crossdesk.cn/existing.exe"}},
"custom": {"keep": True},
}
def files(self, *names):
for path in self.artifacts.iterdir():
path.unlink()
for name in names:
(self.artifacts / name).write_bytes(b"release fixture")
return manifest.artifact_downloads(self.artifacts)
def merge(self, tag, downloads, current=None):
return manifest.merge_release(current or self.current, tag, tag, "Release notes", downloads)
def test_mobile_preserves_desktop_metadata_and_downloads(self):
downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
original = copy.deepcopy(self.current)
result = self.merge("android-v1.6.0-20261002", downloads)
for key, value in original.items():
if key != "downloads":
self.assertEqual(result[key], value)
self.assertEqual(result["downloads"]["windows-x64"], original["downloads"]["windows-x64"])
self.assertEqual(result["downloads"]["android-arm64"]["url"],
"https://downloads.crossdesk.cn/crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
self.assertEqual(result["platforms"]["android"]["latest_version"], "1.6.0-20261002")
self.assertEqual(self.current, original)
def test_sequential_platform_releases_retain_each_other(self):
for order in (("ios", "android"), ("android", "ios")):
result = copy.deepcopy(self.current)
for platform in order:
version, extension = ("0.0.2", "zip") if platform == "ios" else ("1.6.0", "apk")
downloads = self.files(f"crossdesk-{platform}-arm64-unsigned-v{version}-20261002.{extension}")
result = self.merge(f"{platform}-v{version}-20261002", downloads, result)
self.assertEqual(set(result["platforms"]), {"ios", "android"})
self.assertEqual(result["version"], self.current["version"])
self.assertTrue({"windows-x64", "ios-arm64", "android-arm64"} <= result["downloads"].keys())
def test_combined_release_records_independent_mobile_versions(self):
downloads = self.files("crossdesk-win-x64-v1.6.1-20261002.exe",
"crossdesk-win-x64-portable-v1.6.1-20261002.zip",
"crossdesk-ios-arm64-unsigned-v0.0.2-20261002.zip",
"crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
result = self.merge("v1.6.1-20261002", downloads)
self.assertEqual(result["version"], "1.6.1-20261002")
self.assertEqual(result["platforms"]["ios"]["version"], "0.0.2-20261002")
self.assertEqual(result["platforms"]["android"]["version"], "1.6.0-20261002")
def test_mobile_tag_must_match_artifact_and_platform(self):
downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
for tag in ("android-v1.7.0-20261002", "ios-v1.6.0-20261002"):
with self.assertRaises(ValueError):
self.merge(tag, downloads)
def test_invalid_current_manifest_is_rejected(self):
downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
for current in ([], {}, {"downloads": []}, {"downloads": {}, "platforms": []}):
with self.assertRaises(ValueError):
manifest.merge_release(current, "android-v1.6.0-20261002", "Android", "", downloads)
def test_missing_duplicate_and_invalid_artifacts_are_rejected(self):
for names in ((), ("crossdesk-android-arm64-unsigned-v1.6.0-20260230.apk",),
("crossdesk-android-arm64-unsigned-v1.6.0-20261001.apk",
"crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")):
with self.assertRaises(ValueError):
self.files(*names)
def test_hotfix_version_and_date_formats(self):
for value in ("v1.6.0-2-20261002", "v1.6.0-20261002-2"):
info = manifest.version_info(value)
self.assertEqual(info["patch"], 2)
self.assertEqual(info["releaseDate"], "2026-10-02")
def test_cli_preserves_literal_notes_and_release_metadata(self):
self.files("crossdesk-ios-arm64-unsigned-v0.0.2-20261002.zip")
notes = '中文 release notes\n"quoted" $(not-a-command) $HOME `literal`'
release = {"tagName": "ios-v0.0.2-20261002", "name": "iOS release", "body": notes}
(self.root / "current.json").write_text(json.dumps(self.current))
(self.root / "release.json").write_text(json.dumps(release))
output = self.root / "version.json"
subprocess.run([sys.executable, str(Path(manifest.__file__)),
"--current", str(self.root / "current.json"), "--output", str(output),
"--artifacts", str(self.artifacts), "--release-json", str(self.root / "release.json")],
check=True, capture_output=True, text=True)
result = json.loads(output.read_text())
self.assertEqual(result["platforms"]["ios"]["releaseNotes"], notes)
self.assertEqual(result["releaseNotes"], "Desktop notes")
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Merge a release's server downloads into the shared version.json."""
import argparse
import datetime
import json
from pathlib import Path
import re
DOWNLOAD_BASE = "https://downloads.crossdesk.cn/"
ARTIFACTS = {
"windows-x64": ("win-x64", "exe"),
"windows-x64-portable": ("win-x64-portable", "zip"),
"macos-x64": ("macos-x64", "pkg"),
"macos-arm64": ("macos-arm64", "pkg"),
"linux-amd64": ("linux-amd64", "deb"),
"linux-arm64": ("linux-arm64", "deb"),
"ios-arm64": ("ios-arm64-unsigned", "zip"),
"android-arm64": ("android-arm64-unsigned", "apk"),
}
def version_info(version, fallback_date=None):
version = version.removeprefix("v")
base = r"[0-9]+(?:\.[0-9]+){1,3}"
for pattern in (
rf"(?P<base>{base})-(?P<patch>[0-9]+)-(?P<date>[0-9]{{8}})",
rf"(?P<base>{base})-(?P<date>[0-9]{{8}})(?:-(?P<patch>[0-9]+))?",
rf"(?P<base>{base})(?:-(?P<patch>[0-9]+))?",
):
match = re.fullmatch(pattern, version)
if match:
fields = match.groupdict()
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
raise ValueError(f"Invalid release version: {version}")
def artifact_downloads(directory):
downloads = {}
for path in sorted(Path(directory).iterdir()):
if not path.is_file():
continue
for key, (prefix, extension) in ARTIFACTS.items():
match = re.fullmatch(rf"crossdesk-{prefix}-v([0-9][0-9.\-]*)\.{extension}", path.name)
if not match:
continue
if key in downloads:
raise ValueError(f"Multiple release artifacts for {key}")
metadata = version_info(match[1])
downloads[key] = {"url": DOWNLOAD_BASE + path.name, "filename": path.name,
"version": metadata["version"], "releaseDate": metadata["releaseDate"]}
break
if not downloads:
raise ValueError("No supported release artifacts found")
return downloads
def merge_release(current, tag, name, notes, downloads, published_date=None):
if not isinstance(current, dict) or not isinstance(current.get("downloads"), dict):
raise ValueError("Current version.json must contain a downloads object")
if not isinstance(current.get("platforms", {}), dict):
raise ValueError("Current version.json platforms must be an object")
mobile = re.fullmatch(r"(ios|android)-(v.+)", tag)
if mobile:
platform, version = mobile.groups()
expected_key = f"{platform}-arm64"
if set(downloads) != {expected_key}:
raise ValueError(f"{tag} must contain only the {expected_key} release artifact")
metadata = version_info(version, published_date)
if downloads[expected_key]["version"] != metadata["version"]:
raise ValueError("Mobile source tag does not match the artifact version")
else:
if not tag.startswith("v"):
raise ValueError(f"Expected a version tag, got {tag!r}")
metadata = version_info(tag, published_date)
result = dict(current)
result["downloads"] = {**current["downloads"], **downloads}
result["platforms"] = dict(current.get("platforms", {}))
release = {**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag}
if not mobile:
result.update(release)
for platform in ("ios", "android"):
download = downloads.get(f"{platform}-arm64")
if download:
info = version_info(download["version"])
result["platforms"][platform] = {
**info, "releaseName": name, "releaseNotes": notes, "tagName": tag,
}
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--current", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--artifacts", required=True, type=Path)
parser.add_argument("--tag")
parser.add_argument("--name")
parser.add_argument("--notes", type=Path)
parser.add_argument("--release-json", type=Path)
args = parser.parse_args()
if args.release_json:
release = json.loads(args.release_json.read_text())
tag = release["tagName"]
name = release.get("name") or tag
notes = release.get("body") or ""
published_date = (release.get("publishedAt") or "").split("T")[0] or None
else:
if not args.tag or not args.name or not args.notes:
parser.error("Supply --release-json or --tag, --name and --notes")
tag, name, notes = args.tag, args.name, args.notes.read_text()
published_date = None
current = json.loads(args.current.read_text())
downloads = artifact_downloads(args.artifacts)
result = merge_release(current, tag, name, notes, downloads, published_date)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated {tag}: {', '.join(downloads)}")
if __name__ == "__main__":
main()