出貨鏈三站上 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
+70
View File
@@ -59,6 +59,76 @@ if MAIN and VER != "未知" and MAIN != VER:
else:
notes.append("️ 這一份比 ISEP main 新(載入 %s main %s)——沒發版的改動只在這台機器上。" % (VER, MAIN))
# ══ ④ 打了 tag 卻沒 release(運送鏈斷在 leo 看得到的那一站之前)═══════════
#
# 🔴 為什麼要在開場報(inkstone/ISEP#67 → comment 6574,總管 2026-09-07 裁定):
# v0.23.0v0.24.0 的 tag 都推了,Gitea Releases 頁卻停在 v0.22.0——
# 交棒給 leo 的「測試位置 releases/tag/v0.24.0」打開是一頁空 tag。
# 建 release 的工具(scripts/release-ship)在 09-05 就寫好了,但**沒有任何東西在
# 下一個 session 開場說「你上一版少了一站」**——於是斷點靠人記得,而人沒記得。
# 判準(與 release-shiprelease-check 同一份:hooks/lib/release_chain.py):
# · Gitea 上最新的 tag,它的 release 在不在 → 不在=「打了 tag 沒 release」
# · main 宣稱的版本(①已經拿到的 MAIN)比最新 tag 新 → 「定版了沒打 tag」
# 兩者都在 ⇒ 一個字都不說(天天念的東西會被學會忽略)。
# 匿名讀(D20)、逾時 6 秒、問不到就閉嘴;只快取「都在」的結果 6 小時,
# 缺件不快取——補完的下一個 session 就該安靜,不該再念 6 小時。
def chain_gap():
"""回 (版本, [(站, 補法)]) 或 None(問不到/沒有 tag/都在)。"""
fx = None
if os.environ.get("ISEP_BEACON_SKIP_NET") == "1":
fxp = os.environ.get("ISEP_BEACON_CHAIN_FIXTURE")
if not fxp:
return None
try:
fx = json.load(open(fxp, encoding="utf-8"))
except Exception:
return None
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import release_chain as rc
except Exception:
return None
ship = os.path.join(ROOT, "scripts", "release-ship")
ship_cmd = ("python3 %s" % ship) if os.path.isfile(ship) else \
"先 `claude plugin update isep@inkstone`(這一份 v%s 還沒有 scripts/release-ship)再跑 `python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/release-ship`" % VER
chain = rc.Chain(fixture=fx, timeout=6)
try:
tags = chain.tags()
if not tags:
return None
latest = max(tags, key=rc.vtuple)
cache = os.path.join(os.environ.get("ISEP_BEACON_CACHE_DIR", "/tmp"), ".isep-chain-ok-" + latest)
# main 定版了、tag 還沒打 ⇒ 缺的是 tag 那一站(MAIN 來自 ①,可能是 6 小時內的快取)
if MAIN and rc.vtuple(MAIN) > rc.vtuple(latest):
g = [(label, fix.format(v=MAIN, ship=ship_cmd)) for k, label, fix in rc.STATIONS if k == "tag"]
return MAIN, g
try:
if fx is None and time.time() - os.path.getmtime(cache) < 6 * 3600:
return None
except Exception:
pass
rels = chain.releases()
g = [(label, fix.format(v=latest.lstrip("v"), ship=ship_cmd))
for k, label, fix in rc.gaps(latest, tags, rels) if k == "release"]
if not g:
try:
open(cache, "w").write(latest)
except Exception:
pass
return None
return latest.lstrip("v"), g
except Exception:
return None
cg = chain_gap()
if cg:
ver_gap, stations = cg
notes.append(
"🔴 **v%s 送到一半:%s**——leo 那頁(Gitea Releases)看不到這一版,對他等於不存在。"
"補:%sinkstone/ISEP#67"
% (ver_gap, "".join("" + label for label, _f in stations),
"".join(fix for _l, fix in stations)))
# ══ ② 專案裡有沒有 ISEP 腳本的舊複本在遮蔽正門 ══════════════════════════
#
# 🔴 實例(inkstone/ISEP#90 ②):`InkStoneCo/scripts/ticket` 是 ISEP `scripts/ticket`
+220
View File
@@ -0,0 +1,220 @@
"""release_chain — ISEP 運送鏈「tag → release」那兩站的**收貨端事實**,一份定義三處用
inkstone/ISEP#67
誰在用:
scripts/release-ship 送貨:把已推的 tag 建成 release(前置條件從這裡問)
scripts/release-check 覆核:逐版問 Gitea「到了嗎」
hooks/lib/beacon_report.py 開場:SessionStart 報「打了 tag 卻沒 release」
🔴 為什麼收成一份:09-05 那版 release-shiprelease-check 各自抄了一套「問 Gitea」
的程式碼;要再讓 SessionStart 也問一次,就是第三份。同一個判準散在三處,
修一份不會帶動另外兩份——`leo21c-write-guard.sh` 那一族就是這樣漂掉的
system-dev/wiki/mistakes.md 2026-09-07)。
🔴 讀一律匿名(D20:讀不帶憑證=自由、不計次)。inkstone/ISEP 是公開 repo
tagsreleases/某個 tag 底下的檔案/兩個 tag 之間的 commit 都不需要 token。
只有 401/403 才退回帶 token 再試一次;token 只有 POST(建 release)才是必要的。
判準是結構不是關鍵字:某一版「送到了沒」=它的 tag 與 release **在不在** Gitea 上。
離線:每個入口各自把 fixture dict 傳進來(鍵:tagsreleasesmanifestcommits),
不打網路。fixture 的形狀在 `Chain.__init__` 的註解。
"""
import base64
import json
import os
import re
import subprocess
import urllib.error
import urllib.request
DEFAULT_HOST = "https://git.uncle6.me"
DEFAULT_REPO = "inkstone/ISEP"
MANIFEST_PATH = ".claude-plugin/plugin.json"
# 運送鏈的三站:鍵、人話、怎麼補(補法的指令由呼叫端決定要不要換成絕對路徑)
STATIONS = [
("dingban", "定版 commit",
"在 main 上 commit 一顆 `定版 v{v}`(= plugin.json 也改成那一版)"),
("tag", "tagGitea",
"打並推 tag`git tag v{v} && git push origin v{v}`"),
("release", "releaseGitea",
"建 release`{ship} v{v}`(這一站就是最常斷的那站)"),
]
class ChainError(Exception):
"""問不到收貨端(網路/HTTP 非 404)。呼叫端自己決定要死還是要閉嘴。"""
def vtuple(v):
nums = re.findall(r"\d+", v or "")
return tuple(int(x) for x in nums[:3]) if nums else (0,)
def tagname(v):
v = (v or "").strip()
return v if v.startswith("v") else "v" + v
def token_from_env_or_remote(host, root=None):
"""建 release 才用得到。環境變數優先;沒有就從 remote URL 裡撈(bootstrap 會帶)。"""
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
if os.environ.get(env):
return os.environ[env]
if not root:
return ""
h = 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 h in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
return ""
class Chain:
"""一個 repo 的運送鏈收貨端。
fixture(離線用)的形狀:
{"tags": ["v0.24.0", ...],
"releases": ["v0.23.0", ...],
"manifest": {"v0.24.0": "0.24.0", ...}, # 那個 tag 底下 plugin.json 的 version
"commits": {"v0.23.0...v0.24.0": ["定版 v0.24.0(…)", "..."], # 兩 tag 之間的 commit 標題
"v0.24.0": [""]}} # 沒有上一個 release 時
"""
def __init__(self, host=None, repo=None, fixture=None, timeout=25, token=None):
self.host = (host or DEFAULT_HOST).rstrip("/")
self.repo = repo or DEFAULT_REPO
self.fx = fixture
self.timeout = timeout
self.token = token
# ── HTTP:匿名優先 ───────────────────────────────────────────────────
def _get(self, path, params=""):
url = "%s/api/v1/repos/%s/%s%s" % (self.host, self.repo, path, params)
headers = {"Cache-Control": "no-cache"} # 09-05 實撞:邊緣快取的舊值被當 live
for attempt in (0, 1):
if attempt == 1:
if not self.token:
raise ChainError("Gitea 對匿名讀回 401403,而這裡沒有 token%s" % url)
headers["Authorization"] = "token " + self.token
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 404:
return None
if e.code in (401, 403) and attempt == 0:
continue
raise ChainError("Gitea %s%s %s" % (e.code, url, e.read().decode()[:200]))
except Exception as e:
raise ChainError("連不上 %s%s" % (url, e))
return None
def _names(self, path, key):
names, page = set(), 1
while True:
rows = self._get(path, "?limit=50&page=%d" % page) or []
for r in rows:
n = r.get(key)
if n:
names.add(n)
if len(rows) < 50:
return names
page += 1
# ── 收貨端事實 ───────────────────────────────────────────────────────
def tags(self):
if self.fx is not None:
return set(self.fx.get("tags") or [])
return self._names("tags", "name")
def releases(self):
if self.fx is not None:
return set(self.fx.get("releases") or [])
return self._names("releases", "tag_name")
def release(self, tag):
"""這個 tag 的 releasedict),沒有就 None。"""
if self.fx is not None:
return {"tag_name": tag, "html_url": "(fixture)"} if tag in self.releases() else None
return self._get("releases/tags/%s" % tag)
def manifest_version_at(self, tag):
"""那個 tag 的樹裡 plugin.json 宣稱的版本(None = tag 不在或檔案不在)。
🔴 這是 release-ship 前置①的對象:**要建 release 的那個 tag 的樹**
不是「現在 checkout 出來的工作樹」——工作樹可能是任何分支,跟那個 tag 無關。
"""
if self.fx is not None:
return (self.fx.get("manifest") or {}).get(tag)
d = self._get("contents/%s" % MANIFEST_PATH, "?ref=%s" % tag)
if not d:
return None
try:
raw = base64.b64decode(d.get("content") or "").decode("utf-8")
return (json.loads(raw) or {}).get("version")
except Exception:
return None
def commits_between(self, base, head):
"""base(可 None)到 head 之間的 commit 標題,**不含 merge commit**,舊在前。"""
key = ("%s...%s" % (base, head)) if base else head
if self.fx is not None:
return list((self.fx.get("commits") or {}).get(key) or [])
if base:
d = self._get("compare/%s...%s" % (base, head)) or {}
rows = d.get("commits") or []
else:
rows = self._get("commits", "?sha=%s&limit=50&stat=false&verification=false&files=false" % head) or []
rows = list(reversed(rows))
out = []
for c in rows:
if len(c.get("parents") or []) > 1:
continue
msg = ((c.get("commit") or {}).get("message") or "").strip()
if msg:
out.append(msg.splitlines()[0])
return out
def tag_commit_message(self, tag):
"""tag 指到的那顆 commit 的完整訊息(取 `定版 vX(人話)` 用)。"""
if self.fx is not None:
for lines in (self.fx.get("commits") or {}).values():
for line in lines:
if "定版" in line and tag.lstrip("v") in line:
return line
return ""
t = self._get("tags/%s" % tag)
sha = ((t or {}).get("commit") or {}).get("sha")
if not sha:
return ""
c = self._get("git/commits/%s" % sha) or {}
return ((c.get("commit") or {}).get("message") or "")
# ── 判準 ─────────────────────────────────────────────────────────────
def latest_release_before(self, ver, releases=None):
rels = releases if releases is not None else self.releases()
older = [t for t in rels if vtuple(t) < vtuple(ver)]
return max(older, key=vtuple) if older else None
def gaps(ver, tags, releases, dingban=True):
"""某一版缺哪幾站 → [(key, label, fix_template)],按運送順序。空 list = 都在。"""
t = tagname(ver)
present = {"dingban": dingban, "tag": t in tags, "release": t in releases}
return [(k, label, fix) for k, label, fix in STATIONS if not present[k]]
def dingban_title(message, ver):
"""從 `定版 vX.Y.Z(一句人話)` 抓括號那句——leo 既有 release 的命名慣例。"""
m = re.search(r"定版\s+v?%s[(]([^)]+)[)]" % re.escape(ver.lstrip("v")), message or "")
return m.group(1).strip() if m else None
+38
View File
@@ -24,6 +24,7 @@ mkplugin() {
> "$d/hooks/hooks.json"
cp "$HOOK" "$d/hooks/$(basename "$HOOK")"
cp "$REAL_HOOKS/lib/beacon_report.py" "$d/hooks/lib/beacon_report.py"
cp "$REAL_HOOKS/lib/release_chain.py" "$d/hooks/lib/release_chain.py"
printf '#!/bin/sh\necho real\n' > "$d/scripts/ticket"
# inkstone/ISEP#122:會自動載入的東西(skillcommandagent)也要造得出來
mkdir -p "$d/skills/demo" "$d/commands" "$d/agents" "$d/docs"
@@ -190,6 +191,43 @@ out=$(run "$P2" "$JS")
check "㉖ ②b 有報告時,那行綠色信標仍在且 JSON 合法" "$out" \
"🟢 ISEP v1.0.0 已載入" "!__BADJSON__"
echo "── ④打了 tag 卻沒 releaseinkstone/ISEP#67 → 6574)──────────────────"
# 🔴 這一組守的是 09-07 那件:v0.23.0v0.24.0 tag 都推了、Releases 頁停在 v0.22.0
# 交棒給 leo 的測試位置打開是一頁空 tag。開場要說「缺哪一站+怎麼補」,都在時安靜。
P4=$(mkplugin 0.24.0); printf '#!/usr/bin/env python3\n' > "$P4/scripts/release-ship"
FX4="$TMP/chain-gap.json"
printf '{"tags":["v0.24.0","v0.23.0"],"releases":["v0.23.0"]}\n' > "$FX4"
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4" run "$P4" "$J")
check "㉗ 最新 tag v0.24.0 沒有 release → 一行說缺哪一站+怎麼補(正本絕對路徑)" "$out" \
"v0.24.0 送到一半" "缺 releaseGitea" "python3 $P4/scripts/release-ship v0.24.0" "inkstone/ISEP#67"
check "㉘ 信標那一行仍在、JSON 合法" "$out" "🟢 ISEP v0.24.0 已載入" "!__BADJSON__"
FX4b="$TMP/chain-ok.json"
printf '{"tags":["v0.24.0","v0.23.0"],"releases":["v0.24.0","v0.23.0"]}\n' > "$FX4b"
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4b" run "$P4" "$J")
check "㉙ tag 與 release 都在 → 一個字都不說(天天念會被學會忽略)" "$out" "!送到一半" "!缺 release"
out=$(run "$P4" "$J")
check "㉚ 問不到收貨端(沒網路、沒 fixture)→ 閉嘴,不猜" "$out" "!送到一半" "🟢 ISEP v0.24.0 已載入"
out=$(ISEP_FAKE_MAIN_VERSION=0.25.0 ISEP_BEACON_CHAIN_FIXTURE="$FX4b" run "$P4" "$J")
check "㉛ main 定版到 0.25.0、Gitea 最新 tag 還是 v0.24.0 → 說缺 tag 站+push tag 的補法" "$out" \
"v0.25.0 送到一半" "缺 tagGitea" "git push origin v0.25.0"
P4o=$(mkplugin 0.21.0) # 載入的這一份還沒有 scripts/release-ship(舊版)
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4" run "$P4o" "$J")
check "㉜ 載入的這一份還沒有 release-ship → 補法先教 plugin update,不印一條不存在的路徑" "$out" \
"v0.24.0 送到一半" "claude plugin update isep@inkstone" "!python3 $P4o/scripts/release-ship"
# 只快取「都在」:缺件那次不留快取,補完的下一個 session 立刻安靜;都在那次留快取
rm -f "$TMP"/.isep-chain-ok-*
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4" run "$P4" "$J")
[ ! -e "$TMP/.isep-chain-ok-v0.24.0" ] && r1=1 || r1=0
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4b" run "$P4" "$J")
[ -e "$TMP/.isep-chain-ok-v0.24.0" ] && r2=1 || r2=0
N=$((N+1)); if [ "$r1$r2" = "11" ]; then printf ' ✅ ㉝ 缺件不快取、都在才快取(補完下一個 session 就安靜)\n'; PASS=$((PASS+1))
else printf ' ❌ ㉝ 快取方向錯了(缺件留了快取=%s/都在沒留=%s)\n' "$((1-r1))" "$((1-r2))"; FAIL=$((FAIL+1)); fi
echo
printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" = 0 ] || exit 1