feat(v1): add demo runbook and release package
This commit is contained in:
+150
@@ -0,0 +1,150 @@
|
||||
"""Build and verify a credential-free Silver Pose V1 release directory."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable
|
||||
|
||||
|
||||
class ReleaseError(ValueError):
|
||||
"""Raised when a release package cannot be safely created or verified."""
|
||||
|
||||
|
||||
_EXCLUDED_NAMES = {"config.local.json", "artifacts", "__pycache__", ".pytest_cache", "models"}
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_release_package(
|
||||
project_root: Path, model_path: Path, expected_sha256: str, output_dir: Path
|
||||
) -> Dict:
|
||||
"""Copy the V1 runtime and a locked model without local customer artifacts."""
|
||||
|
||||
root = Path(project_root).resolve()
|
||||
source_model = Path(model_path).resolve()
|
||||
output = Path(output_dir).resolve()
|
||||
expected = _validate_model(source_model, expected_sha256)
|
||||
if output.exists():
|
||||
raise ReleaseError("output directory already exists: {0}".format(output))
|
||||
|
||||
output.mkdir(parents=True)
|
||||
_copy_runtime_tree(root / "v1", output / "v1")
|
||||
_copy_required_file(root / "README.md", output / "README.md")
|
||||
_copy_required_file(root / "init.ps1", output / "init.ps1")
|
||||
_copy_required_file(root / "docs" / "demo-runbook.md", output / "docs" / "demo-runbook.md")
|
||||
destination_model = output / "v1" / "models" / "best.pt"
|
||||
destination_model.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source_model), str(destination_model))
|
||||
_write_release_config(root / "v1" / "config.example.json", output / "v1", expected)
|
||||
manifest = _manifest(output, expected)
|
||||
(output / "release-manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def verify_release_package(package_dir: Path) -> Dict:
|
||||
package = Path(package_dir).resolve()
|
||||
manifest_path = package / "release-manifest.json"
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ReleaseError("cannot read release manifest: {0}".format(exc))
|
||||
for relative_path, expected in manifest.get("files", {}).items():
|
||||
actual_path = package / relative_path
|
||||
if not actual_path.is_file() or sha256_file(actual_path) != expected:
|
||||
raise ReleaseError("release file SHA-256 does not match: {0}".format(relative_path))
|
||||
model = package / "v1" / "models" / "best.pt"
|
||||
if not model.is_file() or sha256_file(model) != manifest.get("model_sha256"):
|
||||
raise ReleaseError("model SHA-256 does not match release manifest")
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_model(model_path: Path, expected_sha256: str) -> str:
|
||||
expected = str(expected_sha256).lower()
|
||||
if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected):
|
||||
raise ReleaseError("expected model SHA-256 must be 64 hexadecimal characters")
|
||||
if not model_path.is_file():
|
||||
raise ReleaseError("model file does not exist: {0}".format(model_path))
|
||||
if sha256_file(model_path) != expected:
|
||||
raise ReleaseError("model SHA-256 does not match expected value")
|
||||
return expected
|
||||
|
||||
|
||||
def _copy_runtime_tree(source: Path, destination: Path) -> None:
|
||||
if not source.is_dir():
|
||||
raise ReleaseError("V1 source directory does not exist: {0}".format(source))
|
||||
shutil.copytree(
|
||||
str(source),
|
||||
str(destination),
|
||||
ignore=shutil.ignore_patterns(*_EXCLUDED_NAMES),
|
||||
)
|
||||
|
||||
|
||||
def _copy_required_file(source: Path, destination: Path) -> None:
|
||||
if not source.is_file():
|
||||
raise ReleaseError("required release file does not exist: {0}".format(source))
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(source), str(destination))
|
||||
|
||||
|
||||
def _write_release_config(source: Path, destination_dir: Path, model_sha256: str) -> None:
|
||||
raw = json.loads(source.read_text(encoding="utf-8"))
|
||||
raw["model"]["sha256"] = model_sha256
|
||||
(destination_dir / "config.release.example.json").write_text(
|
||||
json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _manifest(package: Path, model_sha256: str) -> Dict:
|
||||
files = {}
|
||||
for path in _files(package):
|
||||
files[path.relative_to(package).as_posix()] = sha256_file(path)
|
||||
return {
|
||||
"format": "silver-pose-v1-release-1",
|
||||
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"model_sha256": model_sha256,
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _files(root: Path) -> Iterable[Path]:
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file() and path.name != "release-manifest.json":
|
||||
yield path
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Build or verify a Silver Pose V1 release")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
build = commands.add_parser("build")
|
||||
build.add_argument("--project-root", default=".")
|
||||
build.add_argument("--model-path", required=True)
|
||||
build.add_argument("--model-sha256", required=True)
|
||||
build.add_argument("--output", required=True)
|
||||
verify = commands.add_parser("verify")
|
||||
verify.add_argument("--package", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "build":
|
||||
manifest = build_release_package(
|
||||
Path(args.project_root), Path(args.model_path), args.model_sha256, Path(args.output)
|
||||
)
|
||||
print("release package built: {0}".format(args.output))
|
||||
print("model SHA-256: {0}".format(manifest["model_sha256"]))
|
||||
return 0
|
||||
verify_release_package(Path(args.package))
|
||||
print("release package verified: {0}".format(args.package))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,13 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ModelPath,
|
||||
[Parameter(Mandatory = $true)][string]$ModelSha256,
|
||||
[string]$Output = "dist/SilverPose-V1"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
Set-Location $projectRoot
|
||||
python -m v1.release build --project-root $projectRoot --model-path $ModelPath --model-sha256 $ModelSha256 --output $Output
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "V1 release package build failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
|
||||
from v1.release import build_release_package, main, sha256_file
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_build_release_copies_locked_model_and_excludes_local_credentials(tmp_path):
|
||||
model = tmp_path / "best.pt"
|
||||
model.write_bytes(b"locked-model")
|
||||
output = tmp_path / "SilverPose-V1"
|
||||
|
||||
manifest = build_release_package(ROOT, model, sha256_file(model), output)
|
||||
|
||||
assert (output / "v1" / "models" / "best.pt").read_bytes() == b"locked-model"
|
||||
assert manifest["model_sha256"] == sha256_file(model)
|
||||
assert not list(output.rglob("config.local.json"))
|
||||
assert not list(output.rglob("events.jsonl"))
|
||||
|
||||
|
||||
def test_demo_runbook_covers_preflight_evidence_and_scope():
|
||||
text = (ROOT / "docs" / "demo-runbook.md").read_text(encoding="utf-8")
|
||||
|
||||
for heading in ("启动前检查", "现场演示步骤", "证据复查", "适用边界"):
|
||||
assert heading in text
|
||||
assert "rtsp://" not in text
|
||||
|
||||
|
||||
def test_release_command_verifies_a_built_package(tmp_path, capsys):
|
||||
model = tmp_path / "best.pt"
|
||||
model.write_bytes(b"locked-model")
|
||||
output = tmp_path / "SilverPose-V1"
|
||||
build_release_package(ROOT, model, sha256_file(model), output)
|
||||
|
||||
assert main(["verify", "--package", str(output)]) == 0
|
||||
assert "release package verified" in capsys.readouterr().out
|
||||
Reference in New Issue
Block a user