出貨鏈三站上 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
This commit is contained in:
claude-code
2026-09-07 05:52:41 +00:00
parent 1fa45cd539
commit fef35ed03a
12 changed files with 717 additions and 332 deletions
+99 -169
View File
@@ -4,24 +4,36 @@
工單抓到的斷點,一句話:**沒有任何東西會建 release。**
ISEP repo 全樹 grep「建 release」的 API 呼叫 → 0 命中。
後果:v0.22.0 的 tag 早就推了,Gitea Releases 頁卻停在 v0.18.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,不是關鍵字黑名單):
版本一致:`plugin.json` 的 version 這個 tag 的號碼
—— 直接跑既有的 `scripts/check-version-consistency.sh`**不另寫一套判準**。
**那個 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` 就從 `git log <上一個release>..<tag>`
自動生一份**事實**清單(哪幾顆 commit)——leo 可以再改,但不留白。
③ release note 有內容:沒給 `--notes-file` 就從 Gitea「上一個 release ⋯ 這個 tag
的 compare 自動生一份**事實**清單(哪幾顆 commit,不含 merge)——leo 可以再改,但不留白。
問 Gitea 而不是本機 git log:這支從 plugin 快取目錄跑時**旁邊沒有 git repo**。
🔴 送完**自己不算數**:最後回頭跑一次 `release-check`,拿收貨端(Gitea releases 頁)
🔴 送完**自己不算數**:最後回頭跑一次 `release-check <這個 tag>`,拿收貨端(Gitea releases 頁)
的事實覆核「真的建起來了」。送的人說成功不算,問收貨的那一端才算
(工單:「問的人不能是送的人」)。
冪等:release 已經在了 ⇒ 報「已存在」、離開碼 0,不重複建、不報錯。
讀全部匿名(D20),只有 POST 建 release 才要 token
`GITEA_TOKEN_CLAUDE_CODE``GITEA_TOKEN`,或這個 repo remote URL 裡帶的那把)。
冪等:release 已經在了 ⇒ 報「本來就在」、離開碼 0,不重複建、不報錯、**不動它的內容**。
離開碼:0 release 在 Gitea 上了(新建的或本來就在)
2 = 某個前置條件沒滿足(stderr 講出是哪一個+怎麼補)
@@ -36,10 +48,17 @@ 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__))
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+)$")
@@ -55,7 +74,9 @@ def proj_root():
return os.path.dirname(HERE)
def _fixture():
def load_fixture():
if not FIXTURE:
return None
try:
with open(FIXTURE) as f:
return json.load(f)
@@ -63,148 +84,53 @@ def _fixture():
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("/")
FX = load_fixture()
CHAIN = rc.Chain(HOST, REPO, fixture=FX)
def ask(fn, *a):
"""問收貨端;問不到就死(不猜)。"""
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 ""
return fn(*a)
except rc.ChainError as e:
die("🔴 問不到收貨端 %s%s" % (HOST, e))
# ══ 前置條件 ① 版本一致(重用既有腳本,不另寫判準)═══════════════════════
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 "這一版收了以下改動:"
# ══ 前置條件 ③ 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 = "-git log 取不到 commit 清單,請手動補;release 已建起來,leo 可直接編輯。)"
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=None):
name = name or tagname
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 FIXTURE:
if FX is not None:
sink = os.environ.get("RELEASE_SHIP_SINK")
if sink:
try:
@@ -214,9 +140,10 @@ def create_release(tagname, notes, dry, name=None):
cur.append(tagname)
json.dump(cur, open(sink, "w"))
return {"tag_name": tagname, "html_url": "(fixture-created)"}, "fixture 模式,未打網路)"
tk = token()
tk = rc.token_from_env_or_remote(HOST, proj_root())
if not tk:
die("🔴 拿不到 %s 的 token —— 建 release 是寫入,一定要憑證。" % HOST)
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",
@@ -231,13 +158,12 @@ def create_release(tagname, notes, dry, name=None):
# ══ 送完覆核:回頭問收貨端(不是問自己)══════════════════════════════════
def verify_via_check():
"""跑 release-check,拿收貨端事實覆核。回 (ok, 輸出)。"""
def verify_via_check(tagname):
"""跑 `release-check <tag>`,拿收貨端事實覆核**這一版**。回 (ok, 輸出)。"""
check = os.path.join(HERE, "release-check")
env = dict(os.environ)
if FIXTURE:
if FX is not None:
# fixture 模式下,把剛「建」進 sink 的 release 併進 release-check 的 fixture 視圖
fx = _fixture()
sink = os.environ.get("RELEASE_SHIP_SINK")
extra = []
if sink and os.path.exists(sink):
@@ -245,13 +171,15 @@ def verify_via_check():
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"
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], capture_output=True, text=True,
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:
@@ -267,50 +195,52 @@ def ship(arg, notes_file=None, dry=False, do_verify=True):
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):
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 = gitea_release(tagname)
existing = ask(CHAIN.release, tagname)
if existing:
out.append("️ release %s 本來就在了(%s)—— 不重複建。"
out.append("️ release %s 本來就在了(%s)—— 不重複建、不動它。"
% (tagname, existing.get("html_url") or ""))
code = 0
if do_verify:
vok, vout = verify_via_check()
vok, vout = verify_via_check(tagname)
out.append("")
out.append("── 覆核(release-check,問收貨端)──")
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)
out.append("✅ 前置③ release note 由 git log 自動生成(leo 可再改)")
notes = auto_notes(tagname, releases)
out.append("✅ 前置③ release note 由 Gitea 的 commit 清單自動生成(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)
relname = release_name(tagname)
rel, how = create_release(tagname, notes, dry, relname)
if dry:
out.append(" release 名:%s" % relname)
out.append("")
@@ -321,9 +251,9 @@ def ship(arg, notes_file=None, dry=False, do_verify=True):
# 覆核(送完問收貨端)
code = 0
if do_verify:
vok, vout = verify_via_check()
vok, vout = verify_via_check(tagname)
out.append("")
out.append("── 覆核(release-check,問收貨端,不是問自己)──")
out.append("── 覆核(release-check %s,問收貨端,不是問自己)──" % tagname)
out.append(vout)
code = 0 if vok else 2
return code, "\n".join(out)