617315e3dd
docs/governance/cloud-wiring.md:四個缺陷分成兩種病—— ①④ 是「閘的判準寫的是地端才成立的假設」,②③ 是「同一個東西有兩份,雲端跑到舊的」。 每一格都附實測證據與修法,並標明還沒關掉的兩格住在哪張票(ISEP#67/真身 repo)。 TESTING.md 補 A14/A15/A16 三格:怎麼跑、該看到什麼、什麼算失敗。 三支閘被擋下的訊息也一併指向 scripts/gate-ok(原本的手打法保留當等價寫法)。
406 lines
26 KiB
Bash
Executable File
406 lines
26 KiB
Bash
Executable File
#!/bin/sh
|
||
# 管什麼: subagent 推 gitea main 要先有總管戳記;wrangler 部署到非 stage 環境要 leo 手動解保險。
|
||
# 為什麼: leo 2026-08-10——「subagent 推 main 你確認,推 prod 我確認」;規則存在但沒機制驗證有沒有照做,於是閘長在機器上。
|
||
# 誤觸時怎麼關: 讀取/查狀態/演練一律放行;真要推 main,總管看過 commit 後 `git rev-parse --show-toplevel > /tmp/.main-push-ok`;真要打 stage,指令裡帶 staging/--env stage 字樣。
|
||
# main-and-prod-push-guard.sh — PreToolUse(Bash):**兩層手動確認閘**
|
||
#
|
||
# 🔴 立這道閘的來由(leo 2026-08-10):
|
||
# 「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」
|
||
#
|
||
# 當天的事故(本閘的直接成因):
|
||
# 總管派 subagent 去整理 `polaris/mira` 的散落分支,指令寫「驗過了 → 合併(這是預設)」,
|
||
# 引的是 leo 08-05 那句「驗過就要 merge」。subagent 照做,**合併並直接 push 到 gitea main**。
|
||
# ⇒ 而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。
|
||
# ⇒ **規則早就存在,但沒有任何機制驗證有沒有照做**——這是同款的第 N 次
|
||
# (history-first/KBDB-first/派工-first/stage-first 都是這個形狀)。
|
||
# ⇒ 閘漏的是總管:派工指令裡沒把那道閘標出來。**所以閘要長在機器上,不是長在我的記性上。**
|
||
#
|
||
# 兩條,各自對應一個人:
|
||
# ① subagent(`CLAUDE_CODE_CHILD_SESSION=1`)**一律不准 push 到 main** → 交回總管
|
||
# ② **prod 部署**(會讓封測者/用戶拿到東西)需要 leo 親手解保險 `.github-armed`
|
||
# ——補 `stage-before-prod-guard.sh` 的破口:那支只比對
|
||
# `arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字,
|
||
# **`wrangler deploy` 打 prod 它抓不到**(2026-08-10 實查)。
|
||
#
|
||
# 設計紀律(沿用 stage-before-prod-guard 用血換來的兩條):
|
||
# • **fail-closed**:任何內部錯誤都不准變成靜默放行。exit 1 不擋,只有 exit 2 才擋。
|
||
# • **先排除「談論/讀取」再比對關鍵字**:誤攔比漏攔更容易殺死一道閘
|
||
# (被擋得莫名其妙,人就會想辦法繞過它)。
|
||
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
|
||
|
||
# 🔴 2026-08-20(inkstone/InkStoneCo#23、#56):heredoc 的 body 是資料,不是指令。
|
||
# 直接對整條指令字串(含 heredoc 內文)做關鍵字掃描,會把「檔案內容/留言引用裡
|
||
# 剛好提到 git push」當成「真的在推」——同一天兩起實撞:寫 docs/TESTING.md 的
|
||
# 一行範例文字、貼一則引用 #56 敘述的 Gitea 留言,都被這樣擋下。
|
||
# 拿掉 heredoc body 再比對,判準不變,範圍變準。失敗就退回原字串(fail-open
|
||
# 在「這支有沒有幫上忙」,不是「放行與否」——見 hooks/lib/strip_heredoc.py 檔頭)。
|
||
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
|
||
|
||
# ── 先放行明確不發佈的動作(讀取、查狀態、寫本地版控、演練)──────────────
|
||
# 🔴 2026-08-20 修正(release-tag-guard.sh/stage-before-prod-guard.sh 已修過同款洞):
|
||
# 舊版「CMD 開頭是讀取工具就整條放行」本身是穿牆路——
|
||
# echo x && git push gitea HEAD:main
|
||
# 開頭是 echo,照 glob 前綴比對會整條 exit 0,後面真的在推 main 完全不看。
|
||
# ⇒ 改成「先看有沒有出現危險關鍵字,出現了就不吃開頭豁免」,交給下面的
|
||
# 「指令位置」判準去判斷它是不是真的在執行。
|
||
case "$CMD" in
|
||
*"git push"*|*"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;;
|
||
sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;;
|
||
*"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;;
|
||
*"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;;
|
||
*" --dry-run"*|*"--dry-run "*) exit 0 ;;
|
||
esac
|
||
|
||
# ── ① push 到 main 要有「總管決定了」的戳記 ────────────────────────────
|
||
#
|
||
# 🔴 **原本的判斷方式是錯的,上線當天就被自己擋到才發現(2026-08-10)**:
|
||
# 本來用 `CLAUDE_CODE_CHILD_SESSION=1` 當「這是 subagent」的標記——
|
||
# **實測總管主 session 也是 1**(Claude Desktop,`CLAUDE_CODE_ENTRYPOINT=claude-desktop`)。
|
||
# ⇒ 那個變數不是身分標記 ⇒ 這道閘**把該放行的人也鎖住了**,總管推不了 main。
|
||
# ⚠️ `guard-cross-project.sh` 用同一個變數,**可能也一直在誤判**(另記,待查)。
|
||
#
|
||
# leo 2026-08-10 把權責講清楚了:
|
||
# 「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」
|
||
# ⇒ 這道閘要守的不是「誰在敲鍵盤」,是「**有沒有人做過那個決定**」。
|
||
# ⇒ 沒有可靠的身分辨識時,改成**正向確認**:推 main 前要有一枚新鮮的戳記。
|
||
# subagent 當然造得出那枚戳記——但它得刻意繞過一段明講「不要這樣做」的訊息。
|
||
# **閘擋的是無心,不是惡意。** 而現在這個版本連無心都擋不了(它誰都擋)。
|
||
# 🔴 戳記的 mtime 一律走 lib/mtime.sh:舊寫法(`stat -f %m || stat -c %Y`)在 Linux 上
|
||
# **永遠算不出數字**(GNU 的 `-f` 是「檔案系統資訊」,它一邊回非零一邊吐一整段文字,
|
||
# 於是後面接上的秒數被那段垃圾污染)⇒ 這支閘給總管的逃生口在雲端整個焊死,
|
||
# 而閘不會告訴他門是壞的(inkstone/ISEP#90 ④,全文見 hooks/lib/mtime.sh)。
|
||
# lib 不在時就地補一份同順序的定義——這道門不能因為少一個檔案就再關上一次。
|
||
. "$(dirname "$0")/lib/mtime.sh" 2>/dev/null || file_mtime() {
|
||
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
|
||
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
|
||
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
|
||
printf '%s' "$_fm"
|
||
}
|
||
|
||
STAMP="/tmp/.main-push-ok"
|
||
|
||
# 🔴 2026-08-11 這道閘被自己的戳記穿透了,修法寫在這裡:
|
||
# 總管為了推「頂層 InkStoneCo」而 touch 了戳記,15 分鐘之內
|
||
# **一條並行的 subagent 把 commit 推上了 `matrix/arcrun` 的 main**,本閘毫無反應。
|
||
# ⇒ 舊版有兩個洞,缺一不可:
|
||
# ① **不綁 repo**:替 A repo 開的門,B repo 也走得過
|
||
# ② **不會用掉**:一次 touch 在 15 分鐘內可以放行無限多次推送
|
||
# ⇒ 現在改成 **綁 repo + 單次用完即丟**,而且**只在真的要擋的那一刻才檢查**
|
||
# (放在檔頭會被任何一條無關的 Bash 指令把戳記燒掉)。
|
||
stamp_ok() {
|
||
[ -f "$STAMP" ] || return 1
|
||
NOW=$(date +%s 2>/dev/null || echo 0)
|
||
MT=$(file_mtime "$STAMP")
|
||
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
|
||
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
|
||
[ $((NOW - MT)) -lt 900 ] || return 1
|
||
|
||
# 綁 repo:戳記內容要對得上「現在人在哪個 repo」
|
||
#
|
||
# 🔴 2026-08-12 補洞:舊版寫成「內容非空才比對」⇒ **`touch` 造出來的空檔跳過整個綁定**,
|
||
# 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。
|
||
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
|
||
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。
|
||
#
|
||
# 🔴 2026-08-23(inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57):
|
||
# `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。
|
||
# 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己
|
||
# `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是
|
||
# 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法
|
||
# 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做
|
||
# 戳記,戳記內容天生就贏不了)。
|
||
# 改法:先看指令本身有沒有把 push 的執行目錄改掉
|
||
# (lib/push_target_dir.py——純 tokenize,不執行任何指令,
|
||
# 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種
|
||
# 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個
|
||
# 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰;
|
||
# 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。
|
||
# 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個
|
||
# repo」問得更準,比對邏輯(下面兩行)一個字沒動。
|
||
_push_target_dir="$(dirname "$0")/lib/push_target_dir.py"
|
||
_target_expr=""
|
||
if [ -f "$_push_target_dir" ]; then
|
||
_target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '')
|
||
fi
|
||
if [ -n "$_target_expr" ]; then
|
||
HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||
else
|
||
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||
fi
|
||
WANT=$(head -1 "$STAMP" 2>/dev/null || printf '')
|
||
[ -n "$WANT" ] || return 1
|
||
[ -n "$HERE" ] || return 1
|
||
[ "$WANT" = "$HERE" ] || return 1
|
||
|
||
rm -f "$STAMP" 2>/dev/null || true # 單次:用完即丟
|
||
return 0
|
||
}
|
||
|
||
# reworked 2026-08-20 (inkstone/InkStoneCo#23, #56): 'does this command contain
|
||
# git push' moved from a whole-string substring test to a command-position test --
|
||
# same pattern already validated on release-tag-guard.sh: the phrase only counts
|
||
# at the start of the string, or right after ; & | ( ` && ||. A heredoc body was
|
||
# already stripped above; this closes the remaining gap where the phrase shows up
|
||
# quoted inside the *same* command line (e.g. a python -c call whose string argument
|
||
# discusses git push as prose).
|
||
if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]+[^;&|]*)?[[:space:]]+push([[:space:]]|$)'; then
|
||
# only block pushes that target main/master; pushing your own feature branch is fine
|
||
#
|
||
# 2026-08-12: dropped the `push -u` / `push --set-upstream` conditions -- those were
|
||
# meant to catch "push with no branch named", but what they actually caught was
|
||
# `git push -u gitea fix/xxx`, the normal first-push-of-a-branch shape for a
|
||
# subagent. The target branch is what should be gated, not whether a flag is present.
|
||
# 2026-08-20: match the target branch on a word boundary, not a bare substring --
|
||
# a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to
|
||
# `fix/custom-domain-setup` would have false-positived.
|
||
# 2026-08-21: 只看 **push 的目標**,不再掃整條指令。
|
||
# 舊版掃整條 ⇒ 一個晚上誤攔四次,全都是推 feature branch 或 tag:
|
||
# git checkout -b fix/x main && git push origin fix/x ← 「main」在 checkout 上
|
||
# gh pr create --base main ← 根本不是 git push
|
||
# git checkout origin/main --detach; git push origin refs/tags/v0.3.3
|
||
# ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察,
|
||
# 文字層封路必敗)。這裡改成判動作的目標,不是判字面。
|
||
# 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍
|
||
# 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修):
|
||
# `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行,
|
||
# 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。
|
||
# 成因:截斷 refspec 尾巴只切 `;`/`&`/`|` 三種字元,沒算到 `)`——
|
||
# 於是「HEAD:main)」被當成一個 token,`${_tok##*:}` 剝完冒號還剩「main)」,
|
||
# 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。
|
||
# git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全,
|
||
# 不會誤傷任何合法的推送目標。
|
||
_push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//')
|
||
_dest=""
|
||
_seen_remote=0
|
||
_saw_refspec=0
|
||
for _tok in $_push_seg; do
|
||
case "$_tok" in
|
||
-*) continue ;; # 旗標
|
||
refs/tags/*|*:refs/tags/*) _saw_refspec=1; continue ;; # 推 tag 不是推分支
|
||
esac
|
||
if [ "$_seen_remote" = "0" ]; then _seen_remote=1; continue; fi # 第一個非旗標=remote
|
||
_saw_refspec=1
|
||
_dest="$_dest ${_tok##*:}" # a:b 的目標是 b;沒有冒號就是它自己
|
||
done
|
||
# 🔴 只有「一個 refspec 都沒給」才退回猜當前分支。
|
||
# 看到 refspec(哪怕是 tag)就照它判——否則推 tag 會被當成推當前分支,
|
||
# 而當前分支若剛好叫 main 就誤擋(2026-08-21 實測抓到)。
|
||
if [ "${_saw_refspec:-0}" = "0" ]; then
|
||
_dest=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||
fi
|
||
if printf '%s' "$_dest" | tr ' ' '\n' | grep -qE '^(main|master)$'; then
|
||
stamp_ok && exit 0
|
||
|
||
# ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)───────────
|
||
#
|
||
# leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」
|
||
#
|
||
# 做得到的與做不到的,先講清楚:
|
||
# ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。
|
||
# 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。
|
||
# ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄,
|
||
# 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。
|
||
# ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。
|
||
#
|
||
# 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open——
|
||
# 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有
|
||
# 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。
|
||
# 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界);
|
||
# Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。
|
||
# 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。
|
||
_who="${CLAUDE_AGENT_NAME:-未署名}"
|
||
_hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir=""
|
||
_reqdir="${_hookdir%/hooks}/pending-main-push"
|
||
if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then
|
||
_root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown')
|
||
# 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰)
|
||
_slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-')
|
||
case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac
|
||
# ⚠️ 先 printf 再 tr:`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash
|
||
_slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-')
|
||
_slug="${_slugwho}--${_slugrepo}"
|
||
# ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。
|
||
# 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在),
|
||
# ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。
|
||
_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
|
||
_when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?')
|
||
_fence='```'
|
||
{
|
||
printf '%s\n\n' "# 推 main 的請求:$_who"
|
||
printf '%s\n' "- repo:$_root"
|
||
printf '%s\n' "- 分支:$_branch"
|
||
printf '%s\n\n' "- 時間:$_when"
|
||
printf '%s\n' "- 它想跑的指令:"
|
||
printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence"
|
||
printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence"
|
||
git log --oneline '@{upstream}..HEAD' 2>/dev/null \
|
||
|| git log --oneline -20 2>/dev/null \
|
||
|| printf '(列不出來)\n'
|
||
printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence"
|
||
git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n'
|
||
printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。"
|
||
} > "$_reqdir/$_slug.md" 2>/dev/null || true
|
||
fi
|
||
|
||
cat >&2 <<'MSG'
|
||
🚫 推 main 要先有「總管決定了」的戳記(leo 2026-08-10 立)
|
||
|
||
leo 原話:
|
||
「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」
|
||
「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」
|
||
|
||
━━━ 你是 subagent ━━━
|
||
**不要造那枚戳記。** 把改動留在自己的分支上,推那條分支,交件裡寫清楚:
|
||
· 分支名
|
||
· 這幾筆各是什麼、**各自**驗過了沒有(逐筆,不要整包說「驗過了」)
|
||
· 你建議合併還是先擱著,理由是什麼
|
||
總管看過才會併進 main。**這是一句話的事,不要自己想辦法過閘。**
|
||
|
||
📮 **你的請求已經自動留下來了**(`.claude/pending-main-push/` 底下,用你的
|
||
`CLAUDE_AGENT_NAME` 署名;沒有這個變數就署「未署名」)。裡面是 repo/分支/
|
||
逐筆 commit 的原始資料 ⇒ **你不必在交件裡重抄一遍那些,總管會直接讀。**
|
||
|
||
━━━ 你是總管 ━━━
|
||
逐筆看過那些 commit(`git log --oneline gitea/main..<branch>`、`git diff --stat`),
|
||
確定它們該進 main,再:
|
||
|
||
bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" main-push <那個 repo 的路徑>
|
||
<你的 git push 指令>
|
||
(等價的手打法:git rev-parse --show-toplevel > /tmp/.main-push-ok)
|
||
|
||
戳記 **綁這個 repo、只能用一次、15 分鐘失效**——它代表「**這一次、這個 repo,我看過了**」。
|
||
|
||
🔴 **為什麼從 `touch` 改成寫入 repo 路徑(2026-08-11 被穿透過一次)**:
|
||
總管為了推頂層而 `touch` 了戳記,**15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**,
|
||
本閘毫無反應。舊版既不綁 repo、也不會用掉 ⇒ 一次確認等於全域開門。
|
||
**在多條 subagent 並行的情況下,「時間窗」本身就是漏洞。**
|
||
|
||
【為什麼不是形式主義】2026-08-10 真的發生過:
|
||
總管派工寫「驗過了 → 合併(這是預設)」,subagent 照做並直接推上 gitea main。
|
||
而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。
|
||
⇒ 內容是好的,但**該不該先問,和內容對不對,是兩件事**。
|
||
⇒ 而且那次是**總管的指令漏了那道閘**——所以現在由機器守,不靠誰記得。
|
||
|
||
【真的該推 main 的例外】不存在。交回總管,一句話的事。
|
||
MSG
|
||
exit 2
|
||
fi
|
||
fi
|
||
|
||
# ── ② prod 部署要 leo 親手解保險 ────────────────────────────────────────
|
||
# 先排除 stage/staging 的同名動作(它們本來就該自由跑)
|
||
#
|
||
# 🔴 2026-08-13 leo 拍板:「**你可以標示整套測試環境,告訴閘『這些是 stage』,
|
||
# 做這些事時白名單跳過**」。
|
||
#
|
||
# 為什麼要補(同款第三次):`prod-write-guard.sh:188-191` 在 2026-08-12 就補過
|
||
# 一模一樣的例外,理由白紙黑字寫在那裡——
|
||
# 「`youlin`(youlin-hsieh-dev)**就是測試場**,不是 prod⋯⋯
|
||
# 本閘原本只認 `staging` 這個字 ⇒ 打 youlin 做實驗被誤擋」
|
||
# **但同一批補丁漏了這一支。** ⇒ 修法只改其中一份、沒有機制保證另一份跟上
|
||
# (同日另兩例:`kbdb-api-wall-guard.sh` 本機版與 plugin 版不同步,擋了九次)。
|
||
#
|
||
# 🔴 判準不變,只是把「測試環境」講完整:**放行的是目標,不是動作。**
|
||
# 命令看不出打哪裡 ⇒ 不放行。想被放行就把目標寫進命令裡,
|
||
# **不要用假的 `# staging` 註解騙閘**(2026-08-13 一條 subagent 明確拒絕這樣做,是對的)。
|
||
#
|
||
# 整套測試環境=下列任一出現在命令裡:
|
||
# · `youlin-hsieh-dev` ── 帳號名(D37 定的 stage 帳號)
|
||
# · `1129efd7df2e8899d537e9c8fbabb6cb` ── 該帳號 ID(用 env 指過去時只有它看得見)
|
||
# · `_YOULIN_` ── 憑證變數名(如 `CLOUDFLARE_API_TOKEN_YOULIN_CC_USE`)
|
||
# · `X-Arcrun-API-Key: youlin` ── 打該實例 API 時的身分標頭
|
||
case "$CMD" in
|
||
*staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*) exit 0 ;;
|
||
*youlin-hsieh-dev*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
|
||
esac
|
||
|
||
# 🔴 2026-08-12 leo 拍板「加閘」:**geek6688(出貨機)是明文授權「總管可以直接動」的那一台。**
|
||
#
|
||
# 依據——`system-dev/wiki/credentials-map.md` 早就寫死:
|
||
# `CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE`|geek6688 帳號 CF token(**leo 2026-08-12 開**)
|
||
# ——**總管唯一可以直接動的實例**|用途:**把那台拉到最新/推出貨工作流/
|
||
# `ARCRUN_SHIP_BASE` 指過去後的第 17~19 站**
|
||
# ⇒ 規則早就存在,只是這道閘不知道。本段就是把它教給機器。
|
||
#
|
||
# 為什麼該放行:geek6688 的職務就是「**不受出貨控制**」——出貨機與被出貨的是同一套程式,
|
||
# 要出的貨若本身是引擎更新會形成死結,所以要有一台先更新的機器。
|
||
# 每出一次貨都要 leo 親手 arm 它一次 = 把他變成出貨流程裡的按鈕,
|
||
# **而那正是這台實例存在的意義要拔掉的東西**。
|
||
#
|
||
# 🔴 **範圍刻意開到最窄:只認「命令裡明確指名 geek6688 這台」。**
|
||
# 放行的是**目標**,不是動作——打 leo21c/uncle6/任何其他實例一律照舊要 arm。
|
||
# 命令看不出打哪裡 ⇒ 不放行(與上方 stage 同一個原則:那正是該讓它看得出來的理由)。
|
||
case "$CMD" in
|
||
*geek6688*|*CC_SHIPPING_CORE*|*ACCOUNT_ID_GEEK6688*) exit 0 ;;
|
||
esac
|
||
|
||
case "$CMD" in
|
||
*"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;;
|
||
*) exit 0 ;;
|
||
esac
|
||
|
||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||
ARMED="$PROJ/.github-armed"
|
||
|
||
# ── 🔐 leo 解保險的第二條路:Gitea 票上回一句話(2026-08-13,示範接這一道閘)──
|
||
#
|
||
# leo 2026-08-13:「如果你會被通知,就不需要通過 terminal 來 arm 了」。
|
||
# 身分分離做完後(`Leo/InkStoneCo#32`),票上作者是 `Leo` 的留言機器偽造不出來,
|
||
# 可以當人閘證據——於是 arm 可以搬到手機。
|
||
# 🪦 2026-08-16 起不再是單一頻道票(原 issues/34)——票號由發請求的當下指定,
|
||
# 解哪個任務的保險就貼在那個任務自己的票上(issues/34#issuecomment-2804)。
|
||
#
|
||
# 這條路完全獨立於上面的 `$ARMED`(`.github-armed`,終端機那條路照舊在,兩者並存)。
|
||
# 只有本機**已經有人跑過 `scripts/gitea-arm-request.sh`**(本地留下待核請求)時才會
|
||
# 去打一次 Gitea——不是每次 wrangler deploy 都主動打;沒有待核請求就跳過,
|
||
# 不浪費一次 API 呼叫,也不構成輪詢(只在「機器需要解閘的當下」查一次)。
|
||
if [ ! -f "$ARMED" ] && [ -d "$PROJ/.claude/gitea-arm/pending" ] \
|
||
&& [ -n "$(ls -A "$PROJ/.claude/gitea-arm/pending" 2>/dev/null)" ]; then
|
||
# 🔴 `--consume`:這一行放行的就是 prod 部署本身(下面直接 `exit 0`)
|
||
# ⇒ 核准必須當場用掉。2026-08-16 起 check 預設唯讀,不帶這個旗標=同一組碼
|
||
# 可以無限次放行 prod,比原本的「探測會誤消耗」嚴重得多。
|
||
if "$PROJ/scripts/gitea-arm-check.sh" --consume >/tmp/.gitea-arm-check-last.log 2>&1; then
|
||
exit 0 # leo 已在 Gitea 回覆過有效代碼,且核對通過(單次用完,見該腳本)
|
||
fi
|
||
fi
|
||
|
||
if [ ! -f "$ARMED" ]; then
|
||
cat >&2 <<'MSG'
|
||
🚫 prod 部署要 leo 親手確認(leo 2026-08-10:「要推 prod 要我手動確認」)
|
||
|
||
偵測到 `wrangler deploy`,而且命令裡沒有任何 stage/staging 字樣 ⇒ 視同**打 prod**。
|
||
**推 prod 就發佈了**——封測者/用戶當場拿到。
|
||
|
||
【怎麼過這道閘】把指令交給 leo 自己跑,並且照 `/issue-handle` 的規矩帶三樣:
|
||
· 他要打開什麼(確切網址/確切指令,**你要先 `--dry-run` 打過**)
|
||
· 他該看到什麼(正確的樣子長怎樣,例如版本字串會變成什麼)
|
||
· 什麼情況算失敗(看到什麼就回你一聲)
|
||
|
||
【這道閘補的破口】`stage-before-prod-guard.sh` 只比對
|
||
`arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字,
|
||
**抓不到 `wrangler deploy`**(2026-08-10 實查)。它擋的是 bundle 出貨鏈,不是所有發佈。
|
||
|
||
【真的是 stage?】命令裡明確帶 `--env stage`/`staging` 就會自動放行。
|
||
若你確實在打 stage 卻被擋,代表**命令本身看不出打哪裡**——那正是該讓它看得出來的理由。
|
||
|
||
【2026-08-13 新增:leo 現在也能用手機解,不必開電腦】
|
||
1. 跑 `scripts/gitea-arm-request.sh <這件任務對應的票號> "任務描述"` 生一組代碼,
|
||
貼在**這件任務自己那張票上**(不是固定某張頻道票——2026-08-16 起票號由你指定)
|
||
2. 把印出來的那句轉告 leo(Telegram/人話一句+一個代碼)
|
||
3. leo 在 Gitea 上(App 或網頁都行)回那則貼上代碼
|
||
4. 重跑一次你剛剛被擋的指令——這道閘會自動去核對,核對過就放行一次
|
||
MSG
|
||
exit 2
|
||
fi
|
||
|
||
exit 0
|