#!/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>..` 自動生一份**事實**清單(哪幾顆 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:]))