From c04ab49bc643fa92189da7bcf4a97b65364b9b0e Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:26:43 +0800 Subject: [PATCH 1/4] ci: close post-merge efficiency audit gaps --- .github/workflows/ci-bootstrap-validation.yml | 5 + .github/workflows/pr-validation.yml | 61 ++++-- .github/workflows/required-gate.yml | 7 + backend/tests/test_ci_plan.py | 180 +++++++++++++++++- backend/tests/test_required_gate.py | 166 +++++++++++++++- docs/development-efficiency-phase2.md | 10 +- docs/development-efficiency-phase3.md | 22 ++- scripts/ci_plan.py | 121 ++++++++++-- scripts/ci_summary.py | 8 +- scripts/required_gate.py | 47 +++-- 10 files changed, 549 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci-bootstrap-validation.yml b/.github/workflows/ci-bootstrap-validation.yml index e7a1b9eb..6ed400e4 100644 --- a/.github/workflows/ci-bootstrap-validation.yml +++ b/.github/workflows/ci-bootstrap-validation.yml @@ -14,6 +14,7 @@ on: - "docs/development-efficiency-phase3.md" - "backend/tests/test_ci_plan.py" - "backend/tests/test_required_gate.py" + - "backend/docker-bake.ci.hcl" - "scripts/build_skill_evaluation.py" - "scripts/ci_plan.py" - "scripts/ci_summary.py" @@ -23,6 +24,10 @@ on: # Governance changes cannot pass the normal Required Gate. This separate, # PR-triggered full suite supplies eligible checks for maintainer Bootstrap. # It never publishes the Required Gate status or changes the branch ruleset. +concurrency: + group: ci-bootstrap-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 3e114fb4..6a80c1a9 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -5,9 +5,10 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled, edited] -# Label runs have their own group: an unrelated label event must not cancel a valid run. +# Only ignored metadata events use a separate group. Effective label changes and +# new heads replace the active run for the same PR. concurrency: - group: pr-validation-${{ github.event.pull_request.number }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled' || github.event.action == 'edited') && github.run_id || 'head' }} + group: pr-validation-${{ github.event.pull_request.number }}-${{ (((github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name != 'ci:light' && github.event.label.name != 'ci:milestone') || (github.event.action == 'edited' && !github.event.changes.base)) && 'ignored' || 'active' }} cancel-in-progress: true permissions: @@ -41,11 +42,11 @@ jobs: quick: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'quick') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'quick') }} uses: ./.github/workflows/quick-ci.yml backend-standard: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'backend-standard') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'backend-standard') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -82,11 +83,11 @@ jobs: backend-full: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'backend-full') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'backend-full') }} uses: ./.github/workflows/backend-ci.yml frontend-standard: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'frontend-standard') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'frontend-standard') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -120,33 +121,67 @@ jobs: frontend-full: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'frontend-full') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'frontend-full') }} uses: ./.github/workflows/frontend-ci.yml compose: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'compose') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'compose') }} uses: ./.github/workflows/compose-ci.yml security: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'security') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'security') }} uses: ./.github/workflows/security-ci.yml windows: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'windows') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'windows') }} uses: ./.github/workflows/standalone-windows.yml upgrade: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'upgrade') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'upgrade') }} uses: ./.github/workflows/upgrade-ci.yml skills: needs: plan - if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'skills') }} + if: ${{ !fromJSON(needs.plan.outputs.plan).governance_change && contains(fromJSON(needs.plan.outputs.plan).required, 'skills') }} uses: ./.github/workflows/skills-ci.yml + compact: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'compact') }} + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Start six-service Compact stack + run: ./deploy/compact/start.sh + - name: Verify Compact business flow on this PR revision + run: | + set -a + source deploy/compact/.env + set +a + FLOWTEST_SMOKE_API_URL=http://127.0.0.1:3000/api/v1 \ + FLOWTEST_SMOKE_ADMIN_EMAIL="${FLOWTEST_BOOTSTRAP_ADMIN_EMAIL}" \ + FLOWTEST_SMOKE_ADMIN_PASSWORD="${FLOWTEST_BOOTSTRAP_ADMIN_PASSWORD}" \ + FLOWTEST_SMOKE_TARGET_URL=http://backend:8000/api/v1 \ + python3 scripts/smoke_s32.py + - name: Print Compact logs on failure + if: failure() + run: | + if [[ -f deploy/compact/.env ]]; then + docker compose --env-file deploy/compact/.env -f deploy/compact/compose.yaml logs --no-color --tail=300 + fi + - name: Stop Compact stack + if: always() + run: | + if [[ -f deploy/compact/.env ]]; then + docker compose --env-file deploy/compact/.env -f deploy/compact/compose.yaml down --volumes + fi + shadow: name: PR Validation Shadow ${{ github.sha }} ${{ needs.plan.outputs.fingerprint }} if: ${{ always() && (github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'edited' || github.event.label.name == 'ci:light' || github.event.label.name == 'ci:milestone' || github.event.changes.base) }} - needs: [plan, quick, backend-standard, backend-full, frontend-standard, frontend-full, compose, security, windows, upgrade, skills] + needs: [plan, quick, backend-standard, backend-full, frontend-standard, frontend-full, compose, security, windows, upgrade, skills, compact] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/required-gate.yml b/.github/workflows/required-gate.yml index 9c6c1b61..52639c27 100644 --- a/.github/workflows/required-gate.yml +++ b/.github/workflows/required-gate.yml @@ -5,6 +5,13 @@ on: workflows: [PR Validation] types: [requested, in_progress, completed] +# Serialize all wakeups for a candidate Head. Keep completed wakeups queued so +# a late pending handler cannot replace the terminal result. +concurrency: + group: required-gate-${{ github.event.workflow_run.head_sha }} + queue: max + cancel-in-progress: false + permissions: actions: read contents: read diff --git a/backend/tests/test_ci_plan.py b/backend/tests/test_ci_plan.py index a8e743dc..92c843fd 100644 --- a/backend/tests/test_ci_plan.py +++ b/backend/tests/test_ci_plan.py @@ -2,6 +2,7 @@ from __future__ import annotations +import configparser import importlib.util import json import shutil @@ -24,8 +25,19 @@ SHA = "a" * 40 -def plan(paths: list[str], labels: set[str] | None = None) -> Any: - return ci_plan.build_plan(paths, labels or set(), base_sha=SHA, head_sha=SHA, tested_sha=SHA) +def plan( + paths: list[str], + labels: set[str] | None = None, + removed_paths: frozenset[str] = frozenset(), +) -> Any: + return ci_plan.build_plan( + paths, + labels or set(), + base_sha=SHA, + head_sha=SHA, + tested_sha=SHA, + removed_paths=removed_paths, + ) @pytest.mark.parametrize( @@ -37,7 +49,11 @@ def plan(paths: list[str], labels: set[str] | None = None) -> Any: ("backend/app/services/imports.py", "standard", {"backend-standard"}), ("backend/app/domain/execution.py", "integration", {"backend-full", "compose"}), ("backend/app/core/security.py", "integration", {"backend-full", "compose"}), - ("backend/alembic/versions/123.py", "integration", {"upgrade", "windows"}), + ( + "backend/migrations/versions/20260809_0001_access_control.py", + "integration", + {"upgrade", "windows", "backend-full"}, + ), ("backend/uv.lock", "integration", {"security", "compose"}), ("deploy/standalone/mcp.ps1", "integration", {"windows"}), ("deploy/upgrade/verify.sh", "integration", {"upgrade", "compose"}), @@ -72,6 +88,67 @@ def test_identity_boundaries_require_integration_consumers(path: str) -> None: assert {"backend-full", "frontend-full", "compose", "security"} <= set(selected.required) +def test_alembic_config_and_real_migration_root_are_routed() -> None: + root = Path(__file__).parents[2] + config = configparser.ConfigParser() + config.read(root / "backend/alembic.ini") + migration_root = root / "backend" / config["alembic"]["script_location"] + assert migration_root.is_dir() + assert migration_root == root / "backend/migrations" + for path in ( + "backend/alembic.ini", + "backend/migrations/env.py", + "backend/migrations/versions/20260809_0001_access_control.py", + "backend/app/schemas/workflows.py", + ): + selected = plan([path]) + assert selected.tier == "integration" + assert {"backend-full", "upgrade", "windows"} <= set(selected.required) + + +@pytest.mark.parametrize( + ("path", "required"), + [ + ("backend/docker-bake.ci.hcl", {"policy", "backend-full", "compose", "security"}), + ("backend/Dockerfile", {"backend-full", "compose", "security"}), + ("backend/.dockerignore", {"backend-full", "compose", "security"}), + ( + "backend/patches/cpython-3.13-cve-2026-82049.patch", + {"backend-full", "compose", "security"}, + ), + ("frontend/nginx.conf", {"frontend-full", "compose", "security"}), + ("frontend/.dockerignore", {"frontend-full", "compose", "security"}), + ("mock-target/Dockerfile", {"compose", "security"}), + ("deploy/compact/images.env.example", {"compact", "compose", "security"}), + ], +) +def test_real_image_inputs_require_build_and_scan(path: str, required: set[str]) -> None: + assert (Path(__file__).parents[2] / path).exists() + selected = plan([path], {"ci:light"}) + assert required <= set(selected.required) + assert selected.tier in {"integration", "full"} + + +def test_governance_and_compact_change_still_requires_compact() -> None: + selected = plan(["scripts/ci_plan.py", "deploy/compact/compose.yaml"]) + assert selected.governance_change + assert {"policy", "compact"} <= set(selected.required) + + +def test_renamed_image_input_uses_both_directories() -> None: + paths = ci_plan._paths_from_files( + [ + { + "filename": "frontend/nginx.conf", + "previous_filename": "backend/.dockerignore", + "status": "renamed", + } + ] + ) + selected = plan(paths) + assert {"backend-full", "frontend-full", "compose", "security"} <= set(selected.required) + + def test_policy_entrypoints_use_isolated_python() -> None: workflow = (Path(__file__).parents[2] / ".github/workflows/pr-validation.yml").read_text() assert "python3 -I scripts/ci_plan.py" in workflow @@ -117,6 +194,81 @@ def test_targets_include_direct_consumers_and_fallback() -> None: assert plan(["backend/app/services/new_service.py"]).backend_targets == () +def test_changed_tests_join_known_source_targets() -> None: + backend = plan(["backend/app/services/imports.py", "backend/tests/test_imports_api.py"]) + assert backend.backend_targets == ("tests/test_imports_api.py",) + assert plan(["backend/tests/test_imports_api.py"]).backend_targets == ( + "tests/test_imports_api.py", + ) + frontend = plan( + [ + "frontend/src/features/navigation/ShellSidebar.tsx", + "frontend/src/features/navigation/ShellSidebar.test.tsx", + ] + ) + assert "src/features/navigation/ShellSidebar.test.tsx" in frontend.frontend_targets + assert "src/App.test.tsx" in frontend.frontend_targets + assert plan(["backend/tests/test_new_feature.py"]).backend_targets == ( + "tests/test_new_feature.py", + ) + + +@pytest.mark.parametrize( + "paths", + [ + ["backend/app/services/imports.py", "backend/app/services/new_service.py"], + ["backend/app/services/imports.py", "backend/tests/conftest.py"], + ], +) +def test_unknown_or_removed_backend_input_falls_back_to_full_side(paths: list[str]) -> None: + assert plan(paths).backend_targets == () + + +def test_removed_or_renamed_test_falls_back_on_pr_metadata() -> None: + old = "backend/tests/test_imports_api.py" + assert plan([old], removed_paths=frozenset({old})).backend_targets == () + files = [ + { + "filename": "backend/tests/test_imports_api_v2.py", + "previous_filename": old, + "status": "renamed", + } + ] + paths = ci_plan._paths_from_files(files) + removed = ci_plan._removed_paths_from_files(files) + assert plan(paths, removed_paths=removed).backend_targets == () + assert ci_plan._removed_paths_from_files([{"filename": old, "status": "removed"}]) == {old} + + +def test_unknown_or_removed_frontend_input_falls_back_to_full_side() -> None: + assert ( + plan( + ["frontend/src/features/navigation/ShellSidebar.tsx", "frontend/vitest.config.ts"] + ).frontend_targets + == () + ) + old = "frontend/src/features/navigation/Removed.test.tsx" + assert plan([old], removed_paths=frozenset({old})).frontend_targets == () + + +def test_targeted_runner_propagates_no_tests_collected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner_path = Path(__file__).parents[2] / "scripts/run_targeted_tests.py" + spec = importlib.util.spec_from_file_location("run_targeted_tests", runner_path) + assert spec is not None and spec.loader is not None + runner = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runner) + monkeypatch.setenv("TARGETS", json.dumps(["tests/test_ci_plan.py"])) + monkeypatch.setattr(sys, "argv", [str(runner_path), "backend"]) + monkeypatch.setattr( + runner.subprocess, + "run", + lambda command, **_kwargs: subprocess.CompletedProcess(command, 5), + ) + assert runner.main() == 5 + + def test_rename_considers_old_and_new_paths() -> None: result = plan(["docs/说明.md", "backend/app/domain/旧 引擎.py"]) assert result.tier == "integration" @@ -268,6 +420,8 @@ def test_automatic_pr_entries_and_workflow_call_reuse() -> None: f"{prefix}**" for prefix in ci_plan.GOVERNANCE_PREFIXES } assert bootstrap["permissions"] == {"contents": "read"} + assert bootstrap["concurrency"]["cancel-in-progress"] == "true" + assert "github.event.pull_request.number" in bootstrap["concurrency"]["group"] assert set(bootstrap["jobs"]) == { "bootstrap-quick", "bootstrap-backend", @@ -286,7 +440,25 @@ def test_automatic_pr_entries_and_workflow_call_reuse() -> None: "in_progress", "completed", ] - assert "github.run_id" in workflows["pr-validation.yml"]["concurrency"]["group"] + publisher_concurrency = workflows["required-gate.yml"]["concurrency"] + assert "github.event.workflow_run.head_sha" in publisher_concurrency["group"] + assert publisher_concurrency["queue"] == "max" + assert publisher_concurrency["cancel-in-progress"] == "false" + active_group = workflows["pr-validation.yml"]["concurrency"]["group"] + assert "github.run_id" not in active_group + assert "'ignored'" in active_group and "'active'" in active_group + assert "github.event.label.name" in active_group + assert workflows["pr-validation.yml"]["concurrency"]["cancel-in-progress"] == "true" + for name, job in workflows["pr-validation.yml"]["jobs"].items(): + if name != "compact" and job.get("needs") == "plan": + assert "governance_change" in job["if"] + assert set(ci_plan.ALL_JOBS) - {"policy"} <= set(workflows["pr-validation.yml"]["jobs"]) + compact = workflows["pr-validation.yml"]["jobs"]["compact"] + assert "governance_change" not in compact["if"] + assert "compact" in workflows["pr-validation.yml"]["jobs"]["shadow"]["needs"] + assert "run_rc_gates" not in str(compact) + assert "./deploy/compact/start.sh" in str(compact) + assert "scripts/smoke_s32.py" in str(compact) assert "opened" in workflows["pr-validation.yml"]["on"]["pull_request"]["types"] assert "synchronize" in workflows["pr-validation.yml"]["on"]["pull_request"]["types"] for name in ( diff --git a/backend/tests/test_required_gate.py b/backend/tests/test_required_gate.py index df85928c..ca97cd1e 100644 --- a/backend/tests/test_required_gate.py +++ b/backend/tests/test_required_gate.py @@ -160,6 +160,24 @@ def test_missing_or_skipped_shadow_rejected() -> None: def test_governance_change_cannot_self_approve() -> None: selected = plan(["scripts/ci_plan.py"]) assert not gate.evaluate_run(run_for(selected), jobs_for(selected), selected, REPO, 99)[0] + valid, lines = ci_summary.evaluate( + json.loads(selected.to_json()), {"plan": {"result": "success"}} + ) + assert not valid + assert "backend-full: bootstrap_only" in lines + assert not any("missing" in line for line in lines) + + +def test_compact_requires_its_own_successful_compatibility_job() -> None: + selected = json.loads(plan(["deploy/compact/start.sh"]).to_json()) + needs = { + "plan": {"result": "success"}, + "quick": {"result": "success"}, + "compose": {"result": "success"}, + } + assert not ci_summary.evaluate(selected, needs)[0] + assert not ci_summary.evaluate(selected, {**needs, "compact": {"result": "failure"}})[0] + assert ci_summary.evaluate(selected, {**needs, "compact": {"result": "success"}})[0] class FakeGitHub: @@ -171,15 +189,19 @@ def get(self, path: str) -> Any: @pytest.mark.parametrize( - "run_status,latest_id,expected_posts", + "run_status,latest_id,expected_posts,expected_checked", [ - ("in_progress", 42, 1), - ("completed", 42, 0), - ("in_progress", 43, 0), + ("in_progress", 42, 1, 0), + ("completed", 42, 0, 1), + ("in_progress", 43, 0, 0), ], ) -def test_delayed_pending_event_cannot_overwrite_terminal_status( - run_status: str, latest_id: int, expected_posts: int +def test_wakeup_reconciles_actual_run_state( + monkeypatch: pytest.MonkeyPatch, + run_status: str, + latest_id: int, + expected_posts: int, + expected_checked: int, ) -> None: class FakePublisher(FakeGitHub): repository = REPO @@ -223,12 +245,90 @@ def post(self, sha: str, state: str, description: str, run_id: int) -> None: "actions/runs/42": run, } ) - gate.publish_pending_run(gh, {"workflow_run": notified}, "main") + checked: list[int] = [] + monkeypatch.setattr( + gate, + "publish_checked_run", + lambda _gh, _notified, _pr, number, _current: checked.append(number), + ) + gate.publish_current_run(gh, {"workflow_run": notified}, "main") assert len(gh.posts) == expected_posts + assert len(checked) == expected_checked if expected_posts: assert gh.posts == [(SHA, "pending")] +def test_delayed_wakeup_reads_terminal_state_after_pending( + monkeypatch: pytest.MonkeyPatch, +) -> None: + checked: list[int] = [] + monkeypatch.setattr( + gate, + "publish_checked_run", + lambda _gh, _notified, _pr, number, _current: checked.append(number), + ) + + class FakePublisher(FakeGitHub): + repository = REPO + + def __init__(self, pages: dict[str, Any]) -> None: + super().__init__(pages) + self.posts: list[str] = [] + + def post(self, sha: str, state: str, description: str, run_id: int) -> None: + self.posts.append(state) + + pr = { + "state": "open", + "base": {"sha": BASE, "ref": "main", "repo": {"full_name": REPO}}, + "head": {"sha": SHA, "ref": "feature", "repo": {"full_name": REPO}}, + } + notified = { + "id": 42, + "run_attempt": 1, + "head_sha": SHA, + "display_title": "PR Validation active", + "repository": {"full_name": REPO}, + "pull_requests": [{"number": 7}], + } + latest = { + "id": 42, + "run_attempt": 1, + "display_title": "PR Validation active", + "head_branch": "feature", + "head_repository": {"full_name": REPO}, + } + run_path = ( + f"actions/workflows/pr-validation.yml/runs?event=pull_request&head_sha={SHA}&per_page=100" + ) + gh = FakePublisher( + { + "pulls/7": pr, + run_path: {"total_count": 1, "workflow_runs": [latest]}, + "actions/workflows/pr-validation.yml": {"id": 99}, + "actions/runs/42": {**run_for(plan()), "status": "in_progress", "run_attempt": 1}, + } + ) + gate.publish_current_run(gh, {"workflow_run": notified}, "main") + original_get = gh.get + reads = 0 + + def delayed_completion(path: str) -> Any: + nonlocal reads + if path == "actions/runs/42": + reads += 1 + if reads == 2: + gh.pages[path]["status"] = "completed" + return original_get(path) + + monkeypatch.setattr(gh, "get", delayed_completion) + monkeypatch.setattr(gate.time, "sleep", lambda _seconds: None) + gate.publish_current_run(gh, {"action": "completed", "workflow_run": notified}, "main") + assert gh.posts == ["pending"] + assert reads >= 2 + assert checked == [7] + + def test_latest_run_rejects_old_completion_and_ignores_irrelevant_label_run() -> None: pr = {"head": {"sha": SHA, "ref": "feature", "repo": {"full_name": REPO}}} runs = [ @@ -261,6 +361,58 @@ def test_latest_run_rejects_old_completion_and_ignores_irrelevant_label_run() -> assert gate.latest_run(gh, pr)["id"] == 13 +@pytest.mark.parametrize("changed_at", ["latest", "final_run"]) +def test_attempt_change_before_terminal_post_does_not_publish( + monkeypatch: pytest.MonkeyPatch, changed_at: str +) -> None: + selected = plan() + pr = { + "state": "open", + "merge_commit_sha": MERGE, + "base": {"sha": BASE, "ref": "main", "repo": {"full_name": REPO}}, + "head": {"sha": SHA, "ref": "feature", "repo": {"full_name": REPO}}, + "labels": [], + } + current = {"id": 42, "run_attempt": 1} + + class FakePublisher: + repository = REPO + + def __init__(self) -> None: + self.run_reads = 0 + self.posts: list[str] = [] + + def get(self, path: str) -> Any: + if path == "actions/workflows/pr-validation.yml": + return {"id": 99} + if path == "pulls/7": + return pr + if path == "actions/runs/42": + self.run_reads += 1 + attempt = 2 if changed_at == "final_run" and self.run_reads == 2 else 1 + return {**run_for(selected), "run_attempt": attempt} + raise AssertionError(path) + + def pr_plan(self, number: int, tested_sha: str) -> Any: + return selected + + def jobs(self, run_id: int) -> list[dict[str, Any]]: + return jobs_for(selected) + + def post(self, sha: str, state: str, description: str, run_id: int) -> None: + self.posts.append(state) + + gh = FakePublisher() + monkeypatch.setattr(gate, "validate_merge", lambda _gh, _pr: MERGE) + monkeypatch.setattr( + gate, + "latest_run", + lambda _gh, _pr: {"id": 42, "run_attempt": 2 if changed_at == "latest" else 1}, + ) + gate.publish_checked_run(gh, {"run_attempt": 1}, pr, 7, current) + assert gh.posts == [] + + def test_merge_commit_must_match_exact_base_and_head() -> None: pr = {"merge_commit_sha": MERGE, "base": {"sha": BASE}, "head": {"sha": SHA}} gh = FakeGitHub({f"commits/{MERGE}": {"parents": [{"sha": BASE}, {"sha": SHA}]}}) diff --git a/docs/development-efficiency-phase2.md b/docs/development-efficiency-phase2.md index 6c821824..f4747e7f 100644 --- a/docs/development-efficiency-phase2.md +++ b/docs/development-efficiency-phase2.md @@ -1,5 +1,11 @@ # 开发效率优化第二阶段实施记录 +## 合并后状态(2026-09-27) + +第一、二、三阶段分别由 PR #112、#113、#114 合入 `main@0ad736efef03e46c2cfc7cb0e710a7dcd0dab319`。以下各节保留实施时的本机验证快照,不表示当前分支尚未合并。PR #114 的远程样本中,Compose 作业 16:25、Security 作业 3:53、Frontend 完整作业 16:10;历史参考分别为 30:04、21:17、16:24。两组并非同提交同缓存条件的 A/B 实验,不计算固定提速率。 + +基础镜像发布、不可变 digest 默认切换和跨工作流同一应用镜像产物复用仍未完成;现有收益主要来自构建分层及 GHA 缓存。缺少已授权镜像仓库或发布权限时,继续使用可验证的源码目标默认值,不填写不存在的 digest。本次合并后审计修复另见第三阶段记录,其代码和远程门禁须以该修复 PR 的实际结果为准。 + ## 范围与基线 本阶段从第一阶段提交 `ce21e7a74d69597d9c388904102012f265ac4876` 建立独立分支;第一阶段的定向测试入口保持不变。普通 Compose、Security 的触发条件和作业名保持不变,Compact RC 作业未调整。此次不改业务代码、数据库或发布审批。 @@ -54,8 +60,8 @@ Docker 与 k6 超时 fixture 改为 `exec sleep 5`,避免 shell 进程被终 由于另一工作区占用默认宿主端口,本机以临时 Compose override 建立独立项目 `flowtest-phase2`,给前后端分配随机本机端口,其他服务不暴露宿主端口;`up --no-build --wait` 的 15 个服务均健康。12 个由本仓库构建的运行容器 image ID 与已加载镜像相同,后端 `/api/v1/live` 与 `/api/v1/ready` 正常,前端返回 HTTP 200。Playwright 执行登录初始化及 1366×768 编辑器导航场景,2 passed(9.1 秒)。本次项目和卷在测试后清理,原有工作区未重启。该冒烟不替代 CI 中完整浏览器验收。 -尚未执行或无法宣称通过:CI linux/amd64 冷暖构建、GHA 缓存跨 runner 命中、Security 实际漏洞扫描、CI 完整 Compose/Playwright 验收、跨工作流同一镜像复用、基础镜像发布与 digest 清单。Security 与 Compose 仍分别构建当前 checkout 的应用镜像;若要跨工作流共享经扫描产物,需要第三阶段的门禁拓扑与可信产物传递设计。 +合并前尚未执行或无法宣称通过的项目包括:CI linux/amd64 冷暖构建、GHA 缓存跨 runner 命中、Security 实际漏洞扫描、CI 完整 Compose/Playwright 验收、跨工作流同一镜像复用、基础镜像发布与 digest 清单。其后 PR #114 的 Security 与 Compose 检查已提供实际远程样本,但缓存冷暖状态仍未知;跨工作流同一镜像产物复用和基础镜像发布仍未实施。 -`scripts/required_gate.py` 对 `.github/workflows/*` 变更要求受控 Bootstrap。此次两个工作流只改内部构建步骤,正常 PR 的 Required Gate 仍会因 CI 治理文件而阻断;不能用 `ci:light`、删除门禁或伪造检查绕过。需要项目维护者依既有 Bootstrap 流程纳入这两个工作流,并在适用的当前提交上重新运行必需检查。第一阶段提交也尚未进入 `main`,第二阶段分支依赖该提交;远程合并前应先处理这一依赖。 +合并前,`scripts/required_gate.py` 对 `.github/workflows/*` 变更要求受控 Bootstrap,且第二阶段依赖尚未合入的第一阶段。上述依赖已按顺序通过 PR #112、#113 交付;治理保护与唯一 `Required Gate` 已恢复。此段仅记录当时的交付前置条件,不应再次按“尚未合并”执行。 回退方式:工作流回到各自原有 `docker build` / `docker compose up --build` 步骤;Dockerfile 四个参数维持源码目标默认值,任何未发布或缺平台的基础镜像都不会成为默认依赖。禁用持久缓存只影响构建耗时,不影响必需测试和扫描。 diff --git a/docs/development-efficiency-phase3.md b/docs/development-efficiency-phase3.md index 4bd840c7..4ed4d09e 100644 --- a/docs/development-efficiency-phase3.md +++ b/docs/development-efficiency-phase3.md @@ -1,10 +1,10 @@ # 开发效率第三阶段:PR 验证路由与合并门禁 -## 交付状态(2026-09-27) +## 合并后状态与本次修正(2026-09-27) -本分支提供确定性计划器、自动 PR 验证链、影子汇总与受信状态发布器代码。本轮尚未把它们部署到默认分支,也没有真实 PR 运行证据。`main` 的有效 ruleset `main-required-gate`(id `21653796`)要求严格更新,并要求 GitHub Actions App(integration id `15368`)发布名为 `Required Gate` 的结果;没有 merge queue。当前默认分支仍运行旧的加标签控制器。治理文件变更受现有 Bootstrap 规则限制,本分支不能通过修改自身的门禁来给自己放行。 +三阶段代码已由 PR #112、#113、#114 顺序合并到 `main@0ad736efef03e46c2cfc7cb0e710a7dcd0dab319`。文档 PR #115 的最新 Head `abb9b4a8d1d53e6e08e5288aff396d7c56db995e` 通过自动 docs 路由,受信发布器给出正式 `Required Gate: success`;追加提交先使旧结果失效,无关标题编辑未覆盖有效结果。#115 已关闭,验证文件未合入。该证据仅覆盖文档路径与这些时序,不代表其他路径或全部并发交错已在线验收。 -本分支以 `codex/dev-efficiency-phase2` 的 `31a11e4` 为起点;第一阶段定向测试入口和第二阶段 Bake/缓存代码在此 checkout 中可用,但尚未进入当时的 `origin/main`。因此第三阶段的线上启用依赖前两阶段的受控交付和本阶段的 Bootstrap 审核。 +`main-required-gate` ruleset(id `21653796`)仍严格要求 GitHub Actions App(integration id `15368`)发布唯一 `Required Gate`,无 bypass、无 merge queue。合并后审计发现迁移/构建输入漏检、Compact 无生产者、治理 PR 重复全量执行、源码加测试回退全侧及事件并发风险。本修复分支保留现有架构,修改须经新的受控 Bootstrap 与最新 Head 远程检查后才算部署;下文原始性能/本地验证数字均是此前分支快照。 ## 架构与可信边界 @@ -18,12 +18,14 @@ PR opened / synchronize / reopened / ready / 相关标签 / base 编辑 发布唯一 Required Gate Commit Status ``` -执行链使用只读 `pull_request` token。发布器只使用 `workflow_run` 的 requested/in_progress/completed 事件,只有读取元数据与写 Commit Status 的权限,不 checkout、不执行 PR 提供的脚本或产物,不恢复 PR 缓存。pending 绑定当前运行;延迟到达的 requested/in_progress 事件若发现运行已完成,就不再覆盖终态。正式 `Required Gate` 仍是 Commit Status,不能由同名 Check Run 替代。手动 `workflow_dispatch`、周期运行和影子结果都不发布该正式状态。 +执行链使用只读 `pull_request` token。发布器只使用 `workflow_run` 的 requested/in_progress/completed 事件,只有读取元数据与写 Commit Status 的权限,不 checkout、不执行 PR 提供的脚本或产物,不恢复 PR 缓存。本修正将这些事件统一当作唤醒,按 Head 串行化并保留待处理事件,每次读取当前 run/attempt 的实际状态后发布 pending 或终态。正式 `Required Gate` 仍是 Commit Status,不能由同名 Check Run 替代。手动 `workflow_dispatch`、周期运行和影子结果都不发布该正式状态。 `workflow_run` 必须来自本仓库的 `PR Validation` 工作流和指定运行实例;发布器核对当前 PR 的 base/head、合并提交双亲、运行 ID/attempt、工作流 ID、Head、计划指纹和本轮 plan/shadow job 的实际成功结果。旧运行晚完成、标签切换或基线变化时,指纹或实例不匹配就不发布 success。所需 job 由影子汇总在同一 run 的 `needs` 中逐项判断;`skipped`、`cancelled`、`failure`、`missing` 不算通过。计划读取失败保持阻断。GitHub strict up-to-date 仍负责默认分支自行前进后的合并前更新要求。 治理保护包含 `.github/workflows/`、`.github/actions/`、AGENTS/CONTRIBUTING/Makefile/PR 模板、CI 说明、计划器、聚合器、发布器和定向测试包装脚本。改动这些文件时计划增加 `policy` 必需项,影子汇总及受信发布器均拒绝自行放行,必须走受控 Bootstrap。 +本修正将决定镜像目标与缓存的 `backend/docker-bake.ci.hcl` 纳入同一治理清单。治理 PR 的普通链路只计算计划并明确报告 `bootstrap_only`,不再与专用 Bootstrap 链路同时运行两套完整作业;若同一 PR 还改动 Compact,仍执行独立的 Compact 兼容验收。专用链路按 PR 号取消旧 Head 的在途检查。正式状态仍只由默认分支发布器写入。 + ## 检查矩阵 | 最低档位 | 典型输入 | 本轮任务 | @@ -37,7 +39,9 @@ PR opened / synchronize / reopened / ready / 相关标签 / base 编辑 标准档后端定向入口禁用覆盖率,全侧回退也用 `--no-cov`;完整 Backend CI 的 90% 与 Frontend CI 的 80% 覆盖率阈值维持原样。依赖审计由 Security CI 执行,避免 Backend/Frontend full 作业重复执行同一审计。 -`ci:light` 保留为兼容请求,不能降低代码变更的最低档位;`ci:milestone` 请求 full;双标签按较重请求执行并在原因中说明。相关标签增删会重新验证。无关标签和普通标题/正文修改不会执行 plan 或取消在途检查。目标分支编辑触发重新计划;默认分支自身前进由 strict up-to-date 阻止旧基线合并,不声称普通 `edited` 会覆盖它。 +本修正把 Alembic 实际配置的 `backend/migrations/` 与 `backend/alembic.ini` 纳入迁移/升级/Standalone 检查,并按实际 Dockerfile、补丁、dockerignore、Bake、nginx 和镜像清单选择构建、扫描及启动检查。标准档的已知源码映射与 PR 明确改动的测试文件取并集;删除/重命名测试由 PR 文件元数据标记并回退完整侧测试,未知源码或共享 fixture 也保持回退。受信发布器不依赖自己 checkout 中的测试文件是否存在来重算计划。 + +`ci:light` 保留为兼容请求,不能降低代码变更的最低档位;`ci:milestone` 请求 full;双标签按较重请求执行并在原因中说明。相关标签增删与新 Head 使用同一 PR 活动并发组,较新运行替代旧运行;无关标签和普通标题/正文修改使用独立 ignored 组,不取消有效检查。目标分支编辑触发重新计划;默认分支自身前进由 strict up-to-date 阻止旧基线合并,不声称普通 `edited` 会覆盖它。 PR 验证运行将无关标签/元数据编辑标记为 `PR Validation ignored`,发布器只选 `PR Validation active` 运行。这样即使无关事件的全跳过 run 晚完成,也不会让有效运行失去最终汇总机会。 @@ -48,15 +52,15 @@ PR 验证运行将无关标签/元数据编辑标记为 `PR Validation ignored` - 周期回归:`Full Regression` 每周一 03:00 UTC 在受信默认分支运行一次,也允许手动启动。Compose 的 Compact/容量 RC 检查仍使用现有 `Compose Smoke Test` 的 `run_rc_gates` 手动开关;不把它伪装成普通 PR 的结果。 - `main` 上没有每次 push 无条件重复跑整套发布矩阵。周期结果不替代候选提交需要的当前验证,漏洞库扫描也不按源码 SHA 跳过。 -`deploy/compact/` 的自动路由明确加入 `compact` 必需项。现有 Compact RC 作业写明须在 P0/P1 复审清除后才手动执行;本阶段没有获得解除该限制的证据,因此自动链路不把普通 Compose smoke 冒充 Compact 通过,`compact` 当前保持缺失并阻断此类 PR。维护者完成复审并设计可关联当前 PR 的受信 RC 入口前,该路径不能由新门禁放行。 +`deploy/compact/` 的自动路由要求 `compact`。本修正为普通 Compact 变更新增当前 PR 内的六服务启动与业务流兼容 job,汇总器只接受该 job 的实际成功;普通 Compose smoke 不能替代它。原有备份/恢复、离线升级、容量与稳定性 RC 仍只在 P0/P1 复审清除后通过手动入口执行,自动兼容 job 不解除这项人工限制。 ## 验证、性能与激活 -本地已核对规则集和原有工作流,计划/发布器单元测试覆盖 docs、局部前后端、引擎、权限、迁移、依赖、部署、Skill、治理路径、重命名与分页、标签最低档、聚合异常、旧 run、手动运行边界。实际命令与结果以交付记录为准;尚无新工作流在 GitHub 上的运行、消耗分钟数或缓存命中数据,不宣称固定提速。预期省去的工作是文档 PR 的应用构建、局部前端 PR 的无关后端升级与 Windows 打包,以及旧控制器最长 90 分钟的轮询 runner 占用;标准档回退完整侧测试时仍需执行该侧测试。不能把并行 job 时间相加作为等待时间。 +以下为 PR #114 合并前的本地验证快照:计划/发布器单元测试覆盖 docs、局部前后端、引擎、权限、迁移、依赖、部署、Skill、治理路径、重命名与分页、标签最低档、聚合异常、旧 run、手动运行边界。后续远程运行记录见 PR #114;新审计修正须另看当前 PR 的检查。标准档未知源码仍回退完整侧测试,不能把并行 job 时间相加作为等待时间。 本地条件为 macOS、已有 uv 与 pnpm 依赖缓存,不代表 GitHub runner 的冷启动性能。集成期间一次完整后端 `pytest -q` 通过,墙钟 181.33 秒、覆盖率 90.67%(阈值 90%);之后针对最终规则改动复跑 `pytest --no-cov tests/test_ci_plan.py tests/test_required_gate.py -q`,59 项通过。后端 `ruff format --check`、`ruff check`、`mypy app ../scripts/*.py`、`lint-imports` 通过。Skills 契约测试和 `build_skill_evaluation.py --check` 通过。前端 `pnpm format:check`、`pnpm lint`、`pnpm test:coverage`、`pnpm build` 通过;覆盖率测试为 88 个文件、425 项,Vitest 报告历时 172.65 秒,语句 85.71%、分支 80.72%。11 个工作流经 PyYAML 解析;本机无 actionlint,GitHub 平台语法及实际 job 命名尚未验证。Compose、Windows、历史升级及真实 PR E2E 未在本轮本地执行。 -受控启用步骤: +PR #114 的历史受控启用步骤及后续复测清单: 1. 先确认前两阶段依赖已经在受信基线,核对本分支相对最新 `main` 的差异及 Bootstrap 规则。 2. 维护者按现有 Bootstrap 治理流程审查并合入本阶段工作流、计划器与发布器。临时要求精确 Head 的 Bootstrap 子检查时,维持 GitHub Actions App 来源、strict 和无 bypass;普通合并后立即恢复唯一的 `Required Gate`,全程不关闭保护。 @@ -64,4 +68,4 @@ PR 验证运行将无关标签/元数据编辑标记为 `PR Validation ignored` 3. 在代表性文档、局部功能、关键修改 PR 上观察 `PR Validation` 与 `PR Validation Shadow` 的实际 job 名、运行 ID、被测 SHA、跳过状态及发布器 Commit Status;再做一次追加提交、标签变化、失败后重跑,并确认旧运行不能覆盖新结果。 4. 只有平台实测证明 `Required Gate` 的来源和状态被现有 ruleset 正确识别时才宣布激活。如工作流未触发或 publisher 无法可靠关联运行,保留保护并回滚到旧控制器/标签入口;不要以手动诊断成功补写 PR success。 -当前未完成:Bootstrap 合并、上述真实 PR 事件验证、正式门禁显示与约束验证、Compact RC 的自动可信关联、跨平台/Compose 全量执行、实测耗时比较。代码提交与本地通过不等于门禁激活或发布验收完成。 +PR #114 的 Bootstrap 合并与文档 PR #115 的正式门禁激活已完成。仍未完成:本次审计修复的远程 PR 验证、普通 Compact 兼容 job 的真实成功/失败样本、完整 Compact RC 的自动可信关联、更多功能路径与并发事件验收、基础镜像发布和跨工作流同产物复用。代码提交与本地通过不等于这些剩余项的发布验收。 diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 94960deb..5e73ad3d 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -40,6 +40,7 @@ "docs/development-efficiency-phase3.md", "backend/tests/test_ci_plan.py", "backend/tests/test_required_gate.py", + "backend/docker-bake.ci.hcl", "scripts/build_skill_evaluation.py", "scripts/ci_plan.py", "scripts/ci_summary.py", @@ -87,7 +88,25 @@ "backend/app/api/v1/endpoints/executions.py", "backend/app/models/", "backend/app/repositories/", - "backend/alembic/", + "backend/migrations/", +) +MIGRATION_PREFIXES = ("backend/migrations/", "backend/app/schemas/") +BACKEND_IMAGE_INPUTS = frozenset({"backend/Dockerfile", "backend/.dockerignore"}) +FRONTEND_IMAGE_INPUTS = frozenset( + {"frontend/Dockerfile", "frontend/.dockerignore", "frontend/nginx.conf"} +) +DEPLOY_IMAGE_INPUTS = frozenset( + { + "compose.yaml", + "mock-target/Dockerfile", + "mock-target/.dockerignore", + "deploy/postgres-walg/Dockerfile", + "deploy/compact/compose.yaml", + "deploy/compact/compose.build.yaml", + "deploy/compact/images.env.example", + "deploy/compatibility/compose.yaml", + "deploy/upgrade/compose.yaml", + } ) BACKEND_TEST_MAP: dict[str, tuple[str, ...]] = { "backend/app/services/imports.py": ("tests/test_imports_api.py",), @@ -188,6 +207,9 @@ def classify(self, path: str) -> None: self.reasons.add(f"未知路径保守执行完整验收: {path}") def deploy(self, path: str) -> None: + if path in DEPLOY_IMAGE_INPUTS: + self.domains.add("security") + self.reasons.add("镜像清单或构建输入需扫描") if path.startswith("deploy/standalone/"): self.tier = _tier_max(self.tier, "integration") self.domains.update(("windows", "backend", "frontend")) @@ -199,7 +221,7 @@ def deploy(self, path: str) -> None: elif path.startswith("deploy/compact/"): self.tier = _tier_max(self.tier, "integration") self.domains.update(("compose", "compact")) - self.reasons.add("Compact RC 仍需现有人工复审入口") + self.reasons.add("Compact 基础兼容验收;完整 RC 仍需人工复审") else: self.tier = _tier_max(self.tier, "integration") self.domains.add("compose") @@ -215,9 +237,12 @@ def frontend(self, path: str) -> None: if path.startswith(("frontend/src/lib/api", "frontend/src/features/workflows/")): self.domains.add("compose") self.reasons.add("前端 API/流程契约需端到端冒烟") - if path in {"frontend/package.json", "frontend/pnpm-lock.yaml"} or "Dockerfile" in path: + if ( + path in {"frontend/package.json", "frontend/pnpm-lock.yaml"} + or path in FRONTEND_IMAGE_INPUTS + ): self.tier = _tier_max(self.tier, "integration") - self.domains.add("security") + self.domains.update(("security", "compose")) self.reasons.add("前端依赖或镜像输入") def backend(self, path: str) -> None: @@ -228,7 +253,7 @@ def backend(self, path: str) -> None: self.tier = _tier_max(self.tier, "integration") self.domains.update(("compose", "frontend", "security")) self.reasons.add("认证、授权与租户边界需集成消费者和安全检查") - if path.startswith(BACKEND_CRITICAL): + if path.startswith(BACKEND_CRITICAL) or path == "backend/alembic.ini": self.tier = _tier_max(self.tier, "integration") self.domains.add("compose") self.reasons.add("关键后端运行或数据路径") @@ -238,26 +263,60 @@ def backend(self, path: str) -> None: if path.startswith(("backend/app/schemas/", "backend/app/api/v1/endpoints/auth")): self.domains.add("frontend") self.reasons.add("共享 API 契约需前端消费者验证") - if path.startswith( - ("backend/alembic/", "backend/app/models/", "backend/app/repositories/") + if path == "backend/alembic.ini" or path.startswith( + (*MIGRATION_PREFIXES, "backend/app/models/", "backend/app/repositories/") ): self.domains.update(("upgrade", "windows")) self.reasons.add("数据模型与历史升级/Standalone 兼容") - if path in {"backend/pyproject.toml", "backend/uv.lock"} or "Dockerfile" in path: + if ( + path in {"backend/pyproject.toml", "backend/uv.lock"} + or path in BACKEND_IMAGE_INPUTS + or path.startswith("backend/patches/") + ): self.tier = _tier_max(self.tier, "integration") self.domains.update(("security", "compose")) self.reasons.add("后端依赖或镜像输入") -def _mapped_targets(sources: list[str], mapping: dict[str, tuple[str, ...]]) -> tuple[str, ...]: - selected = [mapping.get(path) for path in sources] - if not selected or not all(selected): +def _direct_test_target(path: str, project: str) -> str | None: + prefix = f"{project}/tests/" if project == "backend" else "frontend/src/" + if not path.startswith(prefix): + return None + name = Path(path).name + valid = ( + name.startswith("test_") and name.endswith(".py") + if project == "backend" + else name.endswith((".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")) + ) + if valid: + return path.removeprefix(f"{project}/") + return None + + +def _mapped_targets( + sources: list[str], + mapping: dict[str, tuple[str, ...]], + project: str, + removed_paths: frozenset[str], +) -> tuple[str, ...]: + if not sources: return () - return tuple(sorted({target for group in selected if group for target in group})) + targets: set[str] = set() + for path in sources: + if path in removed_paths: + return () + selected = mapping.get(path) + if selected: + targets.update(selected) + elif direct := _direct_test_target(path, project): + targets.add(direct) + else: + return () # Unknown source or shared test input requires the full side. + return tuple(sorted(targets)) def _required_jobs( - routing: Routing, tier: str + routing: Routing, tier: str, removed_paths: frozenset[str] ) -> tuple[set[str], tuple[str, ...], tuple[str, ...]]: required = {"quick"} backend_targets: tuple[str, ...] = () @@ -269,10 +328,14 @@ def _required_jobs( else: if "backend" in routing.domains: required.add("backend-full" if tier == "integration" else "backend-standard") - backend_targets = _mapped_targets(routing.backend_sources, BACKEND_TEST_MAP) + backend_targets = _mapped_targets( + routing.backend_sources, BACKEND_TEST_MAP, "backend", removed_paths + ) if "frontend" in routing.domains: required.add("frontend-full" if tier == "integration" else "frontend-standard") - frontend_targets = _mapped_targets(routing.frontend_sources, FRONTEND_TEST_MAP) + frontend_targets = _mapped_targets( + routing.frontend_sources, FRONTEND_TEST_MAP, "frontend", removed_paths + ) required.update( routing.domains & {"compose", "security", "windows", "upgrade", "compact", "skills"} ) @@ -298,11 +361,28 @@ def _paths_from_files(files: list[dict[str, Any]]) -> list[str]: return paths +def _removed_paths_from_files(files: list[dict[str, Any]]) -> frozenset[str]: + removed = [ + item["previous_filename"] if item.get("status") == "renamed" else item["filename"] + for item in files + if item.get("status") in {"removed", "renamed"} + ] + return frozenset(removed) + + def build_plan( - paths: list[str], labels: set[str], *, base_sha: str, head_sha: str, tested_sha: str + paths: list[str], + labels: set[str], + *, + base_sha: str, + head_sha: str, + tested_sha: str, + removed_paths: frozenset[str] = frozenset(), ) -> Plan: if not paths or any(not isinstance(path, str) or not path for path in paths): raise PlanError("变更路径为空或无效,不能推断为文档变更") + if not removed_paths <= set(paths): + raise PlanError("删除路径不在 PR 文件清单中") if any( len(sha) != 40 or any(c not in "0123456789abcdef" for c in sha) for sha in (base_sha, head_sha, tested_sha) @@ -320,7 +400,7 @@ def build_plan( routing.reasons.add("ci:light 保留自动最低档位") if {"ci:milestone", "ci:light"} <= labels: routing.reasons.add("两个 CI 标签同时存在,采用较重请求") - required, backend_targets, frontend_targets = _required_jobs(routing, tier) + required, backend_targets, frontend_targets = _required_jobs(routing, tier, removed_paths) return Plan( base_sha, head_sha, @@ -384,7 +464,7 @@ def get(self, path: str) -> dict[str, Any] | list[Any]: except (HTTPError, URLError, ValueError) as exc: raise PlanError("GitHub 元数据读取失败") from exc - def changed_paths(self, number: int, changed_count: object) -> list[str]: + def changed_paths(self, number: int, changed_count: object) -> tuple[list[str], frozenset[str]]: if not isinstance(changed_count, int) or changed_count <= 0 or changed_count >= 3000: raise PlanError("PR 文件清单为空或达到 GitHub API 上限") files: list[dict[str, Any]] = [] @@ -401,13 +481,13 @@ def changed_paths(self, number: int, changed_count: object) -> list[str]: raise PlanError("PR 文件清单与 changed_files 不一致") if len({item.get("filename") for item in files}) != changed_count: raise PlanError("PR 文件分页重复或不完整") - return _paths_from_files(files) + return _paths_from_files(files), _removed_paths_from_files(files) def pr_plan(self, number: int, tested_sha: str) -> Plan: pr = self.get(f"pulls/{number}") if not isinstance(pr, dict) or pr.get("state") != "open": raise PlanError("PR 元数据无效或 PR 未开放") - paths = self.changed_paths(number, pr.get("changed_files")) + paths, removed_paths = self.changed_paths(number, pr.get("changed_files")) current = self.get(f"pulls/{number}") if not isinstance(current, dict) or plan_source_identity(current) != plan_source_identity( pr @@ -424,6 +504,7 @@ def pr_plan(self, number: int, tested_sha: str) -> Plan: base_sha=pr["base"]["sha"], head_sha=pr["head"]["sha"], tested_sha=tested_sha, + removed_paths=removed_paths, ) diff --git a/scripts/ci_summary.py b/scripts/ci_summary.py index a20e255d..835152e5 100644 --- a/scripts/ci_summary.py +++ b/scripts/ci_summary.py @@ -48,7 +48,11 @@ def evaluate(plan: dict[str, Any] | None, needs: dict[str, Any]) -> tuple[bool, f"policy=scripts/ci_plan.py@{plan.get('policy_digest')}", f"档位: {plan.get('tier')};原因: {', '.join(plan.get('reasons', []))}", ] - valid = not plan.get("governance_change", False) + if plan.get("governance_change"): + lines.extend(f"{key}: bootstrap_only" for key in ALL_JOBS if key in required) + lines.append("治理文件需 Bootstrap 受信审核;普通链路不重复执行重型检查") + return False, lines + valid = True for key in ALL_JOBS: if key in required: result = needs.get(key, {}).get("result", "missing") @@ -56,8 +60,6 @@ def evaluate(plan: dict[str, Any] | None, needs: dict[str, Any]) -> tuple[bool, valid &= result == "success" else: lines.append(f"{key}: not_applicable") - if plan.get("governance_change"): - lines.append("治理文件需 Bootstrap 受信审核") return valid, lines diff --git a/scripts/required_gate.py b/scripts/required_gate.py index 4942df28..906180d4 100644 --- a/scripts/required_gate.py +++ b/scripts/required_gate.py @@ -13,6 +13,7 @@ import json import os import sys +import time from pathlib import Path from typing import Any, cast from urllib.error import HTTPError, URLError @@ -237,17 +238,23 @@ def current_event_run( return notified, pr, number, current -def publish_pending_run(gh: Publisher, event: dict[str, Any], default_branch: str) -> None: +def publish_current_run(gh: Publisher, event: dict[str, Any], default_branch: str) -> None: resolved = current_event_run(gh, event, default_branch) if resolved is None: return - notified, pr, _, current = resolved + notified, pr, number, current = resolved workflow = gh.get("actions/workflows/pr-validation.yml") run = gh.get(f"actions/runs/{current['id']}") + if event.get("action") == "completed": + for _ in range(3): + if isinstance(run, dict) and run.get("status") == "completed": + break + time.sleep(2) + run = gh.get(f"actions/runs/{current['id']}") + if not isinstance(run, dict) or run.get("status") != "completed": + raise GateError("完成事件对应的运行尚未进入终态") if not isinstance(workflow, dict) or not isinstance(run, dict): raise GateError("运行身份不可用") - if run.get("status") == "completed": - return # A delayed requested/in_progress event cannot replace a final result. if ( run.get("workflow_id") != workflow.get("id") or run.get("id") != notified.get("id") @@ -255,16 +262,11 @@ def publish_pending_run(gh: Publisher, event: dict[str, Any], default_branch: st or run.get("path", "").split("@")[0] != WORKFLOW or run.get("event") != "pull_request" ): - raise GateError("pending 运行身份不匹配") - gh.post(pr["head"]["sha"], "pending", "等待当前 PR 验证与受信汇总", current["id"]) - - -def publish_complete(gh: Publisher, event: dict[str, Any], default_branch: str) -> None: - resolved = current_event_run(gh, event, default_branch) - if resolved is None: - return - notified, pr, number, current = resolved - publish_checked_run(gh, notified, pr, number, current) + raise GateError("运行身份不匹配") + if run.get("status") == "completed": + publish_checked_run(gh, notified, pr, number, current) + else: + gh.post(pr["head"]["sha"], "pending", "等待当前 PR 验证与受信汇总", current["id"]) def publish_checked_run( @@ -294,7 +296,15 @@ def publish_checked_run( refreshed ) != pr_validation_identity(pr): return # A new PR state must get its own run and status. - if latest_run(gh, pr).get("id") != current["id"]: + latest = latest_run(gh, pr) + if latest.get("id") != current["id"] or latest.get("run_attempt") != current.get("run_attempt"): + return + final_run = gh.get(f"actions/runs/{current['id']}") + if ( + not isinstance(final_run, dict) + or final_run.get("run_attempt") != current.get("run_attempt") + or final_run.get("status") != "completed" + ): return gh.post(plan.head_sha, "success" if valid else "failure", description, current["id"]) if not valid: @@ -310,12 +320,9 @@ def main() -> int: gh = Publisher(repository, token) if os.environ["GITHUB_EVENT_NAME"] != "workflow_run": raise GateError("不支持的门禁事件") - if event.get("action") == "completed": - publish_complete(gh, event, default_branch) - elif event.get("action") in {"requested", "in_progress"}: - publish_pending_run(gh, event, default_branch) - else: + if event.get("action") not in {"requested", "in_progress", "completed"}: raise GateError("不支持的 workflow_run 活动类型") + publish_current_run(gh, event, default_branch) except (GateError, KeyError, ValueError, PlanError) as exc: print(f"Required Gate: {exc}", file=sys.stderr) return 1 From c45181a12c74f1f093aeca48a8383c4d56d3146a Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:02:01 +0800 Subject: [PATCH 2/4] ci: scan Postgres WALG image in security gate --- .github/workflows/required-gate.yml | 2 ++ .github/workflows/security-ci.yml | 11 ++++++++++- backend/docker-bake.ci.hcl | 2 +- backend/tests/test_ci_plan.py | 17 +++++++++++++++++ docs/development-efficiency-phase3.md | 2 +- 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/required-gate.yml b/.github/workflows/required-gate.yml index 52639c27..4a16f18e 100644 --- a/.github/workflows/required-gate.yml +++ b/.github/workflows/required-gate.yml @@ -7,6 +7,8 @@ on: # Serialize all wakeups for a candidate Head. Keep completed wakeups queued so # a late pending handler cannot replace the terminal result. +# GitHub documents queue: max for workflow-level concurrency: +# https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency concurrency: group: required-gate-${{ github.event.workflow_run.head_sha }} queue: max diff --git a/.github/workflows/security-ci.yml b/.github/workflows/security-ci.yml index 8a999bcc..52dd6585 100644 --- a/.github/workflows/security-ci.yml +++ b/.github/workflows/security-ci.yml @@ -75,7 +75,7 @@ jobs: for image in \ flowtest-backend:ci flowtest-frontend:ci flowtest-performance:ci \ flowtest-environment:ci flowtest-environment-daemon:ci \ - flowtest-runner:ci flowtest-mock-target:ci; do + flowtest-runner:ci flowtest-mock-target:ci flowtest-postgres-walg:17.6-v3.0.8; do test "$(docker image inspect "${image}" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = "${BUILD_SHA}" test "$(docker image inspect "${image}" --format '{{.Os}}/{{.Architecture}}')" = 'linux/amd64' docker image inspect "${image}" --format '{{.RepoTags}} {{.Id}} {{.Os}}/{{.Architecture}}' @@ -156,3 +156,12 @@ jobs: output-format: table grype-version: v0.116.1 config: .grype.yaml + - name: Scan Postgres WALG image + uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0 + with: + image: flowtest-postgres-walg:17.6-v3.0.8 + severity-cutoff: high + only-fixed: true + output-format: table + grype-version: v0.116.1 + config: .grype.yaml diff --git a/backend/docker-bake.ci.hcl b/backend/docker-bake.ci.hcl index 0be79079..636b8cf3 100644 --- a/backend/docker-bake.ci.hcl +++ b/backend/docker-bake.ci.hcl @@ -73,7 +73,7 @@ target "postgres" { } group "ci-security" { - targets = ["backend", "frontend", "worker-performance", "worker-environment", "environment-docker", "runner-agent-a", "mock-target"] + targets = ["backend", "frontend", "worker-performance", "worker-environment", "environment-docker", "runner-agent-a", "mock-target", "postgres"] } group "ci-compose" { diff --git a/backend/tests/test_ci_plan.py b/backend/tests/test_ci_plan.py index 92c843fd..4f9d24b8 100644 --- a/backend/tests/test_ci_plan.py +++ b/backend/tests/test_ci_plan.py @@ -135,6 +135,23 @@ def test_governance_and_compact_change_still_requires_compact() -> None: assert {"policy", "compact"} <= set(selected.required) +def test_postgres_walg_image_is_built_verified_and_scanned() -> None: + root = Path(__file__).parents[2] + bake = (root / "backend/docker-bake.ci.hcl").read_text() + security_group = bake.split('group "ci-security" {', 1)[1].split("}", 1)[0] + assert '"postgres"' in security_group + workflow = yaml.load( + (root / ".github/workflows/security-ci.yml").read_text(), Loader=yaml.BaseLoader + ) + steps = workflow["jobs"]["source-and-images"]["steps"] + identity_step = next( + step for step in steps if step.get("name") == "Verify scanned image identities" + ) + assert "flowtest-postgres-walg:17.6-v3.0.8" in identity_step["run"] + scan = next(step for step in steps if step.get("name") == "Scan Postgres WALG image") + assert scan["with"]["image"] == "flowtest-postgres-walg:17.6-v3.0.8" + + def test_renamed_image_input_uses_both_directories() -> None: paths = ci_plan._paths_from_files( [ diff --git a/docs/development-efficiency-phase3.md b/docs/development-efficiency-phase3.md index 4ed4d09e..59a66026 100644 --- a/docs/development-efficiency-phase3.md +++ b/docs/development-efficiency-phase3.md @@ -39,7 +39,7 @@ PR opened / synchronize / reopened / ready / 相关标签 / base 编辑 标准档后端定向入口禁用覆盖率,全侧回退也用 `--no-cov`;完整 Backend CI 的 90% 与 Frontend CI 的 80% 覆盖率阈值维持原样。依赖审计由 Security CI 执行,避免 Backend/Frontend full 作业重复执行同一审计。 -本修正把 Alembic 实际配置的 `backend/migrations/` 与 `backend/alembic.ini` 纳入迁移/升级/Standalone 检查,并按实际 Dockerfile、补丁、dockerignore、Bake、nginx 和镜像清单选择构建、扫描及启动检查。标准档的已知源码映射与 PR 明确改动的测试文件取并集;删除/重命名测试由 PR 文件元数据标记并回退完整侧测试,未知源码或共享 fixture 也保持回退。受信发布器不依赖自己 checkout 中的测试文件是否存在来重算计划。 +本修正把 Alembic 实际配置的 `backend/migrations/` 与 `backend/alembic.ini` 纳入迁移/升级/Standalone 检查,并按实际 Dockerfile、补丁、dockerignore、Bake、nginx 和镜像清单选择构建、扫描及启动检查。Security 的 Bake 目标、镜像身份核对和漏洞扫描同时覆盖 Postgres WALG 镜像。标准档的已知源码映射与 PR 明确改动的测试文件取并集;删除/重命名测试由 PR 文件元数据标记并回退完整侧测试,未知源码或共享 fixture 也保持回退。受信发布器不依赖自己 checkout 中的测试文件是否存在来重算计划。 `ci:light` 保留为兼容请求,不能降低代码变更的最低档位;`ci:milestone` 请求 full;双标签按较重请求执行并在原因中说明。相关标签增删与新 Head 使用同一 PR 活动并发组,较新运行替代旧运行;无关标签和普通标题/正文修改使用独立 ignored 组,不取消有效检查。目标分支编辑触发重新计划;默认分支自身前进由 strict up-to-date 阻止旧基线合并,不声称普通 `edited` 会覆盖它。 From b35975a869adfa5e84327994860d5740c1caf39b Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:30:02 +0800 Subject: [PATCH 3/4] build: patch and scan Postgres WALG image --- .github/workflows/compose-ci.yml | 2 +- .github/workflows/security-ci.yml | 4 +-- backend/tests/test_ci_plan.py | 14 +++++++-- compose.yaml | 2 +- deploy/postgres-walg/Dockerfile | 45 +++++++++++++-------------- docs/development-efficiency-phase3.md | 2 +- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/.github/workflows/compose-ci.yml b/.github/workflows/compose-ci.yml index ee466f05..7587e530 100644 --- a/.github/workflows/compose-ci.yml +++ b/.github/workflows/compose-ci.yml @@ -249,7 +249,7 @@ jobs: flowtest-backend flowtest-worker flowtest-worker-data flowtest-worker-ai flowtest-beat \ flowtest-frontend flowtest-worker-performance flowtest-worker-environment \ flowtest-environment-docker \ - flowtest-mock-target flowtest-grpc-target flowtest-postgres-walg:17.6-v3.0.8; do + flowtest-mock-target flowtest-grpc-target flowtest-postgres-walg:17.11-v3.0.9-patched; do test "$(docker image inspect "${image}" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = "${BUILD_SHA}" test "$(docker image inspect "${image}" --format '{{.Os}}/{{.Architecture}}')" = 'linux/amd64' docker image inspect "${image}" --format '{{.RepoTags}} {{.Id}} {{.Os}}/{{.Architecture}}' diff --git a/.github/workflows/security-ci.yml b/.github/workflows/security-ci.yml index 52dd6585..acc0759b 100644 --- a/.github/workflows/security-ci.yml +++ b/.github/workflows/security-ci.yml @@ -75,7 +75,7 @@ jobs: for image in \ flowtest-backend:ci flowtest-frontend:ci flowtest-performance:ci \ flowtest-environment:ci flowtest-environment-daemon:ci \ - flowtest-runner:ci flowtest-mock-target:ci flowtest-postgres-walg:17.6-v3.0.8; do + flowtest-runner:ci flowtest-mock-target:ci flowtest-postgres-walg:17.11-v3.0.9-patched; do test "$(docker image inspect "${image}" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = "${BUILD_SHA}" test "$(docker image inspect "${image}" --format '{{.Os}}/{{.Architecture}}')" = 'linux/amd64' docker image inspect "${image}" --format '{{.RepoTags}} {{.Id}} {{.Os}}/{{.Architecture}}' @@ -159,7 +159,7 @@ jobs: - name: Scan Postgres WALG image uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0 with: - image: flowtest-postgres-walg:17.6-v3.0.8 + image: flowtest-postgres-walg:17.11-v3.0.9-patched severity-cutoff: high only-fixed: true output-format: table diff --git a/backend/tests/test_ci_plan.py b/backend/tests/test_ci_plan.py index 4f9d24b8..80a6d5ba 100644 --- a/backend/tests/test_ci_plan.py +++ b/backend/tests/test_ci_plan.py @@ -147,9 +147,19 @@ def test_postgres_walg_image_is_built_verified_and_scanned() -> None: identity_step = next( step for step in steps if step.get("name") == "Verify scanned image identities" ) - assert "flowtest-postgres-walg:17.6-v3.0.8" in identity_step["run"] + assert "flowtest-postgres-walg:17.11-v3.0.9-patched" in identity_step["run"] scan = next(step for step in steps if step.get("name") == "Scan Postgres WALG image") - assert scan["with"]["image"] == "flowtest-postgres-walg:17.6-v3.0.8" + assert scan["with"]["image"] == "flowtest-postgres-walg:17.11-v3.0.9-patched" + compose_workflow = yaml.load( + (root / ".github/workflows/compose-ci.yml").read_text(), Loader=yaml.BaseLoader + ) + compose_steps = compose_workflow["jobs"]["smoke"]["steps"] + compose_identity = next( + step + for step in compose_steps + if step.get("name") == "Verify loaded Compose image identities" + ) + assert scan["with"]["image"] in compose_identity["run"] def test_renamed_image_input_uses_both_directories() -> None: diff --git a/compose.yaml b/compose.yaml index c30dc9d5..946ce28a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -94,7 +94,7 @@ services: postgres: build: context: ./deploy/postgres-walg - image: flowtest-postgres-walg:17.6-v3.0.8 + image: flowtest-postgres-walg:17.11-v3.0.9-patched environment: POSTGRES_DB: ${POSTGRES_DB:-flowtest} POSTGRES_USER: ${POSTGRES_USER:-flowtest} diff --git a/deploy/postgres-walg/Dockerfile b/deploy/postgres-walg/Dockerfile index 181b7d80..8a7435e9 100644 --- a/deploy/postgres-walg/Dockerfile +++ b/deploy/postgres-walg/Dockerfile @@ -1,30 +1,29 @@ -FROM debian:bookworm-slim AS wal-g +# syntax=docker/dockerfile:1.7 -ARG TARGETARCH -ARG WAL_G_VERSION=v3.0.8 -ARG WAL_G_AMD64_SHA256=b0df1b484035eb5f131db7bbd303d1a460391848fdcce34ba1e0a564cca493e9 -ARG WAL_G_ARM64_SHA256=048b5033c34c1487dc1fccc0572db030e323230984ccc093e80e4e4081c797d1 +# WAL-G v3.0.9 release binaries contain fixable Go vulnerabilities. Rebuild the +# pinned release source with patched Go dependencies until upstream ships them. +FROM golang:1.27.0-bookworm@sha256:ded31c68586d2e49e760acc2e65a884b23d032e9bbbed0ae0c55abd3fcaf4452 AS wal-g -RUN apt-get update \ - && apt-get install --yes --no-install-recommends ca-certificates curl \ - && detected_arch="${TARGETARCH:-$(dpkg --print-architecture)}" \ - && case "${detected_arch}" in \ - amd64) asset_arch="amd64"; checksum="${WAL_G_AMD64_SHA256}" ;; \ - arm64|aarch64) asset_arch="aarch64"; checksum="${WAL_G_ARM64_SHA256}" ;; \ - *) echo "Unsupported architecture: ${detected_arch}" >&2; exit 1 ;; \ - esac \ - && asset="wal-g-pg-22.04-${asset_arch}.tar.gz" \ - && curl --fail --location --show-error --silent \ - "https://github.com/wal-g/wal-g/releases/download/${WAL_G_VERSION}/${asset}" \ - --output "/tmp/${asset}" \ - && echo "${checksum} /tmp/${asset}" | sha256sum --check --strict \ - && tar --extract --gzip --file "/tmp/${asset}" --directory /tmp \ - && mv "/tmp/wal-g-pg-22.04-${asset_arch}" /usr/local/bin/wal-g \ - && chmod 0755 /usr/local/bin/wal-g - -FROM postgres:17.6-bookworm +ARG WAL_G_COMMIT=3e493188db28335fb93a45bd680c6ff8fc3028c1 + +RUN git clone --depth 1 --branch v3.0.9 --single-branch https://github.com/wal-g/wal-g.git /src \ + && test "$(git -C /src rev-parse HEAD)" = "${WAL_G_COMMIT}" + +WORKDIR /src +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go mod edit -go=1.27.0 \ + -require=golang.org/x/crypto@v0.57.0 \ + -require=google.golang.org/grpc@v1.83.2 \ + && go mod tidy \ + && CGO_ENABLED=0 go build -mod=readonly -trimpath \ + -ldflags "-s -w -X github.com/wal-g/wal-g/cmd/pg.walgVersion=v3.0.9-patched -X github.com/wal-g/wal-g/cmd/pg.gitRevision=${WAL_G_COMMIT}" \ + -o /usr/local/bin/wal-g ./main/pg + +FROM postgres:17.11-bookworm@sha256:639ab7ceb90e13123085b741fb31ef493fba25463002f6da665352e7b534b652 RUN apt-get update \ + && apt-get upgrade --yes --no-install-recommends \ && apt-get install --yes --no-install-recommends \ ca-certificates \ libbrotli1 \ diff --git a/docs/development-efficiency-phase3.md b/docs/development-efficiency-phase3.md index 59a66026..662afd17 100644 --- a/docs/development-efficiency-phase3.md +++ b/docs/development-efficiency-phase3.md @@ -39,7 +39,7 @@ PR opened / synchronize / reopened / ready / 相关标签 / base 编辑 标准档后端定向入口禁用覆盖率,全侧回退也用 `--no-cov`;完整 Backend CI 的 90% 与 Frontend CI 的 80% 覆盖率阈值维持原样。依赖审计由 Security CI 执行,避免 Backend/Frontend full 作业重复执行同一审计。 -本修正把 Alembic 实际配置的 `backend/migrations/` 与 `backend/alembic.ini` 纳入迁移/升级/Standalone 检查,并按实际 Dockerfile、补丁、dockerignore、Bake、nginx 和镜像清单选择构建、扫描及启动检查。Security 的 Bake 目标、镜像身份核对和漏洞扫描同时覆盖 Postgres WALG 镜像。标准档的已知源码映射与 PR 明确改动的测试文件取并集;删除/重命名测试由 PR 文件元数据标记并回退完整侧测试,未知源码或共享 fixture 也保持回退。受信发布器不依赖自己 checkout 中的测试文件是否存在来重算计划。 +本修正把 Alembic 实际配置的 `backend/migrations/` 与 `backend/alembic.ini` 纳入迁移/升级/Standalone 检查,并按实际 Dockerfile、补丁、dockerignore、Bake、nginx 和镜像清单选择构建、扫描及启动检查。Security 的 Bake 目标、镜像身份核对和漏洞扫描同时覆盖 Postgres WALG 镜像;新增扫描发现旧镜像存在可修复的高危项,因此固定 Postgres 17.11 基础镜像与 WAL-G v3.0.9 源码提交,用 Go 1.27.0 和已修补依赖重建。该镜像在本机使用与 CI 相同版本的 Grype 扫描为 High/Critical 0,正式结果仍以当前 PR 的远程检查为准。标准档的已知源码映射与 PR 明确改动的测试文件取并集;删除/重命名测试由 PR 文件元数据标记并回退完整侧测试,未知源码或共享 fixture 也保持回退。受信发布器不依赖自己 checkout 中的测试文件是否存在来重算计划。 `ci:light` 保留为兼容请求,不能降低代码变更的最低档位;`ci:milestone` 请求 full;双标签按较重请求执行并在原因中说明。相关标签增删与新 Head 使用同一 PR 活动并发组,较新运行替代旧运行;无关标签和普通标题/正文修改使用独立 ignored 组,不取消有效检查。目标分支编辑触发重新计划;默认分支自身前进由 strict up-to-date 阻止旧基线合并,不声称普通 `edited` 会覆盖它。 From 85db686abd06094123b85bf664c54cad7208482b Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:48:31 +0800 Subject: [PATCH 4/4] docs: explain PR-bound Compact compatibility check --- deploy/compact/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deploy/compact/README.md b/deploy/compact/README.md index f42e3e37..ef0b7e52 100644 --- a/deploy/compact/README.md +++ b/deploy/compact/README.md @@ -55,8 +55,9 @@ docker compose --env-file deploy/compact/.env -f deploy/compact/compose.yaml sto ``` 验收脚本要求恰好 6 个服务运行,并通过 PostgreSQL、Redis、MinIO Readiness 以及 `compact` 运行档位检查。 -独立 CI 还会执行 `scripts/smoke_s32.py`,覆盖真实登录、API/Workflow 发布、合并 Worker 执行和 -不可变 Snapshot。 +修改 `deploy/compact/` 的 PR 会自动运行当前提交的六服务启动和 `scripts/smoke_s32.py` 基础兼容验收, +覆盖真实登录、API/Workflow 发布、合并 Worker 执行和不可变 Snapshot。备份/恢复、离线升级、 +容量与稳定性完整 RC 仍走复审后的手动入口,不由这项 PR 兼容检查代替。 日常停止不要添加 `--volumes`,否则会删除业务数据。 ## 私有仓库与离线包