Files
awoooi/scripts/security/source-control-reconcile-plan.py

235 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""產生 source-control refs reconcile plan 草案。
此工具只讀取既有 redacted snapshot不呼叫遠端 Git不 push、不 fetch、不改 remote。
輸出用途是 review / AwoooP mirror / approval candidate不是 execution plan runner。
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
DEFAULT_STILL_FORBIDDEN = [
"push refs",
"force push",
"delete refs",
"create GitHub repo",
"change repo visibility",
"switch GitHub primary",
"disable Gitea",
"move secret values",
]
DEFAULT_EXECUTION_GATES = [
"Gitea authenticated 或 admin_export server-side repo inventory status=ok",
"branch-by-branch SHA diff 已完成",
"tag-by-tag SHA diff 已完成",
"workflow / webhook / runner / secret 名稱 inventory 已完成",
"repo owner / visibility / branch protection / CODEOWNERS 已確認",
"rollback plan 與 GitHub primary ADR 已完成",
"人工批准只針對單一 repo 生效,不得批次套用到所有 repo",
]
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def risk_for_repo(gitea_repo: str) -> str:
if gitea_repo == "wooo/awoooi":
return "HIGH"
return "MEDIUM"
def proposed_steps(event: dict[str, Any]) -> list[str]:
gitea_repo = str(event.get("gitea_repo", ""))
github_repo = str(event.get("github_repo", ""))
steps = [
f"針對 `{gitea_repo}` 與 `{github_repo}` 產生 branch-by-branch diff 表。",
f"針對 `{gitea_repo}` 與 `{github_repo}` 產生 tag-by-tag diff 表。",
"標記每個 diff 的真相來源候選Gitea、GitHub、人工指定或 deprecated。",
"列出 workflow / webhook / runner / secret 名稱差異,只記名稱不記 value。",
"產生 dry-run PR / ADR 草案,仍不 push refs。",
]
if gitea_repo == "wooo/awoooi":
steps.insert(
0,
"先確認目前 production deploy 真相來源與 deploy marker 流程,避免主控切換影響發版。",
)
return steps
def build_plan(args: argparse.Namespace) -> dict[str, Any]:
events = [load_json(Path(path)) for path in args.source_events]
gitea_inventory = load_json(Path(args.gitea_inventory))
inventory_ready = gitea_inventory.get("status") == "ok"
inventory_gate_status = "ready" if inventory_ready else "blocked"
inventory_reason = (
"Gitea server-side inventory 已完成,可在人工批准後進入更細 diff。"
if inventory_ready
else "Gitea authenticated / admin_export server-side inventory 尚未完成;本 plan 只能作草案,不可執行 refs sync。"
)
plans: list[dict[str, Any]] = []
for event in events:
gitea_repo = str(event.get("gitea_repo", ""))
github_repo = str(event.get("github_repo", ""))
plans.append(
{
"gitea_repo": gitea_repo,
"github_repo": github_repo,
"risk": risk_for_repo(gitea_repo),
"source_status": str(event.get("status", "")),
"divergence_summary": {
"gitea_branch_count": int(event.get("branch_count_gitea", 0)),
"github_branch_count": int(event.get("branch_count_github", 0)),
"gitea_tag_count": int(event.get("tag_count_gitea", 0)),
"github_tag_count": int(event.get("tag_count_github", 0)),
"gitea_main_sha": str(event.get("latest_sha_gitea", "")),
"github_main_sha": str(event.get("latest_sha_github", "")),
"blocking_reason": str(event.get("blocking_reason", "")),
},
"proposed_plan_steps": proposed_steps(event),
"execution_gates": DEFAULT_EXECUTION_GATES,
"allowed_now": [
"更新 read-only evidence",
"更新 approval board",
"產生 draft reconcile plan",
"讓 AwoooP mirror plan 狀態",
],
"still_forbidden": DEFAULT_STILL_FORBIDDEN,
"evidence_refs": [str(value) for value in event.get("evidence_refs", [])],
"awooop_consumption": "approval_candidate",
}
)
return {
"schema_version": "source_control_reconcile_plan_v1",
"status": "draft_blocked",
"date": args.date,
"default_mode": "plan_only",
"inventory_gate": {
"status": inventory_gate_status,
"reason": inventory_reason,
"required_before_execution": DEFAULT_EXECUTION_GATES,
},
"plan_count": len(plans),
"plans": plans,
}
def short_sha(value: str) -> str:
return value[:8] if value else ""
def write_markdown(plan: dict[str, Any], path: Path) -> None:
gate = plan["inventory_gate"]
lines = [
"# Source Control Draft Reconcile Plan",
"",
"| 項目 | 內容 |",
"|------|------|",
f"| 日期 | {plan['date']} |",
f"| 狀態 | `{plan['status']}` |",
f"| 預設模式 | `{plan['default_mode']}` |",
f"| inventory gate | `{gate['status']}` |",
f"| gate 原因 | {gate['reason']} |",
f"| plan count | {plan['plan_count']} |",
"",
"## 0. 核心結論",
"",
"這份文件只是 refs reconcile 草案,不是同步腳本,也不授權任何 GitHub primary 切換。AwoooP 可以 mirror 成 approval candidate但不得執行 board item 或呼叫任何 push / sync 工具。",
"",
"## 1. Repo 差異摘要",
"",
"| Repo | Risk | Gitea branches | GitHub branches | Gitea tags | GitHub tags | Gitea main | GitHub main |",
"|------|------|----------------|-----------------|------------|-------------|------------|-------------|",
]
for item in plan["plans"]:
diff = item["divergence_summary"]
lines.append(
"| "
+ " | ".join(
[
f"`{item['gitea_repo']} -> {item['github_repo']}`",
f"`{item['risk']}`",
f"`{diff['gitea_branch_count']}`",
f"`{diff['github_branch_count']}`",
f"`{diff['gitea_tag_count']}`",
f"`{diff['github_tag_count']}`",
f"`{short_sha(diff['gitea_main_sha'])}`",
f"`{short_sha(diff['github_main_sha'])}`",
]
)
+ " |"
)
lines.extend(["", "## 2. Draft Plan", ""])
for item in plan["plans"]:
diff = item["divergence_summary"]
lines.extend(
[
f"### {item['gitea_repo']} -> {item['github_repo']}",
"",
f"- 狀態:`{item['source_status']}`",
f"- 阻塞原因:{diff['blocking_reason']}",
"- 允許現在做:",
]
)
for value in item["allowed_now"]:
lines.append(f" - {value}")
lines.append("- 草案步驟:")
for value in item["proposed_plan_steps"]:
lines.append(f" - {value}")
lines.append("- 執行前 gate")
for value in item["execution_gates"]:
lines.append(f" - {value}")
lines.append("- 仍然禁止:")
for value in item["still_forbidden"]:
lines.append(f" - {value}")
lines.append("- Evidence refs")
for value in item["evidence_refs"]:
lines.append(f" - `{value}`")
lines.append("")
lines.extend(
[
"## 3. AwoooP 消費方式",
"",
"1. 只 mirror `source_control_reconcile_plan_v1`。",
"2. 只顯示 `draft_blocked` 與 blocking reason。",
"3. 可產生 approval candidate但不得自動批准。",
"4. 不得新增 execution action button。",
"",
]
)
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--date", required=True)
parser.add_argument("--gitea-inventory", default="docs/security/gitea-repo-inventory.snapshot.json")
parser.add_argument("--source-event", action="append", dest="source_events", required=True)
parser.add_argument("--output-json", required=True)
parser.add_argument("--output-md", required=True)
args = parser.parse_args()
plan = build_plan(args)
Path(args.output_json).write_text(
json.dumps(plan, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_markdown(plan, Path(args.output_md))
print(f"OK source-control reconcile plan count={plan['plan_count']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())