From 4a8e805b16edfef805ac475a543ea95d8430e7e8 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:05:00 +0800 Subject: [PATCH] Separate console CI routing and verification --- .github/workflows/ci.yml | 88 +++++++++++++-- tools/ci/tests/test_ci_change_scope.py | 148 +++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 tools/ci/tests/test_ci_change_scope.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6113ef876..d5121d392 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: runs-on: ubuntu-latest outputs: docs_only: ${{ steps.docs_only_route.outputs.docs_only }} + frontend_only: ${{ steps.docs_only_route.outputs.frontend_only }} all_changed: ${{ steps.filter.outputs.all_changed }} core_code: ${{ steps.filter.outputs.core_code }} product_runtime: ${{ steps.filter.outputs.product_runtime }} @@ -130,18 +131,29 @@ jobs: console_web: - 'apps/aevatar-console-web/**' - '.github/workflows/ci.yml' + frontend_paths: + - 'apps/aevatar-console-web/**' - - name: Resolve Doc-Only Route + - name: Resolve Change Scope id: docs_only_route env: ALL_CHANGED_COUNT: ${{ steps.filter.outputs.all_changed_count }} DOCS_ONLY_COUNT: ${{ steps.filter.outputs.docs_only_count }} + FRONTEND_CHANGED_COUNT: ${{ steps.filter.outputs.frontend_paths_count }} run: | if [ "${ALL_CHANGED_COUNT:-0}" != "0" ] && [ "${ALL_CHANGED_COUNT:-0}" = "${DOCS_ONLY_COUNT:-0}" ]; then echo "docs_only=true" >> "$GITHUB_OUTPUT" else echo "docs_only=false" >> "$GITHUB_OUTPUT" fi + if [ "${ALL_CHANGED_COUNT:-0}" != "0" ] && [ "${ALL_CHANGED_COUNT:-0}" = "${FRONTEND_CHANGED_COUNT:-0}" ]; then + echo "frontend_only=true" >> "$GITHUB_OUTPUT" + else + echo "frontend_only=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify Change Scope Routing + run: python3 tools/ci/tests/test_ci_change_scope.py fkst-host-policy: if: | @@ -233,19 +245,54 @@ jobs: - name: Test Stability Guards run: bash tools/ci/test_stability_guards.sh + console-web-tests: + if: needs.changes.outputs.docs_only == 'false' && needs.changes.outputs.console_web == 'true' + needs: changes + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.2.1 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: apps/aevatar-console-web/pnpm-lock.yaml + + - name: Install console-web dependencies + run: pnpm --dir apps/aevatar-console-web install --frozen-lockfile + + - name: Test console-web shard + run: pnpm --dir apps/aevatar-console-web test --runInBand --shard=${{ matrix.shard }}/4 + console-web: # Refactor (iter16/cluster-ci-systemic-refactor): # Old pattern: console-web skipped scheduled CI and ran for every PR, workflow_dispatch, main/dev push, or console_web changes # New principle: gate on changes.outputs.console_web per Phase 9 #716 r3 consensus A - if: needs.changes.outputs.docs_only == 'false' && needs.changes.outputs.console_web == 'true' - needs: changes + # Keep the existing required check name and fail it if any test shard fails + # or is cancelled. All tests run once across the four Jest partitions. + if: always() && needs.changes.outputs.docs_only == 'false' && needs.changes.outputs.console_web == 'true' + needs: [changes, console-web-tests] runs-on: ubuntu-latest - # The serial suite grew into this budget: recent runs took 19m02s, 19m35s, - # 19m57s, then 20m10s and 20m17s, the last two cancelled mid-test at the - # 20m cap. The ~18m of test work is real, so give it headroom rather than - # letting every run decide the build on runner jitter. - timeout-minutes: 35 + # Retain the base branch's budget for typecheck, build, and canvas benchmark. + timeout-minutes: 45 steps: + - name: Require all console-web test shards + env: + TEST_SHARDS_RESULT: ${{ needs.console-web-tests.result }} + run: test "$TEST_SHARDS_RESULT" = success + - uses: actions/checkout@v4 - name: Setup pnpm @@ -267,12 +314,23 @@ jobs: - name: Type-check console-web run: pnpm --dir apps/aevatar-console-web tsc - - name: Test console-web - run: pnpm --dir apps/aevatar-console-web test --runInBand - - name: Build console-web + env: + AEVATAR_WORKFLOW_CANVAS_BENCHMARK: ${{ hashFiles('apps/aevatar-console-web/playwright.performance.config.ts') != '' && '1' || '0' }} run: pnpm --dir apps/aevatar-console-web build + - name: Benchmark workflow canvas + if: hashFiles('apps/aevatar-console-web/playwright.performance.config.ts') != '' + run: pnpm --dir apps/aevatar-console-web benchmark:workflow-canvas + + - name: Upload workflow canvas benchmark + if: always() && hashFiles('apps/aevatar-console-web/playwright.performance.config.ts') != '' + uses: actions/upload-artifact@v4 + with: + name: workflow-canvas-benchmark + path: apps/aevatar-console-web/artifacts/workflow-canvas-benchmark + if-no-files-found: warn + slow-test-guards: needs: changes # Refactor (iter18/cluster-019-strict-coverage-aevatar-slnx): @@ -388,11 +446,17 @@ jobs: # Old pattern: coverage_quality_guard.sh 吞 test fail 继续 emit coverage;split-test-guards 漏跑 22 orphan project # New principle: strict-coverage fail-fast(不吞 fail);aevatar.slnx-or-slow-test 唯一测试权威; # retire slnf 测试执行(只保留 slnf build boundary);slow-tests 独立 CI job + # Frontend-only PRs do not need backend coverage, regardless of their target branch. + # Mixed changes and changes to CI/build infrastructure retain this gate. if: | github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) || - needs.changes.outputs.docs_only == 'false' + ( + github.event_name == 'pull_request' && + needs.changes.outputs.frontend_only != 'true' && + needs.changes.outputs.docs_only == 'false' + ) runs-on: ubuntu-latest # Full coverage takes 34-35 minutes after a roughly 10-minute restore/build, # then still needs time to upload the report. Keep the fail-fast guard while diff --git a/tools/ci/tests/test_ci_change_scope.py b/tools/ci/tests/test_ci_change_scope.py new file mode 100644 index 000000000..601a93a4b --- /dev/null +++ b/tools/ci/tests/test_ci_change_scope.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Exercise the checked-in path filters, routing shell, and coverage condition.""" + +import ast +import fnmatch +import os +from pathlib import Path +import re +import subprocess +import tempfile +import textwrap +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = (REPO_ROOT / ".github/workflows/ci.yml").read_text() + + +def block(source, header): + """Read an indented YAML block without requiring runner-side packages.""" + lines = source.splitlines() + positions = [i for i, line in enumerate(lines) if line.strip() == header] + if len(positions) != 1: + raise AssertionError(f"Expected one {header!r} block, got {len(positions)}") + start = positions[0] + indent = len(lines[start]) - len(lines[start].lstrip()) + end = start + 1 + while end < len(lines): + line = lines[end] + if line.strip() and len(line) - len(line.lstrip()) <= indent: + break + end += 1 + return textwrap.dedent("\n".join(lines[start + 1:end])) + + +CHANGES = block(WORKFLOW, "changes:") +FILTERS = block(CHANGES, "filters: |") +RESOLVE = block(CHANGES, "- name: Resolve Change Scope") +COVERAGE_CONDITION = block(block(WORKFLOW, "coverage-quality:"), "if: |") + + +def path_matches(path, pattern): + # The routing filters use positive globs only. A leading **/ also matches + # zero directories, as in paths-filter's picomatch implementation. + return fnmatch.fnmatchcase(path, pattern) or ( + pattern.startswith("**/") and fnmatch.fnmatchcase(path, pattern[3:]) + ) + + +def resolve_scope(paths): + env = dict(os.environ) + for name, filter_name in re.findall( + r"(\w+): \$\{\{ steps\.filter\.outputs\.(\w+)_count \}\}", + block(RESOLVE, "env:"), + ): + patterns = [ast.literal_eval(line.strip()[2:]) + for line in block(FILTERS, f"{filter_name}:").splitlines() + if line.strip()] + env[name] = str(sum(any(path_matches(path, p) for p in patterns) for path in paths)) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "output" + output.touch() + env["GITHUB_OUTPUT"] = str(output) + subprocess.run(["bash", "-eu"], input=block(RESOLVE, "run: |"), + text=True, env=env, check=True, capture_output=True) + return dict(line.split("=", 1) for line in output.read_text().splitlines()) + + +def coverage_runs(outputs, event="pull_request", base="dev", ref="refs/pull/1/merge"): + context = { + "github.event_name": event, "github.base_ref": base, "github.ref": ref, + **{f"needs.changes.outputs.{name}": value for name, value in outputs.items()}, + } + expression = re.sub(r"(?:github|needs)\.[\w.]+", + lambda match: repr(context[match[0]]), COVERAGE_CONDITION) + expression = " ".join(expression.replace("&&", "and").replace("||", "or").split()) + + def evaluate(node): + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.BoolOp): + values = [evaluate(value) for value in node.values] + return all(values) if isinstance(node.op, ast.And) else any(values) + if isinstance(node, ast.Compare) and len(node.ops) == 1: + left, right = evaluate(node.left), evaluate(node.comparators[0]) + if isinstance(node.ops[0], ast.Eq): + return left == right + if isinstance(node.ops[0], ast.NotEq): + return left != right + raise AssertionError(f"Unsupported coverage expression: {ast.dump(node)}") + + return evaluate(ast.parse(expression, mode="eval").body) + + +class ChangeScopeTests(unittest.TestCase): + def test_canvas_benchmark_requires_checked_in_capability(self): + condition = "hashFiles('apps/aevatar-console-web/playwright.performance.config.ts') != ''" + build = block(WORKFLOW, "- name: Build console-web") + benchmark = block(WORKFLOW, "- name: Benchmark workflow canvas") + upload = block(WORKFLOW, "- name: Upload workflow canvas benchmark") + self.assertIn(condition + " && '1' || '0'", build) + self.assertIn("if: " + condition, benchmark) + self.assertIn("if: always() && " + condition, upload) + + def test_job_outputs_are_connected_to_resolver(self): + step_id = re.search(r"^id: (\w+)$", RESOLVE, re.MULTILINE)[1] + outputs = block(CHANGES, "outputs:") + for name in ("docs_only", "frontend_only"): + self.assertIn(f"{name}: ${{{{ steps.{step_id}.outputs.{name} }}}}", outputs) + self.assertIn("needs: changes", block(WORKFLOW, "coverage-quality:")) + + def test_pr_routing_depends_on_changed_files_on_every_base(self): + frontend = ["apps/aevatar-console-web/src/app.tsx", + "apps/aevatar-console-web/.env.example", + "apps/aevatar-console-web/pnpm-lock.yaml"] + cases = [ + (frontend, True, False, False), + (["apps/aevatar-console-web/docs/home.md"], True, True, False), + (["docs/home.md"], False, True, False), + ([], False, False, True), + ] + for extra in ("src/Host.cs", "src/contracts.proto", "test/HostTests.cs", + ".github/workflows/ci.yml", "tools/ci/coverage_quality_guard.sh", + "Directory.Build.props", "Directory.Packages.props", + "global.json", "docs/home.md"): + cases.append((frontend + [extra], False, False, True)) + if extra != "docs/home.md": + cases.append(([extra], False, False, True)) + for base in ("dev", "main", "feat/2026-08-04_workflow-activity-vnext", "another-base"): + for paths, frontend_only, docs_only, should_run in cases: + with self.subTest(base=base, paths=paths): + outputs = resolve_scope(paths) + self.assertEqual(outputs["frontend_only"], str(frontend_only).lower()) + self.assertEqual(outputs["docs_only"], str(docs_only).lower()) + self.assertEqual(coverage_runs(outputs, base=base), should_run) + + def test_scheduled_manual_and_mainline_push_coverage_still_runs(self): + for paths in ([], ["docs/home.md"], ["apps/aevatar-console-web/src/app.tsx"]): + outputs = resolve_scope(paths) + for event, ref in (("schedule", "refs/heads/dev"), + ("workflow_dispatch", "refs/heads/feature"), + ("push", "refs/heads/main"), ("push", "refs/heads/dev")): + with self.subTest(paths=paths, event=event, ref=ref): + self.assertTrue(coverage_runs(outputs, event=event, ref=ref)) + + +if __name__ == "__main__": + unittest.main()