Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 16 additions & 98 deletions .github/workflows/butler-deadman-trip.yml
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 语义);复位仅人工
75 changes: 75 additions & 0 deletions .github/workflows/butler-heartbeat-watch.yml
Original file line number Diff line number Diff line change
@@ -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')}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Stale阈值读取路径错误 🐞 Bug ≡ Correctness

butler-heartbeat-watch 用 Python 从 governance/policy/butler.yaml 读取
deadman_stale_hours,但该字段实际位于 thresholds.deadman_stale_hours,导致命令替换失败并使该 workflow 永远 exit 2(infra
红、不 trip)。结果是仓内兜底层不可用,无法按 ADR-0074 预期自动触发缺席即停。
Agent Prompt
### Issue description
`butler-heartbeat-watch.yml` reads `deadman_stale_hours` from `governance/policy/butler.yaml` using `yaml.safe_load(...)["deadman_stale_hours"]`, but the policy file defines it under `thresholds.deadman_stale_hours`. This makes the Python snippet raise a KeyError, resulting in an empty `STALE_H` and the workflow exiting with infra (2), effectively disabling the stale watchdog.

### Issue Context
This watchdog is the ADR-0074 “repo-internal layer”; if it always fails infra, deadman will not be automatically tripped when the heartbeat workflow is broken/disabled.

### Fix Focus Areas
- .github/workflows/butler-heartbeat-watch.yml[48-49]
- governance/policy/butler.yaml[58-68]

### Suggested change
Update the Python expression to read the nested key:
- `...["thresholds"]["deadman_stale_hours"]`

Also consider adding a defensive default/error message if the key is missing, so it fails with a clear infra error rather than a confusing empty value.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

[[ "$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}"
Comment on lines +48 to +66
Comment on lines +65 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. 小数阈值导致算术错误 🐞 Bug ☼ Reliability

butler-heartbeat-watch 允许 deadman_stale_hours 为小数(正则接受 3.5),但随后用 Bash 算术扩展 `THRESH_S=$((
STALE_H * 3600 ))` 计算秒数,Bash 不支持浮点会直接报错并使 job 失败。该失败会把“阈值合法但为小数”的配置误判成 infra 红。
Agent Prompt
### Issue description
`butler-heartbeat-watch.yml` validates `STALE_H` with a regex that allows decimals, but then uses Bash arithmetic expansion to compute seconds. Bash arithmetic is integer-only, so values like `3.5` will cause a runtime arithmetic error.

### Issue Context
Even if today’s default is `3`, the workflow explicitly documents/accepts decimals; future overrides (e.g., drills) can break the watchdog.

### Fix Focus Areas
- .github/workflows/butler-heartbeat-watch.yml[48-66]

### Suggested change (pick one)
1) If decimals are not needed: tighten validation to integers only (`^[0-9]+$`).
2) If decimals are desired: compute `THRESH_S` via Python:
   - `THRESH_S=$(python3 -c 'import sys; print(int(float(sys.argv[1])*3600))' "$STALE_H")`
   and then keep the `(( AGE_S <= THRESH_S ))` comparison integer-based.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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 变红=可见信号
3 changes: 2 additions & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
51 changes: 19 additions & 32 deletions docs/deadman-setup.md
Original file line number Diff line number Diff line change
@@ -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 已由仓内层承担,外部回调不再是必需项。

## 为什么心跳必须在外部

Expand Down Expand Up @@ -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. 演习步骤(月度正控建议 + 上线验证)

Expand Down
Loading