#!/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 也還顯示舊版。 ISEP 的運送鏈有這幾段,**每一段都要有人回頭問「到了嗎」**: 定版 commit → tag(Gitea) → release(Gitea) → main 的 plugin.json → 這個 session 真的載入到的版本 🔴 這支的全部價值在一句話:**它不聽上一站說「我送了」,它自己去問收貨的那一端。** tag 在不在 → 問 Gitea 的 tags;release 在不在 → 問 Gitea 的 releases(leo 會看的那頁); 載入到了沒 → 看這個 session 真的掛著的那一份 plugin.json。 送的人(release-ship)說「成功」不算數——這支拿收貨端的事實覆核。 判準是**結構**,不是關鍵字(leo 2026-08-17 已證偽關鍵字黑名單:8 次誤攔、0 次正確): · 哪些版本「應該」被送到 = main 上有 `定版 vX.Y.Z` commit 的那些(git 說了算,不是猜) · 那個版本「送到了沒」= 去 Gitea 問它的 tag 與 release 在不在(在=到了) 離開碼: 0 現行版本(plugin.json 宣稱的那一版)**tag 與 release 都在** ⇒ 送貨端完成 2 現行版本缺 tag 或缺 release ⇒ **送貨端斷了**,stderr 講出缺哪一站+怎麼補 (這正是工單驗收 4:「故意漏一步 → 管線要擋下來並說出缺哪一站」) 說明:比現行版本更舊的 `定版` 版本若缺 tag/release,只**黃字提醒**、不改離開碼—— 它們多半已被新版取代,硬擋等於「懲罰謹慎」(over-block 比 under-block 嚴重)。 載入端落後(這個 session 跑的比 main 舊)也只提醒:那要 update+重啟, 是收貨端的動作,不是送貨端能修的,且 SessionStart 信標本來就一直在報。 離線測試:`scripts/test-release-check.sh`(走 `RELEASE_CHECK_FIXTURE`,不打 Gitea)。 """ import json import os import re import subprocess import sys import urllib.error import urllib.request HOST = os.environ.get("RELEASE_CHECK_HOST", "https://git.uncle6.me") REPO = os.environ.get("RELEASE_CHECK_REPO", "inkstone/ISEP") FIXTURE = os.environ.get("RELEASE_CHECK_FIXTURE", "") HERE = os.path.dirname(os.path.abspath(__file__)) 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 vtuple(v): nums = re.findall(r"\d+", v or "") return tuple(int(x) for x in nums[:3]) if nums else (0,) # ══ 收貨端事實:全部從「別人」那裡拿,不聽送的人說 ═══════════════════════ def _fixture(): try: with open(FIXTURE) as f: return json.load(f) except Exception: die("🔴 讀不到 RELEASE_CHECK_FIXTURE:%s" % FIXTURE) def token(): for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"): if os.environ.get(env): return os.environ[env] root = os.environ.get("CLAUDE_PROJECT_DIR") or proj_root() 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) return "" def _api_names(path, key): """回一個 set:Gitea 上那個 collection 裡所有的名字(tag_name/name)。""" tk = token() if not tk: die("🔴 拿不到 %s 的 token(remote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE)" % HOST) names, page = set(), 1 while True: url = "%s/api/v1/repos/%s/%s?limit=50&page=%d" % (HOST, REPO, path, page) req = urllib.request.Request(url, headers={"Authorization": "token " + tk}) try: rows = json.load(urllib.request.urlopen(req, timeout=25)) except urllib.error.HTTPError as e: die("🔴 Gitea %s:%s" % (e.code, e.read().decode()[:200])) except Exception as e: die("🔴 連不上 %s:%s" % (HOST, e)) if not rows: break for r in rows: n = r.get(key) if n: names.add(n) if len(rows) < 50: break page += 1 return names def gitea_tags(): if FIXTURE: return set(_fixture().get("tags") or []) return _api_names("tags", "name") def gitea_releases(): if FIXTURE: return set(_fixture().get("releases") or []) return _api_names("releases", "tag_name") # ══ 送貨端事實:main 上「定版」了哪些、現在宣稱哪一版、這個 session 載到哪一版 ══ def dingban_versions(): """main 上有 `定版 vX.Y.Z` commit 的版本 —— 「應該被送到」的權威清單。""" if FIXTURE: return list(_fixture().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 FIXTURE: return _fixture().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 FIXTURE: return _fixture().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(): cur = 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 tags = gitea_tags() rels = gitea_releases() def tagname(v): return "v" + v rows = [] for v in sorted(set(dingban), key=vtuple, reverse=True): rows.append({ "ver": v, "dingban": True, # 這份清單本來就是從 定版 commit 來的 "tag": tagname(v) in tags, "release": tagname(v) in rels, "current": v == cur, }) return cur, rows STATIONS = [ ("dingban", "定版 commit", "在 main 上 commit 一顆 `定版 v<版本>`(= plugin.json 也改成那一版)"), ("tag", "tag(Gitea)", "打並推 tag:`git tag v<版本> && git push origin v<版本>`"), ("release", "release(Gitea)", "建 release:`scripts/release-ship v<版本>`(這一站就是最常斷的那站)"), ] def report(): cur, rows = audit() loaded = loaded_version() icon = {True: "✅", False: "❌"} lines = ["ISEP 運送鏈覆核(收貨端事實,不聽送的人說) repo=%s" % REPO, ""] lines.append(" 版本 定版 tag release (← =現行 plugin.json 宣稱的版本)") for r in rows: mark = " ←" if r["current"] else "" 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 vtuple(loaded) < 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 = [] if currow: for key, label, fix in STATIONS: if not currow[key]: hard.append((label, fix)) older_gaps = [r for r in rows if not r["current"] and (not r["tag"] or not r["release"])] lines.append("") if hard: lines.append("🔴 **現行版本 v%s 的送貨端斷了** —— 對 leo 等於這一版不存在:" % cur) for label, fix in hard: lines.append(" 缺這一站:%s" % label) lines.append(" 怎麼補:%s" % fix) 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: miss = [] if not r["tag"]: miss.append("tag") if not r["release"]: miss.append("release") lines.append(" v%s 缺:%s" % (r["ver"], "+".join(miss))) lines.append(" 要補歷史版本 ⇒ 逐版 `scripts/release-ship v<版本>`;" "確定不補 ⇒ 這是 leo/總管的決定,記一句在 inkstone/ISEP#67。") out = "\n".join(lines) if hard: print(out, file=sys.stderr) return 2 print(out) return 0 def main(argv): if argv and argv[0] in ("-h", "--help"): print(__doc__) return 0 return report() if __name__ == "__main__": sys.exit(main(sys.argv[1:]))