fix(release): pin staging runtime trees

This commit is contained in:
lili 2026-07-23 00:48:19 -07:00
parent 68f8b4fa86
commit ebd74a87ee
5 changed files with 193 additions and 1 deletions

View File

@ -337,6 +337,7 @@ valid_prepared_release() {
[ -d "$target/game-studio/dist" ] || return 1
[ -d "$target/game-admin/dist-stage" ] || return 1
[ -x "$target/cheap-worker/.venv/bin/python" ] || return 1
[ -f "$target/evidence/runtime-tree.json" ] || return 1
if [ -n "$expected" ]; then
[ "$(git -C "$target" rev-parse HEAD)" = "$expected" ] || return 1
fi
@ -596,6 +597,10 @@ set +a
cd "$release"
sha256sum -c evidence/artifacts.sha256 >"$evidence/artifact-verification.txt"
python3 deploy/runtime-tree-manifest.py verify \
--manifest evidence/runtime-tree.json cheap-worker tier2/gen-worker \
>"$evidence/runtime-tree-verification.log"
chmod 600 "$evidence/runtime-tree-verification.log"
log_step "生成并校验 DB-only 存储审计"
storage_audit_output="$(RC_RELEASE="$release" EXPECTED_STORAGE_AUDIT_REF="$expected_storage_audit_ref" \

View File

@ -206,7 +206,9 @@ release_tool_tests=(
python3 -m unittest deploy/tests/test-dogfood-reviewer-security.py
printf 'RUN deploy/dogfood-ops/tests/test_dogfood_ops.py\n'
python3 -m unittest deploy/dogfood-ops/tests/test_dogfood_ops.py
printf 'RELEASE_TOOL_TESTS_PASS shell=%s python=3\n' "${#release_tool_tests[@]}"
printf 'RUN deploy/tests/test-runtime-tree-manifest.py\n'
python3 -m unittest deploy/tests/test-runtime-tree-manifest.py
printf 'RELEASE_TOOL_TESTS_PASS shell=%s python=4\n' "${#release_tool_tests[@]}"
) >"$evidence/release-tool-tests.log" 2>&1
printf '[7/7] 记录制品与启动坐标\n'
@ -221,9 +223,15 @@ printf '[7/7] 记录制品与启动坐标\n'
printf 'tier2_entry=%s/tier2/gen-worker/service/app.py\n' "$release"
} >"$evidence/artifact-paths.env"
python3 deploy/runtime-tree-manifest.py capture \
--output "$evidence/runtime-tree.json" cheap-worker tier2/gen-worker \
>"$evidence/runtime-tree-capture.log"
chmod 600 "$evidence/runtime-tree.json" "$evidence/runtime-tree-capture.log"
{
sha256sum game-cloud/huijing-server/target/huijing-server.jar
find game-studio/dist game-admin/dist-stage -type f -print0 | sort -z | xargs -0 sha256sum
sha256sum "$evidence/runtime-tree.json"
} >"$evidence/artifacts.sha256"
{
@ -244,6 +252,7 @@ printf '[7/7] 记录制品与启动坐标\n'
printf 'frontend_secret_scan=PASS\n'
printf 'frontend_secret_scan_patterns_sha256=%s\n' "$frontend_forbidden_patterns_sha256"
printf 'frontend_secret_scan_sha256=%s\n' "$(sha256sum "$evidence/frontend-secret-scan.log" | awk '{print $1}')"
printf 'runtime_tree_manifest_sha256=%s\n' "$(sha256sum "$evidence/runtime-tree.json" | awk '{print $1}')"
} >"$evidence/verification-summary.txt"
[ -z "$(git status --porcelain --untracked-files=no)" ] || { printf '构建修改了 tracked 文件,拒绝将其标为 ready。\n' >&2; exit 6; }

View File

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""签发或校验 staging RC 的 Python 运行树字节清单。"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import stat
from pathlib import Path
IGNORED_PARTS = frozenset({"__pycache__", ".pytest_cache"})
def sha256_file(path: Path) -> str:
"""流式计算文件摘要,避免大依赖文件一次性进入内存。"""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def should_ignore(path: Path) -> bool:
"""忽略 Python 运行时可再生缓存,其余新增文件一律计入漂移。"""
return path.suffix == ".pyc" or any(part in IGNORED_PARTS for part in path.parts)
def capture(roots: list[str]) -> dict:
"""采集普通文件与符号链接;符号链接同时绑定文本目标和解析后文件摘要。"""
entries: list[dict] = []
cwd = Path.cwd().resolve()
normalized_roots: list[str] = []
for raw_root in roots:
root = Path(raw_root)
if root.is_absolute() or ".." in root.parts or not root.is_dir() or root.is_symlink():
raise ValueError(f"运行树根必须是仓内非符号链接目录: {raw_root}")
normalized_roots.append(root.as_posix())
for path in root.rglob("*"):
relative = path.relative_to(cwd) if path.is_absolute() else path
if should_ignore(relative):
continue
metadata = path.lstat()
mode = stat.S_IMODE(metadata.st_mode)
if path.is_symlink():
target = os.readlink(path)
resolved = path.resolve(strict=False)
entries.append({
"path": relative.as_posix(),
"type": "symlink",
"mode": mode,
"target": target,
"resolvedSha256": sha256_file(resolved) if resolved.is_file() else None,
})
elif path.is_file():
entries.append({
"path": relative.as_posix(),
"type": "file",
"mode": mode,
"sha256": sha256_file(path),
"size": metadata.st_size,
})
entries.sort(key=lambda item: item["path"])
return {"schema": "staging-runtime-tree/1", "roots": normalized_roots, "entries": entries}
def write_manifest(output: Path, roots: list[str]) -> None:
"""以 0600 原子替换清单,避免半写文件被签入 RC。"""
payload = capture(roots)
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_name(output.name + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(output)
def verify_manifest(manifest: Path, roots: list[str]) -> None:
"""逐结构比较当前树,新增、删除、权限、字节或链接目标漂移均拒绝。"""
expected = json.loads(manifest.read_text(encoding="utf-8"))
actual = capture(roots)
if expected != actual:
raise RuntimeError("Python 运行树与 RC 清单不一致")
def main() -> int:
parser = argparse.ArgumentParser(description="staging RC Python 运行树完整性清单")
subparsers = parser.add_subparsers(dest="command", required=True)
capture_parser = subparsers.add_parser("capture")
capture_parser.add_argument("--output", required=True, type=Path)
capture_parser.add_argument("roots", nargs="+")
verify_parser = subparsers.add_parser("verify")
verify_parser.add_argument("--manifest", required=True, type=Path)
verify_parser.add_argument("roots", nargs="+")
args = parser.parse_args()
if args.command == "capture":
write_manifest(args.output, args.roots)
print(f"RUNTIME_TREE_CAPTURE_PASS manifest={args.output}")
else:
verify_manifest(args.manifest, args.roots)
print(f"RUNTIME_TREE_VERIFY_PASS manifest={args.manifest}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""staging RC Python 运行树清单回归测试。"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "runtime-tree-manifest.py"
class RuntimeTreeManifestTest(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.runtime = self.root / "runtime"
self.runtime.mkdir()
(self.runtime / "app.py").write_text("print('ok')\n", encoding="utf-8")
(self.runtime / "python-link").symlink_to("app.py")
self.manifest = self.root / "runtime-tree.json"
def tearDown(self) -> None:
self.temporary.cleanup()
def run_tool(self, command: str, *, expect_success: bool = True) -> subprocess.CompletedProcess:
result = subprocess.run(
[sys.executable, str(SCRIPT), command,
"--output" if command == "capture" else "--manifest", str(self.manifest), "runtime"],
cwd=self.root,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode == 0, expect_success, result.stderr)
return result
def test_capture_and_verify_identical_tree(self) -> None:
self.run_tool("capture")
self.run_tool("verify")
payload = json.loads(self.manifest.read_text(encoding="utf-8"))
self.assertEqual(payload["schema"], "staging-runtime-tree/1")
self.assertEqual(len(payload["entries"]), 2)
def test_rejects_file_content_drift(self) -> None:
self.run_tool("capture")
(self.runtime / "app.py").write_text("print('changed')\n", encoding="utf-8")
self.run_tool("verify", expect_success=False)
def test_rejects_added_file(self) -> None:
self.run_tool("capture")
(self.runtime / "injected.py").write_text("raise SystemExit\n", encoding="utf-8")
self.run_tool("verify", expect_success=False)
def test_rejects_symlink_target_drift(self) -> None:
self.run_tool("capture")
(self.runtime / "other.py").write_text("print('other')\n", encoding="utf-8")
(self.runtime / "python-link").unlink()
(self.runtime / "python-link").symlink_to("other.py")
self.run_tool("verify", expect_success=False)
if __name__ == "__main__":
unittest.main()

View File

@ -243,6 +243,9 @@ rg -Fq "frontend_forbidden_patterns='admin123|test1|mock-token|mock-studio-token
"$PREPARE" || fail "prepare 前端扫描模式未完整覆盖 test1、mock-studio-token 与既有模式"
rg -q "printf 'patterns=%s" "$PREPARE" || fail "prepare 扫描证据未记录实际模式集"
rg -q 'frontend_secret_scan_patterns_sha256=' "$PREPARE" || fail "prepare 摘要未绑定扫描模式集证据"
rg -q 'runtime-tree-manifest\.py capture' "$PREPARE" || fail "prepare 未签发 Python 运行树清单"
rg -q 'runtime_tree_manifest_sha256=' "$PREPARE" || fail "prepare 摘要未绑定 Python 运行树清单"
rg -q 'runtime-tree-manifest\.py verify' "$ACTIVATE" || fail "activate 未校验 Python 运行树漂移"
frontend_forbidden_patterns="$(sed -n "s/^frontend_forbidden_patterns='\\(.*\\)'$/\\1/p" "$PREPARE")"
[ -n "$frontend_forbidden_patterns" ] || fail "无法从 prepare 解析前端禁止模式集"
for forbidden_sample in admin123 test1 mock-token mock-studio-token bootstrap-admin.token DOGFOOD_PASSWORD_SEED; do