Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 76 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions tools/ci/tests/test_ci_change_scope.py
Original file line number Diff line number Diff line change
@@ -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()
Loading