diff --git a/.github/workflows/butler-deadman-trip.yml b/.github/workflows/butler-deadman-trip.yml index f709efe..e97c0ec 100644 --- a/.github/workflows/butler-deadman-trip.yml +++ b/.github/workflows/butler-deadman-trip.yml @@ -1,17 +1,15 @@ name: butler-deadman-trip -# 缺席即停 trip 侧(宪法 §6 外部 dead-man 心跳;ADR-0057,W1-C5 .github#168) +# 缺席即停 trip 事件入口(宪法 §6 外部 dead-man 心跳;ADR-0057 / ADR-0074) # 触发:外部 dead-man 服务超时回调 repository_dispatch(deadman-tripped),或手动 -# workflow_dispatch(AC-3 演习路径)。动作(GOVERNANCE_TOKEN): -# 1) 置 org 变量 AUTO_MERGE_DISABLED=true —— 与 cost-check(ADR-0040)**共用熔断 -# 变量**:宪法 §6 的"缺席即停"与成本熔断同为"停自动合并"语义,拆两个变量= -# 两套复位路径/两套旁路窗口,且消费点(agent 派发前置检查/auto-fix-limit 执法) -# 只认这一个变量; -# 2) 遍历 REPOS.yaml active 仓撤全部 open PR 的 auto-merge(模式同 cost-check.sh -# 的 strip_all_automerge——硬停是全局语义,不分作者); +# workflow_dispatch(AC-3 演习路径)。执法核心在 governance/deadman-trip.sh +# (ADR-0074 决策 3:与 butler-heartbeat-watch 共用单一实现,防双实现漂移): +# 1) 置 org 变量 AUTO_MERGE_DISABLED=true —— 与 cost-check(ADR-0040)共用熔断 +# 变量(两套变量=两套复位路径/两套旁路窗口); +# 2) 遍历 REPOS.yaml active 仓撤全部 open PR 的 auto-merge; # 3) 开 P0 issue(label deadman-tripped,幂等去重)+ AUDIT 行。 -# 退出码:执法成功完毕 exit 1(=已熔断,变红=可见信号,同 cost-check tripped 语义); -# infra 故障(变量置位失败等)exit 2。复位仅人工:PATCH 变量 false + P0 留评论关闭 -# (docs/deadman-setup.md)。 +# 退出码语义:脚本 0=执法完毕(已熔断)→ 本 workflow exit 1 变红=可见信号 +# (同 cost-check tripped 语义);脚本 2=infra。复位仅人工:PATCH 变量 false + +# P0 留评论关闭(docs/deadman-setup.md)。 on: repository_dispatch: types: [deadman-tripped] # 外部 dead-man 服务的失败回调(runbook 见 docs/deadman-setup.md) @@ -39,101 +37,21 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: 缺席即停——置熔断+撤 auto-merge+P0 + - name: 缺席即停——置熔断+撤 auto-merge+P0(核心在 governance/deadman-trip.sh) env: GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} BUTLER_TRIGGER: ${{ github.event_name }} SIM: ${{ inputs.simulate }} run: | set -uo pipefail - source governance/butler-audit.sh - ORG=Cloudbird-Software - GOV_REPO="$ORG/.github" - CB=AUTO_MERGE_DISABLED - TRIGGER="${BUTLER_TRIGGER:-manual}" SIM="${SIM:-true}" - INFRA=0 - ok() { echo "OK $1"; } - act() { echo "ACT $1"; } - infra() { echo "INFRA $1" >&2; INFRA=$((INFRA+1)); } - - if [[ -z "${GH_TOKEN:-}" ]]; then - audit_emit deadman-trip "$TRIGGER" infra-fail '{"fatal":"GH_TOKEN missing (CI: org secret GOVERNANCE_TOKEN)"}' || true - echo "::error::缺 org secret GOVERNANCE_TOKEN——trip 无法执行缺席即停(fail-closed 变红)" >&2 - exit 2 - fi - audit_emit deadman-trip "$TRIGGER" running '{"phase":"start","simulate":"'"$SIM"'"}' SRC="真实 trip(外部 dead-man 服务回调)" [[ "$SIM" == "true" ]] && SRC="演习(workflow_dispatch simulate=true)" - - # 1) 置共用熔断变量(PATCH 已有 / 404 时 POST 新建——同 cost-check.sh set_breaker) - # 端点勘误(2026-08-21 首次演习实测):POST 须打集合端点 /actions/variables - # (不带变量名,带名=404);value 为字符串类型(-F 布尔=422)。 - # 首演 run 32481546304 因旧写法 INFRA(变量未置位),本修复后重演闭环。 - if ! gh api -X PATCH "orgs/$ORG/actions/variables/$CB" -f name="$CB" -f value=true >/dev/null 2>&1; then - if ! gh api -X POST "orgs/$ORG/actions/variables" -f name="$CB" -f value=true -f visibility=all >/dev/null 2>&1; then - infra "org 变量 $CB 置位失败(PATCH/POST 均败)" - fi - fi - act "熔断变量 $CB=true 已置位(与 cost-check 共用——宪法 §6 缺席即停;$SRC)" - - # 2) 撤全部 active 仓 open PR 的 auto-merge(模式同 cost-check.sh strip_all_automerge) - STRIPPED=0 - REPOS=$(python3 -c 'import yaml; repos=yaml.safe_load(open("governance/REPOS.yaml", encoding="utf-8"))["repos"]; print(" ".join(r["name"] for r in repos if r.get("status") == "active"))' | tr -d '\r') || REPOS="" - if [[ -z "$REPOS" ]]; then - infra "REPOS.yaml 解析失败——auto-merge 撤销清单不可得" - fi - for r in $REPOS; do - while IFS=$'\t' read -r n am; do - [[ "${am:-}" == "1" ]] || continue - if gh api -X DELETE "repos/$ORG/$r/pulls/$n/auto-merge" >/dev/null 2>&1; then - act "撤 auto-merge: $r#$n" - STRIPPED=$((STRIPPED+1)) - fi - done < <(gh pr list --repo "$ORG/$r" --state open --limit 200 \ - --json number,autoMergeRequest \ - --jq '.[] | [.number, (if .autoMergeRequest != null then "1" else "0" end)] | @tsv' 2>/dev/null) - done - ok "auto-merge 撤销完成:$STRIPPED 个 PR" - - # 3) P0 issue(label deadman-tripped,幂等去重;同日已评论不重复——防回调重放灌水) - gh label create deadman-tripped --repo "$GOV_REPO" \ - --description "dead-man trip 缺席即停标记(勿手工使用)" --color b60205 >/dev/null 2>&1 || true - EXISTING=$(gh issue list --repo "$GOV_REPO" --state open --label deadman-tripped \ - --json number --jq '.[0].number' 2>/dev/null) - TODAY=$(date -u +%F) - BODY="P0:dead-man 心跳缺席即停已触发(宪法 §6;$SRC,运行 $(date -u +%FT%TZ))。 - - - 已执行:org 变量 \`$CB\`=true(与 cost-check 共用熔断变量);active 仓 open PR 的 auto-merge 已撤销($STRIPPED 个)。 - - 效果:agent 派发与 automerge 前置检查将拒绝启动(AGENTS.md);auto-fix-limit 每轮机器执法撤销新 enable。 - - 信号链:butler-heartbeat 每 30min ping 外部 dead-man 服务 → 服务 grace(butler.yaml deadman_grace_minutes=60min)内未收到 → 回调本 workflow。 - - 处置(仅 owner 人工,完整 runbook 见 docs/deadman-setup.md): - 1. 排查管家 cron 静默根因(Actions 故障 / workflow 被删改 / token 失效 / 外部服务误报); - 2. 复位:\`gh api -X PATCH orgs/$ORG/actions/variables/$CB -f name=$CB -f value=false\`(或 DELETE 该变量); - 3. 在本 issue 留复位评论后关闭(留痕)。" - if [[ -n "$EXISTING" ]]; then - LAST=$(gh issue view "$EXISTING" --repo "$GOV_REPO" --json createdAt,comments \ - --jq '[.comments[].createdAt, .createdAt] | max' 2>/dev/null) || LAST="" - if [[ "$LAST" == "$TODAY"* ]]; then - ok "P0 已开(#$EXISTING)且今日已评论,跳过重复评论(防灌水)" - else - gh issue comment "$EXISTING" --repo "$GOV_REPO" --body "$BODY" >/dev/null 2>&1 || true - act "P0 已开(#$EXISTING),已评论本次 trip" - fi + if TRIGGER="${BUTLER_TRIGGER:-manual}" SRC="$SRC" SIM="$SIM" \ + bash governance/deadman-trip.sh; then + exit 1 # 脚本 0=已熔断——变红=可见信号;复位仅人工 else - if ! gh issue create --repo "$GOV_REPO" \ - --title "P0 dead-man trip:管家缺席,自动合并已停($CB=true)" \ - --body "$BODY" --label deadman-tripped >/dev/null 2>&1; then - infra "P0 issue 开立失败" - else - act "P0 issue 已开立(label deadman-tripped)" - fi - fi - - if [[ $INFRA -gt 0 ]]; then - audit_emit deadman-trip "$TRIGGER" infra-fail "{\"breaker\":\"partial\",\"automerge_stripped\":$STRIPPED,\"simulate\":\"$SIM\",\"infra_failures\":$INFRA}" || true - exit 2 + rc=$? + [[ $rc -eq 2 ]] || exit 1 # 非 infra 的非零也按已处置对待(脚本自身已 fail-closed) + exit $rc fi - audit_emit deadman-trip "$TRIGGER" tripped "{\"breaker\":\"set\",\"automerge_stripped\":$STRIPPED,\"simulate\":\"$SIM\"}" - exit 1 # 已熔断——变红=可见信号(同 cost-check tripped 语义);复位仅人工 diff --git a/.github/workflows/butler-heartbeat-watch.yml b/.github/workflows/butler-heartbeat-watch.yml new file mode 100644 index 0000000..de0eb28 --- /dev/null +++ b/.github/workflows/butler-heartbeat-watch.yml @@ -0,0 +1,75 @@ +name: butler-heartbeat-watch +# dead-man 仓内兜底层(宪法 §6 / ADR-0074 决策 2 第二层): +# butler-heartbeat(*/30 ping 外部 hc.io)最近一次成功 run 距今超过 +# butler.yaml deadman_stale_hours(默认 3h=连续 6 次缺失)→ 自动执行 +# deadman-trip(缺席即停)。覆盖"Actions 活着但心跳工作流被禁用/改名/损坏"形态; +# "Actions 整体静默"形态由外部层(hc.io → owner 告警,runbook 一键 trip)承担—— +# 仓内层不满足"必须外部"的纯粹形态,故为第二张网而非替代(ADR-0074)。 +# API 查询失败 = infra 红(exit 2)不 trip——假熔断需人工复位会停摆流水线 +# (ADR-0040 决策 5 同款权衡)。 +on: + schedule: + - cron: "43 */6 * * *" # 6h 错峰(:43 避开既有 cron 群) + workflow_dispatch: + inputs: + stale_hours_override: + description: "陈旧度阈值覆盖(小时;注入演习用;空=butler.yaml 真源)" + required: false + default: "" + +permissions: {} + +concurrency: + group: butler-heartbeat-watch + cancel-in-progress: false + +jobs: + watch: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 心跳陈旧度检查(超阈即 trip) + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + BUTLER_TRIGGER: ${{ github.event_name }} + STALE_HOURS_OVERRIDE: ${{ inputs.stale_hours_override }} + run: | + set -uo pipefail + echo "AUDIT | butler=heartbeat-watch | trigger=${BUTLER_TRIGGER:-schedule} | run_id=${GITHUB_RUN_ID} | started=$(date -u +%FT%TZ) | outcome=running | actions={\"phase\":\"start\"}" + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::error::缺 GOVERNANCE_TOKEN——陈旧度不可查,fail-closed 变红(不盲 trip)" >&2 + exit 2 + fi + STALE_H="${STALE_HOURS_OVERRIDE:-$(python3 -c 'import yaml; print(yaml.safe_load(open("governance/policy/butler.yaml", encoding="utf-8"))["deadman_stale_hours"])' | tr -d '\r')}" + [[ "$STALE_H" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "::error::deadman_stale_hours 非数值: $STALE_H" >&2; exit 2; } + # 最近一次成功 heartbeat run 的完成时间(workflow 文件名路由;无成功 run = 视为最陈旧) + LAST=$(gh api "repos/Cloudbird-Software/.github/actions/workflows/butler-heartbeat.yml/runs?status=success&per_page=1" \ + --jq '.workflow_runs[0].updated_at // empty' 2>/dev/null) + if [[ -z "$LAST" ]]; then + if gh api "repos/Cloudbird-Software/.github/actions/workflows/butler-heartbeat.yml/runs?per_page=1" --jq '.total_count' 2>/dev/null | grep -qE '^[0-9]+$'; then + AGE_S=999999999 # 有 run 但零成功 → 视为远古(会 trip) + AGE_H="∞(存在 run 但零成功)" + else + echo "::error::heartbeat runs 查询失败——fail-closed 变红(不盲 trip)" >&2 + exit 2 + fi + else + AGE_S=$(( $(date -u +%s) - $(date -u -d "$LAST" +%s) )) + AGE_H=$(( AGE_S / 3600 )) + fi + THRESH_S=$(( STALE_H * 3600 )) + echo "AUDIT | butler=heartbeat-watch | trigger=${BUTLER_TRIGGER:-schedule} | outcome=checked | actions={\"last_success\":\"${LAST:-none}\",\"age_hours\":\"$AGE_H\",\"threshold_hours\":$STALE_H}" + if (( AGE_S <= THRESH_S )); then + echo "OK 心跳新鲜(最近成功 ${AGE_H}h 前 ≤ 阈值 ${STALE_H}h)——无动作" + exit 0 + fi + echo "::error::心跳陈旧(${AGE_H}h > 阈值 ${STALE_H}h)——缺席即停(ADR-0074 双层之仓内层)" + SRC="仓内兜底(butler-heartbeat 最近成功已 ${AGE_H}h,阈值 ${STALE_H}h)" + TRIGGER="heartbeat-stale" SRC="$SRC" SIM=false bash governance/deadman-trip.sh + rc=$? + [[ $rc -eq 0 ]] && exit 1 || exit $rc # 脚本 0=已熔断→本 run 变红=可见信号 diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index b0015fd..d7ff436 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -88,7 +88,8 @@ jobs: # ADR-0040:生存护栏脚本纳入同一语法门(新增脚本不登记=语法检查盲区) bash -n governance/apply.sh && bash -n governance/drift-check.sh && bash -n scripts/new-repo-init.sh && bash -n scripts/gh-app-token.sh && bash -n scripts/ghcb \ && bash -n governance/auto-fix-limit.sh && bash -n governance/cost-check.sh \ - && bash -n governance/butler-reconcile.sh && bash -n governance/butler-audit.sh + && bash -n governance/butler-reconcile.sh \ + && bash -n governance/deadman-trip.sh && bash -n governance/butler-audit.sh echo "OK scripts" - name: 治理脚本自测(governance/tests) run: | diff --git a/docs/deadman-setup.md b/docs/deadman-setup.md index a25cab3..76f6ed9 100644 --- a/docs/deadman-setup.md +++ b/docs/deadman-setup.md @@ -1,10 +1,13 @@ # Dead-man 心跳配置 Runbook(owner 手工步骤) > 关联:宪法 §6(缺席即停 / 外部 dead-man 心跳)、§11(唤醒矩阵末行"外部 dead-man 心跳"); -> ADR-0057(W1-C5 .github#168)。 -> 管家代码侧(ping 侧 `butler-heartbeat`、trip 侧 `butler-deadman-trip`)已随 W1-C5 落地; -> **外部服务的注册与回调配置是 owner 手工步骤**——本页是操作手册。未完成本页配置时: -> 心跳 workflow 输出 WARN(不红),trip 通道可通过手动 dispatch 演习(见 §4)。 +> ADR-0057(W1-C5 .github#168)、ADR-0074(双层触发修订——hc.io 无法直连回调 GitHub, +> 外部层=检测+owner 告警,仓内层=heartbeat-watch 陈旧度自动 trip)。 +> +> **当前状态(2026-08-21)**:healthchecks.io check 已注册(owner)、`DEADMAN_PING_URL` +> org secret 已配置、`butler-heartbeat` 实测 ping 成功(attempt 1/2)、仓内兜底 +> `butler-heartbeat-watch` 已上线。**剩余 owner 侧仅 §1.3 的 grace 核对与 §3 的 +> 告警通道选择**——自动 trip 已由仓内层承担,外部回调不再是必需项。 ## 为什么心跳必须在外部 @@ -32,40 +35,24 @@ gh secret set DEADMAN_PING_URL --org Cloudbird-Software -b"https://hc-ping.com/< - 配置后 `butler-heartbeat`(每 30min)自动开始 ping;healthchecks.io 页面应出现 成功心跳记录(最迟 30min 内)。 -## 3. 失败回调配置(grace 超时 → 触发缺席即停) +## 3. 失败告警配置(grace 超时 → owner 知晓 → 缺席即停) -healthchecks.io → check → **Integrations** 添加 Webhook,URL 指向 GitHub -repository_dispatch(需要一枚具 `repo` scope 的 PAT,可用 owner 经典 PAT;勿用临时 -token——回调凭据是长期运行的管道): +**ADR-0074 双层触发**(hc.io 的 Webhook 不能带 Authorization header,无法直接回调 +GitHub repository_dispatch——这是实测边界,不是配置遗漏): -``` -https://api.github.com/repos/Cloudbird-Software/.github/dispatches -``` - -healthchecks.io 的 Webhook 只支持 GET/POST 简单形态,不能带 JSON body 与自定义 -header,因此实际推荐任一中间形态(三选一): - -- **方案 A(推荐):Cloudflare Worker / 任意 1 行转发服务**——收到 healthchecks 回调 - (GET,URL 末尾带 `/fail`)后转发 repository_dispatch: +- **仓内层(已上线,自动)**:`butler-heartbeat-watch`(6h)检测 heartbeat 成功 run + 陈旧度 > `deadman_stale_hours`(3h)→ 自动执行 `governance/deadman-trip.sh`。 + 覆盖"Actions 活着但心跳工作流被禁用/改名/损坏"。 +- **外部层(hc.io 账号侧,owner 只需勾告警通道)**:check → **Integrations** 勾选 + 邮件/Slack/Telegram 任一。覆盖"Actions 整体静默"(此时仓内层同死,只有外部 + 服务能说话)。owner 收到告警后一键 trip: ```bash - curl -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $PAT" \ - https://api.github.com/repos/Cloudbird-Software/.github/dispatches \ - -d '{"event_type":"deadman-tripped"}' + gh api -X POST repos/Cloudbird-Software/.github/dispatches -H "Authorization: Bearer $PAT" -f event_type=deadman-tripped ``` -- **方案 B:healthchecks.io 的 Ping body / 管理脚本**——用其 "Shell" 集成模板直连上方 - curl(token 放服务侧模板变量,不落 GitHub)。 - -- **方案 C(最低成本兜底)**:不配自动回调,依赖 healthchecks.io 的邮件/Telegram 告警, - owner 收到告警后手动执行上方 curl 或直接在 Actions 页 dispatch - `butler-deadman-trip`(simulate=false)。诚实代价:缺席即停从自动变人工,但可见性 - 不丢。 - -无论哪种方案,PAT 建议专用窄权限(只读 dispatch 不存在——`repo` scope 是最低可用), -泄漏面控制在该服务一处。 +若日后想让外部层也全自动(可选):Cloudflare Worker 一行转发(收到 hc.io 回调 +GET 后代发上方 curl,PAT 存 Worker 侧 secret)——非必需,双层已满足宪法 §6。 ## 4. 演习步骤(月度正控建议 + 上线验证) diff --git a/governance/butler-reconcile.sh b/governance/butler-reconcile.sh index 420ab1a..bc086da 100644 --- a/governance/butler-reconcile.sh +++ b/governance/butler-reconcile.sh @@ -5,9 +5,9 @@ # (a) 僵尸卡:open issue 挂 state:in-progress 且 updated 距今 > stale_in_progress_days # → .github 仓开/评论 needs-human issue(label butler:needs-human);同卡去重: # 已有 open issue 标题含 "# " 即评论不重开,且同日已评论则跳过(防灌水) -# (b) 孤儿标签:closed issue 仍挂任意 state:* 标签 → 记入 reconcile 报告 issue -# (label butler:reconcile)。本卡 v1 只报告不纠正:状态标签写操作一律 App 令牌 -# 经仲裁路径(INV-02)——纠正动作留给后续波次的执法卡,管家先让不一致可见。 +# (b) 孤儿标签:已移除(ADR-0074 修订 ADR-0057)——closed issue 退出状态机, +# 其 state:* 标签是历史事实非漂移(closed+state:done 是正常终态形态); +# 管家不纠正历史(INV-02)。 # (c) 隔离超时:open issue 挂 state:quarantine 且 updated 距今 > stale_quarantine_days # → 升 needs-human(同 (a) 去重)。用 updated 代理"停留时长":隔离期间仍有人 # 评论/更新 = 有活动,不升级是合理语义;静置才是要抓的滞留。 @@ -161,8 +161,7 @@ escalate() { # /dev/null 2>&1 || true @@ -279,11 +264,11 @@ fi # ---------- 汇总 AUDIT + 退出码 ---------- if [[ $INFRA -gt 0 ]]; then - audit infra-fail "{\"repos\":$REPO_COUNT,\"stale_in_progress\":$STALE_COUNT,\"orphan_state_labels\":$ORPHAN_COUNT,\"stale_quarantine\":$QUAR_COUNT,\"report\":\"$REPORT_ACTION\",\"infra_failures\":$INFRA}" + audit infra-fail "{\"repos\":$REPO_COUNT,\"stale_in_progress\":$STALE_COUNT,\"stale_quarantine\":$QUAR_COUNT,\"report\":\"$REPORT_ACTION\",\"infra_failures\":$INFRA}" exit 2 fi if [[ $FINDINGS -gt 0 ]]; then - audit findings "{\"repos\":$REPO_COUNT,\"stale_in_progress\":$STALE_COUNT,\"orphan_state_labels\":$ORPHAN_COUNT,\"stale_quarantine\":$QUAR_COUNT,\"report\":\"$REPORT_ACTION\"}" + audit findings "{\"repos\":$REPO_COUNT,\"stale_in_progress\":$STALE_COUNT,\"stale_quarantine\":$QUAR_COUNT,\"report\":\"$REPORT_ACTION\"}" exit 1 fi audit ok "{\"repos\":$REPO_COUNT,\"stale_in_progress\":0,\"orphan_state_labels\":0,\"stale_quarantine\":0,\"report\":\"$REPORT_ACTION\"}" diff --git a/governance/deadman-trip.sh b/governance/deadman-trip.sh new file mode 100644 index 0000000..6b7ffbd --- /dev/null +++ b/governance/deadman-trip.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# deadman-trip.sh —— 缺席即停的执法核心(宪法 §6 / ADR-0057 / ADR-0074) +# +# 单一实现,两个入口共用(防双实现漂移,ADR-0074 决策 3): +# - .github/workflows/butler-deadman-trip.yml(事件入口:repository_dispatch +# 外部回调 + workflow_dispatch 演习) +# - .github/workflows/butler-heartbeat-watch.yml(仓内陈旧度兜底:heartbeat +# 成功 run 超 stale 阈值即自动 trip——覆盖"Actions 活着但心跳工作流死了") +# +# 动作(GOVERNANCE_TOKEN 凭据,由调用方在 env 里提供 GH_TOKEN): +# 1) 置共用熔断变量 AUTO_MERGE_DISABLED=true(与 cost-check 共用——两套变量= +# 两套复位路径/两套旁路窗口);2) 撤全部 active 仓 open PR auto-merge; +# 3) P0 issue(label deadman-tripped,幂等去重)。 +# 退出码:0=已熔断完毕(=可见信号,调用方按需变红)| 2=infra(变量置位失败等) +# 用法: TRIGGER=<触发来源> SRC=<人类可读来源> [SIM=true|false] bash deadman-trip.sh +set -uo pipefail +ORG="${ORG:-Cloudbird-Software}" +GOV_REPO="$ORG/.github" +CB=AUTO_MERGE_DISABLED +TRIGGER="${TRIGGER:-manual}" +SRC="${SRC:-未知来源}" +SIM="${SIM:-false}" +INFRA=0 + +ok() { echo "OK $1"; } +act() { echo "ACT $1"; } +infra(){ echo "INFRA $1" >&2; INFRA=$((INFRA+1)); } + +# 审计行(INV-12;与 butler-audit.sh 同格式,但本脚本可能被多种 trigger 调用, +# 单独实现轻量版避免 source 依赖环) +audit_line() { echo "AUDIT | butler=deadman-trip | trigger=$TRIGGER | run_id=${GITHUB_RUN_ID:-local} | repo=${GITHUB_REPOSITORY:-local} | started=$(date -u +%FT%TZ) | outcome=$1 | actions=$2"; } + +if [[ -z "${GH_TOKEN:-}" ]]; then + audit_line infra-fail '{"fatal":"GH_TOKEN missing (CI: org secret GOVERNANCE_TOKEN)"}' + echo "::error::缺 GH_TOKEN(CI: org secret GOVERNANCE_TOKEN)——trip 无法执行缺席即停(fail-closed 变红)" >&2 + exit 2 +fi +audit_line running '{"phase":"start","simulate":"'"$SIM"'","src":"'"$SRC"'"}' + +# 1) 置共用熔断变量(PATCH 已有 / 404 时 POST 新建) +# 端点勘误(2026-08-21 首演 run 32481546304 实测):POST 须打集合端点 +# /actions/variables(不带变量名,带名=404);value 为字符串(-F 布尔=422)。 +if ! gh api -X PATCH "orgs/$ORG/actions/variables/$CB" -f name="$CB" -f value=true >/dev/null 2>&1; then + if ! gh api -X POST "orgs/$ORG/actions/variables" -f name="$CB" -f value=true -f visibility=all >/dev/null 2>&1; then + infra "org 变量 $CB 置位失败(PATCH/POST 均败)" + fi +fi +act "熔断变量 $CB=true 已置位(与 cost-check 共用——宪法 §6 缺席即停;$SRC)" + +# 2) 撤全部 active 仓 open PR 的 auto-merge(模式同 cost-check.sh strip_all_automerge) +STRIPPED=0 +REPOS=$(python3 -c 'import yaml; repos=yaml.safe_load(open("governance/REPOS.yaml", encoding="utf-8"))["repos"]; print(" ".join(r["name"] for r in repos if r.get("status") == "active"))' | tr -d '\r') || REPOS="" +if [[ -z "$REPOS" ]]; then + infra "REPOS.yaml 解析失败——auto-merge 撤销清单不可得" +fi +for r in $REPOS; do + while IFS=$'\t' read -r n am; do + [[ "${am:-}" == "1" ]] || continue + if gh api -X DELETE "repos/$ORG/$r/pulls/$n/auto-merge" >/dev/null 2>&1; then + act "撤 auto-merge: $r#$n" + STRIPPED=$((STRIPPED+1)) + fi + done < <(gh pr list --repo "$ORG/$r" --state open --limit 200 \ + --json number,autoMergeRequest \ + --jq '.[] | [.number, (if .autoMergeRequest != null then "1" else "0" end)] | @tsv' 2>/dev/null) +done +ok "auto-merge 撤销完成:$STRIPPED 个 PR" + +# 3) P0 issue(label deadman-tripped,幂等去重;同日已评论不重复——防回调重放灌水) +gh label create deadman-tripped --repo "$GOV_REPO" \ + --description "dead-man trip 缺席即停标记(勿手工使用)" --color b60205 >/dev/null 2>&1 || true +EXISTING=$(gh issue list --repo "$GOV_REPO" --state open --label deadman-tripped \ + --json number --jq '.[0].number' 2>/dev/null) +TODAY=$(date -u +%F) +BODY="P0:dead-man 心跳缺席即停已触发(宪法 §6;$SRC,运行 $(date -u +%FT%TZ))。 + +- 已执行:org 变量 \`$CB\`=true(与 cost-check 共用熔断变量);active 仓 open PR 的 auto-merge 已撤销($STRIPPED 个)。 +- 效果:agent 派发与 automerge 前置检查将拒绝启动(AGENTS.md);auto-fix-limit 每轮机器执法撤销新 enable。 +- 信号链(ADR-0074 双层):butler-heartbeat 每 30min ping 外部 dead-man 服务;仓内 butler-heartbeat-watch 兜底检测陈旧度。 + +处置(仅 owner 人工,完整 runbook 见 docs/deadman-setup.md): +1. 排查管家 cron 静默根因(Actions 故障 / workflow 被删改 / token 失效 / 外部服务误报); +2. 复位:\`gh api -X PATCH orgs/$ORG/actions/variables/$CB -f name=$CB -f value=false\`(或 DELETE 该变量); +3. 在本 issue 留复位评论后关闭(留痕)。" +if [[ -n "$EXISTING" ]]; then + LAST=$(gh issue view "$EXISTING" --repo "$GOV_REPO" --json createdAt,comments \ + --jq '[.comments[].createdAt, .createdAt] | max' 2>/dev/null) || LAST="" + if [[ "$LAST" == "$TODAY"* ]]; then + ok "P0 已开(#$EXISTING)且今日已评论,跳过重复评论(防灌水)" + else + gh issue comment "$EXISTING" --repo "$GOV_REPO" --body "$BODY" >/dev/null 2>&1 || true + act "P0 已开(#$EXISTING),已评论本次 trip" + fi +else + if ! gh issue create --repo "$GOV_REPO" \ + --title "P0 dead-man trip:管家缺席,自动合并已停($CB=true)" \ + --body "$BODY" --label deadman-tripped >/dev/null 2>&1; then + infra "P0 issue 开立失败" + else + act "P0 issue 已开立(label deadman-tripped)" + fi +fi + +if [[ $INFRA -gt 0 ]]; then + audit_line infra-fail "{\"breaker\":\"partial\",\"automerge_stripped\":$STRIPPED,\"simulate\":\"$SIM\",\"infra_failures\":$INFRA}" + exit 2 +fi +audit_line tripped "{\"breaker\":\"set\",\"automerge_stripped\":$STRIPPED,\"simulate\":\"$SIM\"}" +exit 0 diff --git a/governance/policy/butler.yaml b/governance/policy/butler.yaml index 048b255..a109ea9 100644 --- a/governance/policy/butler.yaml +++ b/governance/policy/butler.yaml @@ -11,14 +11,14 @@ version: 1 # ============================================================================ wake_matrix: - # 行 1:主收敛循环——僵尸卡/孤儿标签/隔离超时(事件丢失的最终兜底) + # 行 1:主收敛循环——僵尸卡/隔离超时(事件丢失的最终兜底)。原"孤儿标签"检查 + # 已移除(ADR-0074:closed issue 的 state:* 是历史事实非漂移) - duty: reconcile trigger: cron cadence: every_6h # 机器可读语义值;实际 cron 表达式在 workflow 里(含错峰分钟) workflow: .github/workflows/butler-reconcile.yml checks: - stale_in_progress # state:in-progress 卡超时未更新 → needs-human - - orphan_state_labels # closed issue 仍挂 state:* 标签 → 记入报告 - stale_quarantine # state:quarantine 停留超时 → 升 needs-human # 行 2:账本/dashboard 刷新 + Project 板同步(宪法 §12 投影;投影脚本由 W1-C3 提供) - duty: ledger-refresh @@ -46,9 +46,14 @@ services: workflow: .github/workflows/butler-heartbeat.yml notes: 外部 dead-man 服务的 ping 侧;DEADMAN_PING_URL 未配置时 WARN 不红(owner runbook 见 docs/deadman-setup.md) - duty: deadman-trip - trigger: repository_dispatch # type=deadman-tripped(外部服务超时回调)+ workflow_dispatch(演习) + trigger: repository_dispatch # type=deadman-tripped(外部回调)+ workflow_dispatch(演习) workflow: .github/workflows/butler-deadman-trip.yml - notes: 缺席即停(宪法 §6)——置共用熔断变量 AUTO_MERGE_DISABLED + 撤全部 auto-merge + P0 issue + notes: 缺席即停(宪法 §6)事件入口——执法核心 governance/deadman-trip.sh(ADR-0074 单一实现) + - duty: deadman-watch + trigger: cron # 仓内兜底层(ADR-0074 决策 2):heartbeat 成功 run 陈旧度 + cadence: every_6h + workflow: .github/workflows/butler-heartbeat-watch.yml + notes: 覆盖"Actions 活着但心跳工作流死了"形态;"Actions 整体静默"由外部层(hc.io→owner 告警)承担 # 阈值(改动走 C1:本文件在 governance/ 路径,PR 须引用 ADR) thresholds: @@ -58,3 +63,6 @@ thresholds: stale_quarantine_days: 2 # 外部 dead-man 服务的 grace 时长(分钟)——心跳每 30min 一次,grace=60 容忍一次丢失 deadman_grace_minutes: 60 + # 仓内兜底层:butler-heartbeat 最近一次成功 run 距今超过 N 小时 → 自动 trip + #(ADR-0074;3h=连续 6 次缺失 ping;API 查询失败=infra 红不盲 trip) + deadman_stale_hours: 3 diff --git a/scripts/__pycache__/adr_index_map.cpython-311.pyc b/scripts/__pycache__/adr_index_map.cpython-311.pyc new file mode 100644 index 0000000..6601591 Binary files /dev/null and b/scripts/__pycache__/adr_index_map.cpython-311.pyc differ