From 7cae8ebc65e3d12a35c8b889b8711e580b699cdf Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:18:53 +0800 Subject: [PATCH 1/2] Add light CI label and avoid duplicate main runs --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/backend-ci.yml | 36 -------------------- .github/workflows/compose-ci.yml | 43 ------------------------ .github/workflows/frontend-ci.yml | 5 --- .github/workflows/quick-ci.yml | 41 ++++++++++++++++++++++ .github/workflows/required-gate.yml | 26 ++++++++++++-- .github/workflows/security-ci.yml | 2 -- .github/workflows/standalone-windows.yml | 2 -- .github/workflows/upgrade-ci.yml | 11 ------ backend/tests/test_required_gate.py | 40 +++++++++++++++++++++- docs/ci-milestone.md | 20 +++++++---- scripts/required_gate.py | 17 ++++++++-- 12 files changed, 132 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/quick-ci.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 84437ba6..c81f9500 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,7 +30,7 @@ - [ ] Frontend:format、lint、coverage、build - [ ] Compose Playwright E2E 或说明不适用原因 - [ ] Golden / Roundtrip / Migration / Failure-path(按变更适用) -- [ ] 阶段候选稳定后已添加 `ci:milestone`,最新 Head 的完整远程门禁通过 +- [ ] 已选择 `ci:light` 或 `ci:milestone`,最新 Head 的对应远程门禁通过;轻量模式的本地定向验证已记录 - 证据: ## 风险、审批与 Review Thread diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 29cb660e..4480825e 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -37,42 +37,6 @@ on: - "scripts/capacity_s19.py" - "scripts/capacity_s29.py" - ".github/workflows/backend-ci.yml" - push: - branches: [main] - paths: - - "backend/**" - - "skills/**" - - "scripts/smoke_s3.py" - - "scripts/smoke_s4.py" - - "scripts/smoke_s5.py" - - "scripts/smoke_s6.py" - - "scripts/smoke_s7.py" - - "scripts/smoke_s8.py" - - "scripts/smoke_s9.py" - - "scripts/smoke_s10.py" - - "scripts/smoke_s11.py" - - "scripts/smoke_s18.py" - - "scripts/smoke_s19.py" - - "scripts/smoke_s21.py" - - "scripts/smoke_s22.py" - - "scripts/smoke_s23.py" - - "scripts/smoke_s24.py" - - "scripts/smoke_s25.py" - - "scripts/smoke_s26.py" - - "scripts/smoke_s27.py" - - "scripts/smoke_s28.py" - - "scripts/smoke_s29.py" - - "scripts/smoke_s30.py" - - "scripts/smoke_s47.py" - - "scripts/verify_s47_2_contract_migration.py" - - "scripts/verify_s47_3_contract_migration.py" - - "scripts/verify_kafka_compat.py" - - "scripts/storage_transfer.py" - - "scripts/capacity_s11.py" - - "scripts/capacity_workflow.py" - - "scripts/capacity_s19.py" - - "scripts/capacity_s29.py" - - ".github/workflows/backend-ci.yml" workflow_dispatch: concurrency: diff --git a/.github/workflows/compose-ci.yml b/.github/workflows/compose-ci.yml index b3592259..6aa8b285 100644 --- a/.github/workflows/compose-ci.yml +++ b/.github/workflows/compose-ci.yml @@ -44,49 +44,6 @@ on: - "scripts/backup.sh" - "scripts/verify_restore.sh" - ".github/workflows/compose-ci.yml" - push: - branches: [main] - paths: - - "backend/**" - - "frontend/**" - - "mock-target/**" - - "compose.yaml" - - "deploy/compact/**" - - "deploy/compatibility/**" - - "deploy/s47/**" - - "scripts/smoke_s3.py" - - "scripts/smoke_s4.py" - - "scripts/smoke_s5.py" - - "scripts/smoke_s6.py" - - "scripts/smoke_s7.py" - - "scripts/smoke_s8.py" - - "scripts/smoke_s9.py" - - "scripts/smoke_s10.py" - - "scripts/smoke_s11.py" - - "scripts/smoke_s18.py" - - "scripts/smoke_s19.py" - - "scripts/smoke_s21.py" - - "scripts/smoke_s22.py" - - "scripts/smoke_s23.py" - - "scripts/smoke_s24.py" - - "scripts/smoke_s25.py" - - "scripts/smoke_s26.py" - - "scripts/smoke_s27.py" - - "scripts/smoke_s28.py" - - "scripts/smoke_s29.py" - - "scripts/smoke_s30.py" - - "scripts/smoke_s31.py" - - "scripts/smoke_s32.py" - - "scripts/smoke_s47.py" - - "scripts/verify_kafka_compat.py" - - "scripts/storage_transfer.py" - - "scripts/capacity_s11.py" - - "scripts/capacity_workflow.py" - - "scripts/capacity_s19.py" - - "scripts/capacity_s29.py" - - "scripts/backup.sh" - - "scripts/verify_restore.sh" - - ".github/workflows/compose-ci.yml" workflow_dispatch: inputs: run_rc_gates: diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index fdfc00f0..547de386 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -6,11 +6,6 @@ on: paths: - "frontend/**" - ".github/workflows/frontend-ci.yml" - push: - branches: [main] - paths: - - "frontend/**" - - ".github/workflows/frontend-ci.yml" workflow_dispatch: concurrency: diff --git a/.github/workflows/quick-ci.yml b/.github/workflows/quick-ci.yml new file mode 100644 index 00000000..d66ed09a --- /dev/null +++ b/.github/workflows/quick-ci.yml @@ -0,0 +1,41 @@ +name: Quick CI + +on: + pull_request: + types: [labeled] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quick: + if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:light' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.13.15" + - name: Check changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [[ -z "${BASE_SHA}" || -z "${HEAD_SHA}" ]]; then + git diff --check HEAD^ HEAD + exit 0 + fi + git diff --check "${BASE_SHA}" "${HEAD_SHA}" + if git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" \ + | grep -Eq '^(backend/|scripts/).*\.py$'; then + python3 -m compileall -q backend/app scripts + fi diff --git a/.github/workflows/required-gate.yml b/.github/workflows/required-gate.yml index 32fbae1a..03efc17b 100644 --- a/.github/workflows/required-gate.yml +++ b/.github/workflows/required-gate.yml @@ -3,8 +3,6 @@ name: Required Gate Controller on: pull_request_target: types: [labeled] - push: - branches: [main] workflow_dispatch: concurrency: @@ -19,7 +17,7 @@ permissions: jobs: controller: - if: ${{ github.event_name != 'pull_request_target' || github.event.label.name == 'ci:milestone' }} + if: ${{ github.event_name != 'pull_request_target' || github.event.label.name == 'ci:milestone' || github.event.label.name == 'ci:light' }} name: Required Gate Controller runs-on: ubuntu-latest timeout-minutes: 100 @@ -41,6 +39,21 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ github.event.pull_request.base.sha || github.sha }} + - name: Resolve current CI mode + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + mode='full' + if [[ -n "${PR_NUMBER}" ]]; then + mode="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq 'if ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" + fi + if [[ "${mode}" == 'invalid' ]]; then + echo '当前 PR 缺少 CI 模式标签' >&2 + exit 1 + fi + echo "FLOWTEST_GATE_MODE=${mode}" >>"${GITHUB_ENV}" - name: Resolve changed paths from trusted metadata env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} @@ -76,6 +89,7 @@ jobs: --sha "${FLOWTEST_GATE_SHA}" --paths-file "${FLOWTEST_GATE_PATHS_FILE}" --event-name "${GITHUB_EVENT_NAME}" + --mode "${FLOWTEST_GATE_MODE}" - name: Complete trusted Required Gate status if: always() env: @@ -96,6 +110,12 @@ jobs: metadata_current='false' description='PR 基线或 Head 已变更' fi + current_mode="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq 'if ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" + if [[ "${current_mode}" != "${FLOWTEST_GATE_MODE}" ]]; then + metadata_current='false' + description='PR 的 CI 模式标签已变更' + fi fi if [[ "${GATE_OUTCOME}" == 'success' && "${metadata_current}" == 'true' ]]; then state='success' diff --git a/.github/workflows/security-ci.yml b/.github/workflows/security-ci.yml index b5619e05..bf5e5f1c 100644 --- a/.github/workflows/security-ci.yml +++ b/.github/workflows/security-ci.yml @@ -3,8 +3,6 @@ name: Security CI on: pull_request: types: [labeled] - push: - branches: [main] workflow_dispatch: concurrency: diff --git a/.github/workflows/standalone-windows.yml b/.github/workflows/standalone-windows.yml index 982dd663..d995f255 100644 --- a/.github/workflows/standalone-windows.yml +++ b/.github/workflows/standalone-windows.yml @@ -9,8 +9,6 @@ on: - "frontend/**" - "deploy/standalone/**" - "scripts/storage_transfer.py" - push: - branches: [main] workflow_dispatch: concurrency: diff --git a/.github/workflows/upgrade-ci.yml b/.github/workflows/upgrade-ci.yml index 0edaf026..b27b32ad 100644 --- a/.github/workflows/upgrade-ci.yml +++ b/.github/workflows/upgrade-ci.yml @@ -12,17 +12,6 @@ on: - "scripts/storage_transfer.py" - "scripts/verify_v2_v3_data.py" - "scripts/verify_v2_v3_upgrade.sh" - push: - branches: [main] - paths: - - ".github/workflows/upgrade-ci.yml" - - "backend/**" - - "deploy/upgrade/**" - - "mock-target/**" - - "scripts/smoke_s11.py" - - "scripts/storage_transfer.py" - - "scripts/verify_v2_v3_data.py" - - "scripts/verify_v2_v3_upgrade.sh" workflow_dispatch: concurrency: diff --git a/backend/tests/test_required_gate.py b/backend/tests/test_required_gate.py index dea194bc..5897c37a 100644 --- a/backend/tests/test_required_gate.py +++ b/backend/tests/test_required_gate.py @@ -53,6 +53,25 @@ def test_required_gate_marks_irrelevant_checks_as_no_op() -> None: } +def test_light_gate_requires_only_quick_ci() -> None: + plan = required_gate.build_gate_plan(["backend/app/services/projects.py"], mode="light") + + assert _keys(plan) == {"quick"} + assert {spec.key for spec in plan.no_op} == { + "backend", + "frontend", + "security", + "compose", + "standalone", + "upgrade", + } + + +def test_required_gate_rejects_unknown_mode() -> None: + with pytest.raises(required_gate.RequiredGateError, match="未知 CI 模式"): + required_gate.build_gate_plan([], mode="skip") + + def test_required_gate_selects_backend_dependent_checks() -> None: plan = required_gate.build_gate_plan(["backend/app/services/projects.py"]) @@ -219,6 +238,7 @@ def test_required_gate_controller_runs_trusted_base_code() -> None: assert "pull_request_target" in workflow["on"] assert "pull_request" not in workflow["on"] + assert "push" not in workflow["on"] assert workflow["on"]["pull_request_target"]["types"] == ["labeled"] assert workflow["concurrency"]["cancel-in-progress"] == "true" assert workflow["permissions"]["statuses"] == "write" @@ -226,7 +246,8 @@ def test_required_gate_controller_runs_trusted_base_code() -> None: controller = workflow["jobs"]["controller"] assert controller["if"] == ( "${{ github.event_name != 'pull_request_target' " - "|| github.event.label.name == 'ci:milestone' }}" + "|| github.event.label.name == 'ci:milestone' " + "|| github.event.label.name == 'ci:light' }}" ) assert controller["name"] == "Required Gate Controller" checkout = next(step for step in controller["steps"] if "uses" in step) @@ -256,6 +277,22 @@ def test_required_gate_controller_runs_trusted_base_code() -> None: assert complete_status["if"] == "always()" assert ".base.sha, .head.sha" in complete_status["run"] assert '"${BASE_SHA} ${HEAD_SHA}"' in complete_status["run"] + assert "${FLOWTEST_GATE_MODE}" in complete_status["run"] + + +def test_quick_ci_runs_only_for_light_label() -> None: + workflow = yaml.load( + (WORKSPACE_ROOT / ".github/workflows/quick-ci.yml").read_text(encoding="utf-8"), + Loader=yaml.BaseLoader, + ) + + assert workflow["on"]["pull_request"]["types"] == ["labeled"] + assert "push" not in workflow["on"] + assert workflow["jobs"]["quick"]["if"] == ( + "${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:light' }}" + ) + quick_steps = workflow["jobs"]["quick"]["steps"] + assert any("git diff --check" in step.get("run", "") for step in quick_steps) @pytest.mark.parametrize( @@ -279,6 +316,7 @@ def test_pull_request_ci_runs_only_for_milestone_label( ) assert workflow["on"]["pull_request"]["types"] == ["labeled"] + assert "push" not in workflow["on"] assert workflow["concurrency"]["cancel-in-progress"] == "true" expected_condition = ( "${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }}" diff --git a/docs/ci-milestone.md b/docs/ci-milestone.md index 8456219c..a07b75bf 100644 --- a/docs/ci-milestone.md +++ b/docs/ci-milestone.md @@ -1,13 +1,19 @@ -# 阶段节点 CI +# PR CI 开关 -FlowTest 的 PR 使用 `ci:milestone` 标签启动完整远程 CI。开发过程中的普通提交不会自动启动远程重任务,开发者应先完成本地定向检查,在一个功能阶段稳定后再运行完整门禁。 +FlowTest 的 PR 通过 GitHub 标签选择一次远程检查。普通提交不会自动启动远程重任务;先完成适用的本地验证,再给稳定的候选提交加标签。 + +| 标签 | 用途 | PR 合并门禁 | +| --- | --- | --- | +| `ci:light` | 文档、文案或已完成定向验证的小改动 | Quick CI:检查差异空白错误;涉及 Python 时编译检查语法。不会运行功能测试、构建、容器或安全扫描。 | +| `ci:milestone` | 功能、依赖、部署或需要完整回归的改动 | 按路径运行 Backend、Frontend、Security、Compose、Windows、Upgrade,并由 Required Gate 汇总。 | + +两个标签同时存在时,`ci:milestone` 优先。CI 工作流自身的修改仍须经受控 Bootstrap 合并,不能用轻量标签绕过。`main` 合并后不再重复运行同一组完整 CI;需要补跑时使用各工作流的 `workflow_dispatch`。 ## 使用流程 1. 在同一功能阶段内集中开发、修复和本地验证。 -2. 阶段候选稳定后,为 PR 添加 `ci:milestone` 标签。 -3. Backend、Frontend、Security、Compose、Windows、Upgrade 和 Required Gate 根据变更路径运行。 -4. 如果门禁后又修改了代码,先移除再重新添加 `ci:milestone`,使最新 PR Head 获得新的完整门禁结果。 -5. 合并到 `main` 后仍自动运行完整 CI。 +2. 根据改动风险,为 PR 添加 `ci:light` 或 `ci:milestone`。 +3. 等待当前 PR Head 的 `Required Gate` 成功,再合并。 +4. 如果门禁后又修改了代码,先移除再重新添加所选标签,使最新 Head 获得新的检查结果。改变模式时先移除旧标签,再添加新标签。 -所有工作流按 PR 或分支设置并发组。新的阶段门禁启动时,会取消同一 PR 上仍在运行的旧门禁,避免重复占用 Runner。`workflow_dispatch` 保留为人工补跑入口。 +所有工作流按 PR 设置并发组。新的门禁启动时,会取消同一 PR 上仍在运行的旧门禁,避免重复占用 Runner。GitHub 对 `labeled` 事件会创建其他工作流的跳过记录,但只有所选模式的检查会实际执行。 diff --git a/scripts/required_gate.py b/scripts/required_gate.py index d471425e..d67083f3 100644 --- a/scripts/required_gate.py +++ b/scripts/required_gate.py @@ -160,6 +160,14 @@ def required_for(self, paths: frozenset[str]) -> bool: ), ) +LIGHT_GATE_SPEC = GateSpec( + key="quick", + label="Quick CI", + workflow_path=".github/workflows/quick-ci.yml", + checks=("quick",), + always_required=True, +) + CI_GOVERNANCE_PATHS = frozenset( {spec.workflow_path for spec in GATE_SPECS} | { @@ -234,7 +242,11 @@ def _get(self, path: str) -> object: return json.load(response) -def build_gate_plan(paths: Iterable[str]) -> GatePlan: +def build_gate_plan(paths: Iterable[str], *, mode: str = "full") -> GatePlan: + if mode == "light": + return GatePlan(required=(LIGHT_GATE_SPEC,), no_op=GATE_SPECS) + if mode != "full": + raise RequiredGateError(f"未知 CI 模式:{mode}") normalized = _normalized_paths(paths) required = tuple(spec for spec in GATE_SPECS if spec.required_for(normalized)) no_op = tuple(spec for spec in GATE_SPECS if spec not in required) @@ -390,6 +402,7 @@ def _arguments() -> argparse.Namespace: parser.add_argument("--sha", required=True) parser.add_argument("--paths-file", type=Path, required=True) parser.add_argument("--event-name", default=os.getenv("GITHUB_EVENT_NAME", "")) + parser.add_argument("--mode", choices=("full", "light"), default="full") parser.add_argument("--timeout-seconds", type=int, default=5400) parser.add_argument("--poll-seconds", type=int, default=30) return parser.parse_args() @@ -402,7 +415,7 @@ def main() -> int: print("Required Gate 缺少 GITHUB_REPOSITORY 或 GITHUB_TOKEN", flush=True) return 2 paths = args.paths_file.read_text(encoding="utf-8").splitlines() - plan = build_gate_plan(paths) + plan = build_gate_plan(paths, mode=args.mode) _write_step_summary(plan, status="等待子门禁") required = ", ".join(spec.label for spec in plan.required) no_op = ", ".join(spec.label for spec in plan.no_op) or "无" From 47eabe3bf99e96b7e3b45d3f17b2078bea5d638a Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:52:37 +0800 Subject: [PATCH 2/2] Limit light CI to documentation changes --- .github/workflows/quick-ci.yml | 7 ------- .github/workflows/required-gate.yml | 4 ++-- backend/tests/test_required_gate.py | 21 +++++++++++++++++++++ docs/ci-milestone.md | 4 ++-- scripts/required_gate.py | 19 +++++++++++++++++++ 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/.github/workflows/quick-ci.yml b/.github/workflows/quick-ci.yml index d66ed09a..597e8ca8 100644 --- a/.github/workflows/quick-ci.yml +++ b/.github/workflows/quick-ci.yml @@ -22,9 +22,6 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: "3.13.15" - name: Check changed files env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -35,7 +32,3 @@ jobs: exit 0 fi git diff --check "${BASE_SHA}" "${HEAD_SHA}" - if git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" \ - | grep -Eq '^(backend/|scripts/).*\.py$'; then - python3 -m compileall -q backend/app scripts - fi diff --git a/.github/workflows/required-gate.yml b/.github/workflows/required-gate.yml index 03efc17b..4e2296c3 100644 --- a/.github/workflows/required-gate.yml +++ b/.github/workflows/required-gate.yml @@ -47,7 +47,7 @@ jobs: mode='full' if [[ -n "${PR_NUMBER}" ]]; then mode="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - --jq 'if ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" + --jq 'if (([.labels[].name] | index("ci:milestone")) and ([.labels[].name] | index("ci:light"))) then "invalid" elif ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" fi if [[ "${mode}" == 'invalid' ]]; then echo '当前 PR 缺少 CI 模式标签' >&2 @@ -111,7 +111,7 @@ jobs: description='PR 基线或 Head 已变更' fi current_mode="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - --jq 'if ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" + --jq 'if (([.labels[].name] | index("ci:milestone")) and ([.labels[].name] | index("ci:light"))) then "invalid" elif ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" if [[ "${current_mode}" != "${FLOWTEST_GATE_MODE}" ]]; then metadata_current='false' description='PR 的 CI 模式标签已变更' diff --git a/backend/tests/test_required_gate.py b/backend/tests/test_required_gate.py index 5897c37a..215e2e00 100644 --- a/backend/tests/test_required_gate.py +++ b/backend/tests/test_required_gate.py @@ -72,6 +72,27 @@ def test_required_gate_rejects_unknown_mode() -> None: required_gate.build_gate_plan([], mode="skip") +def test_light_gate_accepts_documentation_only() -> None: + required_gate.enforce_light_scope( + ["README.md", "docs/operations/deployment.md", ".github/PULL_REQUEST_TEMPLATE.md"], + "light", + ) + + +@pytest.mark.parametrize( + "paths", + [ + ["backend/app/services/auth.py"], + ["docs/ci-milestone.md", "frontend/src/App.tsx"], + ["docs/assets/diagram.png"], + [], + ], +) +def test_light_gate_rejects_runtime_or_non_document_paths(paths: list[str]) -> None: + with pytest.raises(required_gate.RequiredGateError, match="ci:light 只适用于"): + required_gate.enforce_light_scope(paths, "light") + + def test_required_gate_selects_backend_dependent_checks() -> None: plan = required_gate.build_gate_plan(["backend/app/services/projects.py"]) diff --git a/docs/ci-milestone.md b/docs/ci-milestone.md index a07b75bf..e3b9844e 100644 --- a/docs/ci-milestone.md +++ b/docs/ci-milestone.md @@ -4,10 +4,10 @@ FlowTest 的 PR 通过 GitHub 标签选择一次远程检查。普通提交不 | 标签 | 用途 | PR 合并门禁 | | --- | --- | --- | -| `ci:light` | 文档、文案或已完成定向验证的小改动 | Quick CI:检查差异空白错误;涉及 Python 时编译检查语法。不会运行功能测试、构建、容器或安全扫描。 | +| `ci:light` | 仅 `README.md`、PR 模板和 `docs/` 下的 Markdown 文档 | Quick CI:检查差异空白错误。不会运行功能测试、构建、容器或安全扫描;其他路径的 PR 会被 Required Gate 拒绝。 | | `ci:milestone` | 功能、依赖、部署或需要完整回归的改动 | 按路径运行 Backend、Frontend、Security、Compose、Windows、Upgrade,并由 Required Gate 汇总。 | -两个标签同时存在时,`ci:milestone` 优先。CI 工作流自身的修改仍须经受控 Bootstrap 合并,不能用轻量标签绕过。`main` 合并后不再重复运行同一组完整 CI;需要补跑时使用各工作流的 `workflow_dispatch`。 +两个标签同时存在时门禁失败;先移除旧标签再添加新标签。CI 工作流自身的修改仍须经受控 Bootstrap 合并,不能用轻量标签绕过。`main` 合并后不再重复运行同一组完整 CI;需要补跑时使用各工作流的 `workflow_dispatch`。 ## 使用流程 diff --git a/scripts/required_gate.py b/scripts/required_gate.py index d67083f3..47cb7851 100644 --- a/scripts/required_gate.py +++ b/scripts/required_gate.py @@ -176,6 +176,7 @@ def required_for(self, paths: frozenset[str]) -> bool: } ) CI_GOVERNANCE_PREFIXES = (".github/workflows/",) +LIGHT_ALLOWED_EXACT_PATHS = frozenset({"README.md", ".github/PULL_REQUEST_TEMPLATE.md"}) @dataclass(frozen=True, slots=True) @@ -267,6 +268,23 @@ def enforce_trusted_governance(paths: Iterable[str], event_name: str) -> None: ) +def enforce_light_scope(paths: Iterable[str], mode: str) -> None: + if mode != "light": + return + normalized = _normalized_paths(paths) + disallowed = sorted( + path + for path in normalized + if path not in LIGHT_ALLOWED_EXACT_PATHS + and not (path.startswith("docs/") and path.endswith(".md")) + ) + if not normalized or disallowed: + raise RequiredGateError( + "ci:light 只适用于 README、PR 模板和 docs/ 下的 Markdown 文档;" + "其他文件请使用 ci:milestone:" + ", ".join(disallowed) + ) + + def _normalized_paths(paths: Iterable[str]) -> frozenset[str]: return frozenset(path.strip().removeprefix("./") for path in paths if path.strip()) @@ -422,6 +440,7 @@ def main() -> int: print(f"必需子门禁:{required};No-op Success:{no_op}", flush=True) try: enforce_trusted_governance(paths, args.event_name) + enforce_light_scope(paths, args.mode) wait_for_required_checks( client=GitHubClient(repository=args.repository, token=token), plan=plan,