diff --git a/.github/workflows/baseline-ci.yml b/.github/workflows/baseline-ci.yml index 8ec66bb..9d68b04 100644 --- a/.github/workflows/baseline-ci.yml +++ b/.github/workflows/baseline-ci.yml @@ -43,8 +43,8 @@ jobs: run: | set -euo pipefail shellcheck scripts/*.sh tests/test-audit.sh tests/bin/gh - .ci-venv/bin/ruff check scripts/audit_github_baseline.py - .ci-venv/bin/ruff format --check scripts/audit_github_baseline.py + .ci-venv/bin/ruff check scripts/audit_github_baseline.py tests/unit + .ci-venv/bin/ruff format --check scripts/audit_github_baseline.py tests/unit PYTHONPYCACHEPREFIX="${RUNNER_TEMP}/engineering-baseline-pycache" \ .ci-venv/bin/python -m py_compile scripts/audit_github_baseline.py PATH="${PWD}/.ci-venv/bin:${PATH}" bash tests/test-audit.sh diff --git a/.github/workflows/baseline-drift-audit.yml b/.github/workflows/baseline-drift-audit.yml index c175e08..64eebb1 100644 --- a/.github/workflows/baseline-drift-audit.yml +++ b/.github/workflows/baseline-drift-audit.yml @@ -19,6 +19,8 @@ jobs: runs-on: ubuntu-latest environment: audit timeout-minutes: 20 + outputs: + status: ${{ steps.audit.outputs.status }} steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -55,9 +57,53 @@ jobs: mdurl==0.1.2 - name: Audit maintained repositories + id: audit env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - set -euo pipefail + # 不使用 set -e:需要保留退出码用于区分 1(漂移)与 3(审计未完成)。 + set -uo pipefail + status=0 PATH="${PWD}/.audit-venv/bin:${PATH}" \ - bash scripts/audit-maintained-repositories.sh + bash scripts/audit-maintained-repositories.sh || status=$? + echo "status=${status}" >>"${GITHUB_OUTPUT}" + exit "${status}" + + # 计划任务失败必须主动落地成 issue,否则周任务的红色没人看见。 + # 通知只使用基线仓库自身的 GITHUB_TOKEN;只读审计 App 保持零写权限。 + notify: + name: Report unattended audit failure + needs: audit + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + steps: + - name: Open or update drift issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AUDIT_STATUS: ${{ needs.audit.outputs.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + case "${AUDIT_STATUS:-}" in + 1) summary="检测到基线漂移" ;; + 3) summary="审计未能完成(GitHub API 不可用)" ;; + 2) summary="审计参数或本地依赖错误" ;; + *) summary="审计异常退出" ;; + esac + title="工程基线周审计失败:${summary}" + # 审计输出可能包含 secret 名称等元数据,公开仓库的 issue 里只放运行链接。 + body=$(printf '%s\n\n%s\n\n%s\n' \ + "计划任务 \`Engineering baseline drift audit\` 失败:${summary}(退出码 ${AUDIT_STATUS:-未知})。" \ + "完整逐仓结论见运行日志:${RUN_URL}" \ + "退出码含义:1=基线漂移,3=审计未完成结论不可信,2=参数或依赖错误。") + existing=$(gh issue list --state open --search "${title} in:title" \ + --json number --jq '.[0].number // empty') + if [[ -n "${existing}" ]]; then + gh issue comment "${existing}" --body "${body}" + else + gh issue create --title "${title}" --body "${body}" + fi diff --git a/AGENTS.md b/AGENTS.md index b2a76ab..cdf8a71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ ## 语言与提交 - 所有回复、代码注释和 Git 提交信息使用中文。 -- Git 提交标题使用 `: <中文描述>`;正文说明背景、改动和验证,并包含 `Co-Authored-By: Codex `。 +- Git 提交标题使用 `: <中文描述>`;正文说明背景、改动和验证。 +- 不强制 `Co-Authored-By:` trailer。AI 协作的提交由 GitHub App 的 bot 身份(如 `chatgpt-codex-connector`)在 author/committer 上体现,contributors 由此产生;邮箱无法解析到真实账号的 trailer 在 GitHub 上只是纯文本,不构成协作者,也不要为此编造地址。 ## 开发与验证 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c0cf07a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# 变更记录 + +版本号用于纳管项目声明"当前对齐到哪一版基线"。审计器每次运行都会回显版本, +漂移排查时可以据此判断是项目变了还是公共规则变了。 + +发布流程见 [MAINTENANCE.md](MAINTENANCE.md) 的"发布基线版本"。 + +## v1.1.0 + +### 修复 + +- **release workflow 引用的本地 Action 不再绕过审计。** `.github/actions/**` 之外的本地 + Action 只在被引用时惰性解析,而供应链检查此前跑在解析之前,且 `inspect_release` + 从不计算受控 release 自身的 local action closure。结果是权限最高的 workflow + 可以通过 `./ci/actions/x` 引入未锁定的第三方 Action,审计仍返回 `PASS`; + prepare job 也能借同样的路径隐藏部署能力。现在所有 `uses:` 引用检查移动到全部 + inspect 之后执行,`inspect_release` 会解析并上报 release 侧 closure, + prepare 的纯净性判断覆盖其本地 Action。 + +### 变更 + +- **区分"基线漂移"与"审计没跑完"。** 新增退出码 `3` 与 `ERROR owner/repo` 前缀、 + `[API_UNAVAILABLE]` 错误码。GitHub 5xx/429/连接失败不再表现为漂移。 +- **`gh api` 增加指数退避重试**(4 次尝试,2s/4s/8s,可用 + `BASELINE_AUDIT_RETRY_BASE_SECONDS` 覆盖)。持续不可用的 optional 端点 + (如 `environments/dev`)报 `[API_UNAVAILABLE]`,不再降级成"Environment 缺失"。 +- **默认分支参数化。** 新增 `BASELINE_DEFAULT_BRANCH`,默认 `master`。API 路径、 + `github.ref` guard 原子、PR `branches` 与 branch protection 判定全部随之变化, + 基线不再硬编码 `master`。 +- **计划任务失败会开 issue。** 漂移 workflow 新增 `notify` job,使用基线仓库自身的 + `GITHUB_TOKEN`(`issues: write`);只读审计 App 权限不变,issue 正文只放运行链接, + 不回显可能包含 secret 名称的审计输出。 +- 审计输出首行回显基线版本与受保护分支。 + +### 测试 + +- 新增 `tests/unit/`:对表达式解析器、secret 静态可枚举性、`gh api` 重试与分支 + 参数化做直接单元测试,不再只靠 fixture 间接覆盖。 +- 新增 fixture:`release-local-action-mutable`、`release-local-action-missing`、 + `release-prepare-local-action-deploy`、`api-unavailable`、`main-default-branch`。 +- 契约测试改为校验错误码与文档的双向覆盖,不再钉死文档的中文措辞。 + +## v1.0.0 + +首个固化版本:PR/release 事件与权限边界、供应链锁定、`dev` Environment 与 secret +边界、`.github/release-safety.yml` 声明式适配与发布/回滚语义校验、`master` branch +protection、根 `AGENTS.md` 的 `## Code Review Rules` 标题校验,以及基于只读 GitHub App +的中央漂移审计。 diff --git a/MAINTENANCE.md b/MAINTENANCE.md index d725e49..7457b4b 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -1,6 +1,11 @@ # 工程基线维护手册 -本目录是 Fusion、Audio 与后续新项目共同使用的工程治理入口。公共契约只描述跨项目安全与交付边界;语言、构建命令、容器拓扑和业务验收继续由各项目负责。 +本文是操作手册。公共 MUST 只在契约中定义,这里不复述规则,只描述"什么时候做什么"。 + +- [contracts/ci-cd-baseline.md](contracts/ci-cd-baseline.md):CI/CD 公共契约 +- [contracts/codex-code-review.md](contracts/codex-code-review.md):官方 Codex Code Review 治理契约 +- [contracts/error-codes.md](contracts/error-codes.md):结论行、退出码与错误码参考 +- [CHANGELOG.md](CHANGELOG.md):基线版本变更记录 ## 日常审计 @@ -10,7 +15,13 @@ ./scripts/audit-maintained-repositories.sh ``` -审计器只读取 GitHub 配置、工作流内容和 secret 名称,不读取 secret 值。任何仓库失败时整体退出码为 `1`;本地依赖或清单错误退出码为 `2`。 +审计器只读取 GitHub 配置、工作流内容和 secret 名称,不读取 secret 值。 + +结论分三类,不能混为一谈: + +- `FAIL` + 退出码 `1`:**基线漂移**,按错误码定位公共 MUST 并修复。 +- `ERROR` + 退出码 `3`:**审计没跑完**(GitHub 5xx/429/网络失败),结论不可信。先确认 GitHub 状态并重跑,不要当成漂移去改项目,也不能当成通过。 +- 退出码 `2`:参数、仓库清单或本地依赖错误。 以下情况必须执行审计并保存结果: @@ -18,60 +29,63 @@ - 根 `AGENTS.md` 或 `## Code Review Rules` 发生变化; - 任一项目新增或修改 PR、发布、部署 workflow; - required check、branch protection、Environment 或 secret scope 发生变化; -- 新项目首次接入,以及接入后的首个 master 发布完成后。 +- 新项目首次接入,以及接入后的首个受保护分支发布完成后。 ## 新项目接入 1. 在独立 worktree 中复制 `templates/AGENTS.md`,替换占位符并补齐项目内部规则;项目规则不得降低公共 MUST,根文件必须保留精确 `## Code Review Rules` 标题。 -2. 在官方 GitHub/Codex 设置中启用 Code Review 与 `Automatic reviews`,并用真实 PR 验证自动审查;未触发时先排查设置和事件,确认没有结果或在途信号后才使用 `@codex review`。 -3. 保持 Auto-merge 关闭;官方 Review 尚非 required check 时不得为单个 PR 开启 Auto-merge。只有仓库仅使用临时 PR head 分支,或所有长期 head 分支受保护时,才启用合并后自动删除远端分支。 +2. 在官方 GitHub/Codex 设置中启用 Code Review 与 `Automatic reviews`,并用真实 PR 验证自动审查。 +3. 保持 Auto-merge 关闭;分支自动删除按项目真实分支模型配置。 4. 复制并按项目实际情况改造 `templates/` 下的 PR、release workflow、`release-safety.yml` 与 `release-safety-contract.sh`;把后两者分别安装为 `.github/release-safety.yml` 与 `.github/scripts/release-safety-contract.sh`,保持 wrapper 的 Git mode 为 `100755`,并映射项目真实 job/step 拓扑。 -5. 通过真实 PR 证明 `PR container validation` 成功,随后原子迁移 master required check。 -6. 通过一次 master 发布证明 Environment-only secret、旧运行态捕获、候选镜像身份、容器内 smoke、成功后清理和 `DEPLOY_TARGET_SHA` 发布证据。 +5. 通过真实 PR 证明 `PR container validation` 成功,随后原子迁移受保护分支的 required check。 +6. 通过一次受保护分支发布证明 Environment-only secret、旧运行态捕获、候选镜像身份、容器内 smoke、成功后清理和 `DEPLOY_TARGET_SHA` 发布证据。 7. 将 `owner/repo` 追加到 `repositories.txt`。 8. 将仓库名追加到中央 `.github/workflows/baseline-drift-audit.yml` 的 `repositories` 显式列表;测试会要求该列表与清单顺序一致。 9. 把只读 GitHub App `Engineering Baseline Auditor` 的安装范围扩展到新仓库;纳管项目不得保存 App 私钥或个人访问令牌。 10. 运行 fixture 测试,并在基线仓库人工触发中央 live 审计;两者都成功后才算纳入基线。 -固定 wrapper 内部可以复用既有语言或容器测试;`PR container validation` job 名称必须全局唯一、无 `if`、无 `needs`、无有效 `continue-on-error`,workflow/job 不得覆盖默认 `shell` 或 `working-directory`。manifest 映射的主 PR targeted step 必须以单行 `run` 精确等于 wrapper 路径,不能附加参数、前后命令、`;`、管道或 `||`,也不能声明 `if`、`continue-on-error`、`shell`、`working-directory`,或把 ID 挂到 checkout、`echo`、`test -f` 与不相关的完整构建步骤。 +默认分支不是 `master` 的项目,通过 `BASELINE_DEFAULT_BRANCH` 指定;该变量同时决定审计读取的 ref、`github.ref` guard 原子与 PR `branches` 判定。同一次审计的所有仓库共用一个值。 -prepare 存在时,publish、migration 与 deploy 必须使用 `needs..outputs.<合法ID>` 指向同一个真实 output signal,deploy 的两条路径还必须共同要求 prepare 成功;prepare 不存在时只能使用 `github.event.inputs.rollback_sha`,且 deploy 的手动回滚分支必须同时要求 `github.event_name == 'workflow_dispatch'`。rollback step condition 只能由 `failure()`、mapped capture 成功和 mapped candidate 已进入三类原子各一次组成。release workflow 的所有 job 都必须保持失败可见,concurrency 不得按 inputs、matrix、job 或运行编号分域。 +manifest 与 workflow 的具体映射约束见[公共契约](contracts/ci-cd-baseline.md) §5 与 §8。 ## 修改公共规则 1. 先在 `contracts/ci-cd-baseline.md` 或 `contracts/codex-code-review.md` 明确公共 MUST 与项目扩展点,避免把单个项目细节升格为通用规则。 2. 为审计器补充能够复现旧实现缺口的失败 fixture,取得 RED 后再修改实现。 -3. 同步模板 profile、manifest schema、已知 Action SHA/版本映射和 README。 -4. 执行: +3. 同步模板 profile、manifest schema、已知 Action SHA/版本映射。 +4. 新增或删除错误码时同步 `contracts/error-codes.md`;测试会双向校验实现与文档完全一致。 +5. 执行: ```bash bash tests/test-audit.sh ./scripts/audit-maintained-repositories.sh ``` -5. 检查四仓真实 PR、master 发布和运行环境证据;只读审计不能替代这些门禁。 +6. 检查各仓真实 PR、发布和运行环境证据;只读审计不能替代这些门禁。 +7. 在 `CHANGELOG.md` 记录本次变更,并按语义化版本更新 `BASELINE_VERSION`。 + +### 审计器改动的额外要求 + +- 表达式解析、重试与分支解析这类纯函数改动,必须在 `tests/unit/` 补直接单元测试;只靠 fixture 间接覆盖不够。 +- 新增"只在被引用时才解析"的输入源时,确认它在 `audit_repository` 的检查顺序中确实被覆盖——本地 Action 曾因此产生过审计盲区,见 `CHANGELOG.md` v1.1.0。 ## 官方 Codex Code Review 维护 -- 只采用官方 Codex Code Review,不自建 Reviewer、GitHub Action、Bot、Webhook 服务、模型调用或平行评论协议。 -- 自动触发依赖官方 `Automatic reviews`;`AGENTS.md` 只定义审查规则,不能代替设置。自动审查未出现时先按官方说明排查仓库开关、Codex cloud 和触发事件,确认没有结果或在途信号后才使用 `@codex review`。 -- 非平凡 PR 先保持 Draft,完成实现、测试、内部交叉审查和范围冻结后再进入官方 Review;多个独立架构边界必须拆分,跨仓变更先冻结共享协议,最终集成 PR 才使用 `Closes #xx`。 -- `## Code Review Rules` 保持两到三条简洁、项目特有的高后果规则,并提供安全路径或正反 few-shot;机械一致性、lint 和格式交给确定性 CI。 -- 只提交 P0/P1 finding:必须证明问题由当前 PR 引入、存在当前可达路径并造成明确正确性、安全、权限、数据、兼容性或发布后果。若产品仍输出 P2/P3,则默认不阻塞;只有维护者给出可复现证据并明确升级后才按阻塞项处理。 -- 等一轮 findings 返回后统一分类。阻塞项按根因批量修复;非阻塞项回复处置依据后解决 thread,不修改代码、不自动建 Issue,也无需重新 Review。 -- 只有阻塞修复显著改变原风险面时才做一次定向 Review;定向 Review 再出现非阻塞建议时直接记录处置并停止,再出现新的阻塞 P0/P1 时先拆分、收窄或重新设计 PR。 -- 官方 Reviewer 给出未发现重大问题的文字结论,或全部 finding 完成阻塞分类与处置,即视为闭环;不追求 emoji、reaction、固定口令或零评论。 -- v1 保持建议模式,不把官方 Review 增加为 required check,不修改现有 branch protection;required checks 仍是确定性门禁。 -- 官方设置和审查行为通过真实 PR 验收;中央审计只检查根 `AGENTS.md` 是普通文件,且固定 `markdown-it-py==3.0.0` CommonMark parser 产生源码行精确等于 `## Code Review Rules` 的真实 h2。不得恢复手写 Markdown 状态机,也不抓取页面、调用私有接口或伪称验证同一 HEAD 请求次数、在途 Review 和审查预算。 -- 官方功能演进时先更新接入说明和精简规则;若官方补齐缺口,优先删除临时流程,不维护平行实现。 +规则本身见[治理契约](contracts/codex-code-review.md)。日常只需要注意: + +- 只采用官方 Codex Code Review,不自建 Reviewer、Bot、Webhook 服务或平行评论协议。 +- 自动触发依赖官方 `Automatic reviews`;`AGENTS.md` 只定义审查规则,不能代替设置。自动审查未出现时先按官方说明排查,确认没有结果或在途信号后才使用 `@codex review`。 +- `## Code Review Rules` 保持两到三条简洁、项目特有的高后果规则,并提供正反 few-shot;机械一致性、lint 和格式交给确定性 CI。 +- 官方设置和审查行为通过真实 PR 验收;中央审计只检查根 `AGENTS.md` 是普通文件且含精确 h2 标题,不得恢复手写 Markdown 状态机。 +- 官方功能演进时先更新接入说明和精简规则;若官方补齐缺口,优先删除临时流程。 ## Auto-merge 维护 -- GitHub 只保证无写权限者推送新提交时关闭 Auto-merge;有写权限者推送后不保证自动关闭。官方 Review 当前不是 required check,因此 v1 暂不允许仓库或单个 PR 开启 Auto-merge。 -- 不建立 HEAD 监听器、自动合并 Bot、Action 或定时任务来补这个能力缺口;未来等官方 Review 可成为当前 HEAD required gate,或 GitHub 保证任意新提交都会撤销 Auto-merge 后再评估。 -- 人工合并前确认 required checks 成功、阻塞 finding 已修复或有明确风险接受、全部 review thread 已完成处置且依赖与发布风险已分类;非阻塞项不要求修改代码或重复审查。 -- Dependabot PR 不能只凭 CI 绿灯自动合并;运行时依赖、跨大版本和会触发生产发布的仓库必须另做兼容性与发布授权判断。 -- 分支自动删除不是公共强制项。只有仓库仅使用临时 PR head 分支,或所有长期 head 分支受保护时,才启用 GitHub 的合并后自动删除;存在未保护且需要复用的长期 head 分支时必须关闭。 +边界与理由见[治理契约](contracts/codex-code-review.md) §5。日常只需要注意: + +- v1 暂不允许仓库或单个 PR 开启 Auto-merge;不建立监听器、Bot 或定时任务来补这个能力缺口。 +- 人工合并前确认 required checks 成功、阻塞 finding 已修复或有明确风险接受、全部 review thread 已完成处置且依赖与发布风险已分类。 +- Dependabot PR 不能只凭 CI 绿灯合并。 - 本地 worktree/分支只在已合并、干净、没有独有提交且相关发布验收完成后删除。 ## Action 升级 @@ -83,26 +97,35 @@ prepare 存在时,publish、migration 与 deploy 必须使用 `needs. ## 发布基线版本 1. 先让基线仓库的 `Baseline contract validation` 在真实 PR 通过。 -2. 合并后人工触发 `Engineering baseline drift audit`,保存四仓 live 结果。 -3. 只有四仓都输出 `PASS`,才更新版本说明并把该版本作为后续新项目入口。 -4. 中央审计的 GitHub App `Engineering Baseline Auditor` 只允许 `Administration: read`、`Actions: read`、`Contents: read`、`Environments: read`、`Metadata: read`、`Secrets: read`,不订阅 webhook 事件且不授予写权限;其中 Metadata 是 GitHub 强制只读权限。 -5. App Client ID 使用 repository variable;私钥只存放在基线仓库的 `audit` Environment。禁止把 App 私钥下发到项目,也禁止保存个人访问令牌。 +2. 合并后人工触发 `Engineering baseline drift audit`,保存各仓 live 结果。 +3. 只有全部仓库输出 `PASS`,才更新 `CHANGELOG.md` 与 `BASELINE_VERSION`,打 `v..` tag,并把该版本作为后续新项目入口。 +4. 中央审计的 GitHub App `Engineering Baseline Auditor` 只允许安装到 `@HyxiaoGe`,不订阅 webhook 事件且不授予写权限,只配置以下 repository permissions: + + - `Administration: read`:读取受保护分支的 branch protection。 + - `Actions: read`:列举 active workflows。 + - `Contents: read`:读取默认分支 workflow 与本地 Action 内容。 + - `Environments: read`:读取 Environment 和 Environment secret 名称元数据。 + - `Metadata: read`:GitHub App installation token 强制携带的仓库元数据只读权限。 + - `Secrets: read`:读取 repository secret 名称元数据。 + +5. App Client ID 使用 repository variable `BASELINE_AUDIT_APP_CLIENT_ID`;私钥只作为 `BASELINE_AUDIT_APP_PRIVATE_KEY` 存放在基线仓库的 `audit` Environment。禁止把 App 私钥下发到项目,也禁止保存个人访问令牌。 +6. 计划任务失败时由独立 `notify` job 开或更新 issue。该 job 只使用基线仓库自身的 `GITHUB_TOKEN`,与只读审计 App 完全隔离;issue 正文只放运行链接,不回显可能包含 secret 名称的审计输出。 ## 发布安全演练 -1. 正常 master 发布使用 merge SHA 作为 `DEPLOY_TARGET_SHA`,保存候选 ref、内容 ID、容器内 smoke 和 finalize 证据。 +1. 正常发布使用 merge SHA 作为 `DEPLOY_TARGET_SHA`,保存候选 ref、内容 ID、容器内 smoke 和 finalize 证据。 2. 人工触发现有 release workflow,填写已存在镜像的 40 位小写 `rollback_sha` 与非空原因;确认 publish 登录/构建/push 和迁移全部跳过。 3. 演练候选部署或验收失败,确认 capture 只接受受管 `IMAGE_NAME:<40 位小写 SHA>` 和 `sha256:<64 位小写十六进制>` 内容 ID,且只在 capture 成功、候选部署已进入后自动恢复;恢复后同时核对旧 image ref、内容 ID 并执行容器内 smoke。 -4. 确认原发布仍为失败;恢复失败也不得被隐藏,旧镜像清理不得以 `always()` 运行。发布证据、metrics 和 finalize 必须记录实际 `DEPLOY_TARGET_SHA`。 +4. 确认原发布仍为失败;恢复失败也不得被隐藏,旧镜像清理不得以 `always()` 运行。 5. 迁移只采用 expand/contract,禁止自动执行 `alembic downgrade`;应用镜像恢复不执行数据库降级。 -6. 首次部署无旧镜像时默认 fail-closed。确需首次部署时,单独审批一次性例外并保存证据,不得长期弱化 capture 门禁。 -7. 检查所有 active workflow;self-hosted、`dev` Environment、secret、镜像拉取/编排或手动部署能力不得散落在受控 release 之外,注释或 `echo` 文本不能作为运行时安全证据。 +6. 首次部署无旧镜像时默认 fail-closed。确需首次部署时,单独审批一次性例外并保存证据。 +7. 检查所有 active workflow;发布能力不得散落在受控 release 之外,注释或 `echo` 文本不能作为运行时安全证据。 8. 中央审计只验证 manifest 与结构化 YAML 高层约束,不解释任意 shell 控制流;ref/内容 ID、容器内 smoke、回滚命令和实际目标 SHA 必须由项目可执行契约测试与真实演练证明。 -9. 同步修改 workflow 与 manifest 不能覆盖公共语义:publish/deploy/finalize 必须 master guarded,publish/migration 必须跳过 rollback,deploy 必须显式区分 publish success 与 rollback+publish skipped,rollback 只能使用允许的 failure/capture/candidate 合取式。 ## 漂移处理 -- 先依据稳定错误码定位公共 MUST,例如 `[ACTION_PIN]`、`[REQUIRED_CHECK_APP]`、`[SECRET_ENV_BOUNDARY]`、`[CODE_REVIEW_RULES]`、`[RELEASE_MANIFEST]`、`[ROLLBACK_CAPTURE]` 或 `[RELEASE_FAILURE_STATE]`。 -- 所有持久修复都从目标仓库的独立分支经 PR 进入 master,不直接修改 dev 服务器代码。 -- 修复后重新执行目标仓库 PR、master 发布、部署身份/健康验收和全清单审计。 +- 先按 [contracts/error-codes.md](contracts/error-codes.md) 定位错误码对应的公共 MUST。 +- 确认结论行是 `FAIL` 而不是 `ERROR`:后者是审计没跑完,应该先修基础设施再重跑。 +- 所有持久修复都从目标仓库的独立分支经 PR 进入受保护分支,不直接修改 dev 服务器代码。 +- 修复后重新执行目标仓库 PR、发布、部署身份/健康验收和全清单审计。 - 新的通用缺口必须沉淀为 fixture;只修项目而不增强审计器,会留下相同漂移再次发生的入口。 diff --git a/README.md b/README.md index 9324391..817013f 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,33 @@ 基线仓库自身不会自动修改纳管项目。`scripts/audit-github-baseline.sh` 只通过 GitHub GET API 读取仓库配置、工作流内容以及 secret **名称**,不会读取或输出 secret 值。 -当前纳管仓库记录在 `repositories.txt`。公共规则的变更、Action 升级、新项目接入和漂移处理统一从 [MAINTENANCE.md](MAINTENANCE.md) 进入。 +当前纳管仓库记录在 `repositories.txt`,版本变更记录在 [CHANGELOG.md](CHANGELOG.md)。 + +## 文档导航 + +公共 MUST 只在 `contracts/` 下定义,本文与维护手册不复述规则,只负责入口与操作步骤。 + +| 文档 | 作用 | +|---|---| +| [contracts/ci-cd-baseline.md](contracts/ci-cd-baseline.md) | CI/CD 公共契约:事件、权限、供应链、发布安全、分支保护、secret 边界 | +| [contracts/codex-code-review.md](contracts/codex-code-review.md) | 官方 Codex Code Review 治理契约:接入、审查内容、finding 收敛、合并边界 | +| [contracts/error-codes.md](contracts/error-codes.md) | 审计结论行、退出码与全部稳定错误码 | +| [MAINTENANCE.md](MAINTENANCE.md) | 日常审计、新项目接入、规则变更、Action 升级、漂移处理 | ## 接入顺序 1. 从独立 Git worktree 创建变更分支,避免污染长期开发目录。 2. 复制 `templates/AGENTS.md` 到项目根目录,替换全部 `PROJECT_REPLACE` 项并补齐项目内部规则,保留精确标题 `## Code Review Rules`。 3. 在官方 GitHub/Codex 设置中启用 Code Review 与 `Automatic reviews`;规则只决定审查重点,不负责触发,人工兜底使用 `@codex review`。 -4. 保持仓库 Auto-merge 关闭;官方 Review 尚非 required check 时不得对单个 PR 开启 Auto-merge。只有仓库仅使用临时 PR head 分支,或所有长期 head 分支受保护时,才启用合并后自动删除远端分支。 +4. 保持仓库 Auto-merge 关闭;分支自动删除按项目真实分支模型配置。边界见[官方 Codex Code Review 治理契约](contracts/codex-code-review.md) §5。 5. 复制 `templates/pr-ci.yml`、`templates/release.yml`、`templates/release-safety.yml` 和 `templates/release-safety-contract.sh`;将后两份分别安装为 `.github/release-safety.yml` 与 `.github/scripts/release-safety-contract.sh`,保持 wrapper 为 `100755`,再替换所有 `PROJECT_REPLACE` 项。 6. 在仓库内实现项目自己的 PR 验证、镜像发布、向前迁移、部署、自动恢复、容器内 smoke、成功后镜像清理和发布收尾脚本;由固定 wrapper 调用项目语言或容器内的可执行契约测试,并让 manifest 声明 wrapper 与 PR 稳定步骤 ID。 7. 创建 `dev` Environment,把发布所需 secret 移入该 Environment;确认发布成功后清理 repository-scope secrets。 -8. 为 `master` 启用保护规则,要求 GitHub Actions 产生的 `PR container validation`,并保持 strict。 +8. 为受保护分支启用保护规则,要求 GitHub Actions 产生的 `PR container validation`,并保持 strict。 9. 在真实 PR 上先让新检查通过,再原子替换旧 required check;不要先删除旧门禁。 10. 合并后核对运行容器的 image ref、内容 ID 与 `DEPLOY_TARGET_SHA`,并执行容器内部 smoke。 11. 使用只读审计检查规则漂移。 -完整 MUST 和项目扩展点见 [CI/CD 公共契约](contracts/ci-cd-baseline.md) 与 [官方 Codex Code Review 治理契约](contracts/codex-code-review.md)。 - -## 官方 Codex Code Review - -基线只接入和治理官方 Codex Code Review,不自建 Reviewer、GitHub Action、Bot、Webhook 服务、模型调用或评论协议。自动唤醒使用官方 `Automatic reviews`;未触发时先按官方说明排查设置和事件,确认没有结果或在途信号后才使用 `@codex review` 人工兜底。 - -根 `AGENTS.md` 的 `## Code Review Rules` 只告诉官方 Reviewer 应检查哪些高后果项目风险,不能开启 Automatic reviews,也不能证明自动审查已经触发。按照[官方建议](https://learn.chatgpt.com/docs/third-party/github#customize-what-codex-reviews),规则保持两到三条简洁约束,提供安全路径与正反 few-shot,把 lint、格式和机械一致性留给 CI。v1 保持建议模式,不增加 required check,不改变现有 branch protection;完整边界见[官方治理契约](contracts/codex-code-review.md)。 - -阻塞边界以影响证据为准:只提交由当前 PR 引入、存在当前可达路径并会造成明确正确性、安全、权限、数据、兼容性或发布后果的 P0/P1。若产品仍输出 P2/P3,则默认不阻塞;测试还能增加 fixture、理论加固、未来同时修改规则与测试、lint/措辞/命名或纯重构无需修改,也无需重新 Review。 - -非平凡 PR 在 Draft 阶段完成实现、测试、内部交叉审查与范围冻结,再让官方 Review 检查稳定变更。一轮 findings 返回后统一分类:阻塞项按根因批量修复;非阻塞项回复处置依据后解决 thread,不修改代码、不自动建 Issue,也不触发新 Review。只有阻塞修复显著改变原风险面时才进行一次定向 Review;若再次出现新的阻塞 P0/P1,先拆分或收窄 PR,而不是自动循环。 - -官方 Reviewer 给出未发现重大问题的明确文字结论,或全部 finding 已完成阻塞分类与处置,即视为闭环;不追求 emoji、reaction、固定批准口令或零评论。包含多个独立架构边界的大型需求应拆成阶段 PR,跨仓变更先冻结共享协议,只有最终集成 PR 使用 `Closes #xx`。 - -## Auto-merge 与分支清理 - -GitHub 只保证无写权限者推送新提交时关闭 Auto-merge;有写权限者推送后不保证自动关闭。由于官方 Review 当前不是 required check,纳管产品仓库在 v1 暂不允许开启 Auto-merge,也不为单个 PR 启用它。 - -人工合并前必须确认 required checks 成功、阻塞 finding 已修复或有明确风险接受、全部 review thread 已完成处置,且依赖或发布风险已分类;非阻塞项不要求代码修改或重复审查。Dependabot PR 不会自动合并;尤其是运行时依赖、跨大版本升级、构建行为变化,以及合并即触发生产发布的仓库,仍需兼容性判断和明确发布授权。 - -分支自动删除不是公共强制项。只有仓库仅使用临时 PR head 分支,或所有长期 head 分支受保护时,才启用 GitHub 的合并后自动删除;存在未保护且需要复用的 `dev`、`release` 等长期 head 分支时必须关闭。本地 worktree 和分支始终只在确认已合并、工作区干净且没有独有提交后清理。 - ## 只读审计 前置条件:已安装并登录 `gh`,安装 `Python 3`、`PyYAML>=6`、`markdown-it-py==3.0.0` 与 `mdurl==0.1.2`,当前身份对目标仓库至少具有读取 Actions、Environment、branch protection 和 secret 名称元数据的权限。 @@ -58,51 +47,49 @@ GitHub 只保证无写权限者推送新提交时关闭 Auto-merge;有写权 ./scripts/audit-maintained-repositories.sh ``` -脚本逐仓输出 `PASS` 或带稳定错误码的 `FAIL`,任一仓库漂移时整体退出码为 `1`。参数或本地依赖错误退出码为 `2`。 +脚本逐仓输出 `PASS`、`FAIL` 或 `ERROR`。**`FAIL` 表示基线漂移,`ERROR` 表示审计本身没跑完、结论不可信**;两者的退出码不同,不得把后者当成通过。全部结论行、退出码与错误码见 [contracts/error-codes.md](contracts/error-codes.md)。 + +### 可配置项 -发布安全相关错误码包括 `[RELEASE_MANIFEST]`、`[RELEASE_WORKFLOW]`、`[RELEASE_CONCURRENCY]`、`[ROLLBACK_TARGET]`、`[ROLLBACK_GUARD]`、`[ROLLBACK_CAPTURE]` 和 `[RELEASE_FAILURE_STATE]`;它们分别定位项目适配、发布入口、串行边界、不可变目标、路径 guard、步骤顺序和失败状态保持。`[CODE_REVIEW_RULES]` 表示根 `AGENTS.md` 缺失、不是普通文件,或 CommonMark 解析后不存在源码行精确等于 `## Code Review Rules` 的真实 h2;fenced code、HTML block、autolink 与 inline code 等边界由固定 markdown-it-py parser 判定。 +| 环境变量 | 默认值 | 作用 | +|---|---|---| +| `BASELINE_DEFAULT_BRANCH` | `master` | 基线受保护分支名;同时决定 API 读取的 ref、`github.ref` guard 原子与 PR `branches` 判定 | +| `BASELINE_AUDIT_RETRY_BASE_SECONDS` | `2` | `gh api` 瞬时失败的退避基数,共尝试 4 次 | -审计覆盖: +### 审计覆盖范围 -- active PR/master workflows 的事件和权限边界; -- 根 `AGENTS.md` 的普通文件属性与精确 `## Code Review Rules` 标题; -- 所有 active workflow 的 self-hosted、`dev` Environment、secret、镜像拉取/编排和手动部署能力;受控 release 之外发现任一发布入口即 fail-closed; -- GitHub 平台内建的 `dynamic/dependabot/update-graph` 会被明确跳过;其他 active workflow 若无法从 `master` 读取则 fail-closed; -- 固定 PR 检查名与 runner 边界; -- 外部 Action 的 40 位 SHA 和版本注释; -- `dev` Environment、active workflow 所引用的 secret 名称边界; -- `.github/release-safety.yml` 的 schema、语义 job/step 引用、依赖、condition 映射和步骤全序; -- `rollback_sha`/原因输入、发布与回滚串行锁、Environment、失败状态及成功后清理等可由结构化 YAML 可靠判断的高层约束; -- 项目发布安全契约 wrapper 是 Git tree 中 mode `100755` 的普通文件;`PR container validation` job 名称在所有 active workflow 中唯一,job 无 `if`、无 `needs`、无有效 `continue-on-error`,workflow/job 不得覆盖默认 `shell` 或 `working-directory`;稳定步骤必须以单行 `run` 精确执行该路径,不带参数、前后命令、管道或兜底逻辑,也不得声明 `if`、`continue-on-error`、`shell` 或 `working-directory`; -- prepare 存在时,publish、migration 与 deploy 只能共享 manifest 映射 prepare job 的同一个合法 output signal,deploy 还必须让正常与回滚路径共同受 `needs..result == 'success'` 约束;prepare 不存在时只能共享 `github.event.inputs.rollback_sha`,手动回滚分支还必须显式限定 `workflow_dispatch`; -- release workflow 的所有 job 都不得启用 `continue-on-error`;发布与回滚 concurrency 只能使用常量、允许的 `github.repository/workflow/ref/ref_name`,或仅由这些 context 构成的 `format(...)`; -- publish/deploy/finalize 的 master 边界、normal/rollback 分支、migration 跳过回滚和 rollback 允许式不能只靠 manifest 自我声明,中央会独立验证公共语义; -- release 内未声明的特权 job、job 级写权限、动态分域 concurrency 和未锁定 digest 的 `docker://` Action; -- `master` branch protection 和 required check。 -- 纳管产品仓库关闭 Auto-merge;分支自动删除按项目真实分支模型配置,不由公共审计强制。人工合并前的 Review/thread/风险分类仍由真实 PR 验收。 +审计只验证可由结构化 YAML 与仓库配置可靠判断的高层约束,逐条 MUST 见[公共契约](contracts/ci-cd-baseline.md): -中央审计明确不解释任意 shell 的控制流,也不尝试从注释、`echo`、here-doc、字符串或脚本名证明 ref/内容 ID 比对、容器内 smoke、回滚命令及实际 `DEPLOY_TARGET_SHA`。它也不抓取官方页面或私有接口来判断 Automatic reviews 设置、模型审查质量、同一 HEAD 请求次数、在途 Review 或审查轮次预算;这些事实分别由项目可执行契约测试、真实发布验收和真实 PR 时间线负责。模板只是一种可复制 profile,`.github/release-safety.yml` 才是每个项目拓扑的声明式适配层。 +- active PR/release workflow 的事件、权限、runner、Environment 与 secret 边界; +- 受控 release 之外发现任一发布能力即 fail-closed;GitHub 内建的 `dynamic/dependabot/update-graph` 明确跳过,其他 active workflow 读不到即 fail-closed; +- 外部 Action 的 40 位 SHA、版本注释、`docker://` 内容摘要,以及 workflow 与本地 Action 闭包中的全部 `uses:` 引用; +- `.github/release-safety.yml` 的 schema、语义 job/step 引用、依赖、condition 映射与步骤全序,以及中央独立验证的 publish/deploy/finalize 公共语义; +- 项目发布安全契约 wrapper 是 mode `100755` 的普通文件,且被唯一的 `PR container validation` job 以单行 `run` 精确调用; +- `dev` Environment 与 repo/env secret 名称边界; +- 受保护分支的 branch protection 与 required check; +- 仓库关闭 Auto-merge;根 `AGENTS.md` 是普通文件且含精确 `## Code Review Rules` h2。 -本地 fixture 自测: +中央审计明确不解释任意 shell 的控制流,也不尝试从注释、`echo`、here-doc、字符串或脚本名证明 ref/内容 ID 比对、容器内 smoke、回滚命令及实际 `DEPLOY_TARGET_SHA`。它也不抓取官方页面或私有接口来判断 Automatic reviews 设置、模型审查质量、在途 Review 或审查轮次预算。这些事实分别由项目可执行契约测试、真实发布验收和真实 PR 时间线负责。 + +PR workflow 无法触达生产的**结构性**保证来自四条硬约束:`contents: read` 顶层权限、不引用 secret、不绑定 Environment、只用 GitHub 托管 runner。命令关键字扫描(`[PR_DEPLOY]`)是这四条之上的次级网,它可以被项目脚本内部的命令绕过,因此只作为补充信号,不作为主要保证。 + +模板只是一种可复制 profile,`.github/release-safety.yml` 才是每个项目拓扑的声明式适配层。 + +### 本地自测 ```bash bash tests/test-audit.sh ``` -## 自动漂移审计 +包含 fixture 端到端用例、表达式解析器与 API 重试的单元测试(`tests/unit/`),以及模板、契约骨架和错误码参考的一致性校验。 -自动审计只在基线仓库的 `.github/workflows/baseline-drift-audit.yml` 中运行。纳管项目不复制 workflow,也不保存审计凭据。中央任务通过 `actions/create-github-app-token` 创建短期 GitHub App installation token,并在 Action 输入中把 token 精确限制到 `repositories.txt` 对应的四个仓库。 +## 自动漂移审计 -GitHub App 名为 `Engineering Baseline Auditor`,只允许安装到 `@HyxiaoGe`。它不订阅 webhook 事件,不授予写权限,只配置以下 repository permissions: +自动审计只在基线仓库的 `.github/workflows/baseline-drift-audit.yml` 中运行。纳管项目不复制 workflow,也不保存审计凭据。中央任务通过 `actions/create-github-app-token` 创建短期 GitHub App installation token,并在 Action 输入中把 token 精确限制到 `repositories.txt` 对应的仓库。 -- `Administration: read`:读取 `master` branch protection。 -- `Actions: read`:列举 active workflows。 -- `Contents: read`:读取默认分支 workflow 与本地 Action 内容。 -- `Environments: read`:读取 Environment 和 Environment secret 名称元数据。 -- `Metadata: read`:GitHub App installation token 强制携带的仓库元数据只读权限。 -- `Secrets: read`:读取 repository secret 名称元数据。 +只读 GitHub App `Engineering Baseline Auditor` 的权限清单与私钥存放规则见 [MAINTENANCE.md](MAINTENANCE.md) 的"发布基线版本"。审计器只处理 secret 名称,GitHub API 和脚本都不会读取 secret 值。 -基线仓库使用 repository variable `BASELINE_AUDIT_APP_CLIENT_ID` 保存 Client ID,并把私钥作为 `BASELINE_AUDIT_APP_PRIVATE_KEY` 存入 `audit` Environment;workflow 自身只声明 `contents: read`,创建短期令牌时再次显式要求上述五项 `read`。审计器只处理 secret 名称,GitHub API 和脚本都不会读取 secret 值。 +计划任务失败时,独立的 `notify` job 会开或更新一个 issue。该 job 只使用基线仓库自身的 `GITHUB_TOKEN`(`issues: write`),审计 App 保持零写权限;issue 正文只放运行链接,不回显可能包含 secret 名称的审计输出。 接入新项目时必须在同一个基线 PR 中完成三处对齐: @@ -114,8 +101,8 @@ GitHub App 名为 `Engineering Baseline Auditor`,只允许安装到 `@HyxiaoGe ## 不由模板决定的内容 -项目自行决定语言、包管理器、测试命令、job/step ID、是否需要 prepare/finalize、端口、容器名、镜像名、迁移步骤、服务数量、smoke URL/命令和通知实现。模板只是一种 profile,不是中央审计的事实源;项目必须按真实拓扑维护 `.github/release-safety.yml`,并结合真实行为完成验证。 +项目自行决定语言、包管理器、测试命令、job/step ID、是否需要 prepare/finalize、端口、容器名、镜像名、迁移步骤、服务数量、smoke URL/命令和通知实现。项目必须按真实拓扑维护 `.github/release-safety.yml`,并结合真实行为完成验证。 -数据库迁移必须采用 expand/contract,禁止自动执行 `alembic downgrade`;镜像恢复只恢复应用运行态,不宣称回滚数据库。捕获的旧 ref 必须是受管 `IMAGE_NAME:<40 位小写 SHA>`,内容 ID 必须是 `sha256:<64 位小写十六进制>`。首次部署没有可捕获的旧镜像时默认 fail-closed,若必须放行,应走独立的一次性审批与可审计例外,完成后立即恢复标准门禁。 +数据库迁移必须采用 expand/contract,禁止自动执行 `alembic downgrade`;镜像恢复只恢复应用运行态,不宣称回滚数据库。捕获的旧 ref 必须是受管 `IMAGE_NAME:<40 位小写 SHA>`,内容 ID 必须是 `sha256:<64 位小写十六进制>`。首次部署没有可捕获的旧镜像时默认 fail-closed。 手动回滚不使用第二份 workflow:在现有 release 的 `workflow_dispatch` 填写 40 位小写 `rollback_sha` 和非空原因。同一 deploy job 使用该值作为 `DEPLOY_TARGET_SHA`,跳过 publish 和迁移,完成身份与容器内 smoke;留空两个输入则仍补跑当前 `GITHUB_SHA`。 diff --git a/contracts/ci-cd-baseline.md b/contracts/ci-cd-baseline.md index f5c6c32..af20860 100644 --- a/contracts/ci-cd-baseline.md +++ b/contracts/ci-cd-baseline.md @@ -14,6 +14,7 @@ - MUST:PR job 只使用 GitHub 托管 `ubuntu-latest`,固定 job 显示名为 `PR container validation`。 - MUST:顶层权限为 `contents: read`,不得授予任何 write 权限。 - MUST:不得引用 secrets、绑定 Environment、使用 self-hosted runner、登录或推送镜像、执行部署。 +- 说明:PR 无法触达生产的结构性保证来自顶层 `contents: read`、无 secret、无 Environment、GitHub 托管 runner 四条硬约束;命令关键字扫描(`[PR_DEPLOY]`)是其上的次级网,可被项目脚本内部命令绕过,不作为主要保证。 - MUST:执行足以证明可合并性的完整测试和容器构建,但不发布构建产物。 - 项目扩展点:安装、lint、测试、构建命令和临时镜像名。 @@ -39,6 +40,7 @@ - MUST:SHA 后保留可读版本注释,例如 `# v6`、`# v4.6.0`。 - MUST:每个 `actions/checkout` 步骤都在自身 `with` mapping 中显式设置 `persist-credentials: false`;顶层 `env` 或 `run` 字符串不能替代该设置。 - MUST:本地 Action 可以用相对路径,但其 manifest 中的外部 Action 同样受本规则约束。 +- MUST:本地 Action 的解析范围覆盖全部 active workflow,包含受控 release workflow 自身及其声明 job;`.github/actions/**` 之外的本地 Action 不得因解析时机而绕过锁定检查。 - MUST:`docker://` Action 只接受 `@sha256:` 加 64 位小写十六进制内容摘要,mutable tag 或未锁定引用一律拒绝。 - 项目扩展点:选择哪些官方/第三方 Action;升级版本时同时更新 SHA、版本注释和契约测试。 @@ -58,6 +60,7 @@ ## 6. master 分支保护 - MUST:required status checks 启用 strict,并要求 GitHub Actions app 产生的 `PR container validation`。 +- MUST:受保护分支名由基线配置项 `BASELINE_DEFAULT_BRANCH` 决定(默认 `master`),并同时约束仓库 default_branch、审计读取的 ref、`github.ref` guard 原子与 PR `branches`;同一次审计的所有仓库共用一个值。 - MUST:要求通过 Pull Request、对管理员执行保护、要求解决对话、禁止 force push、禁止删除 `master`。 - MUST:迁移检查名时先让新检查在真实 PR 成功,再原子替换旧 required check。 - MUST:官方 Review 尚不能作为当前 HEAD 的 required check 时,仓库关闭 Auto-merge。人工合并边界由官方 Review 治理契约决定,不能仅凭 CI 通过合并;分支自动删除按项目分支模型配置,不作为 CI/CD 公共门禁。 @@ -75,6 +78,7 @@ ## 8. 规则漂移 - MUST:定期或在基线变更后运行只读审计;任一公共 MUST 漂移都返回非零退出码。 +- MUST:审计必须区分"基线漂移"与"审计未能完成"。GitHub 5xx/429/连接失败在有限重试后报告 `ERROR` 与退出码 `3`,不得降级成漂移,也不得当成通过;结论行、退出码与全部稳定错误码以 `contracts/error-codes.md` 为事实源,实现与该文件必须双向一致。 - MUST:审计 active workflows、`.github/actions`、live `master` protection、`dev` Environment 以及 repo/env secret 名称边界。 - MUST:逐一分类所有 active workflow;任何包含 self-hosted runner、`dev` Environment、secret、镜像拉取/编排/部署命令或人工部署入口的发布能力,只能存在于唯一受控 release workflow。 - MUST:每个项目维护 `.github/release-safety.yml`,声明受控 workflow、可选 prepare/finalize、publish/deploy、target/capture/migration/candidate/verify/rollback/cleanup/failure 语义角色、needs/condition 映射、项目契约测试文件和主 PR 稳定 step ID。模板只是一种 profile,不是事实源。 diff --git a/contracts/error-codes.md b/contracts/error-codes.md new file mode 100644 index 0000000..a98baba --- /dev/null +++ b/contracts/error-codes.md @@ -0,0 +1,108 @@ +# 审计错误码参考 + +审计器逐仓输出结论行与稳定错误码。错误码是本基线的公开接口:漂移处理、告警和 +文档都按它定位公共 MUST,因此新增或删除错误码必须同步修改本文,`tests/test-audit.sh` +会双向校验实现与本文完全一致。 + +## 结论行与退出码 + +| 结论行 | 含义 | +|---|---| +| `PASS owner/repo` | 该仓库满足全部公共 MUST | +| `FAIL owner/repo` | 检测到基线漂移,逐条错误码列在其后 | +| `ERROR owner/repo` | 审计没能跑完,本次结论不可信,不得当成通过 | + +| 退出码 | 含义 | +|---|---| +| `0` | 全部仓库 `PASS` | +| `1` | 至少一个仓库漂移,且没有未完成的审计 | +| `2` | 参数、仓库清单或本地依赖错误 | +| `3` | 至少一个仓库未能完成审计;优先级高于 `1` | + +## 审计自身 + +| 错误码 | 含义 | +|---|---| +| `[ARGUMENT]` | 仓库参数不是 `owner/repo`,或 `BASELINE_DEFAULT_BRANCH` 不是合法分支名 | +| `[API]` | GitHub 响应结构不符合预期,属于可判定的异常 | +| `[API_UNAVAILABLE]` | 重试后仍无法读取 GitHub:网络、5xx/429 或响应不是合法 JSON | + +## 仓库与分支 + +| 错误码 | 含义 | +|---|---| +| `[DEFAULT_BRANCH]` | 仓库 default_branch 不等于基线受保护分支 | +| `[REPOSITORY_MERGE_POLICY]` | 官方 Review 尚非 required check,仓库必须关闭 Auto-merge | +| `[BRANCH_PROTECTION]` | strict、PR review、管理员保护、对话解决、force push 或删除保护缺失 | +| `[REQUIRED_CHECK]` | 受保护分支未要求 `PR container validation` | +| `[REQUIRED_CHECK_APP]` | required check 未绑定 GitHub Actions app,或 checks 结构无效 | + +## 工作流发现与结构 + +| 错误码 | 含义 | +|---|---| +| `[WORKFLOW_LIST]` | active workflow 缺少合法 path,或无法从受保护分支读取 | +| `[WORKFLOW_STRUCTURE]` | workflow 或本地 Action 的 YAML 结构不满足最低约束 | +| `[ACTION_TREE]` | recursive tree 被截断或条目结构无效,拒绝不完整审计 | + +## 供应链 + +| 错误码 | 含义 | +|---|---| +| `[ACTION_PIN]` | 外部 Action 未锁定 40 位 commit SHA,或 `docker://` 未锁 sha256 摘要 | +| `[ACTION_VERSION]` | 锁定 SHA 后缺少精确版本注释 | +| `[CHECKOUT_CREDENTIAL]` | `actions/checkout` 未在自身 `with` 中设置 `persist-credentials: false` | +| `[LOCAL_ACTION]` | 本地 Action 路径逃逸、穿过 symlink、无法唯一解析或递归引用 | + +## 主 PR workflow + +| 错误码 | 含义 | +|---|---| +| `[PR_CHECK_NAME]` | `PR container validation` job 在 active workflow 中不唯一 | +| `[PR_EVENT]` | 主 PR workflow 事件或 `branches` 不精确等于面向受保护分支的 `pull_request` | +| `[PR_PERMISSION]` | 顶层或 job 权限超出 `contents: read` | +| `[PR_RUNNER]` | 未使用 GitHub 托管 `ubuntu-latest` | +| `[PR_ENVIRONMENT]` | PR job 绑定了 Environment | +| `[PR_SECRET]` | 主 PR workflow 或其本地 Action 引用 secret | +| `[PR_DEPLOY]` | 主 PR workflow 或其本地 Action 出现部署能力信号 | + +## 辅助 PR workflow + +| 错误码 | 含义 | +|---|---| +| `[AUX_PR_PERMISSION]` | 权限超出 CodeQL 例外(`contents: read` + `security-events: write`) | +| `[AUX_PR_RUNNER]` | 使用 self-hosted runner | +| `[AUX_PR_ENVIRONMENT]` | 绑定 Environment | +| `[AUX_PR_SECRET]` | 引用 secret | +| `[AUX_PR_DEPLOY]` | 出现部署能力信号 | + +## 发布安全 + +| 错误码 | 含义 | +|---|---| +| `[RELEASE_MANIFEST]` | manifest schema/引用无效,契约 wrapper 缺失或不可执行,或未声明 job 含特权能力 | +| `[RELEASE_WORKFLOW]` | 受控 release 之外的 workflow 具备发布能力 | +| `[RELEASE_EVENT]` | 发布事件不精确,或 prepare 不是纯校验 job | +| `[RELEASE_PERMISSION]` | release 顶层或 job 权限超出 `contents: read` | +| `[RELEASE_CONCURRENCY]` | 发布与回滚未共享非空串行 concurrency,或 group 按动态量分域 | +| `[RELEASE_FAILURE_STATE]` | 失败被隐藏、cleanup 非 `success()`、failure/finalize guard 无效 | +| `[ROLLBACK_TARGET]` | `workflow_dispatch` 回滚输入不是默认空值的可选 string | +| `[ROLLBACK_GUARD]` | publish/migration/deploy/rollback 的 condition 不满足公共语义 | +| `[ROLLBACK_CAPTURE]` | 发布安全步骤全序无效(capture 必须早于迁移与候选部署等) | +| `[PUBLISH_ENVIRONMENT]` | 发布镜像 job 未绑定 `dev` 且 `deployment: false` | +| `[DEPLOY_ENVIRONMENT]` | 实际部署 job 未绑定真实 `dev` deployment | + +## Environment 与 secret + +| 错误码 | 含义 | +|---|---| +| `[DEV_ENVIRONMENT]` | 缺少 `dev` Environment,或当前身份无权读取 | +| `[ENV_SECRET_BOUNDARY]` | release 引用的 secret 不在 `dev` Environment | +| `[REPO_SECRET_BOUNDARY]` | repository scope 仍存在 secret 名称 | +| `[SECRET_ENV_BOUNDARY]` | 顶层 env 引用 secret、secret context 无法静态枚举,或使用 `secrets: inherit` | + +## 协作规则 + +| 错误码 | 含义 | +|---|---| +| `[CODE_REVIEW_RULES]` | 根 `AGENTS.md` 缺失、不是普通文件,或缺少精确 `## Code Review Rules` h2 | diff --git a/scripts/audit_github_baseline.py b/scripts/audit_github_baseline.py index 922c48f..9768b30 100755 --- a/scripts/audit_github_baseline.py +++ b/scripts/audit_github_baseline.py @@ -4,10 +4,12 @@ import base64 from dataclasses import dataclass, field import json +import os from pathlib import PurePosixPath import re import subprocess import sys +import time from typing import Any, Iterable from urllib.parse import quote @@ -16,6 +18,10 @@ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode +BASELINE_VERSION = "v1.1.0" +DEFAULT_BRANCH_ENV = "BASELINE_DEFAULT_BRANCH" +FALLBACK_DEFAULT_BRANCH = "master" +BRANCH_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") FULL_SHA_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") SEMANTIC_ID_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") @@ -37,12 +43,20 @@ "docker/login-action": ("dbcb813823bdd20940b903addbd779551569679f", "v4.6.0"), } ALLOWED_AUX_PERMISSIONS = {"contents": "read", "security-events": "write"} +HTTP_STATUS_PATTERN = re.compile(r"\(HTTP (\d{3})\)") +RETRYABLE_HTTP_STATUSES = frozenset({408, 429, 500, 502, 503, 504}) +GH_API_MAX_ATTEMPTS = 4 +DEFAULT_RETRY_BASE_SECONDS = 2.0 CODE_REVIEW_RULES_HEADING = "## Code Review Rules" COMMONMARK_PARSER = MarkdownIt("commonmark") class AuditError(RuntimeError): - pass + """基线漂移或仓库结构不满足公共 MUST。""" + + +class AuditUnavailable(AuditError): + """审计本身没能跑完:网络、GitHub 服务端或响应格式问题,结论不可信。""" @dataclass @@ -83,25 +97,66 @@ class ReleaseSafetyManifest: conditions: dict[str, str | None] +def default_branch() -> str: + """公共基线的受保护分支名。默认 master,可由 BASELINE_DEFAULT_BRANCH 覆盖。""" + raw = os.environ.get(DEFAULT_BRANCH_ENV, "").strip() + if not raw: + return FALLBACK_DEFAULT_BRANCH + if not BRANCH_NAME_PATTERN.fullmatch(raw): + raise AuditError(f"{DEFAULT_BRANCH_ENV} 不是合法分支名:{raw}") + return raw + + +def http_status(stderr: str) -> int | None: + match = HTTP_STATUS_PATTERN.search(stderr) + return int(match.group(1)) if match else None + + +def retry_base_seconds() -> float: + raw = os.environ.get("BASELINE_AUDIT_RETRY_BASE_SECONDS", "") + if not raw: + return DEFAULT_RETRY_BASE_SECONDS + try: + value = float(raw) + except ValueError: + return DEFAULT_RETRY_BASE_SECONDS + return value if value >= 0 else DEFAULT_RETRY_BASE_SECONDS + + +def is_transient(status: int | None) -> bool: + """没有 HTTP 状态说明连接层就失败了,同样按可重试处理。""" + return status is None or status in RETRYABLE_HTTP_STATUSES + + def gh_api(repo: str, suffix: str, *, optional: bool = False) -> Any | None: endpoint = f"repos/{repo}" if not suffix else f"repos/{repo}/{suffix}" - result = subprocess.run( - ["gh", "api", endpoint], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - if result.returncode != 0: - if optional: + base = retry_base_seconds() + for attempt in range(1, GH_API_MAX_ATTEMPTS + 1): + result = subprocess.run( + ["gh", "api", endpoint], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode == 0: + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise AuditUnavailable(f"GET {endpoint} 返回了无效 JSON") from error + stderr = result.stderr.strip() + status = http_status(stderr) + # 明确的终态状态码(404/403 等)是可判定的事实,optional 调用据此返回 None。 + if optional and not is_transient(status): return None - detail = result.stderr.strip().splitlines() + if attempt < GH_API_MAX_ATTEMPTS and is_transient(status): + time.sleep(base * (2 ** (attempt - 1))) + continue + detail = stderr.splitlines() message = detail[-1] if detail else f"退出码 {result.returncode}" - raise AuditError(f"GET {endpoint} 失败:{message}") - try: - return json.loads(result.stdout) - except json.JSONDecodeError as error: - raise AuditError(f"GET {endpoint} 返回了无效 JSON") from error + # 瞬时故障重试用尽后不得降级成"配置缺失",否则会把假警报当成漂移。 + raise AuditUnavailable(f"GET {endpoint} 失败:{message}") + raise AuditUnavailable(f"GET {endpoint} 重试 {GH_API_MAX_ATTEMPTS} 次后仍失败") def gh_api_paginated(repo: str, suffix: str, list_key: str) -> list[dict[str, Any]]: @@ -133,7 +188,7 @@ def gh_api_paginated(repo: str, suffix: str, list_key: str) -> list[dict[str, An def repository_content(repo: str, path: str) -> str: - payload = gh_api(repo, f"contents/{quote(path, safe='/')}?ref=master") + payload = gh_api(repo, f"contents/{quote(path, safe='/')}?ref={default_branch()}") if not isinstance(payload, dict) or payload.get("encoding") != "base64": raise AuditError(f"无法解码 {path}:Contents API 未返回 base64 内容") try: @@ -410,18 +465,9 @@ def release_capability_signals( return signals -def release_job_capability_signals( +def job_local_action_closure( source: RepositorySource, document: ParsedYaml, job: dict[str, Any] -) -> set[str]: - signals: set[str] = set() - if uses_self_hosted(job): - signals.add("self-hosted runner") - if "environment" in job: - signals.add("Environment") - if contains_secret_context(job): - signals.add("secret") - if value_has_deploy_capability(job): - signals.add("deploy command") +) -> tuple[list[ParsedYaml], list[str]]: job_mapping_ids = {id(mapping) for mapping in walk_mappings(job)} local_document = ParsedYaml( path=document.path, @@ -434,7 +480,22 @@ def release_job_capability_signals( ], kind="workflow", ) - closure, closure_errors = local_action_closure(source, local_document) + return local_action_closure(source, local_document) + + +def release_job_capability_signals( + source: RepositorySource, document: ParsedYaml, job: dict[str, Any] +) -> set[str]: + signals: set[str] = set() + if uses_self_hosted(job): + signals.add("self-hosted runner") + if "environment" in job: + signals.add("Environment") + if contains_secret_context(job): + signals.add("secret") + if value_has_deploy_capability(job): + signals.add("deploy command") + closure, closure_errors = job_local_action_closure(source, document, job) if closure_errors: signals.add("unresolved local Action") if any(contains_secret_context(action.data) for action in closure): @@ -529,8 +590,9 @@ def build_source(repo: str) -> RepositorySource: if not isinstance(metadata, dict): raise AuditError("repository metadata 响应无效") source = RepositorySource(repo=repo, workflows={}, tree={}) - if metadata.get("default_branch") != "master": - source.errors.append("[DEFAULT_BRANCH] 仓库 default_branch 必须是 master") + branch = default_branch() + if metadata.get("default_branch") != branch: + source.errors.append(f"[DEFAULT_BRANCH] 仓库 default_branch 必须是 {branch}") if metadata.get("allow_auto_merge") is not False: source.errors.append( "[REPOSITORY_MERGE_POLICY] 官方 Review 尚非 required check,仓库必须关闭 Auto-merge" @@ -550,7 +612,7 @@ def build_source(repo: str) -> RepositorySource: text = repository_content(repo, path) except AuditError as error: source.errors.append( - f"[WORKFLOW_LIST] active workflow {path} 无法从 master 读取:{error}" + f"[WORKFLOW_LIST] active workflow {path} 无法从 {branch} 读取:{error}" ) continue try: @@ -558,14 +620,14 @@ def build_source(repo: str) -> RepositorySource: except AuditError as error: source.errors.append(f"[WORKFLOW_STRUCTURE] {error}") - tree_payload = gh_api(repo, "git/trees/master?recursive=1") + tree_payload = gh_api(repo, f"git/trees/{branch}?recursive=1") if not isinstance(tree_payload, dict) or not isinstance( tree_payload.get("tree"), list ): - raise AuditError("master recursive tree 响应无效") + raise AuditUnavailable(f"{branch} recursive tree 响应无效") if tree_payload.get("truncated"): source.errors.append( - "[ACTION_TREE] master recursive tree 被截断,拒绝不完整审计" + f"[ACTION_TREE] {branch} recursive tree 被截断,拒绝不完整审计" ) for item in tree_payload["tree"]: if not isinstance(item, dict) or not isinstance(item.get("path"), str): @@ -596,7 +658,7 @@ def build_source(repo: str) -> RepositorySource: manifest_item = source.tree.get(manifest_path) if manifest_item is None: source.errors.append( - f"[RELEASE_MANIFEST] master 缺少声明式适配文件 {manifest_path}" + f"[RELEASE_MANIFEST] {branch} 缺少声明式适配文件 {manifest_path}" ) elif manifest_item.get("type") != "blob" or manifest_item.get("mode") == "120000": source.errors.append( @@ -641,13 +703,15 @@ def inspect_code_review_rules(source: RepositorySource) -> list[str]: path = "AGENTS.md" item = source.tree.get(path) if item is None: - return [f"[CODE_REVIEW_RULES] master 缺少根级 {path}"] + return [f"[CODE_REVIEW_RULES] {default_branch()} 缺少根级 {path}"] if item.get("type") != "blob" or item.get("mode") not in {"100644", "100755"}: return [f"[CODE_REVIEW_RULES] 根级 {path} 不是普通文件或属于 symlink"] try: text = repository_content(source.repo, path) except AuditError as error: - return [f"[CODE_REVIEW_RULES] 无法从 master 读取根级 {path}:{error}"] + return [ + f"[CODE_REVIEW_RULES] 无法从 {default_branch()} 读取根级 {path}:{error}" + ] if not has_code_review_rules_heading(text): return [ f"[CODE_REVIEW_RULES] 根级 {path} 缺少精确标题 {CODE_REVIEW_RULES_HEADING}" @@ -734,9 +798,9 @@ def inspect_primary_pr(source: RepositorySource) -> list[str]: errors.append(f"[PR_EVENT] {document.path}: pull_request 必须显式配置 branches") else: branches = pull_request.get("branches") - if branches != ["master"] or "branches-ignore" in pull_request: + if branches != [default_branch()] or "branches-ignore" in pull_request: errors.append( - f"[PR_EVENT] {document.path}: PR branches 必须精确等于 [master]" + f"[PR_EVENT] {document.path}: PR branches 必须精确等于 [{default_branch()}]" ) if permission_errors(document.data.get("permissions"), auxiliary=False): errors.append( @@ -768,7 +832,8 @@ def inspect_primary_pr(source: RepositorySource) -> list[str]: errors.append( f"[PR_DEPLOY] {document.path}: 主 PR workflow 或其本地 Action 含部署能力" ) - errors.extend(inspect_action_references(closure)) + # 解析出的本地 Action 统一由 audit_repository 在全部 inspect 之后做引用检查, + # 避免同一个 Action 被多个 workflow 引用时重复报告。 return errors @@ -815,13 +880,12 @@ def inspect_auxiliary_pr(source: RepositorySource) -> list[str]: errors.append( f"[AUX_PR_DEPLOY] {document.path}: 辅助 PR workflow 含部署能力" ) - errors.extend(inspect_action_references(closure)) return errors -def exact_master_push(events: dict[str, Any]) -> bool: +def exact_default_branch_push(events: dict[str, Any]) -> bool: push = events.get("push") - return isinstance(push, dict) and push.get("branches") == ["master"] + return isinstance(push, dict) and push.get("branches") == [default_branch()] def needs_ids(job: dict[str, Any]) -> set[str]: @@ -927,12 +991,9 @@ def top_level_conjuncts(expression: str) -> list[str]: def is_master_guard_atom(expression: str) -> bool: - return bool( - re.fullmatch( - r"github\.ref\s*==\s*(['\"])refs/heads/master\1", - strip_outer_parentheses(expression), - ) - ) + """受保护分支 guard 原子,例如 github.ref == 'refs/heads/master'。""" + pattern = rf"github\.ref\s*==\s*(['\"])refs/heads/{re.escape(default_branch())}\1" + return bool(re.fullmatch(pattern, strip_outer_parentheses(expression))) def is_function_atom(expression: str, name: str) -> bool: @@ -1800,6 +1861,11 @@ def inspect_release(source: RepositorySource) -> tuple[list[str], set[str]]: ], set() errors.extend(inspect_contract_test(source, manifest)) + # 受控 release 是权限最高的 workflow:它引用的本地 Action 必须与 PR 侧一样被解析, + # 否则 .github/actions/** 之外的本地 Action 会绕过供应链锁定与 prepare 纯净性判断。 + _, release_closure_errors = local_action_closure(source, document) + errors.extend(release_closure_errors) + for other in source.workflows.values(): if other.path == document.path: continue @@ -1812,12 +1878,12 @@ def inspect_release(source: RepositorySource) -> tuple[list[str], set[str]]: events = event_map(document) if ( - not exact_master_push(events) + not exact_default_branch_push(events) or not set(events).issubset({"push", "workflow_dispatch"}) or "workflow_dispatch" not in events ): errors.append( - f"[RELEASE_EVENT] {document.path}: 发布事件必须精确包含 master push 与 workflow_dispatch" + f"[RELEASE_EVENT] {document.path}: 发布事件必须精确包含 {default_branch()} push 与 workflow_dispatch" ) errors.extend(release_input_errors(document)) concurrency = document.data.get("concurrency") @@ -1935,12 +2001,17 @@ def inspect_release(source: RepositorySource) -> tuple[list[str], set[str]]: if not isinstance(prepare, dict): return errors, referenced expected_prepare_guard = manifest.conditions["prepare"] or "" + prepare_closure, prepare_closure_errors = job_local_action_closure( + source, document, prepare + ) + prepare_documents = [prepare, *(action.data for action in prepare_closure)] if ( normalized_condition(prepare.get("if")) != expected_prepare_guard or "environment" in prepare or prepare.get("runs-on") != "ubuntu-latest" - or contains_secret_context(prepare) - or value_has_deploy_capability(prepare) + or prepare_closure_errors + or any(contains_secret_context(item) for item in prepare_documents) + or any(value_has_deploy_capability(item) for item in prepare_documents) ): errors.append( f"[RELEASE_EVENT] {document.path}: prepare 必须是 manifest 声明的 GitHub-hosted 纯校验 job" @@ -2022,7 +2093,7 @@ def inspect_release(source: RepositorySource) -> tuple[list[str], set[str]]: def inspect_protection(payload: Any) -> list[str]: if not isinstance(payload, dict): - return ["[BRANCH_PROTECTION] master protection 响应无效"] + return [f"[BRANCH_PROTECTION] {default_branch()} protection 响应无效"] errors: list[str] = [] required = payload.get("required_status_checks") if not isinstance(required, dict): @@ -2040,7 +2111,9 @@ def inspect_protection(payload: Any) -> list[str]: not isinstance(legacy_contexts, list) or "PR container validation" not in legacy_contexts ): - errors.append("[REQUIRED_CHECK] master 未要求 PR container validation") + errors.append( + f"[REQUIRED_CHECK] {default_branch()} 未要求 PR container validation" + ) errors.append("[REQUIRED_CHECK_APP] checks 中缺少 PR container validation") elif not any( isinstance(check, dict) @@ -2052,17 +2125,19 @@ def inspect_protection(payload: Any) -> list[str]: "[REQUIRED_CHECK_APP] PR container validation 必须绑定 app_id=15368" ) if payload.get("required_pull_request_reviews") is None: - errors.append("[BRANCH_PROTECTION] master 未要求 Pull Request review") + errors.append( + f"[BRANCH_PROTECTION] {default_branch()} 未要求 Pull Request review" + ) if (payload.get("enforce_admins") or {}).get("enabled") is not True: - errors.append("[BRANCH_PROTECTION] master 未对管理员执行保护") + errors.append(f"[BRANCH_PROTECTION] {default_branch()} 未对管理员执行保护") if (payload.get("required_conversation_resolution") or {}).get( "enabled" ) is not True: - errors.append("[BRANCH_PROTECTION] master 未要求解决对话") + errors.append(f"[BRANCH_PROTECTION] {default_branch()} 未要求解决对话") if (payload.get("allow_force_pushes") or {}).get("enabled") is not False: - errors.append("[BRANCH_PROTECTION] master 仍允许 force push") + errors.append(f"[BRANCH_PROTECTION] {default_branch()} 仍允许 force push") if (payload.get("allow_deletions") or {}).get("enabled") is not False: - errors.append("[BRANCH_PROTECTION] master 仍允许删除") + errors.append(f"[BRANCH_PROTECTION] {default_branch()} 仍允许删除") return errors @@ -2076,20 +2151,25 @@ def secret_names(items: list[dict[str, Any]]) -> set[str]: return names -def audit_repository(repo: str) -> list[str]: +def audit_repository(repo: str) -> tuple[list[str], bool]: + """返回 (错误列表, 审计是否因基础设施原因未能跑完)。""" try: source = build_source(repo) errors = list(source.errors) if not source.workflows: errors.append("[WORKFLOW_LIST] 未找到可解析的 active workflow") - errors.extend(inspect_action_references(source.workflows.values())) - errors.extend(inspect_action_references(source.local_actions.values())) errors.extend(inspect_code_review_rules(source)) errors.extend(inspect_primary_pr(source)) errors.extend(inspect_auxiliary_pr(source)) release_errors, referenced_secrets = inspect_release(source) errors.extend(release_errors) - errors.extend(inspect_protection(gh_api(repo, "branches/master/protection"))) + # 必须在全部 inspect 之后执行:本地 Action 只有被引用时才会惰性解析进 + # source.local_actions,提前检查会漏掉 .github/actions/** 之外的 Action。 + errors.extend(inspect_action_references(source.workflows.values())) + errors.extend(inspect_action_references(source.local_actions.values())) + errors.extend( + inspect_protection(gh_api(repo, f"branches/{default_branch()}/protection")) + ) environment = gh_api(repo, "environments/dev", optional=True) if environment is None: @@ -2113,9 +2193,11 @@ def audit_repository(repo: str) -> list[str]: "[REPO_SECRET_BOUNDARY] repository scope 仍有 secret 名称:" + ", ".join(sorted(repository_secret_names)) ) - return errors + return errors, False + except AuditUnavailable as error: + return [f"[API_UNAVAILABLE] {error}"], True except AuditError as error: - return [f"[API] {error}"] + return [f"[API] {error}"], False def main(repositories: list[str]) -> int: @@ -2125,18 +2207,33 @@ def main(repositories: list[str]) -> int: print(f"FAIL {repo}") print(" [ARGUMENT] 仓库必须使用 owner/repo 格式") return 2 + try: + branch = default_branch() + except AuditError as error: + print(f"FAIL -\n [ARGUMENT] {error}") + return 2 + print(f"engineering-baseline {BASELINE_VERSION} / 受保护分支 {branch}") failed = 0 + unavailable = 0 for repo in repositories: - errors = audit_repository(repo) - if errors: + errors, incomplete = audit_repository(repo) + if incomplete: + unavailable += 1 + print(f"ERROR {repo}") + elif errors: failed += 1 print(f"FAIL {repo}") - for error in errors: - print(f" {error}") else: print(f"PASS {repo}") - print(f"审计完成:{len(repositories) - failed} 个通过,{failed} 个失败") + for error in errors: + print(f" {error}") + passed = len(repositories) - failed - unavailable + print(f"审计完成:{passed} 个通过,{failed} 个漂移,{unavailable} 个未能完成审计") + # 未跑完的审计不能被当成通过,也不能和真实漂移共用同一个退出码。 + if unavailable: + return 3 return 1 if failed else 0 -raise SystemExit(main(sys.argv[1:])) +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 85c5b3a..973b56d 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -6,13 +6,14 @@ - 所有回复、代码注释和 Git 提交信息使用中文。 - Git 提交标题使用 `: <中文描述>`。 -- 提交正文说明背景、改动和验证,并包含 `Co-Authored-By: Codex `。 +- 提交正文说明背景、改动和验证。 +- 不强制 `Co-Authored-By:` trailer:AI 协作的提交由 GitHub App 的 bot 身份体现在 author/committer 上,邮箱无法解析到真实账号的 trailer 不构成协作者,也不要为此编造地址。 ## 规则优先级与覆盖边界 - 仓库根目录的本文件补充项目级规则,不复制或降低工程基线中的公共 MUST。 - 子目录 `AGENTS.md` 只在对应目录树内覆盖实现细节,例如测试命令、模块边界和验收路径。 -- 子目录规则不得降低公共 MUST,包括 PR 权限隔离、master 发布边界、Action SHA、Environment secret、凭据隔离、分支保护及部署身份/健康验收。 +- 子目录规则不得降低公共 MUST,包括 PR 权限隔离、受保护分支发布边界、Action SHA、Environment secret、凭据隔离、分支保护及部署身份/健康验收。 - 项目确需改变公共 MUST 时,先向 `engineering-baseline` 提交契约变更并通过全部纳管仓库审计;在此之前采用更严格的现有规则。 ## 开发工作流 @@ -43,7 +44,7 @@ ## CI/CD 公共门禁 - PR 只使用 GitHub 托管 runner,权限为 `contents: read`,不得读取发布 secret、绑定发布 Environment、推送镜像或部署。 -- master 发布 job 和 secret 消费 job 绑定 `dev` Environment;真实部署 job 单独持有部署能力。 +- 受保护分支发布 job 和 secret 消费 job 绑定 `dev` Environment;真实部署 job 单独持有部署能力。 - 所有外部 Action 锁定完整 40 位 SHA并保留版本注释;checkout 设置 `persist-credentials: false`。 - Docker 凭据按 run、attempt 和 job 隔离并在 `always()` 中清理。 - 现有 release workflow 通过 `rollback_sha` 和非空原因处理手动回滚;普通发布和回滚共享 concurrency 与 deploy job,回滚模式跳过 publish 和迁移。 @@ -52,7 +53,7 @@ - 数据库迁移采用 expand/contract,禁止自动执行 `alembic downgrade`;首次部署无旧镜像时默认 fail-closed,例外必须一次性审批并留证。 - 发布、手动部署、self-hosted、`dev` Environment 和发布 secret 能力只存在于唯一受控 release workflow;按真实拓扑维护 `.github/release-safety.yml`,不要把模板 job/step ID 当成公共事实。 - 项目可执行契约测试负责证明 ref/内容 ID、容器内 smoke、回滚命令和实际目标 SHA;固定 `100755` wrapper 内部调用项目测试,`PR container validation` job 名称必须唯一、无依赖且无条件执行,workflow/job/targeted step 不得自定义 shell 或工作目录,targeted step 只以精确单行 `run` 执行 manifest 声明路径。中央审计只验证 manifest 与结构化 YAML 高层约束,不解释任意 shell 控制流。 -- `master` 要求 GitHub Actions 产生的 `PR container validation`,启用 strict、管理员保护和对话解决,禁止 force push 与删除。 +- 受保护分支要求 GitHub Actions 产生的 `PR container validation`,启用 strict、管理员保护和对话解决,禁止 force push 与删除。 - 官方 Review 不是 required check 时,仓库保持 `allow_auto_merge=false`;只在 required checks 成功、阻塞 finding 已解决或有明确风险处置且 review thread 已完成处置后人工合并。只有仓库仅使用临时 PR head 分支,或所有长期 head 分支受保护时,才设置 `delete_branch_on_merge=true`。 ## 项目命令 diff --git a/tests/bin/gh b/tests/bin/gh index 297ff1e..fbee63c 100755 --- a/tests/bin/gh +++ b/tests/bin/gh @@ -10,6 +10,17 @@ endpoint="$2" case_dir="${AUDIT_FIXTURE_DIR:?必须设置 AUDIT_FIXTURE_DIR}" good_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../fixtures/good" && pwd)" +# 模拟 GitHub 服务端持续不可用:审计器应重试后报 ERROR,而不是降级成漂移。 +if [[ -f "${case_dir}/unavailable-endpoint" ]]; then + while IFS= read -r pattern || [[ -n "${pattern}" ]]; do + [[ -z "${pattern}" ]] && continue + if [[ "${endpoint}" == *"${pattern}"* ]]; then + echo "gh: Internal Server Error (HTTP 500)" >&2 + exit 1 + fi + done <"${case_dir}/unavailable-endpoint" +fi + fixture_file() { local relative_path="$1" if [[ -f "${case_dir}/${relative_path}" ]]; then @@ -30,7 +41,7 @@ case "${endpoint}" in cat "$(fixture_file workflows.json)" fi ;; - repos/*/branches/master/protection*) + repos/*/branches/*/protection*) cat "$(fixture_file protection.json)" ;; repos/*/environments/dev/secrets*page=2*) @@ -45,7 +56,7 @@ case "${endpoint}" in ;; repos/*/environments/dev*) if [[ -f "${case_dir}/missing-environment" ]]; then - echo "环境不存在" >&2 + echo "gh: Not Found (HTTP 404)" >&2 exit 1 fi cat "$(fixture_file environment.json)" @@ -60,7 +71,7 @@ case "${endpoint}" in cat "$(fixture_file repo-secrets.json)" fi ;; - repos/*/git/trees/master*) + repos/*/git/trees/*) cat "$(fixture_file tree.json)" ;; repos/*/contents/*) diff --git a/tests/fixtures/api-unavailable/unavailable-endpoint b/tests/fixtures/api-unavailable/unavailable-endpoint new file mode 100644 index 0000000..b88fb40 --- /dev/null +++ b/tests/fixtures/api-unavailable/unavailable-endpoint @@ -0,0 +1 @@ +git/trees/ diff --git a/tests/fixtures/main-default-branch/.github/release-safety.yml b/tests/fixtures/main-default-branch/.github/release-safety.yml new file mode 100644 index 0000000..82e2cae --- /dev/null +++ b/tests/fixtures/main-default-branch/.github/release-safety.yml @@ -0,0 +1,36 @@ +version: "1" +workflow: .github/workflows/release.yml +contract_test: + path: .github/scripts/release-safety-contract.sh + pr_step: release_safety_contract +jobs: + prepare: validate_release + publish: publish + deploy: deploy + finalize: finalize +steps: + target: release_target + target_job: prepare + capture: capture_previous + migrations: [migrate] + candidate: candidate_deploy + verify: [verify_candidate] + rollback: rollback_previous + cleanup: cleanup_images + failure: preserve_failure + finalize: finalize_release + finalize_failure: finalize_failure +needs: + publish: [prepare] + deploy: [prepare, publish] + finalize: [prepare, publish, deploy] +conditions: + prepare: github.ref == 'refs/heads/main' + publish: github.ref == 'refs/heads/main' && needs.validate_release.outputs.rollback_mode != 'true' + deploy: always() && github.ref == 'refs/heads/main' && needs.validate_release.result == 'success' && (needs.publish.result == 'success' || (needs.validate_release.outputs.rollback_mode == 'true' && needs.publish.result == 'skipped')) + migration: needs.validate_release.outputs.rollback_mode != 'true' + rollback: failure() && steps.capture_previous.outcome == 'success' && steps.candidate_deploy.outputs.entered == 'true' + cleanup: success() + failure: failure() + finalize: always() && github.ref == 'refs/heads/main' + finalize_failure: needs.validate_release.result != 'success' || needs.publish.result == 'failure' || needs.deploy.result != 'success' diff --git a/tests/fixtures/main-default-branch/.github/workflows/pr-ci.yml b/tests/fixtures/main-default-branch/.github/workflows/pr-ci.yml new file mode 100644 index 0000000..59d311a --- /dev/null +++ b/tests/fixtures/main-default-branch/.github/workflows/pr-ci.yml @@ -0,0 +1,20 @@ +name: PR validation + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + validation: + name: PR container validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - id: release_safety_contract + run: .github/scripts/release-safety-contract.sh + - run: docker build --tag fixture:test . diff --git a/tests/fixtures/main-default-branch/.github/workflows/release.yml b/tests/fixtures/main-default-branch/.github/workflows/release.yml new file mode 100644 index 0000000..e2812a0 --- /dev/null +++ b/tests/fixtures/main-default-branch/.github/workflows/release.yml @@ -0,0 +1,170 @@ +name: Master release + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + rollback_sha: + description: 要回滚到的 40 位小写镜像 SHA;留空则发布当前提交 + required: false + default: "" + type: string + rollback_reason: + description: 回滚原因;设置 rollback_sha 时必填 + required: false + default: "" + type: string + +permissions: + contents: read + +concurrency: + group: master-release + cancel-in-progress: false + +env: + IMAGE_NAME: registry.example/project + CONTAINER_NAME: project + +jobs: + validate_release: + name: Validate release target + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + rollback_mode: ${{ steps.release_target.outputs.rollback_mode }} + target_sha: ${{ steps.release_target.outputs.target_sha }} + steps: + - name: 校验并选择不可变发布目标 + id: release_target + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ROLLBACK_SHA: ${{ github.event.inputs.rollback_sha || '' }} + ROLLBACK_REASON: ${{ github.event.inputs.rollback_reason || '' }} + run: | + set -euo pipefail + if [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${ROLLBACK_SHA}" ]]; then + [[ "${ROLLBACK_SHA}" =~ ^[0-9a-f]{40}$ ]] || { echo "rollback_sha 必须是 40 位小写 SHA" >&2; exit 1; } + [[ -n "${ROLLBACK_REASON}" ]] || { echo "rollback_reason 不能为空" >&2; exit 1; } + echo "rollback_mode=true" >> "${GITHUB_OUTPUT}" + echo "target_sha=${ROLLBACK_SHA}" >> "${GITHUB_OUTPUT}" + else + [[ -z "${ROLLBACK_SHA}" && -z "${ROLLBACK_REASON}" ]] || { echo "回滚参数必须成对提供" >&2; exit 1; } + echo "rollback_mode=false" >> "${GITHUB_OUTPUT}" + echo "target_sha=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + fi + + publish: + name: Publish master image + needs: validate_release + if: github.ref == 'refs/heads/main' && needs.validate_release.outputs.rollback_mode != 'true' + runs-on: [self-hosted, Windows, X64] + environment: + name: dev + deployment: false + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + - run: docker push registry.example/project:${GITHUB_SHA} + + deploy: + name: Deploy master image + needs: [validate_release, publish] + if: ${{ always() && github.ref == 'refs/heads/main' && needs.validate_release.result == 'success' && (needs.publish.result == 'success' || (needs.validate_release.outputs.rollback_mode == 'true' && needs.publish.result == 'skipped')) }} + runs-on: [self-hosted, Linux, X64] + environment: + name: dev + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: 捕获当前运行镜像 + id: capture_previous + shell: bash + run: | + set -euo pipefail + previous_image_ref="$(docker inspect --format '{{.Config.Image}}' "${CONTAINER_NAME}")" + previous_image_id="$(docker inspect --format '{{.Image}}' "${CONTAINER_NAME}")" + [[ -n "${previous_image_ref}" && -n "${previous_image_id}" ]] || { echo "首次部署必须走显式例外流程" >&2; exit 1; } + expected_prefix="${IMAGE_NAME}:" + [[ "${previous_image_ref}" == "${expected_prefix}"* ]] || { echo "旧镜像不属于受管仓库" >&2; exit 1; } + previous_sha="${previous_image_ref#"${expected_prefix}"}" + [[ "${previous_sha}" =~ ^[0-9a-f]{40}$ ]] || { echo "旧镜像 tag 不是 40 位小写 SHA" >&2; exit 1; } + [[ "${previous_image_id}" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "旧镜像内容 ID 无效" >&2; exit 1; } + echo "image_ref=${previous_image_ref}" >> "${GITHUB_OUTPUT}" + echo "image_id=${previous_image_id}" >> "${GITHUB_OUTPUT}" + - name: 执行向前兼容迁移 + id: migrate + if: needs.validate_release.outputs.rollback_mode != 'true' + run: ./scripts/ci-migrate.sh + - name: 部署候选镜像 + id: candidate_deploy + env: + DEPLOY_TARGET_SHA: ${{ needs.validate_release.outputs.target_sha }} + run: | + echo "entered=true" >> "${GITHUB_OUTPUT}" + ./scripts/ci-deploy.sh "${IMAGE_NAME}:${DEPLOY_TARGET_SHA}" + - name: 核对候选镜像并执行容器内 smoke + id: verify_candidate + shell: bash + env: + DEPLOY_TARGET_SHA: ${{ needs.validate_release.outputs.target_sha }} + run: | + set -euo pipefail + expected_image="${IMAGE_NAME}:${DEPLOY_TARGET_SHA}" + actual_image="$(docker inspect --format '{{.Config.Image}}' "${CONTAINER_NAME}")" + actual_image_id="$(docker inspect --format '{{.Image}}' "${CONTAINER_NAME}")" + [[ "${actual_image}" == "${expected_image}" ]] + [[ -n "${actual_image_id}" ]] + docker exec "${CONTAINER_NAME}" ./scripts/ci-container-smoke.sh "${DEPLOY_TARGET_SHA}" + - name: 自动恢复原运行镜像 + id: rollback_previous + if: ${{ failure() && steps.capture_previous.outcome == 'success' && steps.candidate_deploy.outputs.entered == 'true' }} + shell: bash + env: + PREVIOUS_IMAGE_REF: ${{ steps.capture_previous.outputs.image_ref }} + PREVIOUS_IMAGE_ID: ${{ steps.capture_previous.outputs.image_id }} + run: | + set -euo pipefail + [[ -n "${PREVIOUS_IMAGE_REF}" && -n "${PREVIOUS_IMAGE_ID}" ]] + ./scripts/ci-rollback.sh "${PREVIOUS_IMAGE_REF}" "${PREVIOUS_IMAGE_ID}" + restored_image_ref="$(docker inspect --format '{{.Config.Image}}' "${CONTAINER_NAME}")" + restored_image_id="$(docker inspect --format '{{.Image}}' "${CONTAINER_NAME}")" + [[ "${restored_image_ref}" == "${PREVIOUS_IMAGE_REF}" ]] + [[ "${restored_image_id}" == "${PREVIOUS_IMAGE_ID}" ]] + docker exec "${CONTAINER_NAME}" ./scripts/ci-container-smoke.sh "${PREVIOUS_IMAGE_ID}" + - name: 仅在验收成功后清理旧镜像 + id: cleanup_images + if: ${{ success() }} + run: ./scripts/ci-cleanup-images.sh "${{ needs.validate_release.outputs.target_sha }}" + - name: 保留发布失败状态 + id: preserve_failure + if: ${{ failure() }} + run: exit 1 + + finalize: + name: Finalize release + if: ${{ always() && github.ref == 'refs/heads/main' }} + needs: [validate_release, publish, deploy] + runs-on: [self-hosted, Linux, X64] + environment: + name: dev + deployment: false + env: + DEPLOY_TARGET_SHA: ${{ needs.validate_release.outputs.target_sha }} + steps: + - name: 记录实际发布目标和结果 + id: finalize_release + continue-on-error: true + run: ./scripts/ci-release-finalize.sh "${DEPLOY_TARGET_SHA}" + - name: 保留发布失败状态 + id: finalize_failure + if: needs.validate_release.result != 'success' || needs.publish.result == 'failure' || needs.deploy.result != 'success' + run: exit 1 diff --git a/tests/fixtures/main-default-branch/repo.json b/tests/fixtures/main-default-branch/repo.json new file mode 100644 index 0000000..9f4faf6 --- /dev/null +++ b/tests/fixtures/main-default-branch/repo.json @@ -0,0 +1,6 @@ +{ + "full_name": "example/project", + "default_branch": "main", + "allow_auto_merge": false, + "delete_branch_on_merge": true +} diff --git a/tests/fixtures/release-local-action-missing/release-mutations.json b/tests/fixtures/release-local-action-missing/release-mutations.json new file mode 100644 index 0000000..951f1a3 --- /dev/null +++ b/tests/fixtures/release-local-action-missing/release-mutations.json @@ -0,0 +1,6 @@ +[ + [ + " - run: docker push registry.example/project:${GITHUB_SHA}", + " - uses: ./ci/actions/absent\n - run: docker push registry.example/project:${GITHUB_SHA}" + ] +] \ No newline at end of file diff --git a/tests/fixtures/release-local-action-mutable/ci/actions/push/action.yml b/tests/fixtures/release-local-action-mutable/ci/actions/push/action.yml new file mode 100644 index 0000000..a979bc8 --- /dev/null +++ b/tests/fixtures/release-local-action-mutable/ci/actions/push/action.yml @@ -0,0 +1,7 @@ +name: push helper +description: 项目自定义推送步骤 +runs: + using: composite + steps: + - uses: example/unpinned-action@v1 + shell: bash diff --git a/tests/fixtures/release-local-action-mutable/release-mutations.json b/tests/fixtures/release-local-action-mutable/release-mutations.json new file mode 100644 index 0000000..09487b8 --- /dev/null +++ b/tests/fixtures/release-local-action-mutable/release-mutations.json @@ -0,0 +1,6 @@ +[ + [ + " - run: docker push registry.example/project:${GITHUB_SHA}", + " - uses: ./ci/actions/push\n - run: docker push registry.example/project:${GITHUB_SHA}" + ] +] \ No newline at end of file diff --git a/tests/fixtures/release-local-action-mutable/tree.json b/tests/fixtures/release-local-action-mutable/tree.json new file mode 100644 index 0000000..285b04a --- /dev/null +++ b/tests/fixtures/release-local-action-mutable/tree.json @@ -0,0 +1,26 @@ +{ + "sha": "fixture", + "truncated": false, + "tree": [ + { + "path": "AGENTS.md", + "type": "blob", + "mode": "100644" + }, + { + "path": ".github/release-safety.yml", + "type": "blob", + "mode": "100644" + }, + { + "path": ".github/scripts/release-safety-contract.sh", + "type": "blob", + "mode": "100755" + }, + { + "path": "ci/actions/push/action.yml", + "type": "blob", + "mode": "100644" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/release-prepare-local-action-deploy/ci/actions/prepare-helper/action.yml b/tests/fixtures/release-prepare-local-action-deploy/ci/actions/prepare-helper/action.yml new file mode 100644 index 0000000..c2774a1 --- /dev/null +++ b/tests/fixtures/release-prepare-local-action-deploy/ci/actions/prepare-helper/action.yml @@ -0,0 +1,7 @@ +name: prepare helper +description: 看似纯校验,实际具备部署能力 +runs: + using: composite + steps: + - shell: bash + run: ssh deploy@example "docker compose up -d" diff --git a/tests/fixtures/release-prepare-local-action-deploy/release-mutations.json b/tests/fixtures/release-prepare-local-action-deploy/release-mutations.json new file mode 100644 index 0000000..f6874ad --- /dev/null +++ b/tests/fixtures/release-prepare-local-action-deploy/release-mutations.json @@ -0,0 +1,6 @@ +[ + [ + " - name: 校验并选择不可变发布目标", + " - uses: ./ci/actions/prepare-helper\n - name: 校验并选择不可变发布目标" + ] +] \ No newline at end of file diff --git a/tests/fixtures/release-prepare-local-action-deploy/tree.json b/tests/fixtures/release-prepare-local-action-deploy/tree.json new file mode 100644 index 0000000..35134c5 --- /dev/null +++ b/tests/fixtures/release-prepare-local-action-deploy/tree.json @@ -0,0 +1,26 @@ +{ + "sha": "fixture", + "truncated": false, + "tree": [ + { + "path": "AGENTS.md", + "type": "blob", + "mode": "100644" + }, + { + "path": ".github/release-safety.yml", + "type": "blob", + "mode": "100644" + }, + { + "path": ".github/scripts/release-safety-contract.sh", + "type": "blob", + "mode": "100755" + }, + { + "path": "ci/actions/prepare-helper/action.yml", + "type": "blob", + "mode": "100644" + } + ] +} \ No newline at end of file diff --git a/tests/test-audit.sh b/tests/test-audit.sh index b033591..08d61ca 100755 --- a/tests/test-audit.sh +++ b/tests/test-audit.sh @@ -67,6 +67,62 @@ run_expect_failure_codes() { done } +run_expect_unavailable() { + local fixture_name="$1" + local output + local status=0 + + output="$(PATH="${MOCK_BIN}:${PATH}" AUDIT_FIXTURE_DIR="${FIXTURES_DIR}/${fixture_name}" bash "${AUDIT_SCRIPT}" example/project 2>&1)" || status=$? + + if (( status != 3 )); then + echo "FAIL: ${fixture_name} 应以退出码 3 报告审计未完成,实际 ${status}" + echo "${output}" + failures=$((failures + 1)) + return + fi + + local marker + for marker in "ERROR example/project" "[API_UNAVAILABLE]" "未能完成审计"; do + if ! grep -Fq "${marker}" <<<"${output}"; then + echo "FAIL: ${fixture_name} 缺少 ${marker}" + echo "${output}" + failures=$((failures + 1)) + fi + done +} + +run_expect_success_on_branch() { + local fixture_name="$1" + local branch="$2" + local output + + if ! output="$( + PATH="${MOCK_BIN}:${PATH}" \ + AUDIT_FIXTURE_DIR="${FIXTURES_DIR}/${fixture_name}" \ + BASELINE_DEFAULT_BRANCH="${branch}" \ + bash "${AUDIT_SCRIPT}" example/project 2>&1 + )"; then + echo "FAIL: ${fixture_name} 在 ${branch} 分支下应通过" + echo "${output}" + failures=$((failures + 1)) + return + fi + + if ! grep -Fq "受保护分支 ${branch}" <<<"${output}"; then + echo "FAIL: ${fixture_name} 未回显受保护分支 ${branch}" + echo "${output}" + failures=$((failures + 1)) + fi +} + +check_expression_unit_tests() { + if ! output="$(cd "${ROOT_DIR}" && python3 -m unittest discover -s tests/unit -t "${ROOT_DIR}" -v 2>&1)"; then + echo "FAIL: 表达式解析器单元测试不通过" + echo "${output}" + failures=$((failures + 1)) + fi +} + check_templates() { if ! python3 - "${ROOT_DIR}" <<'PY' from pathlib import Path @@ -159,6 +215,7 @@ contract = (root / "contracts" / "ci-cd-baseline.md").read_text() review_contract = (root / "contracts" / "codex-code-review.md").read_text() readme = (root / "README.md").read_text() maintenance = (root / "MAINTENANCE.md").read_text() +error_codes = (root / "contracts" / "error-codes.md").read_text() audit_entrypoint = (root / "scripts" / "audit-github-baseline.sh").read_text() audit_source = (root / "scripts" / "audit_github_baseline.py").read_text() @@ -180,83 +237,72 @@ def section_bullets(document, heading): if line.startswith("- ") ] -for marker in ( - "PROJECT_REPLACE", - "公共 MUST", - "子目录 `AGENTS.md`", - "独立 Git worktree", - "Co-Authored-By: Codex ", - "allow_auto_merge=false", - "临时 PR head 分支", - "长期 head 分支受保护", -): - assert marker in template, marker +# 这里只断言结构性不变量。公共 MUST 的措辞由 contracts/ 单独承载, +# 不再用中文短语把 README/MAINTENANCE 的散文钉死——那样只能证明某个词出现过。 +assert "PROJECT_REPLACE" in template, "模板必须保留待替换占位符" -assert "AGENTS.md 覆盖边界" in contract -assert "不得降低" in contract -for document in (template, root_agents): - assert "## Code Review Rules" in document - assert len(section_bullets(document, "## Code Review Rules")) == 3 +# 根规则与模板都必须提供官方 Reviewer 能直接消费的规则块。 +for name, document in (("templates/AGENTS.md", template), ("AGENTS.md", root_agents)): + assert "## Code Review Rules" in document, name + bullets = section_bullets(document, "## Code Review Rules") + # 官方建议保持两到三条简洁约束。 + assert 2 <= len(bullets) <= 3, (name, len(bullets)) review_rules = section_content(document, "## Code Review Rules") - for marker in ( - "只提交 P0/P1", - "当前可达", - "P2/P3", - "默认不报告", - "正例:", - "反例:", - ): - assert marker in review_rules, (marker, document[:40]) - assert "## 官方 Review 收敛" not in document -for document in (review_contract, readme, maintenance): - for marker in ( - "官方 Codex Code Review", - "Automatic reviews", - "@codex review", - "不自建", - ): - assert marker in document, (marker, document[:40]) -for document in (review_contract, readme, maintenance): - for marker in ( - "单个 PR", - "Auto-merge", - "暂不允许", - ): - assert marker in document, (marker, document[:40]) -for document in (review_contract, readme, maintenance): - for marker in ( - "有写权限", - "不保证自动关闭", - "不是 required check", - "Dependabot", - "P0/P1", - "P2/P3", - "默认不阻塞", - "无需重新 Review", - "当前可达", - ): - assert marker in document, (marker, document[:40]) -for document in (root_agents, template, review_contract, readme, maintenance): - for forbidden in ( - "Review 当前 HEAD", - "最终复审", - "至少等待 15 分钟", - "最多两轮完整", - ): - assert forbidden not in document, (forbidden, document[:40]) + assert "正例:" in review_rules and "反例:" in review_rules, name + +# 契约文件必须存在且保留其顶层章节骨架。 +for name, document, headings in ( + ( + "contracts/ci-cd-baseline.md", + contract, + ("## 8. 规则漂移", "## 9. AGENTS.md 覆盖边界"), + ), + ( + "contracts/codex-code-review.md", + review_contract, + ("## 4. Finding 处置与收敛", "## 5. v1 合并边界", "## 6. 审计与验收"), + ), +): + for heading in headings: + assert heading in document, (name, heading) + +# README 与 MAINTENANCE 是入口,必须指回契约而不是复述契约。 +for name, document in (("README.md", readme), ("MAINTENANCE.md", maintenance)): + assert "contracts/ci-cd-baseline.md" in document, name + assert "contracts/codex-code-review.md" in document, name + assert "contracts/error-codes.md" in document, name + +# 固定 CommonMark parser 是 [CODE_REVIEW_RULES] 的判定基础,不得回退成手写状态机。 for marker in ("markdown-it-py==3.0.0", "mdurl==0.1.2"): assert marker in audit_entrypoint, marker assert 'MarkdownIt("commonmark")' in audit_source for forbidden in ("markdown_fence", "raw_html_block_start"): assert forbidden not in audit_source, forbidden -for document in (template, review_contract, readme, maintenance): - assert "临时 PR head 分支" in document - assert "长期 head 分支受保护" in document +# 分支自动删除按项目分支模型配置,不作为公共审计门禁。 assert "delete_branch_on_merge" not in audit_source -print("AGENTS 模板、根规则与官方 Code Review 治理契约通过") + +# 错误码是公开接口:实现与参考文档必须双向完全一致。 +import re + +emitted = set(re.findall(r"\[([A-Z][A-Z0-9_]+)\]", audit_source)) +documented = set(re.findall(r"`\[([A-Z][A-Z0-9_]+)\]`", error_codes)) +assert emitted == documented, { + "实现有但文档缺": sorted(emitted - documented), + "文档有但实现缺": sorted(documented - emitted), +} + +# 结论行与退出码同样是接口。 +for token in ("PASS owner/repo", "FAIL owner/repo", "ERROR owner/repo"): + assert token in error_codes, token +for token in ("`0`", "`1`", "`2`", "`3`"): + assert token in error_codes, token +for token in ('print(f"PASS', 'print(f"FAIL', 'print(f"ERROR'): + assert token in audit_source, token + +print("AGENTS 模板、契约骨架与错误码参考通过") PY then - echo "FAIL: AGENTS 模板或覆盖契约不满足" + echo "FAIL: AGENTS 模板、契约骨架或错误码参考不满足" failures=$((failures + 1)) fi } @@ -341,28 +387,22 @@ for marker in ( assert marker in template_text, marker assert 'ci-container-smoke.sh "${PREVIOUS_IMAGE_ID}"' not in template_text -contract_files = [ - root / "contracts" / "ci-cd-baseline.md", - root / "README.md", - root / "MAINTENANCE.md", - root / "templates" / "AGENTS.md", -] -for path in contract_files: - text = path.read_text() - for marker in ( - "expand/contract", - "禁止自动执行 `alembic downgrade`", - "首次部署", - "DEPLOY_TARGET_SHA", - "rollback_sha", - ".github/release-safety.yml", - ): - assert marker in text, (path, marker) - -for path in contract_files[:3]: - text = path.read_text() - assert "中央审计" in text, path - assert "不解释任意 shell" in text or "不声称从原始 shell" in text, path +# 发布安全的公共 MUST 只以契约为事实源;README/MAINTENANCE 作为入口另行校验链接。 +contract_path = root / "contracts" / "ci-cd-baseline.md" +contract_text = contract_path.read_text() +for marker in ( + "expand/contract", + "禁止自动执行 `alembic downgrade`", + "首次部署", + "DEPLOY_TARGET_SHA", + "rollback_sha", + ".github/release-safety.yml", +): + assert marker in contract_text, marker +assert "中央审计" in contract_text +assert ( + "不解释任意 shell" in contract_text or "不声称从原始 shell" in contract_text +) print("发布安全模板与维护合同通过") PY @@ -452,13 +492,28 @@ assert workflow["permissions"] == {"contents": "read"} assert workflow["concurrency"]["cancel-in-progress"] == "false" jobs = workflow["jobs"] -assert set(jobs) == {"audit"} +assert set(jobs) == {"audit", "notify"} job = jobs["audit"] assert job["runs-on"] == "ubuntu-latest" assert job["environment"] == "audit" assert job["timeout-minutes"] == "20" +assert "permissions" not in job, "审计 job 沿用顶层 contents:read" + +# 通知与审计必须凭据隔离:审计 job 不碰仓库自身 token,notify job 不碰 App token。 +notify = jobs["notify"] +assert notify["needs"] == "audit" +assert notify["if"].startswith("failure()") +assert "schedule" in notify["if"], "只对无人值守的计划任务开 issue" +assert notify["permissions"] == {"contents": "read", "issues": "write"} +assert "environment" not in notify, "notify 不得进入 audit Environment" +notify_text = yaml.dump(notify) +assert "app-token" not in notify_text, "notify 不得使用只读审计 App 的令牌" +assert "BASELINE_AUDIT_APP_PRIVATE_KEY" not in notify_text steps = job["steps"] +audit_job_text = yaml.dump(job) +for forbidden in ("secrets.GITHUB_TOKEN", "github.token", "issues:"): + assert forbidden not in audit_job_text, forbidden checkout = [step for step in steps if step.get("uses", "").startswith("actions/checkout@")] assert len(checkout) == 1 assert checkout[0]["uses"] == "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803" @@ -503,28 +558,30 @@ assert "scripts/audit-maintained-repositories.sh" in commands audit_steps = [step for step in steps if "audit-maintained-repositories.sh" in step.get("run", "")] assert len(audit_steps) == 1 assert audit_steps[0]["env"]["GH_TOKEN"] == "${{ steps.app-token.outputs.token }}" +# 退出码必须原样传出,notify 才能区分漂移与审计未完成。 +assert audit_steps[0]["id"] == "audit" +assert 'exit "${status}"' in audit_steps[0]["run"] +assert job["outputs"]["status"] == "${{ steps.audit.outputs.status }}" workflow_text = workflow_path.read_text() -assert "github.token" not in workflow_text assert "secrets: inherit" not in workflow_text -for marker in ( - "GitHub App", - "Engineering Baseline Auditor", - "Administration: read", - "Actions: read", - "Contents: read", - "Environments: read", - "Metadata: read", - "Secrets: read", - "audit` Environment", -): - assert marker in readme, marker - assert marker in maintenance, marker + +# App 权限是双向不变量:workflow 实际申请的每一项都必须在维护手册里有据可查。 +requested = { + key.removeprefix("permission-").capitalize() + for key in token["with"] + if key.startswith("permission-") +} +for name in requested: + assert f"{name}: read" in maintenance, name +# Metadata 由 GitHub 强制携带,不出现在 with 里,但同样必须被记录。 +assert "Metadata: read" in maintenance +assert "Engineering Baseline Auditor" in maintenance +assert "audit` Environment" in maintenance for forbidden in ( "templates/drift-audit.yml", "PROJECT_REPLACE_BASELINE_SHA", - "调用仓库自己的 `GITHUB_TOKEN`", ): assert forbidden not in readme, forbidden assert forbidden not in maintenance, forbidden @@ -583,6 +640,8 @@ run_expect_failure root-local-action "[ACTION_PIN]" run_expect_failure checkout-persist-missing "[CHECKOUT_CREDENTIAL]" run_expect_failure checkout-persist-env-fake "[CHECKOUT_CREDENTIAL]" run_expect_failure default-branch-main "[DEFAULT_BRANCH]" +run_expect_success_on_branch main-default-branch main +run_expect_unavailable api-unavailable run_expect_failure unknown-workflow-structure "[WORKFLOW_STRUCTURE]" run_expect_success auxiliary-codeql run_expect_failure_codes auxiliary-unsafe "[AUX_PR_SECRET]" "[AUX_PR_ENVIRONMENT]" "[AUX_PR_RUNNER]" "[AUX_PR_DEPLOY]" @@ -650,6 +709,9 @@ run_expect_failure semantic-rollback-extra-false "[ROLLBACK_GUARD]" run_expect_failure semantic-rollback-extra-success "[ROLLBACK_GUARD]" run_expect_failure semantic-finalize-no-master "[RELEASE_FAILURE_STATE]" run_expect_failure semantic-finalize-extra-false "[RELEASE_FAILURE_STATE]" +run_expect_failure release-local-action-mutable "[ACTION_PIN]" +run_expect_failure release-local-action-missing "[LOCAL_ACTION]" +run_expect_failure release-prepare-local-action-deploy "[RELEASE_EVENT]" run_expect_failure release-undeclared-self-hosted "[RELEASE_MANIFEST]" run_expect_failure release-job-write-permission "[RELEASE_PERMISSION]" run_expect_failure release-publish-job-continue-on-error "[RELEASE_FAILURE_STATE]" @@ -669,6 +731,7 @@ run_expect_failure release-secrets-inherit "[SECRET_ENV_BOUNDARY]" run_expect_success dynamic-platform-workflow run_expect_failure stale-active-workflow "[WORKFLOW_LIST]" run_expect_failure auxiliary-missing-permissions "[AUX_PR_PERMISSION]" +check_expression_unit_tests check_templates check_registry_entrypoint check_agents_template diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_expressions.py b/tests/unit/test_expressions.py new file mode 100644 index 0000000..8f2614f --- /dev/null +++ b/tests/unit/test_expressions.py @@ -0,0 +1,314 @@ +"""表达式解析器与 API 重试的单元测试。 + +fixture 测试只能间接覆盖这些纯函数:一个解析分支写错,往往表现为某个 fixture +“碰巧仍然失败”,而不是定位到具体函数。这里直接对函数做表驱动断言。 +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import subprocess +import sys +import unittest + +MODULE_PATH = ( + Path(__file__).resolve().parents[2] / "scripts" / "audit_github_baseline.py" +) +_spec = importlib.util.spec_from_file_location("audit_github_baseline", MODULE_PATH) +assert _spec is not None and _spec.loader is not None +audit = importlib.util.module_from_spec(_spec) +# dataclass 装饰器要求模块在执行期间已经注册到 sys.modules。 +sys.modules[_spec.name] = audit +_spec.loader.exec_module(audit) + + +class StripOuterParenthesesTest(unittest.TestCase): + def test_strips_only_true_wrappers(self) -> None: + cases = { + "a": "a", + "(a)": "a", + "((a))": "a", + " ( a ) ": "a", + # 首个左括号不在末尾闭合,整体不是被包裹的表达式。 + "(a) && (b)": "(a) && (b)", + "(a) || (b)": "(a) || (b)", + # 括号出现在字符串字面量里,不参与配对。 + "('(')": "'('", + } + for expression, expected in cases.items(): + with self.subTest(expression=expression): + self.assertEqual(audit.strip_outer_parentheses(expression), expected) + + +class SplitTopLevelTest(unittest.TestCase): + def test_splits_only_at_depth_zero(self) -> None: + self.assertEqual(audit.split_top_level("a && b", "&&"), ["a", "b"]) + self.assertEqual( + audit.split_top_level("a && (b && c)", "&&"), ["a", "(b && c)"] + ) + self.assertEqual(audit.split_top_level("a || b || c", "||"), ["a", "b", "c"]) + + def test_ignores_operator_inside_quotes(self) -> None: + self.assertEqual( + audit.split_top_level("a == '&&' && b", "&&"), ["a == '&&'", "b"] + ) + + def test_returns_single_part_when_operator_absent(self) -> None: + self.assertEqual(audit.split_top_level("a", "&&"), ["a"]) + self.assertEqual(audit.split_top_level("(a)", "&&"), ["a"]) + + +class FlattenConjunctionTest(unittest.TestCase): + def test_flattens_nested_conjunctions(self) -> None: + self.assertEqual(audit.flatten_conjunction("a && (b && c)"), ["a", "b", "c"]) + + def test_rejects_any_disjunction(self) -> None: + self.assertIsNone(audit.flatten_conjunction("a || b")) + self.assertIsNone(audit.flatten_conjunction("a && (b || c)")) + + +class GuardAtomTest(unittest.TestCase): + def test_master_guard_atom(self) -> None: + self.assertTrue(audit.is_master_guard_atom("github.ref == 'refs/heads/master'")) + self.assertTrue( + audit.is_master_guard_atom('(github.ref == "refs/heads/master")') + ) + self.assertFalse( + audit.is_master_guard_atom("github.ref != 'refs/heads/master'") + ) + self.assertFalse( + audit.is_master_guard_atom("github.ref == 'refs/heads/master-hotfix'") + ) + + def test_guard_atom_follows_configured_branch(self) -> None: + import os + + os.environ[audit.DEFAULT_BRANCH_ENV] = "main" + try: + self.assertTrue( + audit.is_master_guard_atom("github.ref == 'refs/heads/main'") + ) + self.assertFalse( + audit.is_master_guard_atom("github.ref == 'refs/heads/master'") + ) + finally: + del os.environ[audit.DEFAULT_BRANCH_ENV] + + def test_function_and_dispatch_atoms(self) -> None: + self.assertTrue(audit.is_function_atom("always()", "always")) + self.assertTrue(audit.is_function_atom(" failure( ) ", "failure")) + self.assertFalse(audit.is_function_atom("always(1)", "always")) + self.assertTrue( + audit.is_dispatch_atom("github.event_name == 'workflow_dispatch'") + ) + self.assertTrue( + audit.is_non_dispatch_atom("github.event_name != 'workflow_dispatch'") + ) + self.assertTrue(audit.is_non_dispatch_atom("github.event_name == 'push'")) + + +class RollbackConditionTest(unittest.TestCase): + ALLOWED = ( + "failure() && steps.capture_previous.outcome == 'success' " + "&& steps.candidate_deploy.outputs.entered == 'true'" + ) + + def test_accepts_allowed_conjunction(self) -> None: + self.assertTrue( + audit.rollback_condition_safe( + self.ALLOWED, "capture_previous", "candidate_deploy" + ) + ) + + def test_rejects_extra_or_missing_atoms(self) -> None: + cases = [ + "failure() && steps.capture_previous.outcome == 'success'", + self.ALLOWED + " && success()", + self.ALLOWED + " && false", + "always() && steps.capture_previous.outcome == 'success' " + "&& steps.candidate_deploy.outputs.entered == 'true'", + ] + for condition in cases: + with self.subTest(condition=condition): + self.assertFalse( + audit.rollback_condition_safe( + condition, "capture_previous", "candidate_deploy" + ) + ) + + +class ConcurrencyGroupTest(unittest.TestCase): + def test_accepts_constant_and_allowed_contexts(self) -> None: + for group in ( + "master-release", + "${{ github.repository }}", + "release-${{ github.ref }}", + "${{ format('{0}-{1}', github.workflow, github.ref_name) }}", + ): + with self.subTest(group=group): + self.assertTrue(audit.concurrency_group_safe(group)) + + def test_rejects_partitioning_contexts(self) -> None: + for group in ( + "${{ github.event_name }}", + "${{ github.run_id }}", + "${{ github.event.inputs.rollback_sha }}", + "${{ format('{0}', github.event_name) }}", + "${{ format(github.ref, github.ref) }}", + "${{ unclosed", + ): + with self.subTest(group=group): + self.assertFalse(audit.concurrency_group_safe(group)) + + +class ContinueOnErrorTest(unittest.TestCase): + def test_step_level_accepts_explicit_false_forms(self) -> None: + self.assertTrue(audit.continue_on_error_disabled({})) + self.assertTrue( + audit.continue_on_error_disabled({"continue-on-error": "false"}) + ) + self.assertTrue( + audit.continue_on_error_disabled({"continue-on-error": "${{ false }}"}) + ) + self.assertFalse( + audit.continue_on_error_disabled({"continue-on-error": "true"}) + ) + + def test_pr_required_job_variant_is_stricter(self) -> None: + self.assertTrue(audit.strict_continue_on_error_disabled({})) + self.assertTrue( + audit.strict_continue_on_error_disabled({"continue-on-error": "false"}) + ) + # PR required job 必须无条件执行,因此这里连 ${{ false }} 都不接受; + # release 内其他 job 走 continue_on_error_disabled 的宽松判定。 + self.assertFalse( + audit.strict_continue_on_error_disabled( + {"continue-on-error": "${{ false }}"} + ) + ) + + +class SecretEnumerationTest(unittest.TestCase): + def test_static_names_are_enumerable(self) -> None: + value = {"env": {"A": "${{ secrets.TOKEN }}", "B": "${{ secrets['OTHER'] }}"}} + self.assertTrue(audit.contains_secret_context(value)) + self.assertTrue(audit.secret_context_is_enumerable(value)) + self.assertEqual(audit.referenced_secret_names(value), {"TOKEN", "OTHER"}) + + def test_dynamic_lookup_is_not_enumerable(self) -> None: + value = {"env": {"A": "${{ secrets[format('T_{0}', matrix.name)] }}"}} + self.assertTrue(audit.contains_secret_context(value)) + self.assertFalse(audit.secret_context_is_enumerable(value)) + + +class HttpStatusTest(unittest.TestCase): + def test_parses_gh_error_format(self) -> None: + self.assertEqual(audit.http_status("gh: Not Found (HTTP 404)"), 404) + self.assertEqual(audit.http_status("gh: Bad Gateway (HTTP 502)"), 502) + self.assertIsNone(audit.http_status("dial tcp: connection refused")) + + def test_transient_classification(self) -> None: + self.assertTrue(audit.is_transient(None)) + self.assertTrue(audit.is_transient(503)) + self.assertTrue(audit.is_transient(429)) + self.assertFalse(audit.is_transient(404)) + self.assertFalse(audit.is_transient(403)) + + +class _Result: + def __init__(self, returncode: int, stdout: str = "", stderr: str = "") -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class GhApiRetryTest(unittest.TestCase): + def setUp(self) -> None: + self._run = subprocess.run + self._sleep = audit.time.sleep + # 退避时长是被断言的行为,必须由测试自己决定,不能继承外部环境变量。 + self._retry_base = audit.os.environ.pop( + "BASELINE_AUDIT_RETRY_BASE_SECONDS", None + ) + self.calls: list[list[str]] = [] + self.sleeps: list[float] = [] + audit.time.sleep = lambda seconds: self.sleeps.append(seconds) + + def tearDown(self) -> None: + subprocess.run = self._run + audit.time.sleep = self._sleep + if self._retry_base is not None: + audit.os.environ["BASELINE_AUDIT_RETRY_BASE_SECONDS"] = self._retry_base + + def _patch(self, results: list[_Result]) -> None: + queue = list(results) + + def fake_run(command: list[str], **_: object) -> _Result: + self.calls.append(command) + return queue.pop(0) + + subprocess.run = fake_run + + def test_retries_transient_then_succeeds(self) -> None: + self._patch( + [ + _Result(1, stderr="gh: Bad Gateway (HTTP 502)"), + _Result(1, stderr="dial tcp: connection refused"), + _Result(0, stdout='{"ok": true}'), + ] + ) + self.assertEqual(audit.gh_api("owner/repo", "x"), {"ok": True}) + self.assertEqual(len(self.calls), 3) + self.assertEqual(self.sleeps, [2.0, 4.0]) + + def test_does_not_retry_terminal_status(self) -> None: + self._patch([_Result(1, stderr="gh: Not Found (HTTP 404)")]) + with self.assertRaises(audit.AuditUnavailable): + audit.gh_api("owner/repo", "x") + self.assertEqual(len(self.calls), 1) + self.assertEqual(self.sleeps, []) + + def test_optional_returns_none_only_for_terminal_status(self) -> None: + self._patch([_Result(1, stderr="gh: Not Found (HTTP 404)")]) + self.assertIsNone(audit.gh_api("owner/repo", "x", optional=True)) + self.assertEqual(len(self.calls), 1) + + def test_optional_persistent_outage_is_unavailable_not_missing(self) -> None: + # 关键回归:持续 5xx 不得被降级成“配置缺失”,否则会变成假漂移。 + self._patch([_Result(1, stderr="gh: Service Unavailable (HTTP 503)")] * 4) + with self.assertRaises(audit.AuditUnavailable): + audit.gh_api("owner/repo", "x", optional=True) + self.assertEqual(len(self.calls), audit.GH_API_MAX_ATTEMPTS) + + def test_invalid_json_is_unavailable(self) -> None: + self._patch([_Result(0, stdout="not json")]) + with self.assertRaises(audit.AuditUnavailable): + audit.gh_api("owner/repo", "x") + + +class DefaultBranchTest(unittest.TestCase): + def setUp(self) -> None: + self.env = audit.os.environ + + def tearDown(self) -> None: + self.env.pop(audit.DEFAULT_BRANCH_ENV, None) + + def test_defaults_to_master(self) -> None: + self.env.pop(audit.DEFAULT_BRANCH_ENV, None) + self.assertEqual(audit.default_branch(), "master") + + def test_honours_override(self) -> None: + self.env[audit.DEFAULT_BRANCH_ENV] = "main" + self.assertEqual(audit.default_branch(), "main") + + def test_rejects_injection_shaped_values(self) -> None: + for value in ("../evil", "a b", "-x", "main?ref=x", "main&x=1"): + with self.subTest(value=value): + self.env[audit.DEFAULT_BRANCH_ENV] = value + with self.assertRaises(audit.AuditError): + audit.default_branch() + + +if __name__ == "__main__": + unittest.main()