fef35ed03a
- 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
221 lines
9.5 KiB
Python
221 lines
9.5 KiB
Python
"""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
|