diff --git a/.github/workflows/auto-fix-limit.yml b/.github/workflows/auto-fix-limit.yml new file mode 100644 index 0000000..3064282 --- /dev/null +++ b/.github/workflows/auto-fix-limit.yml @@ -0,0 +1,59 @@ +name: auto-fix-limit +# agent 修复循环上限执法(ADR-0040,P2-8 .github#93): +# 小时级扫描受管仓 agent open PR 的 gate 失败计数(真源=Checks API,崩溃续接), +# 达上限(policy/automation-limits.yaml,默认 3)→ 撤 auto-merge + 关 PR + 开说明 issue; +# 熔断变量置位时每轮撤全部 open PR 的 auto-merge(硬停机器执法,旁路窗口 ≤1h)。 +# 脚本细节/注入通道见 governance/auto-fix-limit.sh 头注。 +on: + schedule: + - cron: "18 * * * *" # 每小时 :18(避开整点 governance-drift 高峰) + workflow_dispatch: + # 注入入口(T1 测试/聚焦扫描):空=走 policy 真源,不留常开旁路 + inputs: + max_attempts: + description: "auto-fix 上限覆盖(空=policy 值)" + required: false + default: "" + check_name: + description: "计数的 check 名覆盖(空=gate)" + required: false + default: "" + repos: + description: "扫描仓覆盖(逗号表,空=REPOS.yaml 全量 active)" + required: false + default: "" + all_prs: + description: "扫描全部 open PR(true=不限 agent 作者;空=仅 agent+opt-in)" + required: false + default: "" + dry_run: + description: "1=只报告不执行写操作(T1 预检)" + required: false + default: "" + +permissions: {} + +# 串行化:关 PR/开 issue 的"查重→写"非原子,并发运行会重复开 issue(同 governance-drift 设计) +concurrency: + group: auto-fix-limit + cancel-in-progress: false + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 扫描并执法(超限关 PR;exit 1=有超限 2=基础设施故障) + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + AUTOFIX_MAX_ATTEMPTS: ${{ inputs.max_attempts }} + AUTOFIX_CHECK_NAME: ${{ inputs.check_name }} + AUTOFIX_REPOS: ${{ inputs.repos }} + AUTOFIX_ALL_PRS: ${{ inputs.all_prs }} + AUTOFIX_DRY_RUN: ${{ inputs.dry_run }} + run: bash governance/auto-fix-limit.sh diff --git a/.github/workflows/cost-check.yml b/.github/workflows/cost-check.yml new file mode 100644 index 0000000..dc481af --- /dev/null +++ b/.github/workflows/cost-check.yml @@ -0,0 +1,58 @@ +name: cost-check +# 额度/成本熔断(ADR-0040,P2-8 .github#93): +# 6h 周期拉取 /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 头注。 +on: + schedule: + - cron: "42 */6 * * *" # 每 6h :42(避开整点 governance-drift 与 :18 auto-fix-limit) + workflow_dispatch: + # 注入入口(T2 注入式测试:79%/85%/100% 全场景不依赖真实超支):空=真实 API/真源 + inputs: + usage_minutes_override: + description: "当月 Actions 分钟用量覆盖(数值;空=billing API 真实值)" + required: false + default: "" + quota_minutes_override: + description: "月预算分钟覆盖(空=policy 值)" + required: false + default: "" + llm_tokens_used_override: + description: "LLM token 用量覆盖(pending 数据源的注入测试通道)" + required: false + default: "" + llm_tokens_quota_override: + description: "LLM token 月预算覆盖(空=policy 值)" + required: false + default: "" + dry_run: + description: "1=只报告不执行写操作(含不置熔断变量——注入预检用)" + required: false + default: "" + +permissions: {} + +concurrency: + group: cost-check + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 用量检查与熔断(exit 1=触发告警/熔断 2=基础设施故障) + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + 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 }} + COST_LLM_TOKENS_QUOTA_OVERRIDE: ${{ inputs.llm_tokens_quota_override }} + COST_DRY_RUN: ${{ inputs.dry_run }} + run: bash governance/cost-check.sh diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 2daa540..e976327 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -85,7 +85,9 @@ jobs: EOF - name: 脚本语法检查 run: | - 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 + # 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 governance/auto-fix-limit.sh && bash -n governance/cost-check.sh echo "OK scripts" - name: REPOS.yaml 引用自检(无重名仓) run: | @@ -169,4 +171,4 @@ jobs: if [[ $MISSING -eq 0 ]]; then echo "OK adr-required: $( { echo "$PR_TITLE"; echo "$PR_BODY"; } | grep -oE "$ADR_RE" | sort -u | tr '\n' ' ')(存在性已验;实体性后验:drift-check §10)" fi - exit $MISSING \ No newline at end of file + exit $MISSING diff --git a/AGENTS.md b/AGENTS.md index 22b127e..c73f809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,14 @@ AI agent 进入本仓的工作契约(索引型,CG-1;细节按需读引用 - agent 写仓库身份 = GitHub App `cloudbrid-agent`(AG-1);令牌经 scripts/gh-app-token.sh,单仓作用域、1h 过期 - 本仓只读治理声明;ADR 与注册条目落盘 agent-registry(REPOS.yaml L1) - 不引入新第三方 Action:白名单见 expected-state.json#actions_policy(CI-2) +- 无人值守护栏(ADR-0040,跨仓生效):(a) 每次任务派发与 `gh pr merge --auto` 前,必须检查 org 变量 `AUTO_MERGE_DISABLED`(`gh api /orgs/Cloudbird-Software/actions/variables/AUTO_MERGE_DISABLED --jq .value`,404=未置位)——置位即停一切派发与 automerge,禁止任何绕过尝试;(b) 同一 PR 的修红重试 ≤ policy/automation-limits.yaml `auto_fix.max_attempts`(默认 3),达上限即停手(auto-fix-limit workflow 会关 PR + 开 issue);(c) 不得 reopen 带 `auto-fix-limit-exhausted` 标签的 PR;计数真源 = Checks API(commit 元数据),删标签/重开不重置计数;(d) 派发前确认 .github 仓无未决 `cost-infra`/`cost-circuit-breaker` issue(用量不可知时同样停) ## 常用命令 - 校验本仓声明:`.github/workflows/gate.yml`(本地等价:yaml/json 解析 + `bash -n` 各脚本) - 漂移检测:`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑) +- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh`(小时级 CI 自动跑;`AUTOFIX_DRY_RUN=1` 只报告) +- 成本熔断检查:`GH_TOKEN= bash governance/cost-check.sh`(6h CI 自动跑;`COST_USAGE_MINUTES_OVERRIDE=` 注入测试) - 漂移修复:`GH_TOKEN= bash governance/apply.sh`(幂等;失败 loud 退出) - 新仓初始化:`bash scripts/new-repo-init.sh `(失败 loud 退出) @@ -25,6 +28,7 @@ AI agent 进入本仓的工作契约(索引型,CG-1;细节按需读引用 | 期望状态(漂移真源) | governance/expected-state.json | | 语言/依赖政策 | governance/policy/languages.yaml | | 测试政策 | governance/policy/testing.yaml | +| 无人值守护栏阈值(auto-fix 上限/成本熔断,ADR-0040) | governance/policy/automation-limits.yaml | | agent 标准 schema | standards/agent/*.schema.yaml | | 自动化规范(CI 链路 / bot 反馈通道,ADR-0031/0032) | standards/automation/ | -| 原型 profiles / 注册条目 | Cloudbird-Software/agent-registry | \ No newline at end of file +| 原型 profiles / 注册条目 | Cloudbird-Software/agent-registry | diff --git a/governance/auto-fix-limit.sh b/governance/auto-fix-limit.sh new file mode 100644 index 0000000..75517d7 --- /dev/null +++ b/governance/auto-fix-limit.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# auto-fix-limit.sh —— agent 修复循环上限执法(ADR-0040,P2-8 .github#93) +# +# 扫描受管仓 open PR:统计每个 PR 各 commit 上 gate check run 的最新失败结论数。 +# 计数真源 = Checks API(commit 元数据)——无内存态/状态文件,runner 崩溃或 workflow +# 重启后计数天然续接而非清零(工作卡 T3 持久性由构造保证)。 +# +# 达上限(policy/automation-limits.yaml auto_fix.max_attempts,默认 3): +# 撤 auto-merge → 关闭 PR → 打 exhausted 标签 → 在 .github 仓开说明 issue(含失败 run 链接)。 +# 带 exhausted 标签被 reopen 的 PR 由下一轮再次关闭(计数是历史事实,不因 reopen 归零)。 +# +# 熔断置位(org 变量 AUTO_MERGE_DISABLED,由 cost-check.sh 负责"置")时: +# 本脚本每轮撤所有 open PR 的 auto-merge——硬停的机器执法,新 enable 旁路窗口 ≤ 扫描周期。 +# +# 用法: GH_TOKEN= bash auto-fix-limit.sh +# 注入(测试/聚焦,T1): AUTOFIX_MAX_ATTEMPTS / AUTOFIX_CHECK_NAME / AUTOFIX_AUTHOR / +# AUTOFIX_REPOS(逗号表)/ AUTOFIX_ALL_PRS=1(全量 open PR)/ AUTOFIX_DRY_RUN=1(只报告不写) +# 退出码: 0=无超限 | 1=本轮关闭了超限 PR(运行变红=可见信号)| 2=基础设施故障(fail-closed) +set -uo pipefail + +ORG="${ORG:-Cloudbird-Software}" +DIR="$(cd "$(dirname "$0")" && pwd)" +GOV_REPO="$ORG/.github" +GH="${GH:-gh}" +DRY_RUN="${AUTOFIX_DRY_RUN:-0}" +EXHAUSTED_CLOSED=0 +INFRA=0 + +ok() { echo "OK $1"; } +act() { echo "ACT $1"; } +infra() { echo "INFRA $1" >&2; INFRA=$((INFRA+1)); } + +[[ -n "${GH_TOKEN:-}" ]] || { echo "FATAL: GH_TOKEN 未设置(需 org admin token)" >&2; exit 2; } + +# ---------- policy 读取(python 解析 YAML;逐行 KEY=value,无 eval;tr 去 CR 兼容 Windows python)---------- +POLICY_ENV=$(python3 - "$DIR/policy/automation-limits.yaml" <<'PYEOF' | tr -d '\r' +import sys, yaml +try: + c = yaml.safe_load(open(sys.argv[1], encoding="utf-8")) + a, k = c["auto_fix"], c["circuit_breaker"] + rows = [("AF_MAX_ATTEMPTS", a["max_attempts"]), ("AF_CHECK_NAME", a["check_name"]), + ("AF_AUTHOR", a["author"]), ("AF_OPT_IN_LABEL", a["opt_in_label"]), + ("AF_EXHAUSTED_LABEL", a["exhausted_label"]), ("AF_ISSUE_LABEL", a["issue_label"]), + ("AF_FAILED_CONCLUSIONS", ",".join(a["failed_conclusions"])), + ("CB_VARIABLE", k["variable"])] + 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"policy 解析失败: {e}") +PYEOF +) || { echo "FATAL: policy/automation-limits.yaml 解析失败" >&2; exit 2; } +while IFS='=' read -r key val; do declare "$key=$val"; done <<< "$POLICY_ENV" +for v in AF_MAX_ATTEMPTS AF_CHECK_NAME AF_AUTHOR AF_OPT_IN_LABEL AF_EXHAUSTED_LABEL AF_ISSUE_LABEL AF_FAILED_CONCLUSIONS CB_VARIABLE; do + [[ -n "${!v:-}" ]] || { echo "FATAL: policy 缺 $v" >&2; exit 2; } +done +# 环境注入优先(T1 负向注入通道) +MAX_ATTEMPTS="${AUTOFIX_MAX_ATTEMPTS:-$AF_MAX_ATTEMPTS}" +CHECK_NAME="${AUTOFIX_CHECK_NAME:-$AF_CHECK_NAME}" +AUTHOR="${AUTOFIX_AUTHOR:-$AF_AUTHOR}" + +# ---------- 受管仓清单:REPOS.yaml 全量 active;AUTOFIX_REPOS 逗号表覆盖 ---------- +if [[ -n "${AUTOFIX_REPOS:-}" ]]; then + REPOS="${AUTOFIX_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 +) || { echo "FATAL: REPOS.yaml 解析失败" >&2; exit 2; } +fi +[[ -n "$REPOS" ]] || { echo "FATAL: 受管仓清单为空" >&2; exit 2; } + +# ---------- helpers ---------- +mutate() { # DRY_RUN 拦截一切写操作(本地验证不产生副作用) + if [[ "$DRY_RUN" == "1" ]]; then echo "DRY (skip) $*"; else "$@"; fi +} +label_ensure() { #