diff --git a/.github/workflows/ci_windows.yml b/.github/workflows/ci_windows.yml index 3b81718a..663b67f2 100644 --- a/.github/workflows/ci_windows.yml +++ b/.github/workflows/ci_windows.yml @@ -6,25 +6,25 @@ on: workflow_dispatch: {} jobs: + # Native MSVC path (library, examples, Python, JNI, …). Excludes //wasm and + # //web so emsdk does not dominate this job's wall clock. build_and_test: runs-on: windows-latest timeout-minutes: 20 steps: - # Checkout the repository - name: Checkout repository uses: actions/checkout@v6 - # Install Bazelisk (recommended Bazel launcher) - name: Setup Bazelisk uses: ./.github/actions/setup-bazelisk - # Warm external deps (emsdk, LLVM, …); retry transient network fetch failures + # Warm external deps for the native graph; retry transient network flakes. - name: Prefetch external repos shell: pwsh run: | $max = 3 for ($i = 1; $i -le $max; $i++) { - bazelisk fetch //... + bazelisk fetch '--' '//...' '-//wasm/...' '-//web/...' if ($LASTEXITCODE -eq 0) { exit 0 } if ($i -lt $max) { Write-Host "Fetch failed (attempt $i/$max). Retrying in 20s..." @@ -33,7 +33,7 @@ jobs: } exit 1 - # Build all targets (retry: fetch flakes can still surface on first analysis). + # Build native targets (retry: fetch flakes can still surface on first analysis). # --config=opt: DDS_CPPOPTS no longer forces /O2 (that fought Bazel's /Od and # triggered MSVC D9025); opt mode is how Windows keeps release-level codegen. - name: Bazel build (retry on transient fetch failure) @@ -41,7 +41,7 @@ jobs: run: | $max = 3 for ($i = 1; $i -le $max; $i++) { - bazelisk build --config=opt --verbose_failures //... + bazelisk build --config=opt --verbose_failures '--' '//...' '-//wasm/...' '-//web/...' if ($LASTEXITCODE -eq 0) { exit 0 } if ($i -lt $max) { Write-Host "Build failed (attempt $i/$max). Retrying in 20s..." @@ -52,13 +52,12 @@ jobs: } exit 1 - # Run all tests (including Python) — no retry so real test failures surface quickly - - name: Run all tests - run: bazelisk test --config=opt --verbose_failures --test_output=errors //... + # Run native tests (including Python) — no retry so real failures surface quickly + - name: Run native tests + run: bazelisk test --config=opt --verbose_failures --test_output=errors '--' '//...' '-//wasm/...' '-//web/...' # .NET binding lives in CI – Windows .NET (ci_windows_dotnet.yml). - # Upload test logs - name: Upload test logs - Windows if: always() uses: actions/upload-artifact@v6 @@ -67,3 +66,55 @@ jobs: path: bazel-testlogs/ if-no-files-found: ignore retention-days: 30 + + # Windows-host emsdk / wasm transition coverage (parallel with native). + wasm_web: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bazelisk + uses: ./.github/actions/setup-bazelisk + + - name: Prefetch external repos + shell: pwsh + run: | + $max = 3 + for ($i = 1; $i -le $max; $i++) { + bazelisk fetch //wasm/... //web/... + if ($LASTEXITCODE -eq 0) { exit 0 } + if ($i -lt $max) { + Write-Host "Fetch failed (attempt $i/$max). Retrying in 20s..." + Start-Sleep -Seconds 20 + } + } + exit 1 + + - name: Bazel build (retry on transient fetch failure) + shell: pwsh + run: | + $max = 3 + for ($i = 1; $i -le $max; $i++) { + bazelisk build --config=opt --verbose_failures //wasm/... //web/... + if ($LASTEXITCODE -eq 0) { exit 0 } + if ($i -lt $max) { + Write-Host "Build failed (attempt $i/$max). Retrying in 20s..." + Start-Sleep -Seconds 20 + bazelisk shutdown || $true + } + } + exit 1 + + - name: Run wasm and web tests + run: bazelisk test --config=opt --verbose_failures --test_output=errors //wasm/... //web/... + + - name: Upload test logs - Windows wasm/web + if: always() + uses: actions/upload-artifact@v6 + with: + name: bazel-test-logs-windows-wasm-web + path: bazel-testlogs/ + if-no-files-found: ignore + retention-days: 30 diff --git a/python/tests/ci_windows_cppopts_test.py b/python/tests/ci_windows_cppopts_test.py index af2018b8..85c92201 100644 --- a/python/tests/ci_windows_cppopts_test.py +++ b/python/tests/ci_windows_cppopts_test.py @@ -358,35 +358,274 @@ def test_matches_when_config_opt_precedes_trailing_comment(self) -> None: ) -class TestWindowsCiUsesOpt(unittest.TestCase): - def test_windows_ci_passes_config_opt(self) -> None: - """Without /O2 in DDS_CPPOPTS, CI must opt in via --config=opt.""" - text = ( - _repo_root() / ".github" / "workflows" / "ci_windows.yml" - ).read_text(encoding="utf-8") +def _windows_ci_workflow_text() -> str: + return ( + _repo_root() / ".github" / "workflows" / "ci_windows.yml" + ).read_text(encoding="utf-8") + + +def _workflow_job_bodies(text: str) -> dict[str, str]: + """Map top-level GitHub Actions job ids under `jobs:` to their bodies.""" + jobs: dict[str, list[str]] = {} + current: str | None = None + in_jobs = False + for line in text.splitlines(): + if re.match(r"^jobs:\s*$", line): + in_jobs = True + current = None + continue + if not in_jobs: + continue + job_header = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if job_header: + current = job_header.group(1) + jobs[current] = [] + continue + if current is None: + continue + # A non-indented key ends `jobs:` so later top-level mappings (e.g. + # `permissions:`) are not parsed as job ids or appended to the last job. + if line.strip() and not line.startswith(" "): + in_jobs = False + current = None + continue + jobs[current].append(line) + return {name: "\n".join(body) for name, body in jobs.items()} + + +def _active_bazelisk_lines(text: str, subcommand: str) -> list[str]: + """Return non-comment lines that invoke bazelisk .""" + lines: list[str] = [] + for line in text.splitlines(): + code = line.split("#", 1)[0] + if re.search(rf"bazelisk\s+{re.escape(subcommand)}\b", code): + lines.append(code) + return lines + + +def _excludes_package_pattern(line: str, package: str) -> bool: + """True if a Bazel target-pattern list excludes ///... + + The exclusion must be quoted (`'-//pkg/...'` or `\"-//pkg/...\"`). On + Windows CI the default shell is pwsh, which treats a bare `-//...` token as + a PowerShell switch, so unquoted exclusions fail with \"Invalid options + syntax\". + """ + return bool( + re.search( + rf"""(? bool: + """True if Bazel `--` is quoted so pwsh does not swallow it. + + PowerShell 7 treats a bare `--` as its own end-of-parameters token and does + not forward it to bazelisk. Bazel then sees `-//wasm/...` as a flag. + """ + return bool(re.search(r"""['"]--['"]""", line)) + + +def _includes_all_packages_pattern(line: str) -> bool: + """True if the line includes a (optionally quoted) //... target pattern.""" + return bool(re.search(r"""(? None: + sample = """ +name: sample +jobs: + native: + runs-on: windows-latest + steps: + - run: echo native + wasm_web: + runs-on: windows-latest + steps: + - run: echo wasm +""" + bodies = _workflow_job_bodies(sample) + self.assertEqual(set(bodies), {"native", "wasm_web"}) + self.assertIn("echo native", bodies["native"]) + self.assertIn("echo wasm", bodies["wasm_web"]) + self.assertNotIn("echo wasm", bodies["native"]) + + def test_stops_at_next_top_level_key(self) -> None: + sample = """ +jobs: + native: + runs-on: windows-latest +permissions: + contents: read +""" + bodies = _workflow_job_bodies(sample) + self.assertEqual(set(bodies), {"native"}) + self.assertIn("runs-on: windows-latest", bodies["native"]) + self.assertNotIn("contents: read", bodies["native"]) + + +class TestPwshQuotedExclusionPatterns(unittest.TestCase): + def test_requires_quotes_around_negative_patterns(self) -> None: + self.assertFalse( + _excludes_package_pattern( + "bazelisk fetch -- //... -//wasm/... -//web/...", + "wasm", + ), + "bare -//wasm/... is unsafe under pwsh", + ) + self.assertTrue( + _excludes_package_pattern( + "bazelisk fetch -- '//...' '-//wasm/...' '-//web/...'", + "wasm", + ) + ) self.assertTrue( - _bazelisk_invocation_has_config_opt(text, "build"), - "expected Windows CI build to use --config=opt", + _excludes_package_pattern( + 'bazelisk build -- "//..." "-//web/..."', + "web", + ) ) + + def test_includes_all_packages_accepts_quoted_or_bare(self) -> None: + self.assertTrue(_includes_all_packages_pattern("bazelisk fetch -- //...")) self.assertTrue( - _bazelisk_invocation_has_config_opt(text, "test"), - "expected Windows CI test to use --config=opt", + _includes_all_packages_pattern("bazelisk fetch -- '//...' '-//wasm/...'") ) + def test_requires_quoted_end_of_options_marker(self) -> None: + self.assertFalse( + _has_quoted_end_of_options_marker( + "bazelisk fetch -- '//...' '-//wasm/...' '-//web/...'" + ), + "bare -- is swallowed by pwsh and never reaches bazelisk", + ) + self.assertTrue( + _has_quoted_end_of_options_marker( + "bazelisk fetch '--' '//...' '-//wasm/...' '-//web/...'" + ) + ) + self.assertTrue( + _has_quoted_end_of_options_marker( + 'bazelisk fetch "--" "//..." "-//wasm/..."' + ) + ) + + +class TestWindowsCiUsesOpt(unittest.TestCase): + def test_windows_ci_passes_config_opt(self) -> None: + """Without /O2 in DDS_CPPOPTS, CI must opt in via --config=opt.""" + text = _windows_ci_workflow_text() + for subcommand in ("build", "test"): + lines = _active_bazelisk_lines(text, subcommand) + self.assertTrue(lines, f"expected at least one bazelisk {subcommand}") + for line in lines: + self.assertRegex( + line, + r"--config=opt\b", + f"expected every Windows CI {subcommand} to use --config=opt: {line.strip()}", + ) + def test_windows_ci_test_prints_failing_output(self) -> None: """Python test.log is not in the Windows bazel-testlogs artifact.""" - text = ( - _repo_root() / ".github" / "workflows" / "ci_windows.yml" - ).read_text(encoding="utf-8") - test_lines = [ - line.split("#", 1)[0] - for line in text.splitlines() - if re.search(r"bazelisk\s+test\b", line.split("#", 1)[0]) - ] - self.assertTrue( - any("--test_output=errors" in line for line in test_lines), - "expected Windows CI test to use --test_output=errors so " - "failing unittest output appears in the job log", + text = _windows_ci_workflow_text() + test_lines = _active_bazelisk_lines(text, "test") + self.assertTrue(test_lines, "expected at least one bazelisk test") + for line in test_lines: + self.assertIn( + "--test_output=errors", + line, + "expected every Windows CI test to use --test_output=errors so " + f"failing unittest output appears in the job log: {line.strip()}", + ) + + +class TestWindowsCiSplitsWasmWeb(unittest.TestCase): + """Keep emsdk/wasm work off the native Windows critical path.""" + + def test_has_parallel_native_and_wasm_web_jobs(self) -> None: + bodies = _workflow_job_bodies(_windows_ci_workflow_text()) + self.assertIn( + "build_and_test", + bodies, + "expected a native Windows job named build_and_test", + ) + self.assertIn( + "wasm_web", + bodies, + "expected a parallel Windows job named wasm_web for //wasm and //web", + ) + + def test_native_job_excludes_wasm_and_web_patterns(self) -> None: + native = _workflow_job_bodies(_windows_ci_workflow_text())["build_and_test"] + for subcommand in ("fetch", "build", "test"): + lines = _active_bazelisk_lines(native, subcommand) + self.assertTrue( + lines, + f"native job must invoke bazelisk {subcommand}", + ) + for line in lines: + self.assertTrue( + _includes_all_packages_pattern(line), + f"native {subcommand} should still cover //... : {line.strip()}", + ) + self.assertTrue( + _has_quoted_end_of_options_marker(line), + f"native {subcommand} must quote '--' so pwsh forwards " + f"it to bazelisk: {line.strip()}", + ) + for package in ("wasm", "web"): + self.assertTrue( + _excludes_package_pattern(line, package), + f"native {subcommand} must quote '-//{package}/...' " + f"for pwsh: {line.strip()}", + ) + + def test_wasm_web_job_targets_only_wasm_and_web(self) -> None: + wasm_web = _workflow_job_bodies(_windows_ci_workflow_text())["wasm_web"] + for subcommand in ("fetch", "build", "test"): + lines = _active_bazelisk_lines(wasm_web, subcommand) + self.assertTrue( + lines, + f"wasm_web job must invoke bazelisk {subcommand}", + ) + for line in lines: + self.assertRegex( + line, + r"(? None: + text = _windows_ci_workflow_text() + bodies = _workflow_job_bodies(text) + native_artifact = re.search( + r"(?m)^\s+name:\s*(\S*bazel-test-logs\S*)\s*$", + bodies["build_and_test"], + ) + wasm_artifact = re.search( + r"(?m)^\s+name:\s*(\S*bazel-test-logs\S*)\s*$", + bodies["wasm_web"], + ) + self.assertIsNotNone(native_artifact, "native job must upload test logs") + self.assertIsNotNone(wasm_artifact, "wasm_web job must upload test logs") + assert native_artifact is not None and wasm_artifact is not None + self.assertNotEqual( + native_artifact.group(1), + wasm_artifact.group(1), + "parallel Windows jobs need distinct artifact names", )