補上運送鏈缺的兩站:建 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
This commit is contained in:
Claude
2026-09-05 21:01:29 +00:00
parent 605f1fe5a6
commit 49dedbbb15
4 changed files with 831 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
#!/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:]))