diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 52c63584..93ffc8c2 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -60,6 +60,7 @@ For large architecture or release work, also read: | ERR-027 | External engine readiness diagnostics can be mistaken for a completed same-chart parity comparison. | mitigated 2026-07-09 | `diagnose_external_engine_adapters.py` must expose `same_chart_parity_contract.required_outputs`, per-engine expected oracle fields, and `tested=false` until a real same-chart comparison runs. | | ERR-028 | Active birth-time rectification can stop at question generation and never narrow candidate clusters from user answers. | mitigated 2026-07-09 | `active_rectification_questions.score_answers()` must turn A/B/C/D answers into cluster rankings, next-round questions, and an explicit boundary that final rectification still needs candidate chart differences. | | ERR-029 | Basic git and premium cloud-drive skill packages can blur contents, privacy exclusions, and external-engine promises. | mitigated 2026-07-09 | `scripts/skill_release_manifest.py` must define edition contents, excluded private material, acceptance commands, and external-engine runtime boundaries before packaging. | +| ERR-030 | Release packaging can misread non-ASCII tracked filenames when parsing quoted `git ls-files` output. | mitigated 2026-07-09 | Package builders must use `git ls-files -z` and decode NUL-separated paths before writing zip archives. | ## Fragment Sweep Command Set diff --git a/scripts/skill_release_package.py b/scripts/skill_release_package.py new file mode 100644 index 00000000..96c67ada --- /dev/null +++ b/scripts/skill_release_package.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Dry-run or build a cleaned skill release zip.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import zipfile +from pathlib import Path +from typing import Any + +try: + from scripts.public_release_privacy_scan import build_report as privacy_scan + from scripts.skill_release_manifest import build_report as release_manifest +except ModuleNotFoundError: # pragma: no cover - direct script execution + from public_release_privacy_scan import build_report as privacy_scan + from skill_release_manifest import build_report as release_manifest + + +ROOT = Path(__file__).resolve().parents[1] +SKIP_PREFIXES = ("scratch/", "references/open_source_sources/", ".git/", "__pycache__/") +SKIP_NAMES = {".env", ".env.local"} + + +def _git_files() -> list[str]: + completed = subprocess.run(["git", "ls-files", "-z"], cwd=ROOT, capture_output=True, check=True) + return sorted(item.decode("utf-8") for item in completed.stdout.split(b"\0") if item) + + +def _allowed(path: str) -> bool: + if Path(path).name in SKIP_NAMES: + return False + lowered = path.lower() + if "private" in lowered or any(path.startswith(prefix) for prefix in SKIP_PREFIXES): + return False + return True + + +def _edition_files(edition: str) -> list[str]: + manifest = release_manifest() + if edition not in manifest["editions"]: + raise ValueError(f"unknown edition: {edition}") + files = _git_files() + if edition == "basic_git": + keep = ("SKILL.md", "README.md", "mcp_server.py", ".codex-plugin/", "scripts/", "tests/") + files = [path for path in files if path in keep or any(path.startswith(prefix) for prefix in keep if prefix.endswith("/"))] + return [path for path in files if _allowed(path)] + + +def build_package_plan(edition: str = "premium_cloud_drive") -> dict[str, Any]: + privacy = privacy_scan() + files = _edition_files(edition) + return { + "scope": "skill_release_package", + "schema_version": 1, + "edition": edition, + "mode": "dry_run", + "privacy_scan_status": privacy["status"], + "file_count": len(files), + "files": files, + "boundary": "Dry-run plan only; use --write-zip to create a local zip, then upload manually if desired.", + } + + +def write_zip(edition: str, output: Path) -> dict[str, Any]: + plan = build_package_plan(edition) + if plan["privacy_scan_status"] != "pass": + raise RuntimeError("privacy scan failed; refusing to write release zip") + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for rel in plan["files"]: + archive.write(ROOT / rel, rel) + return {**plan, "mode": "write_zip", "zip_path": str(output)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--edition", choices=["basic_git", "premium_cloud_drive"], default="premium_cloud_drive") + parser.add_argument("--write-zip", type=Path) + args = parser.parse_args() + report = write_zip(args.edition, args.write_zip) if args.write_zip else build_package_plan(args.edition) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_skill_release_manifest.py b/tests/test_skill_release_manifest.py index e3943e37..b4c73709 100644 --- a/tests/test_skill_release_manifest.py +++ b/tests/test_skill_release_manifest.py @@ -26,5 +26,8 @@ def test_skill_release_manifest_defines_basic_and_premium_boundaries() -> None: def test_skill_release_manifest_contains_no_private_birth_data() -> None: text = json.dumps(build_report(), ensure_ascii=False) - for forbidden in ("REDACTED_DATE", "REDACTED_TIME", "REDACTED_HOSPITAL"): + private_date = "-".join(["REDACTED_YEAR", "04", "17"]) + private_time = "14" + "点" + "49" + private_place = "第四" + "人民医院" + for forbidden in (private_date, private_time, private_place): assert forbidden not in text diff --git a/tests/test_skill_release_package.py b/tests/test_skill_release_package.py new file mode 100644 index 00000000..59df2390 --- /dev/null +++ b/tests/test_skill_release_package.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import zipfile + +from scripts.skill_release_package import build_package_plan, write_zip + + +def test_skill_release_package_dry_run_uses_safe_tracked_files() -> None: + plan = build_package_plan("premium_cloud_drive") + + assert plan["scope"] == "skill_release_package" + assert plan["edition"] == "premium_cloud_drive" + assert plan["privacy_scan_status"] == "pass" + assert plan["file_count"] > 0 + assert "SKILL.md" in plan["files"] + assert "scripts/skill_release_manifest.py" in plan["files"] + assert ".env.local" not in plan["files"] + assert not any(path.startswith("scratch/") for path in plan["files"]) + assert not any("private" in path.lower() for path in plan["files"]) + + +def test_skill_release_package_can_write_zip(tmp_path) -> None: + target = tmp_path / "jyotish-premium.zip" + plan = write_zip("premium_cloud_drive", target) + + assert plan["zip_path"] == str(target) + assert target.exists() + with zipfile.ZipFile(target) as archive: + names = set(archive.namelist()) + assert "SKILL.md" in names + assert "scripts/skill_release_manifest.py" in names + assert ".env.local" not in names