出貨鏈三站上 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:
@@ -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.0/v0.24.0 的 tag 都推了,Gitea Releases 頁卻停在 v0.22.0——
|
||||
# 交棒給 leo 的「測試位置 releases/tag/v0.24.0」打開是一頁空 tag。
|
||||
# 建 release 的工具(scripts/release-ship)在 09-05 就寫好了,但**沒有任何東西在
|
||||
# 下一個 session 開場說「你上一版少了一站」**——於是斷點靠人記得,而人沒記得。
|
||||
# 判準(與 release-ship/release-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)看不到這一版,對他等於不存在。"
|
||||
"補:%s(inkstone/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`
|
||||
|
||||
@@ -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-ship/release-check 各自抄了一套「問 Gitea」
|
||||
的程式碼;要再讓 SessionStart 也問一次,就是第三份。同一個判準散在三處,
|
||||
修一份不會帶動另外兩份——`leo21c-write-guard.sh` 那一族就是這樣漂掉的
|
||||
(system-dev/wiki/mistakes.md 2026-09-07)。
|
||||
|
||||
🔴 讀一律匿名(D20:讀不帶憑證=自由、不計次)。inkstone/ISEP 是公開 repo,
|
||||
tags/releases/某個 tag 底下的檔案/兩個 tag 之間的 commit 都不需要 token。
|
||||
只有 401/403 才退回帶 token 再試一次;token 只有 POST(建 release)才是必要的。
|
||||
|
||||
判準是結構不是關鍵字:某一版「送到了沒」=它的 tag 與 release **在不在** Gitea 上。
|
||||
|
||||
離線:每個入口各自把 fixture dict 傳進來(鍵:tags/releases/manifest/commits),
|
||||
不打網路。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", "tag(Gitea)",
|
||||
"打並推 tag:`git tag v{v} && git push origin v{v}`"),
|
||||
("release", "release(Gitea)",
|
||||
"建 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 對匿名讀回 401/403,而這裡沒有 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 的 release(dict),沒有就 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
|
||||
@@ -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:會自動載入的東西(skill/command/agent)也要造得出來
|
||||
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 卻沒 release(inkstone/ISEP#67 → 6574)──────────────────"
|
||||
# 🔴 這一組守的是 09-07 那件:v0.23.0/v0.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 送到一半" "缺 release(Gitea)" "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 送到一半" "缺 tag(Gitea)" "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
|
||||
|
||||
Reference in New Issue
Block a user