From bc02c2645b851b348d22291f6f2772d040a954ae Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 01:04:18 +0000 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=20repo=20=E7=89=88=E6=9C=AC=E6=B8=85?= =?UTF-8?q?=E5=96=AE=EF=BC=8B=E7=B5=90=E6=A1=88=E8=A8=98=E5=B8=B3=EF=BC=88?= =?UTF-8?q?inkstone/ISEP#84=E3=80=81#85=EF=BC=89=EF=BC=88scripts/release-m?= =?UTF-8?q?anifest=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release-manifest | 410 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 scripts/release-manifest diff --git a/scripts/release-manifest b/scripts/release-manifest new file mode 100644 index 0000000..7db1e67 --- /dev/null +++ b/scripts/release-manifest @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""release-manifest — 一次交貨 = 一份清單(inkstone/ISEP#84)。 + +leo 的問題(票上原文):「一次交貨常常同時動到好幾個 repo,但**沒有任何地方記著 +『這一版 = 哪幾個 repo 的哪幾個版本』**⋯⋯要退版的時候,只退了其中一個 repo, +剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」 + + release-manifest freeze R-2026.08.30 --part inkstone/Arcrun@v1.4.52 \ + --part inkstone/arcrun-collector@v0.18.36 + release-manifest current 我現在手上是哪一版、由什麼組成 + release-manifest list 全部清單,新的在上面 + release-manifest show R-2026.08.30 + release-manifest verify R-2026.08.30 清單上的版本號現在還抓得到嗎 + release-manifest diff R-2026.08.29 R-2026.08.30 + release-manifest rollback R-2026.08.29 退版:**整份一起退** + +🔴 三條硬規則,全部長在這支工具裡(不是寫在文件裡靠人記得): + + ① **freeze 之前每一個版本號都真的去 Gitea 抓一次**。抓不到就不給凍結—— + 「寫上去」跟「存在」是兩件事,而清單的用處完全建立在後者。 + ② **凍結了就不准再加東西**。同名清單已存在 ⇒ 拒絕覆寫,新的需求進下一份。 + ③ **退版沒有「只退一個」這個選項**。`rollback` 一定吐出整份清單的每一格; + 指名單一 repo(`--only`)會被擋下,因為那正是票上那個病: + **剩下的還停在新版 ⇒ 一組從來沒測過的組合。** + +📌 跟 `release-tag-guard.sh` 是包含關係不是重複:那支管「打 tag 那一刻, + 單一 repo 內版本號三處一致」;這支管它上面那一層——**跨 repo 的組合**。 + +📌 單一 repo 的交貨不必特例:清單就退化成只有一格,規則一字不改。 + +離線測試:`scripts/test-release-manifest.sh`(走 `RELEASE_MANIFEST_FIXTURE`,不打網路)。 +""" +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request + +HOST = os.environ.get("RELEASE_MANIFEST_HOST", "https://git.uncle6.me") +HERE = os.path.dirname(os.path.abspath(__file__)) +def _resolve_dir(): + """清單住哪裡。 + + 🔴 **不能住在 plugin 目錄裡**:ISEP 是用 marketplace 裝的, + `claude plugin update` 會把那個目錄整個換掉 ⇒ 所有出貨清單一次消失, + 而清單的用處全部發生在「以後要退版的那一天」。 + 順序:環境變數 → 這個 session 的專案根(有 `system-dev/` =總管的工作區, + 跨 repo 的出貨清單本來就屬於那一層)→ 這份原始碼所在 repo → `~/.claude/`。 + """ + v = os.environ.get("RELEASE_MANIFEST_DIR") + if v: + return v + root = os.path.dirname(HERE) + for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", root): + if base and os.path.isdir(os.path.join(base, "system-dev")): + return os.path.join(base, "releases") + return os.path.join(os.path.expanduser("~"), ".claude", "isep-releases") + + +DIR = _resolve_dir() +FIXTURE = os.environ.get("RELEASE_MANIFEST_FIXTURE", "") +DRY = False + +NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +PART_RE = re.compile(r"^([\w.-]+)/([\w.-]+)@(\S+)$") + + +def die(msg, code=2): + print(msg, file=sys.stderr) + sys.exit(code) + + +def token(): + root = os.environ.get("CLAUDE_PROJECT_DIR") or HERE + host = HOST.split("//")[-1].rstrip("/") + try: + out = subprocess.run(["git", "-C", root, "remote", "-v"], + capture_output=True, text=True, timeout=20).stdout + except Exception: + out = "" + for line in out.splitlines(): + if host in line: + m = re.search(r"//[^:/]+:([^@]+)@", line) + if m: + return m.group(1) + for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"): + if os.environ.get(env): + return os.environ[env] + return "" + + +def _fixture(): + try: + with open(FIXTURE) as f: + return json.load(f) + except Exception: + die("🔴 讀不到 RELEASE_MANIFEST_FIXTURE:%s" % FIXTURE) + + +def api(path): + """GET 一筆。回 dict/list,查不到回 None。fixture 模式完全不碰網路。""" + if FIXTURE: + return None + tk = token() + if not tk: + die("🔴 拿不到 %s 的 token(remote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE)" % HOST) + try: + req = urllib.request.Request(HOST + "/api/v1" + path, + headers={"Authorization": "token " + tk}) + return json.load(urllib.request.urlopen(req, timeout=25)) + except urllib.error.HTTPError: + return None + except Exception as e: + die("🔴 連不上 %s:%s" % (HOST, e)) + + +def resolve(owner, repo, ver): + """這個版本號**真的存在嗎**。存在回一段可以貼給人看的證據,不存在回 None。 + + 先問 release,再問 tag:有些 repo 只打 tag 沒建 release,那也算存在 + (票上要的是「抓得到那個 release」=抓得到那個版本,不是逼每個 repo 都建 release 頁)。 + """ + if FIXTURE: + fx = _fixture().get("versions") or {} + return fx.get("%s/%s@%s" % (owner, repo, ver)) + r = api("/repos/%s/%s/releases/tags/%s" % (owner, repo, ver)) + if isinstance(r, dict) and r.get("tag_name"): + return {"kind": "release", "url": r.get("html_url") or "", + "commit": (r.get("target_commitish") or "")[:12]} + t = api("/repos/%s/%s/tags/%s" % (owner, repo, ver)) + if isinstance(t, dict) and t.get("name"): + return {"kind": "tag", "url": (t.get("commit") or {}).get("url") or "", + "commit": ((t.get("commit") or {}).get("sha") or "")[:12]} + return None + + +# ── 清單的存取 ──────────────────────────────────────────────────────────── +def path_of(name): + return os.path.join(DIR, name + ".json") + + +def load(name): + try: + with open(path_of(name)) as f: + return json.load(f) + except Exception: + return None + + +def all_manifests(): + try: + names = [f[:-5] for f in os.listdir(DIR) if f.endswith(".json")] + except Exception: + return [] + rows = [load(n) for n in names] + rows = [m for m in rows if m] + # 🔴 排序鍵不能只用時間戳:同一分鐘凍兩份清單就會並列, + # 而並列之後「現在手上的是哪一份」會變成 os.listdir 的順序(等於隨機)。 + # seq 是 freeze 當下遞增的流水號 ⇒ 先後永遠有唯一解。 + rows.sort(key=lambda m: (m.get("seq", 0), m.get("frozen_at") or ""), reverse=True) + return rows + + +def parse_part(s): + m = PART_RE.match(s.strip()) + if not m: + die("🔴 寫法是 owner/repo@版本號,你給的是:%s" % s) + return m.group(1), m.group(2), m.group(3) + + +def opts(argv, name): + return [argv[i + 1] for i, a in enumerate(argv) if a == name and i + 1 < len(argv)] + + +def opt(argv, name, default=None): + v = opts(argv, name) + return v[0] if v else default + + +# ── INDEX.md:leo 打開就看得懂「我現在手上是哪一版」 ────────────────────── +def write_index(): + rows = all_manifests() + lines = ["# 出貨清單(哪一版由哪幾個 repo 的哪幾版組成)", "", + "> 這份檔案由 `scripts/release-manifest` 自動重寫,**不要手改**" + "(手改的那一份會安靜地騙人)。", "", + "- 每一列 = 一次交貨。**最上面那一份就是現在手上的這一版。**"] + if not rows: + lines.append("\t- 目前還沒有任何凍結過的清單。") + for i, m in enumerate(rows): + head = "**%s**%s" % (m["name"], " ← 現在手上的這一版" if i == 0 else "") + lines.append("\t- " + head) + lines.append("\t\t- 凍結於 %s(UTC)" % m.get("frozen_at", "?")) + if m.get("note"): + lines.append("\t\t- 說明:%s" % m["note"]) + lines.append("\t\t- 由這些組成(沒有更動的 repo 不會出現在這裡)") + for p in m.get("parts", []): + ev = p.get("verified") or {} + lines.append("\t\t\t- `%s` @ **%s** (凍結當下驗過:%s %s)" + % (p["repo"], p["version"], ev.get("kind", "?"), + ev.get("commit", ""))) + lines.append("\t\t- 要退回這一版:`scripts/release-manifest rollback %s`" % m["name"]) + lines.append("") + lines.append("- 為什麼要有這份東西(`inkstone/ISEP#84`)") + lines.append("\t- 退版只退了其中一個 repo,剩下的還停在新版" + " ⇒ **變成一組從來沒測過的組合**。") + lines.append("\t- 所以送測、放行、出貨、退版**全部以一份清單為單位**," + "不以單一 repo 為單位。") + try: + os.makedirs(DIR, exist_ok=True) + with open(os.path.join(DIR, "INDEX.md"), "w") as f: + f.write("\n".join(lines) + "\n") + except Exception as e: + die("🔴 寫不進 %s:%s" % (DIR, e)) + + +# ── freeze ─────────────────────────────────────────────────────────────── +def cmd_freeze(argv): + if not argv: + die("用法:release-manifest freeze <清單名> --part owner/repo@版本 [--part …] " + "[--note 說明] [--dry-run]") + name = argv[0] + if not NAME_RE.match(name): + die("🔴 清單名只准英數與 . _ -(例:R-2026.08.30),你給的是:%s" % name) + raw = opts(argv[1:], "--part") + if not raw: + die("🚫 一份清單至少要有一格 --part owner/repo@版本號。\n" + " (單一 repo 的交貨也要開清單——它只是退化成一格,規則不變。)") + + if load(name) is not None: + die("🚫 %s 已經凍結過了,不准再加東西。\n" + " 【inkstone/ISEP#84】「清單一旦凍結就不再加東西,**新的需求進下一版**。」\n" + " 要出新的組合 ⇒ 換一個清單名重新 freeze(舊的留著,退版時要用)。" % name) + + seen, parts = set(), [] + for s in raw: + owner, repo, ver = parse_part(s) + full = "%s/%s" % (owner, repo) + if full in seen: + die("🔴 同一個 repo 在清單裡出現兩次:%s" % full) + seen.add(full) + ev = resolve(owner, repo, ver) + if not ev: + die("🚫 %s@%s **在 Gitea 上抓不到**——不給凍結。\n" + " 【inkstone/ISEP#84 驗收 1】「清單上的版本號**真的存在**" + "(不是寫上去而已,要抓得到那個 release)」。\n" + " 先把那個 repo 的版本打出來(tag/release),再回來 freeze。" + % (full, ver)) + parts.append({"repo": full, "version": ver, "verified": ev}) + + nxt = max([m.get("seq", 0) for m in all_manifests()] or [0]) + 1 + man = {"name": name, "seq": nxt, + "frozen_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "note": opt(argv[1:], "--note", "") or "", + "parts": parts} + + if DRY: + print("[dry-run] 會寫進 %s" % path_of(name)) + print(json.dumps(man, ensure_ascii=False, indent=2)) + print("[dry-run] 一個檔都沒有真的寫出去。") + return + os.makedirs(DIR, exist_ok=True) + with open(path_of(name), "w") as f: + json.dump(man, f, ensure_ascii=False, indent=2) + write_index() + print("✅ 清單 %s 已凍結(%d 個 repo,每一格都驗過真的抓得到)" % (name, len(parts))) + for p in parts: + print(" %-34s %s" % (p["repo"], p["version"])) + print("\n📌 從現在起:送測/放行/出貨/退版都以 %s 為單位,不以單一 repo 為單位。" % name) + print(" 人看的版本:%s" % os.path.join(DIR, "INDEX.md")) + + +# ── verify / show / list / current / diff ──────────────────────────────── +def cmd_verify(argv): + if not argv: + die("用法:release-manifest verify <清單名>") + man = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0]) + bad = 0 + for p in man["parts"]: + owner, repo = p["repo"].split("/", 1) + ev = resolve(owner, repo, p["version"]) + print("%s %-34s %s" % ("✅" if ev else "❌", p["repo"], p["version"])) + if not ev: + bad += 1 + if bad: + die("\n🔴 %d 格抓不到了——這份清單現在**退不回去**," + "要嘛版本被刪了,要嘛當初就沒真的存在。" % bad) + print("\n✅ %s 全部 %d 格都還抓得到,可以拿它退版。" % (man["name"], len(man["parts"]))) + + +def cmd_show(argv): + if not argv: + die("用法:release-manifest show <清單名>") + man = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0]) + print(json.dumps(man, ensure_ascii=False, indent=2)) + + +def cmd_list(argv): + rows = all_manifests() + if not rows: + print("(還沒有任何凍結過的清單。第一份用 release-manifest freeze 開。)") + return + for i, m in enumerate(rows): + print("%s %-18s %s %d 個 repo:%s" + % ("👉" if i == 0 else " ", m["name"], m.get("frozen_at", "?"), + len(m.get("parts", [])), + " + ".join("%s@%s" % (p["repo"].split("/")[-1], p["version"]) + for p in m.get("parts", [])))) + print("\n👉 = 現在手上的這一版。人看的版本:%s" % os.path.join(DIR, "INDEX.md")) + + +def cmd_current(argv): + rows = all_manifests() + if not rows: + die("🔴 一份清單都沒有——「我現在手上是哪一版」目前沒有人答得出來。", 1) + m = rows[0] + print("現在手上的這一版:%s(凍結於 %s)" % (m["name"], m.get("frozen_at", "?"))) + for p in m["parts"]: + print(" %-34s %s" % (p["repo"], p["version"])) + + +def cmd_diff(argv): + if len(argv) < 2: + die("用法:release-manifest diff <舊清單> <新清單>") + a = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0]) + b = load(argv[1]) or die("🔴 沒有這份清單:%s" % argv[1]) + va = {p["repo"]: p["version"] for p in a["parts"]} + vb = {p["repo"]: p["version"] for p in b["parts"]} + for repo in sorted(set(va) | set(vb)): + x, y = va.get(repo), vb.get(repo) + if x == y: + print(" = %-34s %s" % (repo, x)) + elif x is None: + print(" + %-34s %s(新進來的)" % (repo, y)) + elif y is None: + print(" - %-34s %s(新清單裡沒有了)" % (repo, x)) + else: + print(" ~ %-34s %s → %s" % (repo, x, y)) + + +# ── rollback:整份一起退,沒有「只退一個」這個選項 ──────────────────────── +def cmd_rollback(argv): + if not argv: + die("用法:release-manifest rollback <清單名> [--dry-run]") + name = argv[0] + man = load(name) or die("🔴 沒有這份清單:%s" % name) + + only = opts(argv[1:], "--only") + if only: + die("🚫 **退版沒有「只退一個」這個選項。**(你指名了:%s)\n\n" + " 【inkstone/ISEP#84】「要退版的時候,只退了其中一個 repo,\n" + " 剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」\n\n" + " %s 這一版由 %d 個 repo 組成:\n%s\n" + " ⇒ 拿掉 --only,整份一起退:release-manifest rollback %s\n" + " ⇒ 真的只有一個 repo 要動 ⇒ 那就 freeze 一份**新的**清單," + "把不動的那幾格照抄過去。" + % (", ".join(only), name, len(man["parts"]), + "\n".join(" %s @ %s" % (p["repo"], p["version"]) + for p in man["parts"]), name)) + + cur = all_manifests() + curv = {p["repo"]: p["version"] for p in cur[0]["parts"]} if cur else {} + curname = cur[0]["name"] if cur else "(沒有)" + + print("退版目標:%s(現在手上的是 %s)\n" % (name, curname)) + moves, stays = [], [] + for p in man["parts"]: + (stays if curv.get(p["repo"]) == p["version"] else moves).append(p) + + print("【要動的】") + if not moves: + print(" (沒有——現在手上的組合已經等於這份清單)") + for p in moves: + owner_repo = p["repo"] + print(" %-34s %s → %s" % (owner_repo, curv.get(owner_repo, "?"), p["version"])) + print(" git -C <%s 的工作目錄> fetch --tags && " + "git -C <同上> checkout %s" % (owner_repo.split("/")[-1], p["version"])) + if stays: + print("\n【已經在目標版本、不必動】(列出來是為了讓你確認**沒有一格被漏掉**)") + for p in stays: + print(" %-34s %s" % (p["repo"], p["version"])) + + print("\n🔴 這 %d 格是一組,要嘛全部到位,要嘛就不算退版完成。" + % len(man["parts"])) + print(" 退完跑一次複驗:release-manifest verify %s" % name) + + +def main(): + global DRY + argv = sys.argv[1:] + if "--dry-run" in argv: + DRY = True + argv = [a for a in argv if a != "--dry-run"] + if not argv: + print(__doc__) + return + cmd, rest = argv[0], argv[1:] + table = {"freeze": cmd_freeze, "verify": cmd_verify, "show": cmd_show, + "list": cmd_list, "current": cmd_current, "diff": cmd_diff, + "rollback": cmd_rollback} + if cmd not in table: + print(__doc__) + die("🔴 沒有這個子指令:%s" % cmd) + table[cmd](rest) + + +if __name__ == "__main__": + main()