From 8a844bc99e3a522979b1cfdc82c88affbeceae42 Mon Sep 17 00:00:00 2001 From: randypanding <66171646+randypanding@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:01:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?pm(P1-C2):=20PM=20=E4=BC=98=E5=85=88?= =?UTF-8?q?=E8=8C=83=E5=BC=8F=E2=80=94=E2=80=94AGENTS/PLAYBOOK/T7-T9/EX-1/?= =?UTF-8?q?providers/ghcb/=E9=80=80=E5=BD=B9=E7=94=B3=E6=8A=A5=EF=BC=88ADR?= =?UTF-8?q?-0085=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cnb-audit.yml | 204 +++ .github/workflows/cnb-dispatch.yml | 177 ++ .github/workflows/conductor.yml | 50 + .github/workflows/gate.yml | 18 +- AGENTS.md | 45 +- docs/pm/PLAYBOOK.md | 134 ++ governance/GOVERNANCE.yaml | 53 +- governance/REPOS.yaml | 57 +- governance/drift-check.sh | 1910 +++++++++++----------- governance/expected-state.json | 15 +- governance/policy/automation-limits.yaml | 167 +- governance/policy/contracts.yaml | 2 +- governance/policy/languages.yaml | 2 +- governance/providers.yaml | 37 + governance/transitions.yaml | 284 ++-- profile/README.md | 2 + scripts/ghcb | 78 + 17 files changed, 1975 insertions(+), 1260 deletions(-) create mode 100644 .github/workflows/cnb-audit.yml create mode 100644 .github/workflows/cnb-dispatch.yml create mode 100644 docs/pm/PLAYBOOK.md create mode 100644 governance/providers.yaml mode change 100755 => 100644 scripts/ghcb diff --git a/.github/workflows/cnb-audit.yml b/.github/workflows/cnb-audit.yml new file mode 100644 index 0000000..48522f7 --- /dev/null +++ b/.github/workflows/cnb-audit.yml @@ -0,0 +1,204 @@ +name: cnb-audit +# ============================================================================= +# CNB 周审计 + 三接缝隔离审计(IR-0004 AC-14/AC-19,BEH-17 周审计项之一) +# +# 硬注释(改动前必读): +# - 本工作流是治理仓 CNB 三接缝之一(GOVERNANCE EX-1 声明条目 / IFACE-01, +# ADR-0085 决策 6 / IR-0004):与 cnb-dispatch.yml、GOVERNANCE 声明条目 +# 共同构成治理仓对 CNB 的全部合法引用面。 +# - 判定永不外置(INV-02):本审计只核资源面(配额/窗口/隔离),不消费 +# NPC 输出作为任何 gate 输入;沙箱自报数字不采信(INV-01)。 +# - 凭据纪律(INV-06):token 只经 org secret 注入,不落日志/摘要/issue; +# 配额数字与窗口状态不含敏感值,可进审计面。 +# - 无人值守护栏适用(ADR-0040):本审计产出即护栏输入——配额告警开 +# cost 类 issue(cost-infra),供派单前置检查消费。 +# - ADR-0085 决策 6 审计口径:第③步 grep 匹配**操作性引用** +# (endpoint=cnb.cool / token 名=CNB_TOKEN / 派单协议=@CodeBuddy), +# 目录导航性提及(如仓库名 cnb-bridge、工作流名)不计入——故 pattern +# 刻意只列三样;排除清单=接缝本体+既有声明/登记面(逐项依据见第③步注释)。 +# - fail-closed(INV-04):配额查询失败、账号清单不可读、隔离 grep 非 0 +# 一律红;窗口停摆仅 WARN(观测面非判定面)。 +# ============================================================================= +on: + schedule: + - cron: "41 3 * * 1" # 周一 03:41 UTC 错峰(避 archive gate 周扫 04:23 / runs-digest 04:37 / Scorecard 05:00+,ADR-0011 谱系) + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: cnb-audit + cancel-in-progress: false + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1(org 白名单 CI-2) + with: + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Cloudbird-Software/cnb-bridge # L2 可删除层(ADR-0085 决策 6) + ref: main + path: cnb-bridge + persist-credentials: false + # ------------------------------------------------------------------ + # ①配额活性:逐账号查余量;任一低于 policy 阈值(cnb.quota_alert_pct,现值 20) + # → LOW=1(下一步开 cost 类 issue);配额查询失败(含 org secret 缺失) + # = fail-closed 红(INV-04/AC-15)。 + # secret 经 env 映射静态引用(GH Actions 无法动态索引 secrets); + # 加账号 = owner 加 org secret + 本映射加一行(C1 PR)。 + # ------------------------------------------------------------------ + - name: quota(配额活性,fail-closed) + env: + CNB_TOKEN_XUEMEI: ${{ secrets.CNB_TOKEN_XUEMEI }} + CNB_TOKEN_P11: ${{ secrets.CNB_TOKEN_P11 }} + run: | + set -euo pipefail + # 账号清单真源 = cnb-bridge/accounts.yaml(无明文 token,ADR-0085 决策 6) + mapfile -t ACCOUNTS < <(python3 - <<'EOF' + import sys, yaml + with open('cnb-bridge/accounts.yaml', encoding='utf-8') as f: + doc = yaml.safe_load(f) + aliases = [a['alias'] for a in (doc.get('accounts') or [])] + if not aliases: + print('::error::accounts.yaml 无账号条目——清单不可读即红(fail-closed)', file=sys.stderr) + sys.exit(1) + print('\n'.join(aliases)) + EOF + ) + # 进程替换不传递 python 退出码——空清单兜底再拦一次(fail-closed,堵 fail-open) + [[ ${#ACCOUNTS[@]} -gt 0 ]] || { echo '::error::账号清单为空/不可读——fail-closed 红'; exit 1; } + FAIL=0 + : > quota-report.txt + for A in "${ACCOUNTS[@]}"; do + VAR="CNB_TOKEN_$(printf '%s' "$A" | tr '[:lower:]' '[:upper:]')" + TOKEN="${!VAR:-}" + if [[ -z "$TOKEN" ]]; then + echo "::error::org secret ${VAR} 缺失(账号 ${A} 无法查配额)——fail-closed 红" + FAIL=1; continue + fi + # token 已按池契约注入 step env(CNB_TOKEN_,cnb_pool.py 直接读 + # os.environ[secret_ref])——不做二次中转,零冗余变量(INV-06) + if ! python3 cnb-bridge/cnb_pool.py quota --account "$A" | tee -a quota-report.txt; then + echo "::error::账号 ${A} 配额查询失败——fail-closed 红(INV-04:平台失效必须可见)" + FAIL=1 + fi + done + [[ $FAIL -eq 0 ]] || exit 1 + # 告警阈值真源 = automation-limits.yaml cnb.quota_alert_pct(当前 20,IR-0004 AC-15); + # cnb 节缺失即红(policy 为准,不内置缺省) + ALERT_PCT=$(python3 - <<'EOF' + import sys, yaml + with open('governance/policy/automation-limits.yaml', encoding='utf-8') as f: + cnb = (yaml.safe_load(f) or {}).get('cnb') + if not isinstance(cnb, dict): + print('::error::automation-limits.yaml 缺 cnb 节——阈值真源不可少(fail-closed)', file=sys.stderr) + sys.exit(1) + print(int(cnb.get('quota_alert_pct', 20))) + EOF + ) || exit 1 + # 余量 <阈值 → LOW=1;quota 行契约:account= remaining_pct= + grep -E 'remaining_pct=[0-9]+' quota-report.txt | awk -F'remaining_pct=' -v pct="$ALERT_PCT" '{split($2,a," "); if (a[1]+0 < pct) print}' > quota-low.txt || true + LOW=0; [[ -s quota-low.txt ]] && LOW=1 + echo "LOW=$LOW" >> "$GITHUB_ENV" + { + echo '## CNB 周审计(配额活性)' + echo + echo '```'; cat quota-report.txt; echo '```' + echo + [[ $LOW -eq 1 ]] && echo '**告警**:以下账号余量 <20%——已开 cost 类 issue:' && sed 's/^/- /' quota-low.txt || echo '全部账号余量 ≥20%' + } >> "$GITHUB_STEP_SUMMARY" + echo "AUDIT | cnb-audit | quota | accounts=${#ACCOUNTS[@]} | low=$LOW" + # ------------------------------------------------------------------ + # 开 cost 类 issue(.github 仓,cloudbrid-agent App 令牌——archive 仓 + # runs-digest.yml 同款铸币模式:CB_APP_ID/AGENT_APP_SECRET org secrets, + # REPO=.github;本仓即 .github,脚本取自本 checkout,无需二次 sparse)。 + # 开 issue 失败 = 红(消费链停摆必须可见,fail-closed)。 + # ------------------------------------------------------------------ + - name: open-cost-issue(余量 <20% 时) + if: env.LOW == '1' + env: + CB_APP_ID: ${{ secrets.CB_APP_ID }} + AGENT_APP_SECRET: ${{ secrets.AGENT_APP_SECRET }} + run: | + set -euo pipefail + TOKEN=$(REPO=".github" CB_APP_ID="$CB_APP_ID" AGENT_APP_SECRET="$AGENT_APP_SECRET" \ + bash scripts/gh-app-token.sh) + { + echo '## CNB 配额告警(cnb-audit 周审计自动开出,IR-0004 AC-15)' + echo + echo '以下账号余量 <20%(处置前 cnb-dispatch 对该账号降频/换账号):' + echo + sed 's/^/- /' quota-low.txt + echo + echo '---' + echo '来源:.github 仓 cnb-audit 周一 03:41 UTC run(本 issue 存在期间,' + echo 'AGENTS.md 无人值守护栏 (d) 视为存在未决 cost issue——派单前置检查会拦)。' + } > cost-body.md + GH_TOKEN="$TOKEN" gh issue create -R Cloudbird-Software/.github \ + -t 'CNB 配额告警:账号余量 <20%(周审计)' -b "$(cat cost-body.md)" -l cost-infra \ + || { echo '::error::cost issue 创建失败——审计消费链停摆,fail-closed'; exit 1; } + # ------------------------------------------------------------------ + # ②窗口停扰扫描:占用中窗口数与 build logs 交叉(IFACE-05 对账真源=平台 + # build logs,交叉在 cnb_pool.py windows 内完成,本步只消费产出); + # 占用率 ≥阈值(policy cnb.window_warn_pct,缺省 80)→ 输出 WARN 行 + # (观测面非判定面,不红)。脚本失败本身=红(INV-04)。 + # ------------------------------------------------------------------ + - name: windows(停摆扫描,WARN 口径) + run: | + set -euo pipefail + python3 cnb-bridge/cnb_pool.py windows | tee windows.out + TOTAL=$(grep -cE '^window=[0-9]+ status=' windows.out || true) + OCCUPIED=$(grep -cE '^window=[0-9]+ status=occupied' windows.out || true) + WARN_PCT=$(python3 - <<'EOF' + import yaml + try: + with open('governance/policy/automation-limits.yaml', encoding='utf-8') as f: + cnb = (yaml.safe_load(f) or {}).get('cnb') or {} + print(int(cnb.get('window_warn_pct', 80))) + except Exception: + print(80) # 巡检旋钮(warn 不红,非判定面);policy 可加 window_warn_pct 覆盖 + EOF + ) + if [[ "$TOTAL" -gt 0 && $(( OCCUPIED * 100 / TOTAL )) -ge "$WARN_PCT" ]]; then + echo "::warn::WARN | cnb-audit | windows | occupied=${OCCUPIED}/${TOTAL}(≥${WARN_PCT}%,池近饱和——派单侧降频或等回收)" + echo "WARN | cnb-audit | windows | occupied=${OCCUPIED}/${TOTAL} >= ${WARN_PCT}% 池近饱和" >> "$GITHUB_STEP_SUMMARY" + else + echo "AUDIT | cnb-audit | windows | occupied=${OCCUPIED}/${TOTAL}(阈值 ${WARN_PCT}%)" + fi + # ------------------------------------------------------------------ + # ③三接缝隔离 grep(IFACE-01,ADR-0085 决策 6 审计口径): + # 治理仓内操作性引用(cnb.cool / CNB_TOKEN / @CodeBuddy)只允许出现在 + # 接缝本体与既有声明/登记面;命中数必须为 0,非 0 即红并列出命中文件。 + # 排除清单(逐项依据,ADR-0085 决策 6"目录导航性/声明性提及不计入"): + # - governance/GOVERNANCE.yaml EX-1 声明条目本体(三接缝之一) + # - .github/workflows/cnb-dispatch.yml 接缝之二(派单经纪人) + # - .github/workflows/cnb-audit.yml 接缝之三(本审计) + # - governance/expected-state.json secret/工作流登记表(声明面:登记名非使用点) + # - governance/policy/automation-limits.yaml cnb 配置节(EX-1 政策面:加账号=改配置零代码) + # - governance/providers.yaml 工具目录(声明面:secret_ref 登记名非值/非使用点,ADR-0085 决策 7) + # - specs/IR-0004/spec.md 制度文本(DECISION-06 对 token 形态的规范性条款) + # 新增排除项必须走 ADR(C1)——本清单是被审计对象的一部分。 + # ------------------------------------------------------------------ + - name: isolation-grep(三接缝隔离,命中非 0 即红) + run: | + set -euo pipefail + HITS=$(git grep -nE 'cnb\.cool|CNB_TOKEN|@CodeBuddy' -- \ + ':!governance/GOVERNANCE.yaml' \ + ':!.github/workflows/cnb-dispatch.yml' \ + ':!.github/workflows/cnb-audit.yml' \ + ':!governance/expected-state.json' \ + ':!governance/policy/automation-limits.yaml' \ + ':!governance/providers.yaml' \ + ':!specs/IR-0004/spec.md' || true) + if [[ -n "$HITS" ]]; then + echo "$HITS" + echo "::error::操作性引用越界(IFACE-01 接缝与声明面之外,命中 $(echo "$HITS" | grep -c '' ) 行)——上列文件须收敛引用或走 ADR 修订接缝定义" + exit 1 + fi + echo 'AUDIT | cnb-audit | isolation | grep hits=0(接缝与声明面外零操作性引用)' >> "$GITHUB_STEP_SUMMARY" + echo 'AUDIT | cnb-audit | isolation | grep hits=0' diff --git a/.github/workflows/cnb-dispatch.yml b/.github/workflows/cnb-dispatch.yml new file mode 100644 index 0000000..5a72d52 --- /dev/null +++ b/.github/workflows/cnb-dispatch.yml @@ -0,0 +1,177 @@ +name: cnb-dispatch +# ============================================================================= +# CNB 派单经纪人(PM 默认实现主力入口,ADR-0085 裁决 2 / 决策 6;IR-0004 AC-14) +# +# 硬注释(改动前必读): +# - 本工作流是治理仓 CNB 三接缝之一(GOVERNANCE EX-1 声明条目 / IFACE-01, +# ADR-0085 决策 6 / IR-0004):治理仓对 CNB 的操作性引用收敛于 +# GOVERNANCE.yaml + org secrets + cnb-dispatch/cnb-audit 两工作流; +# cnb-audit 第③步隔离 grep 在本文件之外的命中即红。 +# - 判定永不外置(INV-02):本经纪人只派发生成侧任务,NPC 产物进任何判定链 +# 前必经机械核对(INV-03:基准 SHA/差异可应用/格式校验)——沙箱自报数字不采信。 +# - 任务文本禁止包含任何凭据(INV-06):token 只经 org secret 注入本经纪人, +# 永不进入 PM 上下文/任务文本/沙箱环境(DECISION-01 缓解条款)。 +# - 无人值守护栏适用(ADR-0040):派单前查 org 变量 AUTO_MERGE_DISABLED, +# 置位即停(fail-closed,禁止任何绕过尝试)。 +# +# 异步派单:不等待 NPC 回复;产物回收走 cnb-bridge `cnb_pool.py collect` +# 或 cnb-audit 周审计。actions 只用 org 白名单内的 checkout(CI-2), +# 其余逻辑一律 python3/bash——不引入新第三方 Action。 +# ============================================================================= +on: + workflow_dispatch: + inputs: + task: + description: '任务文本(机器可读,IFACE-02:必含清单 ID;std/heavy 须带 tier-reason:/tier-adr: 行)' + required: true + type: string + card: + description: 关联卡 issue 编号(选填,写进派单回执审计面) + required: false + type: string + default: '' + tier: + description: '核时档位:light=1C(默认)/ std=2C / heavy=4C;8C 禁用(BUDGET-01)' + required: false + type: string + default: light + account: + description: 'CNB 池账号别名:xuemei(默认)/ p11' + required: false + type: string + default: xuemei + repository_dispatch: + types: [cnb-dispatch] + +permissions: + contents: read + +concurrency: + group: cnb-dispatch + cancel-in-progress: false # 派单不互相挤占——排队而非取消(审计面完整) + +jobs: + dispatch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1(org 白名单 CI-2) + with: + persist-credentials: false + # ------------------------------------------------------------------ + # 步骤 1:归一化双入口入参 + 派单前置检查(ADR-0040 护栏 / INV-06 凭据红线) + # ------------------------------------------------------------------ + - name: preflight(入参归一化 + AUTO_MERGE_DISABLED + 凭据嗅探) + env: + I_TASK: '${{ inputs.task || github.event.client_payload.task }}' + I_CARD: '${{ inputs.card || github.event.client_payload.card }}' + I_TIER: '${{ inputs.tier || github.event.client_payload.tier }}' + I_ACCOUNT: '${{ inputs.account || github.event.client_payload.account }}' + # org Actions 变量(visibility=all,全仓可读,automation-limits.yaml circuit_breaker 节) + AUTO_MERGE_DISABLED: '${{ vars.AUTO_MERGE_DISABLED }}' + run: | + set -euo pipefail + [[ -n "${I_TASK:-}" ]] || { echo '::error::task 缺失(必填)——拒发'; exit 1; } + : "${I_TIER:=light}" + : "${I_ACCOUNT:=xuemei}" + : "${I_CARD:=}" + # ADR-0040:熔断标志置位即停一切派发(空=未置位;fail-closed,禁绕过) + if [[ "${AUTO_MERGE_DISABLED,,}" =~ ^(true|1|yes)$ ]]; then + echo '::error::AUTO_MERGE_DISABLED 已置位——成本熔断中,停止一切 CNB 派单(ADR-0040)' + exit 1 + fi + # INV-06:任务文本禁含凭据字样——机械预检,命中即红(拒发即止损) + if grep -qiE 'CNB_TOKEN|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY' <<<"$I_TASK"; then + echo '::error::任务文本疑似包含凭据(INV-06)——拒发;重写任务文本,凭据只走 org secret' + exit 1 + fi + # 多行 TASK 用 heredoc 定界符写入环境面(GITHUB_ENV 多行语法) + { + printf 'TASK<> "$GITHUB_ENV" + # ------------------------------------------------------------------ + # 步骤 2:档位校验——真源 governance/policy/automation-limits.yaml cnb 节 + # (已落地:tiers {light:1, std:2, heavy:4} / default_tier: light,IR-0004 AC-15; + # cnb 节缺失即红:fail-closed,不内置缺省档位——同 test_integrity + # “本节拉取失败 = gate 红”先例,BUDGET-01) + # ------------------------------------------------------------------ + - name: 档位校验(policy 为准,fail-closed) + run: | + python3 - <<'EOF' + import os, re, sys, yaml + with open('governance/policy/automation-limits.yaml', encoding='utf-8') as f: + policy = yaml.safe_load(f) + cnb = policy.get('cnb') + if not isinstance(cnb, dict) or not cnb.get('tiers'): + print('::error::automation-limits.yaml 缺 cnb.tiers 节——IR-0004 D 组落地前 fail-closed,' + '不得内置缺省档位(policy 为准原则)') + sys.exit(1) + tiers = cnb['tiers'] # 期望形如 {light: 1, std: 2, heavy: 4} + tier = os.environ['TIER'] + if tier not in tiers: + print(f'::error::未知/禁用档位 tier={tier};允许集={sorted(tiers)}(8C 禁用,BUDGET-01)') + sys.exit(1) + task = os.environ['TASK'] + if tier == 'std' and not re.search(r'tier-reason:\s*\S.{4,}', task): + print('::error::std=2C 须在任务文本带 tier-reason: <一句话理由>(BUDGET-01)') + sys.exit(1) + if tier == 'heavy' and not re.search(r'tier-adr:\s*ADR-\d{4}', task): + print('::error::heavy=4C 须在任务文本带 tier-adr: ADR-NNNN(BUDGET-01)') + sys.exit(1) + cpus = tiers[tier] + if int(cpus) >= 8: + print(f'::error::档位 {tier}={cpus}C 达禁用线(8C 禁用,BUDGET-01)') + sys.exit(1) + with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as f: + f.write(f'CPUS={cpus}\n') + print(f'tier={tier} cpus={cpus}C(policy cnb.tiers 校验通过)') + EOF + # ------------------------------------------------------------------ + # 步骤 3:checkout cnb-bridge(L2 可删除层,ADR-0085 决策 6;main) + # ------------------------------------------------------------------ + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Cloudbird-Software/cnb-bridge + ref: main + path: cnb-bridge + persist-credentials: false + # ------------------------------------------------------------------ + # 步骤 4:选定账号令牌 + 派单。 + # secret 引用必须在表达式中静态出现(GH Actions 无法动态索引 secrets)—— + # 故用 env 映射落地 "account=xuemei→CNB_TOKEN_XUEMEI / p11→CNB_TOKEN_P11"; + # 两个 secret 均按 org 侧 required: false 声明(缺失不阻断表达式求值), + # 运行时缺失即 fail-closed 红。加账号 = owner 加 org secret + 本映射加一行(C1 PR)。 + # 令牌只在本步进程内 export,不落 GITHUB_ENV/日志/摘要。 + # ------------------------------------------------------------------ + - name: dispatch(token 仅本步内存面) + env: + CNB_TOKEN_XUEMEI: ${{ secrets.CNB_TOKEN_XUEMEI }} + CNB_TOKEN_P11: ${{ secrets.CNB_TOKEN_P11 }} + run: | + set -euo pipefail + case "$ACCOUNT" in + xuemei) export CNB_TOKEN="${CNB_TOKEN_XUEMEI:-}" ;; + p11) export CNB_TOKEN="${CNB_TOKEN_P11:-}" ;; + *) echo "::error::未知账号 $ACCOUNT(池内:xuemei/p11;新增=owner 加 org secret + 本 env 映射一行,C1)"; exit 1 ;; + esac + [[ -n "${CNB_TOKEN:-}" ]] || { echo "::error::org secret CNB_TOKEN_${ACCOUNT^^} 缺失——fail-closed 红(令牌通道唯一=org secret,INV-06)"; exit 1; } + # [run:] 锚由 cnb_pool.py 在任务文本内附加(IFACE-02:run-id 前缀落 payload.run_id); + # CLI 契约=位置参数 body + --account/--tier(档位→核数映射与 build logs 对账在 cnb-audit 侧) + set -- python3 cnb-bridge/cnb_pool.py dispatch "$TASK" --account "$ACCOUNT" --tier "$TIER" + "$@" | tee dispatch.out + # 审计面:窗口/账号/run_id 回执同步落 run 日志与 step summary(异步派单不等待 NPC) + RUN_ID=$(grep -oE 'run_id=[^ ]+' dispatch.out | head -1 || true) + WINDOW=$(grep -oE 'window=[^ ]+' dispatch.out | head -1 || true) + { + echo '## CNB 派单回执(异步——回收走 collect / cnb-audit 周审计)' + echo + echo "- account=$ACCOUNT tier=$TIER cpus=$CPUS card=${CARD:-(无)} ${WINDOW} ${RUN_ID}" + echo + echo '```' + sed -n '1,60p' dispatch.out + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "AUDIT | cnb-dispatch | account=$ACCOUNT | tier=$TIER | cpus=$CPUS | card=${CARD:-none} | ${WINDOW:-window=n/a} | ${RUN_ID:-run_id=n/a}" diff --git a/.github/workflows/conductor.yml b/.github/workflows/conductor.yml index d08ac83..a41ee7d 100644 --- a/.github/workflows/conductor.yml +++ b/.github/workflows/conductor.yml @@ -395,6 +395,56 @@ jobs: api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3Awave-planned", "DELETE") raise SystemExit(0) + # ---- ADR-0085:T8 卡完成谓词(存在绑定本卡且已合并的 PR)---- + def check_merged_pr(issue_number): + """卡完成谓词:存在 body 含 Card: # 绑定行且已合并的 PR。 + gate 绿由 PR required checks 承担(合并事实即全 gate 绿的载体); + 不信任标签事件载荷——API 重查(fail-closed:读取失败=拒绝)。""" + target = f"{REPO}#{issue_number}" + st, prs = api(E["APP_TOKEN"], f"/repos/{REPO}/pulls?state=closed&per_page=100") + if st != 200: + return False, f"PR 清单读取失败 HTTP {st}(fail-closed)" + for pr in prs or []: + if pr.get("merged_at") and target in (pr.get("body") or ""): + return True, f"PR #{pr['number']} 已合并({pr['merged_at']})绑定 {target}" + return False, f"无绑定 {target} 且已合并的 PR(近 100 个 closed PR 内)" + + if t["id"] == "T8": + okp, reason = check_merged_pr(ISSUE) + audit(f"T8 合并 PR 谓词: ok={okp} reason={reason}") + if not okp: + audit(f"verdict=DENIED-no-merged-pr T8 拒绝——{reason}") + api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3Adone", "DELETE") + raise SystemExit(0) + + # ---- ADR-0085:T9 IR 验收谓词(全部子卡收口 + 验收报告存在)---- + def check_ir_acceptance(issue_number, title): + """IR 验收谓词(Gate 4):①无 open 子卡引用本 IR(子卡=body 含 + "父意图: #";全部收口=done+closed)②specs//acceptance.md + 存在于本仓工作区(=事件时点默认分支)。子卡曾存在性由验收报告自证 + (报告须列子卡清单与逐 AC 证据)。""" + st, cards = api(E["APP_TOKEN"], f"/repos/{REPO}/issues?state=open&per_page=100&labels=type%3Acard") + if st != 200: + return False, f"子卡清单读取失败 HTTP {st}(fail-closed)" + pending = [c["number"] for c in cards or [] + if f"父意图: #{issue_number}" in (c.get("body") or "")] + if pending: + return False, f"尚有 open 子卡未收口: {pending}" + m = re.search(r"(IR-\d+)", title or "") + task_id = m.group(1) if m else f"ISSUE-{issue_number}" + acc = os.path.join(os.getcwd(), "specs", task_id, "acceptance.md") + if not os.path.isfile(acc): + return False, f"验收报告缺失: specs/{task_id}/acceptance.md" + return True, f"全部子卡收口 + 验收报告存在({task_id})" + + if t["id"] == "T9": + okp, reason = check_ir_acceptance(ISSUE, iss.get("title") or "") + audit(f"T9 IR 验收谓词: ok={okp} reason={reason}") + if not okp: + audit(f"verdict=DENIED-acceptance-incomplete T9 拒绝——{reason}") + api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3Adone", "DELETE") + raise SystemExit(0) + # ---- 执行转移(状态标签写=App 身份,INV-02;写失败=fail-closed, # /claim 已建租约时先补偿回滚——杜绝"租约在、卡未变"的不一致面,ADR-0055)---- class WriteFail(Exception): diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index d54dd4f..70ee9e4 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -111,7 +111,7 @@ jobs: if: always() permissions: contents: read # job 级最小权限(评审项:防后续新增 job 继承 workflow 级权限); - # 兼读公开仓 agent-registry/decisions(adr-required 存在性校验) + # 兼读公开仓 archive/adr(adr-required 存在性校验,ADR-0085 家园单仓化) pull-requests: read # 仅 adr-required 步骤读取 PR 文件清单 steps: - name: hygiene green? @@ -202,9 +202,9 @@ jobs: # adr-required(红队 #17-B/D、#18-P0 部分;对齐 agent-registry ADR-0013 同名 check): # C1 路径(governance/ standards/ scripts/ .github/ CODEOWNERS)变更的 PR, # title/body 必须引用 ADR-NNNN——GOVERNANCE flows.governance_change "无 ADR 不合并" - # 的机器执行。本仓不落盘 ADR(L1 决策记录在 agent-registry/decisions/, + # 的机器执行。本仓不落盘 ADR(正本+索引在 archive/adr/(ADR-0085 家园单仓化), # 见 REPOS.yaml role)。存在性校验(ADR-0021 升级):ADR-0020 全仓公开后, - # GITHUB_TOKEN 可读公开仓 contents——被引 ADR 必须真实存在于 agent-registry/decisions/ + # GITHUB_TOKEN 可读公开仓 contents——被引 ADR 必须真实存在于 archive/adr/ #(此前只能等 drift-check §10 后验,伪造引用有 24h 窗口;现在 PR 时点即拦)。 # 实体性(防空壳)仍由 drift-check §10 后验;owner-only review 仍是权威人类门禁。 - name: adr-required(C1 变更须引用 ADR) @@ -255,7 +255,7 @@ jobs: exit 1 fi # 存在性校验(ADR-0021)+ 索引世界(ADR-0053/W1-C1 .github#164)双世界兼容: - # 先试拉 agent-registry decisions/INDEX.yaml(墓碑索引)—— + # 先试拉 archive/adr/INDEX.yaml(墓碑索引,ADR-0085 迁入)—— # 200 → 索引世界(迁移后):decisions/ 只留同名墓碑(文件名保留正是为了 # 让本步骤的存在性清单与 org-gate v1.4.2 按文件名校验零改动兼容), # 正本在 archive 仓 adr/。被引 ADR-NNNN 必须在 INDEX entries 且 @@ -265,7 +265,7 @@ jobs: # 404 → 旧世界(迁移未发生/已回滚):现有 decisions/ 清单逻辑原样。 # 其他 → fail-closed(检测器失明不得伪装通过)。 # 两世界共通不变量:引用不存在的编号一律 fail(防幽灵 ADR 语义不回归,#164 AC-2)。 - ADR_INDEX_JSON=$(gh api "repos/Cloudbird-Software/agent-registry/contents/decisions/INDEX.yaml" 2>&1 >/tmp/adr_index.json; echo "rc=$?") + ADR_INDEX_JSON=$(gh api "repos/Cloudbird-Software/archive/contents/adr/INDEX.yaml" 2>&1 >/tmp/adr_index.json; echo "rc=$?") if jq -e '.content' /tmp/adr_index.json >/dev/null 2>&1; then ADR_WORLD="index" base64 -d <<<"$(jq -r .content /tmp/adr_index.json)" >/tmp/adr_index.yaml 2>/dev/null @@ -289,13 +289,13 @@ jobs: elif grep -q "Not Found" <<<"$ADR_INDEX_JSON"; then ADR_WORLD="legacy" else - echo "::error::agent-registry decisions/INDEX.yaml 拉取失败(非 404)——索引世界判定失败,fail-closed" + echo "::error::archive/adr/INDEX.yaml 拉取失败(非 404)——索引世界判定失败,fail-closed(ADR-0085 家园单仓化)" exit 1 fi if [[ "$ADR_WORLD" == "legacy" ]]; then - ADR_LISTING=$(gh api "repos/Cloudbird-Software/agent-registry/contents/decisions?per_page=100" --paginate --jq '.[].name' 2>/dev/null) + ADR_LISTING=$(gh api "repos/Cloudbird-Software/archive/contents/adr?per_page=100" --paginate --jq '.[].name' 2>/dev/null) if [[ -z "$ADR_LISTING" ]]; then - echo "::error::agent-registry/decisions 清单拉取失败——ADR 引用存在性无法校验(fail-closed)" + echo "::error::archive/adr 清单拉取失败——ADR 引用存在性无法校验(fail-closed)" exit 1 fi fi @@ -326,7 +326,7 @@ jobs: MISSING=1 fi elif ! grep -q "^ADR-${num}-" <<<"$ADR_LISTING"; then - echo "::error::引用的 ${ref} 在 agent-registry/decisions/ 无对应文件(幽灵 ADR——C1 决策背书不成立)" + echo "::error::引用的 ${ref} 在 archive/adr/ 无对应文件(幽灵 ADR——C1 决策背书不成立)" MISSING=1 fi done diff --git a/AGENTS.md b/AGENTS.md index 921863f..e92a2f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 行——协议块+硬规则+索引三合一,ADR-0055 决策 4)。细节按需读引用文件,不常驻上下文。 +PM(项目经理)工作契约(ADR-0085,索引型;治理仓豁免行数放宽至 60——PM 优先范式的入口即本文件)。**组织只规定四道阶段门禁与红线;门禁之间怎么干、调什么资源,由你自主决定。** 细节按需读引用文件,不常驻上下文。 @@ -14,27 +14,42 @@ AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 6. front-desk 命令(卡 issue 评论,conductor 转介 arbiter 处理):/claim 认领 · /release 释放租约 · /retry 隔离回流 +## PM 优先(ADR-0085) + +- **入职三步**:① `governance/REPOS.yaml` 看版图 → ② `docs/pm/PLAYBOOK.md`(阶段手册:资源/用法/代价/报告格式)→ ③ 最近 4 周运行报告(`Cloudbird-Software/archive` 仓 `runs/`) +- **看全局**:`bash ghcb board`(全状态流水线:ir-draft…done 的 IR 与卡,不只 ready 卡) +- **四道门禁**(组织控制的全部过程面):① IR 签署→spec——你自著 spec 合法(PR338 先例),spec-author 流水线=可选快速通道 → ② spec PR 必带 suite/ + 红队 survived → 开卡 → ③ 卡实现 PR 全 gate 绿+合并 → 置 `state:done`(T8 谓词机械查合并事实)→ ④ 全部子卡 done + `specs//acceptance.md` → IR `state:done`(T9 谓词) +- **默认开发主力=CNB 免费算力**:`bash ghcb dispatch <卡#> [--tier light|std] [--account ]`(经 cnb-dispatch 经纪人,key 永不入你上下文);gate 红/语义敏感→你自己接手(上升策略不预设,事后写进运行报告) +- **每次 run 结束**:`bash ghcb report` 生成骨架 → 追加到 archive `runs/YYYY-WNN.md`(三节式:事实/体感/改进点;`[followup]` 行=周度 digest 的机械抓手) +- **验收**:`bash ghcb accept ` 生成验收报告骨架 + +## 红线(自主性的边界——永不触碰、永不试图优化) + +- 判定语义:LLM/沙箱只在生成侧,一切判定锚点机械(INV-01/02);沙箱自报数字不采信 +- fail-closed:任何关卡异常/超时/数据缺失=红,无"默认绿" +- append-only 账本:用量/生命周期/分诊/fan-out 产物只增不改(ADR-0062 hash 链) +- 凭据纪律:一切 key 只存 org secret,你永不接触;调用一律借道 dispatch 工作流 ## 硬规则 -- 治理文件(governance/ standards/ scripts/ .github/ CODEOWNERS profile/ Makefile docs/)= C1 路径:PR 必须引用 ADR-NNNN,owner-only review(GOVERNANCE flows.governance_change;与 gate adr-required 机器检查同路径集) -- agent 写仓库身份 = GitHub App `cloudbrid-agent`(AG-1);令牌经 scripts/gh-app-token.sh,单仓作用域、1h 过期(本仓驻留 agent 直接用 `scripts/ghcb`,等价协议块下载版)。例外:org 级 Project(v2) 写与组织成员判定(App 无 organization_projects/members 权限,ADR-0055 决策 8)用 GOVERNANCE_TOKEN(org admin PAT,仅 workflow secrets 面,不落 agent 手) -- 本仓只读治理声明;ADR 与注册条目落盘 agent-registry(REPOS.yaml L1) -- 红队守门制度(ADR-0082):spec/测试设计路径 PR 必须经红队审计;默认 verifier 范式(ADR-0072/LLM-as-a-Verifier);g060 锁定 specs/*/suite/**(ADR-0061/ADR-0081,scripts/g060-lock.sh + g060-escalation.py,非授权身份 exit 2 开 issue 路由 owner 裁决) +- 治理文件(governance/ standards/ scripts/ .github/ CODEOWNERS profile/ Makefile docs/)= C1 路径:PR 必须引用 ADR-NNNN(家园=archive/adr/,ADR-0085),owner-only review +- 写仓身份 = GitHub App `cloudbrid-agent`(AG-1);令牌经 scripts/gh-app-token.sh,单仓作用域、1h 过期(本仓驻留 agent 直接用 `scripts/ghcb`)。例外:org 级 Project 写与成员判定用 GOVERNANCE_TOKEN(仅 workflow secrets 面,不落 agent 手) +- ADR 落盘:PR 至 `archive/adr/ADR-NNNN-*.md` + 更新同目录 INDEX.yaml(家园单仓化,ADR-0085) +- 红队守门(ADR-0082):spec/测试设计路径 PR 必须经红队审计;g060 锁定 specs/*/suite/**(ADR-0061/0081) - 不引入新第三方 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(用量不可知时同样停) +- 无人值守护栏(ADR-0040,跨仓生效):(a) 派发与 automerge 前查 org 变量 `AUTO_MERGE_DISABLED`——置位即停一切;(b) 同一 PR 修红重试 ≤ automation-limits.yaml `auto_fix.max_attempts`(默认 3);(c) 不得 reopen 带 `auto-fix-limit-exhausted` 标签的 PR;(d) 派发前确认无未决 `cost-infra`/`cost-circuit-breaker` issue ## 常用命令(本仓驻留) -- 校验本仓声明:`.github/workflows/gate.yml`(本地等价:`make gates-pr`——bash -n + yaml 全量解析) -- 漂移检测/漂移修复/新仓初始化(**owner 或 CI 专属**——需 org admin PAT,agent 不得持此令牌,AG-1;agent 需要时提卡转交 owner 或走 workflow_dispatch):`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑;§17=入口协议块对账)· `GH_TOKEN= bash governance/apply.sh`(幂等;失败 loud 退出)· `bash scripts/new-repo-init.sh `(owner) -- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh`(小时级;`AUTOFIX_DRY_RUN=1` 只报告;同上 owner/CI 专属) -- 成本熔断检查:`GH_TOKEN= bash governance/cost-check.sh`(6h;`COST_USAGE_MINUTES_OVERRIDE=` 注入测试;同上 owner/CI 专属) -- 取 App 令牌:`GH_TOKEN=$(scripts/ghcb )`(缓存命中零网络;`--refresh` 强刷,ADR-0044) -- factory-floor 板/账本手动刷新:Actions → board-sync(dispatch-only;日常 cron 归 butler-ledger,ADR-0055) +- 校验本仓声明:`.github/workflows/gate.yml`(本地等价:`make gates-pr`) +- 漂移检测/修复(owner/CI 专属,需 org admin PAT):`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑)· `GH_TOKEN= bash governance/apply.sh`(幂等) +- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh` · 成本熔断:`GH_TOKEN= bash governance/cost-check.sh` +- 取 App 令牌:`GH_TOKEN=$(scripts/ghcb )`(缓存命中零网络,ADR-0044) +- CNB 池运维:`Cloudbird-Software/cnb-bridge` 仓(accounts.yaml/cnb_pool.py/REMOVAL.md);周审计=本仓 cnb-audit 工作流(EX-1 三接缝之一) ## 索引(用到再读) -- 治理总声明 governance/GOVERNANCE.yaml · 组织地图 governance/REPOS.yaml · 期望状态 governance/expected-state.json -- 政策集 governance/policy/(languages.yaml、testing.yaml、无人值守阈值 automation-limits.yaml ADR-0040;入口协议/卡元数据=ADR-0055) -- agent 标准 schema standards/agent/ · 自动化规范 standards/automation/(ADR-0031/0032/0045)· 注册条目与 ADR → agent-registry 仓 +- 治理总声明 governance/GOVERNANCE.yaml(agent_runtime/external_compute 域=ADR-0085 新范式)· 组织地图 governance/REPOS.yaml · 期望状态 governance/expected-state.json +- 政策集 governance/policy/(languages/testing/automation-limits 含 cnb: 节) +- 状态机 governance/transitions.yaml(T7 卡就绪/T8 卡完成/T9 IR 验收——conductor 唯一定义源) +- PM 手册 docs/pm/PLAYBOOK.md · 工具目录 governance/providers.yaml(无密钥) · 运行报告 archive 仓 runs/ diff --git a/docs/pm/PLAYBOOK.md b/docs/pm/PLAYBOOK.md new file mode 100644 index 0000000..dfff386 --- /dev/null +++ b/docs/pm/PLAYBOOK.md @@ -0,0 +1,134 @@ +# PM Playbook(ADR-0085) + +PM(强模型项目经理)的唯一深度文档:按四道阶段门禁组织,阶段内怎么调用资源由你自主决定。 +冷启动顺序见 §1;每次 run 结束写运行报告(§6)。治理总声明 `governance/GOVERNANCE.yaml`, +组织地图 `governance/REPOS.yaml`,状态机 `governance/transitions.yaml`——本手册解释怎么用它们,不复述。 + +## §0 自主边界与红线 + +一句话:**自主的是生成路径,永不自主的是判定语义**——spec 怎么写、卡怎么拆、 +派 CNB 还是自己写、用什么顺序调工具,全是你的裁量;但"什么算通过"由 gate / +conductor / 红队 / 验证者在 GitHub CI 内机械判定,你与外部沙箱都只在生成侧(INV-01/02,IR-0004)。 + +四条不可触碰(违反=违约,无豁免通道): + +1. **判定语义**:LLM/NPC 输出永不直接作为任何 gate 输入;沙箱自报数字一律不采信, + 产物进判定链前必经机械核对(基准 SHA / 差异可应用 / 格式校验,INV-03)。 +2. **fail-closed**:任何判定环节异常、超时、数据缺失一律按红处理,无"未定义默认绿"(INV-04)。 +3. **append-only 账本**:用量账本、运行报告、fan-out 产物目录只增不改;纠错追加 erratum 行(INV-05)。 +4. **凭据纪律**:你的上下文永不出现任何 API key(详见 §7,INV-06)。 + +上升策略不预设(ADR-0085 背景裁决 1):没有"什么情况必须找人"的成文清单——该自己上就上 +(gate 红、语义敏感、机械核对不符),事后把判断过程写进运行报告 §6,经 digest 沉淀后再成文。 + +## §1 入职三步 + +1. **看版图**:读 `governance/REPOS.yaml`——L0 治理三仓(.github / CI-Workflows / archive)、 + L2 产品仓、cnb-bridge(L2 可删除层,ADR-0085 决策 6)各是什么、在哪。 +2. **读本手册**:全文一遍,重点 §0 红线与 §8 速查表。 +3. **读最近 4 周运行报告**:`archive/runs/`(格式见 `archive/runs/README.md`)+ + .github 仓 `pm-digest` 标签的 digest issue——前人踩过的坑不用再踩(ADR-0085 决策 8)。 + +worker 视角的认领/开工协议(AGENTS.md entry-protocol v1 块)对 PM 同样适用——PM 也是执行者之一。 + +## §2 阶段一:IR→spec + +把意图变成可红队审计的 spec + suite。门禁=签署(T1/T2)+ spec PR 过 suite 门与红队。 + +- **开 IR**:用 .github 仓 issue 模板 `intent.yml`(打 `type:intent` 标签)。字段全必填(IFACE-01,IR-0001)。 +- **签署**:owner 评论 `/start`(T2)或打 `state:ir-signed` 标签(T1)——只有 owner 能签,你不代签。 +- **spec 谁写**:两条制度等价路径(ADR-0085 决策 5,PR338 先例追认): + - 你自己写——完全合法,且是深度理解 IR 的最好方式; + - spec-author 快速通道:CI-Workflows `spec-author.yml` 流水线生成骨架你再修。 + 模板:CI-Workflows `pipeline/spec-template.md`(正文条款结构 / AC given-when-then / INV-BEH-IFACE 分节)。 +- **spec PR 必带**: + - `suite/` 目录(ADR-0083 suite 门;至少一个非空可解析测试文件——T5 `suite_ready_required` + 谓词会现场重查,T-14 亦要求);注意 `specs/*/suite/**` 在 g060 锁定集内 + (`g060-guard.yml` + `scripts/g060-lock.sh`,非授权身份 exit 2 开裁决 issue,ADR-0061/0081)。 + - 红队 required check `adversary`(`adversary-gate.yml`,ADR-0082):survived 才绿, + fail-closed——漏配/摘除/跳过即红。 +- spec PR 是治理 C1 路径:引用 ADR-NNNN、owner-only review(AGENTS.md 硬规则)。 + +## §3 阶段二:spec→卡 + +红队放行后把 spec 切成可认领的卡。门禁=survived 审计(T6 三元组)。 + +- **过红队**:spec PR 的 adversary check `verdict=survived` + 打 `adversary:survived` 标签 → + T6 转 `wave-planned`;conductor 校验三元组(卡 ID / specVersion / 审计 run ID 均属本次生命周期, + 禁跨卡/历史短路——transitions.yaml T6 注)。 +- **开卡**:每张卡一个 issue,打 `type:card` + `state:ready`(T7 谓词=卡具备认领条件, + ADR-0085 决策 5 补洞)。**AC 从 spec 逐条派生**,不自由发挥——验收(§5)按 AC 对账。 +- **PR 绑定**:实现 PR body 必带一行卡元数据 `Card: /#` + (`bash ghcb card-meta ` 生成;缺失=后续关卡 exit 3,T-14 卡绑定测试按此路由)。 + +## §4 阶段三:实现 + +默认派 CNB 免费算力,你做编排与机械核对。门禁=卡完成 + 全 gate 绿(T8)。 + +- **派单**(默认路径,IR-0004 低决策密度分支执行者=CNB): + - `bash ghcb dispatch`(ADR-0085 决策 4 子命令),或 + - .github 仓 `cnb-dispatch` 工作流(Actions UI / `gh workflow run`,或 + repository_dispatch `cnb-dispatch`)。两者都经经纪人,你不碰 token(§7)。 + - 任务文本机器可读(IFACE-02):必含档位与清单 ID,输出契约=差异全文+测试原始输出 + (含退出码)+基准 SHA 确认;`[run:]` 锚由派单侧自动附加。 +- **档位**(BUDGET-01,policy 真源=`governance/policy/automation-limits.yaml` cnb 节): + `light=1C` 默认 · `std=2C` 须在任务文本带 `tier-reason: <一句话理由>` · + `heavy=4C` 须带 `tier-adr: ADR-NNNN` · 8C 禁用。档位以 build logs 实际核数交叉证实。 +- **窗口纪律**:派前查窗口占用(`cnb-audit` 周审计/`cnb_pool.py windows`),单账号并发上限默认 8; + 派单是异步的——不等 NPC 回复,回收走 `cnb_pool.py collect` 或等周审计。 +- **护栏**(ADR-0040,无人值守):派单前 org 变量 `AUTO_MERGE_DISABLED` 置位即停; + 同一 PR 修红重试 ≤ `auto_fix.max_attempts`(默认 3)。 +- **产物必须机械核对**:沙箱自报数字不采信(§0 红线 1)——差异可应用、测试输出含退出码、 + 基准 SHA 与 run 开始时动态获取值一致;不符即作废记 infra 失败,不手工"看起来对"放行。 +- **什么时候自己接手**:gate 红、语义敏感、机械核对不符、卡面 AC 歧义——自己 `/claim` + (T3 认领,先到先得)本地做。判断依据写进运行报告。 +- **收卡**:PR 合并后在你认领的卡 issue 评论,`state:done` 由 conductor T8 谓词机械查证 + (存在绑定本卡且已合并的 PR,读 `Card:` 元数据)——不是你说了算。 + +## §5 阶段四:验收 + +IR 级收口,证据可机械回查。门禁=T9 谓词(ADR-0085 决策 5)。 + +- 全部子卡 `state:done` 后,写 `specs/IR-XXXX/acceptance.md`:**逐 AC** 列运行时证据 + (gate run / build log / 红队报告链接)+ SHA 锚(证据所属 commit/产物基准)。 +- 打 `state:done`:conductor T9 谓词=全部子卡 done + acceptance.md 存在,两者缺一即拒绝。 +- 自述不算证据:运行报告(§6)是经验输入,验收只认可机械回查的运行时证据(§0 红线 1)。 + +## §6 运行报告怎么写 + +收口即写,防遗忘锚点=卡 `state:done` 之前。报告是经验输入与改进燃料,**不是验收证据**。 + +- **三节式**(缺节即不合格,格式真源 `archive/runs/README.md`): + - **事实**——做了什么、数据、卡点(客观陈述,可被引用核对); + - **体感**——哪里不顺:门禁/文档/工具/流程(明确标注主观); + - **改进点**——每条一行,机械可抽取:`[followup] <域>: <描述>`, + 域 ∈ {playbook, policy, gate, tool}。一行一提案,只登记意向、不直接改治理。 +- **落点**:追加到 `archive/runs/YYYY-WNN.md`(ISO 周,一周一文件,append-only;纠错追加 erratum 行)。 +- **聚合**:archive 仓 `runs-digest.yml` 周一 04:37 UTC 抽取上周全部 `[followup]` 行自动开 + digest issue(.github 仓,`pm-digest` 标签);owner 逐条处置——转卡 / 转 IR / 否决关掉留痕。 +- **升策略沉淀**(§0):你"该自己上就上"的每次判断,写进事实/体感节——这是上升策略成文的唯一来源。 + +## §7 凭据纪律 + +你永不接触任何 key(ADR-0085 决策 7,INV-06)。 + +- **org secret 保险箱**:一切平台 token(CNB 池账号令牌、LLM_API_KEY、CB_APP_ID/ + AGENT_APP_SECRET)只存 GitHub org secrets,仓库与配置零明文。 +- **dispatch 经纪人**:你调资源一律借道 workflow(如 `cnb-dispatch.yml`)——secret 注入 + 发生在 runner 内,你的上下文、任务文本、PR/issue 里都不出现 token。 +- **无密钥目录**:`governance/providers.yaml` 登记可用资源与入口(ADR-0085 决策 7 落地件), + 目录本身无密钥。 +- **加工具零代码**:owner 加 org secret + 加目录条目,即接入;你发现缺资源时提卡转 owner, + 不要自建凭据通道(含把 key 贴进 issue/任务文本——那是红线 4 违约)。 + +## §8 可调资源速查表 + +| 资源 | 用途 | 入口 | 代价 | +|---|---|---|---| +| ghcb 命令族 | 找活/认领/卡元数据/令牌;board/dispatch/accept/report 为 ADR-0085 决策 4 扩展 | `bash ghcb next\|claim\|status\|card-meta\|board\|dispatch\|accept\|report`(scripts/ghcb,钉 SHA 取用见 AGENTS.md) | 本地零成本;GitHub API 配额 | +| spec-author 流水线 | spec 骨架快速通道(与自著制度等价) | CI-Workflows `spec-author.yml`;模板 `pipeline/spec-template.md` | Actions 分钟(org 内 $0 净额)+ 你复核的时间 | +| 红队窗口 | spec/测试设计 PR 的 required check | .github `adversary-gate.yml`(required check `adversary`);执行层 CI-Workflows `pipeline/adversary/` | CNB 核·秒(多账号池)+ 判定侧 CI 分钟 | +| CNB 池 | 默认实现主力(CodeBuddy NPC 沙箱,多账号) | `cnb-dispatch.yml` 工作流或 `ghcb dispatch`;池真源 cnb-bridge 仓(accounts.yaml / cnb_pool.py)+ 账号令牌 org secrets(登记面:expected-state.json / automation-limits.yaml cnb 节) | 免费算力;档位 BUDGET-01(light=1C 默认) | +| 验证者 | 独立验收/封存场景考试 | holdout 仓(owner 直管)+ CI-Workflows `pipeline/verifier-exam/`、`verifier-exam.yml` | verifier 档 token(计量入账,automation-limits.yaml) | +| 治理政策 | 无人值守阈值/测试政策/语言准入 | `governance/policy/`(automation-limits.yaml ADR-0040 · testing.yaml T-01..T-15 · languages.yaml) | 读它比违约便宜 | +| 运行报告 | 经验沉淀与改进燃料 | `archive/runs/YYYY-WNN.md`(追加);digest=archive `runs-digest.yml` | 10 分钟/次;不写=下周 PM 重复踩坑 | diff --git a/governance/GOVERNANCE.yaml b/governance/GOVERNANCE.yaml index 99a3b2b..713525b 100644 --- a/governance/GOVERNANCE.yaml +++ b/governance/GOVERNANCE.yaml @@ -150,61 +150,48 @@ domains: strength: advisory agent_runtime: - # 标准本体: standards/agent/{tool,skill,agent,team,event}.schema.yaml - # 注册条目: Cloudbird-Software/agent-registry + # ADR-0085 重写:多 agent 编排声明层退役(agent-registry/agent-platform/agent-tools + # 已归档);角色载体=GitHub Actions 工作流(红队/验证者)+ PM 会话(规划)。 + # 保留有现实载体的条款,声明类条款随退役废止(历史正文见 archive/retired/)。 measures: - id: AR-1 - intent: "agent/skill/tool/team 四类声明 + 模型注册表唯一落盘 agent-registry;本地运行目录仅为部署产物;运行实例可追溯到声明+版本" + intent: "角色治理走 GitHub 原生:开发身份=cloudbrid-agent App(AG-1);验证者=verifier-app(ADR-0076);红队=adversary 工作流(AR-10);PM=任意持凭据会话+ghcb 入口协议。显式 agent/skill/tool/team 声明层退役(ADR-0085)" strength: enforced - platform: {github: {mechanism: repo, name: agent-registry, validate: agent-registry/scripts/validate.py}} - verify: {method: registry-validate, on: PR} - id: AR-2 - intent: "引用 status!=approved 的注册条目 = CI 拒绝;proposed→approved 必须走 PR" + intent: "模型/工具接入一律经计量面:LLM 调用唯一入口=CI-Workflows metering wrapper(ADR-0062);外部工具=key 经 org secret、调用经 dispatch 经纪人(providers.yaml 目录+cnb-dispatch 先例);任何仓库/工作流配置零明文 key(AR-3 第一期形态延续,ADR-0048)" strength: enforced - platform: {github: {mechanism: script, entry: agent-registry/scripts/validate.py}} - id: AR-3 - intent: "模型接入一律经 LLM Gateway:声明只引用 alias;provider key 仅存 gateway secret store;任何仓库/agent 配置零明文 key。第一期形态(IR-0001 DECISION-01/ADR-0048)——无 gateway 时允许直连 provider API,key 仅存 org secret LLM_API_KEY(expected-state §5 登记存在性),一切调用经 CI-Workflows 计量 wrapper(scripts/llm-call.sh)落盘用量;alias 语义与 AR-8 不变,回切触发条件见 ADR-0048" + intent: "PM 自主边界(ADR-0085 决策 1):阶段门禁之间资源调用完全自主;不可触碰=判定语义(生成/裁决分离)、fail-closed、append-only 账本、凭据纪律。上升策略不预设——PM 实践经运行报告(archive/runs/)沉淀后再成文" strength: enforced - - id: AR-4 - intent: "agent 凭据最小权限:per-agent/per-team 作用域 token(衔接 AG-1);声明中只写 env 引用" - strength: enforced - - id: AR-5 - intent: "双层控制:软引导(AGENTS.md/identity/skill 正文)+ 硬边界(工具面裁剪→权限引擎→凭据→平台防线);冲突时硬控制优先;拦截记录进事件流" - strength: enforced - - id: AR-6 - intent: "团队必须声明 lifecycle;ephemeral 团队 archive_to+team 侧 handoff 全部完成才允许销毁(stewardship 侧项由 curator 异步消费归档资产执行,不阻塞销毁——红队 #18 措辞对齐,ADR-0016);persistent 团队(team:stewardship——ADR-0004 规划名 governance-core 的落地形态)对治理资产持续负责" - strength: enforced - platform: {github: {mechanism: script, entry: agent-registry/scripts/validate.py, check: lifecycle}} - - id: AR-7 - intent: "过程数据三分离:声明/决策→git(registry/ADR);事件→数据层(JSONL 起步);原始轨迹→对象存储滚动30天;项目仓仅 AGENTS.md+team 引用行" - strength: enforced - schema: standards/agent/event.schema.yaml - - id: AR-8 - intent: "agent 必须声明 archetype(九 LLM 原型封顶:builder/planner/test-author/judge/curator/adversary/researcher/deployer/responder + 六机制原型不实例化,ADR-0010);builder↔test-author、planner↔builder 不得同一声明;test-author/judge 族级独立(models.yaml family);新增原型须 ADR 论证" - strength: enforced - validate: agent-registry/scripts/validate.py - - id: AR-9 - intent: "可信交付=失败可见:agent 内 guardrails → test-author 出题(owner 已批验收示例→冻结测试树)→ verifier 机制判卷(required check)→ persistent 团队外部审计(escape review/控制测试)→ 平台防线兜底;builder 永不自我信任;意图保真靠 owner 批验收示例(intent ratification)" - strength: enforced - validate: agent-registry/scripts/validate.py - id: AR-10 intent: "红队守门 Veto 强制力:红队审计 verdict=insufficient 时状态变为 needs-human 且无法进入 wave-planned;specs/** 路径 PR 必须包含 adversary check;开发实现路径 PR 按 EXPECTED_SKIP 模式条件化豁免" strength: enforced platform: {github: {mechanism: ruleset-required-status, context: "adversary", workflow: "CI-Workflows/.github/workflows/adversary*.yml"}} verify: {method: adversary-run, on: PR} + external_compute: + # ADR-0085 / IR-0004 D 组(cnb-bridge-design 三平面):发散与执行侧可外置免费算力; + # 判定与裁决永不外置。CNB=当前唯一实例(cnb-bridge 仓,可删除层)。 + measures: + - id: EX-1 + intent: "外部免费算力(当前=CNB CodeBuddy NPC 沙箱池)仅用于生成与执行侧(默认实现引擎:常规卡优先派发,强模型 PM 处理 gate 红/语义敏感);判定锚点全部在 GitHub CI(沙箱自报数字不采信,INV-01/02 语义)" + strength: enforced + platform: {external: {bridge: cnb-bridge, removable: true, + removal_drill: "cnb-bridge/REMOVAL.md 单页清单;删除后 gate/org-gate/conductor 语义不变(ADR-0085 决策 6 断言)", + seams: "GOVERNANCE 本条目 + org secrets CNB_TOKEN_ + .github cnb-dispatch/cnb-audit 两工作流(隔离审计=cnb-audit 周扫,操作性引用越界即红)"}} + flows: governance_change: # 治理仓(.github governance|standards、agent-registry、CI-Workflows workflows/)内容变更的唯一合法路径 # 授权凭证 = ADR + PR 记录;破玻璃保留但被监控 classes: - id: C1 - scope: ["governance/(整目录——含 GOVERNANCE/REPOS.yaml、rulesets、expected-state、apply/drift 脚本、policy)", standards/, models.yaml, decisions/, scripts/, ".github/(CI 门禁自身——改门禁=改治理)", CODEOWNERS, profile/, "tests/(agent-registry 验证器元测试——验证器之验证)", "template-service 脚手架面(.github/、AGENTS.md、Makefile、docs/、zizmor.yml——供应链入口:模板防线被污染=全部新仓继承后门,红队修复 P0;src/ 业务面属 C3。ADR-0021 收敛范围:整仓 C1 会与 SC-3 依赖 automerge 冲突——依赖 PR 无 ADR 不可自动合并)"] + scope: ["governance/(整目录——含 GOVERNANCE/REPOS.yaml、rulesets、expected-state、apply/drift 脚本、policy)", standards/, specs/, scripts/, ".github/(CI 门禁自身——改门禁=改治理)", CODEOWNERS, profile/, "tests/(agent-registry 验证器元测试——验证器之验证)", "template-service 脚手架面(.github/、AGENTS.md、Makefile、docs/、zizmor.yml——供应链入口:模板防线被污染=全部新仓继承后门,红队修复 P0;src/ 业务面属 C3。ADR-0021 收敛范围:整仓 C1 会与 SC-3 依赖 automerge 冲突——依赖 PR 无 ADR 不可自动合并)"] requires: [PR, "ADR(新建或引用编号)", "drift-check 本地预检", owner-merge] rationale: "治理意图变更必须可追溯到一个决策记录;无 ADR 不合并。scope 与机器执法路径全集一致(.github gate:governance/standards/scripts/.github/CODEOWNERS/profile;agent-registry validate:standards/decisions/scripts/.github/CODEOWNERS/tests——评审项:声明与执行不得互斥);template-service 的脚手架面是新仓派生源头,视同治理意图变更(gate adr-required 机器检查 + owner-only review;ADR-0021 范围收敛后与 CI-Workflows ci.yml 的 C1 路径判定一致)" - id: C2 - scope: [agent-registry/registry/, 业务仓 AGENTS.md/CODEOWNERS] - requires: [PR, "validate.py 通过"] + scope: ["业务仓 AGENTS.md/CODEOWNERS"] # agent-registry/registry/ 随 ADR-0085 退役;C2 validate 门随声明层废止 + requires: [PR] - id: C3 scope: [文档/注释/README/模板] requires: [PR] diff --git a/governance/REPOS.yaml b/governance/REPOS.yaml index 94da19d..ebbef11 100644 --- a/governance/REPOS.yaml +++ b/governance/REPOS.yaml @@ -13,7 +13,7 @@ org: Cloudbird-Software # ============================================================================ layers: L0: 标准与治理(intent:GOVERNANCE / 标准 schema / 工作流实现) - L1: 注册条目(声明:agent / skill / tool / team / 模型别名 / ADR) + L1: 记忆与试卷(ADR 正本+索引 / 运行报告 / 验收场景封存)——ADR-0085:声明层退役 L2: 实现(代码:框架、工具、业务项目) L3: 数据(不进 git:事件流 / 轨迹 / 团队运行态) @@ -36,21 +36,16 @@ repos: - name: agent-registry layer: L1 visibility: public - status: active - role: 注册层——agent/skill/tool/team 声明 + 模型注册表(models.yaml) + ADR;AR-2 状态门禁校验器 - key_paths: [registry/, decisions/, standards/, scripts/validate.py, scripts/simulate-wave.py] - # ADR-0021:standards/(协作标准)与 simulate-wave.py(CI required - # 门禁,ADR-0015)此前不在组织地图——门禁自身逃逸治理地图 - policies: [agent_runtime] + status: retired # ADR-0085:多 agent 编排声明层退役,GitHub 归档只读;快照=archive/retired/;ADR 家园已迁 archive/adr/ + role: (退役)注册层——声明快照见 archive/retired/agent-registry/;git 历史即正本存档 - name: archive layer: L1 visibility: public status: active - role: 记忆层——ADR 归档正本(append-only,字节保真)+规划回归集+事件 JSONL+红队报告 - 落位(宪法 §1/§13 推论二,W1-C1 .github#164/ADR-0053);ADR 状态真源=agent-registry - decisions/INDEX.yaml 墓碑索引(ADR-0053) - key_paths: [adr/, scripts/verify_migration.py] + role: 记忆层——ADR 家园(正本+INDEX 索引同仓,ADR-0085 单仓化)+退役快照 retired/ + +PM 运行报告 runs/([followup] 周度 digest 消费)+规划回归集+事件 JSONL+红队报告落位 + key_paths: [adr/, runs/, retired/, scripts/verify_migration.py] - name: template-service layer: L2 @@ -63,18 +58,13 @@ repos: - name: agent-tools layer: L2 visibility: public - status: active - role: 自研工具实现仓——被 tool 声明的 implementation 字段引用(ADR-0021:approved 工具 - gitcode-pr 引用本仓,planned=悬空供应链引用;已按 flows.new_repo 从 template-service - 派生并跑 new-repo-init) + status: retired # ADR-0085:TS 工具服务器随编排框架退役,GitHub 归档只读 + role: (退役)工具实现仓——指针页 archive/retired/agent-tools.md - name: agent-platform layer: L2 visibility: public - status: active - role: 执行层仓——agent-registry 声明渲染到 openjiuwen/jiuwenswarm 运行时(ADR-0025): - 渲染器/SwarmFlow workflow 编译器/机制原型执行面/TUI 可观测+agentctl 干预/渲染漂移门禁; - Python 仓(languages.yaml PY-* 规则,声明适配现实——openjiuwen SDK 为 Python) - key_paths: [src/agentplatform/, tests/, docker/, Makefile] + status: retired # ADR-0085:渲染层随编排框架退役,GitHub 归档只读 + role: (退役)执行层仓——指针页 archive/retired/agent-platform.md - name: arbiter layer: L2 @@ -143,18 +133,15 @@ repos: 仅用于测试/验证路径写权(DECISION-02 隔离不变量,drift-check §18 断言) key_paths: [entries/, schema/, canary/] -# 上游依赖(不属于本组织;不 fork、不 submodule) -# 治理方式:声明于此 + 部署渲染时 clone 并 pin tag(ADR-0002 rev1) -external_upstreams: - - name: openjiuwen-runtime - repo: openJiuwen-ai/jiuwenswarm - role: 多智能体编排框架运行时(AgentServer/Gateway/Team/权限引擎/A2A) - consumed_by: [agent-registry 的声明渲染] - - name: openjiuwen-sdk - repo: openJiuwen-ai/agent-core - role: agent 内核 SDK(openjiuwen:DeepAgent/TeamAgent/SwarmFlow 引擎;PyPI 钉版消费,ADR-0025) - consumed_by: [agent-platform 渲染产物/ArchetypeWorkerBackend/治理 rails] - - name: openjiuwen-a2x - repo: openJiuwen-ai/agent-protocol - role: A2X 注册中心(分布式 Team 控制面) - consumed_by: [分布式 team 部署] + - name: cnb-bridge + layer: L2 + visibility: public + status: active + role: CNB 免费算力桥接仓(可删除层,EX-1/ADR-0085/IR-0004 D 组)——多账号池 + accounts.yaml(无明文 token)+派单协议 cnb_pool.py+work-inbox PM 自起协议+ + REMOVAL.md 单页删除清单;判定永不外置(INV-02) + key_paths: [accounts.yaml, cnb_pool.py, work-inbox.yaml, REMOVAL.md] + +# 上游依赖(ADR-0085:openjiuwen 双上游随编排框架退役停止消费;未来引入新上游时 +# 恢复本节声明——部署渲染 pin tag 的治理方式不变) +external_upstreams: [] diff --git a/governance/drift-check.sh b/governance/drift-check.sh index 352f6b1..3fc6e56 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -1,956 +1,956 @@ -#!/usr/bin/env bash -# drift-check.sh —— 治理飘移检测(只读) -# -# 对比 governance/expected-state.json + rulesets/*.json 与线上实际配置。 -# 输出 OK/DRIFT 逐项结果;发现任何漂移 exit 1。 -# -# 用法: GH_TOKEN= bash drift-check.sh -# CI 中由 .github/workflows/governance-drift.yml 调用,凭据来自 secret GOVERNANCE_TOKEN。 -set -uo pipefail - -ORG="${ORG:-Cloudbird-Software}" -DIR="$(cd "$(dirname "$0")" && pwd)" -EXPECTED="$DIR/expected-state.json" -DRIFTS=0 - -ok() { echo "OK $1"; } -drift(){ echo "DRIFT $1"; DRIFTS=$((DRIFTS+1)); } - -api() { curl -sS -H "Authorization: Bearer ${GH_TOKEN:?需要 org admin GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" "$@"; } - -[[ -n "${GH_TOKEN:-}" ]] || { echo "FATAL: GH_TOKEN 未设置"; exit 2; } - -# ---------- 1. Rulesets:存在性 / enforcement / 核心规则 ---------- -ACTUAL_RULESETS=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") -# fail-closed(ADR-0083 决策 4):清单非数组(token 失效/网络失败/权限丢失)时, -# §1 的「不存在」判定全部是伪读数——2026-08-23 GOVERNANCE_TOKEN 失效期间曾把 -# 四个真实存在的 ruleset 全报「不存在」+ jq 对字符串行报错刷屏。检测器失明 -# 不得伪装成漂移或无漂移:显式 exit 2(与 §4 仓库清单 loud-failure 契约一致)。 -if ! jq -e 'type == "array"' <<<"$ACTUAL_RULESETS" >/dev/null 2>&1; then - echo "FATAL: org ruleset 清单拉取失败($(jq -r '.message // "非数组"' <<<"$ACTUAL_RULESETS" 2>/dev/null || echo 传输失败)),drift 检测失明——中止" >&2 - exit 2 -fi -for f in "$DIR"/rulesets/*.json; do - name=$(jq -r .name "$f") - want_enf=$(jq -r .enforcement "$f") - want_rules=$(jq -c '[.rules[].type] | sort' "$f") - row=$(jq -c --arg n "$name" '.[] | select(.name == $n)' <<<"$ACTUAL_RULESETS") - if [[ -z "$row" || "$row" == "null" ]]; then - drift "ruleset '$name' 不存在(定义文件 $f)"; continue - fi - got_enf=$(jq -r .enforcement <<<"$row") - [[ "$got_enf" == "$want_enf" ]] || drift "ruleset '$name' enforcement=$got_enf 期望=$want_enf" - # 线上完整定义(含 id)对比规则集 - rid=$(jq -r .id <<<"$row") - detail=$(api "https://api.github.com/orgs/$ORG/rulesets/$rid") - got_rules=$(jq -c '[.rules[].type] | sort' <<<"$detail") - [[ "$got_rules" == "$want_rules" ]] || drift "ruleset '$name' 规则集变动: $got_rules 期望=$want_rules" - # 精确 diff:把线上定义裁剪成与落盘文件同构后比较(剔除 API 自动填充的空字段) - got_norm=$(jq -S 'del(.rules[].parameters.required_reviewers? | select(. == [])) | {name,target,enforcement,conditions,bypass_actors,rules}' <<<"$detail") - want_norm=$(jq -S '{name,target,enforcement,conditions,bypass_actors,rules}' "$f") - [[ "$got_norm" == "$want_norm" ]] \ - || drift "ruleset '$name' 定义与落盘不一致: $(diff <(echo "$want_norm") <(echo "$got_norm") | head -10)" - ok "ruleset '$name'" -done -EXTRA=$(jq -c --argjson known "$(jq -s '[.[].name]' "$DIR"/rulesets/*.json)" \ - '[.[].name] - $known' <<<"$ACTUAL_RULESETS") -[[ "$EXTRA" == "[]" ]] || drift "线上存在未落盘的 ruleset: $EXTRA(落盘或删除)" - -# ---------- 2. Actions 策略 ---------- -AP=$(api "https://api.github.com/orgs/$ORG/actions/permissions") -[[ "$(jq -r .allowed_actions <<<"$AP")" == "$(jq -r .actions_policy.allowed_actions "$EXPECTED")" ]] \ - || drift "allowed_actions=$(jq -r .allowed_actions <<<"$AP")" -# enabled_repositories 同检(ADR-0021):apply §2 写该字段而 check 不验=修复回路盲区 -[[ "$(jq -r .enabled_repositories <<<"$AP")" == "$(jq -r .actions_policy.enabled_repositories "$EXPECTED")" ]] \ - || drift "enabled_repositories=$(jq -r .enabled_repositories <<<"$AP") ≠ expected $(jq -r .actions_policy.enabled_repositories "$EXPECTED")" - -SA=$(api "https://api.github.com/orgs/$ORG/actions/permissions/selected-actions") -[[ "$(jq -c '{github_owned_allowed, verified_allowed, patterns_allowed}' <<<"$SA")" == \ - "$(jq -c '.actions_policy | {github_owned_allowed, verified_allowed, patterns_allowed}' "$EXPECTED")" ]] \ - || drift "allowed actions 白名单不一致: $(jq -c .patterns_allowed <<<"$SA")" - -WF=$(api "https://api.github.com/orgs/$ORG/actions/permissions/workflow") -[[ "$(jq -r .default_workflow_permissions <<<"$WF")" == "$(jq -r .actions_policy.default_workflow_permissions "$EXPECTED")" ]] \ - || drift "default_workflow_permissions=$(jq -r .default_workflow_permissions <<<"$WF")" -ok "actions 策略(含白名单)" - -# ---------- 3. Code Security 默认配置 ---------- -CS=$(api "https://api.github.com/orgs/$ORG/code-security/configurations") -want_cs=$(jq -r .code_security.configuration_name "$EXPECTED") -csrow=$(jq -c --arg n "$want_cs" '.[] | select(.name == $n)' <<<"$CS") -if [[ -z "$csrow" || "$csrow" == "null" ]]; then - drift "code security 配置 '$want_cs' 不存在" -else - # 注意:default_for_new_repos 是只写 API(PUT .../defaults),GitHub 不提供读取端点, - # 无法只读验证。由 apply.sh 的幂等 PUT 保证,drift-check 只验证配置本体存在。 - ok "code security '$want_cs'(default_for_new_repos 由 apply 保证,API 无读取端点)" -fi - -# ---------- 4. 仓库基线(squash-only / 删分支 / auto-merge,ADR-0029)---------- -EXCLUDES=$(jq -c '.repo_baseline.exclude_repos // []' "$EXPECTED") -# auto-merge 期望值(ADR-0029):repo setting 不受 ruleset 管,静默漂移 = 自动合并 -# 全链路静默失效。字段读不到(null——如凭据缺 administration 读权限的 fine-grained -# PAT)与 false 同判漂移——fail-closed:检测器失明不得伪装成无漂移 -WANT_AUTO_MERGE=$(jq -r '.repo_baseline.allow_auto_merge // true' "$EXPECTED") -# 全分页拉取 org 仓库(评审项:单页 100 时 >100 仓的 org 会漏检后续仓库); -# fail-closed:清单拉取失败/非数组/为空时 REPOS 为空会让后续全部循环静默跳过、 -# 检测整体假绿——此处显式中止,检测器失明不得伪装成无漂移 -REPOS_TMP=$(mktemp) -PAGE=1 -while :; do - CHUNK=$(api "https://api.github.com/orgs/$ORG/repos?per_page=100&page=$PAGE") - if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then - echo "FATAL: org 仓库清单拉取失败($(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)),无法检测" >&2 - exit 2 - fi - N=$(jq 'length' <<<"$CHUNK") - [[ "$N" -eq 0 ]] && break - jq -r '.[].name' <<<"$CHUNK" >>"$REPOS_TMP" - [[ "$N" -lt 100 ]] && break - PAGE=$((PAGE+1)) -done -REPOS=$(cat "$REPOS_TMP") -rm -f "$REPOS_TMP" -if [[ -z "$REPOS" ]]; then - echo "FATAL: org 仓库清单为空(org 至少应含本治理仓)——清单异常,无法检测" >&2 - exit 2 -fi -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - RR=$(api "https://api.github.com/repos/$ORG/$r") - bad="" - [[ "$(jq -r .allow_squash_merge <<<"$RR")" == "true" ]] || bad="$bad squash-off" - [[ "$(jq -r .allow_merge_commit <<<"$RR")" == "false" ]] || bad="$bad merge-commit-on" - [[ "$(jq -r .allow_rebase_merge <<<"$RR")" == "false" ]] || bad="$bad rebase-on" - [[ "$(jq -r .delete_branch_on_merge <<<"$RR")" == "true" ]] || bad="$bad keep-branch" - [[ "$(jq -r .allow_auto_merge <<<"$RR")" == "$WANT_AUTO_MERGE" ]] || bad="$bad auto-merge-off(got=$(jq -r .allow_auto_merge <<<"$RR"))" - [[ -z "$bad" ]] && ok "repo baseline '$r'" || drift "repo '$r' 基线异常:$bad" -done - -# ---------- 5. 必需 org secrets(只查存在性,值不可读)---------- -SECRETS=$(api "https://api.github.com/orgs/$ORG/actions/secrets") -for s in $(jq -r '.org_secrets_required[]' "$EXPECTED"); do - jq -e --arg s "$s" 'any(.secrets[]; .name == $s)' <<<"$SECRETS" >/dev/null \ - && ok "org secret '$s'" || drift "org secret '$s' 缺失" -done - -# ---------- 6. GitHub App 权限形状 ---------- -APP=$(api "https://api.github.com/apps/$(jq -r .github_app.name "$EXPECTED")") -if [[ "$(jq -r .id <<<"$APP" 2>/dev/null)" == "$(jq -r .github_app.id "$EXPECTED")" ]]; then - for p in $(jq -r '.github_app.permissions | to_entries[] | "\(.key)=\(.value)"' "$EXPECTED"); do - k=${p%%=*}; v=${p##*=} - [[ "$(jq -r .permissions.$k <<<"$APP")" == "$v" ]] || drift "App 权限 $k 变动" - done - for p in $(jq -r '.github_app.must_not_have[]' "$EXPECTED"); do - jq -e --arg p "$p" '.permissions | has($p)' <<<"$APP" >/dev/null \ - && drift "App 出现禁用权限 '$p'(应立即在 App 设置页移除)" - done - ok "github app '$(jq -r .github_app.name "$EXPECTED")'" -else - drift "github app '$(jq -r .github_app.name "$EXPECTED")' 不存在或 id 不符" -fi - -# ---------- 7. 组织地图(REPOS.yaml):存在性 / visibility / 未申报仓 ---------- -if python3 -c 'import yaml' 2>/dev/null; then - REPO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' "$DIR/REPOS.yaml") - # 7a. active 仓:必须存在且 visibility 一致 - while IFS=$'\t' read -r r want_vis; do - [[ -n "$r" ]] || continue - RR=$(api "https://api.github.com/repos/$ORG/$r") - if [[ "$(jq -r 'if .message then .message else "" end' <<<"$RR")" == "Not Found" ]]; then - drift "REPOS.yaml 申报的 active 仓 '$r' 不存在"; continue - fi - got_vis=$(jq -r 'if .private then "private" else "public" end' <<<"$RR") - [[ "$got_vis" == "$want_vis" ]] || drift "repo '$r' visibility=$got_vis 期望=$want_vis" - [[ "$want_vis" == "public" ]] || drift "repo '$r' 申报 visibility=$want_vis 违反全仓公开政策(ADR-0020)" - ok "REPOS map '$r'" - done < <(jq -r '.repos[] | select(.status=="active") | "\(.name)\t\(.visibility)"' <<<"$REPO_MAP") - # 7b/7c. 线上全量仓合并遍历(每仓一次 API): - # 7b. 必须在图中申报(任何 status 均可,未申报即漂移) - # 7c. 全仓公开政策(ADR-0020):private 即漂移——不依赖申报完整性(未申报仓 - # 同样受检);exempt 状态只豁免治理基线(repo_baseline),不豁免可见性 - for r in $REPOS; do - jq -e --arg r "$r" '[.repos[].name] | index($r) != null' <<<"$REPO_MAP" >/dev/null \ - || drift "线上仓 '$r' 未在 governance/REPOS.yaml 申报(补申报,或标 exempt 注明原因)" - VR=$(api "https://api.github.com/repos/$ORG/$r") - [[ "$(jq -r 'if .private then "private" else "public" end' <<<"$VR")" == "public" ]] \ - || drift "repo '$r' 为 private,违反全仓公开政策(ADR-0020 改回 public,或新 ADR 推翻政策)" - done -else - echo "SKIP REPOS.yaml 校验(环境缺 python3+pyyaml;GitHub runner 自带)" -fi - -# ---------- 8. 直推检测(GM-2 破玻璃监控)---------- -# flows.governance_change.policy_effective 之后,受治仓默认分支上的非 PR commit = 漂移。 -# 唯一权威判据 = List pull requests associated with commit API(红队 #17-I + PR 评审项): -# 不做消息后缀预筛——"(#N)" 后缀可伪造,直推挂个假后缀即可绕过预筛被报 clean; -# 窗口内每个 commit 都经关联 PR API 复核(真有关联 PR=合法形态放行,无=直推)。 -# fail-closed(评审项):关联 PR 查询本身失败(限流/权限/传输)= 无法验证 = 判漂移报错, -# 绝不静默放行——检测器失明比误报危险。 -# 分页(评审项):commit 列表全分页拉取,第 100 条之后的直推不可漏检; -# 超 MAX_COMMITS 上限时显式报漂移(fail loudly),不静默截断。 -# 回填时限:commit 距今 >24h 且仍在直推状态 = 已超破玻璃回填时限(P0 级标记)。 -SINCE=$(python3 - <<'EOF' -from datetime import datetime, timezone, timedelta -eff = datetime(2026, 8, 19, tzinfo=timezone.utc) # policy_effective -lo = datetime.now(timezone.utc) - timedelta(days=7) # 检测窗口 -print(max(eff, lo).strftime("%Y-%m-%dT%H:%M:%SZ")) -EOF -) -NOW_EPOCH=$(date +%s) -MAX_COMMITS=300 # 窗口内单仓 commit 上限:治理仓日常量级为个位数;超限=异常,人工核查 -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - # 全分页拉取窗口内 commit(评审项:首页之后不可漏检) - COMMITS_TMP=$(mktemp) - TOTAL=0 - PAGE=1 - FETCH_ERR=0 - while :; do - CHUNK=$(api "https://api.github.com/repos/$ORG/$r/commits?sha=main&since=$SINCE&per_page=100&page=$PAGE") - if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then - drift "repo '$r' commit 列表拉取失败,直推检测无法执行(fail-closed): $(jq -r '.message // "非 JSON 响应"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)" - FETCH_ERR=1 - break - fi - N=$(jq 'length' <<<"$CHUNK") - [[ "$N" -eq 0 ]] && break - jq -c '.[] | {sha: .sha, date: (.commit.committer.date | sub("\\.[0-9]+Z$"; "Z"))}' <<<"$CHUNK" >>"$COMMITS_TMP" - TOTAL=$((TOTAL+N)) - [[ "$N" -lt 100 ]] && break - PAGE=$((PAGE+1)) - done - if [[ $FETCH_ERR -eq 1 ]]; then rm -f "$COMMITS_TMP"; continue; fi - if [[ $TOTAL -gt $MAX_COMMITS ]]; then - drift "repo '$r' 窗口内 commit 数=$TOTAL 超上限 $MAX_COMMITS——为防静默截断漏检,须人工核查直推或调窗口" - rm -f "$COMMITS_TMP" - continue - fi - # §8 豁免清单(ADR-0016 附录机制:已经 ADR 追认回填的破玻璃直推—— - # expected-state.json direct_push_exemptions 逐 SHA 登记,事件定性见 ADR-0017; - # 豁免须有 ADR 背书,不得口头/临时豁免) - EXEMPT=$(jq -r --arg r "$r" '.direct_push_exemptions[$r] // [] | join(" ")' "$EXPECTED") - DIRECT_FOUND=0 - while IFS= read -r row; do - [[ -n "$row" ]] || continue - sha=$(jq -r .sha <<<"$row") - cdate=$(jq -r .date <<<"$row") - if grep -qF "$sha" <<<"$EXEMPT"; then - ok "repo '$r' commit ${sha:0:8} 已回填破玻璃直推(§8 豁免清单,ADR-0017)" - continue - fi - # 关联 PR 复核(全 SHA;响应须为数组——error 对象/传输失败均为无法验证) - PRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/pulls?per_page=5" \ - | jq -r 'if type == "array" then (if length > 0 then "has-pr" else "none" end) else "error" end' 2>/dev/null || echo error) - if [[ "$PRS" == "error" ]]; then - drift "repo '$r' commit $sha 的关联 PR 查询失败,无法验证直推(fail-closed:限流/权限/传输故障不得静默放行)" - DIRECT_FOUND=1 - continue - fi - if [[ "$PRS" == "none" ]]; then - AGE=$(( NOW_EPOCH - $(date -u -d "$cdate" +%s) )) - if [[ $AGE -gt 86400 ]]; then - drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(已超 24h 回填时限=${AGE}s——P0:立即回填 ADR+PR,见 flows.governance_change)" - else - drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(破玻璃须 24h 内回填 ADR+PR,见 flows.governance_change)" - fi - DIRECT_FOUND=1 - fi - done <"$COMMITS_TMP" - rm -f "$COMMITS_TMP" - [[ $DIRECT_FOUND -eq 0 ]] && ok "no-direct-push '$r' (since $SINCE, $TOTAL commits)" -done - -# ---------- 9. vcs_admin 唯一性(ADR-0010:admin 全系统唯 owner)---------- -# org 全部受治仓的 admin 数量必须 == 1 且为 owner;任何多出的 admin = P0 漂移 -OWNER_LOGIN="${OWNER_LOGIN:-randypanding}" -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - # 单次拉取全分页(>100 协作者时防漏——CodeRabbit #13);ADMINS/COUNT 同源派生 - ADMIN_TMP=$(mktemp) - PAGE=1 - while :; do - CHUNK=$(api "https://api.github.com/repos/$ORG/$r/collaborators?permission=admin&per_page=100&page=$PAGE") - N=$(jq 'length' <<<"$CHUNK") - [[ "$N" -eq 0 ]] && break - jq -c '.' <<<"$CHUNK" >>"$ADMIN_TMP" - [[ "$N" -lt 100 ]] && break - PAGE=$((PAGE+1)) - done - ADMIN_DATA=$(jq -s 'add // []' "$ADMIN_TMP" 2>/dev/null || echo '[]') - rm -f "$ADMIN_TMP" - ADMINS=$(jq -r '[.[] | select(.permissions.admin == true) | .login] | unique | join(",")' <<<"$ADMIN_DATA") - COUNT=$(jq '[.[] | select(.permissions.admin == true) | .login] | unique | length' <<<"$ADMIN_DATA") - if [[ "$COUNT" != "1" ]]; then - drift "repo '$r' admin 数量=$COUNT ($ADMINS),必须唯一且为 $OWNER_LOGIN(ADR-0010 owner 伪原型不变量)" - elif [[ "$ADMINS" != "$OWNER_LOGIN" ]]; then - drift "repo '$r' admin=$ADMINS 非 owner $OWNER_LOGIN(ADR-0010)" - else - ok "vcs-admin-unique '$r'" - fi -done - -# ---------- 10. ADR 引用存在性+实体性后验(adr-required 的补充防线,评审项 + RB-D5)---------- -# gate.yml 的 adr-required 已在 PR 时点做存在性校验(ADR-0021:全仓公开后 PR 上下文 -# GITHUB_TOKEN 可读公开仓——早期"agent-registry 是私有仓、PR 时点无跨仓读权"的前提 -# 已随 ADR-0020 全仓公开政策失效,注记修正);ADR-0053(W1-C1 .github#164)索引世界 -# 起进一步校验 archive 正本可达性。本节后验实体性(防空壳)并独立复核存在性: -# 窗口内合并 PR 的 ADR-NNNN 引用必须有实质决策结构——伪造/幽灵 ADR 最长 24h 内 -# 检出(与 §8 直推检测同为 post-hoc 防线;C1 的权威人类门禁仍是 owner-only review)。 -# 内容源双世界(ADR-0053):agent-registry/decisions/INDEX.yaml(墓碑索引)存在 → -# decisions/ 只剩同名墓碑(无实质结构),实体性改验 INDEX 指向的 archive 仓正本 -# (字节保真原件);INDEX 404(迁移前/回滚)→ 旧逻辑对 decisions/ 本体验证。 -ADR_RE='ADR-[0-9]{4}' -# 实体性判定(评审项:size 字节数可被空白/注释/填充绕过——100B 阈值挡不住 -# RB-D5 意义上的空壳):拉取被引 ADR 全文做结构校验,H1 编号行、status 行、 -# 背景/决策章节齐备且决策节有正文才判"有实质决策记录";同名多文件(ADR-0011 -# 先例)任一满足即通过;读取失败 fail-closed。按编号缓存(同批多 PR 引用同一 -# ADR 只拉一次)。 -declare -A ADR_VERDICT_CACHE -adr_substantive() { # $1=四位编号 → stdout: missing|ok|shell|unreadable - local num="$1" matches apath content decoded verdict - [[ -n "${ADR_VERDICT_CACHE[$num]:-}" ]] && { echo "${ADR_VERDICT_CACHE[$num]}"; return; } - if [[ -n "${ADR_INDEX_MODE:-}" ]]; then - # 索引世界(ADR-0053):编号→archive 正本路径(可多条:ADR-0011 双档先例) - matches=$(awk -v n="$num" '$1 == n {print $2}' <<<"$ADR_INDEX_MAP") - else - matches=$(jq -r --arg p "ADR-${num}-" '.[] | select((.name | startswith($p)) and (.type == "file")) | .path' <<<"$ADR_DIR_LISTING") - fi - if [[ -z "$matches" ]]; then - verdict="missing" - else - verdict="unreadable" - while IFS= read -r apath; do - [[ -n "$apath" ]] || continue - if [[ -n "${ADR_INDEX_MODE:-}" ]]; then - # 索引世界:archive raw 正本(公开仓,字节保真原件)。raw 在 runner 上有 - # 瞬时拒连抖动(2026-08-24 实测整批 unreadable)——重试 3 次后回退 - # contents API(api.github.com 通道稳定),双通道皆失败才按不可判定处理。 - decoded=$(curl -sSf --retry 3 --retry-delay 2 --max-time 20 "https://raw.githubusercontent.com/$ORG/archive/main/${apath}" 2>/dev/null || true) - if [[ -z "$decoded" ]]; then - _c=$(api "https://api.github.com/repos/$ORG/archive/contents/${apath}" 2>/dev/null | jq -r '.content // empty' 2>/dev/null || true) - [[ -n "$_c" ]] && decoded=$(base64 -d <<<"$_c" 2>/dev/null || true) - fi - else - content=$(api "https://api.github.com/repos/$ORG/agent-registry/contents/$apath" | jq -r '.content // empty') - [[ -z "$content" ]] && continue - decoded=$(base64 -d <<<"$content" 2>/dev/null || true) - fi - [[ -z "$decoded" ]] && continue - if grep -qE "^#[[:space:]]*ADR-${num}([^0-9]|$)" <<<"$decoded" \ - && grep -qE "^-[[:space:]]*(status|状态):[[:space:]]*[^[:space:]]" <<<"$decoded" \ - && grep -qE "^##[[:space:]]*背景" <<<"$decoded" \ - && grep -qE "^##[[:space:]]*决策" <<<"$decoded" \ - && [[ -n "$(sed -n '/^##[[:space:]]*决策/,$p' <<<"$decoded" | tail -n +2 | sed 's/[[:space:]#*-]//g')" ]]; then - verdict="ok"; break - fi - [[ "$verdict" == "unreadable" ]] && verdict="shell" - done <<<"$matches" - fi - ADR_VERDICT_CACHE[$num]="$verdict" - echo "$verdict" -} -# 独立 7 天窗口(不用 §8 的 SINCE——那是 policy_effective 起算的直推检测窗口, -# 而 ADR 引用后验须覆盖 policy 生效前已合并、引用了伪造 ADR 的 PR) -ADR_SINCE=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) -ADR_DIR_LISTING=$(api "https://api.github.com/repos/$ORG/agent-registry/contents/decisions") -if ! jq -e 'type == "array"' <<<"$ADR_DIR_LISTING" >/dev/null 2>&1; then - drift "ADR 真源 agent-registry/decisions 读取失败,引用存在性无法后验(fail-closed): $(jq -r '.message // "非数组"' <<<"$ADR_DIR_LISTING" 2>/dev/null || echo 传输失败)" -else - # 墓碑索引探测(ADR-0053):200 → 索引世界(map:编号→archive 正本路径); - # 404 → 旧世界;其他失败 → 报漂移(fail-closed:检测器失明不得伪装无漂移), - # 并降级旧世界继续跑完本节(漂移行已置红,后续节不受影响)。 - ADR_INDEX_MODE="" - ADR_INDEX_MAP="" - ADR_INDEX_JSON=$(api "https://api.github.com/repos/$ORG/agent-registry/contents/decisions/INDEX.yaml") - if jq -e '.content' <<<"$ADR_INDEX_JSON" >/dev/null 2>&1; then - ADR_INDEX_MAP=$(base64 -d <<<"$(jq -r '.content' <<<"$ADR_INDEX_JSON")" 2>/dev/null | awk ' - /^[[:space:]]*-[[:space:]]*number:/ { n=$3; gsub(/[^0-9]/, "", n); cur=sprintf("%04d", n+0); ap="" } - /^[[:space:]]*archive_path:/ { - line=$0; sub(/^[[:space:]]*archive_path:[[:space:]]*/, "", line); gsub(/"/, "", line) - if (cur != "" && line != "") { print cur " " line; cur="" } - }') - if [[ -n "$ADR_INDEX_MAP" ]] && grep -qE '^[0-9]{4} adr/.+\.md$' <<<"$ADR_INDEX_MAP"; then - ADR_INDEX_MODE=1 - ok "adr-index(ADR-0053 索引世界:$(wc -l <<<"$ADR_INDEX_MAP") entries,实体性改验 archive 正本)" - else - drift "decisions/INDEX.yaml 存在但解析为空/畸形——索引世界不可判定(fail-closed,按旧世界降级继续)" - fi - elif ! grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"' <<<"$ADR_INDEX_JSON"; then - drift "decisions/INDEX.yaml 探测失败(非 404 的 API 错误)——索引世界判定失败(fail-closed,按旧世界降级继续)" - fi - ADR_FILES=$(jq -r '.[].name' <<<"$ADR_DIR_LISTING") - GHOST=0 - for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - PRSLIST=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=closed&sort=updated&direction=desc&per_page=30") - if ! jq -e 'type == "array"' <<<"$PRSLIST" >/dev/null 2>&1; then - drift "repo '$r' PR 列表拉取失败,ADR 引用后验跳过(fail-closed)" - GHOST=1 - continue - fi - while IFS=$'\t' read -r pnum title body; do - [[ -n "$pnum" ]] || continue - for ref in $(printf '%s\n%s\n' "$title" "$body" | grep -oE "$ADR_RE" | sort -u); do - num="${ref#ADR-}" - case "$(adr_substantive "$num")" in - ok) : ;; - missing) - if [[ -n "$ADR_INDEX_MODE" ]]; then - where="INDEX entries 无此编号" - else - where="agent-registry/decisions/ 无 ADR-${num}-*.md" - fi - drift "repo '$r' PR#$pnum 引用幽灵 ADR ${ref}(${where}——C1 变更的决策背书不成立)" - GHOST=1 ;; - shell) - drift "repo '$r' PR#$pnum 引用空壳 ADR ${ref}(正本存在但缺实质结构:H1 编号行/status/背景/决策章节须齐备且决策节有正文——红队 RB-D5:字节数填充不再能绕过)" - GHOST=1 ;; - unreadable) - drift "repo '$r' PR#$pnum 引用的 ADR ${ref} 全部同名文件内容读取失败,实体性无法判定(fail-closed)" - GHOST=1 ;; - esac - done - done < <(jq -r --arg since "$ADR_SINCE" \ - '.[] | select(.merged_at != null and .merged_at >= $since) | [(.number|tostring), (.title // ""), (.body // "")] | @tsv' <<<"$PRSLIST") - done - [[ $GHOST -eq 0 ]] && ok "adr-reference-substantive(窗口内合并 PR 的 ADR 引用全部真实且有实质决策结构)" -fi - -# ---------- 11. CI-Workflows 大版本指针完整性(供应链,红队 #6-A / ADR-0016 决策 3)---------- -# 全部业务仓 gate 引用 CI-Workflows@v1 浮动指针;release-tags ruleset 的 admin bypass -# 使指针可被强移(改变所有业务仓实际执行的 CI 内容)。声明不变式(CI-Workflows README -# 版本策略):vN 恒指向最高 vN.x.y 的 commit。本节每日校验——admin 强移指针 24h 内检出, -# 不可见通道变可检测。附注 tag 指向 tag 对象(非 commit),须解引用后比对。 -# 凭据说明(评审项):本节与 §1-§10 共用 GOVERNANCE_TOKEN——AGENTS.md 声明的 -# drift-check 接口即 "GH_TOKEN= bash governance/drift-check.sh"(组织级 -# 检测 rulesets/admin/PR 清单本就需要 org 权限,token 已在 job 内);本节只读公开仓 -# CI-Workflows 的 refs,复用不扩大暴露面。工作流仅 schedule/dispatch 于可信 main -# 运行,脚本不受 PR 控制(与 gate.yml 不向 PR 上下文注入 org secret 同一威胁模型)。 -# 分页聚合(评审项:单页请求在 tag 数超页容量后会拿旧集合比较出假绿): -# 逐页拉取并验证每页均为数组,任一页失败即 fail-closed,不用部分结果继续比较。 -CW_REFS="[]" -CW_PAGE_FAIL=0 -CW_PAGE=1 -while :; do - CW_PAGE=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/matching-refs/tags/?per_page=100&page=$CW_PAGE") - if ! jq -e 'type == "array"' <<<"$CW_PAGE" >/dev/null 2>&1; then - CW_PAGE_FAIL=1 - break - fi - CW_REFS=$(jq --argjson acc "$CW_REFS" '. as $p | $acc + $p' <<<"$CW_PAGE") - [[ "$(jq 'length' <<<"$CW_PAGE")" -lt 100 ]] && break - CW_PAGE=$((CW_PAGE+1)) - [[ $CW_PAGE -gt 50 ]] && { CW_PAGE_FAIL=1; break; } # >5000 tag 视为异常,fail-closed -done -if [[ $CW_PAGE_FAIL -ne 0 ]]; then - drift "CI-Workflows tag refs 分页拉取失败(第 $CW_PAGE 页非数组/超界),大版本指针完整性无法校验(fail-closed,不用部分结果比较)" -else - # 解引用到 commit SHA(轻量 tag 直指 commit 零额外请求;附注 tag 走 tag 对象一跳) - cw_commit_sha() { # $1=tag 名( refs/tags/ 去前缀) - local row obj type sha - row=$(jq -c --arg r "refs/tags/$1" '.[] | select(.ref == $r)' <<<"$CW_REFS") - [[ -z "$row" || "$row" == "null" ]] && { echo ""; return; } - obj=$(jq -c '.object' <<<"$row") - type=$(jq -r '.type' <<<"$obj"); sha=$(jq -r '.sha' <<<"$obj") - if [[ "$type" == "tag" ]]; then - api "https://api.github.com/repos/$ORG/CI-Workflows/git/tags/$sha" | jq -r '.object.sha // empty' - else - echo "$sha" - fi - } - CW_NAMES=$(jq -r '.[].ref | sub("^refs/tags/"; "")' <<<"$CW_REFS") - while IFS= read -r ptr; do - [[ -n "$ptr" ]] || continue - N="${ptr#v}" - HIGHEST=$(grep -E "^v${N}\.[0-9]+\.[0-9]+$" <<<"$CW_NAMES" | sort -V | tail -1) - PTR_SHA=$(cw_commit_sha "$ptr") - if [[ -z "$HIGHEST" ]]; then - drift "CI-Workflows 指针 tag '$ptr' 存在但无任何 v${N}.x.y 具体版本 tag——指针失去锚点(发布流程漏步,README 版本策略)" - elif [[ -z "$PTR_SHA" ]]; then - drift "CI-Workflows 指针 '$ptr' 解引用失败(fail-closed)" - else - HIGH_SHA=$(cw_commit_sha "$HIGHEST") - if [[ "$PTR_SHA" == "$HIGH_SHA" ]]; then - ok "CI-Workflows 指针 '$ptr' == $HIGHEST(${PTR_SHA:0:7})" - else - drift "CI-Workflows 指针 '$ptr'(${PTR_SHA:0:7})≠ 最高版本 $HIGHEST(${HIGH_SHA:0:7})——指针被强移或发布流程漏步(不变式 vN==最高 vN.x.y,红队 #6-A)" - fi - fi - done < <(grep -E '^v[0-9]+$' <<<"$CW_NAMES" | sort -V) - # 必需指针存在性(评审项:删除 v1 ≠ "无指针即不适用"——v1 是全部业务仓 gate 的 - # 供应链入口(REPOS.yaml CI-Workflows role:"业务仓引用 @v1"),指针被删= - # admin bypass 绕过发布流程,必须报漂移。v2+ 指针出现后由上方循环自动纳入 - # 锚点/一致性校验;必需集合当前只声明 v1——v2 落地时随 ADR 扩充此常量。) - CW_REQUIRED_POINTERS="v1" - REQ_MISSING=0 - for req in $CW_REQUIRED_POINTERS; do - if ! grep -qx "$req" <<<"$CW_NAMES"; then - drift "CI-Workflows 必需大版本指针 '$req' 缺失(业务仓 @v1 供应链入口被删——红队 #6-A:release-tags ruleset admin bypass 通道)" - REQ_MISSING=1 - fi - done - [[ $REQ_MISSING -eq 0 ]] && ok "CI-Workflows 必需大版本指针存在($CW_REQUIRED_POINTERS)" -fi - -# ---------- 12. required check 活体存在性(P1-4,ADR-0034;in-flight 防误报修订)---------- -# 文本对账 ≠ 生效验证:ruleset JSON 完全正确的同时,required check 字符串精确 -# 匹配可能实际为空(job 改名 / workflow 重构)→ "零 required check" → PR 裸奔。 -# 判据:每个受管仓最近更新的至多 3 个已合并/打开 PR 的 head 上,必须存在每个 -# required check 名(从 rulesets/*.json 派生——单一真源)的 check run 且 -# conclusion 非空;无 PR 活动时退化为默认分支 HEAD。修订(2026-08-20 实测误报 -# mutual:三连新开 PR 的 CI 起飞窗口内 gate 尚未报 conclusion,被误判缺失): -# - CI 未完结(任一 check run 非 completed)的 head 不构成任何证据——既不算 -# "缺 gate"也不算"有 gate"(防起飞窗口误报);全部候选 in-flight 时显式 -# SKIP(非漂移,下轮复核) -# - 关闭未合并的 PR 不采样(废弃实验分支的残缺 CI 不构成证据) -# - 已完结 CI 的 PR head 缺 gate 仍照报(PR 群体缺 required check = CI 结构性 -# 异常或改名注入——T1 的检测对象;main HEAD 仅在无 PR 活动时作退化载体, -# 不作为豁免来源)。查询失败维持 fail-closed。 -# IR-0002(spec PR#152)(a)/(b) 形态区分(2026-08-21):org-gate 是 PR/merge_group -# 触发的 required workflow(org-gate.yml 的 on:),其活体证据只能来自 ruleset -# 生效后的 PR head——push-only / 生效前 PR 的 head 缺失属 (a)「从未接入」 -# (OK/INFO 计数,非漂移);仅 (b) 生效后 PR head 缺失才报裸奔。 -# 生效时刻 = org-required-workflows ruleset 创建时刻(API 实测)。 -ORG_GATE_EFFECTIVE="2026-08-20T07:43:21Z" - -# @ir0002-s12-classify-begin —— IR-0002(spec PR#152)(a)/(b) 缺失形态分类器。 -# governance/tests/test-ir0002.sh 按本标记对提取函数体做 fixture 自测((b) 检出 -# 灵敏度是 INV-4 铁律:改动本函数必须连带跑自测,红即不得合并)。 -# 入参:$1=repo $2=check 名 $3=QUERY_FAIL(0/1) $4=HAS_PR_ACTIVITY(0/1) -# $5=PRS_RECENT(PR 数组 JSON)$6=org-gate 生效 ISO 时戳 $7=N_CONCL(采样数) -# 返回:0=(a) 待接入(OK 行,非漂移) 1=(b) 裸奔(drift 报警) 2=fail-closed(drift 报警) -s12_classify() { - local r="$1" ctx="$2" QUERY_FAIL="$3" HAS_PR_ACTIVITY="$4" PRS_RECENT="$5" eff="$6" n_concl="$7" - if [[ $QUERY_FAIL -eq 1 ]]; then - drift "repo '$r' check-runs 查询失败,required check '$ctx' 活体无法验证(fail-closed)" - return 2 - elif [[ "$ctx" == "org-gate" ]] && ! jq -e --arg eff "$eff" \ - '[.[] | select((.state == "open" or .merged_at != null) and (.updated_at >= $eff))] | length > 0' \ - <<<"$PRS_RECENT" >/dev/null 2>&1; then - # IR-0002 (a) 形态:org-gate ruleset 生效后该仓无 PR 活动——从未接入, - # 非裸奔(待接入清单;本地 gate 的活体验证不受影响,照常执行) - echo "OK required-check-live '$r':'$ctx' 待接入(ruleset 生效后无 PR 活动——IR-0002 (a) 形态)" - return 0 - elif [[ "${HAS_PR_ACTIVITY:-1}" -eq 0 ]]; then - # IR-0002 (a) 泛化:无 PR 仓的 degenerate 采样下任意 required check 缺失= - # 从未接线(如新建 L1 记忆层仓无本地 gate workflow)——PR 面仍受 org - # ruleset 的 org-gate 保护;接线后自然转绿。有 PR 的仓不适用(保 (b) 检出) - echo "OK required-check-live '$r':'$ctx' 待接入(无 PR 活动,degenerate 采样——IR-0002 (a) 形态)" - return 0 - else - drift "repo '$r' required check '$ctx' 活体缺失:最近 $n_concl 个已完结 CI 的 head 均无该 check run——job 改名或 workflow 重构?裸奔窗口已开启(ADR-0034 §12;IR-0002 (b) 形态)" - return 1 - fi -} -# @ir0002-s12-classify-end - -REQ_CHECKS=$(jq -rs '[.[].rules[]? | select(.type == "required_status_checks") - | .parameters.required_status_checks[].context] | unique | .[]' "$DIR"/rulesets/*.json) -[[ -n "$REQ_CHECKS" ]] || { echo "FATAL: rulesets 未声明任何 required check——§12 活体验证失去判据"; exit 2; } -epoch_of() { date -u -d "$1" +%s; } # ISO8601 → epoch(runner GNU date) -S12_PENDING=0 # §12 全仓 (a) 待接入累计(IR-0002 spec AC-3 汇总信息行) -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - HAS_PR_ACTIVITY=1 # 有(已合并/打开的)PR head 可采样=强证据形态 - PRS_RECENT=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=all&sort=updated&direction=desc&per_page=20") - if jq -e 'type == "array"' <<<"$PRS_RECENT" >/dev/null 2>&1; then - HEADS=$(jq -r '[.[] | select(.state == "open" or .merged_at != null) | .head.sha][0:3] | .[]' <<<"$PRS_RECENT") - if [[ -z "$HEADS" ]]; then - # 无(已合并/打开的)PR 活动 → 退化为默认分支 HEAD(§12 采样处于最弱证据 - # 形态:degenerate 模式标记——缺失判定走 IR-0002 (a) 待接入分支) - HAS_PR_ACTIVITY=0 - DBR=$(api "https://api.github.com/repos/$ORG/$r" | jq -r '.default_branch // "main"') - HEADS=$(api "https://api.github.com/repos/$ORG/$r/git/ref/heads/$DBR" | jq -r '.object.sha // empty') - [[ -n "$HEADS" ]] || { drift "repo '$r' 无 PR 活动且默认分支 HEAD 不可读,活体验证无载体(fail-closed)"; continue; } - fi - else - drift "repo '$r' PR 清单拉取失败,required check 活体验证无法执行(fail-closed)" - continue - fi - # 采样修订(#85 T1 首轮实测漏检教训:并发连开 PR 会把改名注入头挤出「前3」, - # 而 in-flight 头白白占用采样位):先跳过零-check/in-flight,再收集最近 - # COLLECT_N=3 个「已完结」head(最多扫描 10 个候选)作为活体样本。 - COLLECT_N=3 - CONCL_SHAS="" - N_CONCL=0 - N_SEEN=0 - QUERY_FAIL=0 - SAW_INFLIGHT=0 - while IFS= read -r sha; do - [[ -n "$sha" ]] || continue - { [[ $N_CONCL -ge $COLLECT_N ]] || [[ $N_SEEN -ge 10 ]]; } && break - N_SEEN=$((N_SEEN+1)) - CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") - if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then - QUERY_FAIL=1; continue - fi - TOTAL=$(jq -r '.total_count // 0' <<<"$CRS") - [[ "$TOTAL" -eq 0 ]] && continue - # CI 未完结的 head:不构成证据也不占采样位(防起飞窗口误报——gate job 在 - # 依赖图末端,新开 PR 的前几分钟 conclusion 必为 null) - if jq -e '[.check_runs[] | select(.status != "completed")] | length > 0' <<<"$CRS" >/dev/null 2>&1; then - SAW_INFLIGHT=1; continue - fi - N_CONCL=$((N_CONCL+1)) - CONCL_SHAS="$CONCL_SHAS$sha"$' -' - done <<<"$HEADS" - if [[ $N_CONCL -eq 0 ]]; then - if [[ $QUERY_FAIL -eq 1 ]]; then - drift "repo '$r' check-runs 查询失败,required check 活体无法验证(fail-closed)" - continue - fi - echo "SKIP required-check-live '$r'(候选 head 全部 CI in-flight/零 check,本轮无法判定——非漂移,下轮复核)" - continue - fi - LIVE_MISS=0 - REPO_PENDING=0 # 本仓 (a) 待接入计数(IR-0002 spec AC-3:待接入须计数可见) - for ctx in $REQ_CHECKS; do - FOUND=0 - while IFS= read -r sha; do - [[ -n "$sha" ]] || continue - CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") - jq -e --arg c "$ctx" '[.check_runs[] | select(.name == $c and .conclusion != null)] | length > 0' <<<"$CRS" >/dev/null 2>&1 && { FOUND=1; break; } - done <<<"$CONCL_SHAS" - if [[ $FOUND -ne 1 ]]; then - rc=0 - s12_classify "$r" "$ctx" "$QUERY_FAIL" "${HAS_PR_ACTIVITY:-1}" "$PRS_RECENT" "$ORG_GATE_EFFECTIVE" "$N_CONCL" || rc=$? - case $rc in - 0) REPO_PENDING=$((REPO_PENDING+1)); S12_PENDING=$((S12_PENDING+1)) ;; - 1) LIVE_MISS=1 ;; - 2) : ;; - esac - fi - done - [[ $REPO_PENDING -gt 0 ]] \ - && echo "INFO required-check-live '$r':$REPO_PENDING 项待接入计入汇总(IR-0002 (a)——不阻断)" \ - || true - [[ $LIVE_MISS -eq 0 ]] && ok "required-check-live '$r'(最近 $N_CONCL 个已完结 head 上 required check 齐备)" -done -# IR-0002 spec AC-3:§12 汇总信息行——(a) 待接入非漂移但必须计数可见(防"静默豁免" -# 演变为检测盲区;§17 协议块对账同款"可见即治理"原则)。零待接入时也打 OK 行, -# 让"本节确实跑过"成为可审计事实。 -if [[ ${S12_PENDING:-0} -gt 0 ]]; then - echo "INFO §12 required-check 活体汇总:待接入 $S12_PENDING 项(IR-0002 (a) 形态——ruleset 生效后无 PR 活动,接线后自然转绿;非漂移)" -else - ok "§12 required-check 活体汇总:0 项待接入(全部受管仓要么齐备要么显式 SKIP)" -fi - -# ---------- 13. PR liveness 侦测(P1-4,ADR-0034)---------- -# 治理不漂移但流水线死了的三类形态(#81 §6——无人值守下卡死 PR 是最隐形的 -# 人类瓶颈):(a) auto-merge 已设置但 > 阈值无进展;(b) check 停留 queued/ -# in_progress 超 > 阈值;(c) PR 创建超阈值且 head 上零 check run(应有而无)。 -# 命中即走 GM-1 既有漂移 issue 通道。阈值:expected-state pr_liveness_hours, -# 环境变量 PR_LIVENESS_HOURS 可覆盖(dispatch input liveness_hours 透传,注入测试用)。 -LIVENESS_H="${PR_LIVENESS_HOURS:-$(jq -r '.pr_liveness_hours // 4' "$EXPECTED")}" -LIVENESS_S=$(( LIVENESS_H * 3600 )) -STUCK_TOTAL=0 -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - OPEN_PRS=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=open&per_page=30") - jq -e 'type == "array"' <<<"$OPEN_PRS" >/dev/null 2>&1 \ - || { drift "repo '$r' open PR 清单拉取失败,liveness 侦测无法执行(fail-closed)"; continue; } - STUCK=0 - while IFS=$'\t' read -r pnum created updated headsha has_am; do - [[ -n "$pnum" ]] || continue - AGE_UPD=$(( NOW_EPOCH - $(epoch_of "$updated") )) - AGE_CRE=$(( NOW_EPOCH - $(epoch_of "$created") )) - if [[ "$has_am" == "true" && $AGE_UPD -gt $LIVENESS_S ]]; then - drift "repo '$r' PR#$pnum auto-merge 已开启但 ${LIVENESS_H}h 无进展(updated ${AGE_UPD}s 前)——卡死侦测 (a):查 required check 状态/分支冲突(ADR-0034 §13)" - STUCK=1; continue - fi - CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$headsha/check-runs?per_page=100") - if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then - drift "repo '$r' PR#$pnum head check-runs 查询失败,liveness 无法验证(fail-closed)" - STUCK=1; continue - fi - N_RUNS=$(jq '.check_runs | length' <<<"$CRS") - if [[ "$N_RUNS" -eq 0 && $AGE_CRE -gt $LIVENESS_S ]]; then - drift "repo '$r' PR#$pnum 创建 ${AGE_CRE}s 且 head 零 check run——卡死侦测 (c):应有而无(workflow 未触发/被改名,ADR-0034 §13)" - STUCK=1; continue - fi - # pending 超龄判定(b):shell 循环做日期运算(jq 无日期运算) - while IFS=$'\t' read -r crname crstatus crstart; do - [[ -n "$crname" ]] || continue - [[ "$crstatus" == "queued" || "$crstatus" == "in_progress" ]] || continue - [[ -n "$crstart" ]] || continue - AGE_PEND=$(( NOW_EPOCH - $(epoch_of "$crstart") )) - if [[ $AGE_PEND -gt $LIVENESS_S ]]; then - drift "repo '$r' PR#$pnum check '$crname' 停留 $crstatus 已 ${AGE_PEND}s——卡死侦测 (b):永久 pending(ADR-0034 §13)" - STUCK=1 - fi - done < <(jq -r '.check_runs[] | [.name, .status, (.started_at // "")] | @tsv' <<<"$CRS") - done < <(jq -r '.[] | [(.number|tostring), .created_at, .updated_at, .head.sha, (.auto_merge != null | tostring)] | @tsv' <<<"$OPEN_PRS") - STUCK_TOTAL=$((STUCK_TOTAL+STUCK)) -done -[[ $STUCK_TOTAL -eq 0 ]] && ok "pr-liveness(全部受管仓 open PR 无卡死,阈值 ${LIVENESS_H}h)" - -# ---------- 15. org-required-workflows 钉点完整性(P3-1 #95 / ADR-0046)---------- -# §1 已对账 ruleset JSON 文本(path/ref/repository_id 任何改动即漂移)。本节补盲区: -# ref 为 tag 时,tag 指针被移动(ruleset 文本不变、内容换了)——钉点的 commit 绑定 -# 必须与 expected-state.org_required_workflows.ref_commit 一致;钉点 tag 还必须是 -# CI-Workflows 当前发布不变式(§11:vN==最高 vN.x.y)认可的物。fail-closed。 -ORW_CFG=$(jq -c '.org_required_workflows // empty' "$EXPECTED") -if [[ -n "$ORW_CFG" ]]; then - WANT_RULESET=$(jq -r '.ruleset' <<<"$ORW_CFG") - WANT_REPO_ID=$(jq -r '.repository_id' <<<"$ORW_CFG") - WANT_PATH=$(jq -r '.path' <<<"$ORW_CFG") - WANT_REF=$(jq -r '.ref' <<<"$ORW_CFG") - WANT_COMMIT=$(jq -r '.ref_commit' <<<"$ORW_CFG") - ORW_LIST=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") - ORW_ID=$(jq -r --arg n "$WANT_RULESET" '.[] | select(.name == $n) | .id' <<<"$ORW_LIST" 2>/dev/null | head -1) - if [[ -z "$ORW_ID" || "$ORW_ID" == "null" ]]; then - drift "org-required-workflows ruleset '$WANT_RULESET' 线上不存在——中心审判失效,全部受管仓回落本地 gate(P3-1 枢轴脱离,ADR-0046)" - else - # LIST 端点不含 rules 数组(同 §1 教训)——须拉 detail 才能取 workflows 规则 - ORW_ROW=$(api "https://api.github.com/orgs/$ORG/rulesets/$ORW_ID") - GOT_WF=$(jq -c '.rules[] | select(.type == "workflows") | .parameters.workflows[0] // empty' <<<"$ORW_ROW") - GOT_PATH=$(jq -r '.path // empty' <<<"$GOT_WF") - GOT_REF=$(jq -r '.ref // empty' <<<"$GOT_WF") - GOT_REPO_ID=$(jq -r '.repository_id // empty' <<<"$GOT_WF") - BAD="" - [[ "$GOT_PATH" == "$WANT_PATH" ]] || BAD="$BAD path=$GOT_PATH" - [[ "$GOT_REF" == "$WANT_REF" ]] || BAD="$BAD ref=$GOT_REF" - [[ "$GOT_REPO_ID" == "$WANT_REPO_ID" ]] || BAD="$BAD repository_id=$GOT_REPO_ID" - if [[ -n "$BAD" ]]; then - drift "org-required-workflows 钉点漂移:$BAD(期望 path=$WANT_PATH ref=$WANT_REF repo_id=$WANT_REPO_ID)——审判源被改指(ADR-0046 §15)" - fi - # commit 绑定校验(tag 或直接 SHA;ISSUE-263 W1-C5 改为直接钉 commit SHA) - if [[ "$WANT_REF" =~ ^[0-9a-f]{40}$ ]]; then - # 直接钉 commit SHA:校验该对象在目标仓可达 - COMMIT_OBJ=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/commits/$WANT_REF") - TAGCOMMIT=$(jq -r '.sha // empty' <<<"$COMMIT_OBJ") - else - SHORTREF="${WANT_REF#refs/tags/}" - TAGROW=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/ref/tags/$SHORTREF") - TAGCOMMIT=$(jq -r '.object.sha // empty' <<<"$TAGROW") - fi - if [[ -z "$TAGCOMMIT" ]]; then - drift "org-required-workflows 钉点 $WANT_REF 解引用/校验失败(fail-closed,ADR-0046 §15)" - elif [[ "$TAGCOMMIT" != "$WANT_COMMIT" ]]; then - drift "org-required-workflows 钉点 $WANT_REF 已移动:${TAGCOMMIT:0:8} ≠ 声明 ${WANT_COMMIT:0:8}——审判内容被换(ADR-0046 §15;还原或更新 expected-state)" - elif [[ -n "$BAD" ]]; then - : # 钉点漂移已上报,不再输出 OK 行(避免同段 OK/DRIFT 并存的误导) - else - ok "org-required-workflows 钉点完整($WANT_REF == ${TAGCOMMIT:0:8},path/repository_id 一致)" - fi - fi -fi - -# ---------- 16. 治理标签(state:*/type:* 存在性与形状,W0-C2/ADR-0047)---------- -# expected-state.json#labels.items 为治理标签期望全集。只对账"应有哪些"(新增式), -# 各仓自有业务标签不属治理面、不判漂移;缺失或 color/description 不符 = 漂移。 -# items 为空视为期望状态缺失(fail-closed——治理标签全集被误删不得静默通过)。 -LBL_ITEMS=$(jq -c '.labels.items // []' "$EXPECTED") -LBL_N=$(jq 'length' <<<"$LBL_ITEMS") -if [[ "$LBL_N" -eq 0 ]]; then - drift "expected-state.json#labels.items 为空——治理标签期望状态缺失(fail-closed,ADR-0047)" -else - LBL_DRIFT=0 - for r in $REPOS; do - jq -e --arg r "$r" 'index($r) != null' <<<"$EXCLUDES" >/dev/null && continue - # 分页拉全量标签(>100 标签的仓单页会漏——与 §1 同教训) - RL_TMP=$(mktemp); RL_ERR=0; RL_PAGE=1 - while :; do - CHUNK=$(api "https://api.github.com/repos/$ORG/$r/labels?per_page=100&page=$RL_PAGE") - if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then - CHUNK_MSG=$(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || true) - [[ -z "$CHUNK" ]] && CHUNK_MSG="传输失败(空响应)" - drift "repo '$r' 标签清单拉取失败($CHUNK_MSG)——标签对账跳过(fail-closed)" - LBL_DRIFT=1; RL_ERR=1; break - fi - RL_N=$(jq 'length' <<<"$CHUNK"); [[ "$RL_N" -eq 0 ]] && break - jq -c '.[] | {name, color, description: (.description // "")}' <<<"$CHUNK" >>"$RL_TMP" - [[ "$RL_N" -lt 100 ]] && break - RL_PAGE=$((RL_PAGE+1)) - done - if [[ $RL_ERR -eq 1 ]]; then rm -f "$RL_TMP"; continue; fi - while IFS= read -r want; do - wname=$(jq -r .name <<<"$want"); wcolor=$(jq -r .color <<<"$want"); wdesc=$(jq -r .description <<<"$want") - got=$(jq -r --arg n "$wname" 'select(.name == $n) | "\(.color) \(.description)"' <"$RL_TMP" 2>/dev/null | head -1) - if [[ -z "$got" ]]; then - drift "repo '$r' 缺治理标签 '$wname'(修复:bash governance/apply.sh §7)"; LBL_DRIFT=1 - elif [[ "$got" != "$(printf '%s %s' "$wcolor" "$wdesc")" ]]; then - drift "repo '$r' 标签 '$wname' 形状不符:$got 期望 color=$wcolor(apply.sh §7 对齐)"; LBL_DRIFT=1 - fi - done < <(jq -c '.[]' <<<"$LBL_ITEMS") - rm -f "$RL_TMP" - done - [[ $LBL_DRIFT -eq 0 ]] && ok "治理标签全集($LBL_N 项 × 受管仓)一致" -fi - -# ---------- 17. 统一入口协议块一致性(W1-C3 #166 / ADR-0055,宪法 §11/§4D)---------- -# REPOS.yaml 中 entry_protocol: true 的仓:其 main 的 AGENTS.md 须携带与 -# template-service main(统一下发真源)逐字节一致的协议块—— -# 提取 标记间内容(含标记)比对。 -# 缺失标记/内容不一致/拉取失败 = 漂移(fail-closed:检测器失明不得伪装通过)。 -# 产品仓 rollout(7 个业务仓下发协议块)是后续 fleet 小卡——届时逐仓加 -# entry_protocol: true 即纳入本节管辖。 -PROTO_OK=0 -PROTO_REPOS="" -# REPOS.yaml 是 YAML——jq 直读必失败且被吞掉=假绿(§7 同教训)。§17 自解析 -# (§7 同款 python3+pyyaml→JSON,不信任继承的 REPO_MAP——调用方 env 注入可 -# 重定向对账对象);解析不可用或 entry_protocol 清单为空均 fail-closed。 -PROTO_MAP="" -if python3 -c 'import yaml' 2>/dev/null; then - PROTO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' \ - "$DIR/REPOS.yaml" 2>/dev/null || true) -fi -if [[ -n "$PROTO_MAP" ]]; then - PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' <<<"$PROTO_MAP") - if [[ -z "$PROTO_REPOS" ]]; then - drift "REPOS.yaml 无 entry_protocol 仓——协议块对账失去对象(申报丢失=漂移,fail-closed,ADR-0055 §17)" - PROTO_OK=1 - fi -else - drift "REPOS.yaml 未解析(缺 python3+pyyaml)——协议块对账不可执行(fail-closed,ADR-0055 §17)" - PROTO_OK=1 -fi -proto_block() { # 提取协议块(标记间内容,含首尾标记行)——起止标记任一缺失输出空 - # (缺末标记时不得输出“半块”:半块≠空,会让比对产生误判——fail-closed) - local t - t=$(cat) - if [[ "$t" == *""* ]]; then - awk '//{f=1} f{print} //{exit}' <<<"$t" - fi -} -CANON_RAW=$(api "https://raw.githubusercontent.com/$ORG/template-service/main/AGENTS.md" 2>/dev/null || true) -CANON_BLOCK="" -if [[ -z "$CANON_RAW" || "$CANON_RAW" == "404:"* ]]; then - drift "template-service main AGENTS.md 拉取失败(协议块真源不可读——fail-closed)" - PROTO_OK=1 -else - CANON_BLOCK=$(proto_block <<<"$CANON_RAW") - if [[ -z "$CANON_BLOCK" ]]; then - drift "template-service main AGENTS.md 缺协议块或标记不完整(宪法 §4D:块由 template-service 统一下发)" - PROTO_OK=1 - fi -fi -PROTO_N=0 -for r in $PROTO_REPOS; do - PROTO_N=$((PROTO_N+1)) - [[ "$r" == "template-service" ]] && continue # 真源自身即 canon(同 URL 已验) - RAW=$(api "https://raw.githubusercontent.com/$ORG/$r/main/AGENTS.md" 2>/dev/null || true) - if [[ -z "$RAW" || "$RAW" == "404:"* ]]; then - drift "repo '$r' AGENTS.md 拉取失败(协议块对账跳过——fail-closed,ADR-0055 §17)" - PROTO_OK=1 - continue - fi - BLOCK=$(proto_block <<<"$RAW") - if [[ -z "$BLOCK" ]]; then - drift "repo '$r' AGENTS.md 缺协议块标记或标记不完整(entry_protocol 已申报——宪法 §11 统一入口)" - PROTO_OK=1 - continue - fi - if [[ "$BLOCK" != "$CANON_BLOCK" ]]; then - # 双 here-string 会互相覆盖 stdin(SC2261,diff 拿不到操作数)——用进程替换 - drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <(printf '%s\n' "$CANON_BLOCK") <(printf '%s\n' "$BLOCK") 2>&1 | head -3 | tr '\n' ' ' | cut -c1-160))" - PROTO_OK=1 - fi -done -[[ $PROTO_OK -eq 0 ]] && ok "统一入口协议块一致(真源 template-service × $PROTO_N 个 entry_protocol 仓,逐字节比对)" - -# ---------- 18. holdout 隔离断言(DECISION-02:App 安装差异隔离,W1-C4/ADR-0056,ADR-0080 修订)---------- -# 试卷层 holdout 的读隔离不靠保密(公开仓,ADR-0056/DECISION-02),靠三条: -# - cloudbrid-agent App 不安装到该仓(agent 的组织级凭据通道物理不可达); -# - 验证者 APP 可安装,但仅作为测试/验证路径写入主体(ADR-0080); -# - 泄漏诱饵周检(.github 仓 holdout-canary-sweep,宪法 §6/§11)。 -# 本节断言 App 挂载差异: -# - cloudbrid-agent installation 的仓清单含 holdout = P0 漂移; -# - 验证者 APP installation 的仓清单含 holdout = OK(合法); -# - 验证者 APP 尚未安装 → INFO/SKIP(实施阶段再断言)。 -# 正向对照防检测器失明假绿(§4 同思想):cloudbrid-agent 的 installation 清单必须 -# 非空且含 .github——连 .github 都不在清单 = 端点读法错/权限变了 → 按漂移报错, -# 绝不把"看不见"当"没有"。API 任何失败 = fail-closed 报漂移。 -# §17 编号预留给并行卡 W1-C3(多代理并行修改本文件,编号互不占段)。 -# 端点注(2026-08-21 实测):installation id 走 /orgs//installations(admin -# 端点,new-repo-init.sh §3 同款)——/user/installations 要求 GitHub App 令牌, -# 经典 PAT 一律 403;仓清单走 /user/installations//repositories(经典 PAT 可读)。 -HOLDOUT_REPO="holdout" - -# 拉取一次 org 安装列表,供 cloudbrid-agent 与验证者 APP 共用。 -HOLDOUT_INST=$(api "https://api.github.com/orgs/$ORG/installations?per_page=100") -if ! jq -e 'type == "object" and (.installations | type == "array")' <<<"$HOLDOUT_INST" >/dev/null 2>&1; then - drift "holdout 隔离断言失败:org installations 拉取失败(fail-closed——检测器失明不得伪装通过,ADR-0056 §18): $(jq -r '.message // "非 JSON 响应"' <<<"$HOLDOUT_INST" 2>/dev/null || echo 传输失败)" -else - # 辅助函数:拉取指定 installation 下的全部仓名;stdout 为排序去重后的仓名列表; - # 失败时返回非 0(stdout 为空,不应使用)。 - holdout_install_repos() { - local iid="$1" tmp fail page n chunk - tmp=$(mktemp) - fail=0 - page=1 - while :; do - chunk=$(api "https://api.github.com/user/installations/$iid/repositories?per_page=100&page=$page") - if ! jq -e 'type == "object" and (.repositories | type == "array")' <<<"$chunk" >/dev/null 2>&1; then - fail=1; break - fi - n=$(jq '.repositories | length' <<<"$chunk") - [[ "$n" -eq 0 ]] && break - jq -r '.repositories[].name' <<<"$chunk" >>"$tmp" - [[ "$n" -lt 100 ]] && break - page=$((page+1)) - done - if [[ $fail -ne 0 ]]; then - rm -f "$tmp" - return 1 - fi - sort -u "$tmp" - rm -f "$tmp" - } - - # cloudbrid-agent:老 DECISION-02 主体,必须不挂载 holdout。 - HOLDOUT_APP=$(jq -r .github_app.name "$EXPECTED") - INST_ID=$(jq -r --arg s "$HOLDOUT_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) - if [[ -z "$INST_ID" || "$INST_ID" == "null" ]]; then - drift "app '$HOLDOUT_APP' 的 installation 未找到——holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" - else - INST_REPOS=$(holdout_install_repos "$INST_ID") - if [[ $? -ne 0 ]]; then - drift "installation#$INST_ID 仓清单拉取失败,holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" - else - HOLDOUT_HIT=0 - grep -qx "$HOLDOUT_REPO" <<<"$INST_REPOS" && HOLDOUT_HIT=1 - # 正向对照(防失明):清单非空且含 .github,否则断言结论不可信 - if [[ -z "$INST_REPOS" ]]; then - drift "installation#$INST_ID 仓清单为空——正向对照失败(端点读法或权限已变),不得把看不见当没有(fail-closed,ADR-0056 §18)" - HOLDOUT_HIT=-1 - elif ! grep -qx ".github" <<<"$INST_REPOS"; then - drift "installation#$INST_ID 仓清单不含 .github——正向对照失败(端点读法或权限已变),holdout 隔离结论不可信(fail-closed,ADR-0056 §18)" - HOLDOUT_HIT=-1 - fi - if [[ $HOLDOUT_HIT -eq 1 ]]; then - drift "holdout 出现在 app '$HOLDOUT_APP' installation#$INST_ID 仓清单——P0:cloudbrid-agent 挂上 holdout = 试卷层隔离失效(DECISION-02/ADR-0056 §18;立即在 App 设置页移除该仓访问并追查何时挂载)" - elif [[ $HOLDOUT_HIT -eq 0 ]]; then - ok "holdout 隔离成立(cloudbrid-agent installation#$INST_ID 共 $(grep -c . <<<"$INST_REPOS") 仓不含 holdout;正向对照 .github 在清单)" - fi - fi - fi - - # 验证者 APP(ADR-0080):允许挂载 holdout,作为测试/验证路径写入主体。 - VERIFIER_APP=$(jq -r '.verifier_app.name // empty' "$EXPECTED") - if [[ -n "$VERIFIER_APP" && "$VERIFIER_APP" != "null" ]]; then - VERIFIER_INST_ID=$(jq -r --arg s "$VERIFIER_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) - if [[ -n "$VERIFIER_INST_ID" && "$VERIFIER_INST_ID" != "null" ]]; then - VERIFIER_REPOS=$(holdout_install_repos "$VERIFIER_INST_ID") - if [[ $? -ne 0 ]]; then - drift "验证者 APP '$VERIFIER_APP' installation#$VERIFIER_INST_ID 仓清单拉取失败,无法校验 holdout 挂载合法性(fail-closed,ADR-0080 §18)" - elif grep -qx "$HOLDOUT_REPO" <<<"$VERIFIER_REPOS"; then - ok "holdout 由验证者 APP '$VERIFIER_APP' 合法挂载(installation#$VERIFIER_INST_ID,ADR-0080)" - else - ok "验证者 APP '$VERIFIER_APP' 已安装但未挂载 holdout(ADR-0080 允许,非漂移)" - fi - else - echo "INFO 验证者 APP '$VERIFIER_APP' 尚未安装——holdout 挂载合法性断言跳过(实施阶段再校验,ADR-0080 §18)" - fi - fi -fi - -echo "----------------------------------------" -if [[ $DRIFTS -gt 0 ]]; then - echo "结果: $DRIFTS 项漂移。修复: bash governance/apply.sh 或手动改回" - exit 1 -fi +#!/usr/bin/env bash +# drift-check.sh —— 治理飘移检测(只读) +# +# 对比 governance/expected-state.json + rulesets/*.json 与线上实际配置。 +# 输出 OK/DRIFT 逐项结果;发现任何漂移 exit 1。 +# +# 用法: GH_TOKEN= bash drift-check.sh +# CI 中由 .github/workflows/governance-drift.yml 调用,凭据来自 secret GOVERNANCE_TOKEN。 +set -uo pipefail + +ORG="${ORG:-Cloudbird-Software}" +DIR="$(cd "$(dirname "$0")" && pwd)" +EXPECTED="$DIR/expected-state.json" +DRIFTS=0 + +ok() { echo "OK $1"; } +drift(){ echo "DRIFT $1"; DRIFTS=$((DRIFTS+1)); } + +api() { curl -sS -H "Authorization: Bearer ${GH_TOKEN:?需要 org admin GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" "$@"; } + +[[ -n "${GH_TOKEN:-}" ]] || { echo "FATAL: GH_TOKEN 未设置"; exit 2; } + +# ---------- 1. Rulesets:存在性 / enforcement / 核心规则 ---------- +ACTUAL_RULESETS=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") +# fail-closed(ADR-0083 决策 4):清单非数组(token 失效/网络失败/权限丢失)时, +# §1 的「不存在」判定全部是伪读数——2026-08-23 GOVERNANCE_TOKEN 失效期间曾把 +# 四个真实存在的 ruleset 全报「不存在」+ jq 对字符串行报错刷屏。检测器失明 +# 不得伪装成漂移或无漂移:显式 exit 2(与 §4 仓库清单 loud-failure 契约一致)。 +if ! jq -e 'type == "array"' <<<"$ACTUAL_RULESETS" >/dev/null 2>&1; then + echo "FATAL: org ruleset 清单拉取失败($(jq -r '.message // "非数组"' <<<"$ACTUAL_RULESETS" 2>/dev/null || echo 传输失败)),drift 检测失明——中止" >&2 + exit 2 +fi +for f in "$DIR"/rulesets/*.json; do + name=$(jq -r .name "$f") + want_enf=$(jq -r .enforcement "$f") + want_rules=$(jq -c '[.rules[].type] | sort' "$f") + row=$(jq -c --arg n "$name" '.[] | select(.name == $n)' <<<"$ACTUAL_RULESETS") + if [[ -z "$row" || "$row" == "null" ]]; then + drift "ruleset '$name' 不存在(定义文件 $f)"; continue + fi + got_enf=$(jq -r .enforcement <<<"$row") + [[ "$got_enf" == "$want_enf" ]] || drift "ruleset '$name' enforcement=$got_enf 期望=$want_enf" + # 线上完整定义(含 id)对比规则集 + rid=$(jq -r .id <<<"$row") + detail=$(api "https://api.github.com/orgs/$ORG/rulesets/$rid") + got_rules=$(jq -c '[.rules[].type] | sort' <<<"$detail") + [[ "$got_rules" == "$want_rules" ]] || drift "ruleset '$name' 规则集变动: $got_rules 期望=$want_rules" + # 精确 diff:把线上定义裁剪成与落盘文件同构后比较(剔除 API 自动填充的空字段) + got_norm=$(jq -S 'del(.rules[].parameters.required_reviewers? | select(. == [])) | {name,target,enforcement,conditions,bypass_actors,rules}' <<<"$detail") + want_norm=$(jq -S '{name,target,enforcement,conditions,bypass_actors,rules}' "$f") + [[ "$got_norm" == "$want_norm" ]] \ + || drift "ruleset '$name' 定义与落盘不一致: $(diff <(echo "$want_norm") <(echo "$got_norm") | head -10)" + ok "ruleset '$name'" +done +EXTRA=$(jq -c --argjson known "$(jq -s '[.[].name]' "$DIR"/rulesets/*.json)" \ + '[.[].name] - $known' <<<"$ACTUAL_RULESETS") +[[ "$EXTRA" == "[]" ]] || drift "线上存在未落盘的 ruleset: $EXTRA(落盘或删除)" + +# ---------- 2. Actions 策略 ---------- +AP=$(api "https://api.github.com/orgs/$ORG/actions/permissions") +[[ "$(jq -r .allowed_actions <<<"$AP")" == "$(jq -r .actions_policy.allowed_actions "$EXPECTED")" ]] \ + || drift "allowed_actions=$(jq -r .allowed_actions <<<"$AP")" +# enabled_repositories 同检(ADR-0021):apply §2 写该字段而 check 不验=修复回路盲区 +[[ "$(jq -r .enabled_repositories <<<"$AP")" == "$(jq -r .actions_policy.enabled_repositories "$EXPECTED")" ]] \ + || drift "enabled_repositories=$(jq -r .enabled_repositories <<<"$AP") ≠ expected $(jq -r .actions_policy.enabled_repositories "$EXPECTED")" + +SA=$(api "https://api.github.com/orgs/$ORG/actions/permissions/selected-actions") +[[ "$(jq -c '{github_owned_allowed, verified_allowed, patterns_allowed}' <<<"$SA")" == \ + "$(jq -c '.actions_policy | {github_owned_allowed, verified_allowed, patterns_allowed}' "$EXPECTED")" ]] \ + || drift "allowed actions 白名单不一致: $(jq -c .patterns_allowed <<<"$SA")" + +WF=$(api "https://api.github.com/orgs/$ORG/actions/permissions/workflow") +[[ "$(jq -r .default_workflow_permissions <<<"$WF")" == "$(jq -r .actions_policy.default_workflow_permissions "$EXPECTED")" ]] \ + || drift "default_workflow_permissions=$(jq -r .default_workflow_permissions <<<"$WF")" +ok "actions 策略(含白名单)" + +# ---------- 3. Code Security 默认配置 ---------- +CS=$(api "https://api.github.com/orgs/$ORG/code-security/configurations") +want_cs=$(jq -r .code_security.configuration_name "$EXPECTED") +csrow=$(jq -c --arg n "$want_cs" '.[] | select(.name == $n)' <<<"$CS") +if [[ -z "$csrow" || "$csrow" == "null" ]]; then + drift "code security 配置 '$want_cs' 不存在" +else + # 注意:default_for_new_repos 是只写 API(PUT .../defaults),GitHub 不提供读取端点, + # 无法只读验证。由 apply.sh 的幂等 PUT 保证,drift-check 只验证配置本体存在。 + ok "code security '$want_cs'(default_for_new_repos 由 apply 保证,API 无读取端点)" +fi + +# ---------- 4. 仓库基线(squash-only / 删分支 / auto-merge,ADR-0029)---------- +EXCLUDES=$(jq -c '.repo_baseline.exclude_repos // []' "$EXPECTED") +# auto-merge 期望值(ADR-0029):repo setting 不受 ruleset 管,静默漂移 = 自动合并 +# 全链路静默失效。字段读不到(null——如凭据缺 administration 读权限的 fine-grained +# PAT)与 false 同判漂移——fail-closed:检测器失明不得伪装成无漂移 +WANT_AUTO_MERGE=$(jq -r '.repo_baseline.allow_auto_merge // true' "$EXPECTED") +# 全分页拉取 org 仓库(评审项:单页 100 时 >100 仓的 org 会漏检后续仓库); +# fail-closed:清单拉取失败/非数组/为空时 REPOS 为空会让后续全部循环静默跳过、 +# 检测整体假绿——此处显式中止,检测器失明不得伪装成无漂移 +REPOS_TMP=$(mktemp) +PAGE=1 +while :; do + CHUNK=$(api "https://api.github.com/orgs/$ORG/repos?per_page=100&page=$PAGE") + if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then + echo "FATAL: org 仓库清单拉取失败($(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)),无法检测" >&2 + exit 2 + fi + N=$(jq 'length' <<<"$CHUNK") + [[ "$N" -eq 0 ]] && break + jq -r '.[].name' <<<"$CHUNK" >>"$REPOS_TMP" + [[ "$N" -lt 100 ]] && break + PAGE=$((PAGE+1)) +done +REPOS=$(cat "$REPOS_TMP") +rm -f "$REPOS_TMP" +if [[ -z "$REPOS" ]]; then + echo "FATAL: org 仓库清单为空(org 至少应含本治理仓)——清单异常,无法检测" >&2 + exit 2 +fi +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + RR=$(api "https://api.github.com/repos/$ORG/$r") + bad="" + [[ "$(jq -r .allow_squash_merge <<<"$RR")" == "true" ]] || bad="$bad squash-off" + [[ "$(jq -r .allow_merge_commit <<<"$RR")" == "false" ]] || bad="$bad merge-commit-on" + [[ "$(jq -r .allow_rebase_merge <<<"$RR")" == "false" ]] || bad="$bad rebase-on" + [[ "$(jq -r .delete_branch_on_merge <<<"$RR")" == "true" ]] || bad="$bad keep-branch" + [[ "$(jq -r .allow_auto_merge <<<"$RR")" == "$WANT_AUTO_MERGE" ]] || bad="$bad auto-merge-off(got=$(jq -r .allow_auto_merge <<<"$RR"))" + [[ -z "$bad" ]] && ok "repo baseline '$r'" || drift "repo '$r' 基线异常:$bad" +done + +# ---------- 5. 必需 org secrets(只查存在性,值不可读)---------- +SECRETS=$(api "https://api.github.com/orgs/$ORG/actions/secrets") +for s in $(jq -r '.org_secrets_required[]' "$EXPECTED"); do + jq -e --arg s "$s" 'any(.secrets[]; .name == $s)' <<<"$SECRETS" >/dev/null \ + && ok "org secret '$s'" || drift "org secret '$s' 缺失" +done + +# ---------- 6. GitHub App 权限形状 ---------- +APP=$(api "https://api.github.com/apps/$(jq -r .github_app.name "$EXPECTED")") +if [[ "$(jq -r .id <<<"$APP" 2>/dev/null)" == "$(jq -r .github_app.id "$EXPECTED")" ]]; then + for p in $(jq -r '.github_app.permissions | to_entries[] | "\(.key)=\(.value)"' "$EXPECTED"); do + k=${p%%=*}; v=${p##*=} + [[ "$(jq -r .permissions.$k <<<"$APP")" == "$v" ]] || drift "App 权限 $k 变动" + done + for p in $(jq -r '.github_app.must_not_have[]' "$EXPECTED"); do + jq -e --arg p "$p" '.permissions | has($p)' <<<"$APP" >/dev/null \ + && drift "App 出现禁用权限 '$p'(应立即在 App 设置页移除)" + done + ok "github app '$(jq -r .github_app.name "$EXPECTED")'" +else + drift "github app '$(jq -r .github_app.name "$EXPECTED")' 不存在或 id 不符" +fi + +# ---------- 7. 组织地图(REPOS.yaml):存在性 / visibility / 未申报仓 ---------- +if python3 -c 'import yaml' 2>/dev/null; then + REPO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' "$DIR/REPOS.yaml") + # 7a. active 仓:必须存在且 visibility 一致 + while IFS=$'\t' read -r r want_vis; do + [[ -n "$r" ]] || continue + RR=$(api "https://api.github.com/repos/$ORG/$r") + if [[ "$(jq -r 'if .message then .message else "" end' <<<"$RR")" == "Not Found" ]]; then + drift "REPOS.yaml 申报的 active 仓 '$r' 不存在"; continue + fi + got_vis=$(jq -r 'if .private then "private" else "public" end' <<<"$RR") + [[ "$got_vis" == "$want_vis" ]] || drift "repo '$r' visibility=$got_vis 期望=$want_vis" + [[ "$want_vis" == "public" ]] || drift "repo '$r' 申报 visibility=$want_vis 违反全仓公开政策(ADR-0020)" + ok "REPOS map '$r'" + done < <(jq -r '.repos[] | select(.status=="active") | "\(.name)\t\(.visibility)"' <<<"$REPO_MAP") + # 7b/7c. 线上全量仓合并遍历(每仓一次 API): + # 7b. 必须在图中申报(任何 status 均可,未申报即漂移) + # 7c. 全仓公开政策(ADR-0020):private 即漂移——不依赖申报完整性(未申报仓 + # 同样受检);exempt 状态只豁免治理基线(repo_baseline),不豁免可见性 + for r in $REPOS; do + jq -e --arg r "$r" '[.repos[].name] | index($r) != null' <<<"$REPO_MAP" >/dev/null \ + || drift "线上仓 '$r' 未在 governance/REPOS.yaml 申报(补申报,或标 exempt 注明原因)" + VR=$(api "https://api.github.com/repos/$ORG/$r") + [[ "$(jq -r 'if .private then "private" else "public" end' <<<"$VR")" == "public" ]] \ + || drift "repo '$r' 为 private,违反全仓公开政策(ADR-0020 改回 public,或新 ADR 推翻政策)" + done +else + echo "SKIP REPOS.yaml 校验(环境缺 python3+pyyaml;GitHub runner 自带)" +fi + +# ---------- 8. 直推检测(GM-2 破玻璃监控)---------- +# flows.governance_change.policy_effective 之后,受治仓默认分支上的非 PR commit = 漂移。 +# 唯一权威判据 = List pull requests associated with commit API(红队 #17-I + PR 评审项): +# 不做消息后缀预筛——"(#N)" 后缀可伪造,直推挂个假后缀即可绕过预筛被报 clean; +# 窗口内每个 commit 都经关联 PR API 复核(真有关联 PR=合法形态放行,无=直推)。 +# fail-closed(评审项):关联 PR 查询本身失败(限流/权限/传输)= 无法验证 = 判漂移报错, +# 绝不静默放行——检测器失明比误报危险。 +# 分页(评审项):commit 列表全分页拉取,第 100 条之后的直推不可漏检; +# 超 MAX_COMMITS 上限时显式报漂移(fail loudly),不静默截断。 +# 回填时限:commit 距今 >24h 且仍在直推状态 = 已超破玻璃回填时限(P0 级标记)。 +SINCE=$(python3 - <<'EOF' +from datetime import datetime, timezone, timedelta +eff = datetime(2026, 8, 19, tzinfo=timezone.utc) # policy_effective +lo = datetime.now(timezone.utc) - timedelta(days=7) # 检测窗口 +print(max(eff, lo).strftime("%Y-%m-%dT%H:%M:%SZ")) +EOF +) +NOW_EPOCH=$(date +%s) +MAX_COMMITS=300 # 窗口内单仓 commit 上限:治理仓日常量级为个位数;超限=异常,人工核查 +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + # 全分页拉取窗口内 commit(评审项:首页之后不可漏检) + COMMITS_TMP=$(mktemp) + TOTAL=0 + PAGE=1 + FETCH_ERR=0 + while :; do + CHUNK=$(api "https://api.github.com/repos/$ORG/$r/commits?sha=main&since=$SINCE&per_page=100&page=$PAGE") + if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then + drift "repo '$r' commit 列表拉取失败,直推检测无法执行(fail-closed): $(jq -r '.message // "非 JSON 响应"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)" + FETCH_ERR=1 + break + fi + N=$(jq 'length' <<<"$CHUNK") + [[ "$N" -eq 0 ]] && break + jq -c '.[] | {sha: .sha, date: (.commit.committer.date | sub("\\.[0-9]+Z$"; "Z"))}' <<<"$CHUNK" >>"$COMMITS_TMP" + TOTAL=$((TOTAL+N)) + [[ "$N" -lt 100 ]] && break + PAGE=$((PAGE+1)) + done + if [[ $FETCH_ERR -eq 1 ]]; then rm -f "$COMMITS_TMP"; continue; fi + if [[ $TOTAL -gt $MAX_COMMITS ]]; then + drift "repo '$r' 窗口内 commit 数=$TOTAL 超上限 $MAX_COMMITS——为防静默截断漏检,须人工核查直推或调窗口" + rm -f "$COMMITS_TMP" + continue + fi + # §8 豁免清单(ADR-0016 附录机制:已经 ADR 追认回填的破玻璃直推—— + # expected-state.json direct_push_exemptions 逐 SHA 登记,事件定性见 ADR-0017; + # 豁免须有 ADR 背书,不得口头/临时豁免) + EXEMPT=$(jq -r --arg r "$r" '.direct_push_exemptions[$r] // [] | join(" ")' "$EXPECTED") + DIRECT_FOUND=0 + while IFS= read -r row; do + [[ -n "$row" ]] || continue + sha=$(jq -r .sha <<<"$row") + cdate=$(jq -r .date <<<"$row") + if grep -qF "$sha" <<<"$EXEMPT"; then + ok "repo '$r' commit ${sha:0:8} 已回填破玻璃直推(§8 豁免清单,ADR-0017)" + continue + fi + # 关联 PR 复核(全 SHA;响应须为数组——error 对象/传输失败均为无法验证) + PRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/pulls?per_page=5" \ + | jq -r 'if type == "array" then (if length > 0 then "has-pr" else "none" end) else "error" end' 2>/dev/null || echo error) + if [[ "$PRS" == "error" ]]; then + drift "repo '$r' commit $sha 的关联 PR 查询失败,无法验证直推(fail-closed:限流/权限/传输故障不得静默放行)" + DIRECT_FOUND=1 + continue + fi + if [[ "$PRS" == "none" ]]; then + AGE=$(( NOW_EPOCH - $(date -u -d "$cdate" +%s) )) + if [[ $AGE -gt 86400 ]]; then + drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(已超 24h 回填时限=${AGE}s——P0:立即回填 ADR+PR,见 flows.governance_change)" + else + drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(破玻璃须 24h 内回填 ADR+PR,见 flows.governance_change)" + fi + DIRECT_FOUND=1 + fi + done <"$COMMITS_TMP" + rm -f "$COMMITS_TMP" + [[ $DIRECT_FOUND -eq 0 ]] && ok "no-direct-push '$r' (since $SINCE, $TOTAL commits)" +done + +# ---------- 9. vcs_admin 唯一性(ADR-0010:admin 全系统唯 owner)---------- +# org 全部受治仓的 admin 数量必须 == 1 且为 owner;任何多出的 admin = P0 漂移 +OWNER_LOGIN="${OWNER_LOGIN:-randypanding}" +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + # 单次拉取全分页(>100 协作者时防漏——CodeRabbit #13);ADMINS/COUNT 同源派生 + ADMIN_TMP=$(mktemp) + PAGE=1 + while :; do + CHUNK=$(api "https://api.github.com/repos/$ORG/$r/collaborators?permission=admin&per_page=100&page=$PAGE") + N=$(jq 'length' <<<"$CHUNK") + [[ "$N" -eq 0 ]] && break + jq -c '.' <<<"$CHUNK" >>"$ADMIN_TMP" + [[ "$N" -lt 100 ]] && break + PAGE=$((PAGE+1)) + done + ADMIN_DATA=$(jq -s 'add // []' "$ADMIN_TMP" 2>/dev/null || echo '[]') + rm -f "$ADMIN_TMP" + ADMINS=$(jq -r '[.[] | select(.permissions.admin == true) | .login] | unique | join(",")' <<<"$ADMIN_DATA") + COUNT=$(jq '[.[] | select(.permissions.admin == true) | .login] | unique | length' <<<"$ADMIN_DATA") + if [[ "$COUNT" != "1" ]]; then + drift "repo '$r' admin 数量=$COUNT ($ADMINS),必须唯一且为 $OWNER_LOGIN(ADR-0010 owner 伪原型不变量)" + elif [[ "$ADMINS" != "$OWNER_LOGIN" ]]; then + drift "repo '$r' admin=$ADMINS 非 owner $OWNER_LOGIN(ADR-0010)" + else + ok "vcs-admin-unique '$r'" + fi +done + +# ---------- 10. ADR 引用存在性+实体性后验(adr-required 的补充防线,评审项 + RB-D5)---------- +# gate.yml 的 adr-required 已在 PR 时点做存在性校验(ADR-0021:全仓公开后 PR 上下文 +# GITHUB_TOKEN 可读公开仓——早期"agent-registry 是私有仓、PR 时点无跨仓读权"的前提 +# 已随 ADR-0020 全仓公开政策失效,注记修正);ADR-0053(W1-C1 .github#164)索引世界 +# 起进一步校验 archive 正本可达性。本节后验实体性(防空壳)并独立复核存在性: +# 窗口内合并 PR 的 ADR-NNNN 引用必须有实质决策结构——伪造/幽灵 ADR 最长 24h 内 +# 检出(与 §8 直推检测同为 post-hoc 防线;C1 的权威人类门禁仍是 owner-only review)。 +# 内容源双世界(ADR-0053;ADR-0085 家园单仓化后唯一世界):archive/adr/INDEX.yaml(索引随正本同居)存在 → +# decisions/ 只剩同名墓碑(无实质结构),实体性改验 INDEX 指向的 archive 仓正本 +# (字节保真原件);INDEX 404(迁移前/回滚)→ 旧逻辑对 decisions/ 本体验证。 +ADR_RE='ADR-[0-9]{4}' +# 实体性判定(评审项:size 字节数可被空白/注释/填充绕过——100B 阈值挡不住 +# RB-D5 意义上的空壳):拉取被引 ADR 全文做结构校验,H1 编号行、status 行、 +# 背景/决策章节齐备且决策节有正文才判"有实质决策记录";同名多文件(ADR-0011 +# 先例)任一满足即通过;读取失败 fail-closed。按编号缓存(同批多 PR 引用同一 +# ADR 只拉一次)。 +declare -A ADR_VERDICT_CACHE +adr_substantive() { # $1=四位编号 → stdout: missing|ok|shell|unreadable + local num="$1" matches apath content decoded verdict + [[ -n "${ADR_VERDICT_CACHE[$num]:-}" ]] && { echo "${ADR_VERDICT_CACHE[$num]}"; return; } + if [[ -n "${ADR_INDEX_MODE:-}" ]]; then + # 索引世界(ADR-0053):编号→archive 正本路径(可多条:ADR-0011 双档先例) + matches=$(awk -v n="$num" '$1 == n {print $2}' <<<"$ADR_INDEX_MAP") + else + matches=$(jq -r --arg p "ADR-${num}-" '.[] | select((.name | startswith($p)) and (.type == "file")) | .path' <<<"$ADR_DIR_LISTING") + fi + if [[ -z "$matches" ]]; then + verdict="missing" + else + verdict="unreadable" + while IFS= read -r apath; do + [[ -n "$apath" ]] || continue + if [[ -n "${ADR_INDEX_MODE:-}" ]]; then + # 索引世界:archive raw 正本(公开仓,字节保真原件)。raw 在 runner 上有 + # 瞬时拒连抖动(2026-08-24 实测整批 unreadable)——重试 3 次后回退 + # contents API(api.github.com 通道稳定),双通道皆失败才按不可判定处理。 + decoded=$(curl -sSf --retry 3 --retry-delay 2 --max-time 20 "https://raw.githubusercontent.com/$ORG/archive/main/${apath}" 2>/dev/null || true) + if [[ -z "$decoded" ]]; then + _c=$(api "https://api.github.com/repos/$ORG/archive/contents/${apath}" 2>/dev/null | jq -r '.content // empty' 2>/dev/null || true) + [[ -n "$_c" ]] && decoded=$(base64 -d <<<"$_c" 2>/dev/null || true) + fi + else + content=$(api "https://api.github.com/repos/$ORG/archive/contents/$apath" | jq -r '.content // empty') + [[ -z "$content" ]] && continue + decoded=$(base64 -d <<<"$content" 2>/dev/null || true) + fi + [[ -z "$decoded" ]] && continue + if grep -qE "^#[[:space:]]*ADR-${num}([^0-9]|$)" <<<"$decoded" \ + && grep -qE "^-[[:space:]]*(status|状态):[[:space:]]*[^[:space:]]" <<<"$decoded" \ + && grep -qE "^##[[:space:]]*背景" <<<"$decoded" \ + && grep -qE "^##[[:space:]]*决策" <<<"$decoded" \ + && [[ -n "$(sed -n '/^##[[:space:]]*决策/,$p' <<<"$decoded" | tail -n +2 | sed 's/[[:space:]#*-]//g')" ]]; then + verdict="ok"; break + fi + [[ "$verdict" == "unreadable" ]] && verdict="shell" + done <<<"$matches" + fi + ADR_VERDICT_CACHE[$num]="$verdict" + echo "$verdict" +} +# 独立 7 天窗口(不用 §8 的 SINCE——那是 policy_effective 起算的直推检测窗口, +# 而 ADR 引用后验须覆盖 policy 生效前已合并、引用了伪造 ADR 的 PR) +ADR_SINCE=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) +ADR_DIR_LISTING=$(api "https://api.github.com/repos/$ORG/archive/contents/adr") +if ! jq -e 'type == "array"' <<<"$ADR_DIR_LISTING" >/dev/null 2>&1; then + drift "ADR 真源 archive/adr 读取失败,引用存在性无法后验(fail-closed,ADR-0085): $(jq -r '.message // "非数组"' <<<"$ADR_DIR_LISTING" 2>/dev/null || echo 传输失败)" +else + # 墓碑索引探测(ADR-0053):200 → 索引世界(map:编号→archive 正本路径); + # 404 → 旧世界;其他失败 → 报漂移(fail-closed:检测器失明不得伪装无漂移), + # 并降级旧世界继续跑完本节(漂移行已置红,后续节不受影响)。 + ADR_INDEX_MODE="" + ADR_INDEX_MAP="" + ADR_INDEX_JSON=$(api "https://api.github.com/repos/$ORG/archive/contents/adr/INDEX.yaml") + if jq -e '.content' <<<"$ADR_INDEX_JSON" >/dev/null 2>&1; then + ADR_INDEX_MAP=$(base64 -d <<<"$(jq -r '.content' <<<"$ADR_INDEX_JSON")" 2>/dev/null | awk ' + /^[[:space:]]*-[[:space:]]*number:/ { n=$3; gsub(/[^0-9]/, "", n); cur=sprintf("%04d", n+0); ap="" } + /^[[:space:]]*archive_path:/ { + line=$0; sub(/^[[:space:]]*archive_path:[[:space:]]*/, "", line); gsub(/"/, "", line) + if (cur != "" && line != "") { print cur " " line; cur="" } + }') + if [[ -n "$ADR_INDEX_MAP" ]] && grep -qE '^[0-9]{4} adr/.+\.md$' <<<"$ADR_INDEX_MAP"; then + ADR_INDEX_MODE=1 + ok "adr-index(ADR-0053 索引世界:$(wc -l <<<"$ADR_INDEX_MAP") entries,实体性改验 archive 正本)" + else + drift "decisions/INDEX.yaml 存在但解析为空/畸形——索引世界不可判定(fail-closed,按旧世界降级继续)" + fi + elif ! grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"' <<<"$ADR_INDEX_JSON"; then + drift "decisions/INDEX.yaml 探测失败(非 404 的 API 错误)——索引世界判定失败(fail-closed,按旧世界降级继续)" + fi + ADR_FILES=$(jq -r '.[].name' <<<"$ADR_DIR_LISTING") + GHOST=0 + for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + PRSLIST=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=closed&sort=updated&direction=desc&per_page=30") + if ! jq -e 'type == "array"' <<<"$PRSLIST" >/dev/null 2>&1; then + drift "repo '$r' PR 列表拉取失败,ADR 引用后验跳过(fail-closed)" + GHOST=1 + continue + fi + while IFS=$'\t' read -r pnum title body; do + [[ -n "$pnum" ]] || continue + for ref in $(printf '%s\n%s\n' "$title" "$body" | grep -oE "$ADR_RE" | sort -u); do + num="${ref#ADR-}" + case "$(adr_substantive "$num")" in + ok) : ;; + missing) + if [[ -n "$ADR_INDEX_MODE" ]]; then + where="INDEX entries 无此编号" + else + where="archive/adr/ 无 ADR-${num}-*.md" + fi + drift "repo '$r' PR#$pnum 引用幽灵 ADR ${ref}(${where}——C1 变更的决策背书不成立)" + GHOST=1 ;; + shell) + drift "repo '$r' PR#$pnum 引用空壳 ADR ${ref}(正本存在但缺实质结构:H1 编号行/status/背景/决策章节须齐备且决策节有正文——红队 RB-D5:字节数填充不再能绕过)" + GHOST=1 ;; + unreadable) + drift "repo '$r' PR#$pnum 引用的 ADR ${ref} 全部同名文件内容读取失败,实体性无法判定(fail-closed)" + GHOST=1 ;; + esac + done + done < <(jq -r --arg since "$ADR_SINCE" \ + '.[] | select(.merged_at != null and .merged_at >= $since) | [(.number|tostring), (.title // ""), (.body // "")] | @tsv' <<<"$PRSLIST") + done + [[ $GHOST -eq 0 ]] && ok "adr-reference-substantive(窗口内合并 PR 的 ADR 引用全部真实且有实质决策结构)" +fi + +# ---------- 11. CI-Workflows 大版本指针完整性(供应链,红队 #6-A / ADR-0016 决策 3)---------- +# 全部业务仓 gate 引用 CI-Workflows@v1 浮动指针;release-tags ruleset 的 admin bypass +# 使指针可被强移(改变所有业务仓实际执行的 CI 内容)。声明不变式(CI-Workflows README +# 版本策略):vN 恒指向最高 vN.x.y 的 commit。本节每日校验——admin 强移指针 24h 内检出, +# 不可见通道变可检测。附注 tag 指向 tag 对象(非 commit),须解引用后比对。 +# 凭据说明(评审项):本节与 §1-§10 共用 GOVERNANCE_TOKEN——AGENTS.md 声明的 +# drift-check 接口即 "GH_TOKEN= bash governance/drift-check.sh"(组织级 +# 检测 rulesets/admin/PR 清单本就需要 org 权限,token 已在 job 内);本节只读公开仓 +# CI-Workflows 的 refs,复用不扩大暴露面。工作流仅 schedule/dispatch 于可信 main +# 运行,脚本不受 PR 控制(与 gate.yml 不向 PR 上下文注入 org secret 同一威胁模型)。 +# 分页聚合(评审项:单页请求在 tag 数超页容量后会拿旧集合比较出假绿): +# 逐页拉取并验证每页均为数组,任一页失败即 fail-closed,不用部分结果继续比较。 +CW_REFS="[]" +CW_PAGE_FAIL=0 +CW_PAGE=1 +while :; do + CW_PAGE=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/matching-refs/tags/?per_page=100&page=$CW_PAGE") + if ! jq -e 'type == "array"' <<<"$CW_PAGE" >/dev/null 2>&1; then + CW_PAGE_FAIL=1 + break + fi + CW_REFS=$(jq --argjson acc "$CW_REFS" '. as $p | $acc + $p' <<<"$CW_PAGE") + [[ "$(jq 'length' <<<"$CW_PAGE")" -lt 100 ]] && break + CW_PAGE=$((CW_PAGE+1)) + [[ $CW_PAGE -gt 50 ]] && { CW_PAGE_FAIL=1; break; } # >5000 tag 视为异常,fail-closed +done +if [[ $CW_PAGE_FAIL -ne 0 ]]; then + drift "CI-Workflows tag refs 分页拉取失败(第 $CW_PAGE 页非数组/超界),大版本指针完整性无法校验(fail-closed,不用部分结果比较)" +else + # 解引用到 commit SHA(轻量 tag 直指 commit 零额外请求;附注 tag 走 tag 对象一跳) + cw_commit_sha() { # $1=tag 名( refs/tags/ 去前缀) + local row obj type sha + row=$(jq -c --arg r "refs/tags/$1" '.[] | select(.ref == $r)' <<<"$CW_REFS") + [[ -z "$row" || "$row" == "null" ]] && { echo ""; return; } + obj=$(jq -c '.object' <<<"$row") + type=$(jq -r '.type' <<<"$obj"); sha=$(jq -r '.sha' <<<"$obj") + if [[ "$type" == "tag" ]]; then + api "https://api.github.com/repos/$ORG/CI-Workflows/git/tags/$sha" | jq -r '.object.sha // empty' + else + echo "$sha" + fi + } + CW_NAMES=$(jq -r '.[].ref | sub("^refs/tags/"; "")' <<<"$CW_REFS") + while IFS= read -r ptr; do + [[ -n "$ptr" ]] || continue + N="${ptr#v}" + HIGHEST=$(grep -E "^v${N}\.[0-9]+\.[0-9]+$" <<<"$CW_NAMES" | sort -V | tail -1) + PTR_SHA=$(cw_commit_sha "$ptr") + if [[ -z "$HIGHEST" ]]; then + drift "CI-Workflows 指针 tag '$ptr' 存在但无任何 v${N}.x.y 具体版本 tag——指针失去锚点(发布流程漏步,README 版本策略)" + elif [[ -z "$PTR_SHA" ]]; then + drift "CI-Workflows 指针 '$ptr' 解引用失败(fail-closed)" + else + HIGH_SHA=$(cw_commit_sha "$HIGHEST") + if [[ "$PTR_SHA" == "$HIGH_SHA" ]]; then + ok "CI-Workflows 指针 '$ptr' == $HIGHEST(${PTR_SHA:0:7})" + else + drift "CI-Workflows 指针 '$ptr'(${PTR_SHA:0:7})≠ 最高版本 $HIGHEST(${HIGH_SHA:0:7})——指针被强移或发布流程漏步(不变式 vN==最高 vN.x.y,红队 #6-A)" + fi + fi + done < <(grep -E '^v[0-9]+$' <<<"$CW_NAMES" | sort -V) + # 必需指针存在性(评审项:删除 v1 ≠ "无指针即不适用"——v1 是全部业务仓 gate 的 + # 供应链入口(REPOS.yaml CI-Workflows role:"业务仓引用 @v1"),指针被删= + # admin bypass 绕过发布流程,必须报漂移。v2+ 指针出现后由上方循环自动纳入 + # 锚点/一致性校验;必需集合当前只声明 v1——v2 落地时随 ADR 扩充此常量。) + CW_REQUIRED_POINTERS="v1" + REQ_MISSING=0 + for req in $CW_REQUIRED_POINTERS; do + if ! grep -qx "$req" <<<"$CW_NAMES"; then + drift "CI-Workflows 必需大版本指针 '$req' 缺失(业务仓 @v1 供应链入口被删——红队 #6-A:release-tags ruleset admin bypass 通道)" + REQ_MISSING=1 + fi + done + [[ $REQ_MISSING -eq 0 ]] && ok "CI-Workflows 必需大版本指针存在($CW_REQUIRED_POINTERS)" +fi + +# ---------- 12. required check 活体存在性(P1-4,ADR-0034;in-flight 防误报修订)---------- +# 文本对账 ≠ 生效验证:ruleset JSON 完全正确的同时,required check 字符串精确 +# 匹配可能实际为空(job 改名 / workflow 重构)→ "零 required check" → PR 裸奔。 +# 判据:每个受管仓最近更新的至多 3 个已合并/打开 PR 的 head 上,必须存在每个 +# required check 名(从 rulesets/*.json 派生——单一真源)的 check run 且 +# conclusion 非空;无 PR 活动时退化为默认分支 HEAD。修订(2026-08-20 实测误报 +# mutual:三连新开 PR 的 CI 起飞窗口内 gate 尚未报 conclusion,被误判缺失): +# - CI 未完结(任一 check run 非 completed)的 head 不构成任何证据——既不算 +# "缺 gate"也不算"有 gate"(防起飞窗口误报);全部候选 in-flight 时显式 +# SKIP(非漂移,下轮复核) +# - 关闭未合并的 PR 不采样(废弃实验分支的残缺 CI 不构成证据) +# - 已完结 CI 的 PR head 缺 gate 仍照报(PR 群体缺 required check = CI 结构性 +# 异常或改名注入——T1 的检测对象;main HEAD 仅在无 PR 活动时作退化载体, +# 不作为豁免来源)。查询失败维持 fail-closed。 +# IR-0002(spec PR#152)(a)/(b) 形态区分(2026-08-21):org-gate 是 PR/merge_group +# 触发的 required workflow(org-gate.yml 的 on:),其活体证据只能来自 ruleset +# 生效后的 PR head——push-only / 生效前 PR 的 head 缺失属 (a)「从未接入」 +# (OK/INFO 计数,非漂移);仅 (b) 生效后 PR head 缺失才报裸奔。 +# 生效时刻 = org-required-workflows ruleset 创建时刻(API 实测)。 +ORG_GATE_EFFECTIVE="2026-08-20T07:43:21Z" + +# @ir0002-s12-classify-begin —— IR-0002(spec PR#152)(a)/(b) 缺失形态分类器。 +# governance/tests/test-ir0002.sh 按本标记对提取函数体做 fixture 自测((b) 检出 +# 灵敏度是 INV-4 铁律:改动本函数必须连带跑自测,红即不得合并)。 +# 入参:$1=repo $2=check 名 $3=QUERY_FAIL(0/1) $4=HAS_PR_ACTIVITY(0/1) +# $5=PRS_RECENT(PR 数组 JSON)$6=org-gate 生效 ISO 时戳 $7=N_CONCL(采样数) +# 返回:0=(a) 待接入(OK 行,非漂移) 1=(b) 裸奔(drift 报警) 2=fail-closed(drift 报警) +s12_classify() { + local r="$1" ctx="$2" QUERY_FAIL="$3" HAS_PR_ACTIVITY="$4" PRS_RECENT="$5" eff="$6" n_concl="$7" + if [[ $QUERY_FAIL -eq 1 ]]; then + drift "repo '$r' check-runs 查询失败,required check '$ctx' 活体无法验证(fail-closed)" + return 2 + elif [[ "$ctx" == "org-gate" ]] && ! jq -e --arg eff "$eff" \ + '[.[] | select((.state == "open" or .merged_at != null) and (.updated_at >= $eff))] | length > 0' \ + <<<"$PRS_RECENT" >/dev/null 2>&1; then + # IR-0002 (a) 形态:org-gate ruleset 生效后该仓无 PR 活动——从未接入, + # 非裸奔(待接入清单;本地 gate 的活体验证不受影响,照常执行) + echo "OK required-check-live '$r':'$ctx' 待接入(ruleset 生效后无 PR 活动——IR-0002 (a) 形态)" + return 0 + elif [[ "${HAS_PR_ACTIVITY:-1}" -eq 0 ]]; then + # IR-0002 (a) 泛化:无 PR 仓的 degenerate 采样下任意 required check 缺失= + # 从未接线(如新建 L1 记忆层仓无本地 gate workflow)——PR 面仍受 org + # ruleset 的 org-gate 保护;接线后自然转绿。有 PR 的仓不适用(保 (b) 检出) + echo "OK required-check-live '$r':'$ctx' 待接入(无 PR 活动,degenerate 采样——IR-0002 (a) 形态)" + return 0 + else + drift "repo '$r' required check '$ctx' 活体缺失:最近 $n_concl 个已完结 CI 的 head 均无该 check run——job 改名或 workflow 重构?裸奔窗口已开启(ADR-0034 §12;IR-0002 (b) 形态)" + return 1 + fi +} +# @ir0002-s12-classify-end + +REQ_CHECKS=$(jq -rs '[.[].rules[]? | select(.type == "required_status_checks") + | .parameters.required_status_checks[].context] | unique | .[]' "$DIR"/rulesets/*.json) +[[ -n "$REQ_CHECKS" ]] || { echo "FATAL: rulesets 未声明任何 required check——§12 活体验证失去判据"; exit 2; } +epoch_of() { date -u -d "$1" +%s; } # ISO8601 → epoch(runner GNU date) +S12_PENDING=0 # §12 全仓 (a) 待接入累计(IR-0002 spec AC-3 汇总信息行) +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + HAS_PR_ACTIVITY=1 # 有(已合并/打开的)PR head 可采样=强证据形态 + PRS_RECENT=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=all&sort=updated&direction=desc&per_page=20") + if jq -e 'type == "array"' <<<"$PRS_RECENT" >/dev/null 2>&1; then + HEADS=$(jq -r '[.[] | select(.state == "open" or .merged_at != null) | .head.sha][0:3] | .[]' <<<"$PRS_RECENT") + if [[ -z "$HEADS" ]]; then + # 无(已合并/打开的)PR 活动 → 退化为默认分支 HEAD(§12 采样处于最弱证据 + # 形态:degenerate 模式标记——缺失判定走 IR-0002 (a) 待接入分支) + HAS_PR_ACTIVITY=0 + DBR=$(api "https://api.github.com/repos/$ORG/$r" | jq -r '.default_branch // "main"') + HEADS=$(api "https://api.github.com/repos/$ORG/$r/git/ref/heads/$DBR" | jq -r '.object.sha // empty') + [[ -n "$HEADS" ]] || { drift "repo '$r' 无 PR 活动且默认分支 HEAD 不可读,活体验证无载体(fail-closed)"; continue; } + fi + else + drift "repo '$r' PR 清单拉取失败,required check 活体验证无法执行(fail-closed)" + continue + fi + # 采样修订(#85 T1 首轮实测漏检教训:并发连开 PR 会把改名注入头挤出「前3」, + # 而 in-flight 头白白占用采样位):先跳过零-check/in-flight,再收集最近 + # COLLECT_N=3 个「已完结」head(最多扫描 10 个候选)作为活体样本。 + COLLECT_N=3 + CONCL_SHAS="" + N_CONCL=0 + N_SEEN=0 + QUERY_FAIL=0 + SAW_INFLIGHT=0 + while IFS= read -r sha; do + [[ -n "$sha" ]] || continue + { [[ $N_CONCL -ge $COLLECT_N ]] || [[ $N_SEEN -ge 10 ]]; } && break + N_SEEN=$((N_SEEN+1)) + CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") + if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then + QUERY_FAIL=1; continue + fi + TOTAL=$(jq -r '.total_count // 0' <<<"$CRS") + [[ "$TOTAL" -eq 0 ]] && continue + # CI 未完结的 head:不构成证据也不占采样位(防起飞窗口误报——gate job 在 + # 依赖图末端,新开 PR 的前几分钟 conclusion 必为 null) + if jq -e '[.check_runs[] | select(.status != "completed")] | length > 0' <<<"$CRS" >/dev/null 2>&1; then + SAW_INFLIGHT=1; continue + fi + N_CONCL=$((N_CONCL+1)) + CONCL_SHAS="$CONCL_SHAS$sha"$' +' + done <<<"$HEADS" + if [[ $N_CONCL -eq 0 ]]; then + if [[ $QUERY_FAIL -eq 1 ]]; then + drift "repo '$r' check-runs 查询失败,required check 活体无法验证(fail-closed)" + continue + fi + echo "SKIP required-check-live '$r'(候选 head 全部 CI in-flight/零 check,本轮无法判定——非漂移,下轮复核)" + continue + fi + LIVE_MISS=0 + REPO_PENDING=0 # 本仓 (a) 待接入计数(IR-0002 spec AC-3:待接入须计数可见) + for ctx in $REQ_CHECKS; do + FOUND=0 + while IFS= read -r sha; do + [[ -n "$sha" ]] || continue + CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") + jq -e --arg c "$ctx" '[.check_runs[] | select(.name == $c and .conclusion != null)] | length > 0' <<<"$CRS" >/dev/null 2>&1 && { FOUND=1; break; } + done <<<"$CONCL_SHAS" + if [[ $FOUND -ne 1 ]]; then + rc=0 + s12_classify "$r" "$ctx" "$QUERY_FAIL" "${HAS_PR_ACTIVITY:-1}" "$PRS_RECENT" "$ORG_GATE_EFFECTIVE" "$N_CONCL" || rc=$? + case $rc in + 0) REPO_PENDING=$((REPO_PENDING+1)); S12_PENDING=$((S12_PENDING+1)) ;; + 1) LIVE_MISS=1 ;; + 2) : ;; + esac + fi + done + [[ $REPO_PENDING -gt 0 ]] \ + && echo "INFO required-check-live '$r':$REPO_PENDING 项待接入计入汇总(IR-0002 (a)——不阻断)" \ + || true + [[ $LIVE_MISS -eq 0 ]] && ok "required-check-live '$r'(最近 $N_CONCL 个已完结 head 上 required check 齐备)" +done +# IR-0002 spec AC-3:§12 汇总信息行——(a) 待接入非漂移但必须计数可见(防"静默豁免" +# 演变为检测盲区;§17 协议块对账同款"可见即治理"原则)。零待接入时也打 OK 行, +# 让"本节确实跑过"成为可审计事实。 +if [[ ${S12_PENDING:-0} -gt 0 ]]; then + echo "INFO §12 required-check 活体汇总:待接入 $S12_PENDING 项(IR-0002 (a) 形态——ruleset 生效后无 PR 活动,接线后自然转绿;非漂移)" +else + ok "§12 required-check 活体汇总:0 项待接入(全部受管仓要么齐备要么显式 SKIP)" +fi + +# ---------- 13. PR liveness 侦测(P1-4,ADR-0034)---------- +# 治理不漂移但流水线死了的三类形态(#81 §6——无人值守下卡死 PR 是最隐形的 +# 人类瓶颈):(a) auto-merge 已设置但 > 阈值无进展;(b) check 停留 queued/ +# in_progress 超 > 阈值;(c) PR 创建超阈值且 head 上零 check run(应有而无)。 +# 命中即走 GM-1 既有漂移 issue 通道。阈值:expected-state pr_liveness_hours, +# 环境变量 PR_LIVENESS_HOURS 可覆盖(dispatch input liveness_hours 透传,注入测试用)。 +LIVENESS_H="${PR_LIVENESS_HOURS:-$(jq -r '.pr_liveness_hours // 4' "$EXPECTED")}" +LIVENESS_S=$(( LIVENESS_H * 3600 )) +STUCK_TOTAL=0 +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + OPEN_PRS=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=open&per_page=30") + jq -e 'type == "array"' <<<"$OPEN_PRS" >/dev/null 2>&1 \ + || { drift "repo '$r' open PR 清单拉取失败,liveness 侦测无法执行(fail-closed)"; continue; } + STUCK=0 + while IFS=$'\t' read -r pnum created updated headsha has_am; do + [[ -n "$pnum" ]] || continue + AGE_UPD=$(( NOW_EPOCH - $(epoch_of "$updated") )) + AGE_CRE=$(( NOW_EPOCH - $(epoch_of "$created") )) + if [[ "$has_am" == "true" && $AGE_UPD -gt $LIVENESS_S ]]; then + drift "repo '$r' PR#$pnum auto-merge 已开启但 ${LIVENESS_H}h 无进展(updated ${AGE_UPD}s 前)——卡死侦测 (a):查 required check 状态/分支冲突(ADR-0034 §13)" + STUCK=1; continue + fi + CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$headsha/check-runs?per_page=100") + if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then + drift "repo '$r' PR#$pnum head check-runs 查询失败,liveness 无法验证(fail-closed)" + STUCK=1; continue + fi + N_RUNS=$(jq '.check_runs | length' <<<"$CRS") + if [[ "$N_RUNS" -eq 0 && $AGE_CRE -gt $LIVENESS_S ]]; then + drift "repo '$r' PR#$pnum 创建 ${AGE_CRE}s 且 head 零 check run——卡死侦测 (c):应有而无(workflow 未触发/被改名,ADR-0034 §13)" + STUCK=1; continue + fi + # pending 超龄判定(b):shell 循环做日期运算(jq 无日期运算) + while IFS=$'\t' read -r crname crstatus crstart; do + [[ -n "$crname" ]] || continue + [[ "$crstatus" == "queued" || "$crstatus" == "in_progress" ]] || continue + [[ -n "$crstart" ]] || continue + AGE_PEND=$(( NOW_EPOCH - $(epoch_of "$crstart") )) + if [[ $AGE_PEND -gt $LIVENESS_S ]]; then + drift "repo '$r' PR#$pnum check '$crname' 停留 $crstatus 已 ${AGE_PEND}s——卡死侦测 (b):永久 pending(ADR-0034 §13)" + STUCK=1 + fi + done < <(jq -r '.check_runs[] | [.name, .status, (.started_at // "")] | @tsv' <<<"$CRS") + done < <(jq -r '.[] | [(.number|tostring), .created_at, .updated_at, .head.sha, (.auto_merge != null | tostring)] | @tsv' <<<"$OPEN_PRS") + STUCK_TOTAL=$((STUCK_TOTAL+STUCK)) +done +[[ $STUCK_TOTAL -eq 0 ]] && ok "pr-liveness(全部受管仓 open PR 无卡死,阈值 ${LIVENESS_H}h)" + +# ---------- 15. org-required-workflows 钉点完整性(P3-1 #95 / ADR-0046)---------- +# §1 已对账 ruleset JSON 文本(path/ref/repository_id 任何改动即漂移)。本节补盲区: +# ref 为 tag 时,tag 指针被移动(ruleset 文本不变、内容换了)——钉点的 commit 绑定 +# 必须与 expected-state.org_required_workflows.ref_commit 一致;钉点 tag 还必须是 +# CI-Workflows 当前发布不变式(§11:vN==最高 vN.x.y)认可的物。fail-closed。 +ORW_CFG=$(jq -c '.org_required_workflows // empty' "$EXPECTED") +if [[ -n "$ORW_CFG" ]]; then + WANT_RULESET=$(jq -r '.ruleset' <<<"$ORW_CFG") + WANT_REPO_ID=$(jq -r '.repository_id' <<<"$ORW_CFG") + WANT_PATH=$(jq -r '.path' <<<"$ORW_CFG") + WANT_REF=$(jq -r '.ref' <<<"$ORW_CFG") + WANT_COMMIT=$(jq -r '.ref_commit' <<<"$ORW_CFG") + ORW_LIST=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") + ORW_ID=$(jq -r --arg n "$WANT_RULESET" '.[] | select(.name == $n) | .id' <<<"$ORW_LIST" 2>/dev/null | head -1) + if [[ -z "$ORW_ID" || "$ORW_ID" == "null" ]]; then + drift "org-required-workflows ruleset '$WANT_RULESET' 线上不存在——中心审判失效,全部受管仓回落本地 gate(P3-1 枢轴脱离,ADR-0046)" + else + # LIST 端点不含 rules 数组(同 §1 教训)——须拉 detail 才能取 workflows 规则 + ORW_ROW=$(api "https://api.github.com/orgs/$ORG/rulesets/$ORW_ID") + GOT_WF=$(jq -c '.rules[] | select(.type == "workflows") | .parameters.workflows[0] // empty' <<<"$ORW_ROW") + GOT_PATH=$(jq -r '.path // empty' <<<"$GOT_WF") + GOT_REF=$(jq -r '.ref // empty' <<<"$GOT_WF") + GOT_REPO_ID=$(jq -r '.repository_id // empty' <<<"$GOT_WF") + BAD="" + [[ "$GOT_PATH" == "$WANT_PATH" ]] || BAD="$BAD path=$GOT_PATH" + [[ "$GOT_REF" == "$WANT_REF" ]] || BAD="$BAD ref=$GOT_REF" + [[ "$GOT_REPO_ID" == "$WANT_REPO_ID" ]] || BAD="$BAD repository_id=$GOT_REPO_ID" + if [[ -n "$BAD" ]]; then + drift "org-required-workflows 钉点漂移:$BAD(期望 path=$WANT_PATH ref=$WANT_REF repo_id=$WANT_REPO_ID)——审判源被改指(ADR-0046 §15)" + fi + # commit 绑定校验(tag 或直接 SHA;ISSUE-263 W1-C5 改为直接钉 commit SHA) + if [[ "$WANT_REF" =~ ^[0-9a-f]{40}$ ]]; then + # 直接钉 commit SHA:校验该对象在目标仓可达 + COMMIT_OBJ=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/commits/$WANT_REF") + TAGCOMMIT=$(jq -r '.sha // empty' <<<"$COMMIT_OBJ") + else + SHORTREF="${WANT_REF#refs/tags/}" + TAGROW=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/ref/tags/$SHORTREF") + TAGCOMMIT=$(jq -r '.object.sha // empty' <<<"$TAGROW") + fi + if [[ -z "$TAGCOMMIT" ]]; then + drift "org-required-workflows 钉点 $WANT_REF 解引用/校验失败(fail-closed,ADR-0046 §15)" + elif [[ "$TAGCOMMIT" != "$WANT_COMMIT" ]]; then + drift "org-required-workflows 钉点 $WANT_REF 已移动:${TAGCOMMIT:0:8} ≠ 声明 ${WANT_COMMIT:0:8}——审判内容被换(ADR-0046 §15;还原或更新 expected-state)" + elif [[ -n "$BAD" ]]; then + : # 钉点漂移已上报,不再输出 OK 行(避免同段 OK/DRIFT 并存的误导) + else + ok "org-required-workflows 钉点完整($WANT_REF == ${TAGCOMMIT:0:8},path/repository_id 一致)" + fi + fi +fi + +# ---------- 16. 治理标签(state:*/type:* 存在性与形状,W0-C2/ADR-0047)---------- +# expected-state.json#labels.items 为治理标签期望全集。只对账"应有哪些"(新增式), +# 各仓自有业务标签不属治理面、不判漂移;缺失或 color/description 不符 = 漂移。 +# items 为空视为期望状态缺失(fail-closed——治理标签全集被误删不得静默通过)。 +LBL_ITEMS=$(jq -c '.labels.items // []' "$EXPECTED") +LBL_N=$(jq 'length' <<<"$LBL_ITEMS") +if [[ "$LBL_N" -eq 0 ]]; then + drift "expected-state.json#labels.items 为空——治理标签期望状态缺失(fail-closed,ADR-0047)" +else + LBL_DRIFT=0 + for r in $REPOS; do + jq -e --arg r "$r" 'index($r) != null' <<<"$EXCLUDES" >/dev/null && continue + # 分页拉全量标签(>100 标签的仓单页会漏——与 §1 同教训) + RL_TMP=$(mktemp); RL_ERR=0; RL_PAGE=1 + while :; do + CHUNK=$(api "https://api.github.com/repos/$ORG/$r/labels?per_page=100&page=$RL_PAGE") + if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then + CHUNK_MSG=$(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || true) + [[ -z "$CHUNK" ]] && CHUNK_MSG="传输失败(空响应)" + drift "repo '$r' 标签清单拉取失败($CHUNK_MSG)——标签对账跳过(fail-closed)" + LBL_DRIFT=1; RL_ERR=1; break + fi + RL_N=$(jq 'length' <<<"$CHUNK"); [[ "$RL_N" -eq 0 ]] && break + jq -c '.[] | {name, color, description: (.description // "")}' <<<"$CHUNK" >>"$RL_TMP" + [[ "$RL_N" -lt 100 ]] && break + RL_PAGE=$((RL_PAGE+1)) + done + if [[ $RL_ERR -eq 1 ]]; then rm -f "$RL_TMP"; continue; fi + while IFS= read -r want; do + wname=$(jq -r .name <<<"$want"); wcolor=$(jq -r .color <<<"$want"); wdesc=$(jq -r .description <<<"$want") + got=$(jq -r --arg n "$wname" 'select(.name == $n) | "\(.color) \(.description)"' <"$RL_TMP" 2>/dev/null | head -1) + if [[ -z "$got" ]]; then + drift "repo '$r' 缺治理标签 '$wname'(修复:bash governance/apply.sh §7)"; LBL_DRIFT=1 + elif [[ "$got" != "$(printf '%s %s' "$wcolor" "$wdesc")" ]]; then + drift "repo '$r' 标签 '$wname' 形状不符:$got 期望 color=$wcolor(apply.sh §7 对齐)"; LBL_DRIFT=1 + fi + done < <(jq -c '.[]' <<<"$LBL_ITEMS") + rm -f "$RL_TMP" + done + [[ $LBL_DRIFT -eq 0 ]] && ok "治理标签全集($LBL_N 项 × 受管仓)一致" +fi + +# ---------- 17. 统一入口协议块一致性(W1-C3 #166 / ADR-0055,宪法 §11/§4D)---------- +# REPOS.yaml 中 entry_protocol: true 的仓:其 main 的 AGENTS.md 须携带与 +# template-service main(统一下发真源)逐字节一致的协议块—— +# 提取 标记间内容(含标记)比对。 +# 缺失标记/内容不一致/拉取失败 = 漂移(fail-closed:检测器失明不得伪装通过)。 +# 产品仓 rollout(7 个业务仓下发协议块)是后续 fleet 小卡——届时逐仓加 +# entry_protocol: true 即纳入本节管辖。 +PROTO_OK=0 +PROTO_REPOS="" +# REPOS.yaml 是 YAML——jq 直读必失败且被吞掉=假绿(§7 同教训)。§17 自解析 +# (§7 同款 python3+pyyaml→JSON,不信任继承的 REPO_MAP——调用方 env 注入可 +# 重定向对账对象);解析不可用或 entry_protocol 清单为空均 fail-closed。 +PROTO_MAP="" +if python3 -c 'import yaml' 2>/dev/null; then + PROTO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' \ + "$DIR/REPOS.yaml" 2>/dev/null || true) +fi +if [[ -n "$PROTO_MAP" ]]; then + PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' <<<"$PROTO_MAP") + if [[ -z "$PROTO_REPOS" ]]; then + drift "REPOS.yaml 无 entry_protocol 仓——协议块对账失去对象(申报丢失=漂移,fail-closed,ADR-0055 §17)" + PROTO_OK=1 + fi +else + drift "REPOS.yaml 未解析(缺 python3+pyyaml)——协议块对账不可执行(fail-closed,ADR-0055 §17)" + PROTO_OK=1 +fi +proto_block() { # 提取协议块(标记间内容,含首尾标记行)——起止标记任一缺失输出空 + # (缺末标记时不得输出“半块”:半块≠空,会让比对产生误判——fail-closed) + local t + t=$(cat) + if [[ "$t" == *""* ]]; then + awk '//{f=1} f{print} //{exit}' <<<"$t" + fi +} +CANON_RAW=$(api "https://raw.githubusercontent.com/$ORG/template-service/main/AGENTS.md" 2>/dev/null || true) +CANON_BLOCK="" +if [[ -z "$CANON_RAW" || "$CANON_RAW" == "404:"* ]]; then + drift "template-service main AGENTS.md 拉取失败(协议块真源不可读——fail-closed)" + PROTO_OK=1 +else + CANON_BLOCK=$(proto_block <<<"$CANON_RAW") + if [[ -z "$CANON_BLOCK" ]]; then + drift "template-service main AGENTS.md 缺协议块或标记不完整(宪法 §4D:块由 template-service 统一下发)" + PROTO_OK=1 + fi +fi +PROTO_N=0 +for r in $PROTO_REPOS; do + PROTO_N=$((PROTO_N+1)) + [[ "$r" == "template-service" ]] && continue # 真源自身即 canon(同 URL 已验) + RAW=$(api "https://raw.githubusercontent.com/$ORG/$r/main/AGENTS.md" 2>/dev/null || true) + if [[ -z "$RAW" || "$RAW" == "404:"* ]]; then + drift "repo '$r' AGENTS.md 拉取失败(协议块对账跳过——fail-closed,ADR-0055 §17)" + PROTO_OK=1 + continue + fi + BLOCK=$(proto_block <<<"$RAW") + if [[ -z "$BLOCK" ]]; then + drift "repo '$r' AGENTS.md 缺协议块标记或标记不完整(entry_protocol 已申报——宪法 §11 统一入口)" + PROTO_OK=1 + continue + fi + if [[ "$BLOCK" != "$CANON_BLOCK" ]]; then + # 双 here-string 会互相覆盖 stdin(SC2261,diff 拿不到操作数)——用进程替换 + drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <(printf '%s\n' "$CANON_BLOCK") <(printf '%s\n' "$BLOCK") 2>&1 | head -3 | tr '\n' ' ' | cut -c1-160))" + PROTO_OK=1 + fi +done +[[ $PROTO_OK -eq 0 ]] && ok "统一入口协议块一致(真源 template-service × $PROTO_N 个 entry_protocol 仓,逐字节比对)" + +# ---------- 18. holdout 隔离断言(DECISION-02:App 安装差异隔离,W1-C4/ADR-0056,ADR-0080 修订)---------- +# 试卷层 holdout 的读隔离不靠保密(公开仓,ADR-0056/DECISION-02),靠三条: +# - cloudbrid-agent App 不安装到该仓(agent 的组织级凭据通道物理不可达); +# - 验证者 APP 可安装,但仅作为测试/验证路径写入主体(ADR-0080); +# - 泄漏诱饵周检(.github 仓 holdout-canary-sweep,宪法 §6/§11)。 +# 本节断言 App 挂载差异: +# - cloudbrid-agent installation 的仓清单含 holdout = P0 漂移; +# - 验证者 APP installation 的仓清单含 holdout = OK(合法); +# - 验证者 APP 尚未安装 → INFO/SKIP(实施阶段再断言)。 +# 正向对照防检测器失明假绿(§4 同思想):cloudbrid-agent 的 installation 清单必须 +# 非空且含 .github——连 .github 都不在清单 = 端点读法错/权限变了 → 按漂移报错, +# 绝不把"看不见"当"没有"。API 任何失败 = fail-closed 报漂移。 +# §17 编号预留给并行卡 W1-C3(多代理并行修改本文件,编号互不占段)。 +# 端点注(2026-08-21 实测):installation id 走 /orgs//installations(admin +# 端点,new-repo-init.sh §3 同款)——/user/installations 要求 GitHub App 令牌, +# 经典 PAT 一律 403;仓清单走 /user/installations//repositories(经典 PAT 可读)。 +HOLDOUT_REPO="holdout" + +# 拉取一次 org 安装列表,供 cloudbrid-agent 与验证者 APP 共用。 +HOLDOUT_INST=$(api "https://api.github.com/orgs/$ORG/installations?per_page=100") +if ! jq -e 'type == "object" and (.installations | type == "array")' <<<"$HOLDOUT_INST" >/dev/null 2>&1; then + drift "holdout 隔离断言失败:org installations 拉取失败(fail-closed——检测器失明不得伪装通过,ADR-0056 §18): $(jq -r '.message // "非 JSON 响应"' <<<"$HOLDOUT_INST" 2>/dev/null || echo 传输失败)" +else + # 辅助函数:拉取指定 installation 下的全部仓名;stdout 为排序去重后的仓名列表; + # 失败时返回非 0(stdout 为空,不应使用)。 + holdout_install_repos() { + local iid="$1" tmp fail page n chunk + tmp=$(mktemp) + fail=0 + page=1 + while :; do + chunk=$(api "https://api.github.com/user/installations/$iid/repositories?per_page=100&page=$page") + if ! jq -e 'type == "object" and (.repositories | type == "array")' <<<"$chunk" >/dev/null 2>&1; then + fail=1; break + fi + n=$(jq '.repositories | length' <<<"$chunk") + [[ "$n" -eq 0 ]] && break + jq -r '.repositories[].name' <<<"$chunk" >>"$tmp" + [[ "$n" -lt 100 ]] && break + page=$((page+1)) + done + if [[ $fail -ne 0 ]]; then + rm -f "$tmp" + return 1 + fi + sort -u "$tmp" + rm -f "$tmp" + } + + # cloudbrid-agent:老 DECISION-02 主体,必须不挂载 holdout。 + HOLDOUT_APP=$(jq -r .github_app.name "$EXPECTED") + INST_ID=$(jq -r --arg s "$HOLDOUT_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) + if [[ -z "$INST_ID" || "$INST_ID" == "null" ]]; then + drift "app '$HOLDOUT_APP' 的 installation 未找到——holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" + else + INST_REPOS=$(holdout_install_repos "$INST_ID") + if [[ $? -ne 0 ]]; then + drift "installation#$INST_ID 仓清单拉取失败,holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" + else + HOLDOUT_HIT=0 + grep -qx "$HOLDOUT_REPO" <<<"$INST_REPOS" && HOLDOUT_HIT=1 + # 正向对照(防失明):清单非空且含 .github,否则断言结论不可信 + if [[ -z "$INST_REPOS" ]]; then + drift "installation#$INST_ID 仓清单为空——正向对照失败(端点读法或权限已变),不得把看不见当没有(fail-closed,ADR-0056 §18)" + HOLDOUT_HIT=-1 + elif ! grep -qx ".github" <<<"$INST_REPOS"; then + drift "installation#$INST_ID 仓清单不含 .github——正向对照失败(端点读法或权限已变),holdout 隔离结论不可信(fail-closed,ADR-0056 §18)" + HOLDOUT_HIT=-1 + fi + if [[ $HOLDOUT_HIT -eq 1 ]]; then + drift "holdout 出现在 app '$HOLDOUT_APP' installation#$INST_ID 仓清单——P0:cloudbrid-agent 挂上 holdout = 试卷层隔离失效(DECISION-02/ADR-0056 §18;立即在 App 设置页移除该仓访问并追查何时挂载)" + elif [[ $HOLDOUT_HIT -eq 0 ]]; then + ok "holdout 隔离成立(cloudbrid-agent installation#$INST_ID 共 $(grep -c . <<<"$INST_REPOS") 仓不含 holdout;正向对照 .github 在清单)" + fi + fi + fi + + # 验证者 APP(ADR-0080):允许挂载 holdout,作为测试/验证路径写入主体。 + VERIFIER_APP=$(jq -r '.verifier_app.name // empty' "$EXPECTED") + if [[ -n "$VERIFIER_APP" && "$VERIFIER_APP" != "null" ]]; then + VERIFIER_INST_ID=$(jq -r --arg s "$VERIFIER_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) + if [[ -n "$VERIFIER_INST_ID" && "$VERIFIER_INST_ID" != "null" ]]; then + VERIFIER_REPOS=$(holdout_install_repos "$VERIFIER_INST_ID") + if [[ $? -ne 0 ]]; then + drift "验证者 APP '$VERIFIER_APP' installation#$VERIFIER_INST_ID 仓清单拉取失败,无法校验 holdout 挂载合法性(fail-closed,ADR-0080 §18)" + elif grep -qx "$HOLDOUT_REPO" <<<"$VERIFIER_REPOS"; then + ok "holdout 由验证者 APP '$VERIFIER_APP' 合法挂载(installation#$VERIFIER_INST_ID,ADR-0080)" + else + ok "验证者 APP '$VERIFIER_APP' 已安装但未挂载 holdout(ADR-0080 允许,非漂移)" + fi + else + echo "INFO 验证者 APP '$VERIFIER_APP' 尚未安装——holdout 挂载合法性断言跳过(实施阶段再校验,ADR-0080 §18)" + fi + fi +fi + +echo "----------------------------------------" +if [[ $DRIFTS -gt 0 ]]; then + echo "结果: $DRIFTS 项漂移。修复: bash governance/apply.sh 或手动改回" + exit 1 +fi echo "结果: 无漂移,组织配置与 governance/ 落盘一致" \ No newline at end of file diff --git a/governance/expected-state.json b/governance/expected-state.json index 320412b..187f00a 100644 --- a/governance/expected-state.json +++ b/governance/expected-state.json @@ -101,7 +101,9 @@ "CB_APP_ID", "AGENT_APP_SECRET", "GOVERNANCE_TOKEN", - "LLM_API_KEY1" + "LLM_API_KEY1", + "CNB_TOKEN_XUEMEI", + "CNB_TOKEN_P11" ], "github_app": { "name": "cloudbrid-agent", @@ -139,18 +141,16 @@ "repositories": [ ".github", "CI-Workflows", - "agent-registry", "archive", "template-service", - "agent-tools", - "agent-platform", "arbiter", "Shorts_Director", "Script_Writer", "Use-up-Plan", "AI_Web_School", "mutual", - "holdout" + "holdout", + "cnb-bridge" ] }, "direct_push_exemptions": { @@ -213,5 +213,8 @@ "path": ".github/workflows/adversary-gate.yml" } ] + }, + "notes": { + "adr_0085": "ADR-0085:agent-registry/agent-platform/agent-tools 退役(GitHub 归档只读);CNB 双账号 secret 登记(EX-1 三接缝);cnb-bridge 入 verifier 挂载面" } -} +} \ No newline at end of file diff --git a/governance/policy/automation-limits.yaml b/governance/policy/automation-limits.yaml index 447cf4d..8bb196c 100644 --- a/governance/policy/automation-limits.yaml +++ b/governance/policy/automation-limits.yaml @@ -1,77 +1,90 @@ -version: 1 -# ============================================================================ -# automation-limits.yaml —— 无人值守自动化生存护栏(auto-fix 上限 + 额度/成本熔断) -# 真源: 本文件(阈值声明);执行: governance/auto-fix-limit.sh + governance/cost-check.sh -# (.github/workflows/auto-fix-limit.yml / cost-check.yml 周期调用) -# 决策: ADR-0040(P2-8,.github issue #93 / 计划 #81) -# 注: 熔断标志(org 变量 AUTO_MERGE_DISABLED)是运行态标志,刻意不纳入 -# expected-state.json——置位/复位是运行动作而非期望状态,纳入会造成 -# "人工复位后 drift-check 反向改回去"的对账冲突(ADR-0040 后果节)。 -# ============================================================================ - -auto_fix: - # 修复循环上限:PR 的 gate 失败次数达到 max_attempts → 撤 auto-merge + 关 PR + 开 issue - max_attempts: 3 - # 计数对象:required check 名(BP-2,全组织统一 gate) - check_name: gate - # 默认作用域:agent 身份(AG-1 App slug)创建的 open PR;其他 PR 打 opt_in_label 纳入同路径 - # (脚本按 slug 宽容匹配 REST "[bot]" 与 GraphQL "app/" 两种作者登录名形态) - author: cloudbrid-agent - opt_in_label: auto-fix-loop - # 视为一次失败尝试的 check run 结论(取每 commit 最新一次 gate run) - # fail-closed 方向:多计早关优于少计烧额度;非头 commit 上的未完成(null)亦计 - failed_conclusions: [failure, timed_out, startup_failure, cancelled] - # 超限处置标记(打在目标仓 PR 上 / 说明 issue 打在 .github 仓) - exhausted_label: auto-fix-limit-exhausted - issue_label: auto-fix-limit - -cost: - # Actions 分钟月预算(声明值,非账单——全仓公开计费净额 $0,此处防的是失控烧穿速率; - # 当月用量 = /orgs/{org}/settings/billing/usage 按 product=actions & unitType=Minutes 汇总) - actions_minutes: - # BUDGET-03(spec IR-0001 v3,PR#129,owner 已拍板):Actions 分钟本期不设上限 - # ——2026-08-21 曾按旧值 20000 在故障指标上误触 P0 硬停(#149 用量不可知+ - # billing 端点 410),按 spec 明示的 C1 调整路径改为等效无上限大数。 - # 恢复上限=回改本值(ADR-0040 复位流程不变)。 - quota_per_month: 200000000 - warn_pct: 80 # ≥80% 开告警 issue(同日去重,不硬停) - hardstop_pct: 100 # ≥100% 置熔断 + 撤全部 open PR auto-merge + P0 issue - # LLM token 月预算(W2-C3 .github#216,ADR-0062:数据源接入——CI-Workflows - # pipeline/metering 计量账本,按 invoke 聚合的 JSONL hash 链;cost-check 拉取 - # metering-ledger 分支经 metering.py aggregate 按角色档归账。链断/归账失败= - # infra fail-closed(exit 2),不静默归零不盲熔断。pending 形态保留为回滚值 - # (ADR-0040 决策 6);注入通道 COST_LLM_TOKENS_USED 仍优先于真实数据源) - llm_tokens: - quota_per_month: 200000000 - warn_pct: 80 - hardstop_pct: 100 - data_source: ciw-metering - metering: # data_source=ciw-metering 时消费(复用本段现有结构,不新建配置面) - repo: Cloudbird-Software/CI-Workflows - branch: metering-ledger - code_path: pipeline/metering # 归账引擎 metering.py 所在(cost-check.yml sparse checkout) - # verifier 档口径(AC-11):W3-C3 llm-verifier 接入后,verifier run 的 token 账 - # 落盘并与 LLM 响应 usage 字段交叉核对。偏差超出任一阈值 → 该 run 判定作废转人工 - # (非仅告警)。K 与 pivots 为暴露成本旋钮,降档须留痕且不得改动判定语义与阈值。 - # 计量数据源复用上方 ciw-metering(records-*.jsonl role=verifier 切片),本段仅 - # 声明 verifier 档独立阈值与 fail-closed 行为,不新建计量通道。 - verifier: # data_source=ciw-metering role=verifier 时的 fail-closed 阈值 - deviation_pct: 5 # 相对偏差阈值(%),超出即作废 - deviation_abs: 50 # 绝对偏差阈值(tokens),超出即作废 - role: verifier # metering 记录 role 字段过滤值 - fail_closed_action: void_and_escalate - void_reason: "token 账与 metering 偏差超阈值(相对>5% 或绝对>50 tokens)——作废转人工" - # 成本旋钮(BUDGET-01 / AC-11):K 与 pivots 为暴露的成本旋钮,降档须留痕且 - # 不得改动判定语义与阈值。默认取保守高值(ASSUMPTION-03)。 - cost_knobs: - k_default: 3 # 每 criterion 重复评估次数 - pivots_default: 1 # 成本旋钮 pivots(记录用) - downgrade_requires_log: true - downgrade_preserves_semantic: true - -circuit_breaker: - # 熔断标志:org Actions 变量(visibility=all,全仓 workflow/agent 可读) - variable: AUTO_MERGE_DISABLED - # 人工复位:owner PATCH org 变量为 false(或 DELETE)+ 在 P0 issue 留复位评论(留痕); - # cost-check 确认"变量已复位且用量 <100%"后自动关闭 P0 issue - reset_by: randypanding +version: 1 +# ============================================================================ +# automation-limits.yaml —— 无人值守自动化生存护栏(auto-fix 上限 + 额度/成本熔断) +# 真源: 本文件(阈值声明);执行: governance/auto-fix-limit.sh + governance/cost-check.sh +# (.github/workflows/auto-fix-limit.yml / cost-check.yml 周期调用) +# 决策: ADR-0040(P2-8,.github issue #93 / 计划 #81) +# 注: 熔断标志(org 变量 AUTO_MERGE_DISABLED)是运行态标志,刻意不纳入 +# expected-state.json——置位/复位是运行动作而非期望状态,纳入会造成 +# "人工复位后 drift-check 反向改回去"的对账冲突(ADR-0040 后果节)。 +# ============================================================================ + +auto_fix: + # 修复循环上限:PR 的 gate 失败次数达到 max_attempts → 撤 auto-merge + 关 PR + 开 issue + max_attempts: 3 + # 计数对象:required check 名(BP-2,全组织统一 gate) + check_name: gate + # 默认作用域:agent 身份(AG-1 App slug)创建的 open PR;其他 PR 打 opt_in_label 纳入同路径 + # (脚本按 slug 宽容匹配 REST "[bot]" 与 GraphQL "app/" 两种作者登录名形态) + author: cloudbrid-agent + opt_in_label: auto-fix-loop + # 视为一次失败尝试的 check run 结论(取每 commit 最新一次 gate run) + # fail-closed 方向:多计早关优于少计烧额度;非头 commit 上的未完成(null)亦计 + failed_conclusions: [failure, timed_out, startup_failure, cancelled] + # 超限处置标记(打在目标仓 PR 上 / 说明 issue 打在 .github 仓) + exhausted_label: auto-fix-limit-exhausted + issue_label: auto-fix-limit + +cost: + # Actions 分钟月预算(声明值,非账单——全仓公开计费净额 $0,此处防的是失控烧穿速率; + # 当月用量 = /orgs/{org}/settings/billing/usage 按 product=actions & unitType=Minutes 汇总) + actions_minutes: + # BUDGET-03(spec IR-0001 v3,PR#129,owner 已拍板):Actions 分钟本期不设上限 + # ——2026-08-21 曾按旧值 20000 在故障指标上误触 P0 硬停(#149 用量不可知+ + # billing 端点 410),按 spec 明示的 C1 调整路径改为等效无上限大数。 + # 恢复上限=回改本值(ADR-0040 复位流程不变)。 + quota_per_month: 200000000 + warn_pct: 80 # ≥80% 开告警 issue(同日去重,不硬停) + hardstop_pct: 100 # ≥100% 置熔断 + 撤全部 open PR auto-merge + P0 issue + # LLM token 月预算(W2-C3 .github#216,ADR-0062:数据源接入——CI-Workflows + # pipeline/metering 计量账本,按 invoke 聚合的 JSONL hash 链;cost-check 拉取 + # metering-ledger 分支经 metering.py aggregate 按角色档归账。链断/归账失败= + # infra fail-closed(exit 2),不静默归零不盲熔断。pending 形态保留为回滚值 + # (ADR-0040 决策 6);注入通道 COST_LLM_TOKENS_USED 仍优先于真实数据源) + llm_tokens: + quota_per_month: 200000000 + warn_pct: 80 + hardstop_pct: 100 + data_source: ciw-metering + metering: # data_source=ciw-metering 时消费(复用本段现有结构,不新建配置面) + repo: Cloudbird-Software/CI-Workflows + branch: metering-ledger + code_path: pipeline/metering # 归账引擎 metering.py 所在(cost-check.yml sparse checkout) + # verifier 档口径(AC-11):W3-C3 llm-verifier 接入后,verifier run 的 token 账 + # 落盘并与 LLM 响应 usage 字段交叉核对。偏差超出任一阈值 → 该 run 判定作废转人工 + # (非仅告警)。K 与 pivots 为暴露成本旋钮,降档须留痕且不得改动判定语义与阈值。 + # 计量数据源复用上方 ciw-metering(records-*.jsonl role=verifier 切片),本段仅 + # 声明 verifier 档独立阈值与 fail-closed 行为,不新建计量通道。 + verifier: # data_source=ciw-metering role=verifier 时的 fail-closed 阈值 + deviation_pct: 5 # 相对偏差阈值(%),超出即作废 + deviation_abs: 50 # 绝对偏差阈值(tokens),超出即作废 + role: verifier # metering 记录 role 字段过滤值 + fail_closed_action: void_and_escalate + void_reason: "token 账与 metering 偏差超阈值(相对>5% 或绝对>50 tokens)——作废转人工" + # 成本旋钮(BUDGET-01 / AC-11):K 与 pivots 为暴露的成本旋钮,降档须留痕且 + # 不得改动判定语义与阈值。默认取保守高值(ASSUMPTION-03)。 + cost_knobs: + k_default: 3 # 每 criterion 重复评估次数 + pivots_default: 1 # 成本旋钮 pivots(记录用) + downgrade_requires_log: true + downgrade_preserves_semantic: true + +circuit_breaker: + # 熔断标志:org Actions 变量(visibility=all,全仓 workflow/agent 可读) + variable: AUTO_MERGE_DISABLED + # 人工复位:owner PATCH org 变量为 false(或 DELETE)+ 在 P0 issue 留复位评论(留痕); + # cost-check 确认"变量已复位且用量 <100%"后自动关闭 P0 issue + reset_by: randypanding + +# ---- CNB 免费算力池参数(EX-1 / ADR-0085 / IR-0004 AC-15;配置面:加账号=改配置零代码)---- +cnb: + accounts: # 与 cnb-bridge/accounts.yaml 对账(漂移即红) + - alias: xuemei + secret: CNB_TOKEN_XUEMEI # org secret,仅 cnb-dispatch/cnb-audit 可见 + - alias: p11 + secret: CNB_TOKEN_P11 + tiers: {light: 1, std: 2, heavy: 4} # 核数;8C 禁用;heavy 须 ADR + default_tier: light + max_concurrent_per_account: 8 + quota_alert_pct: 20 # 余量低于此值自动开 cost 类 issue + ledger_reconcile_deviation_pct: 10 # 快照差分 vs build logs 实耗对账偏差告警线 diff --git a/governance/policy/contracts.yaml b/governance/policy/contracts.yaml index ac1d847..8a50680 100644 --- a/governance/policy/contracts.yaml +++ b/governance/policy/contracts.yaml @@ -55,7 +55,7 @@ repos: migrations: null # 盘点备注(不声明、不接线): - # agent-registry 之外的注册表消费方 agent-platform —— 仅 vendor/ 拷贝, + # (ADR-0085:agent-platform 退役,本条历史注释保留备查)agent-platform —— 仅 vendor/ 拷贝, # 源头真身在 agent-registry(vendor 漂移属另一关注面,不在本卡范围) # mutual —— spec/01-schemas.md 为散文 schema,不可机判;落地 JSON Schema 后再声明 # Script_Writer / agent-tools / Use-up-Plan / .github / CI-Workflows —— 无契约面 diff --git a/governance/policy/languages.yaml b/governance/policy/languages.yaml index 534c7a3..5f21a04 100644 --- a/governance/policy/languages.yaml +++ b/governance/policy/languages.yaml @@ -6,7 +6,7 @@ layers: allowed: - {language: go, when: "default"} - {language: typescript, when: "frontend-isomorphic only", requires: [TS-1, TS-2]} - - {language: python, when: "agent-runtime integration only(agent-platform:openjiuwen SDK 渲染/治理执行面——不写 Python 无法落地,ADR-0025 声明适配现实)", requires: [PY-1, PY-2]} + # ADR-0085:agent-platform 退役,Python 准入回归默认拒绝(存量豁免先例:ADR-0023 AI_Web_School、ADR-0084 QW_Arena1) forbidden: [rust, java, csharp, cpp, new_language_without_org_approval] rationale: "训练数据海量、写法单一、编译反馈快 → LLM 产出可靠" llm_prompt: diff --git a/governance/providers.yaml b/governance/providers.yaml new file mode 100644 index 0000000..8b4b41b --- /dev/null +++ b/governance/providers.yaml @@ -0,0 +1,37 @@ +version: 1 +# ============================================================================ +# providers.yaml —— 外部工具/模型资源目录(ADR-0085 决策 7:池化资源的最小凭据方案) +# 铁律:本文件【无任何密钥】——secret_ref 只登记 org secret 名;key 永不进仓库、 +# 永不进 agent/PM 上下文。调用一律借道 dispatch 经纪人工作流(工作流内取 +# org secret → 调用 → 产物写回 run 日志/issue/artifact)。 +# 加工具 = owner 加一条 org secret + 本目录一条登记,零代码。 +# CNB 池的账号级配置在 cnb-bridge/accounts.yaml(可删除层,EX-1 隔离);本目录 +# 只登记入口(三接缝口径:目录条目属导航性提及,不计入操作性引用)。 +# ============================================================================ +entries: + - name: cnb-pool + kind: compute # 免费沙箱算力池(默认实现引擎) + entry: cnb-dispatch(.github 仓 workflow)/ ghcb dispatch <卡#> + broker: .github/workflows/cnb-dispatch.yml + secrets: [CNB_TOKEN_XUEMEI, CNB_TOKEN_P11] + catalog: Cloudbird-Software/cnb-bridge accounts.yaml + limits: automation-limits.yaml#cnb(档位 light/std/heavy、并发、配额告警线) + removal: cnb-bridge/REMOVAL.md(可删除层,删除后核心治理语义不变) + - name: llm-verifier + kind: model # 红队/verifier 判定模型(kimi k2.7) + entry: adversary 工作流(repository_dispatch / workflow_dispatch) + secrets: [LLM_API_KEY1] + vars: [LLM_ENDPOINT1, MODEL1] + metering: CI-Workflows pipeline/metering/metering-wrapper.sh(ADR-0062 账本) + note: 判定链唯一 LLM 通道;NPC 无 logprobs 永不做判定(ADR-0082) + - name: llm-spec-author + kind: model # spec 起草快速通道(可选——PM 自著等价,ADR-0085) + entry: CI-Workflows spec-author.yml(workflow_dispatch) + secrets: [LLM_API_KEY] + metering: 同上 + note: 冷上下文流水线(INV-04);产出仍须过 suite 门+红队 + - name: github-apps + kind: identity # 写仓身份(AG-1:开发/验证分离) + entry: scripts/gh-app-token.sh(单仓作用域 1h 令牌) + secrets: [CB_APP_ID, AGENT_APP_SECRET] + note: cloudbrid-agent(开发)+ verifier-app(仅测试/验证路径写权,ADR-0076) diff --git a/governance/transitions.yaml b/governance/transitions.yaml index 9bfa06a..a9885b2 100644 --- a/governance/transitions.yaml +++ b/governance/transitions.yaml @@ -1,128 +1,156 @@ -version: 1 -# 状态机转移表(IR-0001 IFACE-03 / ADR-0049)——conductor 唯一定义源, -# 只解释不内嵌逻辑。本文件是 C1 资产:改动走 PR+ADR。 -# -# schema: -# from_state = 转移前状态(当前 issue 无 state:* 标签时视为 ir-draft) -# event = label:state: | comment:/start | comment:/claim | comment:/retry -# | label:<非state判定标签>(W3-C1 Bug 流扩展:label:bug/label:reproduced -# ——判定标签驱动的转移,见 B1/B2 注) -# to_state = 转移后状态(conductor 以 App 身份换标签:移除 from、置上 to) -# action = invoke:spec-author | claim | noop(W0 全集) -# guard = 布尔表达式;变量白名单:sender_role(owner|agent|none)、 -# author_association(OWNER|MEMBER|COLLABORATOR|CONTRIBUTOR|NONE)、 -# label_set(当前标签名集合) -# 未列出的 (from_state, event) 组合 = 禁止转移(conductor no-op;重复投递/ -# 跳态/反向天然被 from_state 匹配拒绝——幂等键 {issue, from, to} 的落盘形态)。 -# W3-C1(ADR-0064):append bug 流状态 bug/reproduced/fixed 与转移 B1-B5。 -# Bug 流与 Feature 流的差异:入口免签(bug 标签即 reproduce 阶段)、 -# 判定标签(reproduced/cannot-reproduce/inconclusive)是事实标签而非状态—— -# cannot-reproduce 不产生状态转移(保留人裁,ADR-0064 决策 4); -# bugflow 的状态写序=先移旧态再置新态(防双 state 并存窗口触发 -# conductor 多状态 abort)。 -states: [ir-draft, ir-signed, spec, redteam, wave-planned, ready, in-progress, quarantine, needs-human, done, bug, reproduced, fixed] - -transitions: - - id: T1 # 主通路:owner/agent 打签署标签 → 进 spec 阶段(BEH-01)。 - # from_state=ir-signed:label 事件送达时签署标签已生效(当前态即 ir-signed)—— - # 首版误写 ir-draft 导致必然 no-op(#143 实测 AUDIT: from=ir-signed verdict=noop)。 - # 守卫不过=回退该标签(退回 ir-draft);幂等:已在 spec 态则无匹配=no-op。 - from_state: ir-signed - event: label:state:ir-signed - to_state: spec - action: invoke:spec-author - guard: "sender_role in ['owner', 'agent'] and 'type:intent' in label_set" - - id: T2 # owner 评论 /start 等价签署(AC-1 的 comment 路径) - from_state: ir-draft - event: comment:/start - to_state: spec - action: invoke:spec-author - guard: "sender_role == 'owner' and 'type:intent' in label_set" - - id: T3 # 认领:先到先得(当前态必须 ready,否则 no-op)(BEH-08) - from_state: ready - event: comment:/claim - to_state: in-progress - action: claim - guard: "sender_role == 'agent' or author_association in ['OWNER', 'MEMBER', 'COLLABORATOR']" - - id: T4 # 隔离重判(BEH-07 的 /retry 回流) - from_state: quarantine - event: comment:/retry - to_state: ready - action: noop - guard: "sender_role in ['owner', 'agent']" - # T5/T6 suite 就绪谓词(ISSUE-263 AC-12 / W4-C1 .github#282): - # suite 就绪 = 确定性谓词,conductor 在转移前重新断言(不信任 dispatch 载荷): - # 1. suite/ 目录存在; - # 2. suite/ 含至少一个非空测试文件(字节数 >0 且含有效断言语法); - # 3. 测试文件可解析(python -m py_compile / node --check / 等语言原生语法校验通过)。 - # 任一不满足 → 转移拒绝并告警(fail-closed:不放行不确定的红队/波次规划)。 - # 该谓词由 conductor 在运行时对目标 spec 仓做确定性检查,非仅标签面断言。 - - id: T5 # spec PR 合并 + suite 就绪后进入红队阶段(ISSUE-263 AC-12 / ADR-0049 / W4-C1) - from_state: spec - event: label:state:redteam - to_state: redteam - action: noop - guard: "sender_role in ['owner', 'agent'] and 'type:intent' in label_set" - suite_ready_required: true # conductor 在转移前执行 suite 就绪谓词(存在+非空+可解析) - # T6 三元组校验(ISSUE-263 AC-12 / W4-C1 .github#282): - # 进入 wave-planned 必须存在该卡本次生命周期内、卡 ID + specVersion + 审计 run ID - # 三元组对应的 survived 审计记录。禁止跨卡/历史记录短路: - # - 卡 ID 必须与当前 issue 匹配; - # - specVersion 必须与当前 spec PR 合并后的 spec 版本一致; - # - 审计 run ID 必须来自本次生命周期(非历史 run)。 - # 三元组任一缺失或不匹配 → 转移拒绝并告警。 - - id: T6 # redteam 产出 survived 审计记录后进入波次规划(ISSUE-263 AC-12 / ADR-0079 / W4-C1) - from_state: redteam - event: label:state:wave-planned - to_state: wave-planned - action: noop - guard: "sender_role == 'agent' and 'adversary:survived' in label_set" - triple_check: # conductor 在转移前校验三元组 survived 记录 - card_id: issue # 卡 ID 键 = 当前 issue number - spec_version: spec # specVersion 键 = spec 文件版本(SHA 或 semver) - audit_run_id: adversary-run # 审计 run ID 键 = adversary 报告 run_id - source_label: "adversary:survived" # 触发本转移的 survived 标签 - # needs-human 不可直跳 wave-planned(ISSUE-263 AC-12 / AC-14 / W4-C1): - # needs-human → wave-planned 转移不在本表列 = 禁止(conductor no-op)。 - # 该断言作为 required check 在 CI 常驻强制运行(conductor-negtest 模式), - # 确保没有任何路径可从 needs-human 直跳 wave-planned。 - # ---------- Bug 流(W3-C1 .github#218 / ADR-0064,宪法 §3)---------- - # 执行者:CI-Workflows pipeline/bugflow(bug-reproduce.yml 以 App 身份写标签)。 - # conductor 对 label:bug/label:reproduced 等非 state 标签事件静默 no-op—— - # B1/B2 是状态机声明(唯一转移定义源原则:bug 流的合法转移也必须落表), - # 由 bugflow 执行转移本体;B5 的人工/流水线双路径均满足 from_state 幂等。 - - id: B1 # Bug 流入口(免签——区别于 Feature 流签署前置):bug form 落 bug - # 标签 → 进 reproduce 阶段(state:bug)。无 state 标签按约定视为 ir-draft。 - from_state: ir-draft - event: label:bug - to_state: bug - action: noop - guard: "'bug' in label_set" - - id: B2 # 三值判定之【复现】:base 稳定 fail(+fix 候选 pass)→ reproduced - # 标签 + state:reproduced;reproduced 后产 failing repro test 待 owner 签 - # (锁定 ADR-0061 → 修复 → 关卡 → 合并)。证伪(label:cannot-reproduce) - # 不在此列:不转移状态、不关单——保留人裁。 - from_state: bug - event: label:reproduced - to_state: reproduced - action: noop - guard: "sender_role in ['owner', 'agent']" - - id: B3 # 修复合入:owner/agent 置 state:fixed(fix PR 的合并事实回写 bug 单) - from_state: reproduced - event: label:state:fixed - to_state: fixed - action: noop - guard: "sender_role in ['owner', 'agent']" - - id: B4 # 终态关单:fixed → done(owner/agent 置 state:done 后关单) - from_state: fixed - event: label:state:done - to_state: done - action: noop - guard: "sender_role in ['owner', 'agent']" - - id: B5 # 三值判定之【不可判定】兜底:环境错/超时/翻转,换新环境重试一次仍 - # 不可判定 → label:inconclusive + state:needs-human(转人裁) - from_state: bug - event: label:state:needs-human - to_state: needs-human - action: noop - guard: "sender_role in ['owner', 'agent']" +version: 1 +# 状态机转移表(IR-0001 IFACE-03 / ADR-0049)——conductor 唯一定义源, +# 只解释不内嵌逻辑。本文件是 C1 资产:改动走 PR+ADR。 +# +# schema: +# from_state = 转移前状态(当前 issue 无 state:* 标签时视为 ir-draft) +# event = label:state: | comment:/start | comment:/claim | comment:/retry +# | label:<非state判定标签>(W3-C1 Bug 流扩展:label:bug/label:reproduced +# ——判定标签驱动的转移,见 B1/B2 注) +# to_state = 转移后状态(conductor 以 App 身份换标签:移除 from、置上 to) +# action = claim | noop(W0 全集;invoke:spec-author 已随 ADR-0085 退役——产出方式不再由状态机指定) +# guard = 布尔表达式;变量白名单:sender_role(owner|agent|none)、 +# author_association(OWNER|MEMBER|COLLABORATOR|CONTRIBUTOR|NONE)、 +# label_set(当前标签名集合) +# 未列出的 (from_state, event) 组合 = 禁止转移(conductor no-op;重复投递/ +# 跳态/反向天然被 from_state 匹配拒绝——幂等键 {issue, from, to} 的落盘形态)。 +# W3-C1(ADR-0064):append bug 流状态 bug/reproduced/fixed 与转移 B1-B5。 +# Bug 流与 Feature 流的差异:入口免签(bug 标签即 reproduce 阶段)、 +# 判定标签(reproduced/cannot-reproduce/inconclusive)是事实标签而非状态—— +# cannot-reproduce 不产生状态转移(保留人裁,ADR-0064 决策 4); +# bugflow 的状态写序=先移旧态再置新态(防双 state 并存窗口触发 +# conductor 多状态 abort)。 +states: [ir-draft, ir-signed, spec, redteam, wave-planned, ready, in-progress, quarantine, needs-human, done, bug, reproduced, fixed] + +transitions: + - id: T1 # 主通路:owner/agent 打签署标签 → 进 spec 阶段(BEH-01)。 + # from_state=ir-signed:label 事件送达时签署标签已生效(当前态即 ir-signed)—— + # 首版误写 ir-draft 导致必然 no-op(#143 实测 AUDIT: from=ir-signed verdict=noop)。 + # 守卫不过=回退该标签(退回 ir-draft);幂等:已在 spec 态则无匹配=no-op。 + # ADR-0085:action 降级 noop——spec 产出方式不再由状态机指定(PM 自著与 + # spec-author 流水线制度等价,PR338 先例;流水线保留为可选快速通道)。 + from_state: ir-signed + event: label:state:ir-signed + to_state: spec + action: noop + guard: "sender_role in ['owner', 'agent'] and 'type:intent' in label_set" + - id: T2 # owner 评论 /start 等价签署(AC-1 的 comment 路径;ADR-0085 同 T1 降级 noop) + from_state: ir-draft + event: comment:/start + to_state: spec + action: noop + guard: "sender_role == 'owner' and 'type:intent' in label_set" + - id: T3 # 认领:先到先得(当前态必须 ready,否则 no-op)(BEH-08) + from_state: ready + event: comment:/claim + to_state: in-progress + action: claim + guard: "sender_role == 'agent' or author_association in ['OWNER', 'MEMBER', 'COLLABORATOR']" + - id: T4 # 隔离重判(BEH-07 的 /retry 回流) + from_state: quarantine + event: comment:/retry + to_state: ready + action: noop + guard: "sender_role in ['owner', 'agent']" + # T5/T6 suite 就绪谓词(ISSUE-263 AC-12 / W4-C1 .github#282): + # suite 就绪 = 确定性谓词,conductor 在转移前重新断言(不信任 dispatch 载荷): + # 1. suite/ 目录存在; + # 2. suite/ 含至少一个非空测试文件(字节数 >0 且含有效断言语法); + # 3. 测试文件可解析(python -m py_compile / node --check / 等语言原生语法校验通过)。 + # 任一不满足 → 转移拒绝并告警(fail-closed:不放行不确定的红队/波次规划)。 + # 该谓词由 conductor 在运行时对目标 spec 仓做确定性检查,非仅标签面断言。 + - id: T5 # spec PR 合并 + suite 就绪后进入红队阶段(ISSUE-263 AC-12 / ADR-0049 / W4-C1) + from_state: spec + event: label:state:redteam + to_state: redteam + action: noop + guard: "sender_role in ['owner', 'agent'] and 'type:intent' in label_set" + suite_ready_required: true # conductor 在转移前执行 suite 就绪谓词(存在+非空+可解析) + # T6 三元组校验(ISSUE-263 AC-12 / W4-C1 .github#282): + # 进入 wave-planned 必须存在该卡本次生命周期内、卡 ID + specVersion + 审计 run ID + # 三元组对应的 survived 审计记录。禁止跨卡/历史记录短路: + # - 卡 ID 必须与当前 issue 匹配; + # - specVersion 必须与当前 spec PR 合并后的 spec 版本一致; + # - 审计 run ID 必须来自本次生命周期(非历史 run)。 + # 三元组任一缺失或不匹配 → 转移拒绝并告警。 + - id: T6 # redteam 产出 survived 审计记录后进入波次规划(ISSUE-263 AC-12 / ADR-0079 / W4-C1) + from_state: redteam + event: label:state:wave-planned + to_state: wave-planned + action: noop + guard: "sender_role == 'agent' and 'adversary:survived' in label_set" + triple_check: # conductor 在转移前校验三元组 survived 记录 + card_id: issue # 卡 ID 键 = 当前 issue number + spec_version: spec # specVersion 键 = spec 文件版本(SHA 或 semver) + audit_run_id: adversary-run # 审计 run ID 键 = adversary 报告 run_id + source_label: "adversary:survived" # 触发本转移的 survived 标签 + # needs-human 不可直跳 wave-planned(ISSUE-263 AC-12 / AC-14 / W4-C1): + # needs-human → wave-planned 转移不在本表列 = 禁止(conductor no-op)。 + # 该断言作为 required check 在 CI 常驻强制运行(conductor-negtest 模式), + # 确保没有任何路径可从 needs-human 直跳 wave-planned。 + # ---------- PM 优先范式收口(ADR-0085:T7/T8/T9——卡生命周期补洞 + IR 验收门)---------- + - id: T7 # 卡就绪:波次卡进入可认领态(ADR-0085 决策 5;此前 wave-planned→ready + # 无转移=conductor 永不执行,卡就绪靠手动标签——收口为机器路径) + from_state: wave-planned + event: label:state:ready + to_state: ready + action: noop + guard: "sender_role in ['owner', 'agent'] and 'type:card' in label_set" + - id: T8 # 卡完成:谓词=存在绑定本卡且已合并的 PR(body 含 Card: 行)—— + # "完成工作卡+测试通过"的机械定义(gate 绿由 PR required checks 承担, + # 本谓词只认合并事实,ADR-0085 决策 5) + from_state: in-progress + event: label:state:done + to_state: done + action: noop + guard: "sender_role in ['owner', 'agent'] and 'type:card' in label_set" + merged_pr_required: true # conductor 运行时重查(不信任标签事件载荷) + - id: T9 # IR 全面验收(Gate 4):谓词=①全部子卡 state:done ②验收报告 + # specs//acceptance.md 存在于本仓——两条件 conductor 运行时重查; + # 子卡=body 含"父意图: #<本 IR>"的 open issue(ADR-0085 决策 5) + from_state: wave-planned + event: label:state:done + to_state: done + action: noop + guard: "sender_role in ['owner', 'agent'] and 'type:intent' in label_set" + acceptance_required: true # conductor 运行时重查(全子卡 done + 验收报告存在) + # ---------- Bug 流(W3-C1 .github#218 / ADR-0064,宪法 §3)---------- + # 执行者:CI-Workflows pipeline/bugflow(bug-reproduce.yml 以 App 身份写标签)。 + # conductor 对 label:bug/label:reproduced 等非 state 标签事件静默 no-op—— + # B1/B2 是状态机声明(唯一转移定义源原则:bug 流的合法转移也必须落表), + # 由 bugflow 执行转移本体;B5 的人工/流水线双路径均满足 from_state 幂等。 + - id: B1 # Bug 流入口(免签——区别于 Feature 流签署前置):bug form 落 bug + # 标签 → 进 reproduce 阶段(state:bug)。无 state 标签按约定视为 ir-draft。 + from_state: ir-draft + event: label:bug + to_state: bug + action: noop + guard: "'bug' in label_set" + - id: B2 # 三值判定之【复现】:base 稳定 fail(+fix 候选 pass)→ reproduced + # 标签 + state:reproduced;reproduced 后产 failing repro test 待 owner 签 + # (锁定 ADR-0061 → 修复 → 关卡 → 合并)。证伪(label:cannot-reproduce) + # 不在此列:不转移状态、不关单——保留人裁。 + from_state: bug + event: label:reproduced + to_state: reproduced + action: noop + guard: "sender_role in ['owner', 'agent']" + - id: B3 # 修复合入:owner/agent 置 state:fixed(fix PR 的合并事实回写 bug 单) + from_state: reproduced + event: label:state:fixed + to_state: fixed + action: noop + guard: "sender_role in ['owner', 'agent']" + - id: B4 # 终态关单:fixed → done(owner/agent 置 state:done 后关单) + from_state: fixed + event: label:state:done + to_state: done + action: noop + guard: "sender_role in ['owner', 'agent']" + - id: B5 # 三值判定之【不可判定】兜底:环境错/超时/翻转,换新环境重试一次仍 + # 不可判定 → label:inconclusive + state:needs-human(转人裁) + from_state: bug + event: label:state:needs-human + to_state: needs-human + action: noop + guard: "sender_role in ['owner', 'agent']" diff --git a/profile/README.md b/profile/README.md index e234676..805fef5 100644 --- a/profile/README.md +++ b/profile/README.md @@ -2,6 +2,8 @@ 一人软件公司,AI Agent 驱动开发。所有仓库公开,质量与安全靠制度 + 自动化门禁,不靠人盯。 +**运行范式(ADR-0085)**:强模型 = 项目经理(PM),公司只规定四道阶段门禁——IR→spec → spec 过红队→开卡 → 卡完成+全 gate 绿 → IR 全面验收;门禁之间怎么干由 PM 自主。常规实现默认派 CNB 免费算力池,PM 处理上升问题。PM 入口:[.github 仓 AGENTS.md](https://github.com/Cloudbird-Software/.github/blob/main/AGENTS.md) + [PM Playbook](https://github.com/Cloudbird-Software/.github/blob/main/docs/pm/PLAYBOOK.md)。每次 run 的经验沉淀在 [archive/runs](https://github.com/Cloudbird-Software/archive/tree/main/runs)(周度 digest 消费)。 + ## 我们的工程制度 - 每个仓库唯一的必需 check 是 **`gate`**:聚合 hygiene(密钥/大文件扫描)+ `make check`(lint + test)+ 依赖审查。 diff --git a/scripts/ghcb b/scripts/ghcb old mode 100755 new mode 100644 index f4addea..deb8be0 --- a/scripts/ghcb +++ b/scripts/ghcb @@ -10,6 +10,13 @@ # # (仅 holder 本人或 owner 可成,ADR-0054 §4) # ghcb status [repo] # 只读:卡标签态(真相源)+ arbiter 租约持有者/到期 # ghcb card-meta [repo] # 输出 PR body 应贴的卡元数据行(Card: /#) +# —— PM 子命令(ADR-0085)—— +# ghcb board [repo] # PM 全流水线视图:全部 state:* 的 IR/卡(不只 ready) +# ghcb dispatch [repo] [--tier light|std] [--account ] +# # 派 CNB:触发 .github 仓 cnb-dispatch 工作流(EX-1 经纪人, +# # key 永不入 PM 上下文);=卡 issue 号 +# ghcb accept # 生成 IR 验收报告骨架(specs/IR-XXXX/acceptance.md)到 stdout +# ghcb report [周号] # 生成运行报告骨架(archive/runs/YYYY-WNN.md 追加段)到 stdout # # next/claim/release 用调用方自己的 gh 凭据(gh auth login);认领合法性单一真源=conductor guard # + arbiter CAS(ADR-0055 转介)。status 读 arbiter 仓 refs/leases/*(只读,无写权要求)。 @@ -84,6 +91,77 @@ case "$CMD" in echo "Card: $(norm_repo "$REPO_ARG")#$N" echo "提示:把上面一行原样贴进 PR body(缺失=后续关卡 exit 3)" >&2 ;; + board) + # PM 全流水线视图(ADR-0085):全状态 IR/卡一览——修复冷上下文 PM"只看得到 ready 卡"的断面 + REPO_ARG="${2:-$(origin_repo)}" + [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓(用法: ghcb board [repo])" >&2; exit 2; } + R="$(norm_repo "$REPO_ARG")" + for s in ir-draft ir-signed spec redteam wave-planned ready in-progress quarantine needs-human done; do + echo "== state:$s ==" + gh issue list --repo "$R" --label "state:$s" --state open --json number,title --jq '.[] | "#\(.number) \(.title)"' 2>/dev/null || true + done + echo "== 无状态标签的 type:intent/card(=ir-draft 约定态)==" + gh issue list --repo "$R" --label type:intent --state open --json number,title,labels --jq '.[] | select([.labels[].name | startswith("state:")] | any | not) | "#\(.number) \(.title)"' 2>/dev/null || true + ;; + dispatch) + # CNB 派单(EX-1 三接缝的 PM 面):key 留在 org secret,本命令只发 workflow_dispatch + N="${2:?用法: ghcb dispatch [repo] [--tier light|std] [--account ]}" + REPO_ARG="${3:-$(origin_repo)}"; shift 3 2>/dev/null || shift $# + TIER="light"; ACCOUNT="" + while [[ $# -gt 0 ]]; do + case "$1" in + --tier) TIER="$2"; shift 2 ;; + --account) ACCOUNT="$2"; shift 2 ;; + *) shift ;; + esac + done + CARD_REPO="$(norm_repo "$REPO_ARG")" + TITLE=$(gh issue view "$N" --repo "$CARD_REPO" --json title --jq .title 2>/dev/null) || { echo "错误:卡 #$N 读取失败" >&2; exit 2; } + echo "派单 → CNB(卡 #$N「${TITLE}」|档位 $TIER${ACCOUNT:+|账号 $ACCOUNT})" + echo "任务文本=卡正文+本提示;经 .github 仓 cnb-dispatch 经纪人执行(key 永不出 org secret)" + MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 gh workflow run cnb-dispatch.yml -R "$ORG/.github" -f card="$CARD_REPO#$N" -f tier="$TIER" ${ACCOUNT:+-f account="$ACCOUNT"} -f task="@card" && echo "已触发(几分钟后在 .github 仓 Actions→cnb-dispatch 看 run;回收=gh workflow run cnb-dispatch.yml -f ... 或 cnb-audit 周扫)" || { echo "错误:workflow 触发失败(检查 gh 凭据对 $ORG/.github 的写权)" >&2; exit 2; } + ;; + accept) + # IR 验收报告骨架(T9 谓词消费 specs//acceptance.md)——PM 填证据后 PR 入仓 + N="${2:?用法: ghcb accept }" + IRID=$(gh issue view "$N" -R "$ORG/.github" --json title --jq .title 2>/dev/null | grep -oE 'IR-[0-9]+' | head -1) + [[ -n "$IRID" ]] || IRID="IR-$(printf '%04d' "$N")" + cat < 验收日期: $(date -u +%F) +- 判据: IR 正文的"期望的可观察变化"逐条 + 运行时证据链接(SHA 锚定) + +## 子卡清单(曾存在性自证;全部须 state:done) +- #<卡号> <标题> → PR <链接> + + +## 逐条证据 +| # | 期望变化 | 证据(链接+SHA) | 结论 | +|---|---------|-----------------|------| +| 1 | <从 IR 抄写> | | ✅ | + +## 残留与移交 +- <已知未竟项/后续 IR 建议——诚实申报,验收不掩盖> +SKEL + ;; + report) + # 运行报告骨架(ADR-0085 决策 8)——追加到 archive/runs/YYYY-WNN.md + python3 - <<'PY' +import datetime +t = datetime.date.today(); y, w, _ = t.isocalendar() +print(f"### run-{y}w{w:02d}-{int(datetime.datetime.now().timestamp())%100000} · {t.isoformat()} · ") +print("scope: [, #]") +print() +print("**事实** ——") +print("**体感** ——") +print("**改进点** ——") +print("[followup] : <一行一提案>") +PY + echo "↑ 追加到 archive/runs/$(date -u +%G)-W$(date -u +%V).md(三节式缺节即不合格;格式见 archive/runs/README.md)" >&2 + ;; + *) export REPO="${1:?用法: GH_TOKEN=$(ghcb [--refresh]) | ghcb next [repo] | ghcb claim [repo] | ghcb release [repo] | ghcb status [repo] | ghcb card-meta [repo]}" shift || true From 0c7dd28f005354122322dbdf36bfe63f968ce911 Mon Sep 17 00:00:00 2001 From: randypanding <66171646+randypanding@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:15:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20drift-check.sh=20=E8=A1=8C=E5=B0=BE?= =?UTF-8?q?=E5=BD=92=E4=B8=80=20LF=EF=BC=88stash=20pop=20=E5=9C=A8=20autoc?= =?UTF-8?q?rlf=20=E4=B8=8B=E5=BC=95=E5=85=A5=20CRLF=20=E8=87=B4=20test-ir0?= =?UTF-8?q?002=20eval=20=E5=A4=B1=E8=B4=A5=EF=BC=9BADR-0085=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- governance/drift-check.sh | 1910 ++++++++++++++++++------------------- 1 file changed, 955 insertions(+), 955 deletions(-) diff --git a/governance/drift-check.sh b/governance/drift-check.sh index 3fc6e56..3a1b8b1 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -1,956 +1,956 @@ -#!/usr/bin/env bash -# drift-check.sh —— 治理飘移检测(只读) -# -# 对比 governance/expected-state.json + rulesets/*.json 与线上实际配置。 -# 输出 OK/DRIFT 逐项结果;发现任何漂移 exit 1。 -# -# 用法: GH_TOKEN= bash drift-check.sh -# CI 中由 .github/workflows/governance-drift.yml 调用,凭据来自 secret GOVERNANCE_TOKEN。 -set -uo pipefail - -ORG="${ORG:-Cloudbird-Software}" -DIR="$(cd "$(dirname "$0")" && pwd)" -EXPECTED="$DIR/expected-state.json" -DRIFTS=0 - -ok() { echo "OK $1"; } -drift(){ echo "DRIFT $1"; DRIFTS=$((DRIFTS+1)); } - -api() { curl -sS -H "Authorization: Bearer ${GH_TOKEN:?需要 org admin GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" "$@"; } - -[[ -n "${GH_TOKEN:-}" ]] || { echo "FATAL: GH_TOKEN 未设置"; exit 2; } - -# ---------- 1. Rulesets:存在性 / enforcement / 核心规则 ---------- -ACTUAL_RULESETS=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") -# fail-closed(ADR-0083 决策 4):清单非数组(token 失效/网络失败/权限丢失)时, -# §1 的「不存在」判定全部是伪读数——2026-08-23 GOVERNANCE_TOKEN 失效期间曾把 -# 四个真实存在的 ruleset 全报「不存在」+ jq 对字符串行报错刷屏。检测器失明 -# 不得伪装成漂移或无漂移:显式 exit 2(与 §4 仓库清单 loud-failure 契约一致)。 -if ! jq -e 'type == "array"' <<<"$ACTUAL_RULESETS" >/dev/null 2>&1; then - echo "FATAL: org ruleset 清单拉取失败($(jq -r '.message // "非数组"' <<<"$ACTUAL_RULESETS" 2>/dev/null || echo 传输失败)),drift 检测失明——中止" >&2 - exit 2 -fi -for f in "$DIR"/rulesets/*.json; do - name=$(jq -r .name "$f") - want_enf=$(jq -r .enforcement "$f") - want_rules=$(jq -c '[.rules[].type] | sort' "$f") - row=$(jq -c --arg n "$name" '.[] | select(.name == $n)' <<<"$ACTUAL_RULESETS") - if [[ -z "$row" || "$row" == "null" ]]; then - drift "ruleset '$name' 不存在(定义文件 $f)"; continue - fi - got_enf=$(jq -r .enforcement <<<"$row") - [[ "$got_enf" == "$want_enf" ]] || drift "ruleset '$name' enforcement=$got_enf 期望=$want_enf" - # 线上完整定义(含 id)对比规则集 - rid=$(jq -r .id <<<"$row") - detail=$(api "https://api.github.com/orgs/$ORG/rulesets/$rid") - got_rules=$(jq -c '[.rules[].type] | sort' <<<"$detail") - [[ "$got_rules" == "$want_rules" ]] || drift "ruleset '$name' 规则集变动: $got_rules 期望=$want_rules" - # 精确 diff:把线上定义裁剪成与落盘文件同构后比较(剔除 API 自动填充的空字段) - got_norm=$(jq -S 'del(.rules[].parameters.required_reviewers? | select(. == [])) | {name,target,enforcement,conditions,bypass_actors,rules}' <<<"$detail") - want_norm=$(jq -S '{name,target,enforcement,conditions,bypass_actors,rules}' "$f") - [[ "$got_norm" == "$want_norm" ]] \ - || drift "ruleset '$name' 定义与落盘不一致: $(diff <(echo "$want_norm") <(echo "$got_norm") | head -10)" - ok "ruleset '$name'" -done -EXTRA=$(jq -c --argjson known "$(jq -s '[.[].name]' "$DIR"/rulesets/*.json)" \ - '[.[].name] - $known' <<<"$ACTUAL_RULESETS") -[[ "$EXTRA" == "[]" ]] || drift "线上存在未落盘的 ruleset: $EXTRA(落盘或删除)" - -# ---------- 2. Actions 策略 ---------- -AP=$(api "https://api.github.com/orgs/$ORG/actions/permissions") -[[ "$(jq -r .allowed_actions <<<"$AP")" == "$(jq -r .actions_policy.allowed_actions "$EXPECTED")" ]] \ - || drift "allowed_actions=$(jq -r .allowed_actions <<<"$AP")" -# enabled_repositories 同检(ADR-0021):apply §2 写该字段而 check 不验=修复回路盲区 -[[ "$(jq -r .enabled_repositories <<<"$AP")" == "$(jq -r .actions_policy.enabled_repositories "$EXPECTED")" ]] \ - || drift "enabled_repositories=$(jq -r .enabled_repositories <<<"$AP") ≠ expected $(jq -r .actions_policy.enabled_repositories "$EXPECTED")" - -SA=$(api "https://api.github.com/orgs/$ORG/actions/permissions/selected-actions") -[[ "$(jq -c '{github_owned_allowed, verified_allowed, patterns_allowed}' <<<"$SA")" == \ - "$(jq -c '.actions_policy | {github_owned_allowed, verified_allowed, patterns_allowed}' "$EXPECTED")" ]] \ - || drift "allowed actions 白名单不一致: $(jq -c .patterns_allowed <<<"$SA")" - -WF=$(api "https://api.github.com/orgs/$ORG/actions/permissions/workflow") -[[ "$(jq -r .default_workflow_permissions <<<"$WF")" == "$(jq -r .actions_policy.default_workflow_permissions "$EXPECTED")" ]] \ - || drift "default_workflow_permissions=$(jq -r .default_workflow_permissions <<<"$WF")" -ok "actions 策略(含白名单)" - -# ---------- 3. Code Security 默认配置 ---------- -CS=$(api "https://api.github.com/orgs/$ORG/code-security/configurations") -want_cs=$(jq -r .code_security.configuration_name "$EXPECTED") -csrow=$(jq -c --arg n "$want_cs" '.[] | select(.name == $n)' <<<"$CS") -if [[ -z "$csrow" || "$csrow" == "null" ]]; then - drift "code security 配置 '$want_cs' 不存在" -else - # 注意:default_for_new_repos 是只写 API(PUT .../defaults),GitHub 不提供读取端点, - # 无法只读验证。由 apply.sh 的幂等 PUT 保证,drift-check 只验证配置本体存在。 - ok "code security '$want_cs'(default_for_new_repos 由 apply 保证,API 无读取端点)" -fi - -# ---------- 4. 仓库基线(squash-only / 删分支 / auto-merge,ADR-0029)---------- -EXCLUDES=$(jq -c '.repo_baseline.exclude_repos // []' "$EXPECTED") -# auto-merge 期望值(ADR-0029):repo setting 不受 ruleset 管,静默漂移 = 自动合并 -# 全链路静默失效。字段读不到(null——如凭据缺 administration 读权限的 fine-grained -# PAT)与 false 同判漂移——fail-closed:检测器失明不得伪装成无漂移 -WANT_AUTO_MERGE=$(jq -r '.repo_baseline.allow_auto_merge // true' "$EXPECTED") -# 全分页拉取 org 仓库(评审项:单页 100 时 >100 仓的 org 会漏检后续仓库); -# fail-closed:清单拉取失败/非数组/为空时 REPOS 为空会让后续全部循环静默跳过、 -# 检测整体假绿——此处显式中止,检测器失明不得伪装成无漂移 -REPOS_TMP=$(mktemp) -PAGE=1 -while :; do - CHUNK=$(api "https://api.github.com/orgs/$ORG/repos?per_page=100&page=$PAGE") - if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then - echo "FATAL: org 仓库清单拉取失败($(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)),无法检测" >&2 - exit 2 - fi - N=$(jq 'length' <<<"$CHUNK") - [[ "$N" -eq 0 ]] && break - jq -r '.[].name' <<<"$CHUNK" >>"$REPOS_TMP" - [[ "$N" -lt 100 ]] && break - PAGE=$((PAGE+1)) -done -REPOS=$(cat "$REPOS_TMP") -rm -f "$REPOS_TMP" -if [[ -z "$REPOS" ]]; then - echo "FATAL: org 仓库清单为空(org 至少应含本治理仓)——清单异常,无法检测" >&2 - exit 2 -fi -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - RR=$(api "https://api.github.com/repos/$ORG/$r") - bad="" - [[ "$(jq -r .allow_squash_merge <<<"$RR")" == "true" ]] || bad="$bad squash-off" - [[ "$(jq -r .allow_merge_commit <<<"$RR")" == "false" ]] || bad="$bad merge-commit-on" - [[ "$(jq -r .allow_rebase_merge <<<"$RR")" == "false" ]] || bad="$bad rebase-on" - [[ "$(jq -r .delete_branch_on_merge <<<"$RR")" == "true" ]] || bad="$bad keep-branch" - [[ "$(jq -r .allow_auto_merge <<<"$RR")" == "$WANT_AUTO_MERGE" ]] || bad="$bad auto-merge-off(got=$(jq -r .allow_auto_merge <<<"$RR"))" - [[ -z "$bad" ]] && ok "repo baseline '$r'" || drift "repo '$r' 基线异常:$bad" -done - -# ---------- 5. 必需 org secrets(只查存在性,值不可读)---------- -SECRETS=$(api "https://api.github.com/orgs/$ORG/actions/secrets") -for s in $(jq -r '.org_secrets_required[]' "$EXPECTED"); do - jq -e --arg s "$s" 'any(.secrets[]; .name == $s)' <<<"$SECRETS" >/dev/null \ - && ok "org secret '$s'" || drift "org secret '$s' 缺失" -done - -# ---------- 6. GitHub App 权限形状 ---------- -APP=$(api "https://api.github.com/apps/$(jq -r .github_app.name "$EXPECTED")") -if [[ "$(jq -r .id <<<"$APP" 2>/dev/null)" == "$(jq -r .github_app.id "$EXPECTED")" ]]; then - for p in $(jq -r '.github_app.permissions | to_entries[] | "\(.key)=\(.value)"' "$EXPECTED"); do - k=${p%%=*}; v=${p##*=} - [[ "$(jq -r .permissions.$k <<<"$APP")" == "$v" ]] || drift "App 权限 $k 变动" - done - for p in $(jq -r '.github_app.must_not_have[]' "$EXPECTED"); do - jq -e --arg p "$p" '.permissions | has($p)' <<<"$APP" >/dev/null \ - && drift "App 出现禁用权限 '$p'(应立即在 App 设置页移除)" - done - ok "github app '$(jq -r .github_app.name "$EXPECTED")'" -else - drift "github app '$(jq -r .github_app.name "$EXPECTED")' 不存在或 id 不符" -fi - -# ---------- 7. 组织地图(REPOS.yaml):存在性 / visibility / 未申报仓 ---------- -if python3 -c 'import yaml' 2>/dev/null; then - REPO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' "$DIR/REPOS.yaml") - # 7a. active 仓:必须存在且 visibility 一致 - while IFS=$'\t' read -r r want_vis; do - [[ -n "$r" ]] || continue - RR=$(api "https://api.github.com/repos/$ORG/$r") - if [[ "$(jq -r 'if .message then .message else "" end' <<<"$RR")" == "Not Found" ]]; then - drift "REPOS.yaml 申报的 active 仓 '$r' 不存在"; continue - fi - got_vis=$(jq -r 'if .private then "private" else "public" end' <<<"$RR") - [[ "$got_vis" == "$want_vis" ]] || drift "repo '$r' visibility=$got_vis 期望=$want_vis" - [[ "$want_vis" == "public" ]] || drift "repo '$r' 申报 visibility=$want_vis 违反全仓公开政策(ADR-0020)" - ok "REPOS map '$r'" - done < <(jq -r '.repos[] | select(.status=="active") | "\(.name)\t\(.visibility)"' <<<"$REPO_MAP") - # 7b/7c. 线上全量仓合并遍历(每仓一次 API): - # 7b. 必须在图中申报(任何 status 均可,未申报即漂移) - # 7c. 全仓公开政策(ADR-0020):private 即漂移——不依赖申报完整性(未申报仓 - # 同样受检);exempt 状态只豁免治理基线(repo_baseline),不豁免可见性 - for r in $REPOS; do - jq -e --arg r "$r" '[.repos[].name] | index($r) != null' <<<"$REPO_MAP" >/dev/null \ - || drift "线上仓 '$r' 未在 governance/REPOS.yaml 申报(补申报,或标 exempt 注明原因)" - VR=$(api "https://api.github.com/repos/$ORG/$r") - [[ "$(jq -r 'if .private then "private" else "public" end' <<<"$VR")" == "public" ]] \ - || drift "repo '$r' 为 private,违反全仓公开政策(ADR-0020 改回 public,或新 ADR 推翻政策)" - done -else - echo "SKIP REPOS.yaml 校验(环境缺 python3+pyyaml;GitHub runner 自带)" -fi - -# ---------- 8. 直推检测(GM-2 破玻璃监控)---------- -# flows.governance_change.policy_effective 之后,受治仓默认分支上的非 PR commit = 漂移。 -# 唯一权威判据 = List pull requests associated with commit API(红队 #17-I + PR 评审项): -# 不做消息后缀预筛——"(#N)" 后缀可伪造,直推挂个假后缀即可绕过预筛被报 clean; -# 窗口内每个 commit 都经关联 PR API 复核(真有关联 PR=合法形态放行,无=直推)。 -# fail-closed(评审项):关联 PR 查询本身失败(限流/权限/传输)= 无法验证 = 判漂移报错, -# 绝不静默放行——检测器失明比误报危险。 -# 分页(评审项):commit 列表全分页拉取,第 100 条之后的直推不可漏检; -# 超 MAX_COMMITS 上限时显式报漂移(fail loudly),不静默截断。 -# 回填时限:commit 距今 >24h 且仍在直推状态 = 已超破玻璃回填时限(P0 级标记)。 -SINCE=$(python3 - <<'EOF' -from datetime import datetime, timezone, timedelta -eff = datetime(2026, 8, 19, tzinfo=timezone.utc) # policy_effective -lo = datetime.now(timezone.utc) - timedelta(days=7) # 检测窗口 -print(max(eff, lo).strftime("%Y-%m-%dT%H:%M:%SZ")) -EOF -) -NOW_EPOCH=$(date +%s) -MAX_COMMITS=300 # 窗口内单仓 commit 上限:治理仓日常量级为个位数;超限=异常,人工核查 -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - # 全分页拉取窗口内 commit(评审项:首页之后不可漏检) - COMMITS_TMP=$(mktemp) - TOTAL=0 - PAGE=1 - FETCH_ERR=0 - while :; do - CHUNK=$(api "https://api.github.com/repos/$ORG/$r/commits?sha=main&since=$SINCE&per_page=100&page=$PAGE") - if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then - drift "repo '$r' commit 列表拉取失败,直推检测无法执行(fail-closed): $(jq -r '.message // "非 JSON 响应"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)" - FETCH_ERR=1 - break - fi - N=$(jq 'length' <<<"$CHUNK") - [[ "$N" -eq 0 ]] && break - jq -c '.[] | {sha: .sha, date: (.commit.committer.date | sub("\\.[0-9]+Z$"; "Z"))}' <<<"$CHUNK" >>"$COMMITS_TMP" - TOTAL=$((TOTAL+N)) - [[ "$N" -lt 100 ]] && break - PAGE=$((PAGE+1)) - done - if [[ $FETCH_ERR -eq 1 ]]; then rm -f "$COMMITS_TMP"; continue; fi - if [[ $TOTAL -gt $MAX_COMMITS ]]; then - drift "repo '$r' 窗口内 commit 数=$TOTAL 超上限 $MAX_COMMITS——为防静默截断漏检,须人工核查直推或调窗口" - rm -f "$COMMITS_TMP" - continue - fi - # §8 豁免清单(ADR-0016 附录机制:已经 ADR 追认回填的破玻璃直推—— - # expected-state.json direct_push_exemptions 逐 SHA 登记,事件定性见 ADR-0017; - # 豁免须有 ADR 背书,不得口头/临时豁免) - EXEMPT=$(jq -r --arg r "$r" '.direct_push_exemptions[$r] // [] | join(" ")' "$EXPECTED") - DIRECT_FOUND=0 - while IFS= read -r row; do - [[ -n "$row" ]] || continue - sha=$(jq -r .sha <<<"$row") - cdate=$(jq -r .date <<<"$row") - if grep -qF "$sha" <<<"$EXEMPT"; then - ok "repo '$r' commit ${sha:0:8} 已回填破玻璃直推(§8 豁免清单,ADR-0017)" - continue - fi - # 关联 PR 复核(全 SHA;响应须为数组——error 对象/传输失败均为无法验证) - PRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/pulls?per_page=5" \ - | jq -r 'if type == "array" then (if length > 0 then "has-pr" else "none" end) else "error" end' 2>/dev/null || echo error) - if [[ "$PRS" == "error" ]]; then - drift "repo '$r' commit $sha 的关联 PR 查询失败,无法验证直推(fail-closed:限流/权限/传输故障不得静默放行)" - DIRECT_FOUND=1 - continue - fi - if [[ "$PRS" == "none" ]]; then - AGE=$(( NOW_EPOCH - $(date -u -d "$cdate" +%s) )) - if [[ $AGE -gt 86400 ]]; then - drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(已超 24h 回填时限=${AGE}s——P0:立即回填 ADR+PR,见 flows.governance_change)" - else - drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(破玻璃须 24h 内回填 ADR+PR,见 flows.governance_change)" - fi - DIRECT_FOUND=1 - fi - done <"$COMMITS_TMP" - rm -f "$COMMITS_TMP" - [[ $DIRECT_FOUND -eq 0 ]] && ok "no-direct-push '$r' (since $SINCE, $TOTAL commits)" -done - -# ---------- 9. vcs_admin 唯一性(ADR-0010:admin 全系统唯 owner)---------- -# org 全部受治仓的 admin 数量必须 == 1 且为 owner;任何多出的 admin = P0 漂移 -OWNER_LOGIN="${OWNER_LOGIN:-randypanding}" -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - # 单次拉取全分页(>100 协作者时防漏——CodeRabbit #13);ADMINS/COUNT 同源派生 - ADMIN_TMP=$(mktemp) - PAGE=1 - while :; do - CHUNK=$(api "https://api.github.com/repos/$ORG/$r/collaborators?permission=admin&per_page=100&page=$PAGE") - N=$(jq 'length' <<<"$CHUNK") - [[ "$N" -eq 0 ]] && break - jq -c '.' <<<"$CHUNK" >>"$ADMIN_TMP" - [[ "$N" -lt 100 ]] && break - PAGE=$((PAGE+1)) - done - ADMIN_DATA=$(jq -s 'add // []' "$ADMIN_TMP" 2>/dev/null || echo '[]') - rm -f "$ADMIN_TMP" - ADMINS=$(jq -r '[.[] | select(.permissions.admin == true) | .login] | unique | join(",")' <<<"$ADMIN_DATA") - COUNT=$(jq '[.[] | select(.permissions.admin == true) | .login] | unique | length' <<<"$ADMIN_DATA") - if [[ "$COUNT" != "1" ]]; then - drift "repo '$r' admin 数量=$COUNT ($ADMINS),必须唯一且为 $OWNER_LOGIN(ADR-0010 owner 伪原型不变量)" - elif [[ "$ADMINS" != "$OWNER_LOGIN" ]]; then - drift "repo '$r' admin=$ADMINS 非 owner $OWNER_LOGIN(ADR-0010)" - else - ok "vcs-admin-unique '$r'" - fi -done - -# ---------- 10. ADR 引用存在性+实体性后验(adr-required 的补充防线,评审项 + RB-D5)---------- -# gate.yml 的 adr-required 已在 PR 时点做存在性校验(ADR-0021:全仓公开后 PR 上下文 -# GITHUB_TOKEN 可读公开仓——早期"agent-registry 是私有仓、PR 时点无跨仓读权"的前提 -# 已随 ADR-0020 全仓公开政策失效,注记修正);ADR-0053(W1-C1 .github#164)索引世界 -# 起进一步校验 archive 正本可达性。本节后验实体性(防空壳)并独立复核存在性: -# 窗口内合并 PR 的 ADR-NNNN 引用必须有实质决策结构——伪造/幽灵 ADR 最长 24h 内 -# 检出(与 §8 直推检测同为 post-hoc 防线;C1 的权威人类门禁仍是 owner-only review)。 -# 内容源双世界(ADR-0053;ADR-0085 家园单仓化后唯一世界):archive/adr/INDEX.yaml(索引随正本同居)存在 → -# decisions/ 只剩同名墓碑(无实质结构),实体性改验 INDEX 指向的 archive 仓正本 -# (字节保真原件);INDEX 404(迁移前/回滚)→ 旧逻辑对 decisions/ 本体验证。 -ADR_RE='ADR-[0-9]{4}' -# 实体性判定(评审项:size 字节数可被空白/注释/填充绕过——100B 阈值挡不住 -# RB-D5 意义上的空壳):拉取被引 ADR 全文做结构校验,H1 编号行、status 行、 -# 背景/决策章节齐备且决策节有正文才判"有实质决策记录";同名多文件(ADR-0011 -# 先例)任一满足即通过;读取失败 fail-closed。按编号缓存(同批多 PR 引用同一 -# ADR 只拉一次)。 -declare -A ADR_VERDICT_CACHE -adr_substantive() { # $1=四位编号 → stdout: missing|ok|shell|unreadable - local num="$1" matches apath content decoded verdict - [[ -n "${ADR_VERDICT_CACHE[$num]:-}" ]] && { echo "${ADR_VERDICT_CACHE[$num]}"; return; } - if [[ -n "${ADR_INDEX_MODE:-}" ]]; then - # 索引世界(ADR-0053):编号→archive 正本路径(可多条:ADR-0011 双档先例) - matches=$(awk -v n="$num" '$1 == n {print $2}' <<<"$ADR_INDEX_MAP") - else - matches=$(jq -r --arg p "ADR-${num}-" '.[] | select((.name | startswith($p)) and (.type == "file")) | .path' <<<"$ADR_DIR_LISTING") - fi - if [[ -z "$matches" ]]; then - verdict="missing" - else - verdict="unreadable" - while IFS= read -r apath; do - [[ -n "$apath" ]] || continue - if [[ -n "${ADR_INDEX_MODE:-}" ]]; then - # 索引世界:archive raw 正本(公开仓,字节保真原件)。raw 在 runner 上有 - # 瞬时拒连抖动(2026-08-24 实测整批 unreadable)——重试 3 次后回退 - # contents API(api.github.com 通道稳定),双通道皆失败才按不可判定处理。 - decoded=$(curl -sSf --retry 3 --retry-delay 2 --max-time 20 "https://raw.githubusercontent.com/$ORG/archive/main/${apath}" 2>/dev/null || true) - if [[ -z "$decoded" ]]; then - _c=$(api "https://api.github.com/repos/$ORG/archive/contents/${apath}" 2>/dev/null | jq -r '.content // empty' 2>/dev/null || true) - [[ -n "$_c" ]] && decoded=$(base64 -d <<<"$_c" 2>/dev/null || true) - fi - else - content=$(api "https://api.github.com/repos/$ORG/archive/contents/$apath" | jq -r '.content // empty') - [[ -z "$content" ]] && continue - decoded=$(base64 -d <<<"$content" 2>/dev/null || true) - fi - [[ -z "$decoded" ]] && continue - if grep -qE "^#[[:space:]]*ADR-${num}([^0-9]|$)" <<<"$decoded" \ - && grep -qE "^-[[:space:]]*(status|状态):[[:space:]]*[^[:space:]]" <<<"$decoded" \ - && grep -qE "^##[[:space:]]*背景" <<<"$decoded" \ - && grep -qE "^##[[:space:]]*决策" <<<"$decoded" \ - && [[ -n "$(sed -n '/^##[[:space:]]*决策/,$p' <<<"$decoded" | tail -n +2 | sed 's/[[:space:]#*-]//g')" ]]; then - verdict="ok"; break - fi - [[ "$verdict" == "unreadable" ]] && verdict="shell" - done <<<"$matches" - fi - ADR_VERDICT_CACHE[$num]="$verdict" - echo "$verdict" -} -# 独立 7 天窗口(不用 §8 的 SINCE——那是 policy_effective 起算的直推检测窗口, -# 而 ADR 引用后验须覆盖 policy 生效前已合并、引用了伪造 ADR 的 PR) -ADR_SINCE=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) -ADR_DIR_LISTING=$(api "https://api.github.com/repos/$ORG/archive/contents/adr") -if ! jq -e 'type == "array"' <<<"$ADR_DIR_LISTING" >/dev/null 2>&1; then - drift "ADR 真源 archive/adr 读取失败,引用存在性无法后验(fail-closed,ADR-0085): $(jq -r '.message // "非数组"' <<<"$ADR_DIR_LISTING" 2>/dev/null || echo 传输失败)" -else - # 墓碑索引探测(ADR-0053):200 → 索引世界(map:编号→archive 正本路径); - # 404 → 旧世界;其他失败 → 报漂移(fail-closed:检测器失明不得伪装无漂移), - # 并降级旧世界继续跑完本节(漂移行已置红,后续节不受影响)。 - ADR_INDEX_MODE="" - ADR_INDEX_MAP="" - ADR_INDEX_JSON=$(api "https://api.github.com/repos/$ORG/archive/contents/adr/INDEX.yaml") - if jq -e '.content' <<<"$ADR_INDEX_JSON" >/dev/null 2>&1; then - ADR_INDEX_MAP=$(base64 -d <<<"$(jq -r '.content' <<<"$ADR_INDEX_JSON")" 2>/dev/null | awk ' - /^[[:space:]]*-[[:space:]]*number:/ { n=$3; gsub(/[^0-9]/, "", n); cur=sprintf("%04d", n+0); ap="" } - /^[[:space:]]*archive_path:/ { - line=$0; sub(/^[[:space:]]*archive_path:[[:space:]]*/, "", line); gsub(/"/, "", line) - if (cur != "" && line != "") { print cur " " line; cur="" } - }') - if [[ -n "$ADR_INDEX_MAP" ]] && grep -qE '^[0-9]{4} adr/.+\.md$' <<<"$ADR_INDEX_MAP"; then - ADR_INDEX_MODE=1 - ok "adr-index(ADR-0053 索引世界:$(wc -l <<<"$ADR_INDEX_MAP") entries,实体性改验 archive 正本)" - else - drift "decisions/INDEX.yaml 存在但解析为空/畸形——索引世界不可判定(fail-closed,按旧世界降级继续)" - fi - elif ! grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"' <<<"$ADR_INDEX_JSON"; then - drift "decisions/INDEX.yaml 探测失败(非 404 的 API 错误)——索引世界判定失败(fail-closed,按旧世界降级继续)" - fi - ADR_FILES=$(jq -r '.[].name' <<<"$ADR_DIR_LISTING") - GHOST=0 - for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - PRSLIST=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=closed&sort=updated&direction=desc&per_page=30") - if ! jq -e 'type == "array"' <<<"$PRSLIST" >/dev/null 2>&1; then - drift "repo '$r' PR 列表拉取失败,ADR 引用后验跳过(fail-closed)" - GHOST=1 - continue - fi - while IFS=$'\t' read -r pnum title body; do - [[ -n "$pnum" ]] || continue - for ref in $(printf '%s\n%s\n' "$title" "$body" | grep -oE "$ADR_RE" | sort -u); do - num="${ref#ADR-}" - case "$(adr_substantive "$num")" in - ok) : ;; - missing) - if [[ -n "$ADR_INDEX_MODE" ]]; then - where="INDEX entries 无此编号" - else - where="archive/adr/ 无 ADR-${num}-*.md" - fi - drift "repo '$r' PR#$pnum 引用幽灵 ADR ${ref}(${where}——C1 变更的决策背书不成立)" - GHOST=1 ;; - shell) - drift "repo '$r' PR#$pnum 引用空壳 ADR ${ref}(正本存在但缺实质结构:H1 编号行/status/背景/决策章节须齐备且决策节有正文——红队 RB-D5:字节数填充不再能绕过)" - GHOST=1 ;; - unreadable) - drift "repo '$r' PR#$pnum 引用的 ADR ${ref} 全部同名文件内容读取失败,实体性无法判定(fail-closed)" - GHOST=1 ;; - esac - done - done < <(jq -r --arg since "$ADR_SINCE" \ - '.[] | select(.merged_at != null and .merged_at >= $since) | [(.number|tostring), (.title // ""), (.body // "")] | @tsv' <<<"$PRSLIST") - done - [[ $GHOST -eq 0 ]] && ok "adr-reference-substantive(窗口内合并 PR 的 ADR 引用全部真实且有实质决策结构)" -fi - -# ---------- 11. CI-Workflows 大版本指针完整性(供应链,红队 #6-A / ADR-0016 决策 3)---------- -# 全部业务仓 gate 引用 CI-Workflows@v1 浮动指针;release-tags ruleset 的 admin bypass -# 使指针可被强移(改变所有业务仓实际执行的 CI 内容)。声明不变式(CI-Workflows README -# 版本策略):vN 恒指向最高 vN.x.y 的 commit。本节每日校验——admin 强移指针 24h 内检出, -# 不可见通道变可检测。附注 tag 指向 tag 对象(非 commit),须解引用后比对。 -# 凭据说明(评审项):本节与 §1-§10 共用 GOVERNANCE_TOKEN——AGENTS.md 声明的 -# drift-check 接口即 "GH_TOKEN= bash governance/drift-check.sh"(组织级 -# 检测 rulesets/admin/PR 清单本就需要 org 权限,token 已在 job 内);本节只读公开仓 -# CI-Workflows 的 refs,复用不扩大暴露面。工作流仅 schedule/dispatch 于可信 main -# 运行,脚本不受 PR 控制(与 gate.yml 不向 PR 上下文注入 org secret 同一威胁模型)。 -# 分页聚合(评审项:单页请求在 tag 数超页容量后会拿旧集合比较出假绿): -# 逐页拉取并验证每页均为数组,任一页失败即 fail-closed,不用部分结果继续比较。 -CW_REFS="[]" -CW_PAGE_FAIL=0 -CW_PAGE=1 -while :; do - CW_PAGE=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/matching-refs/tags/?per_page=100&page=$CW_PAGE") - if ! jq -e 'type == "array"' <<<"$CW_PAGE" >/dev/null 2>&1; then - CW_PAGE_FAIL=1 - break - fi - CW_REFS=$(jq --argjson acc "$CW_REFS" '. as $p | $acc + $p' <<<"$CW_PAGE") - [[ "$(jq 'length' <<<"$CW_PAGE")" -lt 100 ]] && break - CW_PAGE=$((CW_PAGE+1)) - [[ $CW_PAGE -gt 50 ]] && { CW_PAGE_FAIL=1; break; } # >5000 tag 视为异常,fail-closed -done -if [[ $CW_PAGE_FAIL -ne 0 ]]; then - drift "CI-Workflows tag refs 分页拉取失败(第 $CW_PAGE 页非数组/超界),大版本指针完整性无法校验(fail-closed,不用部分结果比较)" -else - # 解引用到 commit SHA(轻量 tag 直指 commit 零额外请求;附注 tag 走 tag 对象一跳) - cw_commit_sha() { # $1=tag 名( refs/tags/ 去前缀) - local row obj type sha - row=$(jq -c --arg r "refs/tags/$1" '.[] | select(.ref == $r)' <<<"$CW_REFS") - [[ -z "$row" || "$row" == "null" ]] && { echo ""; return; } - obj=$(jq -c '.object' <<<"$row") - type=$(jq -r '.type' <<<"$obj"); sha=$(jq -r '.sha' <<<"$obj") - if [[ "$type" == "tag" ]]; then - api "https://api.github.com/repos/$ORG/CI-Workflows/git/tags/$sha" | jq -r '.object.sha // empty' - else - echo "$sha" - fi - } - CW_NAMES=$(jq -r '.[].ref | sub("^refs/tags/"; "")' <<<"$CW_REFS") - while IFS= read -r ptr; do - [[ -n "$ptr" ]] || continue - N="${ptr#v}" - HIGHEST=$(grep -E "^v${N}\.[0-9]+\.[0-9]+$" <<<"$CW_NAMES" | sort -V | tail -1) - PTR_SHA=$(cw_commit_sha "$ptr") - if [[ -z "$HIGHEST" ]]; then - drift "CI-Workflows 指针 tag '$ptr' 存在但无任何 v${N}.x.y 具体版本 tag——指针失去锚点(发布流程漏步,README 版本策略)" - elif [[ -z "$PTR_SHA" ]]; then - drift "CI-Workflows 指针 '$ptr' 解引用失败(fail-closed)" - else - HIGH_SHA=$(cw_commit_sha "$HIGHEST") - if [[ "$PTR_SHA" == "$HIGH_SHA" ]]; then - ok "CI-Workflows 指针 '$ptr' == $HIGHEST(${PTR_SHA:0:7})" - else - drift "CI-Workflows 指针 '$ptr'(${PTR_SHA:0:7})≠ 最高版本 $HIGHEST(${HIGH_SHA:0:7})——指针被强移或发布流程漏步(不变式 vN==最高 vN.x.y,红队 #6-A)" - fi - fi - done < <(grep -E '^v[0-9]+$' <<<"$CW_NAMES" | sort -V) - # 必需指针存在性(评审项:删除 v1 ≠ "无指针即不适用"——v1 是全部业务仓 gate 的 - # 供应链入口(REPOS.yaml CI-Workflows role:"业务仓引用 @v1"),指针被删= - # admin bypass 绕过发布流程,必须报漂移。v2+ 指针出现后由上方循环自动纳入 - # 锚点/一致性校验;必需集合当前只声明 v1——v2 落地时随 ADR 扩充此常量。) - CW_REQUIRED_POINTERS="v1" - REQ_MISSING=0 - for req in $CW_REQUIRED_POINTERS; do - if ! grep -qx "$req" <<<"$CW_NAMES"; then - drift "CI-Workflows 必需大版本指针 '$req' 缺失(业务仓 @v1 供应链入口被删——红队 #6-A:release-tags ruleset admin bypass 通道)" - REQ_MISSING=1 - fi - done - [[ $REQ_MISSING -eq 0 ]] && ok "CI-Workflows 必需大版本指针存在($CW_REQUIRED_POINTERS)" -fi - -# ---------- 12. required check 活体存在性(P1-4,ADR-0034;in-flight 防误报修订)---------- -# 文本对账 ≠ 生效验证:ruleset JSON 完全正确的同时,required check 字符串精确 -# 匹配可能实际为空(job 改名 / workflow 重构)→ "零 required check" → PR 裸奔。 -# 判据:每个受管仓最近更新的至多 3 个已合并/打开 PR 的 head 上,必须存在每个 -# required check 名(从 rulesets/*.json 派生——单一真源)的 check run 且 -# conclusion 非空;无 PR 活动时退化为默认分支 HEAD。修订(2026-08-20 实测误报 -# mutual:三连新开 PR 的 CI 起飞窗口内 gate 尚未报 conclusion,被误判缺失): -# - CI 未完结(任一 check run 非 completed)的 head 不构成任何证据——既不算 -# "缺 gate"也不算"有 gate"(防起飞窗口误报);全部候选 in-flight 时显式 -# SKIP(非漂移,下轮复核) -# - 关闭未合并的 PR 不采样(废弃实验分支的残缺 CI 不构成证据) -# - 已完结 CI 的 PR head 缺 gate 仍照报(PR 群体缺 required check = CI 结构性 -# 异常或改名注入——T1 的检测对象;main HEAD 仅在无 PR 活动时作退化载体, -# 不作为豁免来源)。查询失败维持 fail-closed。 -# IR-0002(spec PR#152)(a)/(b) 形态区分(2026-08-21):org-gate 是 PR/merge_group -# 触发的 required workflow(org-gate.yml 的 on:),其活体证据只能来自 ruleset -# 生效后的 PR head——push-only / 生效前 PR 的 head 缺失属 (a)「从未接入」 -# (OK/INFO 计数,非漂移);仅 (b) 生效后 PR head 缺失才报裸奔。 -# 生效时刻 = org-required-workflows ruleset 创建时刻(API 实测)。 -ORG_GATE_EFFECTIVE="2026-08-20T07:43:21Z" - -# @ir0002-s12-classify-begin —— IR-0002(spec PR#152)(a)/(b) 缺失形态分类器。 -# governance/tests/test-ir0002.sh 按本标记对提取函数体做 fixture 自测((b) 检出 -# 灵敏度是 INV-4 铁律:改动本函数必须连带跑自测,红即不得合并)。 -# 入参:$1=repo $2=check 名 $3=QUERY_FAIL(0/1) $4=HAS_PR_ACTIVITY(0/1) -# $5=PRS_RECENT(PR 数组 JSON)$6=org-gate 生效 ISO 时戳 $7=N_CONCL(采样数) -# 返回:0=(a) 待接入(OK 行,非漂移) 1=(b) 裸奔(drift 报警) 2=fail-closed(drift 报警) -s12_classify() { - local r="$1" ctx="$2" QUERY_FAIL="$3" HAS_PR_ACTIVITY="$4" PRS_RECENT="$5" eff="$6" n_concl="$7" - if [[ $QUERY_FAIL -eq 1 ]]; then - drift "repo '$r' check-runs 查询失败,required check '$ctx' 活体无法验证(fail-closed)" - return 2 - elif [[ "$ctx" == "org-gate" ]] && ! jq -e --arg eff "$eff" \ - '[.[] | select((.state == "open" or .merged_at != null) and (.updated_at >= $eff))] | length > 0' \ - <<<"$PRS_RECENT" >/dev/null 2>&1; then - # IR-0002 (a) 形态:org-gate ruleset 生效后该仓无 PR 活动——从未接入, - # 非裸奔(待接入清单;本地 gate 的活体验证不受影响,照常执行) - echo "OK required-check-live '$r':'$ctx' 待接入(ruleset 生效后无 PR 活动——IR-0002 (a) 形态)" - return 0 - elif [[ "${HAS_PR_ACTIVITY:-1}" -eq 0 ]]; then - # IR-0002 (a) 泛化:无 PR 仓的 degenerate 采样下任意 required check 缺失= - # 从未接线(如新建 L1 记忆层仓无本地 gate workflow)——PR 面仍受 org - # ruleset 的 org-gate 保护;接线后自然转绿。有 PR 的仓不适用(保 (b) 检出) - echo "OK required-check-live '$r':'$ctx' 待接入(无 PR 活动,degenerate 采样——IR-0002 (a) 形态)" - return 0 - else - drift "repo '$r' required check '$ctx' 活体缺失:最近 $n_concl 个已完结 CI 的 head 均无该 check run——job 改名或 workflow 重构?裸奔窗口已开启(ADR-0034 §12;IR-0002 (b) 形态)" - return 1 - fi -} -# @ir0002-s12-classify-end - -REQ_CHECKS=$(jq -rs '[.[].rules[]? | select(.type == "required_status_checks") - | .parameters.required_status_checks[].context] | unique | .[]' "$DIR"/rulesets/*.json) -[[ -n "$REQ_CHECKS" ]] || { echo "FATAL: rulesets 未声明任何 required check——§12 活体验证失去判据"; exit 2; } -epoch_of() { date -u -d "$1" +%s; } # ISO8601 → epoch(runner GNU date) -S12_PENDING=0 # §12 全仓 (a) 待接入累计(IR-0002 spec AC-3 汇总信息行) -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - HAS_PR_ACTIVITY=1 # 有(已合并/打开的)PR head 可采样=强证据形态 - PRS_RECENT=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=all&sort=updated&direction=desc&per_page=20") - if jq -e 'type == "array"' <<<"$PRS_RECENT" >/dev/null 2>&1; then - HEADS=$(jq -r '[.[] | select(.state == "open" or .merged_at != null) | .head.sha][0:3] | .[]' <<<"$PRS_RECENT") - if [[ -z "$HEADS" ]]; then - # 无(已合并/打开的)PR 活动 → 退化为默认分支 HEAD(§12 采样处于最弱证据 - # 形态:degenerate 模式标记——缺失判定走 IR-0002 (a) 待接入分支) - HAS_PR_ACTIVITY=0 - DBR=$(api "https://api.github.com/repos/$ORG/$r" | jq -r '.default_branch // "main"') - HEADS=$(api "https://api.github.com/repos/$ORG/$r/git/ref/heads/$DBR" | jq -r '.object.sha // empty') - [[ -n "$HEADS" ]] || { drift "repo '$r' 无 PR 活动且默认分支 HEAD 不可读,活体验证无载体(fail-closed)"; continue; } - fi - else - drift "repo '$r' PR 清单拉取失败,required check 活体验证无法执行(fail-closed)" - continue - fi - # 采样修订(#85 T1 首轮实测漏检教训:并发连开 PR 会把改名注入头挤出「前3」, - # 而 in-flight 头白白占用采样位):先跳过零-check/in-flight,再收集最近 - # COLLECT_N=3 个「已完结」head(最多扫描 10 个候选)作为活体样本。 - COLLECT_N=3 - CONCL_SHAS="" - N_CONCL=0 - N_SEEN=0 - QUERY_FAIL=0 - SAW_INFLIGHT=0 - while IFS= read -r sha; do - [[ -n "$sha" ]] || continue - { [[ $N_CONCL -ge $COLLECT_N ]] || [[ $N_SEEN -ge 10 ]]; } && break - N_SEEN=$((N_SEEN+1)) - CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") - if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then - QUERY_FAIL=1; continue - fi - TOTAL=$(jq -r '.total_count // 0' <<<"$CRS") - [[ "$TOTAL" -eq 0 ]] && continue - # CI 未完结的 head:不构成证据也不占采样位(防起飞窗口误报——gate job 在 - # 依赖图末端,新开 PR 的前几分钟 conclusion 必为 null) - if jq -e '[.check_runs[] | select(.status != "completed")] | length > 0' <<<"$CRS" >/dev/null 2>&1; then - SAW_INFLIGHT=1; continue - fi - N_CONCL=$((N_CONCL+1)) - CONCL_SHAS="$CONCL_SHAS$sha"$' -' - done <<<"$HEADS" - if [[ $N_CONCL -eq 0 ]]; then - if [[ $QUERY_FAIL -eq 1 ]]; then - drift "repo '$r' check-runs 查询失败,required check 活体无法验证(fail-closed)" - continue - fi - echo "SKIP required-check-live '$r'(候选 head 全部 CI in-flight/零 check,本轮无法判定——非漂移,下轮复核)" - continue - fi - LIVE_MISS=0 - REPO_PENDING=0 # 本仓 (a) 待接入计数(IR-0002 spec AC-3:待接入须计数可见) - for ctx in $REQ_CHECKS; do - FOUND=0 - while IFS= read -r sha; do - [[ -n "$sha" ]] || continue - CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") - jq -e --arg c "$ctx" '[.check_runs[] | select(.name == $c and .conclusion != null)] | length > 0' <<<"$CRS" >/dev/null 2>&1 && { FOUND=1; break; } - done <<<"$CONCL_SHAS" - if [[ $FOUND -ne 1 ]]; then - rc=0 - s12_classify "$r" "$ctx" "$QUERY_FAIL" "${HAS_PR_ACTIVITY:-1}" "$PRS_RECENT" "$ORG_GATE_EFFECTIVE" "$N_CONCL" || rc=$? - case $rc in - 0) REPO_PENDING=$((REPO_PENDING+1)); S12_PENDING=$((S12_PENDING+1)) ;; - 1) LIVE_MISS=1 ;; - 2) : ;; - esac - fi - done - [[ $REPO_PENDING -gt 0 ]] \ - && echo "INFO required-check-live '$r':$REPO_PENDING 项待接入计入汇总(IR-0002 (a)——不阻断)" \ - || true - [[ $LIVE_MISS -eq 0 ]] && ok "required-check-live '$r'(最近 $N_CONCL 个已完结 head 上 required check 齐备)" -done -# IR-0002 spec AC-3:§12 汇总信息行——(a) 待接入非漂移但必须计数可见(防"静默豁免" -# 演变为检测盲区;§17 协议块对账同款"可见即治理"原则)。零待接入时也打 OK 行, -# 让"本节确实跑过"成为可审计事实。 -if [[ ${S12_PENDING:-0} -gt 0 ]]; then - echo "INFO §12 required-check 活体汇总:待接入 $S12_PENDING 项(IR-0002 (a) 形态——ruleset 生效后无 PR 活动,接线后自然转绿;非漂移)" -else - ok "§12 required-check 活体汇总:0 项待接入(全部受管仓要么齐备要么显式 SKIP)" -fi - -# ---------- 13. PR liveness 侦测(P1-4,ADR-0034)---------- -# 治理不漂移但流水线死了的三类形态(#81 §6——无人值守下卡死 PR 是最隐形的 -# 人类瓶颈):(a) auto-merge 已设置但 > 阈值无进展;(b) check 停留 queued/ -# in_progress 超 > 阈值;(c) PR 创建超阈值且 head 上零 check run(应有而无)。 -# 命中即走 GM-1 既有漂移 issue 通道。阈值:expected-state pr_liveness_hours, -# 环境变量 PR_LIVENESS_HOURS 可覆盖(dispatch input liveness_hours 透传,注入测试用)。 -LIVENESS_H="${PR_LIVENESS_HOURS:-$(jq -r '.pr_liveness_hours // 4' "$EXPECTED")}" -LIVENESS_S=$(( LIVENESS_H * 3600 )) -STUCK_TOTAL=0 -for r in $REPOS; do - jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue - OPEN_PRS=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=open&per_page=30") - jq -e 'type == "array"' <<<"$OPEN_PRS" >/dev/null 2>&1 \ - || { drift "repo '$r' open PR 清单拉取失败,liveness 侦测无法执行(fail-closed)"; continue; } - STUCK=0 - while IFS=$'\t' read -r pnum created updated headsha has_am; do - [[ -n "$pnum" ]] || continue - AGE_UPD=$(( NOW_EPOCH - $(epoch_of "$updated") )) - AGE_CRE=$(( NOW_EPOCH - $(epoch_of "$created") )) - if [[ "$has_am" == "true" && $AGE_UPD -gt $LIVENESS_S ]]; then - drift "repo '$r' PR#$pnum auto-merge 已开启但 ${LIVENESS_H}h 无进展(updated ${AGE_UPD}s 前)——卡死侦测 (a):查 required check 状态/分支冲突(ADR-0034 §13)" - STUCK=1; continue - fi - CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$headsha/check-runs?per_page=100") - if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then - drift "repo '$r' PR#$pnum head check-runs 查询失败,liveness 无法验证(fail-closed)" - STUCK=1; continue - fi - N_RUNS=$(jq '.check_runs | length' <<<"$CRS") - if [[ "$N_RUNS" -eq 0 && $AGE_CRE -gt $LIVENESS_S ]]; then - drift "repo '$r' PR#$pnum 创建 ${AGE_CRE}s 且 head 零 check run——卡死侦测 (c):应有而无(workflow 未触发/被改名,ADR-0034 §13)" - STUCK=1; continue - fi - # pending 超龄判定(b):shell 循环做日期运算(jq 无日期运算) - while IFS=$'\t' read -r crname crstatus crstart; do - [[ -n "$crname" ]] || continue - [[ "$crstatus" == "queued" || "$crstatus" == "in_progress" ]] || continue - [[ -n "$crstart" ]] || continue - AGE_PEND=$(( NOW_EPOCH - $(epoch_of "$crstart") )) - if [[ $AGE_PEND -gt $LIVENESS_S ]]; then - drift "repo '$r' PR#$pnum check '$crname' 停留 $crstatus 已 ${AGE_PEND}s——卡死侦测 (b):永久 pending(ADR-0034 §13)" - STUCK=1 - fi - done < <(jq -r '.check_runs[] | [.name, .status, (.started_at // "")] | @tsv' <<<"$CRS") - done < <(jq -r '.[] | [(.number|tostring), .created_at, .updated_at, .head.sha, (.auto_merge != null | tostring)] | @tsv' <<<"$OPEN_PRS") - STUCK_TOTAL=$((STUCK_TOTAL+STUCK)) -done -[[ $STUCK_TOTAL -eq 0 ]] && ok "pr-liveness(全部受管仓 open PR 无卡死,阈值 ${LIVENESS_H}h)" - -# ---------- 15. org-required-workflows 钉点完整性(P3-1 #95 / ADR-0046)---------- -# §1 已对账 ruleset JSON 文本(path/ref/repository_id 任何改动即漂移)。本节补盲区: -# ref 为 tag 时,tag 指针被移动(ruleset 文本不变、内容换了)——钉点的 commit 绑定 -# 必须与 expected-state.org_required_workflows.ref_commit 一致;钉点 tag 还必须是 -# CI-Workflows 当前发布不变式(§11:vN==最高 vN.x.y)认可的物。fail-closed。 -ORW_CFG=$(jq -c '.org_required_workflows // empty' "$EXPECTED") -if [[ -n "$ORW_CFG" ]]; then - WANT_RULESET=$(jq -r '.ruleset' <<<"$ORW_CFG") - WANT_REPO_ID=$(jq -r '.repository_id' <<<"$ORW_CFG") - WANT_PATH=$(jq -r '.path' <<<"$ORW_CFG") - WANT_REF=$(jq -r '.ref' <<<"$ORW_CFG") - WANT_COMMIT=$(jq -r '.ref_commit' <<<"$ORW_CFG") - ORW_LIST=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") - ORW_ID=$(jq -r --arg n "$WANT_RULESET" '.[] | select(.name == $n) | .id' <<<"$ORW_LIST" 2>/dev/null | head -1) - if [[ -z "$ORW_ID" || "$ORW_ID" == "null" ]]; then - drift "org-required-workflows ruleset '$WANT_RULESET' 线上不存在——中心审判失效,全部受管仓回落本地 gate(P3-1 枢轴脱离,ADR-0046)" - else - # LIST 端点不含 rules 数组(同 §1 教训)——须拉 detail 才能取 workflows 规则 - ORW_ROW=$(api "https://api.github.com/orgs/$ORG/rulesets/$ORW_ID") - GOT_WF=$(jq -c '.rules[] | select(.type == "workflows") | .parameters.workflows[0] // empty' <<<"$ORW_ROW") - GOT_PATH=$(jq -r '.path // empty' <<<"$GOT_WF") - GOT_REF=$(jq -r '.ref // empty' <<<"$GOT_WF") - GOT_REPO_ID=$(jq -r '.repository_id // empty' <<<"$GOT_WF") - BAD="" - [[ "$GOT_PATH" == "$WANT_PATH" ]] || BAD="$BAD path=$GOT_PATH" - [[ "$GOT_REF" == "$WANT_REF" ]] || BAD="$BAD ref=$GOT_REF" - [[ "$GOT_REPO_ID" == "$WANT_REPO_ID" ]] || BAD="$BAD repository_id=$GOT_REPO_ID" - if [[ -n "$BAD" ]]; then - drift "org-required-workflows 钉点漂移:$BAD(期望 path=$WANT_PATH ref=$WANT_REF repo_id=$WANT_REPO_ID)——审判源被改指(ADR-0046 §15)" - fi - # commit 绑定校验(tag 或直接 SHA;ISSUE-263 W1-C5 改为直接钉 commit SHA) - if [[ "$WANT_REF" =~ ^[0-9a-f]{40}$ ]]; then - # 直接钉 commit SHA:校验该对象在目标仓可达 - COMMIT_OBJ=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/commits/$WANT_REF") - TAGCOMMIT=$(jq -r '.sha // empty' <<<"$COMMIT_OBJ") - else - SHORTREF="${WANT_REF#refs/tags/}" - TAGROW=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/ref/tags/$SHORTREF") - TAGCOMMIT=$(jq -r '.object.sha // empty' <<<"$TAGROW") - fi - if [[ -z "$TAGCOMMIT" ]]; then - drift "org-required-workflows 钉点 $WANT_REF 解引用/校验失败(fail-closed,ADR-0046 §15)" - elif [[ "$TAGCOMMIT" != "$WANT_COMMIT" ]]; then - drift "org-required-workflows 钉点 $WANT_REF 已移动:${TAGCOMMIT:0:8} ≠ 声明 ${WANT_COMMIT:0:8}——审判内容被换(ADR-0046 §15;还原或更新 expected-state)" - elif [[ -n "$BAD" ]]; then - : # 钉点漂移已上报,不再输出 OK 行(避免同段 OK/DRIFT 并存的误导) - else - ok "org-required-workflows 钉点完整($WANT_REF == ${TAGCOMMIT:0:8},path/repository_id 一致)" - fi - fi -fi - -# ---------- 16. 治理标签(state:*/type:* 存在性与形状,W0-C2/ADR-0047)---------- -# expected-state.json#labels.items 为治理标签期望全集。只对账"应有哪些"(新增式), -# 各仓自有业务标签不属治理面、不判漂移;缺失或 color/description 不符 = 漂移。 -# items 为空视为期望状态缺失(fail-closed——治理标签全集被误删不得静默通过)。 -LBL_ITEMS=$(jq -c '.labels.items // []' "$EXPECTED") -LBL_N=$(jq 'length' <<<"$LBL_ITEMS") -if [[ "$LBL_N" -eq 0 ]]; then - drift "expected-state.json#labels.items 为空——治理标签期望状态缺失(fail-closed,ADR-0047)" -else - LBL_DRIFT=0 - for r in $REPOS; do - jq -e --arg r "$r" 'index($r) != null' <<<"$EXCLUDES" >/dev/null && continue - # 分页拉全量标签(>100 标签的仓单页会漏——与 §1 同教训) - RL_TMP=$(mktemp); RL_ERR=0; RL_PAGE=1 - while :; do - CHUNK=$(api "https://api.github.com/repos/$ORG/$r/labels?per_page=100&page=$RL_PAGE") - if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then - CHUNK_MSG=$(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || true) - [[ -z "$CHUNK" ]] && CHUNK_MSG="传输失败(空响应)" - drift "repo '$r' 标签清单拉取失败($CHUNK_MSG)——标签对账跳过(fail-closed)" - LBL_DRIFT=1; RL_ERR=1; break - fi - RL_N=$(jq 'length' <<<"$CHUNK"); [[ "$RL_N" -eq 0 ]] && break - jq -c '.[] | {name, color, description: (.description // "")}' <<<"$CHUNK" >>"$RL_TMP" - [[ "$RL_N" -lt 100 ]] && break - RL_PAGE=$((RL_PAGE+1)) - done - if [[ $RL_ERR -eq 1 ]]; then rm -f "$RL_TMP"; continue; fi - while IFS= read -r want; do - wname=$(jq -r .name <<<"$want"); wcolor=$(jq -r .color <<<"$want"); wdesc=$(jq -r .description <<<"$want") - got=$(jq -r --arg n "$wname" 'select(.name == $n) | "\(.color) \(.description)"' <"$RL_TMP" 2>/dev/null | head -1) - if [[ -z "$got" ]]; then - drift "repo '$r' 缺治理标签 '$wname'(修复:bash governance/apply.sh §7)"; LBL_DRIFT=1 - elif [[ "$got" != "$(printf '%s %s' "$wcolor" "$wdesc")" ]]; then - drift "repo '$r' 标签 '$wname' 形状不符:$got 期望 color=$wcolor(apply.sh §7 对齐)"; LBL_DRIFT=1 - fi - done < <(jq -c '.[]' <<<"$LBL_ITEMS") - rm -f "$RL_TMP" - done - [[ $LBL_DRIFT -eq 0 ]] && ok "治理标签全集($LBL_N 项 × 受管仓)一致" -fi - -# ---------- 17. 统一入口协议块一致性(W1-C3 #166 / ADR-0055,宪法 §11/§4D)---------- -# REPOS.yaml 中 entry_protocol: true 的仓:其 main 的 AGENTS.md 须携带与 -# template-service main(统一下发真源)逐字节一致的协议块—— -# 提取 标记间内容(含标记)比对。 -# 缺失标记/内容不一致/拉取失败 = 漂移(fail-closed:检测器失明不得伪装通过)。 -# 产品仓 rollout(7 个业务仓下发协议块)是后续 fleet 小卡——届时逐仓加 -# entry_protocol: true 即纳入本节管辖。 -PROTO_OK=0 -PROTO_REPOS="" -# REPOS.yaml 是 YAML——jq 直读必失败且被吞掉=假绿(§7 同教训)。§17 自解析 -# (§7 同款 python3+pyyaml→JSON,不信任继承的 REPO_MAP——调用方 env 注入可 -# 重定向对账对象);解析不可用或 entry_protocol 清单为空均 fail-closed。 -PROTO_MAP="" -if python3 -c 'import yaml' 2>/dev/null; then - PROTO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' \ - "$DIR/REPOS.yaml" 2>/dev/null || true) -fi -if [[ -n "$PROTO_MAP" ]]; then - PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' <<<"$PROTO_MAP") - if [[ -z "$PROTO_REPOS" ]]; then - drift "REPOS.yaml 无 entry_protocol 仓——协议块对账失去对象(申报丢失=漂移,fail-closed,ADR-0055 §17)" - PROTO_OK=1 - fi -else - drift "REPOS.yaml 未解析(缺 python3+pyyaml)——协议块对账不可执行(fail-closed,ADR-0055 §17)" - PROTO_OK=1 -fi -proto_block() { # 提取协议块(标记间内容,含首尾标记行)——起止标记任一缺失输出空 - # (缺末标记时不得输出“半块”:半块≠空,会让比对产生误判——fail-closed) - local t - t=$(cat) - if [[ "$t" == *""* ]]; then - awk '//{f=1} f{print} //{exit}' <<<"$t" - fi -} -CANON_RAW=$(api "https://raw.githubusercontent.com/$ORG/template-service/main/AGENTS.md" 2>/dev/null || true) -CANON_BLOCK="" -if [[ -z "$CANON_RAW" || "$CANON_RAW" == "404:"* ]]; then - drift "template-service main AGENTS.md 拉取失败(协议块真源不可读——fail-closed)" - PROTO_OK=1 -else - CANON_BLOCK=$(proto_block <<<"$CANON_RAW") - if [[ -z "$CANON_BLOCK" ]]; then - drift "template-service main AGENTS.md 缺协议块或标记不完整(宪法 §4D:块由 template-service 统一下发)" - PROTO_OK=1 - fi -fi -PROTO_N=0 -for r in $PROTO_REPOS; do - PROTO_N=$((PROTO_N+1)) - [[ "$r" == "template-service" ]] && continue # 真源自身即 canon(同 URL 已验) - RAW=$(api "https://raw.githubusercontent.com/$ORG/$r/main/AGENTS.md" 2>/dev/null || true) - if [[ -z "$RAW" || "$RAW" == "404:"* ]]; then - drift "repo '$r' AGENTS.md 拉取失败(协议块对账跳过——fail-closed,ADR-0055 §17)" - PROTO_OK=1 - continue - fi - BLOCK=$(proto_block <<<"$RAW") - if [[ -z "$BLOCK" ]]; then - drift "repo '$r' AGENTS.md 缺协议块标记或标记不完整(entry_protocol 已申报——宪法 §11 统一入口)" - PROTO_OK=1 - continue - fi - if [[ "$BLOCK" != "$CANON_BLOCK" ]]; then - # 双 here-string 会互相覆盖 stdin(SC2261,diff 拿不到操作数)——用进程替换 - drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <(printf '%s\n' "$CANON_BLOCK") <(printf '%s\n' "$BLOCK") 2>&1 | head -3 | tr '\n' ' ' | cut -c1-160))" - PROTO_OK=1 - fi -done -[[ $PROTO_OK -eq 0 ]] && ok "统一入口协议块一致(真源 template-service × $PROTO_N 个 entry_protocol 仓,逐字节比对)" - -# ---------- 18. holdout 隔离断言(DECISION-02:App 安装差异隔离,W1-C4/ADR-0056,ADR-0080 修订)---------- -# 试卷层 holdout 的读隔离不靠保密(公开仓,ADR-0056/DECISION-02),靠三条: -# - cloudbrid-agent App 不安装到该仓(agent 的组织级凭据通道物理不可达); -# - 验证者 APP 可安装,但仅作为测试/验证路径写入主体(ADR-0080); -# - 泄漏诱饵周检(.github 仓 holdout-canary-sweep,宪法 §6/§11)。 -# 本节断言 App 挂载差异: -# - cloudbrid-agent installation 的仓清单含 holdout = P0 漂移; -# - 验证者 APP installation 的仓清单含 holdout = OK(合法); -# - 验证者 APP 尚未安装 → INFO/SKIP(实施阶段再断言)。 -# 正向对照防检测器失明假绿(§4 同思想):cloudbrid-agent 的 installation 清单必须 -# 非空且含 .github——连 .github 都不在清单 = 端点读法错/权限变了 → 按漂移报错, -# 绝不把"看不见"当"没有"。API 任何失败 = fail-closed 报漂移。 -# §17 编号预留给并行卡 W1-C3(多代理并行修改本文件,编号互不占段)。 -# 端点注(2026-08-21 实测):installation id 走 /orgs//installations(admin -# 端点,new-repo-init.sh §3 同款)——/user/installations 要求 GitHub App 令牌, -# 经典 PAT 一律 403;仓清单走 /user/installations//repositories(经典 PAT 可读)。 -HOLDOUT_REPO="holdout" - -# 拉取一次 org 安装列表,供 cloudbrid-agent 与验证者 APP 共用。 -HOLDOUT_INST=$(api "https://api.github.com/orgs/$ORG/installations?per_page=100") -if ! jq -e 'type == "object" and (.installations | type == "array")' <<<"$HOLDOUT_INST" >/dev/null 2>&1; then - drift "holdout 隔离断言失败:org installations 拉取失败(fail-closed——检测器失明不得伪装通过,ADR-0056 §18): $(jq -r '.message // "非 JSON 响应"' <<<"$HOLDOUT_INST" 2>/dev/null || echo 传输失败)" -else - # 辅助函数:拉取指定 installation 下的全部仓名;stdout 为排序去重后的仓名列表; - # 失败时返回非 0(stdout 为空,不应使用)。 - holdout_install_repos() { - local iid="$1" tmp fail page n chunk - tmp=$(mktemp) - fail=0 - page=1 - while :; do - chunk=$(api "https://api.github.com/user/installations/$iid/repositories?per_page=100&page=$page") - if ! jq -e 'type == "object" and (.repositories | type == "array")' <<<"$chunk" >/dev/null 2>&1; then - fail=1; break - fi - n=$(jq '.repositories | length' <<<"$chunk") - [[ "$n" -eq 0 ]] && break - jq -r '.repositories[].name' <<<"$chunk" >>"$tmp" - [[ "$n" -lt 100 ]] && break - page=$((page+1)) - done - if [[ $fail -ne 0 ]]; then - rm -f "$tmp" - return 1 - fi - sort -u "$tmp" - rm -f "$tmp" - } - - # cloudbrid-agent:老 DECISION-02 主体,必须不挂载 holdout。 - HOLDOUT_APP=$(jq -r .github_app.name "$EXPECTED") - INST_ID=$(jq -r --arg s "$HOLDOUT_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) - if [[ -z "$INST_ID" || "$INST_ID" == "null" ]]; then - drift "app '$HOLDOUT_APP' 的 installation 未找到——holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" - else - INST_REPOS=$(holdout_install_repos "$INST_ID") - if [[ $? -ne 0 ]]; then - drift "installation#$INST_ID 仓清单拉取失败,holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" - else - HOLDOUT_HIT=0 - grep -qx "$HOLDOUT_REPO" <<<"$INST_REPOS" && HOLDOUT_HIT=1 - # 正向对照(防失明):清单非空且含 .github,否则断言结论不可信 - if [[ -z "$INST_REPOS" ]]; then - drift "installation#$INST_ID 仓清单为空——正向对照失败(端点读法或权限已变),不得把看不见当没有(fail-closed,ADR-0056 §18)" - HOLDOUT_HIT=-1 - elif ! grep -qx ".github" <<<"$INST_REPOS"; then - drift "installation#$INST_ID 仓清单不含 .github——正向对照失败(端点读法或权限已变),holdout 隔离结论不可信(fail-closed,ADR-0056 §18)" - HOLDOUT_HIT=-1 - fi - if [[ $HOLDOUT_HIT -eq 1 ]]; then - drift "holdout 出现在 app '$HOLDOUT_APP' installation#$INST_ID 仓清单——P0:cloudbrid-agent 挂上 holdout = 试卷层隔离失效(DECISION-02/ADR-0056 §18;立即在 App 设置页移除该仓访问并追查何时挂载)" - elif [[ $HOLDOUT_HIT -eq 0 ]]; then - ok "holdout 隔离成立(cloudbrid-agent installation#$INST_ID 共 $(grep -c . <<<"$INST_REPOS") 仓不含 holdout;正向对照 .github 在清单)" - fi - fi - fi - - # 验证者 APP(ADR-0080):允许挂载 holdout,作为测试/验证路径写入主体。 - VERIFIER_APP=$(jq -r '.verifier_app.name // empty' "$EXPECTED") - if [[ -n "$VERIFIER_APP" && "$VERIFIER_APP" != "null" ]]; then - VERIFIER_INST_ID=$(jq -r --arg s "$VERIFIER_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) - if [[ -n "$VERIFIER_INST_ID" && "$VERIFIER_INST_ID" != "null" ]]; then - VERIFIER_REPOS=$(holdout_install_repos "$VERIFIER_INST_ID") - if [[ $? -ne 0 ]]; then - drift "验证者 APP '$VERIFIER_APP' installation#$VERIFIER_INST_ID 仓清单拉取失败,无法校验 holdout 挂载合法性(fail-closed,ADR-0080 §18)" - elif grep -qx "$HOLDOUT_REPO" <<<"$VERIFIER_REPOS"; then - ok "holdout 由验证者 APP '$VERIFIER_APP' 合法挂载(installation#$VERIFIER_INST_ID,ADR-0080)" - else - ok "验证者 APP '$VERIFIER_APP' 已安装但未挂载 holdout(ADR-0080 允许,非漂移)" - fi - else - echo "INFO 验证者 APP '$VERIFIER_APP' 尚未安装——holdout 挂载合法性断言跳过(实施阶段再校验,ADR-0080 §18)" - fi - fi -fi - -echo "----------------------------------------" -if [[ $DRIFTS -gt 0 ]]; then - echo "结果: $DRIFTS 项漂移。修复: bash governance/apply.sh 或手动改回" - exit 1 -fi +#!/usr/bin/env bash +# drift-check.sh —— 治理飘移检测(只读) +# +# 对比 governance/expected-state.json + rulesets/*.json 与线上实际配置。 +# 输出 OK/DRIFT 逐项结果;发现任何漂移 exit 1。 +# +# 用法: GH_TOKEN= bash drift-check.sh +# CI 中由 .github/workflows/governance-drift.yml 调用,凭据来自 secret GOVERNANCE_TOKEN。 +set -uo pipefail + +ORG="${ORG:-Cloudbird-Software}" +DIR="$(cd "$(dirname "$0")" && pwd)" +EXPECTED="$DIR/expected-state.json" +DRIFTS=0 + +ok() { echo "OK $1"; } +drift(){ echo "DRIFT $1"; DRIFTS=$((DRIFTS+1)); } + +api() { curl -sS -H "Authorization: Bearer ${GH_TOKEN:?需要 org admin GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" "$@"; } + +[[ -n "${GH_TOKEN:-}" ]] || { echo "FATAL: GH_TOKEN 未设置"; exit 2; } + +# ---------- 1. Rulesets:存在性 / enforcement / 核心规则 ---------- +ACTUAL_RULESETS=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") +# fail-closed(ADR-0083 决策 4):清单非数组(token 失效/网络失败/权限丢失)时, +# §1 的「不存在」判定全部是伪读数——2026-08-23 GOVERNANCE_TOKEN 失效期间曾把 +# 四个真实存在的 ruleset 全报「不存在」+ jq 对字符串行报错刷屏。检测器失明 +# 不得伪装成漂移或无漂移:显式 exit 2(与 §4 仓库清单 loud-failure 契约一致)。 +if ! jq -e 'type == "array"' <<<"$ACTUAL_RULESETS" >/dev/null 2>&1; then + echo "FATAL: org ruleset 清单拉取失败($(jq -r '.message // "非数组"' <<<"$ACTUAL_RULESETS" 2>/dev/null || echo 传输失败)),drift 检测失明——中止" >&2 + exit 2 +fi +for f in "$DIR"/rulesets/*.json; do + name=$(jq -r .name "$f") + want_enf=$(jq -r .enforcement "$f") + want_rules=$(jq -c '[.rules[].type] | sort' "$f") + row=$(jq -c --arg n "$name" '.[] | select(.name == $n)' <<<"$ACTUAL_RULESETS") + if [[ -z "$row" || "$row" == "null" ]]; then + drift "ruleset '$name' 不存在(定义文件 $f)"; continue + fi + got_enf=$(jq -r .enforcement <<<"$row") + [[ "$got_enf" == "$want_enf" ]] || drift "ruleset '$name' enforcement=$got_enf 期望=$want_enf" + # 线上完整定义(含 id)对比规则集 + rid=$(jq -r .id <<<"$row") + detail=$(api "https://api.github.com/orgs/$ORG/rulesets/$rid") + got_rules=$(jq -c '[.rules[].type] | sort' <<<"$detail") + [[ "$got_rules" == "$want_rules" ]] || drift "ruleset '$name' 规则集变动: $got_rules 期望=$want_rules" + # 精确 diff:把线上定义裁剪成与落盘文件同构后比较(剔除 API 自动填充的空字段) + got_norm=$(jq -S 'del(.rules[].parameters.required_reviewers? | select(. == [])) | {name,target,enforcement,conditions,bypass_actors,rules}' <<<"$detail") + want_norm=$(jq -S '{name,target,enforcement,conditions,bypass_actors,rules}' "$f") + [[ "$got_norm" == "$want_norm" ]] \ + || drift "ruleset '$name' 定义与落盘不一致: $(diff <(echo "$want_norm") <(echo "$got_norm") | head -10)" + ok "ruleset '$name'" +done +EXTRA=$(jq -c --argjson known "$(jq -s '[.[].name]' "$DIR"/rulesets/*.json)" \ + '[.[].name] - $known' <<<"$ACTUAL_RULESETS") +[[ "$EXTRA" == "[]" ]] || drift "线上存在未落盘的 ruleset: $EXTRA(落盘或删除)" + +# ---------- 2. Actions 策略 ---------- +AP=$(api "https://api.github.com/orgs/$ORG/actions/permissions") +[[ "$(jq -r .allowed_actions <<<"$AP")" == "$(jq -r .actions_policy.allowed_actions "$EXPECTED")" ]] \ + || drift "allowed_actions=$(jq -r .allowed_actions <<<"$AP")" +# enabled_repositories 同检(ADR-0021):apply §2 写该字段而 check 不验=修复回路盲区 +[[ "$(jq -r .enabled_repositories <<<"$AP")" == "$(jq -r .actions_policy.enabled_repositories "$EXPECTED")" ]] \ + || drift "enabled_repositories=$(jq -r .enabled_repositories <<<"$AP") ≠ expected $(jq -r .actions_policy.enabled_repositories "$EXPECTED")" + +SA=$(api "https://api.github.com/orgs/$ORG/actions/permissions/selected-actions") +[[ "$(jq -c '{github_owned_allowed, verified_allowed, patterns_allowed}' <<<"$SA")" == \ + "$(jq -c '.actions_policy | {github_owned_allowed, verified_allowed, patterns_allowed}' "$EXPECTED")" ]] \ + || drift "allowed actions 白名单不一致: $(jq -c .patterns_allowed <<<"$SA")" + +WF=$(api "https://api.github.com/orgs/$ORG/actions/permissions/workflow") +[[ "$(jq -r .default_workflow_permissions <<<"$WF")" == "$(jq -r .actions_policy.default_workflow_permissions "$EXPECTED")" ]] \ + || drift "default_workflow_permissions=$(jq -r .default_workflow_permissions <<<"$WF")" +ok "actions 策略(含白名单)" + +# ---------- 3. Code Security 默认配置 ---------- +CS=$(api "https://api.github.com/orgs/$ORG/code-security/configurations") +want_cs=$(jq -r .code_security.configuration_name "$EXPECTED") +csrow=$(jq -c --arg n "$want_cs" '.[] | select(.name == $n)' <<<"$CS") +if [[ -z "$csrow" || "$csrow" == "null" ]]; then + drift "code security 配置 '$want_cs' 不存在" +else + # 注意:default_for_new_repos 是只写 API(PUT .../defaults),GitHub 不提供读取端点, + # 无法只读验证。由 apply.sh 的幂等 PUT 保证,drift-check 只验证配置本体存在。 + ok "code security '$want_cs'(default_for_new_repos 由 apply 保证,API 无读取端点)" +fi + +# ---------- 4. 仓库基线(squash-only / 删分支 / auto-merge,ADR-0029)---------- +EXCLUDES=$(jq -c '.repo_baseline.exclude_repos // []' "$EXPECTED") +# auto-merge 期望值(ADR-0029):repo setting 不受 ruleset 管,静默漂移 = 自动合并 +# 全链路静默失效。字段读不到(null——如凭据缺 administration 读权限的 fine-grained +# PAT)与 false 同判漂移——fail-closed:检测器失明不得伪装成无漂移 +WANT_AUTO_MERGE=$(jq -r '.repo_baseline.allow_auto_merge // true' "$EXPECTED") +# 全分页拉取 org 仓库(评审项:单页 100 时 >100 仓的 org 会漏检后续仓库); +# fail-closed:清单拉取失败/非数组/为空时 REPOS 为空会让后续全部循环静默跳过、 +# 检测整体假绿——此处显式中止,检测器失明不得伪装成无漂移 +REPOS_TMP=$(mktemp) +PAGE=1 +while :; do + CHUNK=$(api "https://api.github.com/orgs/$ORG/repos?per_page=100&page=$PAGE") + if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then + echo "FATAL: org 仓库清单拉取失败($(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)),无法检测" >&2 + exit 2 + fi + N=$(jq 'length' <<<"$CHUNK") + [[ "$N" -eq 0 ]] && break + jq -r '.[].name' <<<"$CHUNK" >>"$REPOS_TMP" + [[ "$N" -lt 100 ]] && break + PAGE=$((PAGE+1)) +done +REPOS=$(cat "$REPOS_TMP") +rm -f "$REPOS_TMP" +if [[ -z "$REPOS" ]]; then + echo "FATAL: org 仓库清单为空(org 至少应含本治理仓)——清单异常,无法检测" >&2 + exit 2 +fi +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + RR=$(api "https://api.github.com/repos/$ORG/$r") + bad="" + [[ "$(jq -r .allow_squash_merge <<<"$RR")" == "true" ]] || bad="$bad squash-off" + [[ "$(jq -r .allow_merge_commit <<<"$RR")" == "false" ]] || bad="$bad merge-commit-on" + [[ "$(jq -r .allow_rebase_merge <<<"$RR")" == "false" ]] || bad="$bad rebase-on" + [[ "$(jq -r .delete_branch_on_merge <<<"$RR")" == "true" ]] || bad="$bad keep-branch" + [[ "$(jq -r .allow_auto_merge <<<"$RR")" == "$WANT_AUTO_MERGE" ]] || bad="$bad auto-merge-off(got=$(jq -r .allow_auto_merge <<<"$RR"))" + [[ -z "$bad" ]] && ok "repo baseline '$r'" || drift "repo '$r' 基线异常:$bad" +done + +# ---------- 5. 必需 org secrets(只查存在性,值不可读)---------- +SECRETS=$(api "https://api.github.com/orgs/$ORG/actions/secrets") +for s in $(jq -r '.org_secrets_required[]' "$EXPECTED"); do + jq -e --arg s "$s" 'any(.secrets[]; .name == $s)' <<<"$SECRETS" >/dev/null \ + && ok "org secret '$s'" || drift "org secret '$s' 缺失" +done + +# ---------- 6. GitHub App 权限形状 ---------- +APP=$(api "https://api.github.com/apps/$(jq -r .github_app.name "$EXPECTED")") +if [[ "$(jq -r .id <<<"$APP" 2>/dev/null)" == "$(jq -r .github_app.id "$EXPECTED")" ]]; then + for p in $(jq -r '.github_app.permissions | to_entries[] | "\(.key)=\(.value)"' "$EXPECTED"); do + k=${p%%=*}; v=${p##*=} + [[ "$(jq -r .permissions.$k <<<"$APP")" == "$v" ]] || drift "App 权限 $k 变动" + done + for p in $(jq -r '.github_app.must_not_have[]' "$EXPECTED"); do + jq -e --arg p "$p" '.permissions | has($p)' <<<"$APP" >/dev/null \ + && drift "App 出现禁用权限 '$p'(应立即在 App 设置页移除)" + done + ok "github app '$(jq -r .github_app.name "$EXPECTED")'" +else + drift "github app '$(jq -r .github_app.name "$EXPECTED")' 不存在或 id 不符" +fi + +# ---------- 7. 组织地图(REPOS.yaml):存在性 / visibility / 未申报仓 ---------- +if python3 -c 'import yaml' 2>/dev/null; then + REPO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' "$DIR/REPOS.yaml") + # 7a. active 仓:必须存在且 visibility 一致 + while IFS=$'\t' read -r r want_vis; do + [[ -n "$r" ]] || continue + RR=$(api "https://api.github.com/repos/$ORG/$r") + if [[ "$(jq -r 'if .message then .message else "" end' <<<"$RR")" == "Not Found" ]]; then + drift "REPOS.yaml 申报的 active 仓 '$r' 不存在"; continue + fi + got_vis=$(jq -r 'if .private then "private" else "public" end' <<<"$RR") + [[ "$got_vis" == "$want_vis" ]] || drift "repo '$r' visibility=$got_vis 期望=$want_vis" + [[ "$want_vis" == "public" ]] || drift "repo '$r' 申报 visibility=$want_vis 违反全仓公开政策(ADR-0020)" + ok "REPOS map '$r'" + done < <(jq -r '.repos[] | select(.status=="active") | "\(.name)\t\(.visibility)"' <<<"$REPO_MAP") + # 7b/7c. 线上全量仓合并遍历(每仓一次 API): + # 7b. 必须在图中申报(任何 status 均可,未申报即漂移) + # 7c. 全仓公开政策(ADR-0020):private 即漂移——不依赖申报完整性(未申报仓 + # 同样受检);exempt 状态只豁免治理基线(repo_baseline),不豁免可见性 + for r in $REPOS; do + jq -e --arg r "$r" '[.repos[].name] | index($r) != null' <<<"$REPO_MAP" >/dev/null \ + || drift "线上仓 '$r' 未在 governance/REPOS.yaml 申报(补申报,或标 exempt 注明原因)" + VR=$(api "https://api.github.com/repos/$ORG/$r") + [[ "$(jq -r 'if .private then "private" else "public" end' <<<"$VR")" == "public" ]] \ + || drift "repo '$r' 为 private,违反全仓公开政策(ADR-0020 改回 public,或新 ADR 推翻政策)" + done +else + echo "SKIP REPOS.yaml 校验(环境缺 python3+pyyaml;GitHub runner 自带)" +fi + +# ---------- 8. 直推检测(GM-2 破玻璃监控)---------- +# flows.governance_change.policy_effective 之后,受治仓默认分支上的非 PR commit = 漂移。 +# 唯一权威判据 = List pull requests associated with commit API(红队 #17-I + PR 评审项): +# 不做消息后缀预筛——"(#N)" 后缀可伪造,直推挂个假后缀即可绕过预筛被报 clean; +# 窗口内每个 commit 都经关联 PR API 复核(真有关联 PR=合法形态放行,无=直推)。 +# fail-closed(评审项):关联 PR 查询本身失败(限流/权限/传输)= 无法验证 = 判漂移报错, +# 绝不静默放行——检测器失明比误报危险。 +# 分页(评审项):commit 列表全分页拉取,第 100 条之后的直推不可漏检; +# 超 MAX_COMMITS 上限时显式报漂移(fail loudly),不静默截断。 +# 回填时限:commit 距今 >24h 且仍在直推状态 = 已超破玻璃回填时限(P0 级标记)。 +SINCE=$(python3 - <<'EOF' +from datetime import datetime, timezone, timedelta +eff = datetime(2026, 8, 19, tzinfo=timezone.utc) # policy_effective +lo = datetime.now(timezone.utc) - timedelta(days=7) # 检测窗口 +print(max(eff, lo).strftime("%Y-%m-%dT%H:%M:%SZ")) +EOF +) +NOW_EPOCH=$(date +%s) +MAX_COMMITS=300 # 窗口内单仓 commit 上限:治理仓日常量级为个位数;超限=异常,人工核查 +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + # 全分页拉取窗口内 commit(评审项:首页之后不可漏检) + COMMITS_TMP=$(mktemp) + TOTAL=0 + PAGE=1 + FETCH_ERR=0 + while :; do + CHUNK=$(api "https://api.github.com/repos/$ORG/$r/commits?sha=main&since=$SINCE&per_page=100&page=$PAGE") + if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then + drift "repo '$r' commit 列表拉取失败,直推检测无法执行(fail-closed): $(jq -r '.message // "非 JSON 响应"' <<<"$CHUNK" 2>/dev/null || echo 传输失败)" + FETCH_ERR=1 + break + fi + N=$(jq 'length' <<<"$CHUNK") + [[ "$N" -eq 0 ]] && break + jq -c '.[] | {sha: .sha, date: (.commit.committer.date | sub("\\.[0-9]+Z$"; "Z"))}' <<<"$CHUNK" >>"$COMMITS_TMP" + TOTAL=$((TOTAL+N)) + [[ "$N" -lt 100 ]] && break + PAGE=$((PAGE+1)) + done + if [[ $FETCH_ERR -eq 1 ]]; then rm -f "$COMMITS_TMP"; continue; fi + if [[ $TOTAL -gt $MAX_COMMITS ]]; then + drift "repo '$r' 窗口内 commit 数=$TOTAL 超上限 $MAX_COMMITS——为防静默截断漏检,须人工核查直推或调窗口" + rm -f "$COMMITS_TMP" + continue + fi + # §8 豁免清单(ADR-0016 附录机制:已经 ADR 追认回填的破玻璃直推—— + # expected-state.json direct_push_exemptions 逐 SHA 登记,事件定性见 ADR-0017; + # 豁免须有 ADR 背书,不得口头/临时豁免) + EXEMPT=$(jq -r --arg r "$r" '.direct_push_exemptions[$r] // [] | join(" ")' "$EXPECTED") + DIRECT_FOUND=0 + while IFS= read -r row; do + [[ -n "$row" ]] || continue + sha=$(jq -r .sha <<<"$row") + cdate=$(jq -r .date <<<"$row") + if grep -qF "$sha" <<<"$EXEMPT"; then + ok "repo '$r' commit ${sha:0:8} 已回填破玻璃直推(§8 豁免清单,ADR-0017)" + continue + fi + # 关联 PR 复核(全 SHA;响应须为数组——error 对象/传输失败均为无法验证) + PRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/pulls?per_page=5" \ + | jq -r 'if type == "array" then (if length > 0 then "has-pr" else "none" end) else "error" end' 2>/dev/null || echo error) + if [[ "$PRS" == "error" ]]; then + drift "repo '$r' commit $sha 的关联 PR 查询失败,无法验证直推(fail-closed:限流/权限/传输故障不得静默放行)" + DIRECT_FOUND=1 + continue + fi + if [[ "$PRS" == "none" ]]; then + AGE=$(( NOW_EPOCH - $(date -u -d "$cdate" +%s) )) + if [[ $AGE -gt 86400 ]]; then + drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(已超 24h 回填时限=${AGE}s——P0:立即回填 ADR+PR,见 flows.governance_change)" + else + drift "repo '$r' 存在非 PR 直推 commit: ${sha:0:8}(破玻璃须 24h 内回填 ADR+PR,见 flows.governance_change)" + fi + DIRECT_FOUND=1 + fi + done <"$COMMITS_TMP" + rm -f "$COMMITS_TMP" + [[ $DIRECT_FOUND -eq 0 ]] && ok "no-direct-push '$r' (since $SINCE, $TOTAL commits)" +done + +# ---------- 9. vcs_admin 唯一性(ADR-0010:admin 全系统唯 owner)---------- +# org 全部受治仓的 admin 数量必须 == 1 且为 owner;任何多出的 admin = P0 漂移 +OWNER_LOGIN="${OWNER_LOGIN:-randypanding}" +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + # 单次拉取全分页(>100 协作者时防漏——CodeRabbit #13);ADMINS/COUNT 同源派生 + ADMIN_TMP=$(mktemp) + PAGE=1 + while :; do + CHUNK=$(api "https://api.github.com/repos/$ORG/$r/collaborators?permission=admin&per_page=100&page=$PAGE") + N=$(jq 'length' <<<"$CHUNK") + [[ "$N" -eq 0 ]] && break + jq -c '.' <<<"$CHUNK" >>"$ADMIN_TMP" + [[ "$N" -lt 100 ]] && break + PAGE=$((PAGE+1)) + done + ADMIN_DATA=$(jq -s 'add // []' "$ADMIN_TMP" 2>/dev/null || echo '[]') + rm -f "$ADMIN_TMP" + ADMINS=$(jq -r '[.[] | select(.permissions.admin == true) | .login] | unique | join(",")' <<<"$ADMIN_DATA") + COUNT=$(jq '[.[] | select(.permissions.admin == true) | .login] | unique | length' <<<"$ADMIN_DATA") + if [[ "$COUNT" != "1" ]]; then + drift "repo '$r' admin 数量=$COUNT ($ADMINS),必须唯一且为 $OWNER_LOGIN(ADR-0010 owner 伪原型不变量)" + elif [[ "$ADMINS" != "$OWNER_LOGIN" ]]; then + drift "repo '$r' admin=$ADMINS 非 owner $OWNER_LOGIN(ADR-0010)" + else + ok "vcs-admin-unique '$r'" + fi +done + +# ---------- 10. ADR 引用存在性+实体性后验(adr-required 的补充防线,评审项 + RB-D5)---------- +# gate.yml 的 adr-required 已在 PR 时点做存在性校验(ADR-0021:全仓公开后 PR 上下文 +# GITHUB_TOKEN 可读公开仓——早期"agent-registry 是私有仓、PR 时点无跨仓读权"的前提 +# 已随 ADR-0020 全仓公开政策失效,注记修正);ADR-0053(W1-C1 .github#164)索引世界 +# 起进一步校验 archive 正本可达性。本节后验实体性(防空壳)并独立复核存在性: +# 窗口内合并 PR 的 ADR-NNNN 引用必须有实质决策结构——伪造/幽灵 ADR 最长 24h 内 +# 检出(与 §8 直推检测同为 post-hoc 防线;C1 的权威人类门禁仍是 owner-only review)。 +# 内容源双世界(ADR-0053;ADR-0085 家园单仓化后唯一世界):archive/adr/INDEX.yaml(索引随正本同居)存在 → +# decisions/ 只剩同名墓碑(无实质结构),实体性改验 INDEX 指向的 archive 仓正本 +# (字节保真原件);INDEX 404(迁移前/回滚)→ 旧逻辑对 decisions/ 本体验证。 +ADR_RE='ADR-[0-9]{4}' +# 实体性判定(评审项:size 字节数可被空白/注释/填充绕过——100B 阈值挡不住 +# RB-D5 意义上的空壳):拉取被引 ADR 全文做结构校验,H1 编号行、status 行、 +# 背景/决策章节齐备且决策节有正文才判"有实质决策记录";同名多文件(ADR-0011 +# 先例)任一满足即通过;读取失败 fail-closed。按编号缓存(同批多 PR 引用同一 +# ADR 只拉一次)。 +declare -A ADR_VERDICT_CACHE +adr_substantive() { # $1=四位编号 → stdout: missing|ok|shell|unreadable + local num="$1" matches apath content decoded verdict + [[ -n "${ADR_VERDICT_CACHE[$num]:-}" ]] && { echo "${ADR_VERDICT_CACHE[$num]}"; return; } + if [[ -n "${ADR_INDEX_MODE:-}" ]]; then + # 索引世界(ADR-0053):编号→archive 正本路径(可多条:ADR-0011 双档先例) + matches=$(awk -v n="$num" '$1 == n {print $2}' <<<"$ADR_INDEX_MAP") + else + matches=$(jq -r --arg p "ADR-${num}-" '.[] | select((.name | startswith($p)) and (.type == "file")) | .path' <<<"$ADR_DIR_LISTING") + fi + if [[ -z "$matches" ]]; then + verdict="missing" + else + verdict="unreadable" + while IFS= read -r apath; do + [[ -n "$apath" ]] || continue + if [[ -n "${ADR_INDEX_MODE:-}" ]]; then + # 索引世界:archive raw 正本(公开仓,字节保真原件)。raw 在 runner 上有 + # 瞬时拒连抖动(2026-08-24 实测整批 unreadable)——重试 3 次后回退 + # contents API(api.github.com 通道稳定),双通道皆失败才按不可判定处理。 + decoded=$(curl -sSf --retry 3 --retry-delay 2 --max-time 20 "https://raw.githubusercontent.com/$ORG/archive/main/${apath}" 2>/dev/null || true) + if [[ -z "$decoded" ]]; then + _c=$(api "https://api.github.com/repos/$ORG/archive/contents/${apath}" 2>/dev/null | jq -r '.content // empty' 2>/dev/null || true) + [[ -n "$_c" ]] && decoded=$(base64 -d <<<"$_c" 2>/dev/null || true) + fi + else + content=$(api "https://api.github.com/repos/$ORG/archive/contents/$apath" | jq -r '.content // empty') + [[ -z "$content" ]] && continue + decoded=$(base64 -d <<<"$content" 2>/dev/null || true) + fi + [[ -z "$decoded" ]] && continue + if grep -qE "^#[[:space:]]*ADR-${num}([^0-9]|$)" <<<"$decoded" \ + && grep -qE "^-[[:space:]]*(status|状态):[[:space:]]*[^[:space:]]" <<<"$decoded" \ + && grep -qE "^##[[:space:]]*背景" <<<"$decoded" \ + && grep -qE "^##[[:space:]]*决策" <<<"$decoded" \ + && [[ -n "$(sed -n '/^##[[:space:]]*决策/,$p' <<<"$decoded" | tail -n +2 | sed 's/[[:space:]#*-]//g')" ]]; then + verdict="ok"; break + fi + [[ "$verdict" == "unreadable" ]] && verdict="shell" + done <<<"$matches" + fi + ADR_VERDICT_CACHE[$num]="$verdict" + echo "$verdict" +} +# 独立 7 天窗口(不用 §8 的 SINCE——那是 policy_effective 起算的直推检测窗口, +# 而 ADR 引用后验须覆盖 policy 生效前已合并、引用了伪造 ADR 的 PR) +ADR_SINCE=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) +ADR_DIR_LISTING=$(api "https://api.github.com/repos/$ORG/archive/contents/adr") +if ! jq -e 'type == "array"' <<<"$ADR_DIR_LISTING" >/dev/null 2>&1; then + drift "ADR 真源 archive/adr 读取失败,引用存在性无法后验(fail-closed,ADR-0085): $(jq -r '.message // "非数组"' <<<"$ADR_DIR_LISTING" 2>/dev/null || echo 传输失败)" +else + # 墓碑索引探测(ADR-0053):200 → 索引世界(map:编号→archive 正本路径); + # 404 → 旧世界;其他失败 → 报漂移(fail-closed:检测器失明不得伪装无漂移), + # 并降级旧世界继续跑完本节(漂移行已置红,后续节不受影响)。 + ADR_INDEX_MODE="" + ADR_INDEX_MAP="" + ADR_INDEX_JSON=$(api "https://api.github.com/repos/$ORG/archive/contents/adr/INDEX.yaml") + if jq -e '.content' <<<"$ADR_INDEX_JSON" >/dev/null 2>&1; then + ADR_INDEX_MAP=$(base64 -d <<<"$(jq -r '.content' <<<"$ADR_INDEX_JSON")" 2>/dev/null | awk ' + /^[[:space:]]*-[[:space:]]*number:/ { n=$3; gsub(/[^0-9]/, "", n); cur=sprintf("%04d", n+0); ap="" } + /^[[:space:]]*archive_path:/ { + line=$0; sub(/^[[:space:]]*archive_path:[[:space:]]*/, "", line); gsub(/"/, "", line) + if (cur != "" && line != "") { print cur " " line; cur="" } + }') + if [[ -n "$ADR_INDEX_MAP" ]] && grep -qE '^[0-9]{4} adr/.+\.md$' <<<"$ADR_INDEX_MAP"; then + ADR_INDEX_MODE=1 + ok "adr-index(ADR-0053 索引世界:$(wc -l <<<"$ADR_INDEX_MAP") entries,实体性改验 archive 正本)" + else + drift "decisions/INDEX.yaml 存在但解析为空/畸形——索引世界不可判定(fail-closed,按旧世界降级继续)" + fi + elif ! grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"' <<<"$ADR_INDEX_JSON"; then + drift "decisions/INDEX.yaml 探测失败(非 404 的 API 错误)——索引世界判定失败(fail-closed,按旧世界降级继续)" + fi + ADR_FILES=$(jq -r '.[].name' <<<"$ADR_DIR_LISTING") + GHOST=0 + for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + PRSLIST=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=closed&sort=updated&direction=desc&per_page=30") + if ! jq -e 'type == "array"' <<<"$PRSLIST" >/dev/null 2>&1; then + drift "repo '$r' PR 列表拉取失败,ADR 引用后验跳过(fail-closed)" + GHOST=1 + continue + fi + while IFS=$'\t' read -r pnum title body; do + [[ -n "$pnum" ]] || continue + for ref in $(printf '%s\n%s\n' "$title" "$body" | grep -oE "$ADR_RE" | sort -u); do + num="${ref#ADR-}" + case "$(adr_substantive "$num")" in + ok) : ;; + missing) + if [[ -n "$ADR_INDEX_MODE" ]]; then + where="INDEX entries 无此编号" + else + where="archive/adr/ 无 ADR-${num}-*.md" + fi + drift "repo '$r' PR#$pnum 引用幽灵 ADR ${ref}(${where}——C1 变更的决策背书不成立)" + GHOST=1 ;; + shell) + drift "repo '$r' PR#$pnum 引用空壳 ADR ${ref}(正本存在但缺实质结构:H1 编号行/status/背景/决策章节须齐备且决策节有正文——红队 RB-D5:字节数填充不再能绕过)" + GHOST=1 ;; + unreadable) + drift "repo '$r' PR#$pnum 引用的 ADR ${ref} 全部同名文件内容读取失败,实体性无法判定(fail-closed)" + GHOST=1 ;; + esac + done + done < <(jq -r --arg since "$ADR_SINCE" \ + '.[] | select(.merged_at != null and .merged_at >= $since) | [(.number|tostring), (.title // ""), (.body // "")] | @tsv' <<<"$PRSLIST") + done + [[ $GHOST -eq 0 ]] && ok "adr-reference-substantive(窗口内合并 PR 的 ADR 引用全部真实且有实质决策结构)" +fi + +# ---------- 11. CI-Workflows 大版本指针完整性(供应链,红队 #6-A / ADR-0016 决策 3)---------- +# 全部业务仓 gate 引用 CI-Workflows@v1 浮动指针;release-tags ruleset 的 admin bypass +# 使指针可被强移(改变所有业务仓实际执行的 CI 内容)。声明不变式(CI-Workflows README +# 版本策略):vN 恒指向最高 vN.x.y 的 commit。本节每日校验——admin 强移指针 24h 内检出, +# 不可见通道变可检测。附注 tag 指向 tag 对象(非 commit),须解引用后比对。 +# 凭据说明(评审项):本节与 §1-§10 共用 GOVERNANCE_TOKEN——AGENTS.md 声明的 +# drift-check 接口即 "GH_TOKEN= bash governance/drift-check.sh"(组织级 +# 检测 rulesets/admin/PR 清单本就需要 org 权限,token 已在 job 内);本节只读公开仓 +# CI-Workflows 的 refs,复用不扩大暴露面。工作流仅 schedule/dispatch 于可信 main +# 运行,脚本不受 PR 控制(与 gate.yml 不向 PR 上下文注入 org secret 同一威胁模型)。 +# 分页聚合(评审项:单页请求在 tag 数超页容量后会拿旧集合比较出假绿): +# 逐页拉取并验证每页均为数组,任一页失败即 fail-closed,不用部分结果继续比较。 +CW_REFS="[]" +CW_PAGE_FAIL=0 +CW_PAGE=1 +while :; do + CW_PAGE=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/matching-refs/tags/?per_page=100&page=$CW_PAGE") + if ! jq -e 'type == "array"' <<<"$CW_PAGE" >/dev/null 2>&1; then + CW_PAGE_FAIL=1 + break + fi + CW_REFS=$(jq --argjson acc "$CW_REFS" '. as $p | $acc + $p' <<<"$CW_PAGE") + [[ "$(jq 'length' <<<"$CW_PAGE")" -lt 100 ]] && break + CW_PAGE=$((CW_PAGE+1)) + [[ $CW_PAGE -gt 50 ]] && { CW_PAGE_FAIL=1; break; } # >5000 tag 视为异常,fail-closed +done +if [[ $CW_PAGE_FAIL -ne 0 ]]; then + drift "CI-Workflows tag refs 分页拉取失败(第 $CW_PAGE 页非数组/超界),大版本指针完整性无法校验(fail-closed,不用部分结果比较)" +else + # 解引用到 commit SHA(轻量 tag 直指 commit 零额外请求;附注 tag 走 tag 对象一跳) + cw_commit_sha() { # $1=tag 名( refs/tags/ 去前缀) + local row obj type sha + row=$(jq -c --arg r "refs/tags/$1" '.[] | select(.ref == $r)' <<<"$CW_REFS") + [[ -z "$row" || "$row" == "null" ]] && { echo ""; return; } + obj=$(jq -c '.object' <<<"$row") + type=$(jq -r '.type' <<<"$obj"); sha=$(jq -r '.sha' <<<"$obj") + if [[ "$type" == "tag" ]]; then + api "https://api.github.com/repos/$ORG/CI-Workflows/git/tags/$sha" | jq -r '.object.sha // empty' + else + echo "$sha" + fi + } + CW_NAMES=$(jq -r '.[].ref | sub("^refs/tags/"; "")' <<<"$CW_REFS") + while IFS= read -r ptr; do + [[ -n "$ptr" ]] || continue + N="${ptr#v}" + HIGHEST=$(grep -E "^v${N}\.[0-9]+\.[0-9]+$" <<<"$CW_NAMES" | sort -V | tail -1) + PTR_SHA=$(cw_commit_sha "$ptr") + if [[ -z "$HIGHEST" ]]; then + drift "CI-Workflows 指针 tag '$ptr' 存在但无任何 v${N}.x.y 具体版本 tag——指针失去锚点(发布流程漏步,README 版本策略)" + elif [[ -z "$PTR_SHA" ]]; then + drift "CI-Workflows 指针 '$ptr' 解引用失败(fail-closed)" + else + HIGH_SHA=$(cw_commit_sha "$HIGHEST") + if [[ "$PTR_SHA" == "$HIGH_SHA" ]]; then + ok "CI-Workflows 指针 '$ptr' == $HIGHEST(${PTR_SHA:0:7})" + else + drift "CI-Workflows 指针 '$ptr'(${PTR_SHA:0:7})≠ 最高版本 $HIGHEST(${HIGH_SHA:0:7})——指针被强移或发布流程漏步(不变式 vN==最高 vN.x.y,红队 #6-A)" + fi + fi + done < <(grep -E '^v[0-9]+$' <<<"$CW_NAMES" | sort -V) + # 必需指针存在性(评审项:删除 v1 ≠ "无指针即不适用"——v1 是全部业务仓 gate 的 + # 供应链入口(REPOS.yaml CI-Workflows role:"业务仓引用 @v1"),指针被删= + # admin bypass 绕过发布流程,必须报漂移。v2+ 指针出现后由上方循环自动纳入 + # 锚点/一致性校验;必需集合当前只声明 v1——v2 落地时随 ADR 扩充此常量。) + CW_REQUIRED_POINTERS="v1" + REQ_MISSING=0 + for req in $CW_REQUIRED_POINTERS; do + if ! grep -qx "$req" <<<"$CW_NAMES"; then + drift "CI-Workflows 必需大版本指针 '$req' 缺失(业务仓 @v1 供应链入口被删——红队 #6-A:release-tags ruleset admin bypass 通道)" + REQ_MISSING=1 + fi + done + [[ $REQ_MISSING -eq 0 ]] && ok "CI-Workflows 必需大版本指针存在($CW_REQUIRED_POINTERS)" +fi + +# ---------- 12. required check 活体存在性(P1-4,ADR-0034;in-flight 防误报修订)---------- +# 文本对账 ≠ 生效验证:ruleset JSON 完全正确的同时,required check 字符串精确 +# 匹配可能实际为空(job 改名 / workflow 重构)→ "零 required check" → PR 裸奔。 +# 判据:每个受管仓最近更新的至多 3 个已合并/打开 PR 的 head 上,必须存在每个 +# required check 名(从 rulesets/*.json 派生——单一真源)的 check run 且 +# conclusion 非空;无 PR 活动时退化为默认分支 HEAD。修订(2026-08-20 实测误报 +# mutual:三连新开 PR 的 CI 起飞窗口内 gate 尚未报 conclusion,被误判缺失): +# - CI 未完结(任一 check run 非 completed)的 head 不构成任何证据——既不算 +# "缺 gate"也不算"有 gate"(防起飞窗口误报);全部候选 in-flight 时显式 +# SKIP(非漂移,下轮复核) +# - 关闭未合并的 PR 不采样(废弃实验分支的残缺 CI 不构成证据) +# - 已完结 CI 的 PR head 缺 gate 仍照报(PR 群体缺 required check = CI 结构性 +# 异常或改名注入——T1 的检测对象;main HEAD 仅在无 PR 活动时作退化载体, +# 不作为豁免来源)。查询失败维持 fail-closed。 +# IR-0002(spec PR#152)(a)/(b) 形态区分(2026-08-21):org-gate 是 PR/merge_group +# 触发的 required workflow(org-gate.yml 的 on:),其活体证据只能来自 ruleset +# 生效后的 PR head——push-only / 生效前 PR 的 head 缺失属 (a)「从未接入」 +# (OK/INFO 计数,非漂移);仅 (b) 生效后 PR head 缺失才报裸奔。 +# 生效时刻 = org-required-workflows ruleset 创建时刻(API 实测)。 +ORG_GATE_EFFECTIVE="2026-08-20T07:43:21Z" + +# @ir0002-s12-classify-begin —— IR-0002(spec PR#152)(a)/(b) 缺失形态分类器。 +# governance/tests/test-ir0002.sh 按本标记对提取函数体做 fixture 自测((b) 检出 +# 灵敏度是 INV-4 铁律:改动本函数必须连带跑自测,红即不得合并)。 +# 入参:$1=repo $2=check 名 $3=QUERY_FAIL(0/1) $4=HAS_PR_ACTIVITY(0/1) +# $5=PRS_RECENT(PR 数组 JSON)$6=org-gate 生效 ISO 时戳 $7=N_CONCL(采样数) +# 返回:0=(a) 待接入(OK 行,非漂移) 1=(b) 裸奔(drift 报警) 2=fail-closed(drift 报警) +s12_classify() { + local r="$1" ctx="$2" QUERY_FAIL="$3" HAS_PR_ACTIVITY="$4" PRS_RECENT="$5" eff="$6" n_concl="$7" + if [[ $QUERY_FAIL -eq 1 ]]; then + drift "repo '$r' check-runs 查询失败,required check '$ctx' 活体无法验证(fail-closed)" + return 2 + elif [[ "$ctx" == "org-gate" ]] && ! jq -e --arg eff "$eff" \ + '[.[] | select((.state == "open" or .merged_at != null) and (.updated_at >= $eff))] | length > 0' \ + <<<"$PRS_RECENT" >/dev/null 2>&1; then + # IR-0002 (a) 形态:org-gate ruleset 生效后该仓无 PR 活动——从未接入, + # 非裸奔(待接入清单;本地 gate 的活体验证不受影响,照常执行) + echo "OK required-check-live '$r':'$ctx' 待接入(ruleset 生效后无 PR 活动——IR-0002 (a) 形态)" + return 0 + elif [[ "${HAS_PR_ACTIVITY:-1}" -eq 0 ]]; then + # IR-0002 (a) 泛化:无 PR 仓的 degenerate 采样下任意 required check 缺失= + # 从未接线(如新建 L1 记忆层仓无本地 gate workflow)——PR 面仍受 org + # ruleset 的 org-gate 保护;接线后自然转绿。有 PR 的仓不适用(保 (b) 检出) + echo "OK required-check-live '$r':'$ctx' 待接入(无 PR 活动,degenerate 采样——IR-0002 (a) 形态)" + return 0 + else + drift "repo '$r' required check '$ctx' 活体缺失:最近 $n_concl 个已完结 CI 的 head 均无该 check run——job 改名或 workflow 重构?裸奔窗口已开启(ADR-0034 §12;IR-0002 (b) 形态)" + return 1 + fi +} +# @ir0002-s12-classify-end + +REQ_CHECKS=$(jq -rs '[.[].rules[]? | select(.type == "required_status_checks") + | .parameters.required_status_checks[].context] | unique | .[]' "$DIR"/rulesets/*.json) +[[ -n "$REQ_CHECKS" ]] || { echo "FATAL: rulesets 未声明任何 required check——§12 活体验证失去判据"; exit 2; } +epoch_of() { date -u -d "$1" +%s; } # ISO8601 → epoch(runner GNU date) +S12_PENDING=0 # §12 全仓 (a) 待接入累计(IR-0002 spec AC-3 汇总信息行) +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + HAS_PR_ACTIVITY=1 # 有(已合并/打开的)PR head 可采样=强证据形态 + PRS_RECENT=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=all&sort=updated&direction=desc&per_page=20") + if jq -e 'type == "array"' <<<"$PRS_RECENT" >/dev/null 2>&1; then + HEADS=$(jq -r '[.[] | select(.state == "open" or .merged_at != null) | .head.sha][0:3] | .[]' <<<"$PRS_RECENT") + if [[ -z "$HEADS" ]]; then + # 无(已合并/打开的)PR 活动 → 退化为默认分支 HEAD(§12 采样处于最弱证据 + # 形态:degenerate 模式标记——缺失判定走 IR-0002 (a) 待接入分支) + HAS_PR_ACTIVITY=0 + DBR=$(api "https://api.github.com/repos/$ORG/$r" | jq -r '.default_branch // "main"') + HEADS=$(api "https://api.github.com/repos/$ORG/$r/git/ref/heads/$DBR" | jq -r '.object.sha // empty') + [[ -n "$HEADS" ]] || { drift "repo '$r' 无 PR 活动且默认分支 HEAD 不可读,活体验证无载体(fail-closed)"; continue; } + fi + else + drift "repo '$r' PR 清单拉取失败,required check 活体验证无法执行(fail-closed)" + continue + fi + # 采样修订(#85 T1 首轮实测漏检教训:并发连开 PR 会把改名注入头挤出「前3」, + # 而 in-flight 头白白占用采样位):先跳过零-check/in-flight,再收集最近 + # COLLECT_N=3 个「已完结」head(最多扫描 10 个候选)作为活体样本。 + COLLECT_N=3 + CONCL_SHAS="" + N_CONCL=0 + N_SEEN=0 + QUERY_FAIL=0 + SAW_INFLIGHT=0 + while IFS= read -r sha; do + [[ -n "$sha" ]] || continue + { [[ $N_CONCL -ge $COLLECT_N ]] || [[ $N_SEEN -ge 10 ]]; } && break + N_SEEN=$((N_SEEN+1)) + CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") + if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then + QUERY_FAIL=1; continue + fi + TOTAL=$(jq -r '.total_count // 0' <<<"$CRS") + [[ "$TOTAL" -eq 0 ]] && continue + # CI 未完结的 head:不构成证据也不占采样位(防起飞窗口误报——gate job 在 + # 依赖图末端,新开 PR 的前几分钟 conclusion 必为 null) + if jq -e '[.check_runs[] | select(.status != "completed")] | length > 0' <<<"$CRS" >/dev/null 2>&1; then + SAW_INFLIGHT=1; continue + fi + N_CONCL=$((N_CONCL+1)) + CONCL_SHAS="$CONCL_SHAS$sha"$' +' + done <<<"$HEADS" + if [[ $N_CONCL -eq 0 ]]; then + if [[ $QUERY_FAIL -eq 1 ]]; then + drift "repo '$r' check-runs 查询失败,required check 活体无法验证(fail-closed)" + continue + fi + echo "SKIP required-check-live '$r'(候选 head 全部 CI in-flight/零 check,本轮无法判定——非漂移,下轮复核)" + continue + fi + LIVE_MISS=0 + REPO_PENDING=0 # 本仓 (a) 待接入计数(IR-0002 spec AC-3:待接入须计数可见) + for ctx in $REQ_CHECKS; do + FOUND=0 + while IFS= read -r sha; do + [[ -n "$sha" ]] || continue + CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$sha/check-runs?per_page=100") + jq -e --arg c "$ctx" '[.check_runs[] | select(.name == $c and .conclusion != null)] | length > 0' <<<"$CRS" >/dev/null 2>&1 && { FOUND=1; break; } + done <<<"$CONCL_SHAS" + if [[ $FOUND -ne 1 ]]; then + rc=0 + s12_classify "$r" "$ctx" "$QUERY_FAIL" "${HAS_PR_ACTIVITY:-1}" "$PRS_RECENT" "$ORG_GATE_EFFECTIVE" "$N_CONCL" || rc=$? + case $rc in + 0) REPO_PENDING=$((REPO_PENDING+1)); S12_PENDING=$((S12_PENDING+1)) ;; + 1) LIVE_MISS=1 ;; + 2) : ;; + esac + fi + done + [[ $REPO_PENDING -gt 0 ]] \ + && echo "INFO required-check-live '$r':$REPO_PENDING 项待接入计入汇总(IR-0002 (a)——不阻断)" \ + || true + [[ $LIVE_MISS -eq 0 ]] && ok "required-check-live '$r'(最近 $N_CONCL 个已完结 head 上 required check 齐备)" +done +# IR-0002 spec AC-3:§12 汇总信息行——(a) 待接入非漂移但必须计数可见(防"静默豁免" +# 演变为检测盲区;§17 协议块对账同款"可见即治理"原则)。零待接入时也打 OK 行, +# 让"本节确实跑过"成为可审计事实。 +if [[ ${S12_PENDING:-0} -gt 0 ]]; then + echo "INFO §12 required-check 活体汇总:待接入 $S12_PENDING 项(IR-0002 (a) 形态——ruleset 生效后无 PR 活动,接线后自然转绿;非漂移)" +else + ok "§12 required-check 活体汇总:0 项待接入(全部受管仓要么齐备要么显式 SKIP)" +fi + +# ---------- 13. PR liveness 侦测(P1-4,ADR-0034)---------- +# 治理不漂移但流水线死了的三类形态(#81 §6——无人值守下卡死 PR 是最隐形的 +# 人类瓶颈):(a) auto-merge 已设置但 > 阈值无进展;(b) check 停留 queued/ +# in_progress 超 > 阈值;(c) PR 创建超阈值且 head 上零 check run(应有而无)。 +# 命中即走 GM-1 既有漂移 issue 通道。阈值:expected-state pr_liveness_hours, +# 环境变量 PR_LIVENESS_HOURS 可覆盖(dispatch input liveness_hours 透传,注入测试用)。 +LIVENESS_H="${PR_LIVENESS_HOURS:-$(jq -r '.pr_liveness_hours // 4' "$EXPECTED")}" +LIVENESS_S=$(( LIVENESS_H * 3600 )) +STUCK_TOTAL=0 +for r in $REPOS; do + jq -e --arg r "$r" '($r as $x | . | index($x)) != null' <<<"$EXCLUDES" >/dev/null && continue + OPEN_PRS=$(api "https://api.github.com/repos/$ORG/$r/pulls?state=open&per_page=30") + jq -e 'type == "array"' <<<"$OPEN_PRS" >/dev/null 2>&1 \ + || { drift "repo '$r' open PR 清单拉取失败,liveness 侦测无法执行(fail-closed)"; continue; } + STUCK=0 + while IFS=$'\t' read -r pnum created updated headsha has_am; do + [[ -n "$pnum" ]] || continue + AGE_UPD=$(( NOW_EPOCH - $(epoch_of "$updated") )) + AGE_CRE=$(( NOW_EPOCH - $(epoch_of "$created") )) + if [[ "$has_am" == "true" && $AGE_UPD -gt $LIVENESS_S ]]; then + drift "repo '$r' PR#$pnum auto-merge 已开启但 ${LIVENESS_H}h 无进展(updated ${AGE_UPD}s 前)——卡死侦测 (a):查 required check 状态/分支冲突(ADR-0034 §13)" + STUCK=1; continue + fi + CRS=$(api "https://api.github.com/repos/$ORG/$r/commits/$headsha/check-runs?per_page=100") + if ! jq -e 'type == "object" and has("check_runs")' <<<"$CRS" >/dev/null 2>&1; then + drift "repo '$r' PR#$pnum head check-runs 查询失败,liveness 无法验证(fail-closed)" + STUCK=1; continue + fi + N_RUNS=$(jq '.check_runs | length' <<<"$CRS") + if [[ "$N_RUNS" -eq 0 && $AGE_CRE -gt $LIVENESS_S ]]; then + drift "repo '$r' PR#$pnum 创建 ${AGE_CRE}s 且 head 零 check run——卡死侦测 (c):应有而无(workflow 未触发/被改名,ADR-0034 §13)" + STUCK=1; continue + fi + # pending 超龄判定(b):shell 循环做日期运算(jq 无日期运算) + while IFS=$'\t' read -r crname crstatus crstart; do + [[ -n "$crname" ]] || continue + [[ "$crstatus" == "queued" || "$crstatus" == "in_progress" ]] || continue + [[ -n "$crstart" ]] || continue + AGE_PEND=$(( NOW_EPOCH - $(epoch_of "$crstart") )) + if [[ $AGE_PEND -gt $LIVENESS_S ]]; then + drift "repo '$r' PR#$pnum check '$crname' 停留 $crstatus 已 ${AGE_PEND}s——卡死侦测 (b):永久 pending(ADR-0034 §13)" + STUCK=1 + fi + done < <(jq -r '.check_runs[] | [.name, .status, (.started_at // "")] | @tsv' <<<"$CRS") + done < <(jq -r '.[] | [(.number|tostring), .created_at, .updated_at, .head.sha, (.auto_merge != null | tostring)] | @tsv' <<<"$OPEN_PRS") + STUCK_TOTAL=$((STUCK_TOTAL+STUCK)) +done +[[ $STUCK_TOTAL -eq 0 ]] && ok "pr-liveness(全部受管仓 open PR 无卡死,阈值 ${LIVENESS_H}h)" + +# ---------- 15. org-required-workflows 钉点完整性(P3-1 #95 / ADR-0046)---------- +# §1 已对账 ruleset JSON 文本(path/ref/repository_id 任何改动即漂移)。本节补盲区: +# ref 为 tag 时,tag 指针被移动(ruleset 文本不变、内容换了)——钉点的 commit 绑定 +# 必须与 expected-state.org_required_workflows.ref_commit 一致;钉点 tag 还必须是 +# CI-Workflows 当前发布不变式(§11:vN==最高 vN.x.y)认可的物。fail-closed。 +ORW_CFG=$(jq -c '.org_required_workflows // empty' "$EXPECTED") +if [[ -n "$ORW_CFG" ]]; then + WANT_RULESET=$(jq -r '.ruleset' <<<"$ORW_CFG") + WANT_REPO_ID=$(jq -r '.repository_id' <<<"$ORW_CFG") + WANT_PATH=$(jq -r '.path' <<<"$ORW_CFG") + WANT_REF=$(jq -r '.ref' <<<"$ORW_CFG") + WANT_COMMIT=$(jq -r '.ref_commit' <<<"$ORW_CFG") + ORW_LIST=$(api "https://api.github.com/orgs/$ORG/rulesets?per_page=100") + ORW_ID=$(jq -r --arg n "$WANT_RULESET" '.[] | select(.name == $n) | .id' <<<"$ORW_LIST" 2>/dev/null | head -1) + if [[ -z "$ORW_ID" || "$ORW_ID" == "null" ]]; then + drift "org-required-workflows ruleset '$WANT_RULESET' 线上不存在——中心审判失效,全部受管仓回落本地 gate(P3-1 枢轴脱离,ADR-0046)" + else + # LIST 端点不含 rules 数组(同 §1 教训)——须拉 detail 才能取 workflows 规则 + ORW_ROW=$(api "https://api.github.com/orgs/$ORG/rulesets/$ORW_ID") + GOT_WF=$(jq -c '.rules[] | select(.type == "workflows") | .parameters.workflows[0] // empty' <<<"$ORW_ROW") + GOT_PATH=$(jq -r '.path // empty' <<<"$GOT_WF") + GOT_REF=$(jq -r '.ref // empty' <<<"$GOT_WF") + GOT_REPO_ID=$(jq -r '.repository_id // empty' <<<"$GOT_WF") + BAD="" + [[ "$GOT_PATH" == "$WANT_PATH" ]] || BAD="$BAD path=$GOT_PATH" + [[ "$GOT_REF" == "$WANT_REF" ]] || BAD="$BAD ref=$GOT_REF" + [[ "$GOT_REPO_ID" == "$WANT_REPO_ID" ]] || BAD="$BAD repository_id=$GOT_REPO_ID" + if [[ -n "$BAD" ]]; then + drift "org-required-workflows 钉点漂移:$BAD(期望 path=$WANT_PATH ref=$WANT_REF repo_id=$WANT_REPO_ID)——审判源被改指(ADR-0046 §15)" + fi + # commit 绑定校验(tag 或直接 SHA;ISSUE-263 W1-C5 改为直接钉 commit SHA) + if [[ "$WANT_REF" =~ ^[0-9a-f]{40}$ ]]; then + # 直接钉 commit SHA:校验该对象在目标仓可达 + COMMIT_OBJ=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/commits/$WANT_REF") + TAGCOMMIT=$(jq -r '.sha // empty' <<<"$COMMIT_OBJ") + else + SHORTREF="${WANT_REF#refs/tags/}" + TAGROW=$(api "https://api.github.com/repos/$ORG/CI-Workflows/git/ref/tags/$SHORTREF") + TAGCOMMIT=$(jq -r '.object.sha // empty' <<<"$TAGROW") + fi + if [[ -z "$TAGCOMMIT" ]]; then + drift "org-required-workflows 钉点 $WANT_REF 解引用/校验失败(fail-closed,ADR-0046 §15)" + elif [[ "$TAGCOMMIT" != "$WANT_COMMIT" ]]; then + drift "org-required-workflows 钉点 $WANT_REF 已移动:${TAGCOMMIT:0:8} ≠ 声明 ${WANT_COMMIT:0:8}——审判内容被换(ADR-0046 §15;还原或更新 expected-state)" + elif [[ -n "$BAD" ]]; then + : # 钉点漂移已上报,不再输出 OK 行(避免同段 OK/DRIFT 并存的误导) + else + ok "org-required-workflows 钉点完整($WANT_REF == ${TAGCOMMIT:0:8},path/repository_id 一致)" + fi + fi +fi + +# ---------- 16. 治理标签(state:*/type:* 存在性与形状,W0-C2/ADR-0047)---------- +# expected-state.json#labels.items 为治理标签期望全集。只对账"应有哪些"(新增式), +# 各仓自有业务标签不属治理面、不判漂移;缺失或 color/description 不符 = 漂移。 +# items 为空视为期望状态缺失(fail-closed——治理标签全集被误删不得静默通过)。 +LBL_ITEMS=$(jq -c '.labels.items // []' "$EXPECTED") +LBL_N=$(jq 'length' <<<"$LBL_ITEMS") +if [[ "$LBL_N" -eq 0 ]]; then + drift "expected-state.json#labels.items 为空——治理标签期望状态缺失(fail-closed,ADR-0047)" +else + LBL_DRIFT=0 + for r in $REPOS; do + jq -e --arg r "$r" 'index($r) != null' <<<"$EXCLUDES" >/dev/null && continue + # 分页拉全量标签(>100 标签的仓单页会漏——与 §1 同教训) + RL_TMP=$(mktemp); RL_ERR=0; RL_PAGE=1 + while :; do + CHUNK=$(api "https://api.github.com/repos/$ORG/$r/labels?per_page=100&page=$RL_PAGE") + if ! jq -e 'type == "array"' <<<"$CHUNK" >/dev/null 2>&1; then + CHUNK_MSG=$(jq -r '.message // "非数组"' <<<"$CHUNK" 2>/dev/null || true) + [[ -z "$CHUNK" ]] && CHUNK_MSG="传输失败(空响应)" + drift "repo '$r' 标签清单拉取失败($CHUNK_MSG)——标签对账跳过(fail-closed)" + LBL_DRIFT=1; RL_ERR=1; break + fi + RL_N=$(jq 'length' <<<"$CHUNK"); [[ "$RL_N" -eq 0 ]] && break + jq -c '.[] | {name, color, description: (.description // "")}' <<<"$CHUNK" >>"$RL_TMP" + [[ "$RL_N" -lt 100 ]] && break + RL_PAGE=$((RL_PAGE+1)) + done + if [[ $RL_ERR -eq 1 ]]; then rm -f "$RL_TMP"; continue; fi + while IFS= read -r want; do + wname=$(jq -r .name <<<"$want"); wcolor=$(jq -r .color <<<"$want"); wdesc=$(jq -r .description <<<"$want") + got=$(jq -r --arg n "$wname" 'select(.name == $n) | "\(.color) \(.description)"' <"$RL_TMP" 2>/dev/null | head -1) + if [[ -z "$got" ]]; then + drift "repo '$r' 缺治理标签 '$wname'(修复:bash governance/apply.sh §7)"; LBL_DRIFT=1 + elif [[ "$got" != "$(printf '%s %s' "$wcolor" "$wdesc")" ]]; then + drift "repo '$r' 标签 '$wname' 形状不符:$got 期望 color=$wcolor(apply.sh §7 对齐)"; LBL_DRIFT=1 + fi + done < <(jq -c '.[]' <<<"$LBL_ITEMS") + rm -f "$RL_TMP" + done + [[ $LBL_DRIFT -eq 0 ]] && ok "治理标签全集($LBL_N 项 × 受管仓)一致" +fi + +# ---------- 17. 统一入口协议块一致性(W1-C3 #166 / ADR-0055,宪法 §11/§4D)---------- +# REPOS.yaml 中 entry_protocol: true 的仓:其 main 的 AGENTS.md 须携带与 +# template-service main(统一下发真源)逐字节一致的协议块—— +# 提取 标记间内容(含标记)比对。 +# 缺失标记/内容不一致/拉取失败 = 漂移(fail-closed:检测器失明不得伪装通过)。 +# 产品仓 rollout(7 个业务仓下发协议块)是后续 fleet 小卡——届时逐仓加 +# entry_protocol: true 即纳入本节管辖。 +PROTO_OK=0 +PROTO_REPOS="" +# REPOS.yaml 是 YAML——jq 直读必失败且被吞掉=假绿(§7 同教训)。§17 自解析 +# (§7 同款 python3+pyyaml→JSON,不信任继承的 REPO_MAP——调用方 env 注入可 +# 重定向对账对象);解析不可用或 entry_protocol 清单为空均 fail-closed。 +PROTO_MAP="" +if python3 -c 'import yaml' 2>/dev/null; then + PROTO_MAP=$(python3 -c 'import yaml,json,sys;print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' \ + "$DIR/REPOS.yaml" 2>/dev/null || true) +fi +if [[ -n "$PROTO_MAP" ]]; then + PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' <<<"$PROTO_MAP") + if [[ -z "$PROTO_REPOS" ]]; then + drift "REPOS.yaml 无 entry_protocol 仓——协议块对账失去对象(申报丢失=漂移,fail-closed,ADR-0055 §17)" + PROTO_OK=1 + fi +else + drift "REPOS.yaml 未解析(缺 python3+pyyaml)——协议块对账不可执行(fail-closed,ADR-0055 §17)" + PROTO_OK=1 +fi +proto_block() { # 提取协议块(标记间内容,含首尾标记行)——起止标记任一缺失输出空 + # (缺末标记时不得输出“半块”:半块≠空,会让比对产生误判——fail-closed) + local t + t=$(cat) + if [[ "$t" == *""* ]]; then + awk '//{f=1} f{print} //{exit}' <<<"$t" + fi +} +CANON_RAW=$(api "https://raw.githubusercontent.com/$ORG/template-service/main/AGENTS.md" 2>/dev/null || true) +CANON_BLOCK="" +if [[ -z "$CANON_RAW" || "$CANON_RAW" == "404:"* ]]; then + drift "template-service main AGENTS.md 拉取失败(协议块真源不可读——fail-closed)" + PROTO_OK=1 +else + CANON_BLOCK=$(proto_block <<<"$CANON_RAW") + if [[ -z "$CANON_BLOCK" ]]; then + drift "template-service main AGENTS.md 缺协议块或标记不完整(宪法 §4D:块由 template-service 统一下发)" + PROTO_OK=1 + fi +fi +PROTO_N=0 +for r in $PROTO_REPOS; do + PROTO_N=$((PROTO_N+1)) + [[ "$r" == "template-service" ]] && continue # 真源自身即 canon(同 URL 已验) + RAW=$(api "https://raw.githubusercontent.com/$ORG/$r/main/AGENTS.md" 2>/dev/null || true) + if [[ -z "$RAW" || "$RAW" == "404:"* ]]; then + drift "repo '$r' AGENTS.md 拉取失败(协议块对账跳过——fail-closed,ADR-0055 §17)" + PROTO_OK=1 + continue + fi + BLOCK=$(proto_block <<<"$RAW") + if [[ -z "$BLOCK" ]]; then + drift "repo '$r' AGENTS.md 缺协议块标记或标记不完整(entry_protocol 已申报——宪法 §11 统一入口)" + PROTO_OK=1 + continue + fi + if [[ "$BLOCK" != "$CANON_BLOCK" ]]; then + # 双 here-string 会互相覆盖 stdin(SC2261,diff 拿不到操作数)——用进程替换 + drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <(printf '%s\n' "$CANON_BLOCK") <(printf '%s\n' "$BLOCK") 2>&1 | head -3 | tr '\n' ' ' | cut -c1-160))" + PROTO_OK=1 + fi +done +[[ $PROTO_OK -eq 0 ]] && ok "统一入口协议块一致(真源 template-service × $PROTO_N 个 entry_protocol 仓,逐字节比对)" + +# ---------- 18. holdout 隔离断言(DECISION-02:App 安装差异隔离,W1-C4/ADR-0056,ADR-0080 修订)---------- +# 试卷层 holdout 的读隔离不靠保密(公开仓,ADR-0056/DECISION-02),靠三条: +# - cloudbrid-agent App 不安装到该仓(agent 的组织级凭据通道物理不可达); +# - 验证者 APP 可安装,但仅作为测试/验证路径写入主体(ADR-0080); +# - 泄漏诱饵周检(.github 仓 holdout-canary-sweep,宪法 §6/§11)。 +# 本节断言 App 挂载差异: +# - cloudbrid-agent installation 的仓清单含 holdout = P0 漂移; +# - 验证者 APP installation 的仓清单含 holdout = OK(合法); +# - 验证者 APP 尚未安装 → INFO/SKIP(实施阶段再断言)。 +# 正向对照防检测器失明假绿(§4 同思想):cloudbrid-agent 的 installation 清单必须 +# 非空且含 .github——连 .github 都不在清单 = 端点读法错/权限变了 → 按漂移报错, +# 绝不把"看不见"当"没有"。API 任何失败 = fail-closed 报漂移。 +# §17 编号预留给并行卡 W1-C3(多代理并行修改本文件,编号互不占段)。 +# 端点注(2026-08-21 实测):installation id 走 /orgs//installations(admin +# 端点,new-repo-init.sh §3 同款)——/user/installations 要求 GitHub App 令牌, +# 经典 PAT 一律 403;仓清单走 /user/installations//repositories(经典 PAT 可读)。 +HOLDOUT_REPO="holdout" + +# 拉取一次 org 安装列表,供 cloudbrid-agent 与验证者 APP 共用。 +HOLDOUT_INST=$(api "https://api.github.com/orgs/$ORG/installations?per_page=100") +if ! jq -e 'type == "object" and (.installations | type == "array")' <<<"$HOLDOUT_INST" >/dev/null 2>&1; then + drift "holdout 隔离断言失败:org installations 拉取失败(fail-closed——检测器失明不得伪装通过,ADR-0056 §18): $(jq -r '.message // "非 JSON 响应"' <<<"$HOLDOUT_INST" 2>/dev/null || echo 传输失败)" +else + # 辅助函数:拉取指定 installation 下的全部仓名;stdout 为排序去重后的仓名列表; + # 失败时返回非 0(stdout 为空,不应使用)。 + holdout_install_repos() { + local iid="$1" tmp fail page n chunk + tmp=$(mktemp) + fail=0 + page=1 + while :; do + chunk=$(api "https://api.github.com/user/installations/$iid/repositories?per_page=100&page=$page") + if ! jq -e 'type == "object" and (.repositories | type == "array")' <<<"$chunk" >/dev/null 2>&1; then + fail=1; break + fi + n=$(jq '.repositories | length' <<<"$chunk") + [[ "$n" -eq 0 ]] && break + jq -r '.repositories[].name' <<<"$chunk" >>"$tmp" + [[ "$n" -lt 100 ]] && break + page=$((page+1)) + done + if [[ $fail -ne 0 ]]; then + rm -f "$tmp" + return 1 + fi + sort -u "$tmp" + rm -f "$tmp" + } + + # cloudbrid-agent:老 DECISION-02 主体,必须不挂载 holdout。 + HOLDOUT_APP=$(jq -r .github_app.name "$EXPECTED") + INST_ID=$(jq -r --arg s "$HOLDOUT_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) + if [[ -z "$INST_ID" || "$INST_ID" == "null" ]]; then + drift "app '$HOLDOUT_APP' 的 installation 未找到——holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" + else + INST_REPOS=$(holdout_install_repos "$INST_ID") + if [[ $? -ne 0 ]]; then + drift "installation#$INST_ID 仓清单拉取失败,holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" + else + HOLDOUT_HIT=0 + grep -qx "$HOLDOUT_REPO" <<<"$INST_REPOS" && HOLDOUT_HIT=1 + # 正向对照(防失明):清单非空且含 .github,否则断言结论不可信 + if [[ -z "$INST_REPOS" ]]; then + drift "installation#$INST_ID 仓清单为空——正向对照失败(端点读法或权限已变),不得把看不见当没有(fail-closed,ADR-0056 §18)" + HOLDOUT_HIT=-1 + elif ! grep -qx ".github" <<<"$INST_REPOS"; then + drift "installation#$INST_ID 仓清单不含 .github——正向对照失败(端点读法或权限已变),holdout 隔离结论不可信(fail-closed,ADR-0056 §18)" + HOLDOUT_HIT=-1 + fi + if [[ $HOLDOUT_HIT -eq 1 ]]; then + drift "holdout 出现在 app '$HOLDOUT_APP' installation#$INST_ID 仓清单——P0:cloudbrid-agent 挂上 holdout = 试卷层隔离失效(DECISION-02/ADR-0056 §18;立即在 App 设置页移除该仓访问并追查何时挂载)" + elif [[ $HOLDOUT_HIT -eq 0 ]]; then + ok "holdout 隔离成立(cloudbrid-agent installation#$INST_ID 共 $(grep -c . <<<"$INST_REPOS") 仓不含 holdout;正向对照 .github 在清单)" + fi + fi + fi + + # 验证者 APP(ADR-0080):允许挂载 holdout,作为测试/验证路径写入主体。 + VERIFIER_APP=$(jq -r '.verifier_app.name // empty' "$EXPECTED") + if [[ -n "$VERIFIER_APP" && "$VERIFIER_APP" != "null" ]]; then + VERIFIER_INST_ID=$(jq -r --arg s "$VERIFIER_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) + if [[ -n "$VERIFIER_INST_ID" && "$VERIFIER_INST_ID" != "null" ]]; then + VERIFIER_REPOS=$(holdout_install_repos "$VERIFIER_INST_ID") + if [[ $? -ne 0 ]]; then + drift "验证者 APP '$VERIFIER_APP' installation#$VERIFIER_INST_ID 仓清单拉取失败,无法校验 holdout 挂载合法性(fail-closed,ADR-0080 §18)" + elif grep -qx "$HOLDOUT_REPO" <<<"$VERIFIER_REPOS"; then + ok "holdout 由验证者 APP '$VERIFIER_APP' 合法挂载(installation#$VERIFIER_INST_ID,ADR-0080)" + else + ok "验证者 APP '$VERIFIER_APP' 已安装但未挂载 holdout(ADR-0080 允许,非漂移)" + fi + else + echo "INFO 验证者 APP '$VERIFIER_APP' 尚未安装——holdout 挂载合法性断言跳过(实施阶段再校验,ADR-0080 §18)" + fi + fi +fi + +echo "----------------------------------------" +if [[ $DRIFTS -gt 0 ]]; then + echo "结果: $DRIFTS 项漂移。修复: bash governance/apply.sh 或手动改回" + exit 1 +fi echo "结果: 无漂移,组织配置与 governance/ 落盘一致" \ No newline at end of file