Files
ISEP/scripts/release-check
T
Claude 49dedbbb15 補上運送鏈缺的兩站:建 release + 回頭覆核(inkstone/ISEP#67)
工單的斷點是「沒有任何東西會建 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
2026-09-05 21:01:29 +00:00

292 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""release-check — 回頭問「這一版真的送到了嗎」,而且**問的人不是送的人**
inkstone/ISEP#67
leo 2026-08-27 的病(票上原文):
「版本只到 ISEP v0.3.9,你有新版為什麼不推?」
v0.4.0v0.5.0 的 tag 早就推了,但 Gitea Releases 頁停在 v0.3.9
⇒ 對 leo 等於這兩版不存在;claude plugin list 也還顯示舊版。
ISEP 的運送鏈有這幾段,**每一段都要有人回頭問「到了嗎」**:
定版 commit → tagGitea → releaseGitea → main 的 plugin.json
→ 這個 session 真的載入到的版本
🔴 這支的全部價值在一句話:**它不聽上一站說「我送了」,它自己去問收貨的那一端。**
tag 在不在 → 問 Gitea 的 tagsrelease 在不在 → 問 Gitea 的 releasesleo 會看的那頁);
載入到了沒 → 看這個 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):
"""回一個 setGitea 上那個 collection 裡所有的名字(tag_namename)。"""
tk = token()
if not tk:
die("🔴 拿不到 %s 的 tokenremote 沒帶憑證,也沒有 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", "tagGitea", "打並推 tag`git tag v<版本> && git push origin v<版本>`"),
("release", "releaseGitea", "建 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%smain 已到 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:]))