diff --git a/.github/workflows/butler-deadman-trip.yml b/.github/workflows/butler-deadman-trip.yml new file mode 100644 index 0000000..00d29ec --- /dev/null +++ b/.github/workflows/butler-deadman-trip.yml @@ -0,0 +1,136 @@ +name: butler-deadman-trip +# 缺席即停 trip 侧(宪法 §6 外部 dead-man 心跳;ADR-0057,W1-C5 .github#168) +# 触发:外部 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——硬停是全局语义,不分作者); +# 3) 开 P0 issue(label deadman-tripped,幂等去重)+ AUDIT 行。 +# 退出码:执法成功完毕 exit 1(=已熔断,变红=可见信号,同 cost-check tripped 语义); +# infra 故障(变量置位失败等)exit 2。复位仅人工:PATCH 变量 false + P0 留评论关闭 +# (docs/deadman-setup.md)。 +on: + repository_dispatch: + types: [deadman-tripped] # 外部 dead-man 服务的失败回调(runbook 见 docs/deadman-setup.md) + workflow_dispatch: + inputs: + simulate: + description: "true=演习(默认;动作与真实 trip 完全相同——熔断必须真置位才算演习,复位路径见 docs/deadman-setup.md)" + required: false + default: "true" + +permissions: {} + +concurrency: + group: butler-deadman-trip # trip 幂等:并发触发串行执行,后到者看到变量已置/P0 已开即去重 + cancel-in-progress: false + +jobs: + trip: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + issues: write # P0 issue 开在 .github 仓(gh org 变量/跨仓 auto-merge 撤销用 GOVERNANCE_TOKEN) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 缺席即停——置熔断+撤 auto-merge+P0 + 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) + 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/$CB" -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 + 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 + 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.yml b/.github/workflows/butler-heartbeat.yml new file mode 100644 index 0000000..c330447 --- /dev/null +++ b/.github/workflows/butler-heartbeat.yml @@ -0,0 +1,58 @@ +name: butler-heartbeat +# 外部 dead-man 心跳 ping 侧(宪法 §6;ADR-0057,W1-C5 .github#168) +# 每 30min ping 外部 dead-man 服务(healthchecks.io 或任意同类;owner runbook: +# docs/deadman-setup.md)。服务侧 grace = butler.yaml thresholds.deadman_grace_minutes +# (60min=容忍一次 ping 丢失);超时未收到 ping → 服务回调 butler-deadman-trip +# (缺席即停)。"心跳是唤醒的唤醒,也必须外部"(宪法 §11 末行)——GitHub 侧只做 +# 被动 ping 客户端与 trip 接收方:GitHub cron 全挂时 GitHub 自己无法自我报警, +# 缺席判定必须在外部服务。 +# DEADMAN_PING_URL(org secret,owner 手工步骤)未配置 → WARN 审计行不红——骨架期 +# 不因缺外部配置阻塞;配置后 curl 失败重试 1 次仍败 → 变红(心跳管道故障可见: +# 持续失败意味着外部服务将判定管家缺席并触发 trip)。 +on: + schedule: + - cron: "*/30 * * * *" # 每 30min(:00/:30 与其他治理 cron 重叠可接受——单次 curl <1s) + workflow_dispatch: {} + +permissions: {} + +concurrency: + group: butler-heartbeat + cancel-in-progress: false + +jobs: + ping: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read # 仅为读取 governance/butler-audit.sh + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: dead-man ping + env: + DEADMAN_PING_URL: ${{ secrets.DEADMAN_PING_URL }} + BUTLER_TRIGGER: ${{ github.event_name }} + run: | + set -uo pipefail + source governance/butler-audit.sh + TRIGGER="${BUTLER_TRIGGER:-manual}" + # 未配置(org secret 缺失时 env 为空串)→ WARN 不红:外部服务注册是 owner + # 手工步骤(docs/deadman-setup.md),骨架期代码侧已完备即可 + if [[ -z "${DEADMAN_PING_URL:-}" ]]; then + echo "WARN DEADMAN_PING_URL 未配置——外部 dead-man 服务 owner 侧待配置(runbook: docs/deadman-setup.md);骨架期不红" + audit_emit deadman-ping "$TRIGGER" warn '{"deadman":"unconfigured","runbook":"docs/deadman-setup.md"}' + exit 0 + fi + for attempt in 1 2; do + if curl -fsS --max-time 20 "$DEADMAN_PING_URL" >/dev/null; then + echo "OK dead-man ping 成功(attempt $attempt/2)" + audit_emit deadman-ping "$TRIGGER" ok '{"deadman":"ping-ok","attempt":'$attempt'}' + exit 0 + fi + echo "WARN dead-man ping 失败(attempt $attempt/2,max-time 20s)" + done + audit_emit deadman-ping "$TRIGGER" fail '{"deadman":"ping-failed-twice"}' || true + echo "::error::dead-man ping 两次失败——心跳管道故障(外部服务不可达/URL 失效),变红可见;若持续失败,外部服务将按 grace 判定管家缺席并触发 trip(butler-deadman-trip)" >&2 + exit 1 diff --git a/.github/workflows/butler-ledger.yml b/.github/workflows/butler-ledger.yml new file mode 100644 index 0000000..a791d5f --- /dev/null +++ b/.github/workflows/butler-ledger.yml @@ -0,0 +1,64 @@ +name: butler-ledger +# 管家账本刷新——唤醒矩阵行 2(宪法 §11 行 2 / §12 投影;ADR-0057,W1-C5 .github#168) +# 每 15min 调用 W1-C3 的投影脚本:governance/board-sync.py(label→Project 板)与 +# governance/dashboard-update.py(dashboard 账本 issue 刷新)。**两脚本由 C3 卡并行 +# 开发、本卡时点尚未落盘**——用 [ -f ] 守卫:存在才跑;不存在输出 +# skipped 审计行且保持绿(守卫原因:账本刷新骨架先行——cron 节奏与审计形态先定型, +# 投影脚本随后合入即自动生效,两卡解耦不互相阻塞)。 +# 骨架期本卡自带轻量记账:每次运行无条件追加一条 dashboard 备注行(v1 仅审计日志, +# 不动 issue——dashboard 账本 issue 归 C3 建)。 +on: + schedule: + - cron: "*/15 * * * *" # 每 15min(宪法 §11 行 2;:00 与 governance-drift 整点重叠可接受——drift 是只读 GET 扫描,本流程是 GraphQL 投影同步,通道不同) + workflow_dispatch: {} + +permissions: {} + +concurrency: + group: butler-ledger + cancel-in-progress: false # 15min 高频:排队不取消——取消进行中的同步会留下半写投影状态 + +jobs: + ledger: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 投影脚本守卫调用 + 轻量记账 + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} # C3 投影脚本的跨仓/GraphQL 读 + BUTLER_TRIGGER: ${{ github.event_name }} + run: | + set -uo pipefail + source governance/butler-audit.sh + TRIGGER="${BUTLER_TRIGGER:-manual}" + # --- W1-C3 投影脚本一:board-sync.py(守卫:未落地=skipped 保持绿) --- + if [[ -f governance/board-sync.py ]]; then + if ! python3 governance/board-sync.py; then + audit_emit ledger-refresh "$TRIGGER" infra-fail '{"board_sync":"failed"}' || true + echo "::error::board-sync.py 失败(fail-closed——投影失败不得静默)" >&2 + exit 2 + fi + audit_emit ledger-refresh "$TRIGGER" ok '{"board_sync":"ran"}' + else + echo "OK governance/board-sync.py 不存在(W1-C3 未合并)——skipped" + audit_emit ledger-refresh "$TRIGGER" ok '{"skipped":"dashboard-scripts-not-landed(W1-C3)","board_sync":"absent"}' + fi + # --- W1-C3 投影脚本二:dashboard-update.py(同上守卫) --- + if [[ -f governance/dashboard-update.py ]]; then + if ! python3 governance/dashboard-update.py; then + audit_emit ledger-refresh "$TRIGGER" infra-fail '{"dashboard_update":"failed"}' || true + echo "::error::dashboard-update.py 失败(fail-closed——账本刷新失败不得静默)" >&2 + exit 2 + fi + audit_emit ledger-refresh "$TRIGGER" ok '{"dashboard_update":"ran"}' + else + echo "OK governance/dashboard-update.py 不存在(W1-C3 未合并)——skipped" + audit_emit ledger-refresh "$TRIGGER" ok '{"skipped":"dashboard-scripts-not-landed(W1-C3)","dashboard_update":"absent"}' + fi + # --- 本卡自有轻量记账(无条件):dashboard 备注行 v1=审计日志形态 --- + audit_emit ledger-refresh "$TRIGGER" ok '{"ledger":"append","note_row":{"ts":"'"$(date -u +%FT%TZ)"'","run_id":"'"${GITHUB_RUN_ID:-local}"'","kind":"dashboard-remark-v1","sli_keys_reserved":["auto_merge_rate","check_latency","revert_count"]}}' diff --git a/.github/workflows/butler-reconcile.yml b/.github/workflows/butler-reconcile.yml new file mode 100644 index 0000000..239645d --- /dev/null +++ b/.github/workflows/butler-reconcile.yml @@ -0,0 +1,50 @@ +name: butler-reconcile +# 管家主收敛循环——唤醒矩阵行 1(宪法 §11;ADR-0057,W1-C5 .github#168) +# 每 6h 遍历 REPOS.yaml active 仓:僵尸卡(state:in-progress 停滞)/ 孤儿标签 +# (closed 仍挂 state:*)/ 隔离超时(state:quarantine 滞留)→ needs-human issue + +# reconcile 报告 issue(label 去重、同日防灌水)。铁律(宪法 §11):管家永远不 +# "自己醒来"——本 workflow 只有 cron 与手动 dispatch 两个触发器,每次运行产出 +# AUDIT 审计行(INV-12,governance/butler-audit.sh)。脚本细节见 +# governance/butler-reconcile.sh 头注;阈值真源 governance/policy/butler.yaml。 +on: + schedule: + - cron: "17 */6 * * *" # 每 6h :17(宪法 §11 行 1;错峰避开整点 governance-drift、:18 auto-fix-limit、:23 cost-check) + workflow_dispatch: + # 注入入口(AC-2 演习用;空=butler.yaml 真源,不留常开旁路——同 governance-drift P1-4 模式) + inputs: + stale_days_override: + description: "in-progress stale 阈值覆盖(天;0=全部立即 stale——演习制造检出;空=butler.yaml 真源)" + required: false + default: "" + dry_run: + description: "1=只报告不写(预检)" + required: false + default: "" + +permissions: {} + +# 串行化:开 issue 的"查重→写"非原子,并发运行会重复开 issue(同 governance-drift 设计) +concurrency: + group: butler-reconcile + cancel-in-progress: false + +jobs: + reconcile: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # 读 governance/ 脚本与 policy + issues: write # needs-human/报告 issue 开在 .github 仓(GITHUB_TOKEN 最小权限: + # 跨仓读走 GOVERNANCE_TOKEN,本仓写不占用 org 治理令牌) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 一致性扫描(exit 1=有发现已开 needs-human 2=基础设施故障) + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} # 跨仓读(缺失=脚本 fail-closed 变红) + GH_WRITE_TOKEN: ${{ github.token }} # 本仓 issue 写(最小权限分离) + BUTLER_TRIGGER: ${{ github.event_name }} + STALE_DAYS_OVERRIDE: ${{ inputs.stale_days_override }} + BUTLER_DRY_RUN: ${{ inputs.dry_run }} + run: bash governance/butler-reconcile.sh diff --git a/.github/workflows/cost-check.yml b/.github/workflows/cost-check.yml index dc481af..5e4248e 100644 --- a/.github/workflows/cost-check.yml +++ b/.github/workflows/cost-check.yml @@ -1,12 +1,14 @@ name: cost-check -# 额度/成本熔断(ADR-0040,P2-8 .github#93): -# 6h 周期拉取 /orgs/{org}/settings/billing/usage 当月 Actions 分钟 vs +# 额度/成本熔断(ADR-0040,P2-8 .github#93;cron 收紧至 1h:ADR-0057,W1-C5 .github#168): +# 每小时拉取 /orgs/{org}/settings/billing/usage 当月 Actions 分钟 vs # policy/automation-limits.yaml 声明预算——≥80% 告警 issue;≥100% 置 org 变量 # AUTO_MERGE_DISABLED + 撤全部 open PR auto-merge + P0 issue。复位仅人工(变量 PATCH/DELETE # + P0 留评论),脚本观察到复位后自动关 P0。脚本细节/注入通道见 governance/cost-check.sh 头注。 +# 本流程是管家唤醒矩阵第 3 行(宪法 §11 预算/配额检查 1h)的落地;每次运行产出 +# AUDIT 审计行(INV-12,头行+EXIT 陷阱尾行,trigger 由 COST_TRIGGER 注入)。 on: schedule: - - cron: "42 */6 * * *" # 每 6h :42(避开整点 governance-drift 与 :18 auto-fix-limit) + - cron: "23 * * * *" # 每小时 :23(宪法 §11 行 3 的 1h 预算检查——ADR-0057 由 6h 收紧;避开整点 governance-drift 与 :18 auto-fix-limit) workflow_dispatch: # 注入入口(T2 注入式测试:79%/85%/100% 全场景不依赖真实超支):空=真实 API/真源 inputs: @@ -50,6 +52,7 @@ jobs: - name: 用量检查与熔断(exit 1=触发告警/熔断 2=基础设施故障) env: GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + COST_TRIGGER: ${{ github.event_name }} # AUDIT 行 trigger 字段(schedule/workflow_dispatch——INV-12 谁唤醒) COST_USAGE_MINUTES_OVERRIDE: ${{ inputs.usage_minutes_override }} COST_QUOTA_MINUTES_OVERRIDE: ${{ inputs.quota_minutes_override }} COST_LLM_TOKENS_USED_OVERRIDE: ${{ inputs.llm_tokens_used_override }} diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 6a9a6ff..da9ffb7 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -87,7 +87,8 @@ jobs: run: | # 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/auto-fix-limit.sh && bash -n governance/cost-check.sh \ + && bash -n governance/butler-reconcile.sh && bash -n governance/butler-audit.sh echo "OK scripts" - name: REPOS.yaml 引用自检(无重名仓) run: | diff --git a/docs/deadman-setup.md b/docs/deadman-setup.md new file mode 100644 index 0000000..2e7d1f4 --- /dev/null +++ b/docs/deadman-setup.md @@ -0,0 +1,104 @@ +# 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)。 + +## 为什么心跳必须在外部 + +"心跳是唤醒的唤醒,也必须外部"(宪法 §11):GitHub cron 全挂(Actions 故障、workflow +被删改、token 失效)时,GitHub 自己无法自我报警——缺席判定只能由独立的第三方服务做出。 +GitHub 侧只做两件事:被动 ping(`butler-heartbeat`,每 30min)与被动接收 trip +(`butler-deadman-trip`,`repository_dispatch`)。 + +## 1. 注册外部 dead-man 服务(以 healthchecks.io 为例,任意同类服务均可) + +1. 注册 (免费层足够:20 个 check、100 天日志)。 +2. 新建 check,命名建议 `cloudbird-butler-heartbeat`。 +3. **Grace Time = 60 分钟**(= `governance/policy/butler.yaml` 的 + `thresholds.deadman_grace_minutes`;心跳每 30min 一次,60min grace 容忍一次 ping + 丢失,连续两次丢失即判定缺席)。改阈值须两侧同步改。 +4. 记下 check 的 ping URL(形如 `https://hc-ping.com/`)。 + +## 2. 注入 org secret(需 org admin) + +```bash +gh secret set DEADMAN_PING_URL --org Cloudbird-Software -b"https://hc-ping.com/" +``` + +- 建议可见性 = All repositories(至少 `.github` 仓可读)。 +- 配置后 `butler-heartbeat`(每 30min)自动开始 ping;healthchecks.io 页面应出现 + 成功心跳记录(最迟 30min 内)。 + +## 3. 失败回调配置(grace 超时 → 触发缺席即停) + +healthchecks.io → check → **Integrations** 添加 Webhook,URL 指向 GitHub +repository_dispatch(需要一枚具 `repo` scope 的 PAT,可用 owner 经典 PAT;勿用临时 +token——回调凭据是长期运行的管道): + +``` +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: + + ```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"}' + ``` + +- **方案 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 是最低可用), +泄漏面控制在该服务一处。 + +## 4. 演习步骤(月度正控建议 + 上线验证) + +AC-3 的 fail-closed 实证路径 = 手动 dispatch(无需真停心跳): + +1. **触发**:Actions → `.github` 仓 → `butler-deadman-trip` → Run workflow + (simulate=true,默认)。 +2. **验证**: + - org 变量已置位:`gh api orgs/Cloudbird-Software/actions/variables/AUTO_MERGE_DISABLED --jq .value` → `true`; + - P0 issue 已开(label `deadman-tripped`); + - 运行日志含 `AUDIT | butler=deadman-trip | ... | outcome=tripped` 审计行; + - 若有 open PR 挂着 auto-merge,应已被撤销。 +3. **复位**(仅 owner,留痕): + ```bash + gh api -X PATCH orgs/Cloudbird-Software/actions/variables/AUTO_MERGE_DISABLED \ + -f name=AUTO_MERGE_DISABLED -F value=false + ``` + 然后在 P0 issue 评论"演习复位(who/when)"并关闭。 +4. **月度正控**(宪法 §7 审计节奏建议):每月手动执行一次上述演习,确认 trip 管道 + 仍真实可走(dead-man 通道最怕"配置漂移到静默失效")。 + +配置了自动回调(§3 方案 A/B)后的**端到端负向演习**(每季度可选):在 healthchecks.io +手动暂停 check(Pause)→ 等 grace(60min)超时 → 确认 `butler-deadman-trip` 被自动触发 +→ 按 §4.3 复位。注意暂停期间 butler-heartbeat 的 ping 会持续失败变红,属预期。 + +## 5. 已知边界(诚实清单) + +- 外部服务注册与回调配置是 owner 手工步骤,代码侧无法替代;未配置期间系统处于 + "可演习、未实连"状态——但 cost-check 熔断(ADR-0040)与 drift-check 的独立告警 + 通道仍在,非单点失明。 +- `DEADMAN_PING_URL` org secret 未配置时 `butler-heartbeat` 只 WARN 不红:这是刻意 + 的(骨架期不因缺外部配置阻塞 CI);配置完成即自动进入实测模式(失败重试 1 次后红)。 +- 心跳 workflow 自身挂掉 = 与管家 cron 同死——这正是外部 dead-man 存在的理由: + 外部服务监督的是"包括 heartbeat 在内的全部 cron 的静默"。 +- 复用熔断变量 `AUTO_MERGE_DISABLED`(与 cost-check/ADR-0040 共用):两个触发源 + (成本超限 / 管家缺席)都会停自动合并,复位路径同一(见 §4.3)。 diff --git a/governance/butler-audit.sh b/governance/butler-audit.sh new file mode 100644 index 0000000..953050d --- /dev/null +++ b/governance/butler-audit.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# butler-audit.sh —— 管家统一审计行生成器(ADR-0057,W1-C5 .github#168;宪法 §11/INV-12) +# +# INV-12:管家每次运行必须有明确触发器且产出审计条目(谁唤醒/干了什么/花了多少)。 +# 本脚本是唯一的审计行形态来源,两种用法: +# 1) CLI(一次性输出一行): +# bash butler-audit.sh <动作JSON> [更多JSON...] +# (兼容省略 outcome 的三参形态:bash butler-audit.sh <名> → outcome=ok) +# 2) source(长运行过程多点审计;各 workflow / butler-reconcile.sh / cost-check.sh 用此形态): +# source governance/butler-audit.sh +# audit_emit <动作JSON>... # 可多次调用 +# 输出(stdout + append 到 $GITHUB_STEP_SUMMARY): +# AUDIT | butler=<名> | trigger=<触发器> | run_id= | repo= | started= | +# duration_s=<秒> | outcome=<结果> | actions= +# +# 口径说明: +# - duration:审计起点(BUTLER_AUDIT_T0,epoch 秒)到当前。T0 优先外部注入(跨步骤 +# 统计整轮时长);CI(GITHUB_RUN_STARTED_AT 存在)取 run 开始时刻——等效于用 SECONDS +# 度量整个 workflow 的耗时口径;本地 source 无注入时=source 时刻。 +# - 多个动作 JSON 参数合并为 JSON 数组;JSON 非法时拒绝输出(return 2,绝不输出 +# 畸形审计行——宁红勿假),调用方按 infra 故障处置(fail-closed)。 +# - 审计条目只进运行日志与 step summary,刻意不 commit 回 main:管家 commit main 会 +# 制造 §8(直推漂移)执法面上的噪音;Actions 运行日志是带 run_id 的不可变第三方 +# 台账(ADR-0057 决策 2)。 +# - actions JSON 为 SLI 字段(#98 口径:auto_merge_rate / check_latency / revert_count) +# 预留键位——账本 JSON 状态块由 W1-C3 dashboard 脚本负责,本行结构已兼容(机器可 +# grep '^AUDIT' 提取后 json.loads 尾段)。 + +_butler_audit_cli=0 +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then _butler_audit_cli=1; fi + +# ---------- JSON 工具(python3 → python;探测实跑防 Windows 商店 stub;均无则降级不校验) ---------- +_BUTLER_PY="" +for _c in python3 python; do + if command -v "$_c" >/dev/null 2>&1 && "$_c" -c 'print(1)' >/dev/null 2>&1; then + _BUTLER_PY="$_c"; break + fi +done +unset _c + +_butler_now_epoch() { date -u +%s; } + +_butler_iso2epoch() { # → epoch 秒(解析失败输出空串) + local iso="$1" + if [[ -n "$_BUTLER_PY" ]]; then + "$_BUTLER_PY" -c 'import sys,datetime +try: + print(int(datetime.datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp())) +except Exception: + sys.exit(1)' "$iso" 2>/dev/null | tr -d '\r' + else + date -u -d "$iso" +%s 2>/dev/null || true + fi +} + +# ---------- 审计起点(source / 首次 CLI 调用时确定) ---------- +if [[ -z "${BUTLER_AUDIT_T0:-}" ]]; then + BUTLER_AUDIT_T0="" + if [[ -n "${GITHUB_RUN_STARTED_AT:-}" ]]; then + BUTLER_AUDIT_T0=$(_butler_iso2epoch "$GITHUB_RUN_STARTED_AT") + fi + [[ -n "$BUTLER_AUDIT_T0" ]] || BUTLER_AUDIT_T0=$(_butler_now_epoch) +fi +BUTLER_AUDIT_STARTED="${BUTLER_AUDIT_STARTED:-${GITHUB_RUN_STARTED_AT:-$(date -u +%FT%TZ)}}" +readonly BUTLER_AUDIT_T0 BUTLER_AUDIT_STARTED 2>/dev/null || true + +# 动作 JSON 合并/校验:多个参数合并为数组;非法 → return 2(不输出畸形行) +_actions_json() { + local out rc + if [[ -n "$_BUTLER_PY" ]]; then + out=$("$_BUTLER_PY" -c 'import json,sys +try: + objs = [json.loads(a) for a in sys.argv[1:]] +except Exception as e: + sys.exit(f"illegal JSON: {e}") +print(json.dumps(objs[0] if len(objs) == 1 else objs, ensure_ascii=False))' "$@" 2>&1) + rc=$? + if [[ $rc -ne 0 ]]; then + echo "FATAL: actions JSON 非法($out)——拒绝输出畸形审计行" >&2 + return 2 + fi + printf '%s' "$out" | tr -d '\r' + elif [[ $# -eq 1 ]]; then + printf '%s' "$1" # 无 python 环境:单参直通(CI/本地均有 python,此分支仅极端降级) + else + printf '[%s]' "$(printf '%s,' "$@" | sed 's/,$//')" + fi +} + +# 审计行输出(唯一入口):audit_emit <动作JSON>... +audit_emit() { + [[ $# -ge 4 ]] || { echo "FATAL: audit_emit 用法: audit_emit <动作JSON>..." >&2; return 2; } + local butler="$1" trigger="$2" outcome="$3"; shift 3 + local actions dur line + actions=$(_actions_json "$@") || return 2 + dur=$(( $(_butler_now_epoch) - BUTLER_AUDIT_T0 )) + [[ $dur -ge 0 ]] || dur=0 + line="AUDIT | butler=$butler | trigger=$trigger | run_id=${GITHUB_RUN_ID:-local} | repo=${GITHUB_REPOSITORY:-local} | started=$BUTLER_AUDIT_STARTED | duration_s=$dur | outcome=$outcome | actions=$actions" + echo "$line" + # step summary(CI):首次写入带头部标记,便于 owner 免翻日志看全轮审计 + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + if [[ ! -f "$GITHUB_STEP_SUMMARY" ]] || ! grep -q '管家审计日志' "$GITHUB_STEP_SUMMARY" 2>/dev/null; then + printf '## 管家审计日志(AUDIT,INV-12——宪法 §11)\n' >> "$GITHUB_STEP_SUMMARY" || return 0 + fi + printf '%s\n' "$line" >> "$GITHUB_STEP_SUMMARY" || return 0 + fi +} + +# ---------- CLI 模式(bash butler-audit.sh ...;source 时不执行) ---------- +if [[ $_butler_audit_cli -eq 1 ]]; then + # 三参形态(省略 outcome):第 3 参是 JSON 对象/数组字面量 → 补 outcome=ok + if [[ $# -ge 3 && ( "$3" == \{* || "$3" == \[* ) ]]; then + set -- "$1" "$2" "ok" "${@:3}" + fi + if [[ $# -lt 4 ]]; then + echo "用法: bash butler-audit.sh <动作JSON> [更多JSON...]" >&2 + echo " (或三参形态: bash butler-audit.sh <动作JSON> → outcome=ok)" >&2 + exit 2 + fi + audit_emit "$@" || exit 2 +fi diff --git a/governance/butler-reconcile.sh b/governance/butler-reconcile.sh new file mode 100644 index 0000000..420ab1a --- /dev/null +++ b/governance/butler-reconcile.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# butler-reconcile.sh —— 管家主收敛循环(唤醒矩阵行 1;ADR-0057,W1-C5 .github#168;宪法 §11) +# +# 每 6h(或 workflow_dispatch 手动)遍历 REPOS.yaml active 仓做三类一致性检查: +# (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)——纠正动作留给后续波次的执法卡,管家先让不一致可见。 +# (c) 隔离超时:open issue 挂 state:quarantine 且 updated 距今 > stale_quarantine_days +# → 升 needs-human(同 (a) 去重)。用 updated 代理"停留时长":隔离期间仍有人 +# 评论/更新 = 有活动,不升级是合理语义;静置才是要抓的滞留。 +# +# 令牌分离(最小权限): +# - 跨仓读 = GH_TOKEN(CI 注入 org secret GOVERNANCE_TOKEN;缺失 → fail-closed 变红 +# + 审计行,不静默降级——宪法 §6)。 +# - 写 issue/label = GH_WRITE_TOKEN(CI 注入 GITHUB_TOKEN,仅本仓 issues:write): +# 报告与 needs-human issue 全部开在 .github 仓,无跨仓写需求,用运行级令牌即够 +# (不把 org admin 治理令牌用在 issue 评论上)。 +# +# 阈值真源 governance/policy/butler.yaml(脚本读它,不读注释)。 +# 注入(演习/预检,同 PR_LIVENESS_HOURS 模式——workflow_dispatch 输入注入,不留常开旁路): +# STALE_DAYS_OVERRIDE(0=立即 stale——AC-2 演习用)、STALE_QUARANTINE_DAYS_OVERRIDE、 +# BUTLER_RECONCILE_REPOS(逗号表,聚焦扫描)、BUTLER_DRY_RUN=1(只报告不写) +# 退出码:0=全绿 | 1=有发现(needs-human/报告已动作——变红=可见信号,同 drift-check/ +# cost-check 模式)| 2=基础设施故障(fail-closed) +set -uo pipefail + +ORG="${ORG:-Cloudbird-Software}" +DIR="$(cd "$(dirname "$0")" && pwd)" +GOV_REPO="$ORG/.github" +GH="${GH:-gh}" +DRY_RUN="${BUTLER_DRY_RUN:-0}" +INFRA=0 +FINDINGS=0 +TODAY=$(date -u +%F) +TRIGGER="${BUTLER_TRIGGER:-${GITHUB_EVENT_NAME:-manual}}" + +source "$DIR/butler-audit.sh" # audit_emit(INV-12 审计行唯一来源) + +ok() { echo "OK $1"; } +act() { echo "ACT $1"; } +infra() { echo "INFRA $1" >&2; INFRA=$((INFRA+1)); } +audit() { audit_emit reconcile "$TRIGGER" "$1" "$2" || infra "AUDIT 行输出失败(INV-12 完整性受损)"; } + +# ---------- 令牌(fail-closed:读令牌缺失不静默——审计行可见后变红) ---------- +if [[ -z "${GH_TOKEN:-}" ]]; then + audit infra-fail '{"fatal":"GH_TOKEN missing (CI: org secret GOVERNANCE_TOKEN)"}' || true + echo "::error::GH_TOKEN 未设置(CI=org secret GOVERNANCE_TOKEN,跨仓读)。设置: 组织 Settings → Secrets and variables → Actions → New organization secret" >&2 + exit 2 +fi +GH_WRITE_TOKEN="${GH_WRITE_TOKEN:-$GH_TOKEN}" +ghw() { GH_TOKEN="$GH_WRITE_TOKEN" "$GH" "$@"; } # 本仓写(CI=GITHUB_TOKEN);读直接用 $GH(读 GH_TOKEN 环境变量) + +# ---------- 阈值真源 butler.yaml(python 解析;逐行 KEY=value,无 eval;tr 去 CR 兼容 Windows python) ---------- +POLICY_ENV=$(python3 - "$DIR/policy/butler.yaml" <<'PYEOF' | tr -d '\r' +import sys, yaml +try: + t = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))["thresholds"] + rows = [("STALE_DAYS", t["stale_in_progress_days"]), + ("STALE_Q_DAYS", t["stale_quarantine_days"]), + ("DEADMAN_GRACE", t["deadman_grace_minutes"])] + for kk, vv in rows: + vv = str(vv) + assert "=" not in vv and "\n" not in vv, f"policy 值含非法字符: {kk}" + print(f"{kk}={vv}") +except Exception as e: + sys.exit(f"butler.yaml 解析失败: {e}") +PYEOF +) || { audit infra-fail '{"fatal":"butler.yaml unparsable"}' || true; echo "FATAL: policy/butler.yaml 解析失败" >&2; exit 2; } +while IFS='=' read -r key val; do declare "$key=$val"; done <<< "$POLICY_ENV" +for v in STALE_DAYS STALE_Q_DAYS DEADMAN_GRACE; do + [[ -n "${!v:-}" ]] || { audit infra-fail "{\"fatal\":\"butler.yaml 缺 $v\"}" || true; echo "FATAL: butler.yaml 缺 $v" >&2; exit 2; } +done +# 数值校验(fail-closed,须在父 shell 调用——子 shell 里 infra 计数会丢失) +check_num() { # + local __v="__dummy" + eval "__v=\$${1:?}" + if [[ "$__v" =~ ^[0-9]+([.][0-9]+)?$ ]]; then return 0; fi + infra "非数值($2): '$__v'——判定输入无效" + eval "$1=0" +} +# 环境注入优先(演习通道;空=真源值) +STALE_DAYS="${STALE_DAYS_OVERRIDE:-$STALE_DAYS}" +STALE_Q_DAYS="${STALE_QUARANTINE_DAYS_OVERRIDE:-$STALE_Q_DAYS}" +check_num STALE_DAYS "in-progress stale 天数"; check_num STALE_Q_DAYS "quarantine stale 天数" + +# ---------- 受管仓清单:REPOS.yaml 全量 active;BUTLER_RECONCILE_REPOS 逗号表覆盖 ---------- +if [[ -n "${BUTLER_RECONCILE_REPOS:-}" ]]; then + REPOS="${BUTLER_RECONCILE_REPOS//,/ }" +else + REPOS=$(python3 - "$DIR/REPOS.yaml" <<'PYEOF' | tr -d '\r' +import sys, yaml +try: + repos = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))["repos"] + print(" ".join(r["name"] for r in repos if r.get("status") == "active")) +except Exception as e: + sys.exit(f"REPOS.yaml 解析失败: {e}") +PYEOF +) || { audit infra-fail '{"fatal":"REPOS.yaml unparsable"}' || true; echo "FATAL: REPOS.yaml 解析失败" >&2; exit 2; } +fi +[[ -n "$REPOS" ]] || { audit infra-fail '{"fatal":"active repo list empty"}' || true; echo "FATAL: 受管仓清单为空" >&2; exit 2; } +REPO_COUNT=$(wc -w <<< "$REPOS" | tr -d ' ') + +# stale 判定阈值(epoch 秒;python 算一次容忍小数天。STALE_DAYS=0 → 任何过去时刻都 stale=演习全触发) +stale_ts() { # → epoch 阈值 + python3 -c "import time,sys; print(int(time.time() - float(sys.argv[1])*86400))" "$1" | tr -d '\r' +} +STALE_TS=$(stale_ts "$STALE_DAYS") || { audit infra-fail '{"fatal":"stale_ts compute failed"}' || true; exit 2; } +STALE_Q_TS=$(stale_ts "$STALE_Q_DAYS") || { audit infra-fail '{"fatal":"stale_q_ts compute failed"}' || true; exit 2; } + +audit running "{\"phase\":\"start\",\"repos\":$REPO_COUNT,\"stale_in_progress_days\":$STALE_DAYS,\"stale_quarantine_days\":$STALE_Q_DAYS,\"dry_run\":$DRY_RUN}" + +# ---------- helpers ---------- +mutate() { # DRY_RUN 拦截一切写操作(本地验证不产生副作用) + if [[ "$DRY_RUN" == "1" ]]; then echo "DRY (skip) $*"; else "$@"; fi +} +label_ensure() { #