#!/usr/bin/env python3 """release-check — 回頭問「這一版真的送到了嗎」,而且**問的人不是送的人** (inkstone/ISEP#67) leo 2026-08-27 的病(票上原文): 「版本只到 ISEP v0.3.9,你有新版為什麼不推?」 v0.4.0/v0.5.0 的 tag 早就推了,但 Gitea Releases 頁停在 v0.3.9 ⇒ 對 leo 等於這兩版不存在;claude plugin list 也還顯示舊版。 2026-09-07 又一次:v0.23.0/v0.24.0 打了 tag,Releases 頁停在 v0.22.0—— 交棒給 leo 的「測試位置 releases/tag/v0.24.0」打開是一頁空 tag。 ISEP 的運送鏈有這幾段,**每一段都要有人回頭問「到了嗎」**: 定版 commit → tag(Gitea) → release(Gitea) → main 的 plugin.json → 這個 session 真的載入到的版本 🔴 這支的全部價值在一句話:**它不聽上一站說「我送了」,它自己去問收貨的那一端。** tag 在不在 → 問 Gitea 的 tags;release 在不在 → 問 Gitea 的 releases(leo 會看的那頁); 載入到了沒 → 看這個 session 真的掛著的那一份 plugin.json。 問的方式全部匿名(D20:讀不帶憑證),判準與 release-ship/SessionStart 信標同一份 (hooks/lib/release_chain.py)。 用法: release-check 現行版本 = 這棵樹 plugin.json 宣稱的那一版 release-check v0.23.0 現行版本 = 你指定的那一版(release-ship 送完拿來覆核**那一版**, 不管工作樹現在 checkout 在哪) 判準是**結構**,不是關鍵字(leo 2026-08-17 已證偽關鍵字黑名單:8 次誤攔、0 次正確): · 哪些版本「應該」被送到 = main 上有 `定版 vX.Y.Z` commit 的那些(git 說了算,不是猜) · 那個版本「送到了沒」= 去 Gitea 問它的 tag 與 release 在不在(在=到了) 離開碼: 0 現行版本 **tag 與 release 都在** ⇒ 送貨端完成 2 現行版本缺 tag 或缺 release ⇒ **送貨端斷了**,stderr 講出缺哪一站+怎麼補 (這正是工單驗收 4:「故意漏一步 → 管線要擋下來並說出缺哪一站」) 說明:比現行版本更舊的 `定版` 版本若缺 tag/release,只**黃字提醒**、不改離開碼—— 它們多半已被新版取代,硬擋等於「懲罰謹慎」(over-block 比 under-block 嚴重)。 v0.19/0.20/0.21 就是總管 09-07 裁定「不補」的那三個。 載入端落後(這個 session 跑的比 main 舊)也只提醒:那要 update+重啟, 是收貨端的動作,不是送貨端能修的,且 SessionStart 信標本來就一直在報。 離線測試:`scripts/test-release-check.sh`(走 `RELEASE_CHECK_FIXTURE`,不打 Gitea)。 """ import json import os import re import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "hooks", "lib")) try: import release_chain as rc except Exception as e: # pragma: no cover print("🔴 讀不到 hooks/lib/release_chain.py:%s" % e, file=sys.stderr) sys.exit(2) HOST = os.environ.get("RELEASE_CHECK_HOST", rc.DEFAULT_HOST) REPO = os.environ.get("RELEASE_CHECK_REPO", rc.DEFAULT_REPO) FIXTURE = os.environ.get("RELEASE_CHECK_FIXTURE", "") def die(msg, code=2): print(msg, file=sys.stderr) sys.exit(code) def proj_root(): """這支腳本操作的那個 ISEP repo 的根。""" v = os.environ.get("CLAUDE_PROJECT_DIR") if v and os.path.isfile(os.path.join(v, ".claude-plugin", "plugin.json")): return v return os.path.dirname(HERE) def load_fixture(): if not FIXTURE: return None try: with open(FIXTURE) as f: return json.load(f) except Exception: die("🔴 讀不到 RELEASE_CHECK_FIXTURE:%s" % FIXTURE) FX = load_fixture() CHAIN = rc.Chain(HOST, REPO, fixture=FX) # 🔴 裁定不補的歷史版本(唯一識別碼往放行方向比對,不是關鍵字): # 總管 2026-09-07(inkstone/ISEP#67 → comment 6574):「v0.19/0.20/0.21 不補—— # 沒 tag,v0.22.0 的 note 已列到那三個定版。」這三個定版 commit 永遠在 main 上, # 不列在這裡它們就會每次都黃字——天天念的東西會被學會忽略,等於不存在。 WAIVED = {"0.19.0": "inkstone/ISEP#67 → 6574", "0.20.0": "inkstone/ISEP#67 → 6574", "0.21.0": "inkstone/ISEP#67 → 6574"} # ══ 送貨端事實:main 上「定版」了哪些、現在宣稱哪一版、這個 session 載到哪一版 ══ def dingban_versions(): """main 上有 `定版 vX.Y.Z` commit 的版本 —— 「應該被送到」的權威清單。""" if FX is not None: return list(FX.get("dingban") or []) root = proj_root() try: out = subprocess.run( ["git", "-C", root, "log", "--format=%s", "--grep", "定版 v"], capture_output=True, text=True, timeout=30).stdout except Exception: out = "" seen, vers = set(), [] for line in out.splitlines(): m = re.search(r"定版\s+v?(\d+\.\d+\.\d+)", line) if m and m.group(1) not in seen: seen.add(m.group(1)) vers.append(m.group(1)) return vers def current_version(): """plugin.json 現在宣稱的那一版(= main 要送出去的那一版)。""" if FX is not None: return FX.get("current") or "" try: with open(os.path.join(proj_root(), ".claude-plugin", "plugin.json")) as f: return json.load(f).get("version") or "" except Exception: return "" def loaded_version(): """這個 session 真的掛著的那一份 plugin 是哪一版(收貨端最後一段)。 拿不到就回 None —— 不猜、不謊稱到了(house style:不知道就說不知道)。 """ if FX is not None: return FX.get("loaded") root = os.environ.get("CLAUDE_PLUGIN_ROOT") if not root: return None try: with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f: return json.load(f).get("version") except Exception: return None # ══ 覆核:逐版問「到了嗎」 ════════════════════════════════════════════════ def audit(focus=None): cur = focus or current_version() if not cur: die("🔴 讀不到 .claude-plugin/plugin.json 的 version —— 這不是 ISEP repo,或檔案壞了。") dingban = dingban_versions() if cur not in dingban: # 現行版本連 `定版` commit 都沒有 ⇒ plugin.json 宣稱了一個沒被定版的號碼。 # 這是 check-version-consistency 那條線的病,不是這支的守備範圍——但要講出來。 dingban = [cur] + dingban try: tags = CHAIN.tags() rels = CHAIN.releases() except rc.ChainError as e: die("🔴 問不到收貨端,這一支不猜:%s" % e) rows = [] for v in sorted(set(dingban), key=rc.vtuple, reverse=True): rows.append({ "ver": v, "dingban": True, # 這份清單本來就是從 定版 commit 來的 "tag": rc.tagname(v) in tags, "release": rc.tagname(v) in rels, "current": v == cur, "gaps": rc.gaps(v, tags, rels), "waived": WAIVED.get(v) if v != cur else None, }) return cur, rows def ship_cmd(): """補 release 的那一行要能直接貼著跑:印正本自己的絕對路徑(mistakes.md 2026-08-31)。""" return "python3 %s" % os.path.join(HERE, "release-ship") def report(focus=None): cur, rows = audit(focus) loaded = loaded_version() icon = {True: "✅", False: "❌"} lines = ["ISEP 運送鏈覆核(收貨端事實,不聽送的人說) repo=%s" % REPO, ""] lines.append(" 版本 定版 tag release (← =%s)" % ("你指定的版本" if focus else "現行 plugin.json 宣稱的版本")) for r in rows: mark = " ←" if r["current"] else "" if r["waived"] and r["gaps"]: mark = " ⏭ 裁定不補(%s)" % r["waived"] lines.append(" %-10s %s %s %s%s" % ( "v" + r["ver"], icon[r["dingban"]], icon[r["tag"]], icon[r["release"]], mark)) # ── 載入端(這個 session)────────────────────────────────────────── lines.append("") if loaded is None: lines.append(" 載入端:這個 context 讀不到 CLAUDE_PLUGIN_ROOT ⇒ 說不出「這個 session 載到哪一版」" "(不是 session 裡跑的,或沒裝 plugin)。") elif rc.vtuple(loaded) < rc.vtuple(cur): lines.append(" 🟡 載入端落後:這個 session 掛的是 v%s,main 已到 v%s。" % (loaded, cur)) lines.append(" 修:本機 `claude plugin update isep@inkstone` **並重啟 session**" "(升版與生效是兩件事);雲端去動一下 Environment 的 setup script 逼它重拍快照。") lines.append(" 這一段 SessionStart 信標本來就一直在報(beacon_report.py ①)。") else: lines.append(" ✅ 載入端:這個 session 掛的是 v%s,跟 main 宣稱的一致。" % loaded) # ── 判決 ──────────────────────────────────────────────────────────── currow = next((r for r in rows if r["current"]), None) hard = currow["gaps"] if currow else [] older_gaps = [r for r in rows if not r["current"] and r["gaps"] and not r["waived"]] lines.append("") if hard: lines.append("🔴 **現行版本 v%s 的送貨端斷了** —— 對 leo 等於這一版不存在:" % cur) for _key, label, fix in hard: lines.append(" 缺這一站:%s" % label) lines.append(" 怎麼補:%s" % fix.format(v=cur, ship=ship_cmd())) if older_gaps: lines.append(" (另外還有更舊的版本也缺,見下方黃字,但那些不擋。)") else: lines.append("✅ 現行版本 v%s:定版、tag、release 都在 —— 送貨端完成,leo 那頁看得到。" % cur) if older_gaps: lines.append("") lines.append("🟡 更舊的定版版本也缺件(多半已被新版取代,只提醒、不擋):") for r in older_gaps: lines.append(" v%s 缺:%s" % (r["ver"], "+".join(label for _k, label, _f in r["gaps"]))) lines.append(" 要補歷史版本 ⇒ 逐版 `%s v<版本>`;" "確定不補 ⇒ 這是 leo/總管的決定,記一句在 inkstone/ISEP#67" "(v0.19/0.20/0.21 已於 2026-09-07 裁定不補)。" % ship_cmd()) out = "\n".join(lines) if hard: print(out, file=sys.stderr) return 2 print(out) return 0 def main(argv): focus = None for a in argv: if a in ("-h", "--help"): print(__doc__) return 0 m = re.match(r"^v?(\d+\.\d+\.\d+)$", a.strip()) if not m: die("用法:release-check [vX.Y.Z](--help 看說明);不認得:%s" % a) focus = m.group(1) return report(focus) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))