Files
ISEP/scripts/release-ship
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

361 lines
15 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-ship — 把「已經推上去的 tag」變成「leo 那頁看得到的 release」
inkstone/ISEP#67,對應 arcrun-rag 的 release-record 站)
工單抓到的斷點,一句話:**沒有任何東西會建 release。**
ISEP repo 全樹 grep「建 release」的 API 呼叫 → 0 命中。
後果:v0.22.0 的 tag 早就推了,Gitea Releases 頁卻停在 v0.18.0
⇒ 對 leo 等於這一版不存在(「ISEP 現在是哪一版」只有 Releases 答得出來)。
這支就是補上那一站。它**只做 tag → release** 這一段,不打 tag、不改 plugin.json、
不碰 main —— 打 tag 是 `release-tag-guard.sh` 在管的、總管驗過整個 milestone 之後的動作。
🔴 送之前,三件事「必須在場」才放行(require-something-present,不是關鍵字黑名單):
① 版本一致:`plugin.json` 的 version 這個 tag 的號碼
—— 直接跑既有的 `scripts/check-version-consistency.sh`**不另寫一套判準**。
② tag 真的在 Gitea 上:去問收貨端,不在就不給建 release(先去推 tag)。
③ release note 有內容:沒給 `--notes-file` 就從 `git log <上一個release>..<tag>`
自動生一份**事實**清單(哪幾顆 commit)——leo 可以再改,但不留白。
🔴 送完**自己不算數**:最後回頭跑一次 `release-check`,拿收貨端(Gitea releases 頁)
的事實覆核「真的建起來了」。送的人說成功不算,問收貨的那一端才算
(工單:「問的人不能是送的人」)。
冪等:release 已經在了 ⇒ 報「已存在」、離開碼 0,不重複建、不報錯。
離開碼:0 release 在 Gitea 上了(新建的或本來就在)
2 = 某個前置條件沒滿足(stderr 講出是哪一個+怎麼補)
離線測試:`scripts/test-release-ship.sh`(走 `RELEASE_SHIP_FIXTURE`,不打 Gitea、不建真 release)。
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
HOST = os.environ.get("RELEASE_SHIP_HOST", "https://git.uncle6.me")
REPO = os.environ.get("RELEASE_SHIP_REPO", "inkstone/ISEP")
FIXTURE = os.environ.get("RELEASE_SHIP_FIXTURE", "")
HERE = os.path.dirname(os.path.abspath(__file__))
VER_RE = re.compile(r"^v?(\d+\.\d+\.\d+)$")
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def proj_root():
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 _fixture():
try:
with open(FIXTURE) as f:
return json.load(f)
except Exception:
die("🔴 讀不到 RELEASE_SHIP_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 check_consistency():
if FIXTURE:
fx = _fixture()
return bool(fx.get("consistency_ok", True)), fx.get("consistency_msg", "")
script = os.path.join(proj_root(), "scripts", "check-version-consistency.sh")
if not os.path.isfile(script):
return False, "找不到 scripts/check-version-consistency.sh —— 這不是 ISEP repo"
p = subprocess.run(["sh", script], capture_output=True, text=True, timeout=30)
return p.returncode == 0, (p.stdout + p.stderr).strip()
# ══ 前置條件 ② tag 在不在 Gitea(問收貨端)═════════════════════════════════
def gitea_has_tag(tagname):
if FIXTURE:
return tagname in set(_fixture().get("tags") or [])
tk = token()
if not tk:
die("🔴 拿不到 %s 的 tokenremote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE" % HOST)
url = "%s/api/v1/repos/%s/tags/%s" % (HOST, REPO, tagname)
req = urllib.request.Request(url, headers={"Authorization": "token " + tk})
try:
urllib.request.urlopen(req, timeout=25)
return True
except urllib.error.HTTPError as e:
if e.code == 404:
return False
die("🔴 Gitea %s%s" % (e.code, e.read().decode()[:200]))
except Exception as e:
die("🔴 連不上 %s%s" % (HOST, e))
def gitea_release(tagname):
"""回這個 tag 的 release dict,沒有回 None。"""
if FIXTURE:
rels = _fixture().get("releases") or []
if tagname in rels:
return {"tag_name": tagname, "html_url": "(fixture)"}
return None
tk = token()
url = "%s/api/v1/repos/%s/releases/tags/%s" % (HOST, REPO, tagname)
req = urllib.request.Request(url, headers={"Authorization": "token " + tk})
try:
return json.load(urllib.request.urlopen(req, timeout=25))
except urllib.error.HTTPError as e:
if e.code == 404:
return None
die("🔴 Gitea %s%s" % (e.code, e.read().decode()[:200]))
except Exception as e:
die("🔴 連不上 %s%s" % (HOST, e))
# ══ 前置條件 ③ release note ════════════════════════════════════════════════
def latest_release_before(ver):
"""比 ver 舊、且在 Gitea 上有 release 的最新那一版 tag(拿來當 git log 起點)。"""
if FIXTURE:
rels = _fixture().get("releases") or []
else:
tk = token()
url = "%s/api/v1/repos/%s/releases?limit=50" % (HOST, REPO)
req = urllib.request.Request(url, headers={"Authorization": "token " + tk})
try:
rels = [r.get("tag_name") for r in
json.load(urllib.request.urlopen(req, timeout=25))]
except Exception:
rels = []
def vt(v):
n = re.findall(r"\d+", v or "")
return tuple(int(x) for x in n[:3]) if n else (0,)
older = [t for t in rels if t and vt(t) < vt(ver)]
return max(older, key=vt) if older else None
def dingban_title(ver):
"""這個版本的 `定版 vX.Y.Z(…)` commit 裡括號那句話 —— 拿來當 release 名,
對齊 leo 既有 release 的命名慣例(`vX.Y.Z — 一句人話`)。找不到就回純 tag 名。"""
if FIXTURE:
return None
root = proj_root()
try:
out = subprocess.run(
["git", "-C", root, "log", "--format=%s", "--grep", "定版 v%s" % ver],
capture_output=True, text=True, timeout=30).stdout
except Exception:
out = ""
for line in out.splitlines():
m = re.search(r"定版\s+v%s[(]([^)]+)[)]" % re.escape(ver), line)
if m:
return m.group(1).strip()
return None
def auto_notes(tagname):
"""從 git log 生一份事實 note:上一個 release 之後到這個 tag 之間的 commit。"""
root = proj_root()
since = latest_release_before(tagname)
rng = ("%s..%s" % (since, tagname)) if since else tagname
try:
out = subprocess.run(
["git", "-C", root, "log", "--no-merges", "--format=- %s", rng],
capture_output=True, text=True, timeout=30)
body = out.stdout.strip()
except Exception:
body = ""
head = ("這一版收了以下改動(%s 之後):" % since) if since \
else "這一版收了以下改動:"
if not body:
body = "-git log 取不到 commit 清單,請手動補;release 已建起來,leo 可直接編輯。)"
return ("%s\n\n%s\n\n---\n(本 note 由 scripts/release-ship 從 git log 自動生成,"
"leo/總管可直接在 Gitea 上編輯。inkstone/ISEP#67" % (head, body))
# ══ 真的建 release(POST 收貨端)═══════════════════════════════════════════
def create_release(tagname, notes, dry, name=None):
name = name or tagname
payload = {"tag_name": tagname, "name": name, "body": notes,
"draft": False, "prerelease": False}
if dry:
return None, "🧪 --dry-run:不 POST。要建的 release payload\n%s" % \
json.dumps({"tag_name": tagname, "name": name,
"body_lines": notes.count("\n") + 1}, ensure_ascii=False)
if FIXTURE:
sink = os.environ.get("RELEASE_SHIP_SINK")
if sink:
try:
cur = json.load(open(sink)) if os.path.exists(sink) else []
except Exception:
cur = []
cur.append(tagname)
json.dump(cur, open(sink, "w"))
return {"tag_name": tagname, "html_url": "(fixture-created)"}, "fixture 模式,未打網路)"
tk = token()
if not tk:
die("🔴 拿不到 %s 的 token —— 建 release 是寫入,一定要憑證。" % HOST)
url = "%s/api/v1/repos/%s/releases" % (HOST, REPO)
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), method="POST",
headers={"Authorization": "token " + tk, "Content-Type": "application/json"})
try:
r = json.load(urllib.request.urlopen(req, timeout=40))
return r, "已建立"
except urllib.error.HTTPError as e:
die("🔴 Gitea 建 release 失敗 %s%s" % (e.code, e.read().decode()[:300]))
except Exception as e:
die("🔴 連不上 %s%s" % (HOST, e))
# ══ 送完覆核:回頭問收貨端(不是問自己)══════════════════════════════════
def verify_via_check():
"""跑 release-check,拿收貨端事實覆核。回 (ok, 輸出)。"""
check = os.path.join(HERE, "release-check")
env = dict(os.environ)
if FIXTURE:
# fixture 模式下,把剛「建」進 sink 的 release 併進 release-check 的 fixture 視圖
fx = _fixture()
sink = os.environ.get("RELEASE_SHIP_SINK")
extra = []
if sink and os.path.exists(sink):
try:
extra = json.load(open(sink))
except Exception:
extra = []
merged = dict(fx)
merged["releases"] = sorted(set((fx.get("releases") or []) + extra))
cf = (os.environ.get("RELEASE_SHIP_SINK") or "/tmp/rs") + ".checkfx.json"
json.dump(merged, open(cf, "w"))
env["RELEASE_CHECK_FIXTURE"] = cf
try:
p = subprocess.run(["python3", check], capture_output=True, text=True,
timeout=60, env=env)
return p.returncode == 0, (p.stdout + p.stderr).strip()
except Exception as e:
return False, "跑不動 release-check%s" % e
# ══ 主流程 ════════════════════════════════════════════════════════════════
def ship(arg, notes_file=None, dry=False, do_verify=True):
m = VER_RE.match(arg.strip())
if not m:
die("🔴 版本寫法是 vX.Y.Z 或 X.Y.Z,你給的是:%s" % arg)
ver = m.group(1)
tagname = "v" + ver
out = ["📦 release-ship %s(把 tag 變成 leo 看得到的 release" % tagname, ""]
# 前置 ①
ok, msg = check_consistency()
if not ok:
die("🚫 前置①版本一致性沒過 —— 不給建 release。\n%s\n"
" plugin.json 的 version 要等於 %s。這一步重用 "
"scripts/check-version-consistency.sh 的判準,不另立一套。)" % (msg, tagname))
out.append("✅ 前置① 版本一致(plugin.json %s" % tagname)
# 前置 ②
if not gitea_has_tag(tagname):
die("🚫 前置② Gitea 上沒有 tag %s —— 不給建 release。\n"
" release 是「把已存在的 tag 公開成一頁」,tag 都還沒推,先推:\n"
" git tag %s && git push origin %s\n"
" (推 tag 那一刻 release-tag-guard.sh 會擋下版本對不上的 tag。)"
% (tagname, tagname, tagname))
out.append("✅ 前置② Gitea 上有 tag %s(問過收貨端)" % tagname)
# 冪等
existing = gitea_release(tagname)
if existing:
out.append("️ release %s 本來就在了(%s)—— 不重複建。"
% (tagname, existing.get("html_url") or ""))
code = 0
if do_verify:
vok, vout = verify_via_check()
out.append("")
out.append("── 覆核(release-check,問收貨端)──")
out.append(vout)
code = 0 if vok else 2
return code, "\n".join(out)
# 前置 ③
if notes_file:
with open(notes_file) as f:
notes = f.read()
out.append("✅ 前置③ release note 來自 %s" % notes_file)
else:
notes = auto_notes(tagname)
out.append("✅ 前置③ release note 由 git log 自動生成(leo 可再改)")
# 建(release 名對齊 leo 慣例:vX.Y.Z — 定版 commit 那句人話)
title = dingban_title(ver)
relname = ("%s%s" % (tagname, title)) if title else tagname
rel, how = create_release(tagname, notes, dry, name=relname)
if dry:
out.append(" release 名:%s" % relname)
out.append("")
out.append(how)
return 0, "\n".join(out)
out.append("✅ 已建立 release「%s」(%s" % (relname, (rel or {}).get("html_url") or how))
# 覆核(送完問收貨端)
code = 0
if do_verify:
vok, vout = verify_via_check()
out.append("")
out.append("── 覆核(release-check,問收貨端,不是問自己)──")
out.append(vout)
code = 0 if vok else 2
return code, "\n".join(out)
def main(argv):
arg, notes_file, dry, do_verify = None, None, False, True
i = 0
while i < len(argv):
a = argv[i]
if a in ("-h", "--help"):
print(__doc__)
return 0
elif a == "--notes-file":
i += 1
notes_file = argv[i]
elif a == "--dry-run":
dry = True
elif a == "--no-verify":
do_verify = False
elif a.startswith("-"):
die("不認得的參數:%s--help 看用法)" % a)
else:
arg = a
i += 1
if not arg:
die("用法:release-ship <版本 vX.Y.Z> [--notes-file <檔>] [--dry-run] [--no-verify]")
code, report = ship(arg, notes_file, dry, do_verify)
print(report, file=(sys.stdout if code == 0 else sys.stderr))
return code
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))