chore: publish stable media assets bundle
Some checks failed
release / release (push) Failing after 19s

Add CI validation, zip packaging, and vlatest release publishing for user-facing media-assets.zip downloads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 14:40:02 +08:00
parent 6d8e08ae95
commit 97f5b6ec6e
4 changed files with 428 additions and 4 deletions

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Publish or replace a fixed release asset on Gitea."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def _fail(message: str, *, status: int | None = None, body: str | None = None) -> None:
print(f"ERROR: {message}", file=sys.stderr)
if status is not None:
print(f"HTTP status: {status}", file=sys.stderr)
if body:
print(f"Response body: {body}", file=sys.stderr)
sys.exit(1)
def _request(
method: str,
url: str,
token: str,
*,
data: bytes | None = None,
headers: dict[str, str] | None = None,
content_type: str | None = None,
) -> tuple[int, str]:
req_headers = {"Authorization": f"token {token}"}
if headers:
req_headers.update(headers)
if content_type:
req_headers["Content-Type"] = content_type
request = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(request) as response:
body = response.read().decode("utf-8", errors="replace")
return response.status, body
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return exc.code, body
def _api_base(server_url: str, repository: str) -> str:
base = server_url.rstrip("/")
owner, repo = repository.split("/", 1)
return f"{base}/api/v1/repos/{owner}/{repo}"
def _parse_json(body: str) -> dict | list:
if not body.strip():
return {}
return json.loads(body)
def _get_release_by_tag(api_base: str, token: str, tag: str) -> dict | None:
url = f"{api_base}/releases/tags/{urllib.parse.quote(tag)}"
status, body = _request("GET", url, token)
if status == 404:
return None
if status >= 400:
_fail(f"failed to query release for tag {tag}", status=status, body=body)
return _parse_json(body) # type: ignore[return-value]
def _create_release(
api_base: str,
token: str,
*,
tag: str,
title: str,
target_commitish: str | None,
) -> dict:
payload: dict[str, object] = {
"tag_name": tag,
"name": title,
"body": "Stable media-assets bundle generated by CI.",
"draft": False,
"prerelease": False,
}
if target_commitish:
payload["target_commitish"] = target_commitish
url = f"{api_base}/releases"
status, body = _request(
"POST",
url,
token,
data=json.dumps(payload).encode("utf-8"),
content_type="application/json",
)
if status >= 400:
hint = ""
if "tag" in body.lower() or status in (404, 422):
hint = " 请先创建或允许 CI 推送固定 tag vlatest"
_fail(
f"failed to create release for tag {tag}.{hint}",
status=status,
body=body,
)
return _parse_json(body) # type: ignore[return-value]
def _list_release_assets(api_base: str, token: str, release_id: int) -> list[dict]:
url = f"{api_base}/releases/{release_id}/assets"
status, body = _request("GET", url, token)
if status >= 400:
_fail(f"failed to list assets for release {release_id}", status=status, body=body)
assets = _parse_json(body)
if not isinstance(assets, list):
_fail("unexpected assets response shape", body=body)
return assets
def _delete_asset(api_base: str, token: str, asset_id: int) -> None:
url = f"{api_base}/releases/assets/{asset_id}"
status, body = _request("DELETE", url, token)
if status >= 400:
_fail(f"failed to delete asset {asset_id}", status=status, body=body)
def _upload_asset(
api_base: str,
token: str,
release_id: int,
*,
asset_name: str,
file_path: Path,
) -> dict:
boundary = "----jiangchang-media-assets-boundary"
file_bytes = file_path.read_bytes()
content_type = mimetypes.guess_type(asset_name)[0] or "application/octet-stream"
parts: list[bytes] = []
parts.append(f"--{boundary}\r\n".encode("utf-8"))
parts.append(
(
f'Content-Disposition: form-data; name="attachment"; filename="{asset_name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
)
parts.append(file_bytes)
parts.append(b"\r\n")
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
payload = b"".join(parts)
query = urllib.parse.urlencode({"name": asset_name})
url = f"{api_base}/releases/{release_id}/assets?{query}"
status, body = _request(
"POST",
url,
token,
data=payload,
content_type=f"multipart/form-data; boundary={boundary}",
)
if status >= 400:
_fail(
f"failed to upload asset {asset_name} to release {release_id}",
status=status,
body=body,
)
return _parse_json(body) # type: ignore[return-value]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Publish a fixed Gitea release asset.")
parser.add_argument("--tag", required=True, help="Release tag, e.g. vlatest")
parser.add_argument("--title", required=True, help="Release title")
parser.add_argument("--file", required=True, help="Local asset file path")
parser.add_argument("--asset-name", required=True, help="Asset name in release")
args = parser.parse_args(argv)
server_url = os.environ.get("GITEA_SERVER_URL", "").strip()
token = os.environ.get("GITEA_TOKEN", "").strip()
repository = os.environ.get("GITEA_REPOSITORY", "").strip()
target_commitish = os.environ.get("GITEA_SHA", "").strip() or None
if not server_url:
_fail("missing environment variable GITEA_SERVER_URL")
if not token:
_fail("missing environment variable GITEA_TOKEN")
if not repository or "/" not in repository:
_fail("missing or invalid environment variable GITEA_REPOSITORY")
file_path = Path(args.file)
if not file_path.is_file():
_fail(f"asset file not found: {file_path}")
api_base = _api_base(server_url, repository)
release = _get_release_by_tag(api_base, token, args.tag)
if release is None:
print(f"Release for tag {args.tag} not found; creating...")
release = _create_release(
api_base,
token,
tag=args.tag,
title=args.title,
target_commitish=target_commitish,
)
else:
print(f"Reusing existing release for tag {args.tag} (id={release['id']})")
release_id = int(release["id"])
assets = _list_release_assets(api_base, token, release_id)
for asset in assets:
if asset.get("name") == args.asset_name:
asset_id = int(asset["id"])
print(f"Deleting existing asset {args.asset_name} (id={asset_id})...")
_delete_asset(api_base, token, asset_id)
print(f"Uploading {file_path} as {args.asset_name}...")
uploaded = _upload_asset(
api_base,
token,
release_id,
asset_name=args.asset_name,
file_path=file_path,
)
download_url = uploaded.get("browser_download_url") or uploaded.get("download_url")
if not download_url:
owner, repo = repository.split("/", 1)
download_url = (
f"{server_url.rstrip('/')}/{owner}/{repo}/releases/download/"
f"{args.tag}/{urllib.parse.quote(args.asset_name)}"
)
print(f"OK: uploaded release asset {args.asset_name}")
print(f"Download URL: {download_url}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

104
scripts/validate_bundle.py Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Validate a media-assets bundle directory before packaging or release."""
from __future__ import annotations
import json
import sys
from pathlib import Path
LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
MIN_MP3_BYTES = 100 * 1024
def _fail(message: str) -> None:
print(f"ERROR: {message}", file=sys.stderr)
sys.exit(1)
def _is_lfs_pointer(path: Path) -> bool:
with path.open("rb") as handle:
header = handle.read(256)
return header.startswith(LFS_POINTER_PREFIX)
def _require_path(root: Path, relative: str, *, must_be_dir: bool = False) -> Path:
target = root / relative
if not target.exists():
_fail(f"missing required path: {relative}")
if must_be_dir and not target.is_dir():
_fail(f"expected directory: {relative}")
if not must_be_dir and not target.is_file():
_fail(f"expected file: {relative}")
return target
def _validate_manifest(manifest_path: Path) -> None:
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
_fail(f"manifest.json is not valid JSON: {exc}")
try:
win32 = data["tools"]["ffmpeg"]["win32-x64"]
urls = win32["urls"]
archive_paths = win32["archive_paths"]
except (KeyError, TypeError) as exc:
_fail(f"manifest.json missing required tools.ffmpeg.win32-x64 fields: {exc}")
if not isinstance(urls, list) or not urls:
_fail("manifest.json tools.ffmpeg.win32-x64.urls must be a non-empty array")
for key in ("ffmpeg", "ffprobe"):
if key not in archive_paths or not archive_paths[key]:
_fail(f"manifest.json missing tools.ffmpeg.win32-x64.archive_paths.{key}")
def validate_bundle(root: Path) -> list[tuple[Path, int]]:
root = root.resolve()
if not root.is_dir():
_fail(f"bundle root is not a directory: {root}")
_require_path(root, "README.md")
manifest_path = _require_path(root, "manifest.json")
_require_path(root, "music", must_be_dir=True)
_require_path(root, "fonts", must_be_dir=True)
_require_path(root, "watermark", must_be_dir=True)
mp3_files = sorted(root.joinpath("music").rglob("*.mp3"))
if not mp3_files:
_fail("music/ must contain at least one .mp3 file")
mp3_stats: list[tuple[Path, int]] = []
for mp3_path in mp3_files:
size = mp3_path.stat().st_size
if _is_lfs_pointer(mp3_path):
rel = mp3_path.relative_to(root)
_fail(f"{rel} is a Git LFS pointer, not a real mp3 file")
if size <= MIN_MP3_BYTES:
rel = mp3_path.relative_to(root)
_fail(f"{rel} is too small ({size} bytes); expected > {MIN_MP3_BYTES} bytes")
mp3_stats.append((mp3_path.relative_to(root), size))
_validate_manifest(manifest_path)
return mp3_stats
def main(argv: list[str] | None = None) -> int:
args = argv if argv is not None else sys.argv[1:]
if len(args) != 1:
print("Usage: python scripts/validate_bundle.py <bundle-root>", file=sys.stderr)
return 1
root = Path(args[0])
mp3_stats = validate_bundle(root)
print("OK: media-assets bundle validation passed")
print(f"mp3 count: {len(mp3_stats)}")
for rel_path, size in mp3_stats:
print(f" - {rel_path.as_posix()}: {size} bytes")
return 0
if __name__ == "__main__":
raise SystemExit(main())