diff --git a/.github/workflows/board-sync.yml b/.github/workflows/board-sync.yml index 862ddec..ad46315 100644 --- a/.github/workflows/board-sync.yml +++ b/.github/workflows/board-sync.yml @@ -33,7 +33,11 @@ jobs: BUTLER_TRIGGER: ${{ github.event_name }} run: | set -uo pipefail - source governance/butler-audit.sh + # 审计发射器加载失败=检测器失明——fail-closed,不得让投影在无审计下静默运行 + if ! source governance/butler-audit.sh || ! command -v audit_emit >/dev/null; then + echo "::error::governance/butler-audit.sh 加载失败或未定义 audit_emit(审计失明——fail-closed)" >&2 + exit 2 + fi if ! python3 governance/board-sync.py; then audit_emit board-sync manual infra-fail '{"rc":"nonzero"}' || true echo "::error::board-sync.py 失败(fail-closed——投影失败不得静默,ADR-0055)" >&2 diff --git a/.github/workflows/butler-ledger.yml b/.github/workflows/butler-ledger.yml index a791d5f..0bb45e2 100644 --- a/.github/workflows/butler-ledger.yml +++ b/.github/workflows/butler-ledger.yml @@ -34,7 +34,11 @@ jobs: BUTLER_TRIGGER: ${{ github.event_name }} run: | set -uo pipefail - source governance/butler-audit.sh + # 审计发射器加载失败=检测器失明——fail-closed(与 board-sync.yml 同款守卫) + if ! source governance/butler-audit.sh || ! command -v audit_emit >/dev/null; then + echo "::error::governance/butler-audit.sh 加载失败或未定义 audit_emit(审计失明——fail-closed)" >&2 + exit 2 + fi TRIGGER="${BUTLER_TRIGGER:-manual}" # --- W1-C3 投影脚本一:board-sync.py(守卫:未落地=skipped 保持绿) --- if [[ -f governance/board-sync.py ]]; then diff --git a/.github/workflows/conductor.yml b/.github/workflows/conductor.yml index 0fedd29..90aefc3 100644 --- a/.github/workflows/conductor.yml +++ b/.github/workflows/conductor.yml @@ -86,6 +86,11 @@ jobs: E = os.environ ORG, REPO = "Cloudbird-Software", E["REPO"] ISSUE = E["ISSUE_NUMBER"] + # issue 输出最先落盘:后续任何失败路径(读 issue 失败/仲裁 infra/状态写失败) + # 都会触发 on-failure,届时 needs.route.outputs.issue 必须非空(BEH-01 通知要求) + if E.get("GITHUB_OUTPUT"): + with open(E["GITHUB_OUTPUT"], "a", encoding="utf-8") as _o: + _o.write(f"issue={ISSUE}\n") def api(token, path, method="GET", body=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(f"https://api.github.com{path}", data=data, method=method, @@ -93,7 +98,10 @@ jobs: "User-Agent": "conductor"}) try: with urllib.request.urlopen(req) as r: - return r.status, json.load(r) + raw = r.read() + # 204(DELETE label 等)响应体为空——json.load 空体必抛 + # JSONDecodeError,状态码拿不到就 fail-closed 不成立 + return r.status, (json.loads(raw) if raw.strip() else {}) except urllib.error.HTTPError as e: return e.code, {} @@ -153,11 +161,14 @@ jobs: # ---- arbiter 前置裁决(ADR-0055:/claim /release 转介;退出码三态)---- # cwd 必须是 arbiter checkout 根(python -m arbiter.cli 的包根在那里) ARBITER_DIR = os.path.join(os.getcwd(), "arbiter") - def adjudicate(command): + def adjudicate(command, delivery_id=None): + # delivery_id 缺省=评论 node_id(重投幂等键);补偿调用必须传独立 id—— + # arbiter seen_ref 只按 sha1(delivery_id) 判重放(kernel §2,与命令无关), + # 复用原 id 的补偿 /release 会被判 replay no-op,租约释放不掉 argv = ["bash", os.path.join(ARBITER_DIR, "scripts", "adjudicate.sh"), command, "--card", f"{REPO}#{ISSUE}", "--sender", actor, "--sender-role", role, - "--delivery-id", E.get("COMMENT_NODE_ID") or f"run-{E.get('RUN_ID', 'unknown')}", + "--delivery-id", delivery_id or (E.get("COMMENT_NODE_ID") or f"run-{E.get('RUN_ID', 'unknown')}"), "--event", "created", "--current-state", current, "--backend", "github"] return subprocess.call(argv, cwd=ARBITER_DIR) @@ -184,10 +195,16 @@ jobs: audit(f"verdict=abort guard 求值失败 {t['id']}: {e}"); raise SystemExit(1) def swap_state(frm, to): - enc_from = urllib.parse.quote(f"state:{frm}", safe="") - api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3A{frm}", "DELETE") - api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels", - "POST", {"labels": [f"state:{to}"]}) + # 写结果逐项检查(ADR-0055):标签写失败=状态面失真——不许“路由记 allow + # 但卡未变更”静默成功,抛 WriteFail 交上层 fail-closed/租约补偿。 + # DELETE 容 404:标签已不在=目标态已达成(幂等删/补偿恢复场景) + st_del, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels/state%3A{frm}", "DELETE") + if st_del not in (200, 204, 404): + raise WriteFail(f"删标签 state:{frm} HTTP {st_del}") + st_add, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/labels", + "POST", {"labels": [f"state:{to}"]}) + if st_add not in (200, 201): + raise WriteFail(f"加标签 state:{to} HTTP {st_add}") if not ok: # 静默丢弃(AC-11):回退标签、不评论、不启动 @@ -212,28 +229,58 @@ jobs: audit(f"event={ev} transition={t['id']} sender_role={role} arbiter=allow " f"(租约已建——T3 落地;TTL 到期由下一 /claim 原子接管,ADR-0054)") - # ---- 执行转移(状态标签写=App 身份,INV-02)---- + # ---- 执行转移(状态标签写=App 身份,INV-02;写失败=fail-closed, + # /claim 已建租约时先补偿回滚——杜绝“租约在、卡未变”的不一致面,ADR-0055)---- + class WriteFail(Exception): + pass + out = open(E["GITHUB_OUTPUT"], "a", encoding="utf-8") - action = t["action"] - if action == "invoke:spec-author": - m = re.match(r"(IR-\d+)", iss.get("title") or "") - task_id = m.group(1) if m else f"ISSUE-{ISSUE}" - swap_state(t["from_state"], t["to_state"]) - out.write(f"invoke=spec-author\nissue={ISSUE}\nir_ref={task_id} {REPO}#{ISSUE}\n") - audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " - f"{t['from_state']}->{t['to_state']} action=invoke:spec-author") - elif action == "claim": - swap_state(t["from_state"], t["to_state"]) - api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/assignees", "POST", {"assignees": [actor]}) - out.write("invoke=none\n") - audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " - f"claim->in-progress assignee={actor}") - else: - swap_state(t["from_state"], t["to_state"]) - out.write("invoke=none\n") - audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " - f"{t['from_state']}->{t['to_state']} action=noop") - out.close() + try: + action = t["action"] + if action == "invoke:spec-author": + m = re.match(r"(IR-\d+)", iss.get("title") or "") + task_id = m.group(1) if m else f"ISSUE-{ISSUE}" + swap_state(t["from_state"], t["to_state"]) + out.write(f"invoke=spec-author\nir_ref={task_id} {REPO}#{ISSUE}\n") + audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " + f"{t['from_state']}->{t['to_state']} action=invoke:spec-author") + elif action == "claim": + lease_created = ev == "comment:/claim" # 裁决 allow 时租约已建 + try: + swap_state(t["from_state"], t["to_state"]) + st_as, _ = api(E["APP_TOKEN"], f"/repos/{REPO}/issues/{ISSUE}/assignees", + "POST", {"assignees": [actor]}) + if st_as not in (200, 201): + raise WriteFail(f"置 assignee HTTP {st_as}") + except WriteFail as e: + if lease_created: + # 补偿双面:状态标签(宪法 §12 真相源——先恢复,卡回到 + # 可重试态)+ 租约(独立 delivery id:复用原 id 会被 + # arbiter seen_ref 判 replay no-op)。每步结果都入审计, + # 任一失败仍 fail-closed 退出(delivery 幂等可安全重投)。 + audit(f"event={ev} transition={t['id']} write-fail {e}——开始补偿回滚") + try: + swap_state(t["to_state"], t["from_state"]) + except WriteFail as e2: + audit(f"compensate 状态恢复失败 {e2}——人工置回 state:{t['from_state']}") + comp_id = f"{E.get('COMMENT_NODE_ID') or E.get('RUN_ID', 'run')}-compensate-release" + rc = adjudicate("/release", comp_id) + audit(f"compensate /release rc={rc}" + f"(0=租约已释放;非 0=TTL 到期自然回收或人工 /release)") + raise + out.write("invoke=none\n") + audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " + f"claim->in-progress assignee={actor}") + else: + swap_state(t["from_state"], t["to_state"]) + out.write("invoke=none\n") + audit(f"event={ev} transition={t['id']} sender_role={role} verdict=ALLOWED " + f"{t['from_state']}->{t['to_state']} action=noop") + except WriteFail as e: + audit(f"event={ev} verdict=ABORT 状态写失败 {e}(fail-closed;delivery 幂等可安全重投)") + raise SystemExit(1) + finally: + out.close() PYEOF - name: 幂等键落盘({issue, from, to}——重复投递复核凭据) if: steps.route.outputs.invoke == 'spec-author' diff --git a/AGENTS.md b/AGENTS.md index 07cdc5e..b558e47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 ### 入口协议(陌生 agent 从这里开始——宪法 §11 / ADR-0055) -1. 取 ghcb(钉 SHA,禁浮动 main):`curl -sS -o ghcb https://raw.githubusercontent.com/Cloudbird-Software/.github/f72d9520706c8fca974d92456f65cae5c1412bb7/scripts/ghcb && chmod +x ghcb`(凭据用你自己的:`gh auth login` 或 `export GH_TOKEN=`) +1. 取 ghcb(钉 SHA,禁浮动 main):`curl -fsS -o ghcb https://raw.githubusercontent.com/Cloudbird-Software/.github/f72d9520706c8fca974d92456f65cae5c1412bb7/scripts/ghcb && chmod +x ghcb`(凭据用你自己的:`gh auth login` 或 `export GH_TOKEN=`;`-f` 必带——404 时 curl 无 -f 仍退出 0,会把错误页当脚本落盘) 2. 找活:`bash ghcb next [owner/repo]` → 列 state:ready 卡(卡 issue 是唯一工作凭证,无卡不开工) 3. 认领:`bash ghcb claim [owner/repo]` → 评论 /claim——conductor 转介 arbiter 原子 CAS 租约,先到先得;败者换下一张(`bash ghcb status ` 看持有者) 4. 开工:`make card-test CARD=`(读卡 AC、测试先行)→ `make gates-pr`(本地复现 CI 关卡) @@ -18,7 +18,7 @@ AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 ## 硬规则 - 治理文件(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`,等价协议块下载版) +- 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) - 不引入新第三方 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(用量不可知时同样停) @@ -26,10 +26,9 @@ AI agent 进入本仓的工作契约(索引型,CG-1;治理仓豁免 ≤40 ## 常用命令(本仓驻留) - 校验本仓声明:`.github/workflows/gate.yml`(本地等价:`make gates-pr`——bash -n + yaml 全量解析) -- 漂移检测:`GH_TOKEN= bash governance/drift-check.sh`(每日 CI 自动跑;§17=入口协议块对账) -- 修复循环上限执法:`GH_TOKEN= bash governance/auto-fix-limit.sh`(小时级;`AUTOFIX_DRY_RUN=1` 只报告) -- 成本熔断检查:`GH_TOKEN= bash governance/cost-check.sh`(6h;`COST_USAGE_MINUTES_OVERRIDE=` 注入测试) -- 漂移修复:`GH_TOKEN= bash governance/apply.sh`(幂等;失败 loud 退出)· 新仓初始化:`bash scripts/new-repo-init.sh ` +- 漂移检测/漂移修复/新仓初始化(**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) diff --git a/Makefile b/Makefile index 0543dd1..48ca757 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,9 @@ # gates-pr 真实执行 gate.yml 的本地可等价部分(bash -n / yaml 解析),CI 关卡语义 # 仍以 .github/workflows/gate.yml 为准,不伪装已运行 CI。 CARD ?= -REPO ?= Cloudbird-Software/.github # 卡所在仓(W1 波次卡都在治理仓;产品仓自有卡时 REPO=... 覆盖) +# 卡所在仓(W1 波次卡都在治理仓;产品仓自有卡时 REPO=... 覆盖)。 +# 注释须独立成行:行尾注释会把 # 前的尾随空格并入 REPO 值,gh -R 解析失败且被吞。 +REPO ?= Cloudbird-Software/.github .PHONY: card-test gates-pr card-test: ## 读卡 AC 列表并提示测试先行:make card-test CARD= diff --git a/governance/board-sync.py b/governance/board-sync.py index 3261e1d..62e8a8d 100644 --- a/governance/board-sync.py +++ b/governance/board-sync.py @@ -170,9 +170,13 @@ def scan_cards(repos): for it in batch: if "pull_request" in it: # issues 端点混入 PR——不是卡 continue - sl = [l["name"] for l in it.get("labels", []) if str(l.get("name", "")).startswith("state:")] + sl = sorted(l["name"] for l in it.get("labels", []) + if str(l.get("name", "")).startswith("state:")) if not sl: continue + if len(sl) > 1: # 真相源唯一性被破坏(宪法 §12)——排序取首保证两投影一致 + print(f"WARN multi-state {repo}#{it['number']}: {sl}" + f"——多 state 标签并存,本轮取 {sl[0]},请修标签") body = it.get("body") or "" boxes = re.findall(r"^\s*[-*]\s+\[( |x|X)\]", body, re.M) cards.append({ @@ -192,8 +196,9 @@ def scan_cards(repos): # ---------- Project(v2) 幂等准备 ---------- -Q_ORG = """query($org:String!){ organization(login:$org){ - id projectsV2(first:100){ nodes{ id title url } } } }""" +Q_ORG = """query($org:String!,$cur:String){ organization(login:$org){ + id projectsV2(first:100, after:$cur){ nodes{ id title url } + pageInfo{ hasNextPage endCursor } } } }""" Q_FIELDS = """query($pid:ID!){ node(id:$pid){ ... on ProjectV2 { fields(first:50){ nodes{ __typename ... on ProjectV2Field{ id name dataType } @@ -228,12 +233,19 @@ def scan_cards(repos): def ensure_project(): - org = gql(Q_ORG, {"org": ORG})["organization"] + org = gql(Q_ORG, {"org": ORG, "cur": None})["organization"] if org is None: raise Infra(f"organization {ORG} 不可见(GOVERNANCE_TOKEN 权限?)") - for p in (org.get("projectsV2") or {}).get("nodes") or []: - if p.get("title") == PROJECT_TITLE: - return p["id"], p.get("url") or "" + # 游标翻页遍历全量后再判“不存在”(org 项目 >100 时不翻页会重复建同名板) + while True: + conn = (org.get("projectsV2") or {}) + for p in conn.get("nodes") or []: + if p.get("title") == PROJECT_TITLE: + return p["id"], p.get("url") or "" + if not conn.get("pageInfo", {}).get("hasNextPage"): + break + cur = conn["pageInfo"]["endCursor"] + org = gql(Q_ORG, {"org": ORG, "cur": cur})["organization"] if DRY_RUN: print(f"[dry-run] 将创建 org Project(v2)「{PROJECT_TITLE}」") return None, "" @@ -351,7 +363,7 @@ def main(): key = (c["repo"], c["number"]) if c["state"] not in state_names: print(f"WARN unknown-state {c['repo']}#{c['number']}: label 态 {c['state']} " - f"不在 expected-state 全集——字段照设为文本态名,请修标签") + f"不在 expected-state 全集——State 无对应单选选项,将跳过 State 写入(报警留观),请修标签") entry = board.get(key) preexisting = entry is not None # 报警面只认"板上有旧值"的漂移(新增不算) if entry is None: @@ -402,7 +414,8 @@ def main(): for key, entry in board.items(): if key in card_keys or entry.get("issue_state") != "CLOSED": continue - final = next((n[len("state:"):] for n in entry["labels"] + # 与 scan_cards 同判据:排序取首(closed 多标签时两投影确定性一致) + final = next((n[len("state:"):] for n in sorted(entry["labels"]) if n.startswith("state:")), None) if final and entry["fields"].get("State") != final and final in opt_ids: if DRY_RUN: diff --git a/governance/dashboard-update.py b/governance/dashboard-update.py index 12ad88f..db80eae 100644 --- a/governance/dashboard-update.py +++ b/governance/dashboard-update.py @@ -45,13 +45,20 @@ TRIGGER = os.environ.get("BUTLER_TRIGGER") or "manual" APP_BOT = "cloudbrid-agent[bot]" JSON_MARK = "" +FENCE = "`" * 8 # 长于任何合理用户输入的 fence(标题可含 ```——防截断机器可读区) + + +def _safe_text(s): + """剥离用户可控文本里可破坏 fence / 伪造区标记的字面量(标题进 body 的必经清洗)。""" + return (str(s or "").replace("`", "'") + .replace("", "-- >")) class Infra(Exception): pass -def _req(url, body=None, method=None, ok_codes=(200, 201)): +def _req(url, body=None, method=None): # 状态码判定归调用方(send/显式检查)——不设 ok_codes 形参以免“声明了却不用” headers = {"Authorization": f"Bearer {TOKEN}", "User-Agent": "dashboard-update", "Accept": "application/vnd.github+json"} data = json.dumps(body).encode() if body is not None else None @@ -80,7 +87,7 @@ def get(path): def send(method, path, body, ok_codes=(200, 201)): - st, payload = _req(f"{GH_API}{path}", body, method, ok_codes) + st, payload = _req(f"{GH_API}{path}", body, method) if st not in ok_codes: raise Infra(f"{method} {path} HTTP {st}: {str(payload.get('message'))[:160]}") return payload @@ -112,11 +119,15 @@ def scan_cards(repos): for it in batch: if "pull_request" in it: continue - sl = [l["name"] for l in it.get("labels", []) if str(l.get("name", "")).startswith("state:")] + sl = sorted(l["name"] for l in it.get("labels", []) + if str(l.get("name", "")).startswith("state:")) if not sl: continue - cards.append({"repo": repo, "number": it["number"], "title": it["title"], - "state": sl[0][len("state:"):], + if len(sl) > 1: # 真相源唯一性被破坏(宪法 §12)——排序取首保证两投影一致 + print(f"WARN multi-state {repo}#{it['number']}: {sl}" + f"——多 state 标签并存,本轮取 {sl[0]},请修标签") + cards.append({"repo": repo, "number": it["number"], "title": _safe_text(it["title"]), + "state": _safe_text(sl[0][len("state:"):]), "assignee": (it.get("assignees") or [{}])[0].get("login", ""), "url": it["html_url"], "updated_at": it.get("updated_at") or "", "days_idle": max(0, (NOW - _iso(it.get("updated_at"))).days)}) @@ -175,8 +186,13 @@ def sli_stuck(repos): cutoff = NOW - _dt.timedelta(hours=24) stuck = 0 for repo in repos: - prs = get(f"/repos/{ORG}/{repo}/pulls?state=open&per_page=100") - stuck += sum(1 for pr in prs if _iso(pr.get("created_at")) < cutoff) + page = 1 # 分页拉全量(>100 open PR 单页漏计——与 scan_cards 同教训) + while True: + prs = get(f"/repos/{ORG}/{repo}/pulls?state=open&per_page=100&page={page}") + stuck += sum(1 for pr in prs if _iso(pr.get("created_at")) < cutoff) + if len(prs) < 100: + break + page += 1 return stuck @@ -219,9 +235,9 @@ def render_body(payload): ## 机器可读区(agent 一次读取全局;历史留痕=本 issue 编辑历史) {JSON_MARK} -```json +{FENCE}json {json.dumps(payload, ensure_ascii=False, indent=2)} -``` +{FENCE} ## 人类一屏摘要 @@ -236,21 +252,31 @@ def render_body(payload): def ensure_issue(body): - """幂等找到/创建账本 issue;返回 (number, created)。""" + """幂等找到/创建账本 issue;返回 (number, created)。 + + 查找范围 state=all(含已关闭:账本被人工关闭后复用之,不得重复创建—— + 否则账本分裂、编辑历史散落);/issues 端点混入 PR,须按 "pull_request" + 键排除;标题不唯一——复用已存在账本还须带 `dashboard` label(本脚本创建 + 即打标;同名无标 issue 不接管,防 body 覆盖写进无关 issue)。 + """ found = None page = 1 while True: - batch = get(f"/repos/{ORG}/{HOME_REPO}/issues?state=open&per_page=100&page={page}") - found = next((i for i in batch if i["title"] == ISSUE_TITLE), None) + batch = get(f"/repos/{ORG}/{HOME_REPO}/issues?state=all&per_page=100&page={page}") + found = next((i for i in batch + if "pull_request" not in i and i["title"] == ISSUE_TITLE + and LABEL["name"] in [l.get("name") for l in i.get("labels", [])]), None) if found or len(batch) < 100: break page += 1 if found: return found["number"], False - # 幂等建 label(422=已存在,容忍) - _req(f"{GH_API}/repos/{ORG}/{HOME_REPO}/labels", - {"name": LABEL["name"], "color": LABEL["color"], "description": LABEL["description"]}, - "POST", ok_codes=(201, 422)) + # 幂等建 label(201=新建,422=已存在;其余=真故障——fail-closed 不静默) + st, payload = _req(f"{GH_API}/repos/{ORG}/{HOME_REPO}/labels", + {"name": LABEL["name"], "color": LABEL["color"], + "description": LABEL["description"]}, "POST") + if st not in (201, 422): + raise Infra(f"POST labels HTTP {st}: {str(payload.get('message'))[:160]}") if DRY_RUN: print(f"[dry-run] 将创建 dashboard 账本 issue「{ISSUE_TITLE}」") return None, True @@ -260,20 +286,39 @@ def ensure_issue(body): def project_url(): - """只读取 factory-floor 项目链接(board-sync 已建;失败不阻塞账本——置空)。""" + """只读取 factory-floor 项目链接(board-sync 已建;失败不阻塞账本——置空)。 + + projectsV2 游标翻页(与 board-sync.ensure_project 同判据):org 项目 >100 时 + 目标不在首页,不翻页会把“存在”误判为“不存在”。 + """ try: - st, payload = _req(f"{GH_API}/graphql", { - "query": "query($o:String!){ organization(login:$o){ projectsV2(first:100){ nodes{ title url } } } }", - "variables": {"o": ORG}}, "POST") - if st == 200 and not payload.get("errors"): - for p in payload["data"]["organization"]["projectsV2"]["nodes"]: + cur = None + while True: + st, payload = _req(f"{GH_API}/graphql", { + "query": "query($o:String!,$cur:String){ organization(login:$o){" + " projectsV2(first:100, after:$cur){ nodes{ title url }" + " pageInfo{ hasNextPage endCursor } } } }", + "variables": {"o": ORG, "cur": cur}}, "POST") + if st != 200 or payload.get("errors"): + break + conn = payload["data"]["organization"]["projectsV2"] + for p in conn["nodes"]: if p["title"] == "factory-floor": return p["url"] + if not conn["pageInfo"]["hasNextPage"]: + break + cur = conn["pageInfo"]["endCursor"] except Exception: pass return "" +def _stable(body): + """剥离每轮必变的时间戳再比对(generated_at 精度到秒——不剥离则“内容相同 + 跳过写”永不生效,每 15min 一条无意义编辑淹没 issue 历史)。""" + return re.sub(r'"generated_at":\s*"[^"]*"', '"generated_at":"-"', body).strip() + + def main(): if not TOKEN: print("FATAL 需要环境变量 GH_TOKEN=GOVERNANCE_TOKEN", file=sys.stderr) @@ -294,7 +339,7 @@ def main(): return 0 if not created: cur = get(f"/repos/{ORG}/{HOME_REPO}/issues/{num}") - if (cur.get("body") or "").strip() == body.strip(): + if _stable(cur.get("body") or "") == _stable(body): stats["unchanged"] = 1 elif DRY_RUN: print(f"[dry-run] 将编辑 issue #{num} body({len(body)} 字节)") diff --git a/governance/drift-check.sh b/governance/drift-check.sh index 7baf9f0..b880c18 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -733,10 +733,32 @@ fi # 产品仓 rollout(7 个业务仓下发协议块)是后续 fleet 小卡——届时逐仓加 # entry_protocol: true 即纳入本节管辖。 PROTO_OK=0 -PROTO_REPOS=$(jq -r '[.repos[] | select((.entry_protocol // false) == true) | .name] | join(" ")' \ - "$DIR/REPOS.yaml" 2>/dev/null || echo "") -proto_block() { # 提取协议块(标记间内容,含首尾标记行)——无标记则输出空 - awk '//{f=1} f{print} //{f=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="" @@ -762,12 +784,13 @@ for r in $PROTO_REPOS; do fi BLOCK=$(proto_block <<<"$RAW") if [[ -z "$BLOCK" ]]; then - drift "repo '$r' AGENTS.md 缺协议块标记(entry_protocol 已申报——宪法 §11 统一入口)" + drift "repo '$r' AGENTS.md 缺协议块标记或标记不完整(entry_protocol 已申报——宪法 §11 统一入口)" PROTO_OK=1 continue fi if [[ "$BLOCK" != "$CANON_BLOCK" ]]; then - drift "repo '$r' 协议块与 template-service 不一致(首处差异: $(diff <<<"$CANON_BLOCK" <<<"$BLOCK" | head -3 | tr '\n' ' ' | cut -c1-160))" + # 双 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 diff --git a/scripts/ghcb b/scripts/ghcb old mode 100644 new mode 100755 index 40a92cb..f4addea --- a/scripts/ghcb +++ b/scripts/ghcb @@ -33,7 +33,7 @@ case "$CMD" in next) REPO_ARG="${2:-$(origin_repo)}" [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓(用法: ghcb next [repo])" >&2; exit 2; } - gh issue list --repo "$REPO_ARG" --label state:ready --state open --json number,title --jq '.[] | "#\(.number) \(.title)"' || { echo "查询失败:检查 gh 凭据(gh auth login)与仓权限" >&2; exit 2; } + gh issue list --repo "$(norm_repo "$REPO_ARG")" --label state:ready --state open --json number,title --jq '.[] | "#\(.number) \(.title)"' || { echo "查询失败:检查 gh 凭据(gh auth login)与仓权限" >&2; exit 2; } ;; claim) # 参数序 [repo](首版误把 $1 当 repo,PR#157 修正) @@ -43,7 +43,7 @@ case "$CMD" in # MSYS 路径转换豁免:Git Bash 会把以 / 开头的参数改写成 Windows 路径 # (实测 "/claim"→"D:/development/Git/claim",conductor 白名单不认); # 非 MSYS 环境该 env 为无害空设 - MSYS2_ARG_CONV_EXCL='/claim' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$REPO_ARG" --body "/claim" >/dev/null + MSYS2_ARG_CONV_EXCL='/claim' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$(norm_repo "$REPO_ARG")" --body "/claim" >/dev/null echo "已评论 /claim(#$N @$REPO_ARG)——conductor 转介 arbiter CAS 裁决并置 state:in-progress;确认:ghcb status $N" ;; release) @@ -51,7 +51,7 @@ case "$CMD" in N="${2:?用法: ghcb release [repo]}" REPO_ARG="${3:-$(origin_repo)}" [[ -n "$REPO_ARG" ]] || { echo "错误:不在 git 仓内且未指定仓" >&2; exit 2; } - MSYS2_ARG_CONV_EXCL='/release' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$REPO_ARG" --body "/release" >/dev/null + MSYS2_ARG_CONV_EXCL='/release' MSYS_NO_PATHCONV=1 gh issue comment "$N" --repo "$(norm_repo "$REPO_ARG")" --body "/release" >/dev/null echo "已评论 /release(#$N @$REPO_ARG)——conductor 转介 arbiter 释放租约(仅 holder/owner);确认:ghcb status $N" ;; status) @@ -63,14 +63,17 @@ case "$CMD" in gh issue view "$N" --repo "$FULL" --json number,title,state,labels,assignees \ --jq '"#\(.number) \(.title)\n 状态: \(.state) | 标签: \([.labels[].name] | join(", ")) | assignee: \([.assignees[].login] | join(", "))"' \ || { echo "查询失败:检查 gh 凭据与仓权限" >&2; exit 2; } - REF="refs/leases/${ORG}__${NAME}__${N}" - if SHA=$(gh api "repos/$LEAS_REPO/git/ref/$REF" --jq '.object.sha' 2>/dev/null); then + # GET /git/ref/{ref} 的 ref 参数不带 refs/ 前缀(heads/main 而非 refs/heads/main; + # 带 refs/ 前缀恒 404——实测),展示仍用完整 ref 名符合 git 语义 + REF_FULL="refs/leases/${ORG}__${NAME}__${N}" + REF_API="leases/${ORG}__${NAME}__${N}" + if SHA=$(gh api "repos/$LEAS_REPO/git/ref/$REF_API" --jq '.object.sha' 2>/dev/null); then MSG=$(gh api "repos/$LEAS_REPO/git/commits/$SHA" --jq '.message' 2>/dev/null || true) HOLDER=$(grep -o '"holder": *"[^"]*"' <<<"$MSG" | head -1 | sed 's/.*"holder": *"//; s/"$//' || true) EXP=$(grep -o '"expires_at": *"[^"]*"' <<<"$MSG" | head -1 | sed 's/.*"expires_at": *"//; s/"$//' || true) - echo " 租约: holder=${HOLDER:-?} expires_at=${EXP:-?}(ref $REF)" + echo " 租约: holder=${HOLDER:-?} expires_at=${EXP:-?}(ref $REF_FULL)" else - echo " 租约: 无活跃租约(ref $REF 不存在)" + echo " 租约: 无活跃租约(ref $REF_FULL 不存在)" fi ;; card-meta)