build.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. #!/usr/bin/env python3
  2. """构建版本化 Linux 部署包;默认打包当前工作区并运行离线检查。"""
  3. from __future__ import annotations
  4. import argparse
  5. from datetime import datetime, timezone
  6. import gzip
  7. from importlib import metadata
  8. import json
  9. import os
  10. from pathlib import Path
  11. import shutil
  12. import subprocess
  13. import sys
  14. import tarfile
  15. import tempfile
  16. sys.dont_write_bytecode = True
  17. from common import sha256, validate_package, validation_env, version
  18. ROOT = Path(__file__).resolve().parents[1]
  19. ROOT_FILES = {"main.py", "config.py", "README.md", "docs/DEVELOPMENT.md", "docs/BUSINESS_AGENTS.md", "docs/MAIN_FAN.md", "docs/TEST_LAB.md", "requirements.txt", "pytest.ini", "VERSION", ".env.example"}
  20. TREE_SUFFIXES = {
  21. "algorithms": {".py", ".md", ".json", ".txt"},
  22. "runtime": {".py"},
  23. "extensions": {".py", ".md", ".txt", ".json", ".yaml", ".yml"},
  24. "agents": {".py"}, "api": {".py"}, "billing": {".py"}, "db": {".py", ".sql"},
  25. "obs": {".py"}, "scheduler": {".py"}, "tools": {".py"},
  26. "skills": {".md", ".txt", ".json", ".yaml", ".yml"},
  27. "static": {".html", ".css", ".js", ".md", ".LICENSE", ".svg", ".png", ".ico", ".woff", ".woff2"},
  28. "tests": {".py", ".html"},
  29. "mock_gateway": {".py"},
  30. "mock_result_platform": {".py"},
  31. "examples": {".json", ".md"},
  32. }
  33. RELEASE_FILES = {"build.py", "common.py", "deploy.py", "verify.py", "install.sh", "service.sh",
  34. "lock.py", "requirements.lock", "lock.meta.json", "baseline.constraints", "README.md",
  35. "vent-agent.service.example"}
  36. EXCLUDED_PARTS = {"__pycache__", "node_modules", "data", "workspace", "logs", "dist", "venv"}
  37. def source_files(root: Path) -> list[Path]:
  38. """白名单收集源文件;运行目录、隐藏文件和符号链接不会被打包。"""
  39. selected = [root / name for name in sorted(ROOT_FILES)]
  40. selected += [root / "release" / name for name in sorted(RELEASE_FILES)]
  41. selected += [root / "scripts" / name for name in ("selfcheck.py", "demo_main_fan.py", "mock_shaanxi_gateway.py", "mock_result_platform.py", "import_main_fan_facts.py", "run_main_fan_document.py", "refresh_main_fan_mines.py", "run_main_fan_batch.py", "mock_main_fan_reports.py", "verify_main_fan_batch.py", "compact_main_fan_history.py")]
  42. for directory, suffixes in TREE_SUFFIXES.items():
  43. base = root / directory
  44. if base.is_symlink():
  45. raise ValueError(f"不接受源目录符号链接:{directory}")
  46. for current, dirs, names in os.walk(base, followlinks=False):
  47. dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d not in EXCLUDED_PARTS)
  48. for directory_name in dirs:
  49. if (Path(current) / directory_name).is_symlink():
  50. raise ValueError(f"不接受源目录符号链接:{Path(current) / directory_name}")
  51. for name in sorted(names):
  52. path = Path(current) / name
  53. if not name.startswith(".") and path.suffix in suffixes:
  54. selected.append(path)
  55. for path in selected:
  56. if not path.is_file() or any(p.is_symlink() for p in (path, *path.parents) if p != root and root in p.parents):
  57. raise ValueError(f"源文件缺失或是符号链接:{path.relative_to(root)}")
  58. return sorted(selected, key=lambda p: p.relative_to(root).as_posix())
  59. def git_info(root: Path) -> dict:
  60. def git(*args):
  61. try:
  62. result = subprocess.run(["git", "-C", str(root), *args], text=True, capture_output=True)
  63. except FileNotFoundError:
  64. return None
  65. return result.stdout.strip() if result.returncode == 0 else None
  66. status = git("status", "--porcelain")
  67. return {"commit": git("rev-parse", "HEAD"), "dirty": None if status is None else bool(status)}
  68. def verify_lock(root: Path, check_environment: bool) -> None:
  69. meta = json.loads((root / "release/lock.meta.json").read_text())
  70. for name in ("requirements.txt", "release/baseline.constraints", "release/requirements.lock"):
  71. if meta["sha256"].get(name) != sha256(root / name):
  72. raise ValueError(f"依赖输入或锁文件已变化,请先运行 release/lock.py:{name}")
  73. if not check_environment:
  74. return
  75. from packaging.requirements import Requirement
  76. # uv 的 requirements 输出:每项以 name==version 开始,后接哈希续行。
  77. for line in (root / "release/requirements.lock").read_text().splitlines():
  78. if not line or line[0].isspace() or line.startswith("#"):
  79. continue
  80. req = Requirement(line.removesuffix("\\").strip())
  81. if req.marker and not req.marker.evaluate():
  82. continue
  83. if metadata.version(req.name) not in req.specifier:
  84. raise ValueError(f"构建环境与锁文件不一致:{req.name};请安装 release/requirements.lock")
  85. def build(root: Path, output: Path, release_version: str, *, checks: bool = True) -> Path:
  86. if sys.version_info[:2] != (3, 12):
  87. raise ValueError("本发布流程使用 Python 3.12")
  88. release_version = version(release_version)
  89. verify_lock(root, checks)
  90. output.mkdir(parents=True, exist_ok=True)
  91. name = f"vent-agent-{release_version}-linux"
  92. archive = output / f"{name}.tar.gz"
  93. checksum = output / f"{name}.tar.gz.sha256"
  94. if archive.exists() or checksum.exists():
  95. raise ValueError(f"版本产物已存在,不覆盖:{archive}")
  96. with tempfile.TemporaryDirectory(prefix="vent-release-") as temporary:
  97. temp = Path(temporary)
  98. staged = temp / name
  99. staged.mkdir()
  100. sources = source_files(root)
  101. for source in sources:
  102. target = staged / source.relative_to(root)
  103. target.parent.mkdir(parents=True, exist_ok=True)
  104. shutil.copyfile(source, target)
  105. target.chmod(0o755 if target.suffix == ".sh" else 0o644)
  106. (staged / "VERSION").write_text(release_version + "\n")
  107. if checks:
  108. env = validation_env(temp)
  109. subprocess.run([sys.executable, "-m", "pytest", "-p", "no:cacheprovider"], cwd=staged, env=env, check=True)
  110. subprocess.run([sys.executable, "scripts/selfcheck.py"], cwd=staged, env=env, check=True)
  111. # 自检可能建出空运行目录。它们不会进入发布包。
  112. files = {}
  113. for source in sources:
  114. relative = source.relative_to(root).as_posix()
  115. path = staged / relative
  116. files[relative] = {"sha256": sha256(path), "size": path.stat().st_size}
  117. manifest = {"format": 1, "version": release_version, "python": "3.12", "target": "linux",
  118. "built_at": datetime.now(timezone.utc).isoformat(), "source": git_info(root),
  119. "checks": "passed" if checks else "skipped", "files": files}
  120. (staged / "release-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
  121. temporary_archive = temp / archive.name
  122. with temporary_archive.open("wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz:
  123. with tarfile.open(fileobj=gz, mode="w") as tar:
  124. for relative in sorted([*files, "release-manifest.json"]):
  125. path = staged / relative
  126. info = tar.gettarinfo(str(path), arcname=f"{name}/{relative}")
  127. info.uid = info.gid = 0
  128. info.uname = info.gname = ""
  129. info.mtime = 0
  130. with path.open("rb") as content:
  131. tar.addfile(info, content)
  132. # 验证真正归档后的内容,避免只检查 staging 而遗漏打包错误。
  133. extracted = temp / "verify"
  134. with tarfile.open(temporary_archive) as tar:
  135. tar.extractall(extracted, filter="data")
  136. validate_package(extracted / name)
  137. # 使用排他创建,重复构建同一版本不会覆盖已有发布文件。
  138. with archive.open("xb") as target, temporary_archive.open("rb") as source:
  139. shutil.copyfileobj(source, target)
  140. with checksum.open("x") as target:
  141. target.write(f"{sha256(archive)} {archive.name}\n")
  142. return archive
  143. def main():
  144. parser = argparse.ArgumentParser(description=__doc__)
  145. parser.add_argument("--version", default=(ROOT / "VERSION").read_text().strip())
  146. parser.add_argument("--output", type=Path, default=ROOT / "release/dist")
  147. parser.add_argument("--skip-checks", action="store_true", help="仅调试:跳过检查,清单将标记 skipped")
  148. args = parser.parse_args()
  149. try:
  150. path = build(ROOT, args.output.resolve(), args.version, checks=not args.skip_checks)
  151. except (ValueError, subprocess.CalledProcessError, metadata.PackageNotFoundError) as exc:
  152. parser.exit(1, f"构建失败:{exc}\n")
  153. print(f"发布包:{path}\n校验文件:{path}.sha256")
  154. if __name__ == "__main__":
  155. main()