From ac1387debc30fb527ee5c07ef6b193e20bd12ce6 Mon Sep 17 00:00:00 2001 From: lipan Date: Thu, 13 Aug 2026 18:17:26 +0800 Subject: [PATCH] chore: add reproducible source packaging skill --- .agents/skills/package-source-sha1/SKILL.md | 39 ++++ .../package-source-sha1/agents/openai.yaml | 4 + .../scripts/package_source.py | 176 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 .agents/skills/package-source-sha1/SKILL.md create mode 100644 .agents/skills/package-source-sha1/agents/openai.yaml create mode 100644 .agents/skills/package-source-sha1/scripts/package_source.py diff --git a/.agents/skills/package-source-sha1/SKILL.md b/.agents/skills/package-source-sha1/SKILL.md new file mode 100644 index 0000000..9cc783d --- /dev/null +++ b/.agents/skills/package-source-sha1/SKILL.md @@ -0,0 +1,39 @@ +--- +name: package-source-sha1 +description: 为 massive-cloud-v3 生成可复现的纯源码 ZIP 包、内容清单和 40 位 SHA-1。用户要求打包整个项目、交付源码包、排除点云/CAD/数据库/构建产物/密钥,或计算项目 ZIP 哈希时使用。 +--- + +# 打包源码并计算 SHA-1 + +使用 `scripts/package_source.py`,不要临时拼装 `find`、`tar` 或 `zip` 命令。 + +## 工作流 + +1. 根据项目 `AGENTS.md` 查看开发服务器 `/home/lipan/massive-cloud-v3` 的实时工作树和 `git status`。 +2. 先运行 `--dry-run` 检查纳入、排除数量和排除原因;发现异常大文件或新的数据格式时,先更新脚本规则。 +3. 运行正式打包。脚本只从 `git ls-files --cached --others --exclude-standard` 取候选文件,因此包含已跟踪文件和未忽略的新文件,也包含尚未提交的实际代码改动。 +4. 将 ZIP、`.sha1` 和 `.manifest.json` 一起交付。明确说明包是否包含未提交改动,并报告基线提交。 +5. 用 `unzip -t` 验证 ZIP;再独立运行 `sha1sum` 或 `shasum -a 1`,确认与 `.sha1` 一致且为 40 位小写十六进制。 + +## 固定规则 + +- 保留源码、测试、SQL、配置模板、依赖清单/锁文件、容器配置、脚本和文档。 +- 排除 Git 历史、被 `.gitignore` 忽略的内容、真实 `.env`、密钥、日志、缓存、依赖目录和构建产物。 +- 排除点云、CAD/网格、数据库、表格结果、归档包、模型权重等非源码文件。 +- 不要使用 `git archive`:它会遗漏工作区中尚未提交的实际代码。 +- ZIP 使用固定时间戳、固定顺序和规范权限;相同文件内容应生成相同哈希。 + +## 命令 + +```bash +python3 scripts/package_source.py \ + --source /home/lipan/massive-cloud-v3 \ + --output /tmp/massive-cloud-v3-source.zip \ + --dry-run + +python3 scripts/package_source.py \ + --source /home/lipan/massive-cloud-v3 \ + --output /tmp/massive-cloud-v3-source.zip +``` + +不要把产物写进被打包的源码目录;优先使用 `/tmp`,再复制到交付位置。 diff --git a/.agents/skills/package-source-sha1/agents/openai.yaml b/.agents/skills/package-source-sha1/agents/openai.yaml new file mode 100644 index 0000000..5ba9229 --- /dev/null +++ b/.agents/skills/package-source-sha1/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "源码包 SHA-1" + short_description: "可复现地打包项目纯源码并自动生成 SHA-1 校验值" + default_prompt: "使用 $package-source-sha1 打包当前项目纯源码并返回 ZIP 的 SHA-1。" diff --git a/.agents/skills/package-source-sha1/scripts/package_source.py b/.agents/skills/package-source-sha1/scripts/package_source.py new file mode 100644 index 0000000..5a61bbd --- /dev/null +++ b/.agents/skills/package-source-sha1/scripts/package_source.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Create a reproducible source-only ZIP and its SHA-1/manifest files.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import stat +import subprocess +import sys +import zipfile +from collections import Counter +from pathlib import Path, PurePosixPath + + +EXCLUDED_SUFFIXES = { + # Point clouds, CAD and meshes + ".ply", ".pcd", ".las", ".laz", ".e57", ".xyz", ".pts", + ".stp", ".step", ".iges", ".igs", ".obj", ".stl", ".glb", ".gltf", + # Databases and generated tabular results + ".db", ".sqlite", ".sqlite3", ".mdb", ".accdb", ".xlsx", ".xls", + # Logs, caches, backups and generated archives + ".log", ".pyc", ".pyo", ".bak", ".tmp", ".zip", ".7z", ".rar", + ".tar", ".gz", ".bz2", ".xz", + # Model weights / binary artifacts + ".pt", ".pth", ".onnx", ".ckpt", ".safetensors", ".bin", + # Private-key/container formats + ".pem", ".key", ".p12", ".pfx", ".jks", +} + +EXCLUDED_DIRS = { + ".git", ".manus-logs", ".pnpm-store", "node_modules", "dist", "build", + "coverage", "results", "result", "outputs", "output", "__pycache__", + ".pytest_cache", ".mypy_cache", ".ruff_cache", ".cache", "data", "datasets", +} + +EXCLUDED_NAMES = {".env", "credentials.json", "secrets.json", "=1.10", "=3.1"} +FIXED_ZIP_TIME = (1980, 1, 1, 0, 0, 0) +ZIP_ROOT = "massive-cloud-v3-source" + + +def git_output(root: Path, *args: str) -> bytes: + result = subprocess.run( + ["git", "-C", str(root), *args], check=True, stdout=subprocess.PIPE + ) + return result.stdout + + +def candidates(root: Path) -> list[str]: + raw = git_output(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z") + return sorted(os.fsdecode(item) for item in raw.split(b"\0") if item) + + +def exclusion_reason(relative: str) -> str | None: + path = PurePosixPath(relative) + lowered_parts = tuple(part.lower() for part in path.parts) + name = path.name.lower() + + if any(part in EXCLUDED_DIRS for part in lowered_parts[:-1]): + return "excluded-directory" + if name in EXCLUDED_NAMES: + return "secret-or-artifact-name" + if name.startswith(".env.") and name not in {".env.example", ".env.template", ".env.sample"}: + return "environment-secret" + if path.suffix.lower() in EXCLUDED_SUFFIXES: + return "non-source-suffix" + return None + + +def sha1_file(path: Path) -> str: + digest = hashlib.sha1() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def git_text(root: Path, *args: str) -> str: + return git_output(root, *args).decode("utf-8", "replace").strip() + + +def build_manifest(root: Path, included: list[str], excluded: list[dict[str, str]]) -> dict: + return { + "format": "massive-cloud-source-package-v1", + "source_root": str(root), + "git_branch": git_text(root, "branch", "--show-current"), + "git_commit": git_text(root, "rev-parse", "HEAD"), + "git_status": git_text(root, "status", "--short"), + "included_count": len(included), + "included_files": included, + "excluded_count": len(excluded), + "excluded_files": excluded, + "rules": { + "candidate_source": "git ls-files --cached --others --exclude-standard", + "excluded_directories": sorted(EXCLUDED_DIRS), + "excluded_names": sorted(EXCLUDED_NAMES), + "excluded_suffixes": sorted(EXCLUDED_SUFFIXES), + }, + } + + +def write_zip(root: Path, output: Path, included: list[str]) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for relative in included: + source = root / relative + if not source.is_file() or source.is_symlink(): + continue + info = zipfile.ZipInfo(f"{ZIP_ROOT}/{PurePosixPath(relative)}", FIXED_ZIP_TIME) + mode = source.stat().st_mode + normalized_mode = 0o755 if mode & stat.S_IXUSR else 0o644 + info.create_system = 3 + info.external_attr = (stat.S_IFREG | normalized_mode) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + with source.open("rb") as handle: + archive.writestr(info, handle.read(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True, help="Git working tree") + parser.add_argument("--output", type=Path, required=True, help="Output .zip path") + parser.add_argument("--dry-run", action="store_true", help="Print selection summary only") + args = parser.parse_args() + + root = args.source.resolve() + output = args.output.resolve() + if output.suffix.lower() != ".zip": + parser.error("--output must end with .zip") + if not (root / ".git").exists(): + parser.error(f"not a Git working tree: {root}") + try: + output.relative_to(root) + except ValueError: + pass + else: + parser.error("write the package outside the source tree") + + selected: list[str] = [] + excluded: list[dict[str, str]] = [] + for relative in candidates(root): + reason = exclusion_reason(relative) + if reason: + excluded.append({"path": relative, "reason": reason}) + elif (root / relative).is_file() and not (root / relative).is_symlink(): + selected.append(relative) + + reasons = Counter(item["reason"] for item in excluded) + print(f"source: {root}") + print(f"included: {len(selected)} files") + print(f"excluded: {len(excluded)} files ({dict(sorted(reasons.items()))})") + print(f"git commit: {git_text(root, 'rev-parse', 'HEAD')}") + status = git_text(root, "status", "--short") + print("working tree: dirty" if status else "working tree: clean") + if args.dry_run: + return 0 + + manifest = build_manifest(root, selected, excluded) + write_zip(root, output, selected) + digest = sha1_file(output) + manifest.update({"zip_file": output.name, "zip_size": output.stat().st_size, "sha1": digest}) + + sha_path = output.with_suffix(output.suffix + ".sha1") + manifest_path = output.with_suffix(output.suffix + ".manifest.json") + sha_path.write_text(f"{digest} {output.name}\n", encoding="ascii") + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"zip: {output}") + print(f"sha1: {digest}") + print(f"manifest: {manifest_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())