diff --git a/scripts/release-check b/scripts/release-check new file mode 100755 index 0000000..68a10f7 --- /dev/null +++ b/scripts/release-check @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""release-check — 回頭問「這一版真的送到了嗎」,而且**問的人不是送的人** + (inkstone/ISEP#67) + +leo 2026-08-27 的病(票上原文): + 「版本只到 ISEP v0.3.9,你有新版為什麼不推?」 + v0.4.0/v0.5.0 的 tag 早就推了,但 Gitea Releases 頁停在 v0.3.9 + ⇒ 對 leo 等於這兩版不存在;claude plugin list 也還顯示舊版。 + +ISEP 的運送鏈有這幾段,**每一段都要有人回頭問「到了嗎」**: + + 定版 commit → tag(Gitea) → release(Gitea) → main 的 plugin.json + → 這個 session 真的載入到的版本 + +🔴 這支的全部價值在一句話:**它不聽上一站說「我送了」,它自己去問收貨的那一端。** + tag 在不在 → 問 Gitea 的 tags;release 在不在 → 問 Gitea 的 releases(leo 會看的那頁); + 載入到了沒 → 看這個 session 真的掛著的那一份 plugin.json。 + 送的人(release-ship)說「成功」不算數——這支拿收貨端的事實覆核。 + +判準是**結構**,不是關鍵字(leo 2026-08-17 已證偽關鍵字黑名單:8 次誤攔、0 次正確): + · 哪些版本「應該」被送到 = main 上有 `定版 vX.Y.Z` commit 的那些(git 說了算,不是猜) + · 那個版本「送到了沒」= 去 Gitea 問它的 tag 與 release 在不在(在=到了) + +離開碼: + 0 現行版本(plugin.json 宣稱的那一版)**tag 與 release 都在** ⇒ 送貨端完成 + 2 現行版本缺 tag 或缺 release ⇒ **送貨端斷了**,stderr 講出缺哪一站+怎麼補 + (這正是工單驗收 4:「故意漏一步 → 管線要擋下來並說出缺哪一站」) + 說明:比現行版本更舊的 `定版` 版本若缺 tag/release,只**黃字提醒**、不改離開碼—— + 它們多半已被新版取代,硬擋等於「懲罰謹慎」(over-block 比 under-block 嚴重)。 + 載入端落後(這個 session 跑的比 main 舊)也只提醒:那要 update+重啟, + 是收貨端的動作,不是送貨端能修的,且 SessionStart 信標本來就一直在報。 + +離線測試:`scripts/test-release-check.sh`(走 `RELEASE_CHECK_FIXTURE`,不打 Gitea)。 +""" +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +HOST = os.environ.get("RELEASE_CHECK_HOST", "https://git.uncle6.me") +REPO = os.environ.get("RELEASE_CHECK_REPO", "inkstone/ISEP") +FIXTURE = os.environ.get("RELEASE_CHECK_FIXTURE", "") +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def die(msg, code=2): + print(msg, file=sys.stderr) + sys.exit(code) + + +def proj_root(): + """這支腳本操作的那個 ISEP repo 的根。""" + v = os.environ.get("CLAUDE_PROJECT_DIR") + if v and os.path.isfile(os.path.join(v, ".claude-plugin", "plugin.json")): + return v + return os.path.dirname(HERE) + + +def vtuple(v): + nums = re.findall(r"\d+", v or "") + return tuple(int(x) for x in nums[:3]) if nums else (0,) + + +# ══ 收貨端事實:全部從「別人」那裡拿,不聽送的人說 ═══════════════════════ +def _fixture(): + try: + with open(FIXTURE) as f: + return json.load(f) + except Exception: + die("🔴 讀不到 RELEASE_CHECK_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("/") + 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 "" + + +def _api_names(path, key): + """回一個 set:Gitea 上那個 collection 裡所有的名字(tag_name/name)。""" + tk = token() + if not tk: + die("🔴 拿不到 %s 的 token(remote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE)" % HOST) + names, page = set(), 1 + while True: + url = "%s/api/v1/repos/%s/%s?limit=50&page=%d" % (HOST, REPO, path, page) + req = urllib.request.Request(url, headers={"Authorization": "token " + tk}) + try: + rows = json.load(urllib.request.urlopen(req, timeout=25)) + except urllib.error.HTTPError as e: + die("🔴 Gitea %s:%s" % (e.code, e.read().decode()[:200])) + except Exception as e: + die("🔴 連不上 %s:%s" % (HOST, e)) + if not rows: + break + for r in rows: + n = r.get(key) + if n: + names.add(n) + if len(rows) < 50: + break + page += 1 + return names + + +def gitea_tags(): + if FIXTURE: + return set(_fixture().get("tags") or []) + return _api_names("tags", "name") + + +def gitea_releases(): + if FIXTURE: + return set(_fixture().get("releases") or []) + return _api_names("releases", "tag_name") + + +# ══ 送貨端事實:main 上「定版」了哪些、現在宣稱哪一版、這個 session 載到哪一版 ══ +def dingban_versions(): + """main 上有 `定版 vX.Y.Z` commit 的版本 —— 「應該被送到」的權威清單。""" + if FIXTURE: + return list(_fixture().get("dingban") or []) + root = proj_root() + try: + out = subprocess.run( + ["git", "-C", root, "log", "--format=%s", "--grep", "定版 v"], + capture_output=True, text=True, timeout=30).stdout + except Exception: + out = "" + seen, vers = set(), [] + for line in out.splitlines(): + m = re.search(r"定版\s+v?(\d+\.\d+\.\d+)", line) + if m and m.group(1) not in seen: + seen.add(m.group(1)) + vers.append(m.group(1)) + return vers + + +def current_version(): + """plugin.json 現在宣稱的那一版(= main 要送出去的那一版)。""" + if FIXTURE: + return _fixture().get("current") or "" + try: + with open(os.path.join(proj_root(), ".claude-plugin", "plugin.json")) as f: + return json.load(f).get("version") or "" + except Exception: + return "" + + +def loaded_version(): + """這個 session 真的掛著的那一份 plugin 是哪一版(收貨端最後一段)。 + + 拿不到就回 None —— 不猜、不謊稱到了(house style:不知道就說不知道)。 + """ + if FIXTURE: + return _fixture().get("loaded") + root = os.environ.get("CLAUDE_PLUGIN_ROOT") + if not root: + return None + try: + with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f: + return json.load(f).get("version") + except Exception: + return None + + +# ══ 覆核:逐版問「到了嗎」 ════════════════════════════════════════════════ +def audit(): + cur = current_version() + if not cur: + die("🔴 讀不到 .claude-plugin/plugin.json 的 version —— 這不是 ISEP repo,或檔案壞了。") + dingban = dingban_versions() + if cur not in dingban: + # 現行版本連 `定版` commit 都沒有 ⇒ plugin.json 宣稱了一個沒被定版的號碼。 + # 這是 check-version-consistency 那條線的病,不是這支的守備範圍——但要講出來。 + dingban = [cur] + dingban + tags = gitea_tags() + rels = gitea_releases() + + def tagname(v): + return "v" + v + + rows = [] + for v in sorted(set(dingban), key=vtuple, reverse=True): + rows.append({ + "ver": v, + "dingban": True, # 這份清單本來就是從 定版 commit 來的 + "tag": tagname(v) in tags, + "release": tagname(v) in rels, + "current": v == cur, + }) + return cur, rows + + +STATIONS = [ + ("dingban", "定版 commit", "在 main 上 commit 一顆 `定版 v<版本>`(= plugin.json 也改成那一版)"), + ("tag", "tag(Gitea)", "打並推 tag:`git tag v<版本> && git push origin v<版本>`"), + ("release", "release(Gitea)", "建 release:`scripts/release-ship v<版本>`(這一站就是最常斷的那站)"), +] + + +def report(): + cur, rows = audit() + loaded = loaded_version() + icon = {True: "✅", False: "❌"} + lines = ["ISEP 運送鏈覆核(收貨端事實,不聽送的人說) repo=%s" % REPO, ""] + lines.append(" 版本 定版 tag release (← =現行 plugin.json 宣稱的版本)") + for r in rows: + mark = " ←" if r["current"] else "" + lines.append(" %-10s %s %s %s%s" % ( + "v" + r["ver"], icon[r["dingban"]], icon[r["tag"]], icon[r["release"]], mark)) + + # ── 載入端(這個 session)────────────────────────────────────────── + lines.append("") + if loaded is None: + lines.append(" 載入端:這個 context 讀不到 CLAUDE_PLUGIN_ROOT ⇒ 說不出「這個 session 載到哪一版」" + "(不是 session 裡跑的,或沒裝 plugin)。") + elif vtuple(loaded) < vtuple(cur): + lines.append(" 🟡 載入端落後:這個 session 掛的是 v%s,main 已到 v%s。" % (loaded, cur)) + lines.append(" 修:本機 `claude plugin update isep@inkstone` **並重啟 session**" + "(升版與生效是兩件事);雲端去動一下 Environment 的 setup script 逼它重拍快照。") + lines.append(" 這一段 SessionStart 信標本來就一直在報(beacon_report.py ①)。") + else: + lines.append(" ✅ 載入端:這個 session 掛的是 v%s,跟 main 宣稱的一致。" % loaded) + + # ── 判決 ──────────────────────────────────────────────────────────── + currow = next((r for r in rows if r["current"]), None) + hard = [] + if currow: + for key, label, fix in STATIONS: + if not currow[key]: + hard.append((label, fix)) + older_gaps = [r for r in rows if not r["current"] and (not r["tag"] or not r["release"])] + + lines.append("") + if hard: + lines.append("🔴 **現行版本 v%s 的送貨端斷了** —— 對 leo 等於這一版不存在:" % cur) + for label, fix in hard: + lines.append(" 缺這一站:%s" % label) + lines.append(" 怎麼補:%s" % fix) + if older_gaps: + lines.append(" (另外還有更舊的版本也缺,見下方黃字,但那些不擋。)") + else: + lines.append("✅ 現行版本 v%s:定版、tag、release 都在 —— 送貨端完成,leo 那頁看得到。" % cur) + + if older_gaps: + lines.append("") + lines.append("🟡 更舊的定版版本也缺件(多半已被新版取代,只提醒、不擋):") + for r in older_gaps: + miss = [] + if not r["tag"]: + miss.append("tag") + if not r["release"]: + miss.append("release") + lines.append(" v%s 缺:%s" % (r["ver"], "+".join(miss))) + lines.append(" 要補歷史版本 ⇒ 逐版 `scripts/release-ship v<版本>`;" + "確定不補 ⇒ 這是 leo/總管的決定,記一句在 inkstone/ISEP#67。") + + out = "\n".join(lines) + if hard: + print(out, file=sys.stderr) + return 2 + print(out) + return 0 + + +def main(argv): + if argv and argv[0] in ("-h", "--help"): + print(__doc__) + return 0 + return report() + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/release-ship b/scripts/release-ship new file mode 100755 index 0000000..ca5fdfc --- /dev/null +++ b/scripts/release-ship @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""release-ship — 把「已經推上去的 tag」變成「leo 那頁看得到的 release」 + (inkstone/ISEP#67,對應 arcrun-rag 的 release-record 站) + +工單抓到的斷點,一句話:**沒有任何東西會建 release。** + ISEP repo 全樹 grep「建 release」的 API 呼叫 → 0 命中。 + 後果:v0.22.0 的 tag 早就推了,Gitea Releases 頁卻停在 v0.18.0 + ⇒ 對 leo 等於這一版不存在(「ISEP 現在是哪一版」只有 Releases 答得出來)。 + +這支就是補上那一站。它**只做 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 真的在 Gitea 上:去問收貨端,不在就不給建 release(先去推 tag)。 + ③ release note 有內容:沒給 `--notes-file` 就從 `git log <上一個release>..` + 自動生一份**事實**清單(哪幾顆 commit)——leo 可以再改,但不留白。 + +🔴 送完**自己不算數**:最後回頭跑一次 `release-check`,拿收貨端(Gitea releases 頁) + 的事實覆核「真的建起來了」。送的人說成功不算,問收貨的那一端才算 + (工單:「問的人不能是送的人」)。 + +冪等:release 已經在了 ⇒ 報「已存在」、離開碼 0,不重複建、不報錯。 + +離開碼:0 = release 在 Gitea 上了(新建的或本來就在) + 2 = 某個前置條件沒滿足(stderr 講出是哪一個+怎麼補) + +離線測試:`scripts/test-release-ship.sh`(走 `RELEASE_SHIP_FIXTURE`,不打 Gitea、不建真 release)。 +""" +import json +import os +import re +import subprocess +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__)) +VER_RE = re.compile(r"^v?(\d+\.\d+\.\d+)$") + + +def die(msg, code=2): + print(msg, file=sys.stderr) + sys.exit(code) + + +def proj_root(): + v = os.environ.get("CLAUDE_PROJECT_DIR") + if v and os.path.isfile(os.path.join(v, ".claude-plugin", "plugin.json")): + return v + return os.path.dirname(HERE) + + +def _fixture(): + try: + with open(FIXTURE) as f: + return json.load(f) + except Exception: + 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("/") + 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 "" + + +# ══ 前置條件 ① 版本一致(重用既有腳本,不另寫判準)═══════════════════════ +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 的 token(remote 沒帶憑證,也沒有 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 "這一版收了以下改動:" + if not body: + body = "-(git log 取不到 commit 清單,請手動補;release 已建起來,leo 可直接編輯。)" + return ("%s\n\n%s\n\n---\n(本 note 由 scripts/release-ship 從 git log 自動生成," + "leo/總管可直接在 Gitea 上編輯。inkstone/ISEP#67)" % (head, body)) + + +# ══ 真的建 release(POST 收貨端)═══════════════════════════════════════════ +def create_release(tagname, notes, dry, name=None): + name = name or tagname + 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: + sink = os.environ.get("RELEASE_SHIP_SINK") + if sink: + try: + cur = json.load(open(sink)) if os.path.exists(sink) else [] + except Exception: + cur = [] + cur.append(tagname) + json.dump(cur, open(sink, "w")) + return {"tag_name": tagname, "html_url": "(fixture-created)"}, "(fixture 模式,未打網路)" + tk = token() + if not tk: + die("🔴 拿不到 %s 的 token —— 建 release 是寫入,一定要憑證。" % HOST) + url = "%s/api/v1/repos/%s/releases" % (HOST, REPO) + req = urllib.request.Request( + url, data=json.dumps(payload).encode(), method="POST", + headers={"Authorization": "token " + tk, "Content-Type": "application/json"}) + try: + r = json.load(urllib.request.urlopen(req, timeout=40)) + return r, "已建立" + except urllib.error.HTTPError as e: + die("🔴 Gitea 建 release 失敗 %s:%s" % (e.code, e.read().decode()[:300])) + except Exception as e: + die("🔴 連不上 %s:%s" % (HOST, e)) + + +# ══ 送完覆核:回頭問收貨端(不是問自己)══════════════════════════════════ +def verify_via_check(): + """跑 release-check,拿收貨端事實覆核。回 (ok, 輸出)。""" + check = os.path.join(HERE, "release-check") + env = dict(os.environ) + if FIXTURE: + # fixture 模式下,把剛「建」進 sink 的 release 併進 release-check 的 fixture 視圖 + fx = _fixture() + sink = os.environ.get("RELEASE_SHIP_SINK") + extra = [] + if sink and os.path.exists(sink): + try: + 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" + json.dump(merged, open(cf, "w")) + env["RELEASE_CHECK_FIXTURE"] = cf + try: + p = subprocess.run(["python3", check], capture_output=True, text=True, + timeout=60, env=env) + return p.returncode == 0, (p.stdout + p.stderr).strip() + except Exception as e: + return False, "跑不動 release-check:%s" % e + + +# ══ 主流程 ════════════════════════════════════════════════════════════════ +def ship(arg, notes_file=None, dry=False, do_verify=True): + m = VER_RE.match(arg.strip()) + if not m: + die("🔴 版本寫法是 vX.Y.Z 或 X.Y.Z,你給的是:%s" % arg) + ver = m.group(1) + 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): + 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)) + out.append("✅ 前置② Gitea 上有 tag %s(問過收貨端)" % tagname) + + # 冪等 + existing = gitea_release(tagname) + if existing: + out.append("ℹ️ release %s 本來就在了(%s)—— 不重複建。" + % (tagname, existing.get("html_url") or "")) + code = 0 + if do_verify: + vok, vout = verify_via_check() + out.append("") + out.append("── 覆核(release-check,問收貨端)──") + out.append(vout) + code = 0 if vok else 2 + return code, "\n".join(out) + + # 前置 ③ + 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 可再改)") + + # 建(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) + if dry: + out.append(" release 名:%s" % relname) + out.append("") + out.append(how) + return 0, "\n".join(out) + out.append("✅ 已建立 release「%s」(%s)" % (relname, (rel or {}).get("html_url") or how)) + + # 覆核(送完問收貨端) + code = 0 + if do_verify: + vok, vout = verify_via_check() + out.append("") + out.append("── 覆核(release-check,問收貨端,不是問自己)──") + out.append(vout) + code = 0 if vok else 2 + return code, "\n".join(out) + + +def main(argv): + arg, notes_file, dry, do_verify = None, None, False, True + i = 0 + while i < len(argv): + a = argv[i] + if a in ("-h", "--help"): + print(__doc__) + return 0 + elif a == "--notes-file": + i += 1 + notes_file = argv[i] + elif a == "--dry-run": + dry = True + elif a == "--no-verify": + do_verify = False + elif a.startswith("-"): + die("不認得的參數:%s(--help 看用法)" % a) + else: + arg = a + i += 1 + if not arg: + die("用法:release-ship <版本 vX.Y.Z> [--notes-file <檔>] [--dry-run] [--no-verify]") + code, report = ship(arg, notes_file, dry, do_verify) + print(report, file=(sys.stdout if code == 0 else sys.stderr)) + return code + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test-release-check.sh b/scripts/test-release-check.sh new file mode 100755 index 0000000..21c885c --- /dev/null +++ b/scripts/test-release-check.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# scripts/release-check 的迴歸測試(inkstone/ISEP#67) +# +# 兩個方向都要有證據: +# A 群「該擋」(exit 2)——現行版本缺 tag 或缺 release,且**說得出缺哪一站** +# (這就是工單驗收 4:「故意漏一步 → 管線要擋下來並說出缺哪一站」) +# B 群「該過」(exit 0)——現行版本 tag+release 都在;更舊版本缺件只黃字提醒不擋 +# +# 🔴 全程離線(RELEASE_CHECK_FIXTURE),不打 Gitea。fixture 直接給收貨端與送貨端的事實。 +set -u +TOOL="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-check}" +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0; N=0 + +mkfx() { # $1=檔名 之後 heredoc + cat > "$TMP/$1" +} + +run() { RELEASE_CHECK_FIXTURE="$TMP/$1" python3 "$TOOL" 2>&1; } + +# t <期望exit> <說明> <必須出現的字串> +t() { + want="$1"; desc="$2"; must="$3"; fx="$4" + N=$((N+1)) + out=$(RELEASE_CHECK_FIXTURE="$TMP/$fx" python3 "$TOOL" 2>&1); rc=$? + ok=1 + [ "$rc" -eq "$want" ] || ok=0 + if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi + if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) + else + printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \ + "${must:+ 且輸出含「$must」}" "$rc" + printf '%s\n' "$out" | sed -n '1,14p' | sed 's/^/ /'; FAIL=$((FAIL+1)) + fi +} + +echo "── A 群:該擋(現行版本送貨端斷了,要說出缺哪一站)──" + +# A1:這就是 2026-09-05 的真實狀態——v0.22.0 有 tag、沒 release +mkfx a1.json <<'JSON' +{"current":"0.22.0", + "dingban":["0.22.0","0.21.0","0.20.0","0.19.0","0.18.0"], + "tags":["v0.22.0","v0.18.0"], + "releases":["v0.18.0"], + "loaded":"0.22.0"} +JSON +t 2 "現行 v0.22.0 有 tag 沒 release → 擋,且指名缺 release 站" "release(Gitea)" a1.json +t 2 "缺 release 時給得出補法(release-ship)" "release-ship" a1.json + +# A2:連 tag 都沒有(v0.19-0.21 的真實狀態搬成現行來測) +mkfx a2.json <<'JSON' +{"current":"0.20.0", + "dingban":["0.20.0","0.18.0"], + "tags":["v0.18.0"], + "releases":["v0.18.0"], + "loaded":"0.20.0"} +JSON +t 2 "現行版本連 tag 都沒有 → 擋,且指名缺 tag 站" "tag(Gitea)" a2.json +t 2 "缺 tag 時給得出補法(push tag)" "git push origin" a2.json + +echo "── B 群:該過(現行版本 tag+release 都在)──" + +# B1:全部送到 +mkfx b1.json <<'JSON' +{"current":"0.18.0", + "dingban":["0.18.0","0.9.0"], + "tags":["v0.18.0","v0.9.0"], + "releases":["v0.18.0","v0.9.0"], + "loaded":"0.18.0"} +JSON +t 0 "現行版本 tag+release 都在 → 放行" "送貨端完成" b1.json + +# B2:現行版本送到了,但更舊的 定版 版本缺件 → 仍放行,只黃字 +mkfx b2.json <<'JSON' +{"current":"0.22.0", + "dingban":["0.22.0","0.21.0","0.20.0","0.19.0","0.18.0"], + "tags":["v0.22.0","v0.18.0"], + "releases":["v0.22.0","v0.18.0"], + "loaded":"0.22.0"} +JSON +t 0 "現行送到、舊版缺件 → 放行(不 over-block)" "送貨端完成" b2.json +t 0 "舊版缺件會被黃字提醒出來" "更舊的定版版本也缺件" b2.json + +# B3:載入端落後(送貨端完成,但這個 session 掛的是舊版)→ 仍 exit 0,但要報出來 +mkfx b3.json <<'JSON' +{"current":"0.22.0", + "dingban":["0.22.0","0.18.0"], + "tags":["v0.22.0","v0.18.0"], + "releases":["v0.22.0","v0.18.0"], + "loaded":"0.21.0"} +JSON +t 0 "載入端落後 → 送貨端仍算完成(exit 0)" "送貨端完成" b3.json +t 0 "載入端落後會被明確報出+給 update/重啟的修法" "載入端落後" b3.json + +echo "" +echo "──────────────────────────────" +printf '共 %d 項:✅ %d / ❌ %d\n' "$N" "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/scripts/test-release-ship.sh b/scripts/test-release-ship.sh new file mode 100755 index 0000000..0837f6d --- /dev/null +++ b/scripts/test-release-ship.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# scripts/release-ship 的迴歸測試(inkstone/ISEP#67) +# +# 兩個方向都要有證據: +# A 群「該擋」(exit 2)——前置條件沒滿足就不給建 release,且說得出是哪一個前置 +# ① 版本不一致 ② Gitea 上沒那個 tag ③ 版本寫法不對 +# B 群「該放行」(exit 0)——前置都在就建;release 已存在則冪等不重複建;--dry-run 不 POST +# +# 🔴 全程離線(RELEASE_SHIP_FIXTURE + RELEASE_SHIP_SINK),不打 Gitea、不建任何真 release。 +set -u +TOOL="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-ship}" +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0; N=0 + +# t <期望exit> <說明> <必須出現的字串> [額外參數…] +t() { + want="$1"; desc="$2"; must="$3"; fx="$4"; shift 4 + N=$((N+1)) + rm -f "$TMP/sink.json" "$TMP/sink.json.checkfx.json" + out=$(RELEASE_SHIP_FIXTURE="$TMP/$fx" RELEASE_SHIP_SINK="$TMP/sink.json" \ + python3 "$TOOL" "$@" 2>&1); rc=$? + ok=1 + [ "$rc" -eq "$want" ] || ok=0 + if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi + if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) + else + printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \ + "${must:+ 且輸出含「$must」}" "$rc" + printf '%s\n' "$out" | sed -n '1,14p' | sed 's/^/ /'; FAIL=$((FAIL+1)) + fi +} + +echo "── A 群:該擋(前置條件沒滿足)──" + +# A1:版本一致性沒過 +cat > "$TMP/a1.json" <<'JSON' +{"consistency_ok":false,"consistency_msg":"❌ plugin.json=0.21.0 但 tag=v0.22.0", + "tags":["v0.22.0"],"releases":[]} +JSON +t 2 "版本不一致 → 擋,指名前置①" "前置①" a1.json v0.22.0 + +# A2:Gitea 上沒有這個 tag +cat > "$TMP/a2.json" <<'JSON' +{"consistency_ok":true,"tags":["v0.18.0"],"releases":["v0.18.0"]} +JSON +t 2 "Gitea 沒有該 tag → 擋,指名前置②+叫人先推 tag" "git push origin v0.22.0" a2.json v0.22.0 + +# A3:版本寫法不對 +cat > "$TMP/a3.json" <<'JSON' +{"consistency_ok":true,"tags":[],"releases":[]} +JSON +t 2 "版本寫法不對 → 擋" "版本寫法" a3.json "0.22" + +echo "── B 群:該放行 ──" + +# B1:前置都在、release 還沒建 → 建(fixture sink),然後覆核通過 +cat > "$TMP/b1.json" <<'JSON' +{"consistency_ok":true, + "current":"0.22.0","dingban":["0.22.0","0.18.0"],"loaded":"0.22.0", + "tags":["v0.22.0","v0.18.0"],"releases":["v0.18.0"]} +JSON +t 0 "前置齊全、release 缺 → 建起來" "已建立 release「v0.22.0」" b1.json v0.22.0 --notes-file "$TMP/b1.json" +t 0 "建完回頭跑 release-check 覆核(問收貨端)" "覆核" b1.json v0.22.0 --notes-file "$TMP/b1.json" + +# B2:release 已存在 → 冪等,不重複建 +cat > "$TMP/b2.json" <<'JSON' +{"consistency_ok":true, + "current":"0.18.0","dingban":["0.18.0"],"loaded":"0.18.0", + "tags":["v0.18.0"],"releases":["v0.18.0"]} +JSON +t 0 "release 已存在 → 冪等放行、不重複建" "本來就在了" b2.json v0.18.0 --notes-file "$TMP/b2.json" + +# B3:--dry-run 不 POST +cat > "$TMP/b3.json" <<'JSON' +{"consistency_ok":true,"tags":["v0.22.0"],"releases":[]} +JSON +t 0 "--dry-run 全走一遍但不 POST" "不 POST" b3.json v0.22.0 --dry-run + +echo "" +echo "──────────────────────────────" +printf '共 %d 項:✅ %d / ❌ %d\n' "$N" "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ]