#!/bin/sh # release-tag-guard.sh — PreToolUse(Bash):打 tag 那一刻擋下版本不一致 # # 立這道閘的來由(inkstone/ISEP#6,2026-08-20): # README.md 曾寫死「狀態:0.1.0」,但這個 repo `release_counter=0`、 # 一個 tag 都沒打。leo 當場指出這是違規,且命中規範自己的 E12 # (宣稱交付但沒有 tag);leo 補充:「release 不是寫在 readme,要放在 release 裡」。 # # 這支閘解的不是「README 寫錯字」,是**結構性防漂移**: # 「ISEP 現在是哪一版」只有一個地方答得出來= Gitea Releases(git tag)。 # `.claude-plugin/plugin.json` 的 `version` 欄位必須跟這個 tag 完全一致, # 不然又回到「版本號在不同地方各說各話」的老路——只是這次換成 # 「manifest 一個號碼、tag 又一個號碼」而不是「README 一個號碼、repo 裡沒 tag」。 # 同一套判準也寫在 `scripts/check-version-consistency.sh`(可以隨時手動跑, # 不必等到打 tag那一刻);這支 hook 是「結構性做不到」的那一半—— # 在真正動手打 tag 的當下擋住,而不是靠事後補跑腳本才發現。 # # 🔴 紅線:**這支 hook 不打 tag、不建 release**——它只在別人(人類/總管)要打 tag 時 # 檢查一致性。真正打哪個版本號的 tag,是總管驗過整個 milestone 之後的動作。 # # 設計紀律(沿用本 repo既有 guard 的兩條,見 main-and-prod-push-guard.sh 註解): # • **先排除「談論/讀取/刪除/列出」**,只擋「真的要打一個新 tag」的那個命令形狀。 # • **抽不出版本號就不擋**(fail-open on 解析失敗,不是 fail-open on 檢查結果)—— # 避免因為指令格式特殊(例如帶簽名 `-s`、多行訊息)誤攔到人, # 那正是「閘被誤攔多次會被繞過」的病根(README 段落引的 D95 同一款教訓)。 set -eu INPUT="$(cat)" CMD=$(printf '%s' "$INPUT" | python3 -c ' import sys, json try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "") except Exception: print("") ' 2>/dev/null || printf '') [ -z "$CMD" ] && exit 0 # ── 判準:`git tag` 要出現在「指令位置」才算真的要打 tag ────────────── # 🔴 這裡原本用前綴比對排除 sed/cat/grep/echo/ls…(`echo\ *)` 這種), # 2026-08-20 被一個新 session 抓到洞、總管複驗屬實: # echo 開始 && git tag -a v9.9.9 -m test → 整條放行 # ls && git tag -a v9.9.9 -m x → 整條放行 # 因為前綴是 `echo `/`ls ` 就整條 exit 0,後面串什麼都不看。 # ⇒ 就是 inkstone/InkStoneCo#36「守 prod 的閘,包一層腳本就繞過去」的同一個病, # 而且發生在同一天新寫的閘上。 # 修法用 #23 已驗證過的判準:**關鍵字要在指令位置才算執行** # (行首、或跟在 ; & | ( && || 之後),只是被別的指令當成文字提到就不算。 printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+tag[[:space:]]' || exit 0 # 讀取/刪除類的 tag 動作不是「打新 tag」,放行 case "$CMD" in *"git tag -d"*|*"git tag --delete"*|*"git tag -l"*|*"git tag --list"*|*"git tag -n"*) exit 0 ;; *" --dry-run"*|*"--dry-run "*) exit 0 ;; esac case "$CMD" in *"git tag "*) ;; *) exit 0 ;; esac # ── 從命令裡萃取版本號(第一個 vX.Y.Z 或 X.Y.Z 樣式的 token)─────────── TAGNAME=$(printf '%s' "$CMD" | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || printf '') [ -n "$TAGNAME" ] || exit 0 # 抽不出版本號=不是本閘管的形狀,不擋(見上方設計紀律) VER=${TAGNAME#v} PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" PLUGIN_JSON="$PROJ/.claude-plugin/plugin.json" [ -f "$PLUGIN_JSON" ] || exit 0 # 不在 ISEP repo 裡(沒有這個檔案)=不是本閘管的 repo PJVER=$(python3 -c "import json;print(json.load(open('$PLUGIN_JSON')).get('version',''))" 2>/dev/null || printf '') [ -n "$PJVER" ] || exit 0 if [ "$PJVER" != "$VER" ]; then cat >&2 <