Files
ISEP/scripts/release-ship
claude-code fef35ed03a 出貨鏈三站上 main:release-ship 前置①改看 tag 的樹、release-check 可指定版本、信標開場報「打了 tag 沒 release」(inkstone/ISEP#67)
- hooks/lib/release_chain.py:問 Gitea 的判準一份三處用(匿名讀,D20)
- scripts/release-ship:前置①比「要建的那個 tag 的樹」裡的 plugin.json,不比工作樹/最新 tag
  (總管 09-07 在 v0.23.0 被擋的情境現在放行);note 從 Gitea compare 生,從 plugin 快取跑也行;
  覆核改跑 release-check <那個 tag>
- scripts/release-check [vX.Y.Z]:指定版本;v0.19/0.20/0.21 依裁決標「不補」不再黃字;補法印正本絕對路徑
- hooks/lib/beacon_report.py ④:最新 tag 沒 release/main 定版沒打 tag ⇒ 一行缺站+補法;都在安靜;
  只快取「都在」6 小時
- 測試:test-release-ship 14、test-release-check 14、信標 26→33;docs:TESTING A37/A38、盤點表、README 60 支腳本、
  白名單加 release-ship/release-check 形狀、mistakes 一條
- 沒改 plugin.json 版本號(待總管定版)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 05:52:41 +00:00

291 lines
13 KiB
Python
Executable File
Raw Permalink 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 命中。
後果:tag 早就推了,Gitea Releases 頁卻停在舊版
⇒ 對 leo 等於這一版不存在(「ISEP 現在是哪一版」只有 Releases 答得出來)。
08-27 v0.4.0v0.5.0、09-02 v0.22.0、09-07 v0.23.0v0.24.0——同一站斷了三輪。
這支就是補上那一站。它**只做 tag → release** 這一段,不打 tag、不改 plugin.json、
不碰 main —— 打 tag 是 `release-tag-guard.sh` 在管的、總管驗過整個 milestone 之後的動作。
🔴 送之前,三件事「必須在場」才放行(require-something-present,不是關鍵字黑名單):
① **那個 tag 的樹**裡 `plugin.json` 的 version 這個 tag 的號碼。
比對的對象是 tag 自己的樹(問 Gitea `contents/…?ref=<tag>`),**不是工作樹**。
🔴 09-05 那版拿 `check-version-consistency.sh`(工作樹 plugin.json vs 本機最新 tag
當前置①,結果對「不是最新的那個 tag」永遠建不了:總管在 v0.23.0 的 checkout
跑 `release-ship v0.23.0` 被擋,訊息說最新 tag 是 v0.24.0——而 v0.23.0 的樹
明明就一致。**要送的東西是 tag,判它的就該看它的樹**;工作樹可能是任何分支
(這支本身就是在一條 feature 分支上寫的),跟那個 tag 沒有關係。
`check-version-consistency.sh` 守的是「main 上 plugin.json 跟最新 tag 一致」,
那是打 tag 那一刻的事(release-tag-guard.sh),不是建 release 這一刻的事。
② tag 真的在 Gitea 上:去問收貨端,不在就不給建 release(先去推 tag)。
③ release note 有內容:沒給 `--notes-file` 就從 Gitea「上一個 release ⋯ 這個 tag」
的 compare 自動生一份**事實**清單(哪幾顆 commit,不含 merge)——leo 可以再改,但不留白。
問 Gitea 而不是本機 git log:這支從 plugin 快取目錄跑時**旁邊沒有 git repo**。
🔴 送完**自己不算數**:最後回頭跑一次 `release-check <這個 tag>`,拿收貨端(Gitea releases 頁)
的事實覆核「真的建起來了」。送的人說成功不算,問收貨的那一端才算
(工單:「問的人不能是送的人」)。
讀全部匿名(D20),只有 POST 建 release 才要 token
`GITEA_TOKEN_CLAUDE_CODE``GITEA_TOKEN`,或這個 repo remote URL 裡帶的那把)。
冪等: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
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "hooks", "lib"))
try:
import release_chain as rc
except Exception as e: # pragma: no cover
print("🔴 讀不到 hooks/lib/release_chain.py%s" % e, file=sys.stderr)
sys.exit(2)
HOST = os.environ.get("RELEASE_SHIP_HOST", rc.DEFAULT_HOST)
REPO = os.environ.get("RELEASE_SHIP_REPO", rc.DEFAULT_REPO)
FIXTURE = os.environ.get("RELEASE_SHIP_FIXTURE", "")
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 load_fixture():
if not FIXTURE:
return None
try:
with open(FIXTURE) as f:
return json.load(f)
except Exception:
die("🔴 讀不到 RELEASE_SHIP_FIXTURE%s" % FIXTURE)
FX = load_fixture()
CHAIN = rc.Chain(HOST, REPO, fixture=FX)
def ask(fn, *a):
"""問收貨端;問不到就死(不猜)。"""
try:
return fn(*a)
except rc.ChainError as e:
die("🔴 問不到收貨端 %s%s" % (HOST, e))
# ══ 前置條件 ③ release note ════════════════════════════════════════════
def auto_notes(tagname, releases):
"""從 Gitea compare 生一份事實 note:上一個 release 之後到這個 tag 之間的 commit。"""
since = CHAIN.latest_release_before(tagname, releases)
subjects = ask(CHAIN.commits_between, since, tagname)
head = ("這一版收了以下改動(%s 之後):" % since) if since else "這一版收了以下改動:"
body = "\n".join("- %s" % s for s in subjects)
if not body:
body = "-Gitea 取不到 commit 清單,請手動補;release 已建起來,leo 可直接編輯。)"
return ("%s\n\n%s\n\n---\n(本 note 由 scripts/release-ship 從 git log 自動生成,"
"leo/總管可直接在 Gitea 上編輯。inkstone/ISEP#67" % (head, body))
def release_name(tagname):
"""`vX.Y.Z — 定版 commit 括號那句人話`,對齊 leo 既有 release 的命名慣例;找不到就純 tag 名。"""
ver = tagname.lstrip("v")
msg = ask(CHAIN.tag_commit_message, tagname)
title = rc.dingban_title(msg, ver)
if not title:
for s in ask(CHAIN.commits_between, None, tagname)[-20:]:
title = rc.dingban_title(s, ver)
if title:
break
return ("%s%s" % (tagname, title)) if title else tagname
# ══ 真的建 release(POST 收貨端)═══════════════════════════════════════════
def create_release(tagname, notes, dry, name):
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 FX is not None:
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 = rc.token_from_env_or_remote(HOST, proj_root())
if not tk:
die("🔴 拿不到 %s 的 token —— 建 release 是寫入,一定要憑證"
"GITEA_TOKEN_CLAUDE_CODE,或這個 repo 的 remote URL 帶憑證)。" % 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(tagname):
"""跑 `release-check <tag>`,拿收貨端事實覆核**這一版**。回 (ok, 輸出)。"""
check = os.path.join(HERE, "release-check")
env = dict(os.environ)
if FX is not None:
# fixture 模式下,把剛「建」進 sink 的 release 併進 release-check 的 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 = (sink or "/tmp/rs") + ".checkfx.json"
json.dump(merged, open(cf, "w"))
env["RELEASE_CHECK_FIXTURE"] = cf
env.setdefault("RELEASE_CHECK_HOST", HOST)
env.setdefault("RELEASE_CHECK_REPO", REPO)
try:
p = subprocess.run(["python3", check, tagname], 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, ""]
tags = ask(CHAIN.tags)
# 前置 ②(先問 tag 在不在——①要讀它的樹,tag 不在就沒有樹可讀)
if tagname not in tags:
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))
# 前置 ①(看 tag 自己的樹,不看工作樹)
at_tag = ask(CHAIN.manifest_version_at, tagname)
if at_tag != ver:
die("🚫 前置① tag %s 的樹裡 plugin.json 寫的是「%s」—— 不給建 release。\n"
" 建 release 是把這個 tag 公開給 leo 裝;tag 的樹自己就講錯版本,裝了會各說各話。\n"
" 這種 tag 不該存在(release-tag-guard.sh 在推 tag 那一刻就會擋),\n"
" 修法:刪掉這個 tag、在 main 上把 plugin.json 改成 %s 重新定版、再打一次 tag。"
% (tagname, at_tag or "(讀不到)", ver))
out.append("✅ 前置② Gitea 上有 tag %s(問過收貨端)" % tagname)
out.append("✅ 前置① tag %s 的樹裡 plugin.json %s(看的是 tag 的樹,不是工作樹)" % (tagname, ver))
# 冪等
existing = ask(CHAIN.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(tagname)
out.append("")
out.append("── 覆核(release-check %s,問收貨端)──" % tagname)
out.append(vout)
code = 0 if vok else 2
return code, "\n".join(out)
# 前置 ③
releases = ask(CHAIN.releases)
if notes_file:
with open(notes_file) as f:
notes = f.read()
out.append("✅ 前置③ release note 來自 %s" % notes_file)
else:
notes = auto_notes(tagname, releases)
out.append("✅ 前置③ release note 由 Gitea 的 commit 清單自動生成(leo 可再改)")
# 建(release 名對齊 leo 慣例:vX.Y.Z — 定版 commit 那句人話)
relname = release_name(tagname)
rel, how = create_release(tagname, notes, dry, 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(tagname)
out.append("")
out.append("── 覆核(release-check %s,問收貨端,不是問自己)──" % tagname)
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:]))