49dedbbb15
工單的斷點是「沒有任何東西會建 release」——v0.22.0 tag 早推了, Gitea Releases 頁卻停在 v0.18.0,對 leo 等於這一版不存在。 新增兩支獨立的站(不改任何既有閘,只補送貨鏈缺的那兩段): - scripts/release-ship(= arcrun-rag 的 release-record 站) 把「已推的 tag」變成「leo 看得到的 release」。三個前置條件「必須在場」才放行: ①版本一致(重用 check-version-consistency.sh,不另立判準)②tag 真的在 Gitea ③release note 有內容(沒給就從 git log 自動生事實清單)。冪等;建完回頭跑 release-check 拿收貨端覆核——送的人說成功不算。 - scripts/release-check(= release-check 站) 回頭問每一段「到了嗎」,而且**問的是收貨端**(Gitea tags/releases、這個 session 載到的 plugin.json),不聽送的人說。現行版本缺 tag 或缺 release ⇒ exit 2 並指名 缺哪一站+怎麼補(工單驗收 4)。判準是結構(定版 commit / tag / release 在不在), 不是關鍵字黑名單。 - 兩支離線測試(RELEASE_*_FIXTURE,不打 Gitea):release-check 9 項、release-ship 7 項全過。 實跑證據:release-ship v0.22.0 已在 Gitea 建起 https://git.uncle6.me/inkstone/ISEP/releases/tag/v0.22.0 release-check 覆核 v0.22.0 tag+release 都在(收貨端事實)。 版本:新增的是 scripts/(非自動載入),本身不需升版;待總管定版隨下一版一起送出。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012656SLtw96aCxTgScH5bbK
292 lines
12 KiB
Python
Executable File
292 lines
12 KiB
Python
Executable File
#!/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:]))
|