#!/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()