mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
release: attach runtime assets to Python releases (#3405)
Co-authored-by: zhifu gao <[email protected]>
This commit is contained in:
@@ -18,8 +18,11 @@ jobs:
|
||||
- name: Create GitHub Release with auto-generated notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release create "${{ github.ref_name }}" --generate-notes --verify-tag --title "${{ github.ref_name }}"
|
||||
- name: Add Runtime downloads to GitHub Release
|
||||
run: |
|
||||
if ! gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then
|
||||
gh release create "${{ github.ref_name }}" --generate-notes --verify-tag --title "${{ github.ref_name }}"
|
||||
fi
|
||||
- name: Add Runtime assets and downloads to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python scripts/update_release_runtime_downloads.py "${{ github.ref_name }}"
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add prebuilt runtime download links to a Python package GitHub release."""
|
||||
"""Attach prebuilt runtimes and their download links to a Python GitHub release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
RUNTIME_SECTION_HEADING = "## Runtime downloads"
|
||||
RUNTIME_SECTION_START = "<!-- funasr-runtime-downloads:start -->"
|
||||
RUNTIME_SECTION_END = "<!-- funasr-runtime-downloads:end -->"
|
||||
|
||||
|
||||
def run_gh_json(args: list[str]) -> Any:
|
||||
@@ -51,7 +56,18 @@ def platform_label(asset_name: str) -> str:
|
||||
return asset_name
|
||||
|
||||
|
||||
def latest_runtime_tag(repository: str) -> str:
|
||||
def parse_published_at(value: str, *, label: str) -> datetime:
|
||||
if not value:
|
||||
raise RuntimeError(f"{label} has no publication timestamp")
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise RuntimeError(
|
||||
f"{label} has invalid publication timestamp: {value}"
|
||||
) from error
|
||||
|
||||
|
||||
def latest_runtime_tag(repository: str, *, published_before: str) -> str:
|
||||
releases = run_gh_json(
|
||||
[
|
||||
"release",
|
||||
@@ -59,17 +75,29 @@ def latest_runtime_tag(repository: str) -> str:
|
||||
"--repo",
|
||||
repository,
|
||||
"--limit",
|
||||
"50",
|
||||
"1000",
|
||||
"--json",
|
||||
"tagName,publishedAt,isDraft,isPrerelease",
|
||||
]
|
||||
)
|
||||
cutoff = parse_published_at(published_before, label="Python release")
|
||||
eligible = []
|
||||
for release in releases:
|
||||
if release["tagName"].startswith("runtime-llamacpp-v") and not release.get(
|
||||
"isDraft"
|
||||
if (
|
||||
release["tagName"].startswith("runtime-llamacpp-v")
|
||||
and not release.get("isDraft")
|
||||
and not release.get("isPrerelease")
|
||||
):
|
||||
return release["tagName"]
|
||||
raise RuntimeError("No published runtime-llamacpp release found")
|
||||
published_at = parse_published_at(
|
||||
release.get("publishedAt") or "", label=release["tagName"]
|
||||
)
|
||||
if published_at <= cutoff:
|
||||
eligible.append((published_at, release["tagName"]))
|
||||
if eligible:
|
||||
return max(eligible)[1]
|
||||
raise RuntimeError(
|
||||
f"No stable runtime-llamacpp release published by {published_before} found"
|
||||
)
|
||||
|
||||
|
||||
def load_release(repository: str, tag: str) -> dict[str, Any]:
|
||||
@@ -81,22 +109,137 @@ def load_release(repository: str, tag: str) -> dict[str, Any]:
|
||||
"--repo",
|
||||
repository,
|
||||
"--json",
|
||||
"tagName,name,body,url,assets",
|
||||
"tagName,name,body,url,assets,publishedAt,isDraft,isPrerelease",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def runtime_assets(release: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
assets = release.get("assets") or []
|
||||
selected = [
|
||||
asset for asset in assets if asset["name"].startswith("funasr-llamacpp-")
|
||||
]
|
||||
if not selected:
|
||||
raise RuntimeError(f"{release['tagName']} has no llama.cpp assets")
|
||||
return selected
|
||||
|
||||
|
||||
def validate_runtime_release(
|
||||
*, runtime_release: dict[str, Any], python_release: dict[str, Any]
|
||||
) -> None:
|
||||
runtime_tag = runtime_release["tagName"]
|
||||
if runtime_release.get("isDraft"):
|
||||
raise RuntimeError(f"{runtime_tag} is a draft")
|
||||
if runtime_release.get("isPrerelease"):
|
||||
raise RuntimeError(f"{runtime_tag} is a prerelease")
|
||||
runtime_published_at = parse_published_at(
|
||||
runtime_release.get("publishedAt") or "", label=runtime_tag
|
||||
)
|
||||
python_published_at = parse_published_at(
|
||||
python_release.get("publishedAt") or "", label=python_release["tagName"]
|
||||
)
|
||||
if runtime_published_at > python_published_at:
|
||||
raise RuntimeError(
|
||||
f"{runtime_tag} was published after {python_release['tagName']}"
|
||||
)
|
||||
|
||||
|
||||
def asset_sha256(asset: dict[str, Any]) -> str:
|
||||
digest = asset.get("digest")
|
||||
if not isinstance(digest, str) or not digest.startswith("sha256:"):
|
||||
raise RuntimeError(f"{asset['name']} has no SHA-256 digest")
|
||||
sha256 = digest.removeprefix("sha256:")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{64}", sha256):
|
||||
raise RuntimeError(f"{asset['name']} has an invalid SHA-256 digest")
|
||||
return sha256.lower()
|
||||
|
||||
|
||||
def runtime_assets_to_upload(
|
||||
*, python_release: dict[str, Any], runtime_release: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
existing = {asset["name"]: asset for asset in (python_release.get("assets") or [])}
|
||||
missing = []
|
||||
for source_asset in runtime_assets(runtime_release):
|
||||
source_digest = asset_sha256(source_asset)
|
||||
target_asset = existing.get(source_asset["name"])
|
||||
if target_asset is None:
|
||||
missing.append(source_asset)
|
||||
continue
|
||||
target_digest = asset_sha256(target_asset)
|
||||
if target_digest != source_digest:
|
||||
raise RuntimeError(
|
||||
f"{python_release['tagName']} already has {source_asset['name']} "
|
||||
"with a different digest"
|
||||
)
|
||||
return missing
|
||||
|
||||
|
||||
def verify_asset_digest(asset_path: Path, asset: dict[str, Any]) -> None:
|
||||
expected = asset_sha256(asset)
|
||||
with asset_path.open("rb") as handle:
|
||||
actual = hashlib.file_digest(handle, "sha256").hexdigest()
|
||||
if actual != expected:
|
||||
raise RuntimeError(
|
||||
f"SHA-256 mismatch for {asset['name']}: expected {expected}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
def sync_runtime_assets(
|
||||
*,
|
||||
repository: str,
|
||||
python_tag: str,
|
||||
runtime_release: dict[str, Any],
|
||||
python_release: dict[str, Any],
|
||||
) -> None:
|
||||
missing = runtime_assets_to_upload(
|
||||
python_release=python_release,
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
if not missing:
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
download_dir = Path(temp_dir)
|
||||
paths = []
|
||||
for asset in missing:
|
||||
run_gh(
|
||||
[
|
||||
"release",
|
||||
"download",
|
||||
runtime_release["tagName"],
|
||||
"--repo",
|
||||
repository,
|
||||
"--pattern",
|
||||
asset["name"],
|
||||
"--dir",
|
||||
str(download_dir),
|
||||
]
|
||||
)
|
||||
asset_path = download_dir / asset["name"]
|
||||
verify_asset_digest(asset_path, asset)
|
||||
paths.append(str(asset_path))
|
||||
|
||||
run_gh(
|
||||
[
|
||||
"release",
|
||||
"upload",
|
||||
python_tag,
|
||||
*paths,
|
||||
"--repo",
|
||||
repository,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_downloads_section(
|
||||
*, python_tag: str, runtime_release: dict[str, Any]
|
||||
) -> str:
|
||||
assets = sorted(runtime_release.get("assets") or [], key=lambda asset: asset["name"])
|
||||
runtime_assets = [
|
||||
asset for asset in assets if asset["name"].startswith("funasr-llamacpp-")
|
||||
]
|
||||
if not runtime_assets:
|
||||
raise RuntimeError(f"{runtime_release['tagName']} has no llama.cpp assets")
|
||||
selected_assets = sorted(
|
||||
runtime_assets(runtime_release), key=lambda asset: asset["name"]
|
||||
)
|
||||
|
||||
python_version = python_tag.removeprefix("v")
|
||||
repository_url = runtime_release["url"].split("/releases/tag/", maxsplit=1)[0]
|
||||
lines = [
|
||||
RUNTIME_SECTION_HEADING,
|
||||
"",
|
||||
@@ -106,19 +249,20 @@ def build_runtime_downloads_section(
|
||||
),
|
||||
"",
|
||||
(
|
||||
"Use these assets when you want the self-contained `llama-funasr-*` "
|
||||
"binaries instead of the Python package. The runtime release is shared "
|
||||
f"by the current Python releases; `{python_tag}` itself is the PyPI package release."
|
||||
"The same verified runtime assets are attached directly to this Python "
|
||||
"release so users can find the package and self-contained "
|
||||
"`llama-funasr-*` binaries in one place."
|
||||
),
|
||||
"",
|
||||
"| Platform | Asset | SHA-256 |",
|
||||
"|---|---|---|",
|
||||
]
|
||||
for asset in runtime_assets:
|
||||
digest = (asset.get("digest") or "").removeprefix("sha256:")
|
||||
for asset in selected_assets:
|
||||
digest = asset_sha256(asset)
|
||||
asset_url = f"{repository_url}/releases/download/{python_tag}/{asset['name']}"
|
||||
lines.append(
|
||||
f"| {platform_label(asset['name'])} | "
|
||||
f"[{asset['name']}]({asset['url']}) | "
|
||||
f"[{asset['name']}]({asset_url}) | "
|
||||
f"`{digest}` |"
|
||||
)
|
||||
|
||||
@@ -145,19 +289,68 @@ def build_runtime_downloads_section(
|
||||
def merge_release_body(
|
||||
*, current_body: str, python_tag: str, runtime_release: dict[str, Any]
|
||||
) -> str:
|
||||
body_without_runtime = current_body.split(RUNTIME_SECTION_HEADING, maxsplit=1)[
|
||||
0
|
||||
].rstrip()
|
||||
runtime_section = build_runtime_downloads_section(
|
||||
python_tag=python_tag, runtime_release=runtime_release
|
||||
)
|
||||
return f"{body_without_runtime}\n\n{runtime_section}\n"
|
||||
managed_section = (
|
||||
f"{RUNTIME_SECTION_START}\n{runtime_section}\n{RUNTIME_SECTION_END}"
|
||||
)
|
||||
|
||||
marker_start = current_body.find(RUNTIME_SECTION_START)
|
||||
marker_end = current_body.find(RUNTIME_SECTION_END)
|
||||
if (marker_start == -1) != (marker_end == -1):
|
||||
raise RuntimeError("Release body has incomplete runtime download markers")
|
||||
if marker_start != -1:
|
||||
if marker_end < marker_start:
|
||||
raise RuntimeError("Release body has invalid runtime download markers")
|
||||
prefix = current_body[:marker_start]
|
||||
suffix = current_body[marker_end + len(RUNTIME_SECTION_END) :]
|
||||
else:
|
||||
legacy_start = current_body.find(RUNTIME_SECTION_HEADING)
|
||||
if legacy_start == -1:
|
||||
prefix = current_body
|
||||
suffix = ""
|
||||
else:
|
||||
next_heading = current_body.find(
|
||||
"\n## ", legacy_start + len(RUNTIME_SECTION_HEADING)
|
||||
)
|
||||
legacy_end = len(current_body) if next_heading == -1 else next_heading + 1
|
||||
prefix = current_body[:legacy_start]
|
||||
suffix = current_body[legacy_end:]
|
||||
|
||||
parts = [
|
||||
part for part in (prefix.rstrip(), managed_section, suffix.strip()) if part
|
||||
]
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def update_release_body(repository: str, python_tag: str, runtime_tag: str | None) -> None:
|
||||
runtime_tag = runtime_tag or latest_runtime_tag(repository)
|
||||
def update_release(repository: str, python_tag: str, runtime_tag: str | None) -> None:
|
||||
python_release = load_release(repository, python_tag)
|
||||
published_at = python_release.get("publishedAt")
|
||||
if not published_at:
|
||||
raise RuntimeError(f"{python_tag} has no publication timestamp")
|
||||
runtime_tag = runtime_tag or latest_runtime_tag(
|
||||
repository, published_before=published_at
|
||||
)
|
||||
runtime_release = load_release(repository, runtime_tag)
|
||||
validate_runtime_release(
|
||||
runtime_release=runtime_release,
|
||||
python_release=python_release,
|
||||
)
|
||||
sync_runtime_assets(
|
||||
repository=repository,
|
||||
python_tag=python_tag,
|
||||
runtime_release=runtime_release,
|
||||
python_release=python_release,
|
||||
)
|
||||
python_release = load_release(repository, python_tag)
|
||||
remaining = runtime_assets_to_upload(
|
||||
python_release=python_release,
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
if remaining:
|
||||
names = ", ".join(asset["name"] for asset in remaining)
|
||||
raise RuntimeError(f"{python_tag} is still missing runtime assets: {names}")
|
||||
merged = merge_release_body(
|
||||
current_body=python_release.get("body") or "",
|
||||
python_tag=python_tag,
|
||||
@@ -185,7 +378,9 @@ def update_release_body(repository: str, python_tag: str, runtime_tag: str | Non
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("python_tag", help="Python package release tag, for example v1.3.26")
|
||||
parser.add_argument(
|
||||
"python_tag", help="Python package release tag, for example v1.3.26"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
default=os.environ.get("GITHUB_REPOSITORY", "modelscope/FunASR"),
|
||||
@@ -201,7 +396,7 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
update_release_body(args.repo, args.python_tag, args.runtime_tag)
|
||||
update_release(args.repo, args.python_tag, args.runtime_tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def sha256_digest(value):
|
||||
return "sha256:" + hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def load_release_script():
|
||||
script_path = ROOT / "scripts" / "update_release_runtime_downloads.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
@@ -19,63 +26,385 @@ def runtime_release_payload():
|
||||
return {
|
||||
"tagName": "runtime-llamacpp-v0.1.9",
|
||||
"url": "https://github.com/modelscope/FunASR/releases/tag/runtime-llamacpp-v0.1.9",
|
||||
"publishedAt": "2026-07-22T00:00:00Z",
|
||||
"isDraft": False,
|
||||
"isPrerelease": False,
|
||||
"assets": [
|
||||
{
|
||||
"name": "funasr-llamacpp-linux-arm64.tar.gz",
|
||||
"url": "https://example.test/linux-arm64.tar.gz",
|
||||
"digest": "sha256:linuxarm64",
|
||||
"digest": sha256_digest("linuxarm64"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-linux-x64-avx2.tar.gz",
|
||||
"url": "https://example.test/linux-avx2.tar.gz",
|
||||
"digest": "sha256:linuxavx2",
|
||||
"digest": sha256_digest("linuxavx2"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-linux-x64-vulkan.tar.gz",
|
||||
"url": "https://example.test/linux-vulkan.tar.gz",
|
||||
"digest": "sha256:linuxvulkan",
|
||||
"digest": sha256_digest("linuxvulkan"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-linux-x64.tar.gz",
|
||||
"url": "https://example.test/linux-x64.tar.gz",
|
||||
"digest": "sha256:linuxx64",
|
||||
"digest": sha256_digest("linuxx64"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-macos-arm64.tar.gz",
|
||||
"url": "https://example.test/macos-arm64.tar.gz",
|
||||
"digest": "sha256:macosarm64",
|
||||
"digest": sha256_digest("macosarm64"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-windows-x64-avx2.zip",
|
||||
"url": "https://example.test/windows-avx2.zip",
|
||||
"digest": "sha256:windowsavx2",
|
||||
"digest": sha256_digest("windowsavx2"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-windows-x64-cuda.zip",
|
||||
"url": "https://example.test/windows-cuda.zip",
|
||||
"digest": "sha256:windowscuda",
|
||||
"digest": sha256_digest("windowscuda"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-windows-x64-vulkan.zip",
|
||||
"url": "https://example.test/windows-vulkan.zip",
|
||||
"digest": "sha256:windowsvulkan",
|
||||
"digest": sha256_digest("windowsvulkan"),
|
||||
},
|
||||
{
|
||||
"name": "funasr-llamacpp-windows-x64.zip",
|
||||
"url": "https://example.test/windows-x64.zip",
|
||||
"digest": "sha256:windowsx64",
|
||||
"digest": sha256_digest("windowsx64"),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_release_on_tag_workflow_adds_runtime_downloads_to_latest_release():
|
||||
def python_release_payload(assets=None):
|
||||
return {
|
||||
"tagName": "v1.3.27",
|
||||
"publishedAt": "2026-07-24T01:00:00Z",
|
||||
"assets": assets or [],
|
||||
}
|
||||
|
||||
|
||||
def test_release_on_tag_workflow_adds_runtime_assets_and_downloads():
|
||||
workflow = (ROOT / ".github" / "workflows" / "release-on-tag.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "scripts/update_release_runtime_downloads.py" in workflow
|
||||
assert "Runtime downloads" in workflow
|
||||
assert "Runtime assets and downloads" in workflow
|
||||
assert "gh release view" in workflow
|
||||
assert "if !" in workflow
|
||||
|
||||
|
||||
def test_latest_runtime_tag_is_stable_for_python_release_time():
|
||||
module = load_release_script()
|
||||
calls = []
|
||||
module.run_gh_json = lambda args: (
|
||||
calls.append(args)
|
||||
or [
|
||||
{
|
||||
"tagName": "runtime-llamacpp-v0.2.0",
|
||||
"publishedAt": "2026-07-25T00:00:00Z",
|
||||
"isDraft": False,
|
||||
"isPrerelease": False,
|
||||
},
|
||||
{
|
||||
"tagName": "runtime-llamacpp-v0.1.10-rc1",
|
||||
"publishedAt": "2026-07-23T00:00:00Z",
|
||||
"isDraft": False,
|
||||
"isPrerelease": True,
|
||||
},
|
||||
{
|
||||
"tagName": "runtime-llamacpp-v0.1.9",
|
||||
"publishedAt": "2026-07-22T00:00:00Z",
|
||||
"isDraft": False,
|
||||
"isPrerelease": False,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
tag = module.latest_runtime_tag(
|
||||
"modelscope/FunASR", published_before="2026-07-24T01:00:00Z"
|
||||
)
|
||||
|
||||
assert tag == "runtime-llamacpp-v0.1.9"
|
||||
assert calls[0][calls[0].index("--limit") + 1] == "1000"
|
||||
|
||||
|
||||
def test_runtime_assets_to_upload_returns_missing_assets():
|
||||
module = load_release_script()
|
||||
|
||||
missing = module.runtime_assets_to_upload(
|
||||
python_release=python_release_payload(),
|
||||
runtime_release=runtime_release_payload(),
|
||||
)
|
||||
|
||||
assert [asset["name"] for asset in missing] == [
|
||||
asset["name"] for asset in runtime_release_payload()["assets"]
|
||||
]
|
||||
|
||||
|
||||
def test_runtime_assets_to_upload_skips_matching_assets():
|
||||
module = load_release_script()
|
||||
runtime_release = runtime_release_payload()
|
||||
existing = [
|
||||
{
|
||||
"name": asset["name"],
|
||||
"digest": asset["digest"],
|
||||
}
|
||||
for asset in runtime_release["assets"]
|
||||
]
|
||||
|
||||
missing = module.runtime_assets_to_upload(
|
||||
python_release=python_release_payload(existing),
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_runtime_assets_to_upload_recovers_partially_uploaded_release():
|
||||
module = load_release_script()
|
||||
runtime_release = runtime_release_payload()
|
||||
uploaded = runtime_release["assets"][:3]
|
||||
|
||||
missing = module.runtime_assets_to_upload(
|
||||
python_release=python_release_payload(uploaded),
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
|
||||
assert [asset["name"] for asset in missing] == [
|
||||
asset["name"] for asset in runtime_release["assets"][3:]
|
||||
]
|
||||
|
||||
|
||||
def test_runtime_assets_to_upload_rejects_digest_mismatch():
|
||||
module = load_release_script()
|
||||
runtime_release = runtime_release_payload()
|
||||
source_asset = runtime_release["assets"][0]
|
||||
|
||||
with pytest.raises(RuntimeError, match="different digest"):
|
||||
module.runtime_assets_to_upload(
|
||||
python_release=python_release_payload(
|
||||
[{"name": source_asset["name"], "digest": sha256_digest("different")}]
|
||||
),
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_assets_to_upload_rejects_missing_source_digest():
|
||||
module = load_release_script()
|
||||
runtime_release = runtime_release_payload()
|
||||
runtime_release["assets"][0].pop("digest")
|
||||
|
||||
with pytest.raises(RuntimeError, match="has no SHA-256 digest"):
|
||||
module.runtime_assets_to_upload(
|
||||
python_release=python_release_payload(),
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_assets_to_upload_rejects_missing_existing_digest():
|
||||
module = load_release_script()
|
||||
runtime_release = runtime_release_payload()
|
||||
source_asset = runtime_release["assets"][0]
|
||||
|
||||
with pytest.raises(RuntimeError, match="has no SHA-256 digest"):
|
||||
module.runtime_assets_to_upload(
|
||||
python_release=python_release_payload([{"name": source_asset["name"]}]),
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_assets_to_upload_rejects_malformed_digest():
|
||||
module = load_release_script()
|
||||
runtime_release = runtime_release_payload()
|
||||
runtime_release["assets"][0]["digest"] = "sha256:not-a-real-digest"
|
||||
|
||||
with pytest.raises(RuntimeError, match="invalid SHA-256 digest"):
|
||||
module.runtime_assets_to_upload(
|
||||
python_release=python_release_payload(),
|
||||
runtime_release=runtime_release,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_asset_digest_checks_downloaded_bytes(tmp_path):
|
||||
module = load_release_script()
|
||||
asset_path = tmp_path / "funasr-llamacpp-linux-x64.tar.gz"
|
||||
asset_path.write_bytes(b"verified runtime")
|
||||
expected = hashlib.sha256(b"verified runtime").hexdigest()
|
||||
asset = {"name": asset_path.name, "digest": f"sha256:{expected}"}
|
||||
|
||||
module.verify_asset_digest(asset_path, asset)
|
||||
|
||||
asset["digest"] = "sha256:" + ("0" * 64)
|
||||
with pytest.raises(RuntimeError, match="SHA-256 mismatch"):
|
||||
module.verify_asset_digest(asset_path, asset)
|
||||
|
||||
|
||||
def test_sync_runtime_assets_downloads_verifies_and_uploads_missing_asset():
|
||||
module = load_release_script()
|
||||
payload = b"runtime archive"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
asset_name = "funasr-llamacpp-linux-x64.tar.gz"
|
||||
runtime_release = {
|
||||
"tagName": "runtime-llamacpp-v0.1.9",
|
||||
"assets": [
|
||||
{
|
||||
"name": asset_name,
|
||||
"digest": f"sha256:{digest}",
|
||||
}
|
||||
],
|
||||
}
|
||||
calls = []
|
||||
|
||||
def fake_run_gh(args):
|
||||
calls.append(args)
|
||||
if args[:2] == ["release", "download"]:
|
||||
download_dir = Path(args[args.index("--dir") + 1])
|
||||
(download_dir / asset_name).write_bytes(payload)
|
||||
|
||||
module.run_gh = fake_run_gh
|
||||
|
||||
module.sync_runtime_assets(
|
||||
repository="modelscope/FunASR",
|
||||
python_tag="v1.3.27",
|
||||
runtime_release=runtime_release,
|
||||
python_release=python_release_payload(),
|
||||
)
|
||||
|
||||
assert calls[0][:3] == [
|
||||
"release",
|
||||
"download",
|
||||
"runtime-llamacpp-v0.1.9",
|
||||
]
|
||||
assert calls[0][calls[0].index("--pattern") + 1] == asset_name
|
||||
assert calls[1][:3] == ["release", "upload", "v1.3.27"]
|
||||
assert calls[1][-2:] == ["--repo", "modelscope/FunASR"]
|
||||
|
||||
|
||||
def test_update_release_syncs_assets_before_editing_notes():
|
||||
module = load_release_script()
|
||||
python_release_before = python_release_payload()
|
||||
python_release_before["body"] = "stale body"
|
||||
python_release_after = python_release_payload(runtime_release_payload()["assets"])
|
||||
python_release_after["body"] = "fresh body"
|
||||
runtime_release = runtime_release_payload()
|
||||
events = []
|
||||
python_loads = 0
|
||||
|
||||
def fake_load_release(repository, tag):
|
||||
nonlocal python_loads
|
||||
assert repository == "modelscope/FunASR"
|
||||
if tag != "v1.3.27":
|
||||
return runtime_release
|
||||
python_loads += 1
|
||||
return python_release_before if python_loads == 1 else python_release_after
|
||||
|
||||
def fake_sync_runtime_assets(**kwargs):
|
||||
assert kwargs["python_release"] is python_release_before
|
||||
assert kwargs["runtime_release"] is runtime_release
|
||||
events.append("sync")
|
||||
|
||||
def fake_run_gh(args):
|
||||
assert args[:3] == ["release", "edit", "v1.3.27"]
|
||||
notes_path = Path(args[args.index("--notes-file") + 1])
|
||||
notes = notes_path.read_text(encoding="utf-8")
|
||||
assert "fresh body" in notes
|
||||
assert "stale body" not in notes
|
||||
assert "## Runtime downloads" in notes
|
||||
events.append("edit")
|
||||
|
||||
module.load_release = fake_load_release
|
||||
module.sync_runtime_assets = fake_sync_runtime_assets
|
||||
module.run_gh = fake_run_gh
|
||||
|
||||
module.update_release(
|
||||
repository="modelscope/FunASR",
|
||||
python_tag="v1.3.27",
|
||||
runtime_tag="runtime-llamacpp-v0.1.9",
|
||||
)
|
||||
|
||||
assert events == ["sync", "edit"]
|
||||
assert python_loads == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "message"),
|
||||
[
|
||||
("isDraft", True, "is a draft"),
|
||||
("isPrerelease", True, "is a prerelease"),
|
||||
("publishedAt", "2026-07-25T00:00:00Z", "was published after"),
|
||||
],
|
||||
)
|
||||
def test_update_release_rejects_explicit_unstable_or_future_runtime(
|
||||
field, value, message
|
||||
):
|
||||
module = load_release_script()
|
||||
python_release = python_release_payload()
|
||||
runtime_release = runtime_release_payload()
|
||||
runtime_release[field] = value
|
||||
module.load_release = lambda repository, tag: (
|
||||
python_release if tag == "v1.3.27" else runtime_release
|
||||
)
|
||||
module.sync_runtime_assets = lambda **kwargs: pytest.fail(
|
||||
"invalid runtime must be rejected before upload"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
module.update_release(
|
||||
repository="modelscope/FunASR",
|
||||
python_tag="v1.3.27",
|
||||
runtime_tag="runtime-llamacpp-v0.1.9",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("assets_after_upload", "message"),
|
||||
[
|
||||
([], "still missing runtime assets"),
|
||||
(
|
||||
[
|
||||
{
|
||||
"name": "funasr-llamacpp-linux-arm64.tar.gz",
|
||||
"digest": sha256_digest("different"),
|
||||
}
|
||||
],
|
||||
"different digest",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_update_release_does_not_edit_notes_when_post_upload_verification_fails(
|
||||
assets_after_upload, message
|
||||
):
|
||||
module = load_release_script()
|
||||
python_release_before = python_release_payload()
|
||||
python_release_after = python_release_payload(assets_after_upload)
|
||||
runtime_release = runtime_release_payload()
|
||||
python_loads = 0
|
||||
edit_calls = []
|
||||
|
||||
def fake_load_release(repository, tag):
|
||||
nonlocal python_loads
|
||||
if tag != "v1.3.27":
|
||||
return runtime_release
|
||||
python_loads += 1
|
||||
return python_release_before if python_loads == 1 else python_release_after
|
||||
|
||||
module.load_release = fake_load_release
|
||||
module.sync_runtime_assets = lambda **kwargs: None
|
||||
module.run_gh = lambda args: edit_calls.append(args)
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
module.update_release(
|
||||
repository="modelscope/FunASR",
|
||||
python_tag="v1.3.27",
|
||||
runtime_tag="runtime-llamacpp-v0.1.9",
|
||||
)
|
||||
|
||||
assert edit_calls == []
|
||||
|
||||
|
||||
def test_runtime_download_section_lists_all_prebuilt_assets():
|
||||
@@ -88,13 +417,18 @@ def test_runtime_download_section_lists_all_prebuilt_assets():
|
||||
|
||||
assert "## Runtime downloads" in section
|
||||
assert "runtime-llamacpp-v0.1.9" in section
|
||||
assert "attached directly to this Python release" in section
|
||||
assert 'python -m pip install -U "funasr==1.3.26"' in section
|
||||
assert section.count("| [funasr-llamacpp-") == 9
|
||||
assert "Windows x64 CUDA" in section
|
||||
assert "Windows x64 Vulkan" in section
|
||||
assert "Linux x64 Vulkan" in section
|
||||
assert "`windowscuda`" in section
|
||||
assert "`windowsvulkan`" in section
|
||||
assert f"`{sha256_digest('windowscuda').removeprefix('sha256:')}`" in section
|
||||
assert f"`{sha256_digest('windowsvulkan').removeprefix('sha256:')}`" in section
|
||||
assert (
|
||||
"https://github.com/modelscope/FunASR/releases/download/v1.3.26/"
|
||||
"funasr-llamacpp-linux-arm64.tar.gz"
|
||||
) in section
|
||||
|
||||
|
||||
def test_merge_release_body_replaces_stale_runtime_download_section():
|
||||
@@ -118,3 +452,34 @@ old runtime table
|
||||
assert merged.count("## Runtime downloads") == 1
|
||||
assert "funasr-llamacpp-windows-x64-cuda.zip" in merged
|
||||
assert "funasr-llamacpp-windows-x64-vulkan.zip" in merged
|
||||
|
||||
|
||||
def test_merge_release_body_preserves_sections_after_managed_runtime_downloads():
|
||||
module = load_release_script()
|
||||
current = """## What's Changed
|
||||
* release note
|
||||
|
||||
## Runtime downloads
|
||||
|
||||
old runtime table
|
||||
|
||||
## Security notes
|
||||
|
||||
Keep this manual section.
|
||||
"""
|
||||
|
||||
merged = module.merge_release_body(
|
||||
current_body=current,
|
||||
python_tag="v1.3.27",
|
||||
runtime_release=runtime_release_payload(),
|
||||
)
|
||||
merged_again = module.merge_release_body(
|
||||
current_body=merged,
|
||||
python_tag="v1.3.27",
|
||||
runtime_release=runtime_release_payload(),
|
||||
)
|
||||
|
||||
assert "## Security notes\n\nKeep this manual section." in merged
|
||||
assert merged.count("<!-- funasr-runtime-downloads:start -->") == 1
|
||||
assert merged.count("<!-- funasr-runtime-downloads:end -->") == 1
|
||||
assert merged_again == merged
|
||||
|
||||
Reference in New Issue
Block a user