From ac0a08de47596cb1e0d6b2c0eef696135dd768d0 Mon Sep 17 00:00:00 2001 From: zhifu gao Date: Fri, 24 Jul 2026 06:34:31 +0800 Subject: [PATCH] release: attach runtime assets to Python releases (#3405) Co-authored-by: zhifu gao <18321252+LauraGPT@users.noreply.github.com> --- .github/workflows/release-on-tag.yml | 7 +- scripts/update_release_runtime_downloads.py | 251 +++++++++++-- tests/test_release_runtime_downloads.py | 391 +++++++++++++++++++- 3 files changed, 606 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 7eac594b4..47865bd0c 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -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 }}" diff --git a/scripts/update_release_runtime_downloads.py b/scripts/update_release_runtime_downloads.py index 837389b64..3b63526b2 100644 --- a/scripts/update_release_runtime_downloads.py +++ b/scripts/update_release_runtime_downloads.py @@ -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 = "" +RUNTIME_SECTION_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__": diff --git a/tests/test_release_runtime_downloads.py b/tests/test_release_runtime_downloads.py index 14398049a..b62d9327b 100644 --- a/tests/test_release_runtime_downloads.py +++ b/tests/test_release_runtime_downloads.py @@ -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("") == 1 + assert merged.count("") == 1 + assert merged_again == merged