From 97f5b6ec6e186e390068f655daf1a516529457ca Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 6 Jun 2026 14:40:02 +0800 Subject: [PATCH] chore: publish stable media assets bundle Add CI validation, zip packaging, and vlatest release publishing for user-facing media-assets.zip downloads. Co-authored-by: Cursor --- .gitea/workflows/release.yml | 53 ++++++ README.md | 34 +++- scripts/publish_gitea_release_asset.py | 241 +++++++++++++++++++++++++ scripts/validate_bundle.py | 104 +++++++++++ 4 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 scripts/publish_gitea_release_asset.py create mode 100644 scripts/validate_bundle.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..808b525 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,53 @@ +name: release + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + + - name: Pull Git LFS + run: | + git lfs install + git lfs pull + + - name: Validate source assets + run: python3 scripts/validate_bundle.py . + + - name: Build stable zip + run: | + rm -rf dist + mkdir -p dist/media-assets + cp README.md manifest.json dist/media-assets/ + cp -r music fonts watermark dist/media-assets/ + cd dist + zip -r media-assets.zip media-assets + + - name: Validate packaged zip content + run: | + rm -rf dist/verify + mkdir -p dist/verify + unzip -q dist/media-assets.zip -d dist/verify + python3 scripts/validate_bundle.py dist/verify/media-assets + + - name: Publish stable release asset + env: + GITEA_SERVER_URL: https://git.jc2009.com + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_REPOSITORY: client-commons/media-assets + GITEA_SHA: ${{ github.sha }} + run: | + python3 scripts/publish_gitea_release_asset.py \ + --tag vlatest \ + --title "media-assets latest" \ + --file dist/media-assets.zip \ + --asset-name media-assets.zip diff --git a/README.md b/README.md index 32b7295..ec24d4a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,28 @@ 匠厂(深圳匠厂科技)跨项目共享媒体资产库。 -本仓库只维护**轻量媒体资源**与**工具下载 manifest**,供各技能(skill)的录制脚本使用。 +本仓库是 **media-assets 源仓库**,只维护轻量媒体资源与工具下载 manifest,供各技能(skill)的录制脚本使用。 + +## 用户下载方式 + +**不要下载 Gitea 源码 ZIP。** 源码 ZIP 中的 Git LFS 文件可能是 pointer,无法直接作为背景音乐或字体使用。 + +用户端应下载 CI 生成的稳定包: + +https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip + +### 稳定包内容 + +`media-assets.zip` 包含: + +- `music/` +- `fonts/` +- `watermark/` +- `manifest.json` + +`media-assets.zip` **不包含** `ffmpeg.exe` / `ffprobe.exe` 等二进制工具。 + +ffmpeg 由 `jiangchang-platform-kit` 按 `manifest.json` 里的 `tools.ffmpeg` 下载源另行下载、解压并缓存到用户数据目录。 ## 仓库内容 @@ -44,13 +65,13 @@ D:\jiangchang-data\shared\media-assets\bin\win32-x64\ 说明: - 用户**不需要**手工安装 ffmpeg,也**不需要**依赖系统 PATH。 -- 用户电脑**没有 Git** 也可以工作:kit 会通过 HTTP ZIP 下载 media-assets 与 ffmpeg essentials 包。 +- 用户电脑**没有 Git** 也可以工作:kit 会通过 HTTP 下载稳定 `media-assets.zip` 与 ffmpeg essentials 包。 - Windows x64 默认使用 **ffmpeg-release-essentials**(非 full build)。 - `manifest.json` 中配置了国内镜像与 gyan.dev 官方源;kit 会按顺序尝试。 ## 背景音乐 -背景音乐仍从本仓库的 `music/` 目录读取。各技能可通过 `jiangchang_skill_core.media_assets` 解析路径并选取音频文件。 +背景音乐从稳定包或本仓库的 `music/` 目录读取。各技能可通过 `jiangchang_skill_core.media_assets` 解析路径并选取音频文件。 ## 使用方式 @@ -62,7 +83,12 @@ MEDIA_ASSETS_ROOT = os.environ.get("MEDIA_ASSETS_ROOT", r"D:\OpenClaw\client-com 未设置环境变量时,kit 默认使用 `{JIANGCHANG_DATA_ROOT}/shared/media-assets`(例如 `D:\jiangchang-data\shared\media-assets`)。 +## 开发者说明 + +- MP3、字体、图片等媒体文件通过 **Git LFS** 管理,clone 后请执行 `git lfs pull`。 +- 更新音乐后,推送 `main` 前请确认本地 mp3 不是 LFS pointer(可用 `python scripts/validate_bundle.py .` 校验)。 +- `main` 分支 push 后,CI 会覆盖 `vlatest` release 下的 `media-assets.zip`,永远只保留一个稳定包。 + ## 注意 -- MP3、字体、图片等媒体文件通过 **Git LFS** 管理,clone 前请确认已安装 `git lfs`。 - 请勿将版权不明的音乐文件上传至本库。 diff --git a/scripts/publish_gitea_release_asset.py b/scripts/publish_gitea_release_asset.py new file mode 100644 index 0000000..737781c --- /dev/null +++ b/scripts/publish_gitea_release_asset.py @@ -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()) diff --git a/scripts/validate_bundle.py b/scripts/validate_bundle.py new file mode 100644 index 0000000..706873b --- /dev/null +++ b/scripts/validate_bundle.py @@ -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 ", 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())