feat(playbook): add install_mode deployment config

BREAKING CHANGE: [vendor] and playbook.deploy_root are no longer supported. Use [playbook].install_mode and playbook.playbook_root instead.
This commit is contained in:
csh
2026-06-01 10:49:16 +08:00
parent 2c3a559a30
commit f2ab57b39f
15 changed files with 376 additions and 184 deletions
+137 -43
View File
@@ -14,7 +14,6 @@ except ModuleNotFoundError: # Python < 3.11
tomllib = None
ORDER = [
"vendor",
"sync_rules",
"sync_memory_bank",
"sync_prompts",
@@ -29,7 +28,14 @@ MAIN_LOOP_SPEC = importlib.util.spec_from_file_location("playbook_main_loop", MA
assert MAIN_LOOP_SPEC and MAIN_LOOP_SPEC.loader
MAIN_LOOP = importlib.util.module_from_spec(MAIN_LOOP_SPEC)
MAIN_LOOP_SPEC.loader.exec_module(MAIN_LOOP)
PATH_CONFIG_KEYS = {"project_root", "deploy_root", "agents_home", "codex_home", "skill_link"}
PATH_CONFIG_KEYS = {
"project_root",
"playbook_root",
"deploy_root",
"agents_home",
"codex_home",
"skill_link",
}
DOCS_INDEX_SECTION_HEADINGS = {
"common": "## 跨语言(common",
"tsl": "## TSLtsl/tsf",
@@ -275,13 +281,13 @@ def normalize_relative_dir(raw: object, label: str) -> str:
return "." if normalized == "" else normalized
def join_deploy_subpath(root: str, child: str) -> str:
def join_playbook_subpath(root: str, child: str) -> str:
if root in ("", "."):
return child.lstrip("/")
return f"{root.rstrip('/')}/{child.lstrip('/')}"
def resolve_in_project_deploy_root(project_root: Path) -> str | None:
def resolve_in_project_playbook_root(project_root: Path) -> str | None:
try:
rel = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve())
if str(rel) != ".":
@@ -291,9 +297,8 @@ def resolve_in_project_deploy_root(project_root: Path) -> str | None:
return None
def config_requires_deploy_root(config: dict) -> bool:
def config_uses_playbook_root(config: dict) -> bool:
for key in (
"vendor",
"sync_rules",
"sync_memory_bank",
"sync_prompts",
@@ -305,46 +310,74 @@ def config_requires_deploy_root(config: dict) -> bool:
return False
def resolve_configured_deploy_root(config: dict, project_root: Path) -> str:
def validate_removed_config(config: dict) -> None:
if "vendor" in config:
raise ValueError(
"[vendor] is no longer supported; use [playbook].install_mode = "
'"snapshot" and configure languages in [sync_standards]'
)
playbook_config = config.get("playbook", {})
if not isinstance(playbook_config, dict):
raise ValueError("[playbook] must be a table")
if "deploy_root" in playbook_config:
raise ValueError(
"playbook.deploy_root is no longer supported; use playbook.playbook_root"
)
if "intall_mode" in playbook_config:
raise ValueError(
"playbook.intall_mode is not supported; use playbook.install_mode"
)
def resolve_install_mode(config: dict) -> str:
playbook_config = config.get("playbook", {})
raw = "subtree"
if (
isinstance(playbook_config, dict)
and playbook_config.get("install_mode") is not None
):
raw = playbook_config.get("install_mode")
mode = str(raw).strip().lower()
if mode not in ("subtree", "snapshot"):
raise ValueError(
"playbook.install_mode must be one of: subtree, snapshot"
)
return mode
def resolve_configured_playbook_root(config: dict, project_root: Path) -> str:
playbook_config = config.get("playbook", {})
raw = None
if isinstance(playbook_config, dict):
raw = playbook_config.get("deploy_root")
vendor_config = config.get("vendor", {})
if isinstance(vendor_config, dict) and vendor_config.get("target_dir") is not None:
raise ValueError(
"vendor.target_dir is no longer supported; use [playbook].deploy_root"
)
raw = playbook_config.get("playbook_root")
if raw is not None and str(raw).strip():
return normalize_relative_dir(raw, "deploy_root")
return normalize_relative_dir(raw, "playbook_root")
in_project_deploy_root = resolve_in_project_deploy_root(project_root)
if in_project_deploy_root is not None:
return in_project_deploy_root
in_project_playbook_root = resolve_in_project_playbook_root(project_root)
if in_project_playbook_root is not None:
return in_project_playbook_root
if config_requires_deploy_root(config):
if resolve_install_mode(config) == "snapshot" or config_uses_playbook_root(config):
raise ValueError(
"playbook.deploy_root is required when running from an external clone; "
"set it to the target project's relative deployment path"
"playbook.playbook_root is required when running from an external clone; "
"set it to the target project's relative Playbook path"
)
return "docs/standards/playbook"
def resolve_deploy_root(context: dict) -> str:
project_root: Path = context["project_root"]
in_project_deploy_root = resolve_in_project_deploy_root(project_root)
if in_project_deploy_root is not None:
return in_project_deploy_root
return context["deploy_root"]
def resolve_playbook_root(context: dict) -> str:
return context["playbook_root"]
def resolve_docs_prefix(context: dict) -> str:
return join_deploy_subpath(resolve_deploy_root(context), "docs")
return join_playbook_subpath(resolve_playbook_root(context), "docs")
def resolve_playbook_scripts(context: dict) -> str:
return join_deploy_subpath(resolve_deploy_root(context), "scripts")
return join_playbook_subpath(resolve_playbook_root(context), "scripts")
def read_git_commit(root: Path) -> str:
@@ -408,9 +441,11 @@ def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str]) -> None:
scripts_path = join_deploy_subpath(deploy_root, "scripts/playbook.py")
docs_index_path = join_deploy_subpath(deploy_root, "docs/index.md")
def write_snapshot_readme(
dest_prefix: Path, playbook_root: str, langs: list[str]
) -> None:
scripts_path = join_playbook_subpath(playbook_root, "scripts/playbook.py")
docs_index_path = join_playbook_subpath(playbook_root, "docs/index.md")
lines = [
"# Playbook(裁剪快照)",
"",
@@ -424,7 +459,7 @@ def write_snapshot_readme(dest_prefix: Path, deploy_root: str, langs: list[str])
f"python {scripts_path} -config playbook.toml",
"```",
"",
f"配置示例:`{join_deploy_subpath(deploy_root, 'playbook.toml.example')}`",
f"配置示例:`{join_playbook_subpath(playbook_root, 'playbook.toml.example')}`",
"",
"文档入口:",
"",
@@ -453,23 +488,51 @@ def write_source_file(dest_prefix: Path, langs: list[str]) -> None:
)
def vendor_action(config: dict, context: dict) -> int:
def snapshot_langs(config: dict) -> list[str]:
sync_standards = config.get("sync_standards")
if isinstance(sync_standards, dict):
return normalize_langs(sync_standards.get("langs"))
return normalize_langs(None)
def is_generated_snapshot(path: Path) -> bool:
source = path / "SOURCE.md"
if not source.is_file():
return False
try:
langs = normalize_langs(config.get("langs"))
return "Generated-by: scripts/playbook.py" in source.read_text(encoding="utf-8")
except OSError:
return False
def install_snapshot(config: dict, context: dict) -> int:
try:
langs = snapshot_langs(config)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
deploy_root = context["deploy_root"]
target_path = Path(deploy_root)
playbook_root = context["playbook_root"]
target_path = Path(playbook_root)
project_root: Path = context["project_root"]
dest_prefix = project_root / target_path
dest_standards = dest_prefix.parent
if dest_prefix.resolve() == PLAYBOOK_ROOT.resolve():
log(f"Snapshot already installed at {dest_prefix}")
return 0
ensure_dir(dest_standards)
if dest_prefix.exists():
if not is_generated_snapshot(dest_prefix):
print(
"ERROR: refusing to replace existing playbook_root because it is "
"not a generated snapshot; use install_mode = \"subtree\" for a "
"git subtree-managed Playbook",
file=sys.stderr,
)
return 2
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
backup = dest_standards / f"{dest_prefix.name}.bak.{timestamp}"
dest_prefix.rename(backup)
@@ -535,13 +598,29 @@ def vendor_action(config: dict, context: dict) -> int:
copy2(example_config, dest_prefix / "playbook.toml.example")
write_docs_index(dest_prefix, langs)
write_snapshot_readme(dest_prefix, deploy_root, langs)
write_snapshot_readme(dest_prefix, playbook_root, langs)
write_source_file(dest_prefix, langs)
log(f"Vendored snapshot -> {dest_prefix}")
log(f"Installed snapshot -> {dest_prefix}")
return 0
def validate_subtree_install(context: dict) -> int:
project_root: Path = context["project_root"]
playbook_root = context["playbook_root"]
expected_root = (project_root / playbook_root).resolve()
if PLAYBOOK_ROOT.resolve() == expected_root:
return 0
print(
"ERROR: install_mode = \"subtree\" requires running the project-local "
f"Playbook script at "
f"{join_playbook_subpath(playbook_root, 'scripts/playbook.py')}",
file=sys.stderr,
)
return 2
def replace_placeholders(
text: str,
project_name: str | None,
@@ -1413,8 +1492,6 @@ def format_md_action(config: dict, context: dict) -> int:
def run_action(name: str, config: dict, context: dict) -> int:
print(f"[action] {name}")
if name == "vendor":
return vendor_action(config, context)
if name == "sync_rules":
return sync_rules_action(config, context)
if name == "sync_memory_bank":
@@ -1489,6 +1566,13 @@ def main(argv: list[str]) -> int:
return 2
config = load_config(config_path)
try:
validate_removed_config(config)
install_mode = resolve_install_mode(config)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
playbook_config = config.get("playbook", {})
project_root = playbook_config.get("project_root")
if project_root:
@@ -1499,7 +1583,7 @@ def main(argv: list[str]) -> int:
root = config_path.parent
resolved_root = root.resolve()
try:
deploy_root = resolve_configured_deploy_root(config, resolved_root)
playbook_root = resolve_configured_playbook_root(config, resolved_root)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
@@ -1507,9 +1591,19 @@ def main(argv: list[str]) -> int:
"project_root": resolved_root,
"config_path": config_path.resolve(),
"config": config,
"deploy_root": deploy_root,
"install_mode": install_mode,
"playbook_root": playbook_root,
}
if install_mode == "snapshot":
result = install_snapshot(config, context)
if result != 0:
return result
elif config_uses_playbook_root(config):
result = validate_subtree_install(context)
if result != 0:
return result
if should_sync_agents(config):
result = sync_agents_template(context)
if result != 0: