411 lines
18 KiB
Python
411 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""release-manifest — 一次交貨 = 一份清單(inkstone/ISEP#84)。
|
||
|
||
leo 的問題(票上原文):「一次交貨常常同時動到好幾個 repo,但**沒有任何地方記著
|
||
『這一版 = 哪幾個 repo 的哪幾個版本』**⋯⋯要退版的時候,只退了其中一個 repo,
|
||
剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」
|
||
|
||
release-manifest freeze R-2026.08.30 --part inkstone/Arcrun@v1.4.52 \
|
||
--part inkstone/arcrun-collector@v0.18.36
|
||
release-manifest current 我現在手上是哪一版、由什麼組成
|
||
release-manifest list 全部清單,新的在上面
|
||
release-manifest show R-2026.08.30
|
||
release-manifest verify R-2026.08.30 清單上的版本號現在還抓得到嗎
|
||
release-manifest diff R-2026.08.29 R-2026.08.30
|
||
release-manifest rollback R-2026.08.29 退版:**整份一起退**
|
||
|
||
🔴 三條硬規則,全部長在這支工具裡(不是寫在文件裡靠人記得):
|
||
|
||
① **freeze 之前每一個版本號都真的去 Gitea 抓一次**。抓不到就不給凍結——
|
||
「寫上去」跟「存在」是兩件事,而清單的用處完全建立在後者。
|
||
② **凍結了就不准再加東西**。同名清單已存在 ⇒ 拒絕覆寫,新的需求進下一份。
|
||
③ **退版沒有「只退一個」這個選項**。`rollback` 一定吐出整份清單的每一格;
|
||
指名單一 repo(`--only`)會被擋下,因為那正是票上那個病:
|
||
**剩下的還停在新版 ⇒ 一組從來沒測過的組合。**
|
||
|
||
📌 跟 `release-tag-guard.sh` 是包含關係不是重複:那支管「打 tag 那一刻,
|
||
單一 repo 內版本號三處一致」;這支管它上面那一層——**跨 repo 的組合**。
|
||
|
||
📌 單一 repo 的交貨不必特例:清單就退化成只有一格,規則一字不改。
|
||
|
||
離線測試:`scripts/test-release-manifest.sh`(走 `RELEASE_MANIFEST_FIXTURE`,不打網路)。
|
||
"""
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
HOST = os.environ.get("RELEASE_MANIFEST_HOST", "https://git.uncle6.me")
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
def _resolve_dir():
|
||
"""清單住哪裡。
|
||
|
||
🔴 **不能住在 plugin 目錄裡**:ISEP 是用 marketplace 裝的,
|
||
`claude plugin update` 會把那個目錄整個換掉 ⇒ 所有出貨清單一次消失,
|
||
而清單的用處全部發生在「以後要退版的那一天」。
|
||
順序:環境變數 → 這個 session 的專案根(有 `system-dev/` =總管的工作區,
|
||
跨 repo 的出貨清單本來就屬於那一層)→ 這份原始碼所在 repo → `~/.claude/`。
|
||
"""
|
||
v = os.environ.get("RELEASE_MANIFEST_DIR")
|
||
if v:
|
||
return v
|
||
root = os.path.dirname(HERE)
|
||
for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", root):
|
||
if base and os.path.isdir(os.path.join(base, "system-dev")):
|
||
return os.path.join(base, "releases")
|
||
return os.path.join(os.path.expanduser("~"), ".claude", "isep-releases")
|
||
|
||
|
||
DIR = _resolve_dir()
|
||
FIXTURE = os.environ.get("RELEASE_MANIFEST_FIXTURE", "")
|
||
DRY = False
|
||
|
||
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||
PART_RE = re.compile(r"^([\w.-]+)/([\w.-]+)@(\S+)$")
|
||
|
||
|
||
def die(msg, code=2):
|
||
print(msg, file=sys.stderr)
|
||
sys.exit(code)
|
||
|
||
|
||
def token():
|
||
root = os.environ.get("CLAUDE_PROJECT_DIR") or HERE
|
||
host = 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 host in line:
|
||
m = re.search(r"//[^:/]+:([^@]+)@", line)
|
||
if m:
|
||
return m.group(1)
|
||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||
if os.environ.get(env):
|
||
return os.environ[env]
|
||
return ""
|
||
|
||
|
||
def _fixture():
|
||
try:
|
||
with open(FIXTURE) as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
die("🔴 讀不到 RELEASE_MANIFEST_FIXTURE:%s" % FIXTURE)
|
||
|
||
|
||
def api(path):
|
||
"""GET 一筆。回 dict/list,查不到回 None。fixture 模式完全不碰網路。"""
|
||
if FIXTURE:
|
||
return None
|
||
tk = token()
|
||
if not tk:
|
||
die("🔴 拿不到 %s 的 token(remote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE)" % HOST)
|
||
try:
|
||
req = urllib.request.Request(HOST + "/api/v1" + path,
|
||
headers={"Authorization": "token " + tk})
|
||
return json.load(urllib.request.urlopen(req, timeout=25))
|
||
except urllib.error.HTTPError:
|
||
return None
|
||
except Exception as e:
|
||
die("🔴 連不上 %s:%s" % (HOST, e))
|
||
|
||
|
||
def resolve(owner, repo, ver):
|
||
"""這個版本號**真的存在嗎**。存在回一段可以貼給人看的證據,不存在回 None。
|
||
|
||
先問 release,再問 tag:有些 repo 只打 tag 沒建 release,那也算存在
|
||
(票上要的是「抓得到那個 release」=抓得到那個版本,不是逼每個 repo 都建 release 頁)。
|
||
"""
|
||
if FIXTURE:
|
||
fx = _fixture().get("versions") or {}
|
||
return fx.get("%s/%s@%s" % (owner, repo, ver))
|
||
r = api("/repos/%s/%s/releases/tags/%s" % (owner, repo, ver))
|
||
if isinstance(r, dict) and r.get("tag_name"):
|
||
return {"kind": "release", "url": r.get("html_url") or "",
|
||
"commit": (r.get("target_commitish") or "")[:12]}
|
||
t = api("/repos/%s/%s/tags/%s" % (owner, repo, ver))
|
||
if isinstance(t, dict) and t.get("name"):
|
||
return {"kind": "tag", "url": (t.get("commit") or {}).get("url") or "",
|
||
"commit": ((t.get("commit") or {}).get("sha") or "")[:12]}
|
||
return None
|
||
|
||
|
||
# ── 清單的存取 ────────────────────────────────────────────────────────────
|
||
def path_of(name):
|
||
return os.path.join(DIR, name + ".json")
|
||
|
||
|
||
def load(name):
|
||
try:
|
||
with open(path_of(name)) as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def all_manifests():
|
||
try:
|
||
names = [f[:-5] for f in os.listdir(DIR) if f.endswith(".json")]
|
||
except Exception:
|
||
return []
|
||
rows = [load(n) for n in names]
|
||
rows = [m for m in rows if m]
|
||
# 🔴 排序鍵不能只用時間戳:同一分鐘凍兩份清單就會並列,
|
||
# 而並列之後「現在手上的是哪一份」會變成 os.listdir 的順序(等於隨機)。
|
||
# seq 是 freeze 當下遞增的流水號 ⇒ 先後永遠有唯一解。
|
||
rows.sort(key=lambda m: (m.get("seq", 0), m.get("frozen_at") or ""), reverse=True)
|
||
return rows
|
||
|
||
|
||
def parse_part(s):
|
||
m = PART_RE.match(s.strip())
|
||
if not m:
|
||
die("🔴 寫法是 owner/repo@版本號,你給的是:%s" % s)
|
||
return m.group(1), m.group(2), m.group(3)
|
||
|
||
|
||
def opts(argv, name):
|
||
return [argv[i + 1] for i, a in enumerate(argv) if a == name and i + 1 < len(argv)]
|
||
|
||
|
||
def opt(argv, name, default=None):
|
||
v = opts(argv, name)
|
||
return v[0] if v else default
|
||
|
||
|
||
# ── INDEX.md:leo 打開就看得懂「我現在手上是哪一版」 ──────────────────────
|
||
def write_index():
|
||
rows = all_manifests()
|
||
lines = ["# 出貨清單(哪一版由哪幾個 repo 的哪幾版組成)", "",
|
||
"> 這份檔案由 `scripts/release-manifest` 自動重寫,**不要手改**"
|
||
"(手改的那一份會安靜地騙人)。", "",
|
||
"- 每一列 = 一次交貨。**最上面那一份就是現在手上的這一版。**"]
|
||
if not rows:
|
||
lines.append("\t- 目前還沒有任何凍結過的清單。")
|
||
for i, m in enumerate(rows):
|
||
head = "**%s**%s" % (m["name"], " ← 現在手上的這一版" if i == 0 else "")
|
||
lines.append("\t- " + head)
|
||
lines.append("\t\t- 凍結於 %s(UTC)" % m.get("frozen_at", "?"))
|
||
if m.get("note"):
|
||
lines.append("\t\t- 說明:%s" % m["note"])
|
||
lines.append("\t\t- 由這些組成(沒有更動的 repo 不會出現在這裡)")
|
||
for p in m.get("parts", []):
|
||
ev = p.get("verified") or {}
|
||
lines.append("\t\t\t- `%s` @ **%s** (凍結當下驗過:%s %s)"
|
||
% (p["repo"], p["version"], ev.get("kind", "?"),
|
||
ev.get("commit", "")))
|
||
lines.append("\t\t- 要退回這一版:`scripts/release-manifest rollback %s`" % m["name"])
|
||
lines.append("")
|
||
lines.append("- 為什麼要有這份東西(`inkstone/ISEP#84`)")
|
||
lines.append("\t- 退版只退了其中一個 repo,剩下的還停在新版"
|
||
" ⇒ **變成一組從來沒測過的組合**。")
|
||
lines.append("\t- 所以送測、放行、出貨、退版**全部以一份清單為單位**,"
|
||
"不以單一 repo 為單位。")
|
||
try:
|
||
os.makedirs(DIR, exist_ok=True)
|
||
with open(os.path.join(DIR, "INDEX.md"), "w") as f:
|
||
f.write("\n".join(lines) + "\n")
|
||
except Exception as e:
|
||
die("🔴 寫不進 %s:%s" % (DIR, e))
|
||
|
||
|
||
# ── freeze ───────────────────────────────────────────────────────────────
|
||
def cmd_freeze(argv):
|
||
if not argv:
|
||
die("用法:release-manifest freeze <清單名> --part owner/repo@版本 [--part …] "
|
||
"[--note 說明] [--dry-run]")
|
||
name = argv[0]
|
||
if not NAME_RE.match(name):
|
||
die("🔴 清單名只准英數與 . _ -(例:R-2026.08.30),你給的是:%s" % name)
|
||
raw = opts(argv[1:], "--part")
|
||
if not raw:
|
||
die("🚫 一份清單至少要有一格 --part owner/repo@版本號。\n"
|
||
" (單一 repo 的交貨也要開清單——它只是退化成一格,規則不變。)")
|
||
|
||
if load(name) is not None:
|
||
die("🚫 %s 已經凍結過了,不准再加東西。\n"
|
||
" 【inkstone/ISEP#84】「清單一旦凍結就不再加東西,**新的需求進下一版**。」\n"
|
||
" 要出新的組合 ⇒ 換一個清單名重新 freeze(舊的留著,退版時要用)。" % name)
|
||
|
||
seen, parts = set(), []
|
||
for s in raw:
|
||
owner, repo, ver = parse_part(s)
|
||
full = "%s/%s" % (owner, repo)
|
||
if full in seen:
|
||
die("🔴 同一個 repo 在清單裡出現兩次:%s" % full)
|
||
seen.add(full)
|
||
ev = resolve(owner, repo, ver)
|
||
if not ev:
|
||
die("🚫 %s@%s **在 Gitea 上抓不到**——不給凍結。\n"
|
||
" 【inkstone/ISEP#84 驗收 1】「清單上的版本號**真的存在**"
|
||
"(不是寫上去而已,要抓得到那個 release)」。\n"
|
||
" 先把那個 repo 的版本打出來(tag/release),再回來 freeze。"
|
||
% (full, ver))
|
||
parts.append({"repo": full, "version": ver, "verified": ev})
|
||
|
||
nxt = max([m.get("seq", 0) for m in all_manifests()] or [0]) + 1
|
||
man = {"name": name, "seq": nxt,
|
||
"frozen_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||
"note": opt(argv[1:], "--note", "") or "",
|
||
"parts": parts}
|
||
|
||
if DRY:
|
||
print("[dry-run] 會寫進 %s" % path_of(name))
|
||
print(json.dumps(man, ensure_ascii=False, indent=2))
|
||
print("[dry-run] 一個檔都沒有真的寫出去。")
|
||
return
|
||
os.makedirs(DIR, exist_ok=True)
|
||
with open(path_of(name), "w") as f:
|
||
json.dump(man, f, ensure_ascii=False, indent=2)
|
||
write_index()
|
||
print("✅ 清單 %s 已凍結(%d 個 repo,每一格都驗過真的抓得到)" % (name, len(parts)))
|
||
for p in parts:
|
||
print(" %-34s %s" % (p["repo"], p["version"]))
|
||
print("\n📌 從現在起:送測/放行/出貨/退版都以 %s 為單位,不以單一 repo 為單位。" % name)
|
||
print(" 人看的版本:%s" % os.path.join(DIR, "INDEX.md"))
|
||
|
||
|
||
# ── verify / show / list / current / diff ────────────────────────────────
|
||
def cmd_verify(argv):
|
||
if not argv:
|
||
die("用法:release-manifest verify <清單名>")
|
||
man = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0])
|
||
bad = 0
|
||
for p in man["parts"]:
|
||
owner, repo = p["repo"].split("/", 1)
|
||
ev = resolve(owner, repo, p["version"])
|
||
print("%s %-34s %s" % ("✅" if ev else "❌", p["repo"], p["version"]))
|
||
if not ev:
|
||
bad += 1
|
||
if bad:
|
||
die("\n🔴 %d 格抓不到了——這份清單現在**退不回去**,"
|
||
"要嘛版本被刪了,要嘛當初就沒真的存在。" % bad)
|
||
print("\n✅ %s 全部 %d 格都還抓得到,可以拿它退版。" % (man["name"], len(man["parts"])))
|
||
|
||
|
||
def cmd_show(argv):
|
||
if not argv:
|
||
die("用法:release-manifest show <清單名>")
|
||
man = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0])
|
||
print(json.dumps(man, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def cmd_list(argv):
|
||
rows = all_manifests()
|
||
if not rows:
|
||
print("(還沒有任何凍結過的清單。第一份用 release-manifest freeze 開。)")
|
||
return
|
||
for i, m in enumerate(rows):
|
||
print("%s %-18s %s %d 個 repo:%s"
|
||
% ("👉" if i == 0 else " ", m["name"], m.get("frozen_at", "?"),
|
||
len(m.get("parts", [])),
|
||
" + ".join("%s@%s" % (p["repo"].split("/")[-1], p["version"])
|
||
for p in m.get("parts", []))))
|
||
print("\n👉 = 現在手上的這一版。人看的版本:%s" % os.path.join(DIR, "INDEX.md"))
|
||
|
||
|
||
def cmd_current(argv):
|
||
rows = all_manifests()
|
||
if not rows:
|
||
die("🔴 一份清單都沒有——「我現在手上是哪一版」目前沒有人答得出來。", 1)
|
||
m = rows[0]
|
||
print("現在手上的這一版:%s(凍結於 %s)" % (m["name"], m.get("frozen_at", "?")))
|
||
for p in m["parts"]:
|
||
print(" %-34s %s" % (p["repo"], p["version"]))
|
||
|
||
|
||
def cmd_diff(argv):
|
||
if len(argv) < 2:
|
||
die("用法:release-manifest diff <舊清單> <新清單>")
|
||
a = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0])
|
||
b = load(argv[1]) or die("🔴 沒有這份清單:%s" % argv[1])
|
||
va = {p["repo"]: p["version"] for p in a["parts"]}
|
||
vb = {p["repo"]: p["version"] for p in b["parts"]}
|
||
for repo in sorted(set(va) | set(vb)):
|
||
x, y = va.get(repo), vb.get(repo)
|
||
if x == y:
|
||
print(" = %-34s %s" % (repo, x))
|
||
elif x is None:
|
||
print(" + %-34s %s(新進來的)" % (repo, y))
|
||
elif y is None:
|
||
print(" - %-34s %s(新清單裡沒有了)" % (repo, x))
|
||
else:
|
||
print(" ~ %-34s %s → %s" % (repo, x, y))
|
||
|
||
|
||
# ── rollback:整份一起退,沒有「只退一個」這個選項 ────────────────────────
|
||
def cmd_rollback(argv):
|
||
if not argv:
|
||
die("用法:release-manifest rollback <清單名> [--dry-run]")
|
||
name = argv[0]
|
||
man = load(name) or die("🔴 沒有這份清單:%s" % name)
|
||
|
||
only = opts(argv[1:], "--only")
|
||
if only:
|
||
die("🚫 **退版沒有「只退一個」這個選項。**(你指名了:%s)\n\n"
|
||
" 【inkstone/ISEP#84】「要退版的時候,只退了其中一個 repo,\n"
|
||
" 剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」\n\n"
|
||
" %s 這一版由 %d 個 repo 組成:\n%s\n"
|
||
" ⇒ 拿掉 --only,整份一起退:release-manifest rollback %s\n"
|
||
" ⇒ 真的只有一個 repo 要動 ⇒ 那就 freeze 一份**新的**清單,"
|
||
"把不動的那幾格照抄過去。"
|
||
% (", ".join(only), name, len(man["parts"]),
|
||
"\n".join(" %s @ %s" % (p["repo"], p["version"])
|
||
for p in man["parts"]), name))
|
||
|
||
cur = all_manifests()
|
||
curv = {p["repo"]: p["version"] for p in cur[0]["parts"]} if cur else {}
|
||
curname = cur[0]["name"] if cur else "(沒有)"
|
||
|
||
print("退版目標:%s(現在手上的是 %s)\n" % (name, curname))
|
||
moves, stays = [], []
|
||
for p in man["parts"]:
|
||
(stays if curv.get(p["repo"]) == p["version"] else moves).append(p)
|
||
|
||
print("【要動的】")
|
||
if not moves:
|
||
print(" (沒有——現在手上的組合已經等於這份清單)")
|
||
for p in moves:
|
||
owner_repo = p["repo"]
|
||
print(" %-34s %s → %s" % (owner_repo, curv.get(owner_repo, "?"), p["version"]))
|
||
print(" git -C <%s 的工作目錄> fetch --tags && "
|
||
"git -C <同上> checkout %s" % (owner_repo.split("/")[-1], p["version"]))
|
||
if stays:
|
||
print("\n【已經在目標版本、不必動】(列出來是為了讓你確認**沒有一格被漏掉**)")
|
||
for p in stays:
|
||
print(" %-34s %s" % (p["repo"], p["version"]))
|
||
|
||
print("\n🔴 這 %d 格是一組,要嘛全部到位,要嘛就不算退版完成。"
|
||
% len(man["parts"]))
|
||
print(" 退完跑一次複驗:release-manifest verify %s" % name)
|
||
|
||
|
||
def main():
|
||
global DRY
|
||
argv = sys.argv[1:]
|
||
if "--dry-run" in argv:
|
||
DRY = True
|
||
argv = [a for a in argv if a != "--dry-run"]
|
||
if not argv:
|
||
print(__doc__)
|
||
return
|
||
cmd, rest = argv[0], argv[1:]
|
||
table = {"freeze": cmd_freeze, "verify": cmd_verify, "show": cmd_show,
|
||
"list": cmd_list, "current": cmd_current, "diff": cmd_diff,
|
||
"rollback": cmd_rollback}
|
||
if cmd not in table:
|
||
print(__doc__)
|
||
die("🔴 沒有這個子指令:%s" % cmd)
|
||
table[cmd](rest)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|