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
361 lines
15 KiB
Python
Executable File
361 lines
15 KiB
Python
Executable File
#!/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 的 token(remote 沒帶憑證,也沒有 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:]))
|